Compare commits

...
48 Commits
Author SHA1 Message Date
fatedierandGitHub 954f918d0c docs: clear release notes (#5570) 2026-10-15 23:25:45 +08:00
fatedierandGitHub 031716afd1 Merge pull request #5569 from fatedier/fix/docker-workspace-lockfile
fix(docker): install web dependencies from the workspace lockfile
2026-10-15 13:01:59 +08:00
fatedierandGitHub 41ed2aecf8 test: align UDP compatibility protocol boundaries (#5567) 2026-10-14 10:49:55 +08:00
fatedierandGitHub 12b0093ccf chore: prepare v0.71.1 release notes (#5564) 2026-10-13 10:46:29 +08:00
fatedierandGitHub 2542d11a62 fix(net): coordinate websocket handoff with listener shutdown (#5563) 2026-10-10 19:58:05 +08:00
Abdellatif AnaflousandGitHub c4d2db4177 fix(websocket): release a pending Accept when the listener is closed (#5552)
the websocket listener kept its accept channel open on Close, so an Accept call that was already waiting blocked forever

every other listener in pkg/util/net closes the channel and returns an error, but this one never did, so the HandleListener goroutine stayed stuck after the server closed

i close the channel on Close, guard the hand off send so a late connection does not panic, and return ErrWebsocketListenerClosed which was declared but never reached

Signed-off-by: Abdel <62770500+hktitof@users.noreply.github.com>
2026-10-10 18:19:59 +08:00
Mxmilu666andGitHub 71b31978b3 fix(server): close underlying work conn when server bandwidth limiter is enabled (#5553)
When bandwidthLimitMode is "server", the work connection is wrapped by
libio.WrapReadWriteCloser with a close callback that captured the
variable being reassigned (local/rwc). At call time the callback
referenced the wrapper itself, whose closed guard was already set, so
Close returned nil without closing the underlying work connection.

For TCP proxies this left libio.Join blocked on the other copy
goroutine after the user disconnected, keeping the work connection and
the backend connection open. HTTP and UDP proxies had the same pattern.

Bind the Close method value before reassignment, and add an e2e test
verifying that the local connection is closed after the user
disconnects.
2026-10-10 10:45:34 +08:00
fatedierandGitHub 1cc2dd8a23 fix(vhost): avoid request body access after CONNECT hijack (#5562) 2026-10-09 23:53:05 +08:00
dhairyaandGitHub 9c7d9898b3 fix(vhost): preserve buffered HTTP CONNECT tunnel data (#5556)
* fix(vhost): preserve buffered HTTP CONNECT tunnel data

* test(vhost): cover pipelined CONNECT data and bidirectional tunnel traffic
2026-10-09 23:07:48 +08:00
dhairyaandGitHub ab0959868a fix: rotate daily counters by calendar date across DST (#5555)
* Fix daily counter rotation across daylight-saving changes

* Test daily counter rotation across DST and skipped midnight
2026-10-09 20:13:03 +08:00
fatedierandGitHub 1dbd44edd3 fix(msg): interrupt control reads during dispatcher shutdown (#5561) 2026-10-09 17:46:31 +08:00
CyoidandGitHub fccc7b0e9f fix(msg): close control connection on write failure (#5550)
* fix(msg): close control connection on write failure

* fix(msg): handle shutdown enqueue race and preserve constructor API
2026-10-09 16:17:08 +08:00
fatedierandGitHub 55107d003d fix: synchronize service startup and shutdown (#5560) 2026-10-09 12:27:12 +08:00
fatedierandGitHub 4cd4747525 fix: use Go 1.26 new expressions for pointer initialization (#5559) 2026-10-08 22:28:31 +08:00
f8be1cdfe2 deps: bump golang.org/x/crypto to v0.56.0 and build with Go 1.26 (#5558)
x/crypto v0.56.0 fixes two denial-of-service bugs in golang.org/x/crypto/ssh
channel handling (GO-2026-6354 / CVE-2026-78662, GO-2026-6355 /
CVE-2026-56855), reachable through the frps SSH tunnel gateway in pkg/ssh.
v0.55.0 fixes GO-2026-6303 / CVE-2026-56854. v0.56.0 requires Go 1.26, so the
go directive, CI and Docker builders move from 1.25 to 1.26; Go 1.25 stopped
receiving security fixes when Go 1.27 was released.

Also bumps github.com/Azure/go-ntlmssp to v0.1.1 (GO-2026-5543 /
CVE-2026-32952).


Claude-Session: https://claude.ai/code/session_01FV4GkYDtbatepp2KH3s247

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 22:06:35 +08:00
SebastianandGitHub d5636108e6 [fix] race condition when trying to reconnect (#5547) 2026-10-08 21:22:43 +08:00
fatedierandGitHub d20a232996 test: strengthen health check coverage (#5528) 2026-09-10 01:28:26 +08:00
fatedierandGitHub b0d840858f ci: upgrade CircleCI config to 2.1 (#5530) 2026-09-10 00:17:12 +08:00
fatedierandGitHub c5508627c4 docs: add rapidproxy as gold sponsor (#5529) 2026-09-10 00:02:51 +08:00
Rui JingAnandGitHub 832df8dff6 fix(health): reset failed count after successful check (#5502)
Signed-off-by: racequite <quiterace@gmail.com>
2026-08-29 22:55:32 +08:00
fatedierandGitHub ebb4773c2c docs: update release notes (#5521) 2026-08-29 14:41:43 +08:00
fatedierandGitHub 23ec9b4979 vnet: serialize VirtualNet route lifecycle (#5512)
* vnet: serialize virtual net route lifecycle

* test: remove fixed virtual net helper parameters
2026-08-28 10:56:05 +08:00
flyingandGitHub 40bb45d192 vnet: fix route cleanup and reconnect backoff (#5492) 2026-08-26 23:33:45 +08:00
fatedierandGitHub 758f07d59e deps: migrate to fatedier/yamux v0.2.0 (#5498) 2026-08-18 00:24:11 +08:00
fatedierandGitHub 25a4ba306a ci: pin golangci-lint to v2.12.2 (#5496) 2026-08-17 00:48:09 +08:00
fatedierandGitHub 6c8a8d0a97 web: patch vulnerable transitive dependencies (#5490) 2026-08-13 01:12:34 +08:00
fatedierandGitHub da04e1e07a log: improve prefix handling (#5489) 2026-08-12 23:56:00 +08:00
fatedierandGitHub 71a2bf30f9 docs: add release note for domain validation fix (#5475) 2026-08-09 23:04:09 +08:00
fatedierandGitHub a6a782bed4 config: reject case-insensitive subdomain domains (#5474) 2026-08-09 22:52:30 +08:00
fatedierandGitHub 223b44336c ssh: serialize tunnel channel writes (#5473) 2026-08-09 19:12:12 +08:00
fatedierandGitHub f6688e2a0d client: reject invalid work connection addresses (#5472) 2026-08-09 18:39:21 +08:00
fatedierandGitHub f666d97b64 limit: clamp bandwidth limiter burst (#5471) 2026-08-09 17:30:39 +08:00
fatedierandGitHub 5b68148f11 udp: handle closed forwarding channel (#5470) 2026-08-09 15:31:11 +08:00
Yarden ShohamandGitHub d1928f9689 remove retired Go Report Card badge (#5467) 2026-08-05 10:52:16 +08:00
fatedierandGitHub 1a3a872bd2 fix(frpc): respect feature gates in verify (#5465) 2026-08-04 12:53:10 +08:00
fatedierandGitHub 290017cb53 update version to 0.71.0 (#5464) 2026-08-03 23:37:36 +08:00
fatedierandGitHub 2291e8835f feat: use binary codec for SUDP packets (#5461) 2026-07-31 16:53:16 +08:00
fatedierandGitHub 1ab59e763c server: validate control pool counts (#5459) 2026-07-30 23:30:16 +08:00
fatedierandGitHub 9d45a55720 docs: add release note for #5456 (#5458) 2026-07-30 14:40:54 +08:00
fatedierandGitHub effa496859 feat: add negotiated binary UDP packets (#5456) 2026-07-30 13:19:55 +08:00
fatedierandGitHub 5c6d761c12 chore(web): upgrade frontend dependencies (#5454) 2026-07-28 19:36:44 +08:00
fatedierandGitHub 3d89ab7ff1 build(web): upgrade auto import plugins (#5453) 2026-07-28 17:09:46 +08:00
fatedierandGitHub f3828cbfdb build(web): upgrade ESLint to v10 (#5452) 2026-07-28 16:10:29 +08:00
fatedierandGitHub 00c24e39bb test(web): add frontend unit test baseline (#5451) 2026-07-28 01:27:35 +08:00
fatedierandGitHub 2175557d48 web: fix Vite config tsconfig includes (#5450) 2026-07-27 19:21:28 +08:00
fatedierandGitHub de4b483710 web: patch vulnerable transitive dependencies (#5449) 2026-07-27 14:57:58 +08:00
fatedierandGitHub 2d63f6b1f9 web/frpc: fix visitor plugin validation for sudp (#5448) 2026-07-27 12:04:00 +08:00
barkureandGitHub 40274d8c92 web/frpc: support virtual net visitor plugin (#5414) 2026-07-23 22:02:49 +08:00
127 changed files with 9484 additions and 1518 deletions
+13 -11
View File
@@ -1,24 +1,26 @@
version: 2
version: 2.1
jobs:
go-version-latest:
docker:
- image: cimg/go:1.25-node
- image: cimg/go:1.26-node
resource_class: large
steps:
- checkout
- run:
name: Build web assets (frps)
command: make install build
working_directory: web/frps
name: Test and build web assets
command: make web-ci
- run:
name: Build web assets (frpc)
command: make install build
working_directory: web/frpc
- run: make
- run: make alltest
name: Check Go formatting and build binaries
command: |
set -e
make env fmt
git diff --exit-code
make build
- run:
name: Run all tests
command: make alltest
workflows:
version: 2
build_and_test:
jobs:
- go-version-latest
+4 -8
View File
@@ -17,19 +17,15 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.25'
go-version: '1.26'
cache: false
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Build web assets (frps)
run: make build
working-directory: web/frps
- name: Build web assets (frpc)
run: make build
working-directory: web/frpc
- name: Test and build web assets
run: make web-ci
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
version: v2.11
version: v2.12.2
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.25'
go-version: '1.26'
- uses: actions/setup-node@v6
with:
node-version: '22'
+4 -1
View File
@@ -5,7 +5,7 @@ NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',n
FRP_COMPAT_BASELINE_COUNT ?= 8
FRP_COMPAT_FLOOR_VERSION ?= 0.61.0
.PHONY: web frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor
.PHONY: web web-ci frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor
all: env fmt web build
@@ -16,6 +16,9 @@ env:
web: frps-web frpc-web
web-ci:
cd web && npm ci && npm run lint:check --workspace frps && npm run lint:check --workspace frpc && npm run test:unit && npm run build --workspace frps && npm run build --workspace frpc
frps-web:
$(MAKE) -C web/frps build
+1
View File
@@ -39,6 +39,7 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<!--gold sponsors end-->
## What is frp?
+1 -1
View File
@@ -2,7 +2,6 @@
[![Build Status](https://circleci.com/gh/fatedier/frp.svg?style=shield)](https://circleci.com/gh/fatedier/frp)
[![GitHub release](https://img.shields.io/github/tag/fatedier/frp.svg?label=release)](https://github.com/fatedier/frp/releases)
[![Go Report Card](https://goreportcard.com/badge/github.com/fatedier/frp)](https://goreportcard.com/report/github.com/fatedier/frp)
[![GitHub Releases Stats](https://img.shields.io/github/downloads/fatedier/frp/total.svg?logo=github)](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
[README](README.md) | [中文文档](README_zh.md)
@@ -42,6 +41,7 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<!--gold sponsors end-->
## 为什么使用 frp ?
+1 -5
View File
@@ -1,5 +1 @@
## Fixes
* HTTP vhost servers no longer support HTTP/1.1 `Upgrade: h2c` requests. Cleartext HTTP/2 prior-knowledge remains supported.
* Fixed control-session replacement leaks when frpc reconnects through a half-open TCP multiplexed connection.
* Fixed an SSH tunnel gateway panic when handling malformed exec requests.
## Features
+254
View File
@@ -2,12 +2,16 @@ package client
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/fatedier/frp/client/configmgmt"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/policy/security"
"github.com/fatedier/frp/pkg/vnet"
)
func newTestRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
@@ -22,6 +26,256 @@ func newTestRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
}
}
func newTestVirtualNetProxyConfig(name string) *v1.STCPProxyConfig {
return &v1.STCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
Name: name,
Type: "stcp",
ProxyBackend: v1.ProxyBackend{
Plugin: v1.TypedClientPluginOptions{
Type: v1.PluginVirtualNet,
ClientPluginOptions: &v1.VirtualNetPluginOptions{Type: v1.PluginVirtualNet},
},
},
},
}
}
func newTestVirtualNetVisitorConfig(name string) *v1.STCPVisitorConfig {
return &v1.STCPVisitorConfig{
VisitorBaseConfig: v1.VisitorBaseConfig{
Name: name,
Type: "stcp",
ServerName: "vnet-server",
SecretKey: "secret",
BindPort: -1,
Plugin: v1.TypedVisitorPluginOptions{
Type: v1.VisitorPluginVirtualNet,
VisitorPluginOptions: &v1.VirtualNetVisitorPluginOptions{
Type: v1.VisitorPluginVirtualNet,
DestinationIP: "100.86.0.1",
},
},
},
}
}
func TestServiceConfigManagerReloadVirtualNetRuntimeDependency(t *testing.T) {
const runtimeErr = "VirtualNet-dependent configuration requires a VirtualNet runtime enabled at startup"
tests := []struct {
name string
startupVirtualNetAddr string
nextConfig string
wantRuntimeDependency bool
}{
{
name: "unrelated common config",
nextConfig: `serverAddr = "0.0.0.0"`,
},
{
name: "VirtualNet address without startup runtime",
nextConfig: `featureGates = { VirtualNet = true }
virtualNet.address = "100.86.0.4/24"
`,
wantRuntimeDependency: true,
},
{
name: "VirtualNet proxy without startup runtime",
nextConfig: `[[proxies]]
name = "vnet-proxy"
type = "stcp"
secretKey = "secret"
[proxies.plugin]
type = "virtual_net"
`,
wantRuntimeDependency: true,
},
{
name: "VirtualNet visitor without startup runtime",
nextConfig: `[[visitors]]
name = "vnet-visitor"
type = "stcp"
serverName = "vnet-server"
secretKey = "secret"
bindPort = -1
[visitors.plugin]
type = "virtual_net"
destinationIP = "100.86.0.1"
`,
wantRuntimeDependency: true,
},
{
name: "existing VirtualNet startup runtime",
startupVirtualNetAddr: "100.86.0.4/24",
nextConfig: `featureGates = { VirtualNet = true }
virtualNet.address = "100.86.0.5/24"
`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
current := &v1.ClientCommonConfig{}
if tc.startupVirtualNetAddr != "" {
current.FeatureGates = map[string]bool{"VirtualNet": true}
current.VirtualNet.Address = tc.startupVirtualNetAddr
}
if err := current.Complete(); err != nil {
t.Fatalf("complete current config: %v", err)
}
configFile := filepath.Join(t.TempDir(), "frpc.toml")
if err := os.WriteFile(configFile, []byte(tc.nextConfig), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
configSource := source.NewConfigSource()
aggregator := source.NewAggregator(configSource)
svr := &Service{
common: current,
reloadCommon: current,
configFilePath: configFile,
unsafeFeatures: security.NewUnsafeFeatures(nil),
aggregator: aggregator,
configSource: configSource,
}
if tc.startupVirtualNetAddr != "" {
svr.vnetController = vnet.NewController(current.VirtualNet)
}
err := (&serviceConfigManager{svr: svr}).ReloadFromFile(true)
if tc.wantRuntimeDependency {
if !errors.Is(err, configmgmt.ErrApplyConfig) || !strings.Contains(err.Error(), runtimeErr) {
t.Fatalf("expected VirtualNet runtime dependency error, got: %v", err)
}
return
}
if err != nil {
t.Fatalf("reload config: %v", err)
}
if svr.common != current {
t.Fatal("reload should not replace startup common config")
}
if tc.startupVirtualNetAddr == "" && svr.vnetController != nil {
t.Fatal("reload should not enable startup-only VirtualNet runtime state")
}
})
}
}
func TestServiceConfigManagerReloadVirtualNetRuntimeDependencyUsesMergedSources(t *testing.T) {
const runtimeErr = "VirtualNet-dependent configuration requires a VirtualNet runtime enabled at startup"
tests := []struct {
name string
nextConfig string
storeProxy v1.ProxyConfigurer
storeVisitor v1.VisitorConfigurer
wantRuntimeDependency bool
wantProxyPlugin string
}{
{
name: "Store VirtualNet proxy is rejected",
nextConfig: `serverAddr = "0.0.0.0"`,
storeProxy: newTestVirtualNetProxyConfig("store-vnet"),
wantRuntimeDependency: true,
},
{
name: "Store VirtualNet visitor is rejected",
nextConfig: `serverAddr = "0.0.0.0"`,
storeVisitor: newTestVirtualNetVisitorConfig("store-vnet"),
wantRuntimeDependency: true,
},
{
name: "Store VirtualNet proxy overrides file proxy",
nextConfig: `[[proxies]]
name = "shared"
type = "tcp"
localPort = 10080
remotePort = 10081
`,
storeProxy: newTestVirtualNetProxyConfig("shared"),
wantRuntimeDependency: true,
},
{
name: "Store non-VirtualNet proxy overrides file VirtualNet proxy",
nextConfig: `[[proxies]]
name = "shared"
type = "stcp"
secretKey = "secret"
[proxies.plugin]
type = "virtual_net"
`,
storeProxy: newTestRawTCPProxyConfig("shared"),
wantProxyPlugin: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
current := &v1.ClientCommonConfig{}
if err := current.Complete(); err != nil {
t.Fatalf("complete current config: %v", err)
}
configFile := filepath.Join(t.TempDir(), "frpc.toml")
if err := os.WriteFile(configFile, []byte(tc.nextConfig), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
Path: filepath.Join(t.TempDir(), "store.json"),
})
if err != nil {
t.Fatalf("new store source: %v", err)
}
if tc.storeProxy != nil {
if err := storeSource.AddProxy(tc.storeProxy); err != nil {
t.Fatalf("add store proxy: %v", err)
}
}
if tc.storeVisitor != nil {
if err := storeSource.AddVisitor(tc.storeVisitor); err != nil {
t.Fatalf("add store visitor: %v", err)
}
}
configSource := source.NewConfigSource()
aggregator := source.NewAggregator(configSource)
aggregator.SetStoreSource(storeSource)
svr := &Service{
common: current,
reloadCommon: current,
configFilePath: configFile,
unsafeFeatures: security.NewUnsafeFeatures(nil),
aggregator: aggregator,
configSource: configSource,
storeSource: storeSource,
}
err = (&serviceConfigManager{svr: svr}).ReloadFromFile(true)
if tc.wantRuntimeDependency {
if !errors.Is(err, configmgmt.ErrApplyConfig) || !strings.Contains(err.Error(), runtimeErr) {
t.Fatalf("expected VirtualNet runtime dependency error, got: %v", err)
}
return
}
if err != nil {
t.Fatalf("reload config: %v", err)
}
if len(svr.proxyCfgs) != 1 {
t.Fatalf("expected one applied proxy, got %d", len(svr.proxyCfgs))
}
if got := svr.proxyCfgs[0].GetBaseConfig().Plugin.Type; got != tc.wantProxyPlugin {
t.Fatalf("unexpected applied proxy plugin: %q", got)
}
})
}
}
func TestServiceConfigManagerCreateStoreProxyConflict(t *testing.T) {
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
Path: filepath.Join(t.TempDir(), "store.json"),
+1 -1
View File
@@ -24,7 +24,7 @@ import (
"time"
libnet "github.com/fatedier/golib/net"
fmux "github.com/hashicorp/yamux"
fmux "github.com/fatedier/yamux"
quic "github.com/quic-go/quic-go"
"github.com/samber/lo"
+11 -2
View File
@@ -47,6 +47,8 @@ type SessionContext struct {
Connector MessageConnector
// Virtual net controller
VnetController *vnet.Controller
// UDPPacketCodec is immutable for the lifetime of this negotiated session.
UDPPacketCodec string
}
type Control struct {
@@ -92,9 +94,16 @@ 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, sessionCtx.Auth.EncryptionKey(), ctl.msgTransporter, sessionCtx.VnetController)
ctl.pm = proxy.NewManager(
ctl.ctx,
sessionCtx.Common,
sessionCtx.Auth.EncryptionKey(),
ctl.msgTransporter,
sessionCtx.VnetController,
sessionCtx.UDPPacketCodec,
)
ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common,
ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController)
ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController, sessionCtx.UDPPacketCodec)
return ctl, nil
}
+9 -4
View File
@@ -99,6 +99,7 @@ func (d *controlSessionDialer) Dial(previousRunID string) (*SessionContext, erro
Auth: d.auth,
Connector: newMessageConnector(connector, d.common.Transport.WireProtocol),
VnetController: d.vnetController,
UDPPacketCodec: loginResult.udpPacketCodec,
}, nil
}
@@ -127,8 +128,9 @@ func (d *controlSessionDialer) buildLoginMsg(previousRunID string) (*msg.Login,
}
type loginExchangeResult struct {
resp *msg.LoginResp
crypto *wire.CryptoContext
resp *msg.LoginResp
crypto *wire.CryptoContext
udpPacketCodec string
}
func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login) (*loginExchangeResult, error) {
@@ -172,6 +174,7 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
}()
var cryptoContext *wire.CryptoContext
var udpPacketCodec string
if wireConn != nil {
serverHelloFrame, err := wireConn.ReadFrame()
if err != nil {
@@ -191,6 +194,7 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
if err != nil {
return nil, err
}
udpPacketCodec = serverHello.Selected.Message.UDPPacketCodec
}
var loginRespMsg msg.LoginResp
@@ -198,8 +202,9 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
return nil, err
}
return &loginExchangeResult{
resp: &loginRespMsg,
crypto: cryptoContext,
resp: &loginRespMsg,
crypto: cryptoContext,
udpPacketCodec: udpPacketCodec,
}, nil
}
+2
View File
@@ -117,6 +117,7 @@ func TestControlSessionDialerDialV1(t *testing.T) {
defer sessionCtx.Connector.Close()
require.Equal(t, "run-v1", sessionCtx.RunID)
require.Empty(t, sessionCtx.UDPPacketCodec)
require.NotNil(t, sessionCtx.Conn)
require.NotNil(t, sessionCtx.Connector)
require.False(t, connector.closed.Load())
@@ -225,6 +226,7 @@ func TestControlSessionDialerDialV2(t *testing.T) {
defer sessionCtx.Connector.Close()
require.Equal(t, "run-v2", sessionCtx.RunID)
require.Equal(t, wire.UDPPacketCodecBinary, sessionCtx.UDPPacketCodec)
require.NotNil(t, sessionCtx.Conn)
require.NotNil(t, sessionCtx.Connector)
require.False(t, connector.closed.Load())
+125
View File
@@ -0,0 +1,125 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !frps
package client
import (
"context"
"encoding/binary"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
clientproxy "github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/pkg/auth"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestControlPropagatesBinaryUDPPacketCodecToWorkConn(t *testing.T) {
echoConn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
require.NoError(t, err)
t.Cleanup(func() { _ = echoConn.Close() })
echoDone := make(chan error, 1)
go func() {
buf := make([]byte, 64)
n, addr, err := echoConn.ReadFromUDP(buf)
if err == nil {
_, err = echoConn.WriteToUDP(buf[:n], addr)
}
echoDone <- err
}()
authRuntime, err := auth.BuildClientAuth(&v1.AuthClientConfig{
Method: v1.AuthMethodToken,
Token: "token",
})
require.NoError(t, err)
controlConn, controlPeer := net.Pipe()
t.Cleanup(func() {
_ = controlConn.Close()
_ = controlPeer.Close()
})
common := &v1.ClientCommonConfig{
Transport: v1.ClientTransportConfig{WireProtocol: wire.ProtocolV2},
UDPPacketSize: 1500,
}
ctl, err := NewControl(context.Background(), &SessionContext{
Common: common,
RunID: "binary-udp-test",
Conn: msg.NewConn(controlConn, msg.NewV2ReadWriter(controlConn)),
Auth: authRuntime,
UDPPacketCodec: wire.UDPPacketCodecBinary,
})
require.NoError(t, err)
t.Cleanup(ctl.pm.Close)
echoAddr := echoConn.LocalAddr().(*net.UDPAddr)
proxyCfg := &v1.UDPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
Name: "udp",
Type: string(v1.ProxyTypeUDP),
ProxyBackend: v1.ProxyBackend{
LocalIP: "127.0.0.1",
LocalPort: echoAddr.Port,
},
},
}
ctl.pm.UpdateAll([]v1.ProxyConfigurer{proxyCfg})
require.Eventually(t, func() bool {
status, ok := ctl.pm.GetProxyStatus("udp")
return ok && status.Phase == clientproxy.ProxyPhaseWaitStart
}, time.Second, 10*time.Millisecond)
require.NoError(t, ctl.pm.StartProxy("udp", "", ""))
workClient, workServer := net.Pipe()
t.Cleanup(func() {
_ = workClient.Close()
_ = workServer.Close()
})
deadline := time.Now().Add(3 * time.Second)
require.NoError(t, workClient.SetDeadline(deadline))
require.NoError(t, workServer.SetDeadline(deadline))
ctl.pm.HandleWorkConn("udp", workClient, &msg.StartWorkConn{ProxyName: "udp"})
serverRW, err := msg.NewUDPPacketReadWriter(workServer, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
writeDone := make(chan error, 1)
in := &msg.UDPPacket{
Content: []byte("binary udp"),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345},
}
go func() {
writeDone <- serverRW.WriteMsg(in)
}()
frame, err := wire.NewConn(workServer).ReadFrame()
require.NoError(t, err)
require.Equal(t, wire.FrameTypeMessage, frame.Type)
require.GreaterOrEqual(t, len(frame.Payload), 2)
require.Equal(t, msg.V2TypeUDPPacketBinary, binary.BigEndian.Uint16(frame.Payload[:2]))
out, err := msg.DecodeUDPPacketBinary(frame.Payload[2:])
require.NoError(t, err)
require.Equal(t, in.Content, out.Content)
require.Equal(t, in.RemoteAddr.String(), out.RemoteAddr.String())
require.NoError(t, <-writeDone)
require.NoError(t, <-echoDone)
}
+59 -22
View File
@@ -30,6 +30,11 @@ import (
var ErrHealthCheckType = errors.New("error health check type")
func newHealthTimer(interval time.Duration) (<-chan time.Time, func()) {
timer := time.NewTimer(interval)
return timer.C, func() { timer.Stop() }
}
type Monitor struct {
checkType string
interval time.Duration
@@ -49,6 +54,9 @@ type Monitor struct {
ctx context.Context
cancel context.CancelFunc
doneCh chan struct{}
timerFactory func(time.Duration) (<-chan time.Time, func())
}
func NewMonitor(ctx context.Context, cfg v1.HealthCheckConfig, addr string,
@@ -91,6 +99,8 @@ func NewMonitor(ctx context.Context, cfg v1.HealthCheckConfig, addr string,
statusFailedFn: statusFailedFn,
ctx: newctx,
cancel: cancel,
doneCh: make(chan struct{}),
timerFactory: newHealthTimer,
}
}
@@ -102,39 +112,66 @@ func (monitor *Monitor) Stop() {
monitor.cancel()
}
// Done is closed when the worker launched by Start has exited.
func (monitor *Monitor) Done() <-chan struct{} {
return monitor.doneCh
}
func (monitor *Monitor) checkWorker() {
xl := xlog.FromContextSafe(monitor.ctx)
defer close(monitor.doneCh)
for {
if monitor.ctx.Err() != nil {
return
}
doCtx, cancel := context.WithDeadline(monitor.ctx, time.Now().Add(monitor.timeout))
err := monitor.doCheck(doCtx)
cancel()
// check if this monitor has been closed
select {
case <-monitor.ctx.Done():
cancel()
if monitor.ctx.Err() != nil {
return
default:
cancel()
}
monitor.handleCheckResult(err)
if err == nil {
xl.Tracef("do one health check success")
if !monitor.statusOK && monitor.statusNormalFn != nil {
xl.Infof("health check status change to success")
monitor.statusOK = true
monitor.statusNormalFn()
}
} else {
xl.Warnf("do one health check failed: %v", err)
monitor.failedTimes++
if monitor.statusOK && int(monitor.failedTimes) >= monitor.maxFailedTimes && monitor.statusFailedFn != nil {
xl.Warnf("health check status change to failed")
monitor.statusOK = false
monitor.statusFailedFn()
}
if !monitor.waitForNextCheck() {
return
}
}
}
time.Sleep(monitor.interval)
func (monitor *Monitor) handleCheckResult(err error) {
xl := xlog.FromContextSafe(monitor.ctx)
if err == nil {
xl.Tracef("do one health check success")
monitor.failedTimes = 0
if !monitor.statusOK && monitor.statusNormalFn != nil {
xl.Infof("health check status change to success")
monitor.statusOK = true
monitor.statusNormalFn()
}
return
}
xl.Warnf("do one health check failed: %v", err)
monitor.failedTimes++
if monitor.statusOK && int(monitor.failedTimes) >= monitor.maxFailedTimes && monitor.statusFailedFn != nil {
xl.Warnf("health check status change to failed")
monitor.statusOK = false
monitor.statusFailedFn()
}
}
func (monitor *Monitor) waitForNextCheck() bool {
timerC, stopTimer := monitor.timerFactory(monitor.interval)
defer stopTimer()
select {
case <-monitor.ctx.Done():
return false
case <-timerC:
return monitor.ctx.Err() == nil
}
}
+575
View File
@@ -0,0 +1,575 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package health
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
type tcpHealthBackend struct {
listener net.Listener
accepted chan struct{}
done chan struct{}
}
func newTCPHealthBackend(t *testing.T, addr string, accepted chan struct{}) *tcpHealthBackend {
listener, err := net.Listen("tcp", addr)
require.NoError(t, err)
backend := &tcpHealthBackend{
listener: listener,
accepted: accepted,
done: make(chan struct{}),
}
go func() {
defer close(backend.done)
for {
conn, err := backend.listener.Accept()
if err != nil {
return
}
_ = conn.Close()
select {
case backend.accepted <- struct{}{}:
default:
}
}
}()
return backend
}
func (backend *tcpHealthBackend) Close() {
_ = backend.listener.Close()
<-backend.done
}
func TestMonitorConsecutiveFailureWindows(t *testing.T) {
tests := []struct {
name string
setup func(*testing.T, func(), func()) (*Monitor, func(bool))
}{
{
name: "HTTP",
setup: func(t *testing.T, normalFn, failedFn func()) (*Monitor, func(bool)) {
var healthy atomic.Bool
healthy.Store(true)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if !healthy.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
}
}))
t.Cleanup(server.Close)
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{
Type: "http",
Path: "/health",
TimeoutSeconds: 1,
IntervalSeconds: 1,
MaxFailed: 3,
},
strings.TrimPrefix(server.URL, "http://"),
normalFn,
failedFn,
)
return monitor, healthy.Store
},
},
{
name: "TCP",
setup: func(t *testing.T, normalFn, failedFn func()) (*Monitor, func(bool)) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = listener.Close() })
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
_ = conn.Close()
}
}()
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{
Type: "tcp",
TimeoutSeconds: 1,
IntervalSeconds: 1,
MaxFailed: 3,
},
listener.Addr().String(),
normalFn,
failedFn,
)
healthyAddr := monitor.addr
return monitor, func(healthy bool) {
if healthy {
monitor.addr = healthyAddr
} else {
monitor.addr = "127.0.0.1:0"
}
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var events []string
monitor, setHealthy := test.setup(
t,
func() { events = append(events, "normal") },
func() { events = append(events, "failed") },
)
t.Cleanup(monitor.Stop)
runCheck := func(healthy bool) {
t.Helper()
setHealthy(healthy)
ctx, cancel := context.WithTimeout(monitor.ctx, time.Second)
err := monitor.doCheck(ctx)
cancel()
if healthy {
require.NoError(t, err)
} else {
require.Error(t, err)
}
monitor.handleCheckResult(err)
}
runCheck(true)
require.True(t, monitor.statusOK)
require.Zero(t, monitor.failedTimes)
require.Equal(t, []string{"normal"}, events)
runCheck(false)
runCheck(false)
require.True(t, monitor.statusOK)
require.Equal(t, uint64(2), monitor.failedTimes)
require.Equal(t, []string{"normal"}, events)
runCheck(true)
require.True(t, monitor.statusOK)
require.Zero(t, monitor.failedTimes)
require.Equal(t, []string{"normal"}, events)
runCheck(false)
require.True(t, monitor.statusOK)
require.Equal(t, uint64(1), monitor.failedTimes)
require.Equal(t, []string{"normal"}, events)
runCheck(false)
require.True(t, monitor.statusOK)
require.Equal(t, uint64(2), monitor.failedTimes)
runCheck(false)
require.False(t, monitor.statusOK)
require.Equal(t, uint64(3), monitor.failedTimes)
require.Equal(t, []string{"normal", "failed"}, events)
runCheck(true)
require.True(t, monitor.statusOK)
require.Zero(t, monitor.failedTimes)
require.Equal(t, []string{"normal", "failed", "normal"}, events)
})
}
}
type workerStatus struct {
failedTimes uint64
statusOK bool
}
type workerInterval struct {
status workerStatus
timer chan time.Time
}
func observeWorkerIntervals(monitor *Monitor) <-chan workerInterval {
intervals := make(chan workerInterval, 1)
monitor.timerFactory = func(time.Duration) (<-chan time.Time, func()) {
timer := make(chan time.Time, 1)
// The worker takes this snapshot after handleCheckResult. Sending it
// publishes the state to the test; no test goroutine reads live fields.
interval := workerInterval{
status: workerStatus{failedTimes: monitor.failedTimes, statusOK: monitor.statusOK},
timer: timer,
}
select {
case intervals <- interval:
case <-monitor.ctx.Done():
}
return timer, func() {}
}
return intervals
}
func awaitWorkerInterval(t *testing.T, intervals <-chan workerInterval, want workerStatus) chan time.Time {
t.Helper()
select {
case interval := <-intervals:
require.Equal(t, want, interval.status)
return interval.timer
case <-time.After(time.Second):
t.Fatal("health worker did not reach the interval barrier")
return nil
}
}
func stopMonitorWorker(t *testing.T, monitor *Monitor) {
t.Helper()
monitor.Stop()
select {
case <-monitor.Done():
case <-time.After(time.Second):
t.Error("health worker did not exit after Stop")
}
}
func TestMonitorWorkerProcessesResults(t *testing.T) {
requestReady := make(chan struct{}, 1)
responses := make(chan int)
requestCanceled := make(chan struct{})
var cancelOnce sync.Once
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case requestReady <- struct{}{}:
case <-r.Context().Done():
return
}
select {
case code := <-responses:
w.WriteHeader(code)
case <-r.Context().Done():
cancelOnce.Do(func() { close(requestCanceled) })
}
}))
t.Cleanup(server.Close)
events := make(chan string, 3)
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{
Type: "http",
Path: "/health",
TimeoutSeconds: 5,
MaxFailed: 3,
},
strings.TrimPrefix(server.URL, "http://"),
func() { events <- "normal" },
func() { events <- "failed" },
)
intervals := observeWorkerIntervals(monitor)
t.Cleanup(func() { stopMonitorWorker(t, monitor) })
awaitRequest := func() {
t.Helper()
select {
case <-requestReady:
case <-time.After(time.Second):
t.Fatal("health check request did not start")
}
}
awaitEvent := func(want string) {
t.Helper()
select {
case got := <-events:
require.Equal(t, want, got)
case <-time.After(time.Second):
t.Fatalf("health check callback %q was not called", want)
}
}
checkResult := func(code int, want workerStatus, event string) {
t.Helper()
awaitRequest()
select {
case responses <- code:
case <-time.After(time.Second):
t.Fatal("health check handler did not accept the response")
}
timer := awaitWorkerInterval(t, intervals, want)
if event != "" {
awaitEvent(event)
}
// The worker has finished this result and cannot emit another
// callback until the test releases the next check.
select {
case got := <-events:
t.Fatalf("unexpected health check callback %q", got)
default:
}
timer <- time.Now()
}
monitor.Start()
checkResult(http.StatusOK, workerStatus{statusOK: true}, "normal")
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 1, statusOK: true}, "")
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 2, statusOK: true}, "")
checkResult(http.StatusOK, workerStatus{statusOK: true}, "")
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 1, statusOK: true}, "")
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 2, statusOK: true}, "")
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 3}, "failed")
checkResult(http.StatusOK, workerStatus{statusOK: true}, "normal")
awaitRequest()
stopMonitorWorker(t, monitor)
select {
case <-requestCanceled:
case <-time.After(time.Second):
t.Fatal("health check request context was not canceled")
}
}
func TestMonitorTCPWorkerProcessesResults(t *testing.T) {
initialAccepted := make(chan struct{}, 1)
initialBackend := newTCPHealthBackend(t, "127.0.0.1:0", initialAccepted)
addr := initialBackend.listener.Addr().String()
recoveryAccepted := make(chan struct{}, 1)
normalCallbacks := make(chan workerStatus, 2)
failedCallbacks := make(chan workerStatus, 2)
var monitor *Monitor
monitor = NewMonitor(
context.Background(),
v1.HealthCheckConfig{
Type: "tcp",
TimeoutSeconds: 1,
MaxFailed: 3,
},
addr,
func() {
normalCallbacks <- workerStatus{failedTimes: monitor.failedTimes, statusOK: monitor.statusOK}
},
func() {
failedCallbacks <- workerStatus{failedTimes: monitor.failedTimes, statusOK: monitor.statusOK}
},
)
intervals := observeWorkerIntervals(monitor)
recoveryBackend := (*tcpHealthBackend)(nil)
t.Cleanup(func() {
stopMonitorWorker(t, monitor)
initialBackend.Close()
if recoveryBackend != nil {
recoveryBackend.Close()
}
})
awaitStatus := func(ch <-chan workerStatus, want workerStatus, message string) {
t.Helper()
select {
case got := <-ch:
require.Equal(t, want, got)
case <-time.After(time.Second):
t.Fatal(message)
}
}
monitor.Start()
select {
case <-initialAccepted:
case <-time.After(time.Second):
t.Fatal("TCP health check did not reach the initial backend")
}
awaitStatus(normalCallbacks, workerStatus{failedTimes: 0, statusOK: true}, "TCP worker did not report the initial success")
initialTimer := awaitWorkerInterval(t, intervals, workerStatus{statusOK: true})
initialBackend.Close()
initialTimer <- time.Now()
firstFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 1, statusOK: true})
firstFailureTimer <- time.Now()
secondFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 2, statusOK: true})
secondFailureTimer <- time.Now()
awaitStatus(failedCallbacks, workerStatus{failedTimes: 3, statusOK: false}, "TCP worker did not report the third failed health check")
thirdFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 3})
recoveryBackend = newTCPHealthBackend(t, addr, recoveryAccepted)
thirdFailureTimer <- time.Now()
select {
case <-recoveryAccepted:
case <-time.After(time.Second):
t.Fatal("TCP health check did not reach the recovery backend")
}
awaitStatus(normalCallbacks, workerStatus{failedTimes: 0, statusOK: true}, "TCP worker did not report recovery")
recoveryTimer := awaitWorkerInterval(t, intervals, workerStatus{statusOK: true})
recoveryBackend.Close()
recoveryTimer <- time.Now()
firstRecoveryFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 1, statusOK: true})
firstRecoveryFailureTimer <- time.Now()
secondRecoveryFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 2, statusOK: true})
secondRecoveryFailureTimer <- time.Now()
awaitStatus(failedCallbacks, workerStatus{failedTimes: 3, statusOK: false}, "TCP worker did not report the third failed health check after recovery")
stopMonitorWorker(t, monitor)
}
func TestMonitorMaxFailedOne(t *testing.T) {
var events []string
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{Type: "tcp", MaxFailed: 1},
"",
func() { events = append(events, "normal") },
func() { events = append(events, "failed") },
)
t.Cleanup(monitor.Stop)
checkErr := errors.New("health check failed")
monitor.handleCheckResult(nil)
monitor.handleCheckResult(checkErr)
require.Equal(t, []string{"normal", "failed"}, events)
require.False(t, monitor.statusOK)
require.Equal(t, uint64(1), monitor.failedTimes)
monitor.handleCheckResult(checkErr)
require.Equal(t, []string{"normal", "failed"}, events)
monitor.handleCheckResult(nil)
monitor.handleCheckResult(checkErr)
require.Equal(t, []string{"normal", "failed", "normal", "failed"}, events)
}
func TestMonitorStopCancelsWork(t *testing.T) {
t.Run("in-flight check", func(t *testing.T) {
requestStarted := make(chan struct{})
requestCanceled := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
close(requestStarted)
<-r.Context().Done()
close(requestCanceled)
}))
t.Cleanup(server.Close)
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{Type: "http", Path: "/health"},
strings.TrimPrefix(server.URL, "http://"),
func() {},
func() {},
)
t.Cleanup(func() { stopMonitorWorker(t, monitor) })
monitor.Start()
select {
case <-requestStarted:
case <-time.After(time.Second):
t.Fatal("health check request did not start")
}
stopMonitorWorker(t, monitor)
select {
case <-requestCanceled:
case <-time.After(time.Second):
t.Fatal("health check request context was not canceled")
}
})
t.Run("interval wait", func(t *testing.T) {
monitor := NewMonitor(context.Background(), v1.HealthCheckConfig{Type: "tcp"}, "", nil, nil)
intervals := observeWorkerIntervals(monitor)
t.Cleanup(func() { stopMonitorWorker(t, monitor) })
monitor.Start()
// A real worker has completed its first check and installed a timer
// that will never fire. Stop must release the wait and exit the loop.
awaitWorkerInterval(t, intervals, workerStatus{})
stopMonitorWorker(t, monitor)
})
}
type gatedErrContext struct {
context.Context
errCalled chan struct{}
releaseErr chan struct{}
}
func (ctx *gatedErrContext) Err() error {
select {
case ctx.errCalled <- struct{}{}:
default:
}
<-ctx.releaseErr
return ctx.Context.Err()
}
func TestMonitorTimerCancellationAfterTimerFires(t *testing.T) {
baseCtx, cancel := context.WithCancel(context.Background())
ctx := &gatedErrContext{
Context: baseCtx,
errCalled: make(chan struct{}, 1),
releaseErr: make(chan struct{}),
}
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{Type: "tcp"},
"",
nil,
nil,
)
monitor.ctx = ctx
monitor.cancel = cancel
timerReady := make(chan time.Time, 1)
timerReady <- time.Now()
var timerStopped atomic.Bool
monitor.timerFactory = func(time.Duration) (<-chan time.Time, func()) {
return timerReady, func() { timerStopped.Store(true) }
}
waitResult := make(chan bool, 1)
go func() {
waitResult <- monitor.waitForNextCheck()
}()
// ctx.Done is not ready when select runs, so receiving timerReady is the
// only possible branch. Err signals after that receive and blocks until the
// test cancels the context, deterministically exercising the cancellation
// re-check in the timer branch.
select {
case <-ctx.errCalled:
case <-time.After(time.Second):
t.Fatal("timer branch did not re-check the monitor context")
}
cancel()
close(ctx.releaseErr)
select {
case shouldContinue := <-waitResult:
require.False(t, shouldContinue)
case <-time.After(time.Second):
t.Fatal("timer wait did not observe context cancellation")
}
require.True(t, timerStopped.Load())
}
+27 -9
View File
@@ -61,11 +61,12 @@ func NewProxy(
encryptionKey []byte,
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
udpPacketCodec string,
) (pxy Proxy) {
var limiter *rate.Limiter
limitBytes := pxyConf.GetBaseConfig().Transport.BandwidthLimit.Bytes()
if limitBytes > 0 && pxyConf.GetBaseConfig().Transport.BandwidthLimitMode == types.BandwidthLimitModeClient {
limiter = rate.NewLimiter(rate.Limit(float64(limitBytes)), int(limitBytes))
limiter = limit.NewBandwidthLimiter(limitBytes)
}
baseProxy := BaseProxy{
@@ -77,6 +78,7 @@ func NewProxy(
vnetController: vnetController,
xl: xlog.FromContextSafe(ctx),
ctx: ctx,
udpPacketCodec: udpPacketCodec,
}
factory := proxyFactoryRegistry[reflect.TypeOf(pxyConf)]
@@ -98,9 +100,10 @@ type BaseProxy struct {
proxyPlugin plugin.Plugin
inWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) /* continue */ bool
mu sync.RWMutex
xl *xlog.Logger
ctx context.Context
mu sync.RWMutex
xl *xlog.Logger
ctx context.Context
udpPacketCodec string
}
func (pxy *BaseProxy) Run() error {
@@ -171,6 +174,26 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
xl.Tracef("handle tcp work connection, useEncryption: %t, useCompression: %t",
baseCfg.Transport.UseEncryption, baseCfg.Transport.UseCompression)
var srcAddr, dstAddr *net.TCPAddr
if m.SrcAddr != "" && m.SrcPort != 0 {
if m.DstAddr == "" {
m.DstAddr = "127.0.0.1"
}
var err error
srcAddr, err = net.ResolveTCPAddr("tcp", net.JoinHostPort(m.SrcAddr, strconv.Itoa(int(m.SrcPort))))
if err != nil {
xl.Warnf("resolve source address [%s] error: %v", m.SrcAddr, err)
_ = workConn.Close()
return
}
dstAddr, err = net.ResolveTCPAddr("tcp", net.JoinHostPort(m.DstAddr, strconv.Itoa(int(m.DstPort))))
if err != nil {
xl.Warnf("resolve destination address [%s] error: %v", m.DstAddr, err)
_ = workConn.Close()
return
}
}
remote, recycleFn, err := pxy.wrapWorkConn(workConn, encKey)
if err != nil {
xl.Errorf("wrap work connection: %v", err)
@@ -180,11 +203,6 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
// check if we need to send proxy protocol info
var connInfo plugin.ConnectionInfo
if m.SrcAddr != "" && m.SrcPort != 0 {
if m.DstAddr == "" {
m.DstAddr = "127.0.0.1"
}
srcAddr, _ := net.ResolveTCPAddr("tcp", net.JoinHostPort(m.SrcAddr, strconv.Itoa(int(m.SrcPort))))
dstAddr, _ := net.ResolveTCPAddr("tcp", net.JoinHostPort(m.DstAddr, strconv.Itoa(int(m.DstPort))))
connInfo.SrcAddr = srcAddr
connInfo.DstAddr = dstAddr
}
+5 -2
View File
@@ -43,7 +43,8 @@ type Manager struct {
encryptionKey []byte
clientCfg *v1.ClientCommonConfig
ctx context.Context
ctx context.Context
udpPacketCodec string
}
func NewManager(
@@ -52,6 +53,7 @@ func NewManager(
encryptionKey []byte,
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
udpPacketCodec string,
) *Manager {
return &Manager{
proxies: make(map[string]*Wrapper),
@@ -61,6 +63,7 @@ func NewManager(
encryptionKey: encryptionKey,
clientCfg: clientCfg,
ctx: ctx,
udpPacketCodec: udpPacketCodec,
}
}
@@ -166,7 +169,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.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController)
pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController, pm.udpPacketCodec)
if pm.inWorkConnCallback != nil {
pxy.SetInWorkConnCallback(pm.inWorkConnCallback)
}
+47
View File
@@ -0,0 +1,47 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !frps
package proxy
import (
"io"
"net"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/util/xlog"
)
func TestHandleTCPWorkConnectionRejectsInvalidAddress(t *testing.T) {
workConn, peerConn := net.Pipe()
defer peerConn.Close()
pxy := &BaseProxy{
baseCfg: &v1.ProxyBaseConfig{},
xl: xlog.New(),
}
pxy.HandleTCPWorkConnection(workConn, &msg.StartWorkConn{
SrcAddr: "[",
SrcPort: 1,
}, nil)
buffer := make([]byte, 1)
_, err := peerConn.Read(buffer)
require.ErrorIs(t, err, io.EOF)
}
+4 -2
View File
@@ -99,6 +99,7 @@ func NewWrapper(
eventHandler event.Handler,
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
udpPacketCodec string,
) *Wrapper {
baseInfo := cfg.GetBaseConfig()
xl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(baseInfo.Name)
@@ -127,7 +128,7 @@ func NewWrapper(
xl.Tracef("enable health check monitor")
}
pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController)
pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController, udpPacketCodec)
return pw
}
@@ -266,8 +267,9 @@ func (pw *Wrapper) InWorkConn(workConn net.Conn, m *msg.StartWorkConn) {
xl := pw.xl
pw.mu.RLock()
pxy := pw.pxy
running := pw.Phase == ProxyPhaseRunning
pw.mu.RUnlock()
if pxy != nil && pw.Phase == ProxyPhaseRunning {
if pxy != nil && running {
xl.Debugf("start a new work connection, localAddr: %s remoteAddr: %s", workConn.LocalAddr().String(), workConn.RemoteAddr().String())
go pxy.InWorkConn(workConn, m)
} else {
+76
View File
@@ -0,0 +1,76 @@
//go:build !frps
package proxy
import (
"io"
"net"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/util/xlog"
)
type closingWorkProxy struct {
Proxy
accepted atomic.Int64
}
func (p *closingWorkProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
p.accepted.Add(1)
_ = conn.Close()
}
func assertWrapperWorkConnClosed(t *testing.T, pw *Wrapper) {
t.Helper()
conn, peer := net.Pipe()
defer conn.Close()
defer peer.Close()
require.NoError(t, peer.SetReadDeadline(time.Now().Add(time.Second)))
pw.InWorkConn(conn, &msg.StartWorkConn{})
_, err := peer.Read(make([]byte, 1))
require.ErrorIs(t, err, io.EOF)
}
func TestWrapperInWorkConnPhaseSnapshot(t *testing.T) {
pxy := &closingWorkProxy{}
pw := &Wrapper{pxy: pxy, xl: xlog.New()}
for _, phase := range []string{ProxyPhaseNew, ProxyPhaseWaitStart, ProxyPhaseStartErr, ProxyPhaseCheckFailed, ProxyPhaseClosed} {
pw.Phase = phase
assertWrapperWorkConnClosed(t, pw)
}
require.Zero(t, pxy.accepted.Load(), "inactive proxy received a work connection")
pw.Phase = ProxyPhaseRunning
pw.pxy = nil
assertWrapperWorkConnClosed(t, pw)
pw.pxy = pxy
assertWrapperWorkConnClosed(t, pw)
require.Equal(t, int64(1), pxy.accepted.Load())
start, done := make(chan struct{}), make(chan struct{})
go func() {
<-start
for range 20000 {
pw.mu.Lock()
pw.Phase = ProxyPhaseRunning
pw.mu.Unlock()
pw.mu.Lock()
pw.Phase = ProxyPhaseClosed
pw.mu.Unlock()
}
close(done)
}()
close(start)
for range 10000 {
assertWrapperWorkConnClosed(t, pw)
}
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("phase writer did not finish")
}
}
+7 -1
View File
@@ -87,7 +87,13 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol))
payloadRW, err := msg.NewUDPPacketReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol, pxy.udpPacketCodec)
if err != nil {
xl.Errorf("create SUDP packet read writer: %v", err)
_ = workConn.Close()
return
}
payloadConn := msg.NewConn(workConn, payloadRW)
readCh := make(chan *msg.UDPPacket, 1024)
sendCh := make(chan msg.Message, 1024)
isClose := false
+10 -3
View File
@@ -97,10 +97,17 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
return
}
pxy.mu.Lock()
pxy.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn)
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
// Plain UDP payload follows the configured wire protocol for message framing.
payloadRW := msg.NewReadWriter(pxy.workConn, pxy.clientCfg.Transport.WireProtocol)
payloadRW, err := msg.NewUDPPacketReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol, pxy.udpPacketCodec)
if err != nil {
xl.Errorf("create UDP packet read writer: %v", err)
workConn.Close()
return
}
pxy.mu.Lock()
pxy.workConn = workConn
pxy.readCh = make(chan *msg.UDPPacket, 1024)
pxy.sendCh = make(chan msg.Message, 1024)
pxy.closed = false
+1 -1
View File
@@ -22,7 +22,7 @@ import (
"reflect"
"time"
fmux "github.com/hashicorp/yamux"
fmux "github.com/fatedier/yamux"
"github.com/quic-go/quic-go"
v1 "github.com/fatedier/frp/pkg/config/v1"
+52 -8
View File
@@ -33,6 +33,7 @@ import (
"github.com/fatedier/frp/pkg/config"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/policy/security"
httppkg "github.com/fatedier/frp/pkg/util/http"
@@ -154,6 +155,9 @@ type Service struct {
ctx context.Context
// call cancel to stop service
cancel context.CancelCauseFunc
// Protect cancel publication and shutdown before the first Run.
lifecycleMu sync.Mutex
closed bool
connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector
handleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool
@@ -224,8 +228,20 @@ func NewService(options ServiceOptions) (*Service, error) {
func (svr *Service) Run(ctx context.Context) error {
ctx, cancel := context.WithCancelCause(ctx)
svr.lifecycleMu.Lock()
if svr.closed {
svr.lifecycleMu.Unlock()
cancel(nil)
return ctx.Err()
}
svr.ctx = xlog.NewContext(ctx, xlog.FromContextSafe(ctx))
svr.cancel = cancel
svr.lifecycleMu.Unlock()
defer cancel(nil)
defer svr.stop()
if err := ctx.Err(); err != nil {
return err
}
// set custom DNSServer
if svr.common.DNSServer != "" {
@@ -236,7 +252,6 @@ func (svr *Service) Run(ctx context.Context) error {
vnetController := svr.vnetController
if err := svr.vnetController.Init(); err != nil {
log.Errorf("init virtual network controller error: %v", err)
svr.stop()
return err
}
go func() {
@@ -262,19 +277,25 @@ func (svr *Service) Run(ctx context.Context) error {
if svr.ctl == nil {
cancelCause := cancelErr{}
_ = errors.As(context.Cause(svr.ctx), &cancelCause)
svr.stop()
return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err)
}
go svr.keepControllerWorking()
<-svr.ctx.Done()
svr.stop()
return nil
}
func (svr *Service) keepControllerWorking() {
<-svr.ctl.Done()
// ctl is swapped under ctlMu in loopLoginUntilSuccess / stop; take a
// snapshot before waiting so stop's nil write does not race this read.
svr.ctlMu.RLock()
ctl := svr.ctl
svr.ctlMu.RUnlock()
if ctl == nil {
return
}
<-ctl.Done()
// There is a situation where the login is successful but due to certain reasons,
// the control immediately exits. It is necessary to limit the frequency of reconnection in this case.
@@ -284,8 +305,11 @@ func (svr *Service) keepControllerWorking() {
// loopLoginUntilSuccess is another layer of loop that will continuously attempt to
// login to the server until successful.
svr.loopLoginUntilSuccess(20*time.Second, false)
if svr.ctl != nil {
<-svr.ctl.Done()
svr.ctlMu.RLock()
ctl := svr.ctl
svr.ctlMu.RUnlock()
if ctl != nil {
<-ctl.Done()
return false, errors.New("control is closed and try another loop")
}
// If the control is nil, it means that the login failed and the service is also closed.
@@ -376,7 +400,7 @@ func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorC
svr.ctlMu.RUnlock()
if ctl != nil {
return svr.ctl.UpdateAllConfigurer(proxyCfgs, visitorCfgs)
return ctl.UpdateAllConfigurer(proxyCfgs, visitorCfgs)
}
return nil
}
@@ -416,7 +440,20 @@ func (svr *Service) Close() {
func (svr *Service) GracefulClose(d time.Duration) {
svr.gracefulShutdownDuration.Store(int64(d))
svr.cancel(nil)
svr.lifecycleMu.Lock()
if svr.closed {
svr.lifecycleMu.Unlock()
return
}
svr.closed = true
cancel := svr.cancel
svr.lifecycleMu.Unlock()
if cancel != nil {
// Once Run has claimed startup, it also owns cleanup.
cancel(nil)
} else {
svr.stop()
}
}
func (svr *Service) stop() {
@@ -510,6 +547,13 @@ func (svr *Service) reloadConfigFromSourcesLocked() error {
proxies, visitors = config.FilterClientConfigurers(reloadCommon, proxies, visitors)
proxies = config.CompleteProxyConfigurers(proxies)
visitors = config.CompleteVisitorConfigurers(visitors)
requirements := validation.GetClientConfigRequirements(reloadCommon, proxies, visitors)
if svr.vnetController == nil && requirements.VirtualNet {
return errors.New(
"VirtualNet-dependent configuration requires a VirtualNet runtime enabled at startup; " +
"restart frpc after configuring featureGates.VirtualNet and virtualNet.address",
)
}
// Atomically replace the entire configuration
if err := svr.UpdateAllConfigurer(proxies, visitors); err != nil {
+186
View File
@@ -0,0 +1,186 @@
package client
import (
"context"
"io"
"net"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/msg"
)
func newLifecycleTestService(t *testing.T, creator func(context.Context, *v1.ClientCommonConfig) Connector) (*Service, string) {
t.Helper()
svr, err := NewService(ServiceOptions{
Common: &v1.ClientCommonConfig{
LoginFailExit: new(false),
WebServer: v1.WebServerConfig{Addr: "127.0.0.1", Port: getFreeTCPPort(t)},
},
ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
ConnectorCreator: creator,
})
require.NoError(t, err)
_, err = validation.NewConfigValidator(nil).ValidateClientCommonConfig(svr.common)
require.NoError(t, err)
address := svr.webServer.Address()
assertLifecycleAdminPort(t, address, false)
return svr, address
}
func assertLifecycleAdminPort(t *testing.T, address string, released bool) {
t.Helper()
ln, err := net.Listen("tcp", address)
if ln != nil {
_ = ln.Close()
}
if released {
require.NoError(t, err, "admin listener was not released")
} else {
require.Error(t, err, "admin listener was not bound")
}
}
func waitLifecycleSignal(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("service lifecycle operation did not finish")
}
}
func TestServiceRunAfterEarlyShutdown(t *testing.T) {
for _, mode := range []string{"close before Run", "parent already cancelled"} {
t.Run(mode, func(t *testing.T) {
var attempts atomic.Int64
svr, address := newLifecycleTestService(t, func(context.Context, *v1.ClientCommonConfig) Connector {
attempts.Add(1)
return &failingConnector{err: net.ErrClosed}
})
// Detect startup's global DNS side effect without making DNS requests.
resolver := net.DefaultResolver
t.Cleanup(func() { net.DefaultResolver = resolver })
svr.common.DNSServer = "127.0.0.1:53"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if mode == "close before Run" {
svr.Close()
assertLifecycleAdminPort(t, address, true)
} else {
cancel()
}
done := make(chan struct{})
var runErr error
go func() {
runErr = svr.Run(ctx)
close(done)
}()
waitLifecycleSignal(t, done)
require.ErrorIs(t, runErr, context.Canceled)
require.Zero(t, attempts.Load(), "closed service attempted login")
require.Same(t, resolver, net.DefaultResolver, "closed service changed DNS")
assertLifecycleAdminPort(t, address, true)
})
}
}
func TestServiceStartupShutdownLeavesCleanupToRun(t *testing.T) {
for _, mode := range []string{"Close", "parent cancellation"} {
t.Run(mode, func(t *testing.T) {
entered, resume := make(chan struct{}), make(chan struct{})
svr, address := newLifecycleTestService(t, func(ctx context.Context, _ *v1.ClientCommonConfig) Connector {
close(entered)
<-resume
return &failingConnector{err: ctx.Err()}
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
defer close(resume)
runDone := make(chan struct{})
go func() { _ = svr.Run(ctx); close(runDone) }()
waitLifecycleSignal(t, entered)
closeDone := make(chan struct{})
go func() {
if mode == "Close" {
svr.Close()
} else {
cancel()
}
close(closeDone)
}()
waitLifecycleSignal(t, closeDone)
select {
case <-runDone:
t.Fatal("Run exited while startup still owned its resources")
default:
}
assertLifecycleAdminPort(t, address, false)
resume <- struct{}{}
waitLifecycleSignal(t, runDone)
assertLifecycleAdminPort(t, address, true)
require.ErrorIs(t, svr.ctx.Err(), context.Canceled)
})
}
}
func TestServiceShutdownAfterLogin(t *testing.T) {
for _, mode := range []string{"Close", "parent cancellation"} {
t.Run(mode, func(t *testing.T) {
clientConn, serverConn := net.Pipe()
defer clientConn.Close()
defer serverConn.Close()
connector := &testConnector{conn: clientConn}
svr, address := newLifecycleTestService(t, func(context.Context, *v1.ClientCommonConfig) Connector {
return connector
})
defer svr.Close()
peerDone := make(chan error, 1)
go func() {
rw := msg.NewV1ReadWriter(serverConn)
var login msg.Login
if err := rw.ReadMsgInto(&login); err != nil {
peerDone <- err
return
}
if err := rw.WriteMsg(&msg.LoginResp{RunID: "lifecycle-test"}); err != nil {
peerDone <- err
return
}
_, err := io.Copy(io.Discard, serverConn)
peerDone <- err
}()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runDone := make(chan struct{})
var runErr error
go func() { runErr = svr.Run(ctx); close(runDone) }()
require.Eventually(t, func() bool {
svr.ctlMu.RLock()
defer svr.ctlMu.RUnlock()
return svr.ctl != nil
}, 3*time.Second, time.Millisecond, "login did not establish control")
if mode == "Close" {
svr.Close()
} else {
cancel()
}
waitLifecycleSignal(t, runDone)
require.NoError(t, runErr)
require.True(t, connector.closed.Load())
select {
case err := <-peerDone:
require.NoError(t, err)
case <-time.After(3 * time.Second):
t.Fatal("control connection was not released")
}
assertLifecycleAdminPort(t, address, true)
})
}
}
+2 -2
View File
@@ -34,8 +34,8 @@ func newGracefulCloseTestService() *Service {
},
doneCh: make(chan struct{}),
}
ctl.pm = proxy.NewManager(ctx, common, nil, nil, nil)
ctl.vm = visitor.NewManager(ctx, "graceful-close-race", common, nil, nil, nil)
ctl.pm = proxy.NewManager(ctx, common, nil, nil, nil, "")
ctl.vm = visitor.NewManager(ctx, "graceful-close-race", common, nil, nil, nil, "")
return &Service{ctl: ctl, cancel: context.CancelCauseFunc(func(error) {})}
}
+1 -3
View File
@@ -9,8 +9,6 @@ import (
"strings"
"testing"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
@@ -49,7 +47,7 @@ func TestRunStopsStartedComponentsOnInitialLoginFailure(t *testing.T) {
svr, err := NewService(ServiceOptions{
Common: &v1.ClientCommonConfig{
LoginFailExit: lo.ToPtr(true),
LoginFailExit: new(true),
WebServer: v1.WebServerConfig{
Addr: "127.0.0.1",
Port: port,
+7 -1
View File
@@ -113,7 +113,13 @@ func (sv *SUDPVisitor) dispatcher() {
func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
xl := xlog.FromContextSafe(sv.ctx)
xl.Debugf("starting sudp proxy worker")
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, sv.clientCfg.Transport.WireProtocol))
payloadRW, err := msg.NewUDPPacketReadWriter(workConn, sv.clientCfg.Transport.WireProtocol, udpPacketCodecFromHelper(sv.helper))
if err != nil {
xl.Errorf("create SUDP packet read writer: %v", err)
_ = workConn.Close()
return
}
payloadConn := msg.NewConn(workConn, payloadRW)
wg := &sync.WaitGroup{}
wg.Add(2)
+11
View File
@@ -50,6 +50,17 @@ type Helper interface {
RunID() string
}
type udpPacketCodecProvider interface {
UDPPacketCodec() string
}
func udpPacketCodecFromHelper(helper Helper) string {
if provider, ok := helper.(udpPacketCodecProvider); ok {
return provider.UDPPacketCodec()
}
return ""
}
// Visitor is used for forward traffics from local port tot remote service.
type Visitor interface {
Run() error
+11
View File
@@ -53,7 +53,12 @@ func NewManager(
connectServer func() (*msg.Conn, error),
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
udpPacketCodecs ...string,
) *Manager {
udpPacketCodec := ""
if len(udpPacketCodecs) > 0 {
udpPacketCodec = udpPacketCodecs[0]
}
m := &Manager{
clientCfg: clientCfg,
cfgs: make(map[string]v1.VisitorConfigurer),
@@ -68,6 +73,7 @@ func NewManager(
vnetController: vnetController,
transferConnFn: m.TransferConn,
runID: runID,
udpPacketCodec: udpPacketCodec,
}
return m
}
@@ -205,6 +211,7 @@ type visitorHelperImpl struct {
vnetController *vnet.Controller
transferConnFn func(name string, conn net.Conn) error
runID string
udpPacketCodec string
}
func (v *visitorHelperImpl) ConnectServer() (*msg.Conn, error) {
@@ -226,3 +233,7 @@ func (v *visitorHelperImpl) VNetController() *vnet.Controller {
func (v *visitorHelperImpl) RunID() string {
return v.runID
}
func (v *visitorHelperImpl) UDPPacketCodec() string {
return v.udpPacketCodec
}
+1 -1
View File
@@ -25,7 +25,7 @@ import (
"time"
libio "github.com/fatedier/golib/io"
fmux "github.com/hashicorp/yamux"
fmux "github.com/fatedier/yamux"
quic "github.com/quic-go/quic-go"
"golang.org/x/time/rate"
-7
View File
@@ -33,7 +33,6 @@ import (
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/featuregate"
"github.com/fatedier/frp/pkg/policy/security"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/version"
@@ -131,12 +130,6 @@ func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) erro
"please use yaml/json/toml format instead!\n")
}
if len(result.Common.FeatureGates) > 0 {
if err := featuregate.SetFromMap(result.Common.FeatureGates); err != nil {
return err
}
}
return runClientWithAggregator(result, unsafeFeatures, cfgFilePath)
}
+13 -6
View File
@@ -29,6 +29,18 @@ func init() {
rootCmd.AddCommand(verifyCmd)
}
func verifyClientConfig(
configFile string,
strict bool,
unsafeFeatures *security.UnsafeFeatures,
) (validation.Warning, error) {
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(configFile, strict)
if err != nil {
return nil, err
}
return validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures)
}
var verifyCmd = &cobra.Command{
Use: "verify",
Short: "Verify that the configures is valid",
@@ -38,13 +50,8 @@ var verifyCmd = &cobra.Command{
return nil
}
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures)
warning, err := verifyClientConfig(cfgFile, strictConfigMode, unsafeFeatures)
if warning != nil {
fmt.Printf("WARNING: %v\n", warning)
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package sub
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/policy/security"
)
func TestVerifyClientConfigFeatureGates(t *testing.T) {
tests := []struct {
name string
content string
wantErr string
}{
{
name: "VirtualNet enabled",
content: `featureGates = { VirtualNet = true }
virtualNet.address = "100.86.0.4/24"
`,
},
{
name: "VirtualNet disabled",
content: `featureGates = { VirtualNet = false }
virtualNet.address = "100.86.0.4/24"
`,
wantErr: "VirtualNet feature is not enabled",
},
{
name: "unknown feature gate",
content: `featureGates = { UnknownFeature = true }`,
wantErr: "unrecognized feature gate: UnknownFeature",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
configFile := filepath.Join(t.TempDir(), "frpc.toml")
require.NoError(t, os.WriteFile(configFile, []byte(tc.content), 0o600))
warning, err := verifyClientConfig(configFile, true, security.NewUnsafeFeatures(nil))
require.NoError(t, warning)
if tc.wantErr == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tc.wantErr)
})
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+9 -7
View File
@@ -1,14 +1,16 @@
FROM node:22 AS web-builder
COPY web/package.json /web/package.json
COPY web/shared/ /web/shared/
COPY web/frpc/ /web/frpc/
WORKDIR /web
RUN npm install
WORKDIR /web/frpc
RUN npm run build
COPY web/package.json web/package-lock.json ./
COPY web/shared/package.json ./shared/package.json
COPY web/frpc/package.json ./frpc/package.json
COPY web/frps/package.json ./frps/package.json
RUN npm ci
COPY web/shared/ ./shared/
COPY web/frpc/ ./frpc/
RUN npm run build --workspace frpc
FROM golang:1.25 AS building
FROM golang:1.26 AS building
COPY . /building
COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist
+9 -7
View File
@@ -1,14 +1,16 @@
FROM node:22 AS web-builder
COPY web/package.json /web/package.json
COPY web/shared/ /web/shared/
COPY web/frps/ /web/frps/
WORKDIR /web
RUN npm install
WORKDIR /web/frps
RUN npm run build
COPY web/package.json web/package-lock.json ./
COPY web/shared/package.json ./shared/package.json
COPY web/frpc/package.json ./frpc/package.json
COPY web/frps/package.json ./frps/package.json
RUN npm ci
COPY web/shared/ ./shared/
COPY web/frps/ ./frps/
RUN npm run build --workspace frps
FROM golang:1.25 AS building
FROM golang:1.26 AS building
COPY . /building
COPY --from=web-builder /web/frps/dist /building/web/frps/dist
+9 -12
View File
@@ -1,19 +1,19 @@
module github.com/fatedier/frp
go 1.25.0
go 1.26.0
require (
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
github.com/coreos/go-oidc/v3 v3.18.0
github.com/fatedier/golib v0.8.1
github.com/fatedier/golib v0.8.2
github.com/fatedier/yamux v0.2.0
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.0
github.com/hashicorp/yamux v0.1.1
github.com/onsi/ginkgo/v2 v2.23.4
github.com/onsi/gomega v1.36.3
github.com/pelletier/go-toml/v2 v2.2.0
github.com/pires/go-proxyproto v0.7.0
github.com/pires/go-proxyproto v0.15.0
github.com/prometheus/client_golang v1.19.1
github.com/quic-go/quic-go v0.60.0
github.com/rodaine/table v1.2.0
@@ -25,8 +25,8 @@ 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.54.0
golang.org/x/net v0.56.0
golang.org/x/crypto v0.56.0
golang.org/x/net v0.57.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
@@ -39,7 +39,7 @@ require (
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -64,8 +64,8 @@ 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
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/tools v0.48.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
@@ -73,6 +73,3 @@ require (
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6
+16 -16
View File
@@ -1,6 +1,6 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
@@ -20,10 +20,10 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatedier/golib v0.8.1 h1:pHcIu0zAcZ6VTkO1dW/meelCGN5nem52DKCBY7cUvyA=
github.com/fatedier/golib v0.8.1/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
github.com/fatedier/golib v0.8.2 h1:02n2Dg7KJ7rR7p7n4/6hBUjaLQf2J7EiHYZQsgGTvww=
github.com/fatedier/golib v0.8.2/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
github.com/fatedier/yamux v0.2.0 h1:H+2A9iBVh7aJlEOc1Ws1FXWOaecBf2nRv9zpFMPUWg8=
github.com/fatedier/yamux v0.2.0/go.mod h1:d4FtRDrC9sHvRpiDL6J5EnfjLhqzZZplHe5yToSn2Ac=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
@@ -78,8 +78,8 @@ github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -151,8 +151,8 @@ 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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
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=
@@ -163,8 +163,8 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
@@ -186,8 +186,8 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -195,8 +195,8 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
+6 -8
View File
@@ -17,8 +17,6 @@ package legacy
import (
"strings"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/config/types"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
@@ -49,7 +47,7 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
out.Transport.ConnectServerLocalIP = conf.ConnectServerLocalIP
out.Transport.ProxyURL = conf.HTTPProxy
out.Transport.PoolCount = conf.PoolCount
out.Transport.TCPMux = lo.ToPtr(conf.TCPMux)
out.Transport.TCPMux = new(conf.TCPMux)
out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
out.Transport.Protocol = conf.Protocol
out.Transport.HeartbeatInterval = conf.HeartbeatInterval
@@ -57,8 +55,8 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
out.Transport.QUIC.KeepalivePeriod = conf.QUICKeepalivePeriod
out.Transport.QUIC.MaxIdleTimeout = conf.QUICMaxIdleTimeout
out.Transport.QUIC.MaxIncomingStreams = conf.QUICMaxIncomingStreams
out.Transport.TLS.Enable = lo.ToPtr(conf.TLSEnable)
out.Transport.TLS.DisableCustomTLSFirstByte = lo.ToPtr(conf.DisableCustomTLSFirstByte)
out.Transport.TLS.Enable = new(conf.TLSEnable)
out.Transport.TLS.DisableCustomTLSFirstByte = new(conf.DisableCustomTLSFirstByte)
out.Transport.TLS.CertFile = conf.TLSCertFile
out.Transport.TLS.KeyFile = conf.TLSKeyFile
out.Transport.TLS.TrustedCaFile = conf.TLSTrustedCaFile
@@ -77,7 +75,7 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
out.WebServer.PprofEnable = conf.PprofEnable
out.DNSServer = conf.DNSServer
out.LoginFailExit = lo.ToPtr(conf.LoginFailExit)
out.LoginFailExit = new(conf.LoginFailExit)
out.Start = conf.Start
out.UDPPacketSize = conf.UDPPacketSize
out.Metadatas = conf.Metas
@@ -134,14 +132,14 @@ func Convert_ServerCommonConf_To_v1(conf *ServerCommonConf) *v1.ServerConfig {
out.Log.MaxDays = conf.LogMaxDays
out.Log.DisablePrintColor = conf.DisableLogColor
out.DetailedErrorsToClient = lo.ToPtr(conf.DetailedErrorsToClient)
out.DetailedErrorsToClient = new(conf.DetailedErrorsToClient)
out.SubDomainHost = conf.SubDomainHost
out.Custom404Page = conf.Custom404Page
out.UserConnTimeout = conf.UserConnTimeout
out.UDPPacketSize = conf.UDPPacketSize
out.NatHoleAnalysisDataReserveHours = conf.NatHoleAnalysisDataReserveHours
out.Transport.TCPMux = lo.ToPtr(conf.TCPMux)
out.Transport.TCPMux = new(conf.TCPMux)
out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
out.Transport.TCPKeepAlive = conf.TCPKeepAlive
out.Transport.MaxPoolCount = conf.MaxPoolCount
+4 -4
View File
@@ -85,7 +85,7 @@ type ClientCommonConfig struct {
func (c *ClientCommonConfig) Complete() error {
c.ServerAddr = util.EmptyOr(c.ServerAddr, "0.0.0.0")
c.ServerPort = util.EmptyOr(c.ServerPort, 7000)
c.LoginFailExit = util.EmptyOr(c.LoginFailExit, lo.ToPtr(true))
c.LoginFailExit = util.EmptyOr(c.LoginFailExit, new(true))
c.NatHoleSTUNServer = util.EmptyOr(c.NatHoleSTUNServer, "stun.easyvoip.com:3478")
if err := c.Auth.Complete(); err != nil {
@@ -151,7 +151,7 @@ func (c *ClientTransportConfig) Complete() {
c.DialServerKeepAlive = util.EmptyOr(c.DialServerKeepAlive, 7200)
c.ProxyURL = util.EmptyOr(c.ProxyURL, os.Getenv("http_proxy"))
c.PoolCount = util.EmptyOr(c.PoolCount, 1)
c.TCPMux = util.EmptyOr(c.TCPMux, lo.ToPtr(true))
c.TCPMux = util.EmptyOr(c.TCPMux, new(true))
c.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30)
if lo.FromPtr(c.TCPMux) {
// If TCPMux is enabled, heartbeat of application layer is unnecessary because we can rely on heartbeat in tcpmux.
@@ -180,8 +180,8 @@ type TLSClientConfig struct {
}
func (c *TLSClientConfig) Complete() {
c.Enable = util.EmptyOr(c.Enable, lo.ToPtr(true))
c.DisableCustomTLSFirstByte = util.EmptyOr(c.DisableCustomTLSFirstByte, lo.ToPtr(true))
c.Enable = util.EmptyOr(c.Enable, new(true))
c.DisableCustomTLSFirstByte = util.EmptyOr(c.DisableCustomTLSFirstByte, new(true))
}
type AuthClientConfig struct {
+2 -4
View File
@@ -17,8 +17,6 @@ package v1
import (
"reflect"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -125,7 +123,7 @@ type HTTPS2HTTPPluginOptions struct {
}
func (o *HTTPS2HTTPPluginOptions) Complete() {
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, new(true))
}
func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
@@ -149,7 +147,7 @@ type HTTPS2HTTPSPluginOptions struct {
}
func (o *HTTPS2HTTPSPluginOptions) Complete() {
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, new(true))
}
func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
+3 -3
View File
@@ -118,7 +118,7 @@ func (c *ServerConfig) Complete() error {
}
c.VhostHTTPTimeout = util.EmptyOr(c.VhostHTTPTimeout, 60)
c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, lo.ToPtr(true))
c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, new(true))
c.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10)
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
c.NatHoleAnalysisDataReserveHours = util.EmptyOr(c.NatHoleAnalysisDataReserveHours, 7*24)
@@ -167,7 +167,7 @@ type ServerTransportConfig struct {
// If negative, keep-alive probes are disabled.
TCPKeepAlive int64 `json:"tcpKeepalive,omitempty"`
// MaxPoolCount specifies the maximum pool size for each proxy. By default,
// this value is 5.
// this value is 5. Negative values are invalid.
MaxPoolCount int64 `json:"maxPoolCount,omitempty"`
// HeartBeatTimeout specifies the maximum time to wait for a heartbeat
// before terminating the connection. It is not recommended to change this
@@ -180,7 +180,7 @@ type ServerTransportConfig struct {
}
func (c *ServerTransportConfig) Complete() {
c.TCPMux = util.EmptyOr(c.TCPMux, lo.ToPtr(true))
c.TCPMux = util.EmptyOr(c.TCPMux, new(true))
c.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30)
c.TCPKeepAlive = util.EmptyOr(c.TCPKeepAlive, 7200)
c.MaxPoolCount = util.EmptyOr(c.MaxPoolCount, 5)
+38 -1
View File
@@ -51,14 +51,51 @@ func (v *ConfigValidator) ValidateClientCommonConfig(c *v1.ClientCommonConfig) (
}
func validateFeatureGates(c *v1.ClientCommonConfig) (Warning, error) {
gates := featuregate.NewFeatureGate()
if err := gates.SetFromMap(c.FeatureGates); err != nil {
return nil, err
}
if c.VirtualNet.Address != "" {
if !featuregate.Enabled(featuregate.VirtualNet) {
if !gates.Enabled(featuregate.VirtualNet) {
return nil, fmt.Errorf("VirtualNet feature is not enabled; enable it by setting the appropriate feature gate flag")
}
}
return nil, nil
}
// ClientConfigRequirements describes runtime capabilities needed by a client configuration.
type ClientConfigRequirements struct {
VirtualNet bool
}
// GetClientConfigRequirements returns the runtime capabilities needed by a client configuration.
func GetClientConfigRequirements(
common *v1.ClientCommonConfig,
proxyCfgs []v1.ProxyConfigurer,
visitorCfgs []v1.VisitorConfigurer,
) ClientConfigRequirements {
requirements := ClientConfigRequirements{}
if common != nil && common.VirtualNet.Address != "" {
requirements.VirtualNet = true
}
for _, cfg := range proxyCfgs {
if cfg.GetBaseConfig().Plugin.Type == v1.PluginVirtualNet {
requirements.VirtualNet = true
break
}
}
if !requirements.VirtualNet {
for _, cfg := range visitorCfgs {
if cfg.GetBaseConfig().Plugin.Type == v1.VisitorPluginVirtualNet {
requirements.VirtualNet = true
break
}
}
}
return requirements
}
func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, error) {
var errs error
if !slices.Contains(SupportedAuthMethods, c.Method) {
+140
View File
@@ -0,0 +1,140 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package validation
import (
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/policy/featuregate"
"github.com/fatedier/frp/pkg/policy/security"
)
func validateClientFeatureGates(t *testing.T, gates map[string]bool, virtualNetAddress string) error {
t.Helper()
cfg := &v1.ClientCommonConfig{
FeatureGates: gates,
VirtualNet: v1.VirtualNetConfig{
Address: virtualNetAddress,
},
}
require.NoError(t, cfg.Complete())
_, err := NewConfigValidator(security.NewUnsafeFeatures(nil)).ValidateClientCommonConfig(cfg)
return err
}
func TestValidateClientFeatureGates(t *testing.T) {
tests := []struct {
name string
featureGates map[string]bool
virtualNetAddress string
wantErr string
}{
{
name: "VirtualNet enabled",
featureGates: map[string]bool{"VirtualNet": true},
virtualNetAddress: "100.86.0.4/24",
},
{
name: "VirtualNet explicitly disabled",
featureGates: map[string]bool{"VirtualNet": false},
virtualNetAddress: "100.86.0.4/24",
wantErr: "VirtualNet feature is not enabled",
},
{
name: "VirtualNet disabled by default",
virtualNetAddress: "100.86.0.4/24",
wantErr: "VirtualNet feature is not enabled",
},
{
name: "unknown feature gate",
featureGates: map[string]bool{"UnknownFeature": true},
wantErr: "unrecognized feature gate: UnknownFeature",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateClientFeatureGates(t, tc.featureGates, tc.virtualNetAddress)
if tc.wantErr == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tc.wantErr)
})
}
}
func TestGetClientConfigRequirements(t *testing.T) {
virtualNetProxy := &v1.STCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
ProxyBackend: v1.ProxyBackend{
Plugin: v1.TypedClientPluginOptions{Type: v1.PluginVirtualNet},
},
},
}
virtualNetVisitor := &v1.STCPVisitorConfig{
VisitorBaseConfig: v1.VisitorBaseConfig{
Plugin: v1.TypedVisitorPluginOptions{Type: v1.VisitorPluginVirtualNet},
},
}
tests := []struct {
name string
common *v1.ClientCommonConfig
proxies []v1.ProxyConfigurer
visitors []v1.VisitorConfigurer
wantVNet bool
}{
{name: "no requirements"},
{
name: "common VirtualNet address",
common: &v1.ClientCommonConfig{VirtualNet: v1.VirtualNetConfig{Address: "100.86.0.4/24"}},
wantVNet: true,
},
{name: "VirtualNet proxy", proxies: []v1.ProxyConfigurer{virtualNetProxy}, wantVNet: true},
{name: "VirtualNet visitor", visitors: []v1.VisitorConfigurer{virtualNetVisitor}, wantVNet: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := GetClientConfigRequirements(tc.common, tc.proxies, tc.visitors)
require.Equal(t, tc.wantVNet, got.VirtualNet)
})
}
}
func TestValidateClientFeatureGatesAreConfigScoped(t *testing.T) {
defaultGatesBefore := featuregate.DefaultFeatureGates.String()
require.NoError(t, validateClientFeatureGates(
t,
map[string]bool{"VirtualNet": true},
"100.86.0.4/24",
))
require.Equal(t, defaultGatesBefore, featuregate.DefaultFeatureGates.String())
err := validateClientFeatureGates(
t,
map[string]bool{"VirtualNet": false},
"100.86.0.4/24",
)
require.ErrorContains(t, err, "VirtualNet feature is not enabled")
require.Equal(t, defaultGatesBefore, featuregate.DefaultFeatureGates.String())
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package validation
import (
"fmt"
"unicode"
"unicode/utf8"
)
const (
// MaxRunIDLength is the maximum number of bytes accepted for a control run ID.
MaxRunIDLength = 64
)
func validateIdentifier(value, kind string, maxLength int) error {
if value == "" {
return fmt.Errorf("%s cannot be empty", kind)
}
if len(value) > maxLength {
return fmt.Errorf("%s is too long: length %d exceeds maximum %d", kind, len(value), maxLength)
}
if !utf8.ValidString(value) {
return fmt.Errorf("%s must be valid UTF-8", kind)
}
for _, r := range value {
if !unicode.IsPrint(r) {
return fmt.Errorf("%s contains non-printable character", kind)
}
}
return nil
}
func ValidateRunID(runID string) error {
return validateIdentifier(runID, "run id", MaxRunIDLength)
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package validation
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestValidateIdentifiers(t *testing.T) {
tests := []struct {
name string
validate func(string) error
value string
wantError string
}{
{name: "run id accepts printable values", validate: ValidateRunID, value: "run-%1000s-中文"},
{name: "run id rejects empty", validate: ValidateRunID, wantError: "cannot be empty"},
{name: "run id rejects control character", validate: ValidateRunID, value: "run\nforged", wantError: "non-printable"},
{name: "run id rejects invalid utf8", validate: ValidateRunID, value: string([]byte{0xff}), wantError: "valid UTF-8"},
{name: "run id rejects excessive length", validate: ValidateRunID, value: strings.Repeat("a", MaxRunIDLength+1), wantError: "too long"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.validate(tt.value)
if tt.wantError == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tt.wantError)
})
}
}
+4 -2
View File
@@ -79,9 +79,11 @@ func validateDomainConfigForClient(c *v1.DomainConfig) error {
}
func validateDomainConfigForServer(c *v1.DomainConfig, s *v1.ServerConfig) error {
subDomainHost := strings.ToLower(s.SubDomainHost)
for _, domain := range c.CustomDomains {
if s.SubDomainHost != "" && len(strings.Split(s.SubDomainHost, ".")) < len(strings.Split(domain, ".")) {
if strings.HasSuffix(domain, "."+s.SubDomainHost) {
canonicalDomain := strings.ToLower(domain)
if subDomainHost != "" && len(strings.Split(subDomainHost, ".")) < len(strings.Split(canonicalDomain, ".")) {
if strings.HasSuffix(canonicalDomain, "."+subDomainHost) {
return fmt.Errorf("custom domain [%s] should not belong to subdomain host [%s]", domain, s.SubDomainHost)
}
}
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package validation
import (
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestValidateDomainConfigForServerRejectsSubdomainHostCaseInsensitively(t *testing.T) {
tests := []struct {
name string
subDomainHost string
customDomain string
wantErr bool
}{
{
name: "lowercase subdomain",
subDomainHost: "frp.example.com",
customDomain: "victim.frp.example.com",
wantErr: true,
},
{
name: "mixed case custom domain",
subDomainHost: "frp.example.com",
customDomain: "victim.FRP.example.com",
wantErr: true,
},
{
name: "mixed case wildcard domain",
subDomainHost: "frp.example.com",
customDomain: "*.FRP.example.com",
wantErr: true,
},
{
name: "mixed case subdomain host",
subDomainHost: "FRP.Example.Com",
customDomain: "victim.frp.example.com",
wantErr: true,
},
{
name: "external domain",
subDomainHost: "frp.example.com",
customDomain: "victim.example.net",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateDomainConfigForServer(
&v1.DomainConfig{CustomDomains: []string{tt.customDomain}},
&v1.ServerConfig{SubDomainHost: tt.subDomainHost},
)
if tt.wantErr {
require.ErrorContains(t, err, "should not belong to subdomain host")
return
}
require.NoError(t, err)
})
}
}
+3
View File
@@ -51,6 +51,9 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err
errs = AppendError(errs, ValidatePort(c.VhostHTTPPort, "vhostHTTPPort"))
errs = AppendError(errs, ValidatePort(c.VhostHTTPSPort, "vhostHTTPSPort"))
errs = AppendError(errs, ValidatePort(c.TCPMuxHTTPConnectPort, "tcpMuxHTTPConnectPort"))
if c.Transport.MaxPoolCount < 0 {
errs = AppendError(errs, fmt.Errorf("invalid transport.maxPoolCount, must be non-negative"))
}
for _, p := range c.HTTPPlugins {
if !lo.Every(SupportedHTTPPluginOps, p.Ops) {
+51
View File
@@ -0,0 +1,51 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package validation
import (
"math"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestValidateServerConfigMaxPoolCount(t *testing.T) {
for _, tc := range []struct {
name string
maxPoolCount int64
wantErr bool
}{
{name: "negative", maxPoolCount: -1, wantErr: true},
{name: "zero", maxPoolCount: 0},
{name: "positive", maxPoolCount: 5},
{name: "maximum int64", maxPoolCount: math.MaxInt64},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := validServerConfigWithAuth(v1.AuthServerConfig{Method: v1.AuthMethodToken})
cfg.Transport.MaxPoolCount = tc.maxPoolCount
require.NoError(t, cfg.Complete())
_, err := NewConfigValidator(nil).ValidateServerConfig(cfg)
if tc.wantErr {
require.ErrorContains(t, err, "invalid transport.maxPoolCount")
require.ErrorContains(t, err, "must be non-negative")
return
}
require.NoError(t, err)
})
}
}
+56 -5
View File
@@ -19,6 +19,8 @@ import (
"io"
"net"
"reflect"
"sync"
"time"
"github.com/fatedier/frp/pkg/proto/wire"
)
@@ -111,14 +113,22 @@ type Dispatcher struct {
sendCh chan Message
doneCh chan struct{}
stopCh chan struct{}
stopOnce sync.Once
msgHandlers map[reflect.Type]func(Message)
}
// NewDispatcher uses SetReadDeadline, when supported, and Close, when rw
// implements io.Closer, to interrupt ReadMsg after a write failure. Without a
// working read deadline, Close or the caller must arrange for ReadMsg to return.
// These methods must return in bounded time and must not wait for Done: Done
// closes only after ReadMsg, any synchronous handler, and stop have returned.
func NewDispatcher(rw ReadWriter) *Dispatcher {
return &Dispatcher{
rw: rw,
sendCh: make(chan Message, 100),
doneCh: make(chan struct{}),
stopCh: make(chan struct{}),
msgHandlers: make(map[reflect.Type]func(Message)),
}
}
@@ -129,37 +139,78 @@ func (d *Dispatcher) Run() {
go d.readLoop()
}
// stop rejects new sends and interrupts ReadMsg when supported by rw. Done remains
// owned by readLoop so the control owner cannot clean up while a handler is running.
func (d *Dispatcher) stop() {
d.stopOnce.Do(func() {
close(d.stopCh)
if conn, ok := d.rw.(interface{ SetReadDeadline(time.Time) error }); ok {
_ = conn.SetReadDeadline(time.Now())
}
if closer, ok := d.rw.(io.Closer); ok {
_ = closer.Close()
}
})
}
func (d *Dispatcher) sendLoop() {
for {
select {
case <-d.doneCh:
case <-d.stopCh:
return
case m := <-d.sendCh:
_ = d.rw.WriteMsg(m)
if err := d.rw.WriteMsg(m); err != nil {
d.stop()
return
}
}
}
}
func (d *Dispatcher) readLoop() {
defer close(d.doneCh)
defer d.stop()
for {
m, err := d.rw.ReadMsg()
if err != nil {
close(d.doneCh)
return
}
select {
case <-d.stopCh:
return
default:
}
if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok {
handler(m)
}
}
}
// Send queues m for writing. A nil error does not guarantee delivery.
func (d *Dispatcher) Send(m Message) error {
select {
case <-d.doneCh:
case <-d.stopCh:
return io.EOF
default:
}
return d.enqueue(m)
}
func (d *Dispatcher) enqueue(m Message) error {
select {
case <-d.stopCh:
return io.EOF
case d.sendCh <- m:
return nil
// Both cases can be ready if shutdown races with enqueueing. Do not
// report success when shutdown is already observable after the send.
select {
case <-d.stopCh:
return io.EOF
default:
return nil
}
}
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package msg
import (
"bytes"
"errors"
"io"
"testing"
)
// Preserve the original exported constructor function type.
var _ func(ReadWriter) *Dispatcher = NewDispatcher
func TestDispatcherEnqueueRejectsStoppedDispatcher(t *testing.T) {
c := newRegressionControl(t)
d := NewDispatcher(c)
d.stop()
// Model shutdown after Send's initial check: the stop and buffered send
// cases are both ready. A send selected here must still return EOF.
if err := d.enqueue(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
t.Fatalf("enqueue racing with shutdown: %v, want EOF", err)
}
// This single call checks that a stopped dispatcher's enqueue returns EOF; it
// does not guarantee exercising the send case and post-enqueue check on every run.
if len(d.sendCh) > 0 {
t.Log("selected the send case; post-enqueue check returned EOF")
}
}
func TestDispatcherAcceptsReadWriterWithoutClose(t *testing.T) {
t.Run("codec factory", func(t *testing.T) {
d := NewDispatcher(NewV1ReadWriter(&bytes.Buffer{}))
d.Run()
regressionAwait(t, d.Done(), "non-closable codec EOF")
})
t.Run("write failure", func(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
// Hide Close to model an existing caller implementing only ReadWriter.
d := NewDispatcher(struct{ ReadWriter }{c})
d.Run()
t.Cleanup(func() {
c.transport.close()
regressionAwait(t, d.Done(), "externally interrupted reader")
})
c.transport.arm()
if err := d.Send(&ReqWorkConn{}); err != nil && !errors.Is(err, io.EOF) {
t.Fatal(err)
}
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatalf("injected write failure: %v", err)
}
regressionAwait(t, d.stopCh, "write failure stops sends")
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
t.Fatalf("Send after write failure: %v, want EOF", err)
}
if c.closeCalls.Load() != 0 {
t.Fatal("dispatcher closed a connection hidden by ReadWriter")
}
select {
case <-d.Done():
t.Fatal("Done closed before the non-closable reader returned")
default:
}
})
}
+383
View File
@@ -0,0 +1,383 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package msg
import (
"bytes"
"errors"
"io"
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"time"
libcrypto "github.com/fatedier/golib/crypto"
)
var (
errRegressionWriteTimeout = errors.New("injected recoverable connection write timeout")
regressionKey = []byte("isolated-public-test-key")
)
// regressionTransport fails exactly one Write, then accepts later writes. This
// models an individual mux stream write timing out while its session survives.
type regressionTransport struct {
mu sync.Mutex
buffer bytes.Buffer
calls int
failures int
failNext bool
closed chan struct{}
once sync.Once
}
func (w *regressionTransport) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.calls++
if w.failNext {
w.failNext = false
w.failures++
return 0, errRegressionWriteTimeout
}
select {
case <-w.closed:
return 0, io.ErrClosedPipe
default:
}
return w.buffer.Write(p)
}
func (w *regressionTransport) arm() {
w.mu.Lock()
w.failNext = true
w.mu.Unlock()
}
func (w *regressionTransport) snapshot() (calls, failures, size int) {
w.mu.Lock()
defer w.mu.Unlock()
return w.calls, w.failures, w.buffer.Len()
}
func (w *regressionTransport) close() {
w.once.Do(func() { close(w.closed) })
}
// regressionControl uses the production FRP message encoder and production
// golib crypto.Writer. Only the final transport and incoming messages are fake.
type regressionControl struct {
w *libcrypto.Writer
transport *regressionTransport
writes chan error
incoming chan Message
beforeRead func() error
beforeWrite func()
closeCalls atomic.Int32
}
func newRegressionControl(t *testing.T) *regressionControl {
t.Helper()
transport := &regressionTransport{closed: make(chan struct{})}
w, err := libcrypto.NewWriter(transport, regressionKey)
if err != nil {
t.Fatal(err)
}
return &regressionControl{
w: w, transport: transport, writes: make(chan error, 100), incoming: make(chan Message, 1),
}
}
func (c *regressionControl) ReadMsg() (Message, error) {
if c.beforeRead != nil {
if err := c.beforeRead(); err != nil {
return nil, err
}
}
select {
case m := <-c.incoming:
return m, nil
case <-c.transport.closed:
return nil, io.EOF
}
}
func (c *regressionControl) ReadMsgInto(Message) error {
_, err := c.ReadMsg()
return err
}
func (c *regressionControl) WriteMsg(m Message) error {
if c.beforeWrite != nil {
c.beforeWrite()
}
err := WriteMsg(c.w, m)
c.writes <- err
return err
}
func (c *regressionControl) Close() error {
c.closeCalls.Add(1)
c.transport.close()
return nil
}
func regressionReceiveWrite(t *testing.T, c *regressionControl) error {
t.Helper()
select {
case err := <-c.writes:
return err
case <-time.After(time.Second):
t.Fatal("dispatcher did not attempt WriteMsg")
return nil
}
}
func regressionAwait(t *testing.T, ch <-chan struct{}, what string) {
t.Helper()
select {
case <-ch:
case <-time.After(time.Second):
t.Fatalf("timed out waiting for %s", what)
}
}
func regressionWarm(t *testing.T, c *regressionControl) {
t.Helper()
if err := c.WriteMsg(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
<-c.writes
c.transport.mu.Lock()
raw := append([]byte(nil), c.transport.buffer.Bytes()...)
c.transport.mu.Unlock()
m, err := ReadMsg(libcrypto.NewReader(bytes.NewReader(raw), regressionKey))
if err != nil {
t.Fatalf("encrypted control roundtrip: %v", err)
}
if _, ok := m.(*ReqWorkConn); !ok {
t.Fatalf("decoded %T", m)
}
}
func TestControlCryptoWriterKeepsErrorAfterTransportRecovery(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
defer c.transport.close()
c.transport.arm()
if err := c.WriteMsg(&ReqWorkConn{}); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatalf("want timeout, got %v", err)
}
<-c.writes
beforeCalls, failures, beforeBytes := c.transport.snapshot()
for range 5 {
if err := c.WriteMsg(&ReqWorkConn{}); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatalf("sticky error missing: %v", err)
}
<-c.writes
}
afterCalls, _, afterBytes := c.transport.snapshot()
if beforeCalls != afterCalls || beforeBytes != afterBytes || failures != 1 {
t.Fatalf("unexpected retry: calls %d -> %d, failures %d, bytes %d -> %d",
beforeCalls, afterCalls, failures, beforeBytes, afterBytes)
}
if n, err := c.transport.Write([]byte("healthy underlying transport")); err != nil || n == 0 {
t.Fatalf("underlying transport did not recover: %v", err)
}
t.Log("one recoverable write error permanently invalidates this crypto writer; recovered underlying transport is never retried")
}
func TestDispatcherWriteErrorClosesControlAndFreshSessionWorks(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
defer c.transport.close()
d := NewDispatcher(c)
d.Run()
c.transport.arm()
if err := d.Send(&ReqWorkConn{}); err != nil && !errors.Is(err, io.EOF) {
t.Fatal(err)
}
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatal(err)
}
regressionAwait(t, c.transport.closed, "failed control connection close")
regressionAwait(t, d.Done(), "failed dispatcher completion")
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
t.Fatalf("Send after completed shutdown: %v", err)
}
if c.closeCalls.Load() != 1 {
t.Fatalf("connection closed %d times", c.closeCalls.Load())
}
fresh := newRegressionControl(t)
defer fresh.transport.close()
d2 := NewDispatcher(fresh)
d2.Run()
for range 5 {
if err := d2.Send(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
if err := regressionReceiveWrite(t, fresh); err != nil {
t.Fatalf("fresh session write failed: %v", err)
}
}
fresh.transport.mu.Lock()
raw := append([]byte(nil), fresh.transport.buffer.Bytes()...)
fresh.transport.mu.Unlock()
r := libcrypto.NewReader(bytes.NewReader(raw), regressionKey)
for i := range 5 {
m, err := ReadMsg(r)
if err != nil {
t.Fatalf("new stream decode %d: %v", i, err)
}
if _, ok := m.(*ReqWorkConn); !ok {
t.Fatalf("decoded %T", m)
}
}
fresh.transport.close()
regressionAwait(t, d2.Done(), "new-session cleanup")
t.Log("write error closes failed control; new session sends and decrypts five valid ReqWorkConn messages")
}
func TestDispatcherWriteErrorWaitsForSynchronousHandler(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
defer c.transport.close()
d := NewDispatcher(c)
entered, release := make(chan struct{}), make(chan struct{})
var releaseOnce sync.Once
defer releaseOnce.Do(func() { close(release) })
d.RegisterHandler(&ReqWorkConn{}, func(Message) {
close(entered)
<-release
})
d.Run()
c.incoming <- &ReqWorkConn{}
regressionAwait(t, entered, "synchronous handler entry")
c.transport.arm()
if err := d.Send(&ReqWorkConn{}); err != nil && !errors.Is(err, io.EOF) {
t.Fatal(err)
}
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatal(err)
}
regressionAwait(t, c.transport.closed, "write failure closes control during handler")
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
t.Fatalf("Send after stop while handler is active: %v", err)
}
// Let the writer finish stopping while the handler remains blocked.
synctest.Wait()
select {
case <-d.Done():
t.Fatal("Done closed before synchronous handler returned; owner cleanup could race proxy registration")
default:
}
releaseOnce.Do(func() { close(release) })
regressionAwait(t, d.Done(), "completion after handler returned")
})
}
func TestDispatcherWriteErrorUnblocksSenderWithFullQueue(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
defer c.transport.close()
entered, release := make(chan struct{}), make(chan struct{})
var releaseOnce sync.Once
defer releaseOnce.Do(func() { close(release) })
c.beforeWrite = func() { close(entered); <-release }
c.transport.arm()
d := NewDispatcher(c)
if err := d.Send(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
d.Run()
regressionAwait(t, entered, "blocked transport write")
for i := 0; i < cap(d.sendCh); i++ {
if err := d.Send(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
}
sent := make(chan error, 1)
go func() { sent <- d.Send(&ReqWorkConn{}) }()
// Wait until the sender has reached its full-queue channel wait.
synctest.Wait()
select {
case err := <-sent:
t.Fatalf("Send did not block on full queue: %v", err)
default:
}
releaseOnce.Do(func() { close(release) })
select {
case err := <-sent:
if !errors.Is(err, io.EOF) {
t.Fatalf("blocked Send after write failure: %v", err)
}
case <-time.After(time.Second):
t.Fatal("write failure did not unblock full-queue sender")
}
regressionAwait(t, d.Done(), "full-queue dispatcher cleanup")
})
}
func TestDispatcherConcurrentReadWriteFailureClosesOnce(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
defer c.transport.close()
readEntered, writeEntered := make(chan struct{}), make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
defer releaseOnce.Do(func() { close(release) })
c.beforeRead = func() error {
close(readEntered)
<-release
return io.EOF
}
c.beforeWrite = func() {
close(writeEntered)
<-release
}
d := NewDispatcher(c)
c.transport.arm()
if err := d.Send(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
readExited, writeExited := make(chan struct{}), make(chan struct{})
go func() {
defer close(readExited)
d.readLoop()
}()
go func() {
defer close(writeExited)
d.sendLoop()
}()
regressionAwait(t, readEntered, "read path before injected failure")
regressionAwait(t, writeEntered, "write path before injected failure")
releaseOnce.Do(func() { close(release) })
regressionAwait(t, readExited, "failed read loop exit")
regressionAwait(t, writeExited, "failed write loop exit")
regressionAwait(t, d.Done(), "concurrent read/write shutdown")
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatalf("concurrent WriteMsg failure: %v", err)
}
if _, failures, _ := c.transport.snapshot(); failures != 1 {
t.Fatalf("injected transport failed %d times", failures)
}
if c.closeCalls.Load() != 1 {
t.Fatalf("connection closed %d times", c.closeCalls.Load())
}
}
+225
View File
@@ -0,0 +1,225 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package msg
import (
"errors"
"io"
"net"
"sync"
"sync/atomic"
"testing"
"time"
fmux "github.com/fatedier/yamux"
"github.com/fatedier/frp/pkg/proto/wire"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
// Delay one real TCP write without injecting an error. The stream's write
// failure must come from yamux's ConnectionWriteTimeout.
type delayedTCPConn struct {
net.Conn
armed atomic.Bool
entered chan struct{}
release chan struct{}
}
func (c *delayedTCPConn) Write(p []byte) (int, error) {
if c.armed.Swap(false) {
close(c.entered)
<-c.release
}
return c.Conn.Write(p)
}
type observedYamuxConn struct {
net.Conn
writes chan error
readEntered chan struct{}
readOnce sync.Once
closed chan struct{}
closeCalls atomic.Int32
}
func (c *observedYamuxConn) Read(p []byte) (int, error) {
c.readOnce.Do(func() { close(c.readEntered) })
return c.Conn.Read(p)
}
func (c *observedYamuxConn) Write(p []byte) (int, error) {
n, err := c.Conn.Write(p)
c.writes <- err
return n, err
}
func (c *observedYamuxConn) Close() error {
c.closeCalls.Add(1)
err := c.Conn.Close()
close(c.closed)
return err
}
func newLoopbackYamuxPair(t *testing.T) (*fmux.Session, *fmux.Session, *delayedTCPConn) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
clientTCP, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { clientTCP.Close() })
serverTCP, err := listener.Accept()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { serverTCP.Close() })
gate := &delayedTCPConn{Conn: clientTCP, entered: make(chan struct{}), release: make(chan struct{})}
config := fmux.DefaultConfig()
config.ConnectionWriteTimeout = 80 * time.Millisecond
config.LogOutput = io.Discard
client, err := fmux.Client(gate, config)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { client.Close() })
server, err := fmux.Server(serverTCP, config)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { server.Close() })
return client, server, gate
}
func loopbackCryptoConn(t *testing.T, conn net.Conn, protocol string, role netpkg.AEADCryptoRole) *Conn {
t.Helper()
var rw io.ReadWriter
var err error
if protocol == wire.ProtocolV2 {
rw, err = netpkg.NewAEADCryptoReadWriter(
conn, regressionKey, role, wire.AEADAlgorithmAES256GCM, []byte("isolated-test-transcript"))
} else {
rw, err = netpkg.NewCryptoReadWriter(conn, regressionKey)
}
if err != nil {
t.Fatal(err)
}
return NewConn(conn, NewReadWriter(rw, protocol))
}
func loopbackControlPair(t *testing.T, client, server *fmux.Session, protocol string) (*Conn, *Conn, *observedYamuxConn) {
t.Helper()
local, err := client.OpenStream()
if err != nil {
t.Fatal(err)
}
remote, err := server.AcceptStream()
if err != nil {
t.Fatal(err)
}
observed := &observedYamuxConn{
Conn: local, writes: make(chan error, 16), readEntered: make(chan struct{}), closed: make(chan struct{}),
}
return loopbackCryptoConn(t, observed, protocol, netpkg.AEADCryptoRoleClient),
loopbackCryptoConn(t, remote, protocol, netpkg.AEADCryptoRoleServer), observed
}
func loopbackReadReq(t *testing.T, conn *Conn) {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatal(err)
}
defer conn.SetReadDeadline(time.Time{})
m, err := conn.ReadMsg()
if err != nil {
t.Fatal(err)
}
if _, ok := m.(*ReqWorkConn); !ok {
t.Fatalf("decoded %T, want ReqWorkConn", m)
}
}
func TestDispatcherYamuxWriteTimeoutInterruptsRead(t *testing.T) {
for _, protocol := range []string{wire.ProtocolV1, wire.ProtocolV2} {
t.Run(protocol, func(t *testing.T) {
client, server, gate := newLoopbackYamuxPair(t)
var releaseOnce sync.Once
defer releaseOnce.Do(func() { close(gate.release) })
control, peer, observed := loopbackControlPair(t, client, server, protocol)
if err := control.WriteMsg(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
loopbackReadReq(t, peer)
// Warm-up has completed, including all crypto header writes.
for len(observed.writes) > 0 {
<-observed.writes
}
d := NewDispatcher(control)
readExited := make(chan struct{})
go func() {
defer close(readExited)
d.readLoop()
}()
regressionAwait(t, observed.readEntered, "control reader entry")
gate.armed.Store(true)
// Queue before starting the writer so this Send cannot race its failure.
if err := d.Send(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
go d.sendLoop()
regressionAwait(t, gate.entered, "TCP write delay barrier")
select {
case err := <-observed.writes:
if !errors.Is(err, fmux.ErrConnectionWriteTimeout) {
t.Fatalf("stream write error: %v, want yamux connection write timeout", err)
}
t.Logf("production yamux write failed: %v", err)
case <-time.After(time.Second):
t.Fatal("yamux write did not time out")
}
regressionAwait(t, d.stopCh, "write failure stops sends")
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
t.Fatalf("Send after stop: %v, want EOF", err)
}
releaseOnce.Do(func() { close(gate.release) })
regressionAwait(t, observed.closed, "control Close returns")
// peer never closes its write half: local yamux Close alone cannot
// interrupt Read. Neither a peer FIN nor session cleanup may help.
regressionAwait(t, d.Done(), "Done without peer FIN")
regressionAwait(t, readExited, "control readLoop exit without peer FIN")
if observed.closeCalls.Load() != 1 {
t.Fatalf("control closed %d times, want once", observed.closeCalls.Load())
}
if client.IsClosed() || server.IsClosed() {
t.Fatal("yamux session unexpectedly closed")
}
fresh, freshPeer, freshObserved := loopbackControlPair(t, client, server, protocol)
for range 5 {
if err := fresh.WriteMsg(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
loopbackReadReq(t, freshPeer)
for len(freshObserved.writes) > 0 {
<-freshObserved.writes
}
}
t.Log("Done/readLoop exited without peer FIN; Close once; same yamux session decrypted five fresh messages")
})
}
}
+199
View File
@@ -0,0 +1,199 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package msg
import (
"bytes"
"fmt"
"net"
"runtime"
"testing"
"github.com/fatedier/frp/pkg/proto/wire"
)
type udpBenchmarkCase struct {
name string
packet *UDPPacket
}
var (
udpBenchmarkBytesSink []byte
udpBenchmarkMessageSink Message
)
func udpBenchmarkCases(payloadSize int) []udpBenchmarkCase {
content := bytes.Repeat([]byte{0x5a}, payloadSize)
return []udpBenchmarkCase{
{
name: "ipv4-remote",
packet: &UDPPacket{
Content: content,
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345},
},
},
{
name: "ipv4-local-remote",
packet: &UDPPacket{
Content: content,
LocalAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.2"), Port: 23456},
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345},
},
},
{
name: "ipv6-remote",
packet: &UDPPacket{
Content: content,
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345},
},
},
{
name: "ipv6-local-remote",
packet: &UDPPacket{
Content: content,
LocalAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::2"), Port: 23456, Zone: "bench0"},
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345, Zone: "bench1"},
},
},
}
}
func TestUDPPacketV2FrameSizes(t *testing.T) {
t.Logf("environment go=%s goos=%s goarch=%s gomaxprocs=%d", runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.GOMAXPROCS(0))
for _, payloadSize := range []int{64, 512, 1200, 1472} {
for _, tc := range udpBenchmarkCases(payloadSize) {
jsonFrame := udpBenchmarkWireBytes(t, tc.packet, "")
binaryFrame := udpBenchmarkWireBytes(t, tc.packet, wire.UDPPacketCodecBinary)
saving := 100 * float64(len(jsonFrame)-len(binaryFrame)) / float64(len(jsonFrame))
t.Logf("frame payload=%d case=%s json_bytes=%d binary_bytes=%d binary_saving_pct=%.2f", payloadSize, tc.name, len(jsonFrame), len(binaryFrame), saving)
}
}
}
func udpBenchmarkWireBytes(t testing.TB, packet *UDPPacket, codec string) []byte {
t.Helper()
var buf bytes.Buffer
rw, err := NewUDPPacketReadWriter(&buf, wire.ProtocolV2, codec)
if err != nil {
t.Fatalf("create UDP read writer: %v", err)
}
if err := rw.WriteMsg(packet); err != nil {
t.Fatalf("write UDP packet: %v", err)
}
return append([]byte(nil), buf.Bytes()...)
}
type udpBenchmarkReadWriter struct {
reader bytes.Reader
}
func (rw *udpBenchmarkReadWriter) Read(p []byte) (int, error) {
return rw.reader.Read(p)
}
func (rw *udpBenchmarkReadWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func (rw *udpBenchmarkReadWriter) Reset(p []byte) {
rw.reader.Reset(p)
}
func udpBenchmarkValidatePacket(b testing.TB, got, want *UDPPacket) {
b.Helper()
if !bytes.Equal(got.Content, want.Content) || !udpBenchmarkUDPAddrEqual(got.LocalAddr, want.LocalAddr) ||
!udpBenchmarkUDPAddrEqual(got.RemoteAddr, want.RemoteAddr) {
b.Fatalf("decoded packet mismatch: got %+v, want %+v", got, want)
}
}
func udpBenchmarkUDPAddrEqual(got, want *net.UDPAddr) bool {
if got == nil || want == nil {
return got == want
}
return got.IP.Equal(want.IP) && got.Port == want.Port && got.Zone == want.Zone
}
func BenchmarkUDPPacketV2CodecWrite(b *testing.B) {
for _, payloadSize := range []int{64, 512, 1200, 1472} {
for _, tc := range udpBenchmarkCases(payloadSize) {
for _, codec := range []struct {
name string
value string
}{
{name: "json", value: ""},
{name: "binary", value: wire.UDPPacketCodecBinary},
} {
b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, codec.name), func(b *testing.B) {
var buf bytes.Buffer
rw, err := NewUDPPacketReadWriter(&buf, wire.ProtocolV2, codec.value)
if err != nil {
b.Fatal(err)
}
expected := udpBenchmarkWireBytes(b, tc.packet, codec.value)
b.SetBytes(int64(len(expected)))
for b.Loop() {
buf.Reset()
if err := rw.WriteMsg(tc.packet); err != nil {
b.Fatal(err)
}
}
if !bytes.Equal(buf.Bytes(), expected) {
b.Fatalf("encoded packet mismatch: got %d bytes, want %d", buf.Len(), len(expected))
}
udpBenchmarkBytesSink = buf.Bytes()
})
}
}
}
}
func BenchmarkUDPPacketV2CodecRead(b *testing.B) {
for _, payloadSize := range []int{64, 512, 1200, 1472} {
for _, tc := range udpBenchmarkCases(payloadSize) {
for _, codec := range []struct {
name string
value string
}{
{name: "json", value: ""},
{name: "binary", value: wire.UDPPacketCodecBinary},
} {
b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, codec.name), func(b *testing.B) {
encoded := udpBenchmarkWireBytes(b, tc.packet, codec.value)
stream := &udpBenchmarkReadWriter{}
rw, err := NewUDPPacketReadWriter(stream, wire.ProtocolV2, codec.value)
if err != nil {
b.Fatal(err)
}
var decoded Message
b.SetBytes(int64(len(encoded)))
for b.Loop() {
stream.Reset(encoded)
decoded, err = rw.ReadMsg()
if err != nil {
b.Fatal(err)
}
}
packet, ok := decoded.(*UDPPacket)
if !ok {
b.Fatalf("decoded message type %T, want *UDPPacket", decoded)
}
udpBenchmarkValidatePacket(b, packet, tc.packet)
udpBenchmarkMessageSink = decoded
})
}
}
}
}
+338
View File
@@ -0,0 +1,338 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package msg
import (
"encoding/binary"
"fmt"
"io"
"net"
"unicode/utf8"
"github.com/fatedier/frp/pkg/proto/wire"
)
const MaxUDPPayloadSize = 65507
const (
udpPacketFlagLocalAddr byte = 1 << 0
udpPacketFlagRemoteAddr byte = 1 << 1
udpPacketValidFlags = udpPacketFlagLocalAddr | udpPacketFlagRemoteAddr
)
type binaryUDPAddr struct {
family byte
ip []byte
port uint16
zone string
}
// EncodeUDPPacketBinary encodes the body of a V2 binary UDP packet message.
// RemoteAddr is required by the UDP forwarding path.
func EncodeUDPPacketBinary(packet *UDPPacket) ([]byte, error) {
if packet == nil {
return nil, fmt.Errorf("nil UDP packet")
}
if packet.RemoteAddr == nil {
return nil, fmt.Errorf("UDP packet missing remote address")
}
if len(packet.Content) > MaxUDPPayloadSize {
return nil, fmt.Errorf("UDP payload length %d exceeds limit %d", len(packet.Content), MaxUDPPayloadSize)
}
var flags byte
var localAddr, remoteAddr binaryUDPAddr
bodyLen := 1 + 2 + len(packet.Content)
if packet.LocalAddr != nil {
flags |= udpPacketFlagLocalAddr
var err error
localAddr, err = validateBinaryUDPAddr(packet.LocalAddr)
if err != nil {
return nil, fmt.Errorf("local address: %w", err)
}
bodyLen += binaryUDPAddrLen(localAddr)
}
flags |= udpPacketFlagRemoteAddr
var err error
remoteAddr, err = validateBinaryUDPAddr(packet.RemoteAddr)
if err != nil {
return nil, fmt.Errorf("remote address: %w", err)
}
bodyLen += binaryUDPAddrLen(remoteAddr)
if 2+bodyLen > wire.DefaultMaxFramePayloadSize {
return nil, fmt.Errorf("v2 frame payload length %d exceeds limit %d", 2+bodyLen, wire.DefaultMaxFramePayloadSize)
}
body := make([]byte, bodyLen)
body[0] = flags
offset := 1
if flags&udpPacketFlagLocalAddr != 0 {
offset = putBinaryUDPAddr(body, offset, localAddr)
}
offset = putBinaryUDPAddr(body, offset, remoteAddr)
binary.BigEndian.PutUint16(body[offset:offset+2], uint16(len(packet.Content)))
offset += 2
copy(body[offset:], packet.Content)
return body, nil
}
// DecodeUDPPacketBinary decodes a V2 binary UDP packet body and returns data
// that does not alias the input frame buffer.
func DecodeUDPPacketBinary(body []byte) (*UDPPacket, error) {
if len(body) < 3 {
return nil, fmt.Errorf("UDP packet body too short: %d", len(body))
}
if 2+len(body) > wire.DefaultMaxFramePayloadSize {
return nil, fmt.Errorf("v2 frame payload length %d exceeds limit %d", 2+len(body), wire.DefaultMaxFramePayloadSize)
}
flags := body[0]
if flags&^udpPacketValidFlags != 0 {
return nil, fmt.Errorf("reserved UDP packet flags set: 0x%02x", flags)
}
if flags&udpPacketFlagRemoteAddr == 0 {
return nil, fmt.Errorf("UDP packet missing remote address")
}
packet := &UDPPacket{}
offset := 1
var err error
if flags&udpPacketFlagLocalAddr != 0 {
packet.LocalAddr, offset, err = readBinaryUDPAddr(body, offset)
if err != nil {
return nil, fmt.Errorf("local address: %w", err)
}
}
if flags&udpPacketFlagRemoteAddr != 0 {
packet.RemoteAddr, offset, err = readBinaryUDPAddr(body, offset)
if err != nil {
return nil, fmt.Errorf("remote address: %w", err)
}
}
if len(body)-offset < 2 {
return nil, fmt.Errorf("truncated UDP payload length")
}
payloadLen := int(binary.BigEndian.Uint16(body[offset : offset+2]))
offset += 2
if payloadLen > MaxUDPPayloadSize {
return nil, fmt.Errorf("UDP payload length %d exceeds limit %d", payloadLen, MaxUDPPayloadSize)
}
remaining := len(body) - offset
if remaining < payloadLen {
return nil, fmt.Errorf("truncated UDP payload: have %d want %d", remaining, payloadLen)
}
if remaining > payloadLen {
return nil, fmt.Errorf("trailing UDP packet bytes: %d", remaining-payloadLen)
}
packet.Content = append([]byte(nil), body[offset:offset+payloadLen]...)
return packet, nil
}
func validateBinaryUDPAddr(addr *net.UDPAddr) (binaryUDPAddr, error) {
if addr.Port < 0 || addr.Port > 65535 {
return binaryUDPAddr{}, fmt.Errorf("port out of range: %d", addr.Port)
}
if ip := addr.IP.To4(); ip != nil {
if addr.Zone != "" {
return binaryUDPAddr{}, fmt.Errorf("IPv4 zone is forbidden")
}
return binaryUDPAddr{family: 4, ip: ip, port: uint16(addr.Port)}, nil
}
ip := addr.IP.To16()
if ip == nil {
return binaryUDPAddr{}, fmt.Errorf("invalid IP")
}
if len(addr.Zone) > 255 {
return binaryUDPAddr{}, fmt.Errorf("zone exceeds 255 bytes")
}
if !utf8.ValidString(addr.Zone) {
return binaryUDPAddr{}, fmt.Errorf("zone is not valid UTF-8")
}
return binaryUDPAddr{family: 6, ip: ip, port: uint16(addr.Port), zone: addr.Zone}, nil
}
func binaryUDPAddrLen(addr binaryUDPAddr) int {
return 1 + len(addr.ip) + 2 + 1 + len(addr.zone)
}
func putBinaryUDPAddr(body []byte, offset int, addr binaryUDPAddr) int {
body[offset] = addr.family
offset++
copy(body[offset:], addr.ip)
offset += len(addr.ip)
binary.BigEndian.PutUint16(body[offset:offset+2], addr.port)
offset += 2
body[offset] = byte(len(addr.zone))
offset++
copy(body[offset:], addr.zone)
return offset + len(addr.zone)
}
func readBinaryUDPAddr(body []byte, offset int) (*net.UDPAddr, int, error) {
if offset >= len(body) {
return nil, offset, fmt.Errorf("truncated address family")
}
family := body[offset]
offset++
var ipLen int
switch family {
case 4:
ipLen = net.IPv4len
case 6:
ipLen = net.IPv6len
default:
return nil, offset, fmt.Errorf("unknown address family %d", family)
}
if len(body)-offset < ipLen+3 {
return nil, offset, fmt.Errorf("truncated address")
}
ip := append(net.IP(nil), body[offset:offset+ipLen]...)
offset += ipLen
port := binary.BigEndian.Uint16(body[offset : offset+2])
offset += 2
zoneLen := int(body[offset])
offset++
if len(body)-offset < zoneLen {
return nil, offset, fmt.Errorf("truncated zone")
}
zoneBytes := body[offset : offset+zoneLen]
if family == 4 && zoneLen != 0 {
return nil, offset, fmt.Errorf("IPv4 zone is forbidden")
}
if !utf8.Valid(zoneBytes) {
return nil, offset, fmt.Errorf("zone is not valid UTF-8")
}
offset += zoneLen
return &net.UDPAddr{IP: ip, Port: int(port), Zone: string(zoneBytes)}, offset, nil
}
type V2BinaryUDPPacketReadWriter struct {
conn *wire.Conn
}
func NewV2BinaryUDPPacketReadWriter(rw io.ReadWriter) *V2BinaryUDPPacketReadWriter {
return &V2BinaryUDPPacketReadWriter{conn: wire.NewConn(rw)}
}
func (rw *V2BinaryUDPPacketReadWriter) ReadMsg() (Message, error) {
frame, err := rw.conn.ReadFrame()
if err != nil {
return nil, err
}
if isV2MessageType(frame, V2TypeUDPPacketBinary) {
return decodeV2BinaryUDPPacketFrame(frame)
}
if isV2MessageType(frame, V2TypeUDPPacket) {
return nil, fmt.Errorf("received JSON UDP packet after binary codec negotiation")
}
return DecodeV2MessageFrame(frame)
}
func (rw *V2BinaryUDPPacketReadWriter) ReadMsgInto(out Message) error {
frame, err := rw.conn.ReadFrame()
if err != nil {
return err
}
if packetOut, ok := out.(*UDPPacket); ok {
if !isV2MessageType(frame, V2TypeUDPPacketBinary) {
return unexpectedV2UDPPacketType(frame)
}
packet, err := decodeV2BinaryUDPPacketFrame(frame)
if err != nil {
return err
}
*packetOut = *packet
return nil
}
return DecodeV2MessageFrameInto(frame, out)
}
func (rw *V2BinaryUDPPacketReadWriter) WriteMsg(message Message) error {
var packet *UDPPacket
switch typed := message.(type) {
case *UDPPacket:
packet = typed
case UDPPacket:
packet = &typed
default:
frame, err := EncodeV2MessageFrame(message)
if err != nil {
return err
}
return rw.conn.WriteFrame(frame)
}
body, err := EncodeUDPPacketBinary(packet)
if err != nil {
return err
}
payload := make([]byte, 2+len(body))
binary.BigEndian.PutUint16(payload[:2], V2TypeUDPPacketBinary)
copy(payload[2:], body)
return rw.conn.WriteFrame(&wire.Frame{Type: wire.FrameTypeMessage, Payload: payload})
}
func decodeV2BinaryUDPPacketFrame(frame *wire.Frame) (*UDPPacket, error) {
if frame.Type != wire.FrameTypeMessage {
return nil, fmt.Errorf("unexpected frame type %d, want %d", frame.Type, wire.FrameTypeMessage)
}
if len(frame.Payload) < 2 {
return nil, fmt.Errorf("message frame payload too short")
}
if binary.BigEndian.Uint16(frame.Payload[:2]) != V2TypeUDPPacketBinary {
return nil, unexpectedV2UDPPacketType(frame)
}
return DecodeUDPPacketBinary(frame.Payload[2:])
}
func isV2MessageType(frame *wire.Frame, typeID uint16) bool {
return frame.Type == wire.FrameTypeMessage && len(frame.Payload) >= 2 && binary.BigEndian.Uint16(frame.Payload[:2]) == typeID
}
func unexpectedV2UDPPacketType(frame *wire.Frame) error {
if frame.Type != wire.FrameTypeMessage {
return fmt.Errorf("unexpected frame type %d, want %d", frame.Type, wire.FrameTypeMessage)
}
if len(frame.Payload) < 2 {
return fmt.Errorf("message frame payload too short")
}
typeID := binary.BigEndian.Uint16(frame.Payload[:2])
if typeID == V2TypeUDPPacket {
return fmt.Errorf("received JSON UDP packet after binary codec negotiation")
}
return fmt.Errorf("unexpected message type %d, want %d", typeID, V2TypeUDPPacketBinary)
}
// NewUDPPacketReadWriter selects the negotiated packet codec without changing
// the framing or codecs used by non-UDP messages on the work connection.
func NewUDPPacketReadWriter(rw io.ReadWriter, wireProtocol, udpPacketCodec string) (ReadWriter, error) {
switch wireProtocol {
case "", wire.ProtocolV1:
if udpPacketCodec != "" {
return nil, fmt.Errorf("UDP packet codec %q requires wire protocol v2", udpPacketCodec)
}
return NewV1ReadWriter(rw), nil
case wire.ProtocolV2:
switch udpPacketCodec {
case "":
return NewV2ReadWriter(rw), nil
case wire.UDPPacketCodecBinary:
return NewV2BinaryUDPPacketReadWriter(rw), nil
default:
return nil, fmt.Errorf("unsupported UDP packet codec %q", udpPacketCodec)
}
default:
return nil, fmt.Errorf("unsupported wire protocol %q", wireProtocol)
}
}
+248
View File
@@ -0,0 +1,248 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
package msg
import (
"bytes"
"encoding/binary"
"net"
"strconv"
"testing"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestUDPPacketBinaryRoundTrip(t *testing.T) {
payload := bytes.Repeat([]byte{0xa5}, 1472)
in := &UDPPacket{
Content: payload,
LocalAddr: &net.UDPAddr{
IP: net.ParseIP("2001:db8::1"),
Port: 1234,
Zone: "en0",
},
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
}
body, err := EncodeUDPPacketBinary(in)
require.NoError(t, err)
out, err := DecodeUDPPacketBinary(body)
require.NoError(t, err)
require.Equal(t, in.Content, out.Content)
require.Equal(t, in.LocalAddr.String(), out.LocalAddr.String())
require.Equal(t, in.RemoteAddr.String(), out.RemoteAddr.String())
body[len(body)-1] ^= 0xff
body[25] ^= 0xff
require.Equal(t, byte(0xa5), out.Content[len(out.Content)-1], "decoded payload must own frame bytes")
require.Equal(t, byte(203), out.RemoteAddr.IP.To4()[0], "decoded address must own frame bytes")
}
func TestUDPPacketBinarySizesAndOptionalLocalAddress(t *testing.T) {
for _, size := range []int{0, 32, 128, 512, 1200, 1472, 4096, 49107, 65507} {
t.Run(strconv.Itoa(size), func(t *testing.T) {
in := &UDPPacket{
Content: bytes.Repeat([]byte{byte(size)}, size),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
}
body, err := EncodeUDPPacketBinary(in)
require.NoError(t, err)
out, err := DecodeUDPPacketBinary(body)
require.NoError(t, err)
require.Equal(t, len(in.Content), len(out.Content))
if size == 0 {
require.Empty(t, out.Content)
} else {
require.Equal(t, in.Content, out.Content)
}
})
}
}
func TestUDPPacketBinaryMalformed(t *testing.T) {
valid, err := EncodeUDPPacketBinary(&UDPPacket{
Content: []byte("payload"),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
})
require.NoError(t, err)
tests := [][]byte{
{0x80, 0, 0},
{0x02, 4, 1, 2},
{0x02, 4, 1, 2, 3, 4, 0xd4},
append(append([]byte(nil), valid...), 0),
}
for _, malformed := range tests {
_, err := DecodeUDPPacketBinary(malformed)
require.Error(t, err)
}
_, err = DecodeUDPPacketBinary([]byte{0, 0, 0})
require.ErrorContains(t, err, "missing remote address")
payloadLengthOffset := len(valid) - len("payload") - 2
invalidPayloadLength := append([]byte(nil), valid...)
binary.BigEndian.PutUint16(invalidPayloadLength[payloadLengthOffset:payloadLengthOffset+2], 0xffff)
_, err = DecodeUDPPacketBinary(invalidPayloadLength)
require.ErrorContains(t, err, "payload length")
truncatedPayload := append([]byte(nil), valid[:payloadLengthOffset+2]...)
binary.BigEndian.PutUint16(truncatedPayload[payloadLengthOffset:payloadLengthOffset+2], 1)
_, err = DecodeUDPPacketBinary(truncatedPayload)
require.ErrorContains(t, err, "truncated UDP payload")
_, err = DecodeUDPPacketBinary(make([]byte, wire.DefaultMaxFramePayloadSize))
require.ErrorContains(t, err, "frame payload length")
badIPv4Zone := []byte{2, 4, 203, 0, 113, 9, 0xd4, 0x31, 1, 'z', 0, 0}
_, err = DecodeUDPPacketBinary(badIPv4Zone)
require.ErrorContains(t, err, "IPv4 zone")
badFamily := []byte{2, 9, 0, 0}
_, err = DecodeUDPPacketBinary(badFamily)
require.ErrorContains(t, err, "unknown address family")
badUTF8 := make([]byte, 0, 24)
badUTF8 = append(badUTF8, 2, 6)
badUTF8 = append(badUTF8, make([]byte, 16)...)
badUTF8 = append(badUTF8, 0, 1, 1, 0xff, 0, 0)
_, err = DecodeUDPPacketBinary(badUTF8)
require.ErrorContains(t, err, "UTF-8")
}
func TestUDPPacketBinaryEncodeRejectsInvalidPackets(t *testing.T) {
_, err := EncodeUDPPacketBinary(&UDPPacket{})
require.ErrorContains(t, err, "missing remote address")
_, err = EncodeUDPPacketBinary(&UDPPacket{
LocalAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 1234},
})
require.ErrorContains(t, err, "missing remote address")
_, err = EncodeUDPPacketBinary(&UDPPacket{
Content: make([]byte, MaxUDPPayloadSize+1),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
})
require.ErrorContains(t, err, "exceeds limit")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 1, Zone: "bad"}})
require.ErrorContains(t, err, "IPv4 zone")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1, Zone: string(bytes.Repeat([]byte{'z'}, 256))}})
require.ErrorContains(t, err, "zone exceeds")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1, Zone: string([]byte{0xff})}})
require.ErrorContains(t, err, "UTF-8")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{Port: -1}})
require.ErrorContains(t, err, "port out of range")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{Port: 65536}})
require.ErrorContains(t, err, "port out of range")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.IP{1, 2, 3}}})
require.ErrorContains(t, err, "invalid IP")
_, err = EncodeUDPPacketBinary(&UDPPacket{
Content: make([]byte, MaxUDPPayloadSize),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Zone: string(bytes.Repeat([]byte{'z'}, 255))},
})
require.ErrorContains(t, err, "frame payload length")
}
func TestV2BinaryUDPPacketReadWriterPreservesOtherMessages(t *testing.T) {
var buf bytes.Buffer
rw := NewV2BinaryUDPPacketReadWriter(&buf)
in := &UDPPacket{Content: []byte("udp"), RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321}}
require.NoError(t, rw.WriteMsg(in))
require.NoError(t, rw.WriteMsg(&Ping{Timestamp: 7}))
frameConn := wire.NewConn(&buf)
frame, err := frameConn.ReadFrame()
require.NoError(t, err)
require.Equal(t, V2TypeUDPPacketBinary, binary.BigEndian.Uint16(frame.Payload[:2]))
frame, err = frameConn.ReadFrame()
require.NoError(t, err)
require.Equal(t, V2TypePing, binary.BigEndian.Uint16(frame.Payload[:2]))
}
func TestV2BinaryUDPPacketReadWriterRoundTripAndCodecInvariant(t *testing.T) {
in := &UDPPacket{Content: []byte("udp"), RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321}}
var binaryStream bytes.Buffer
binaryWriter, err := NewUDPPacketReadWriter(&binaryStream, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
require.NoError(t, binaryWriter.WriteMsg(in))
binaryReader, err := NewUDPPacketReadWriter(&binaryStream, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
out, err := binaryReader.ReadMsg()
require.NoError(t, err)
require.Equal(t, in.Content, out.(*UDPPacket).Content)
for _, read := range []func(ReadWriter) error{
func(rw ReadWriter) error {
_, err := rw.ReadMsg()
return err
},
func(rw ReadWriter) error {
return rw.ReadMsgInto(&UDPPacket{})
},
} {
var jsonStream bytes.Buffer
require.NoError(t, NewReadWriter(&jsonStream, wire.ProtocolV2).WriteMsg(in))
negotiatedReader, err := NewUDPPacketReadWriter(&jsonStream, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
require.ErrorContains(t, read(negotiatedReader), "JSON UDP packet after binary codec negotiation")
}
var fallbackStream bytes.Buffer
fallbackWriter, err := NewUDPPacketReadWriter(&fallbackStream, wire.ProtocolV2, "")
require.NoError(t, err)
require.NoError(t, fallbackWriter.WriteMsg(in))
frame, err := wire.NewConn(&fallbackStream).ReadFrame()
require.NoError(t, err)
require.Equal(t, V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
}
func TestNewUDPPacketReadWriterDefaultProtocolUsesV1(t *testing.T) {
var stream bytes.Buffer
rw, err := NewUDPPacketReadWriter(&stream, "", "")
require.NoError(t, err)
require.IsType(t, &V1ReadWriter{}, rw)
require.NoError(t, rw.WriteMsg(&UDPPacket{Content: []byte("legacy")}))
require.Equal(t, TypeUDPPacket, stream.Bytes()[0])
}
func TestNewUDPPacketReadWriterRejectsInvalidSelection(t *testing.T) {
for _, tc := range []struct {
name string
wireProtocol string
udpPacketCodec string
errorSubstring string
}{
{
name: "binary codec over v1",
wireProtocol: wire.ProtocolV1,
udpPacketCodec: wire.UDPPacketCodecBinary,
errorSubstring: "requires wire protocol v2",
},
{
name: "binary codec over default protocol",
udpPacketCodec: wire.UDPPacketCodecBinary,
errorSubstring: "requires wire protocol v2",
},
{
name: "unknown v2 codec",
wireProtocol: wire.ProtocolV2,
udpPacketCodec: "unknown",
errorSubstring: "unsupported UDP packet codec",
},
{
name: "unknown wire protocol",
wireProtocol: "unknown",
errorSubstring: "unsupported wire protocol",
},
} {
t.Run(tc.name, func(t *testing.T) {
rw, err := NewUDPPacketReadWriter(&bytes.Buffer{}, tc.wireProtocol, tc.udpPacketCodec)
require.Nil(t, rw)
require.ErrorContains(t, err, tc.errorSubstring)
})
}
}
func FuzzDecodeUDPPacketBinary(f *testing.F) {
f.Add([]byte{0, 0, 0})
f.Add([]byte{2, 4, 203, 0, 113, 9, 0xd4, 0x31, 0, 0, 1})
f.Fuzz(func(t *testing.T, body []byte) {
_, _ = DecodeUDPPacketBinary(body)
})
}
+1
View File
@@ -43,6 +43,7 @@ const (
V2TypeNatHoleResp uint16 = 16
V2TypeNatHoleSid uint16 = 17
V2TypeNatHoleReport uint16 = 18
V2TypeUDPPacketBinary uint16 = 19
)
var v2MsgTypeMap = map[uint16]any{
+3
View File
@@ -84,6 +84,9 @@ func TestV2MessageTypeIDsAreStable(t *testing.T) {
require.Equal(t, uint16(16), V2TypeNatHoleResp)
require.Equal(t, uint16(17), V2TypeNatHoleSid)
require.Equal(t, uint16(18), V2TypeNatHoleReport)
require.Equal(t, uint16(19), V2TypeUDPPacketBinary)
_, registered := v2MsgTypeMap[V2TypeUDPPacketBinary]
require.False(t, registered, "binary UDP has a dedicated codec and must not alter generic type registry")
}
func TestV2MessageFrameEncoding(t *testing.T) {
+86 -29
View File
@@ -20,6 +20,7 @@ import (
"context"
"errors"
"fmt"
"io"
"net"
"sync"
"time"
@@ -33,10 +34,16 @@ func init() {
Register(v1.VisitorPluginVirtualNet, NewVirtualNetPlugin)
}
type clientRouteController interface {
RegisterClientRoute(context.Context, string, []net.IPNet, io.ReadWriteCloser)
UnregisterClientRoute(string, io.Writer) bool
}
type VirtualNetPlugin struct {
pluginCtx PluginContext
routes []net.IPNet
routeController clientRouteController
routes []net.IPNet
mu sync.Mutex
controllerConn net.Conn
@@ -48,6 +55,11 @@ type VirtualNetPlugin struct {
cancel context.CancelFunc
}
const (
virtualNetReconnectBaseDelay = 60 * time.Second
virtualNetReconnectMaxDelay = 300 * time.Second
)
func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOptions) (Plugin, error) {
opts := options.(*v1.VirtualNetVisitorPluginOptions)
@@ -55,6 +67,9 @@ func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOption
pluginCtx: pluginCtx,
routes: make([]net.IPNet, 0),
}
if pluginCtx.VnetController != nil {
p.routeController = pluginCtx.VnetController
}
p.ctx, p.cancel = context.WithCancel(pluginCtx.Ctx)
@@ -85,7 +100,7 @@ func (p *VirtualNetPlugin) Name() string {
func (p *VirtualNetPlugin) Start() {
xl := xlog.FromContextSafe(p.pluginCtx.Ctx)
if p.pluginCtx.VnetController == nil {
if p.routeController == nil {
return
}
@@ -111,16 +126,17 @@ func (p *VirtualNetPlugin) run() {
select {
case <-p.ctx.Done():
xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before pipe creation).", p.pluginCtx.Name)
p.cleanupControllerConn(xl)
p.cleanupCurrentControllerConn(xl)
return
default:
}
controllerConn, pluginConn := net.Pipe()
p.mu.Lock()
p.controllerConn = controllerConn
p.mu.Unlock()
xl.Infof("attempting to register client route for visitor [%s]", p.pluginCtx.Name)
if !p.registerControllerConn(controllerConn, pluginConn) {
xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before route registration).", p.pluginCtx.Name)
return
}
// Wrap with CloseNotifyConn which supports both close notification and error recording
var closeErr error
@@ -129,8 +145,6 @@ func (p *VirtualNetPlugin) run() {
close(currentCloseSignal) // Signal the run loop on close.
})
xl.Infof("attempting to register client route for visitor [%s]", p.pluginCtx.Name)
p.pluginCtx.VnetController.RegisterClientRoute(p.ctx, p.pluginCtx.Name, p.routes, controllerConn)
xl.Infof("successfully registered client route for visitor [%s]. Starting connection handler with CloseNotifyConn.", p.pluginCtx.Name)
// Pass the CloseNotifyConn to the visitor for handling.
@@ -141,7 +155,7 @@ func (p *VirtualNetPlugin) run() {
select {
case <-p.ctx.Done():
xl.Infof("VirtualNetPlugin run loop stopping for visitor [%s] (context cancelled while waiting).", p.pluginCtx.Name)
p.cleanupControllerConn(xl)
p.cleanupControllerConn(xl, controllerConn)
return
case <-currentCloseSignal:
// Determine reconnect delay based on error with exponential backoff
@@ -152,8 +166,7 @@ func (p *VirtualNetPlugin) run() {
p.pluginCtx.Name, p.consecutiveErrors, closeErr)
// Exponential backoff: 60s, 120s, 240s, 300s (capped)
baseDelay := 60 * time.Second
reconnectDelay = min(baseDelay*time.Duration(1<<uint(p.consecutiveErrors-1)), 300*time.Second)
reconnectDelay = virtualNetReconnectDelay(p.consecutiveErrors)
} else {
// Reset consecutive errors on successful connection
if p.consecutiveErrors > 0 {
@@ -167,7 +180,7 @@ func (p *VirtualNetPlugin) run() {
}
// The visitor closed the plugin side. Close the controller side.
p.cleanupControllerConn(xl)
p.cleanupControllerConn(xl, controllerConn)
xl.Infof("waiting %v before attempting reconnection for visitor [%s]...", reconnectDelay, p.pluginCtx.Name)
select {
@@ -182,16 +195,66 @@ func (p *VirtualNetPlugin) run() {
}
}
// cleanupControllerConn closes the current controllerConn (if it exists) under lock.
func (p *VirtualNetPlugin) cleanupControllerConn(xl *xlog.Logger) {
// registerControllerConn publishes and registers controllerConn atomically with
// respect to Close. A canceled plugin cannot register a new route.
func (p *VirtualNetPlugin) registerControllerConn(controllerConn, pluginConn net.Conn) bool {
p.mu.Lock()
if p.ctx.Err() != nil || p.routeController == nil {
p.mu.Unlock()
_ = controllerConn.Close()
_ = pluginConn.Close()
return false
}
p.controllerConn = controllerConn
p.routeController.RegisterClientRoute(p.ctx, p.pluginCtx.Name, p.routes, controllerConn)
p.mu.Unlock()
return true
}
// virtualNetReconnectDelay returns a bounded reconnect delay without allowing
// the exponential shift to overflow for large consecutive error counts.
func virtualNetReconnectDelay(consecutiveErrors int) time.Duration {
if consecutiveErrors <= 1 {
return virtualNetReconnectBaseDelay
}
if consecutiveErrors >= 4 {
return virtualNetReconnectMaxDelay
}
return virtualNetReconnectBaseDelay * time.Duration(1<<uint(consecutiveErrors-1))
}
// cleanupControllerConn unregisters and closes one connection round without
// affecting a replacement route owned by another connection.
func (p *VirtualNetPlugin) cleanupControllerConn(xl *xlog.Logger, controllerConn net.Conn) {
p.mu.Lock()
defer p.mu.Unlock()
if p.controllerConn != nil {
xl.Debugf("cleaning up controllerConn for visitor [%s]", p.pluginCtx.Name)
p.controllerConn.Close()
p.controllerConn = nil
p.cleanupControllerConnLocked(xl, controllerConn)
}
func (p *VirtualNetPlugin) cleanupCurrentControllerConn(xl *xlog.Logger) {
p.mu.Lock()
defer p.mu.Unlock()
p.cleanupControllerConnLocked(xl, p.controllerConn)
}
// cleanupControllerConnLocked must be called with p.mu held.
func (p *VirtualNetPlugin) cleanupControllerConnLocked(xl *xlog.Logger, controllerConn net.Conn) {
if controllerConn == nil {
p.closeSignal = nil
return
}
if p.routeController != nil &&
p.routeController.UnregisterClientRoute(p.pluginCtx.Name, controllerConn) {
xl.Infof("unregistered client route for visitor [%s]", p.pluginCtx.Name)
}
xl.Debugf("cleaning up controllerConn for visitor [%s]", p.pluginCtx.Name)
_ = controllerConn.Close()
if p.controllerConn == controllerConn {
p.controllerConn = nil
p.closeSignal = nil
}
p.closeSignal = nil
}
// Close initiates the plugin shutdown.
@@ -202,15 +265,9 @@ func (p *VirtualNetPlugin) Close() error {
// Signal the run loop goroutine to stop.
p.cancel()
// Unregister the route from the controller.
if p.pluginCtx.VnetController != nil {
p.pluginCtx.VnetController.UnregisterClientRoute(p.pluginCtx.Name)
xl.Infof("unregistered client route for visitor [%s]", p.pluginCtx.Name)
}
// Explicitly close the controller side of the pipe.
// This ensures the pipe is broken even if the run loop is stuck or the visitor hasn't closed its end.
p.cleanupControllerConn(xl)
// Unregister and close the current connection while holding the same lock
// used to check cancellation and register a route in run.
p.cleanupCurrentControllerConn(xl)
xl.Infof("finished cleaning up connections during close for visitor [%s]", p.pluginCtx.Name)
return nil
+245
View File
@@ -0,0 +1,245 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !frps
package visitor
import (
"context"
"io"
"net"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/util/xlog"
)
const testVirtualNetVisitorName = "vnet-visitor"
type fakeClientRouteController struct {
mu sync.Mutex
routes map[string]io.Writer
beforeRegister func()
registerCalls int
unregisterCalls int
}
func newFakeClientRouteController() *fakeClientRouteController {
return &fakeClientRouteController{
routes: make(map[string]io.Writer),
}
}
func (c *fakeClientRouteController) RegisterClientRoute(
_ context.Context,
name string,
_ []net.IPNet,
conn io.ReadWriteCloser,
) {
if c.beforeRegister != nil {
c.beforeRegister()
}
c.mu.Lock()
defer c.mu.Unlock()
c.registerCalls++
c.routes[name] = conn
}
func (c *fakeClientRouteController) UnregisterClientRoute(name string, conn io.Writer) bool {
c.mu.Lock()
defer c.mu.Unlock()
c.unregisterCalls++
owner, ok := c.routes[name]
if !ok || owner != conn {
return false
}
delete(c.routes, name)
return true
}
func (c *fakeClientRouteController) owner() io.Writer {
c.mu.Lock()
defer c.mu.Unlock()
return c.routes[testVirtualNetVisitorName]
}
func (c *fakeClientRouteController) callCounts() (register, unregister int) {
c.mu.Lock()
defer c.mu.Unlock()
return c.registerCalls, c.unregisterCalls
}
type trackedConn struct {
net.Conn
closed atomic.Bool
}
func (c *trackedConn) Close() error {
c.closed.Store(true)
return c.Conn.Close()
}
func newTrackedPipe(t *testing.T) (*trackedConn, *trackedConn) {
t.Helper()
left, right := net.Pipe()
trackedLeft := &trackedConn{Conn: left}
trackedRight := &trackedConn{Conn: right}
t.Cleanup(func() {
_ = trackedLeft.Close()
_ = trackedRight.Close()
})
return trackedLeft, trackedRight
}
func newTestVirtualNetPlugin(t *testing.T, controller *fakeClientRouteController) *VirtualNetPlugin {
t.Helper()
pluginCtx := context.Background()
ctx, cancel := context.WithCancel(pluginCtx)
p := &VirtualNetPlugin{
pluginCtx: PluginContext{
Name: testVirtualNetVisitorName,
Ctx: pluginCtx,
},
routeController: controller,
routes: []net.IPNet{{
IP: net.ParseIP("10.1.0.1"),
Mask: net.CIDRMask(32, 32),
}},
ctx: ctx,
cancel: cancel,
}
t.Cleanup(func() {
_ = p.Close()
})
return p
}
func waitResult[T any](t *testing.T, ch <-chan T) T {
t.Helper()
select {
case result := <-ch:
return result
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for concurrent operation")
var zero T
return zero
}
}
// TestVirtualNetReconnectDelay verifies the documented exponential backoff and
// ensures large error counts remain capped instead of overflowing to zero.
func TestVirtualNetReconnectDelay(t *testing.T) {
tests := []struct {
name string
consecutiveErrors int
want time.Duration
}{
{name: "first error", consecutiveErrors: 1, want: 60 * time.Second},
{name: "second error", consecutiveErrors: 2, want: 120 * time.Second},
{name: "third error", consecutiveErrors: 3, want: 240 * time.Second},
{name: "fourth error", consecutiveErrors: 4, want: 300 * time.Second},
{name: "shift width boundary", consecutiveErrors: 64, want: 300 * time.Second},
{name: "observed retry storm", consecutiveErrors: 329769, want: 300 * time.Second},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, virtualNetReconnectDelay(tt.consecutiveErrors))
})
}
}
func TestVirtualNetPluginCloseBeforeRegisterDoesNotReplaceNewRoute(t *testing.T) {
controller := newFakeClientRouteController()
oldPlugin := newTestVirtualNetPlugin(t, controller)
newPlugin := newTestVirtualNetPlugin(t, controller)
oldControllerConn, oldPluginConn := newTrackedPipe(t)
newControllerConn, newPluginConn := newTrackedPipe(t)
allowOldRegister := make(chan struct{})
oldRegisterResult := make(chan bool, 1)
go func() {
<-allowOldRegister
oldRegisterResult <- oldPlugin.registerControllerConn(oldControllerConn, oldPluginConn)
}()
require.NoError(t, oldPlugin.Close())
require.True(t, newPlugin.registerControllerConn(newControllerConn, newPluginConn))
close(allowOldRegister)
require.False(t, waitResult(t, oldRegisterResult))
require.Same(t, newControllerConn, controller.owner())
registerCalls, _ := controller.callCounts()
require.Equal(t, 1, registerCalls)
require.True(t, oldControllerConn.closed.Load())
require.True(t, oldPluginConn.closed.Load())
}
func TestVirtualNetPluginRegisterBeforeCloseIsCleanedUp(t *testing.T) {
controller := newFakeClientRouteController()
p := newTestVirtualNetPlugin(t, controller)
controllerConn, pluginConn := newTrackedPipe(t)
registerEntered := make(chan struct{})
var registerEnteredOnce sync.Once
controller.beforeRegister = func() {
registerEnteredOnce.Do(func() {
close(registerEntered)
})
<-p.ctx.Done()
}
registerResult := make(chan bool, 1)
go func() {
registerResult <- p.registerControllerConn(controllerConn, pluginConn)
}()
waitResult(t, registerEntered)
closeResult := make(chan error, 1)
go func() {
closeResult <- p.Close()
}()
require.NoError(t, waitResult(t, closeResult))
require.True(t, waitResult(t, registerResult))
require.Nil(t, controller.owner())
registerCalls, unregisterCalls := controller.callCounts()
require.Equal(t, 1, registerCalls)
require.Equal(t, 1, unregisterCalls)
require.True(t, controllerConn.closed.Load())
}
func TestVirtualNetPluginOldConnectionCleanupKeepsReplacementRoute(t *testing.T) {
controller := newFakeClientRouteController()
oldPlugin := newTestVirtualNetPlugin(t, controller)
newPlugin := newTestVirtualNetPlugin(t, controller)
oldControllerConn, oldPluginConn := newTrackedPipe(t)
newControllerConn, newPluginConn := newTrackedPipe(t)
require.True(t, oldPlugin.registerControllerConn(oldControllerConn, oldPluginConn))
require.True(t, newPlugin.registerControllerConn(newControllerConn, newPluginConn))
require.Same(t, newControllerConn, controller.owner())
oldPlugin.cleanupControllerConn(xlog.FromContextSafe(oldPlugin.ctx), oldControllerConn)
require.Same(t, newControllerConn, controller.owner())
require.True(t, oldControllerConn.closed.Load())
}
+7 -3
View File
@@ -63,9 +63,13 @@ func ForwardUserConn(udpConn *net.UDPConn, readCh <-chan *msg.UDPPacket, sendCh
// NewUDPPacket copies buf[:n], so the read buffer can be reused
udpMsg := NewUDPPacket(buf[:n], nil, remoteAddr)
select {
case sendCh <- udpMsg:
default:
if err = errors.PanicToError(func() {
select {
case sendCh <- udpMsg:
default:
}
}); err != nil {
return
}
}
}
+34
View File
@@ -1,9 +1,13 @@
package udp
import (
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/msg"
)
func TestUdpPacket(t *testing.T) {
@@ -16,3 +20,33 @@ func TestUdpPacket(t *testing.T) {
require.NoError(err)
require.EqualValues(buf, newBuf)
}
func TestForwardUserConnReturnsWhenSendChannelIsClosed(t *testing.T) {
listener, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
require.NoError(t, err)
t.Cleanup(func() { _ = listener.Close() })
readCh := make(chan *msg.UDPPacket)
sendCh := make(chan *msg.UDPPacket)
close(sendCh)
t.Cleanup(func() { close(readCh) })
done := make(chan struct{})
go func() {
ForwardUserConn(listener, readCh, sendCh, 1500)
close(done)
}()
sender, err := net.DialUDP("udp4", nil, listener.LocalAddr().(*net.UDPAddr))
require.NoError(t, err)
t.Cleanup(func() { _ = sender.Close() })
_, err = sender.Write([]byte("trigger"))
require.NoError(t, err)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("ForwardUserConn did not return after sending to a closed channel")
}
}
+18 -1
View File
@@ -68,7 +68,8 @@ func NewServerHello(clientHello ClientHello) (ServerHello, error) {
return ServerHello{
Selected: ServerSelection{
Message: MessageSelection{
Codec: MessageCodecJSON,
Codec: MessageCodecJSON,
UDPPacketCodec: selectUDPPacketCodec(clientHello.Capabilities.Message.UDPPacketCodecs),
},
Crypto: CryptoSelection{
Algorithm: algorithm,
@@ -92,6 +93,15 @@ func ValidateServerHelloForClient(clientHello ClientHello, serverHello ServerHel
if serverHello.Selected.Message.Codec != MessageCodecJSON {
return fmt.Errorf("unsupported selected message codec: %s", serverHello.Selected.Message.Codec)
}
udpPacketCodec := serverHello.Selected.Message.UDPPacketCodec
if udpPacketCodec != "" {
if udpPacketCodec != UDPPacketCodecBinary {
return fmt.Errorf("unsupported selected UDP packet codec: %s", udpPacketCodec)
}
if !Supports(clientHello.Capabilities.Message.UDPPacketCodecs, udpPacketCodec) {
return fmt.Errorf("selected UDP packet codec was not advertised by client: %s", udpPacketCodec)
}
}
cryptoSelection := serverHello.Selected.Crypto
if !IsSupportedAEADAlgorithm(cryptoSelection.Algorithm) {
return fmt.Errorf("unknown selected crypto algorithm: %s", cryptoSelection.Algorithm)
@@ -105,6 +115,13 @@ func ValidateServerHelloForClient(clientHello ClientHello, serverHello ServerHel
return nil
}
func selectUDPPacketCodec(codecs []string) string {
if Supports(codecs, UDPPacketCodecBinary) {
return UDPPacketCodecBinary
}
return ""
}
func NewCryptoContext(algorithm string, clientHelloPayload, serverHelloPayload []byte) *CryptoContext {
return &CryptoContext{
Algorithm: algorithm,
+7 -3
View File
@@ -36,6 +36,7 @@ const (
FrameTypeMessage uint16 = 16
MessageCodecJSON = "json"
UDPPacketCodecBinary = "binary-v1"
DefaultMaxFramePayloadSize = 64 * 1024
MagicV2 = "FRP\x00\x02\r\n"
@@ -182,7 +183,8 @@ type ClientCapabilities struct {
}
type MessageCapabilities struct {
Codecs []string `json:"codecs,omitempty"`
Codecs []string `json:"codecs,omitempty"`
UDPPacketCodecs []string `json:"udpPacketCodecs,omitempty"`
}
type CryptoCapabilities struct {
@@ -201,7 +203,8 @@ type ServerSelection struct {
}
type MessageSelection struct {
Codec string `json:"codec,omitempty"`
Codec string `json:"codec,omitempty"`
UDPPacketCodec string `json:"udpPacketCodec,omitempty"`
}
type CryptoSelection struct {
@@ -214,7 +217,8 @@ func clientHelloWithCryptoRandom(bootstrap BootstrapInfo, clientRandom []byte) C
Bootstrap: bootstrap,
Capabilities: ClientCapabilities{
Message: MessageCapabilities{
Codecs: []string{MessageCodecJSON},
Codecs: []string{MessageCodecJSON},
UDPPacketCodecs: []string{UDPPacketCodecBinary},
},
Crypto: CryptoCapabilities{
Algorithms: PreferredAEADAlgorithms(),
+30
View File
@@ -148,10 +148,40 @@ func TestNewServerHelloSelectsFirstSupportedAEADAlgorithm(t *testing.T) {
serverHello, err := NewServerHello(hello)
require.NoError(t, err)
require.Equal(t, MessageCodecJSON, serverHello.Selected.Message.Codec)
require.Equal(t, UDPPacketCodecBinary, serverHello.Selected.Message.UDPPacketCodec)
require.Equal(t, AEADAlgorithmXChaCha20Poly1305, serverHello.Selected.Crypto.Algorithm)
require.Len(t, serverHello.Selected.Crypto.ServerRandom, CryptoRandomSize)
}
func TestUDPPacketCodecNegotiationFallbackAndValidation(t *testing.T) {
hello := mustClientHello(t, BootstrapInfo{})
serverHello, err := NewServerHello(hello)
require.NoError(t, err)
require.Equal(t, UDPPacketCodecBinary, serverHello.Selected.Message.UDPPacketCodec)
require.NoError(t, ValidateServerHelloForClient(hello, serverHello))
legacyHello := hello
legacyHello.Capabilities.Message.UDPPacketCodecs = nil
legacyServerHello, err := NewServerHello(legacyHello)
require.NoError(t, err)
require.Empty(t, legacyServerHello.Selected.Message.UDPPacketCodec)
require.NoError(t, ValidateServerHelloForClient(legacyHello, legacyServerHello))
unknownOffer := hello
unknownOffer.Capabilities.Message.UDPPacketCodecs = []string{"unknown"}
unknownServerHello, err := NewServerHello(unknownOffer)
require.NoError(t, err)
require.Empty(t, unknownServerHello.Selected.Message.UDPPacketCodec)
rejected := serverHello
rejected.Selected.Message.UDPPacketCodec = "unknown"
require.ErrorContains(t, ValidateServerHelloForClient(hello, rejected), "unsupported selected UDP packet codec")
unadvertised := serverHello
unadvertised.Selected.Message.UDPPacketCodec = UDPPacketCodecBinary
require.ErrorContains(t, ValidateServerHelloForClient(legacyHello, unadvertised), "was not advertised")
}
func TestNewClientCryptoContextValidatesServerHello(t *testing.T) {
hello := mustClientHello(t, BootstrapInfo{})
serverHello, err := NewServerHello(hello)
+5
View File
@@ -70,6 +70,7 @@ type TunnelServer struct {
sshConn *ssh.ServerConn
sc *ssh.ServerConfig
firstChannel ssh.Channel
firstChannelMu sync.Mutex
vc *virtual.Client
peerServerListener *netpkg.InternalListener
@@ -191,6 +192,8 @@ func (s *TunnelServer) Run() error {
}
func (s *TunnelServer) writeToClient(data string) {
s.firstChannelMu.Lock()
defer s.firstChannelMu.Unlock()
if s.firstChannel == nil {
return
}
@@ -304,9 +307,11 @@ func (s *TunnelServer) handleNewChannel(channel ssh.NewChannel, extraPayloadCh c
if err != nil {
return
}
s.firstChannelMu.Lock()
if s.firstChannel == nil {
s.firstChannel = ch
}
s.firstChannelMu.Unlock()
go s.keepAlive(ch)
for req := range reqs {
+44
View File
@@ -16,7 +16,11 @@ package ssh
import (
"encoding/binary"
"io"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
cryptossh "golang.org/x/crypto/ssh"
@@ -69,3 +73,43 @@ func TestParseExecPayloadRejectsMalformedPayloads(t *testing.T) {
})
}
}
type trackingChannel struct {
active atomic.Int32
concurrent atomic.Bool
}
func (c *trackingChannel) Read([]byte) (int, error) { return 0, io.EOF }
func (c *trackingChannel) Write(p []byte) (int, error) {
if c.active.Add(1) != 1 {
c.concurrent.Store(true)
}
time.Sleep(time.Millisecond)
c.active.Add(-1)
return len(p), nil
}
func (c *trackingChannel) Close() error { return nil }
func (c *trackingChannel) CloseWrite() error { return nil }
func (c *trackingChannel) SendRequest(string, bool, []byte) (bool, error) { return false, nil }
func (c *trackingChannel) Stderr() io.ReadWriter { return nil }
func TestWriteToClientSerializesChannelWrites(t *testing.T) {
channel := &trackingChannel{}
s := &TunnelServer{firstChannel: channel}
start := make(chan struct{})
var wg sync.WaitGroup
for range 8 {
wg.Go(func() {
<-start
s.writeToClient("message")
})
}
close(start)
wg.Wait()
if channel.concurrent.Load() {
t.Fatal("channel writes were concurrent")
}
}
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package limit
import (
"fmt"
"golang.org/x/time/rate"
)
// NewBandwidthLimiter creates a limiter whose rate preserves the configured
// byte limit while keeping the burst representable as an int on all targets.
func NewBandwidthLimiter(bytes int64) *rate.Limiter {
if bytes <= 0 {
return nil
}
maxInt := int64(^uint(0) >> 1)
burst := min(bytes, maxInt)
return rate.NewLimiter(rate.Limit(float64(bytes)), int(burst))
}
func invalidBurstError(burst int) error {
return fmt.Errorf("invalid limiter burst: %d", burst)
}
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package limit
import (
"bytes"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/time/rate"
)
func TestNewBandwidthLimiterClampsBurstToTargetInt(t *testing.T) {
const bytesPerSecond = int64(1 << 31)
limiter := NewBandwidthLimiter(bytesPerSecond)
require.NotNil(t, limiter)
wantBurst := bytesPerSecond
maxInt := int64(^uint(0) >> 1)
if wantBurst > maxInt {
wantBurst = maxInt
}
require.Equal(t, int(wantBurst), limiter.Burst())
require.Equal(t, rate.Limit(float64(bytesPerSecond)), limiter.Limit())
}
func TestNewBandwidthLimiterDisablesNonPositiveLimit(t *testing.T) {
require.Nil(t, NewBandwidthLimiter(0))
require.Nil(t, NewBandwidthLimiter(-1))
}
func TestReaderAndWriterRejectInvalidBurst(t *testing.T) {
for _, burst := range []int{0, -1} {
t.Run("reader/"+strconv.Itoa(burst), func(t *testing.T) {
reader := NewReader(strings.NewReader("payload"), rate.NewLimiter(rate.Limit(1), burst))
n, err := reader.Read(make([]byte, 1))
require.Zero(t, n)
require.EqualError(t, err, "invalid limiter burst: "+strconv.Itoa(burst))
})
t.Run("writer/"+strconv.Itoa(burst), func(t *testing.T) {
var dst bytes.Buffer
writer := NewWriter(&dst, rate.NewLimiter(rate.Limit(1), burst))
n, err := writer.Write([]byte("payload"))
require.Zero(t, n)
require.EqualError(t, err, "invalid limiter burst: "+strconv.Itoa(burst))
require.Empty(t, dst.Bytes())
})
}
}
+6
View File
@@ -35,6 +35,12 @@ func NewReader(r io.Reader, limiter *rate.Limiter) *Reader {
func (r *Reader) Read(p []byte) (n int, err error) {
b := r.limiter.Burst()
if b <= 0 {
if len(p) == 0 {
return 0, nil
}
return 0, invalidBurstError(b)
}
if b < len(p) {
p = p[:b]
}
+7
View File
@@ -34,8 +34,15 @@ func NewWriter(w io.Writer, limiter *rate.Limiter) *Writer {
}
func (w *Writer) Write(p []byte) (n int, err error) {
if len(p) == 0 {
return 0, nil
}
var nn int
b := w.limiter.Burst()
if b <= 0 {
return 0, invalidBurstError(b)
}
for {
end := len(p)
if end == 0 {
+6 -5
View File
@@ -58,7 +58,7 @@ func newStandardDateCounterWithClock(reserveDays int64, clk clock.PassiveClock)
reserveDays: reserveDays,
counts: make([]int64, reserveDays),
clock: clk,
lastUpdateDate: startOfDay(clk.Now()),
lastUpdateDate: calendarDate(clk.Now()),
}
}
@@ -114,7 +114,7 @@ func (c *StandardDateCounter) Clear() {
// rotate
// Must hold the lock before calling this function.
func (c *StandardDateCounter) rotate(now time.Time) {
now = startOfDay(now)
now = calendarDate(now)
days := int(now.Sub(c.lastUpdateDate).Hours() / 24)
reserveDays := int(c.reserveDays)
@@ -132,7 +132,8 @@ func (c *StandardDateCounter) rotate(now time.Time) {
c.lastUpdateDate = now
}
// startOfDay returns midnight in t's location.
func startOfDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
// calendarDate represents t's local date as midnight UTC for day arithmetic.
// This avoids variable day lengths and nonexistent local midnights during DST.
func calendarDate(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
+64 -4
View File
@@ -31,7 +31,7 @@ func TestDateCounter(t *testing.T) {
func TestDateCounterRotate(t *testing.T) {
loc := time.FixedZone("test", 8*60*60)
lastUpdateDate := time.Date(2026, time.May, 8, 0, 0, 0, 0, loc)
lastUpdateDate := time.Date(2026, time.May, 8, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
@@ -55,19 +55,19 @@ func TestDateCounterRotate(t *testing.T) {
name: "one day",
now: time.Date(2026, time.May, 9, 12, 30, 0, 0, loc),
want: []int64{0, 10, 7},
wantLastUpdateDate: time.Date(2026, time.May, 9, 0, 0, 0, 0, loc),
wantLastUpdateDate: time.Date(2026, time.May, 9, 0, 0, 0, 0, time.UTC),
},
{
name: "two days",
now: time.Date(2026, time.May, 10, 12, 30, 0, 0, loc),
want: []int64{0, 0, 10},
wantLastUpdateDate: time.Date(2026, time.May, 10, 0, 0, 0, 0, loc),
wantLastUpdateDate: time.Date(2026, time.May, 10, 0, 0, 0, 0, time.UTC),
},
{
name: "all reserved days elapsed",
now: time.Date(2026, time.May, 11, 12, 30, 0, 0, loc),
want: []int64{0, 0, 0},
wantLastUpdateDate: time.Date(2026, time.May, 11, 0, 0, 0, 0, loc),
wantLastUpdateDate: time.Date(2026, time.May, 11, 0, 0, 0, 0, time.UTC),
},
}
@@ -132,3 +132,63 @@ func TestDateCounterConcurrentAccess(t *testing.T) {
}
wg.Wait()
}
func TestDateCounterCalendarDaysAcrossDST(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
require.NoError(t, err)
midnightLoc, err := time.LoadLocation("America/Sao_Paulo")
require.NoError(t, err)
tests := []struct {
name string
start time.Time
end time.Time
want []int64
}{
{
name: "spring next day",
start: time.Date(2026, time.March, 8, 12, 0, 0, 0, loc),
end: time.Date(2026, time.March, 9, 12, 0, 0, 0, loc),
want: []int64{0, 10, 0},
},
{
name: "spring two days",
start: time.Date(2026, time.March, 8, 12, 0, 0, 0, loc),
end: time.Date(2026, time.March, 10, 12, 0, 0, 0, loc),
want: []int64{0, 0, 10},
},
{
name: "spring retention expiry",
start: time.Date(2026, time.March, 8, 12, 0, 0, 0, loc),
end: time.Date(2026, time.March, 11, 12, 0, 0, 0, loc),
want: []int64{0, 0, 0},
},
{
name: "fall next day",
start: time.Date(2026, time.November, 1, 12, 0, 0, 0, loc),
end: time.Date(2026, time.November, 2, 12, 0, 0, 0, loc),
want: []int64{0, 10, 0},
},
{
name: "skipped midnight",
start: time.Date(2018, time.November, 3, 12, 0, 0, 0, midnightLoc),
end: time.Date(2018, time.November, 4, 12, 0, 0, 0, midnightLoc),
want: []int64{0, 10, 0},
},
{
name: "day after skipped midnight",
start: time.Date(2018, time.November, 4, 12, 0, 0, 0, midnightLoc),
end: time.Date(2018, time.November, 5, 12, 0, 0, 0, midnightLoc),
want: []int64{0, 10, 0},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clk := clocktesting.NewFakeClock(tt.start)
dc := newStandardDateCounterWithClock(3, clk)
dc.Inc(10)
clk.SetTime(tt.end)
require.Equal(t, tt.want[0], dc.TodayCount())
require.Equal(t, tt.want, dc.GetLastDaysCount(3))
})
}
}
+23 -4
View File
@@ -4,6 +4,7 @@ import (
"errors"
"net"
"net/http"
"sync"
"time"
"golang.org/x/net/websocket"
@@ -18,8 +19,11 @@ const (
type WebsocketListener struct {
ln net.Listener
acceptCh chan net.Conn
done chan struct{}
server *http.Server
closeOnce sync.Once
}
// NewWebsocketListener to handle websocket connections
@@ -28,6 +32,7 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
wl = &WebsocketListener{
ln: ln,
acceptCh: make(chan net.Conn),
done: make(chan struct{}),
}
muxer := http.NewServeMux()
@@ -41,7 +46,14 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
conn := WrapCloseNotifyConn(c, func(_ error) {
close(notifyCh)
})
wl.acceptCh <- conn
select {
case <-wl.done:
conn.Close()
return
case wl.acceptCh <- conn:
// A ready handoff may win when Close races with it. Once delivered,
// the connection is owned by the caller of Accept.
}
<-notifyCh
}))
@@ -58,14 +70,21 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
}
func (p *WebsocketListener) Accept() (net.Conn, error) {
c, ok := <-p.acceptCh
if !ok {
select {
case <-p.done:
return nil, ErrWebsocketListenerClosed
default:
}
select {
case <-p.done:
return nil, ErrWebsocketListenerClosed
case c := <-p.acceptCh:
return c, nil
}
return c, nil
}
func (p *WebsocketListener) Close() error {
p.closeOnce.Do(func() { close(p.done) })
return p.server.Close()
}
+197
View File
@@ -0,0 +1,197 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package net
import (
"bufio"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"sync"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/net/websocket"
)
type closeStubListener struct {
closed chan struct{}
once sync.Once
err error
}
func (l *closeStubListener) Accept() (net.Conn, error) {
<-l.closed
return nil, net.ErrClosed
}
func (l *closeStubListener) Close() error {
l.once.Do(func() { close(l.closed) })
return l.err
}
func (l *closeStubListener) Addr() net.Addr {
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}
}
func TestWebsocketListenerAcceptUnblocksOnClose(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
wl := NewWebsocketListener(&closeStubListener{closed: make(chan struct{})})
t.Cleanup(func() { wl.Close() })
errCh := make(chan error, 3)
for range 3 {
go func() {
c, err := wl.Accept()
if c != nil {
t.Error("Accept returned a connection without a sender")
}
errCh <- err
}()
}
synctest.Wait() // All Accept calls are blocked before Close.
require.Empty(t, errCh)
var wg sync.WaitGroup
for range 3 {
wg.Go(func() {
if err := wl.Close(); err != nil {
t.Errorf("Close: %v", err)
}
})
}
wg.Wait()
synctest.Wait()
require.Len(t, errCh, 3)
for range 3 {
require.ErrorIs(t, <-errCh, ErrWebsocketListenerClosed)
}
c, err := wl.Accept()
require.Nil(t, c)
require.ErrorIs(t, err, ErrWebsocketListenerClosed)
})
}
func TestWebsocketListenerCloseError(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
closeErr := errors.New("listener close failed")
wl := NewWebsocketListener(&closeStubListener{closed: make(chan struct{}), err: closeErr})
synctest.Wait() // Let Serve register its listener before Close.
require.ErrorIs(t, wl.Close(), closeErr)
require.NoError(t, wl.Close())
})
}
type websocketPipeHijacker struct {
*httptest.ResponseRecorder
conn net.Conn
rw *bufio.ReadWriter
}
func (h *websocketPipeHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return h.conn, h.rw, nil
}
func newWebsocketPipe(t *testing.T) (*WebsocketListener, *websocket.Conn, net.Conn, <-chan struct{}) {
t.Helper()
wl := NewWebsocketListener(&closeStubListener{closed: make(chan struct{})})
serverConn, clientConn := net.Pipe()
t.Cleanup(func() {
wl.Close()
serverConn.Close()
clientConn.Close()
})
require.NoError(t, serverConn.SetDeadline(time.Now().Add(time.Second)))
require.NoError(t, clientConn.SetDeadline(time.Now().Add(time.Second)))
handlerDone := make(chan struct{})
go func() {
defer close(handlerDone)
rw := bufio.NewReadWriter(bufio.NewReader(serverConn), bufio.NewWriter(serverConn))
req, err := http.ReadRequest(rw.Reader)
if err != nil {
t.Errorf("ReadRequest: %v", err)
return
}
wl.server.Handler.ServeHTTP(&websocketPipeHijacker{httptest.NewRecorder(), serverConn, rw}, req)
}()
cfg, err := websocket.NewConfig("ws://localhost"+FrpWebsocketPath, "http://localhost")
require.NoError(t, err)
peer, err := websocket.NewClient(cfg, clientConn)
require.NoError(t, err)
return wl, peer, serverConn, handlerDone
}
func TestWebsocketListenerUnacceptedConnectionClose(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
wl, peer, raw, handlerDone := newWebsocketPipe(t)
peerErr := make(chan error, 1)
go func() {
_, err := peer.Read(make([]byte, 1))
peerErr <- err
}()
// No Accept consumer exists. Do not Wait here: it would synchronize
// the handler's send with Close and hide the baseline send/close race.
require.NoError(t, wl.Close())
require.ErrorIs(t, <-peerErr, io.EOF)
<-handlerDone
_, err := raw.Read(make([]byte, 1))
require.ErrorIs(t, err, io.ErrClosedPipe)
})
}
func TestWebsocketListenerAcceptedConnectionOwnership(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
wl, peer, raw, handlerDone := newWebsocketPipe(t)
owner, err := wl.Accept()
require.NoError(t, err)
t.Cleanup(func() {
raw.Close()
owner.Close()
})
require.NoError(t, wl.Close())
payload := []byte{0, 255, 128, 42}
reply := []byte("reply")
gotReply := make([]byte, len(reply))
peerErr := make(chan error, 2)
go func() {
if _, err := peer.Write(payload); err != nil {
peerErr <- err
return
}
_, err := io.ReadFull(peer, gotReply)
peerErr <- err
_, err = peer.Read(make([]byte, 1))
peerErr <- err
}()
got := make([]byte, len(payload))
_, err = io.ReadFull(owner, got)
require.NoError(t, err)
require.Equal(t, payload, got)
_, err = owner.Write(reply)
require.NoError(t, err)
require.NoError(t, <-peerErr)
require.Equal(t, reply, gotReply)
require.NoError(t, owner.Close())
require.NoError(t, owner.Close())
require.ErrorIs(t, <-peerErr, io.EOF)
<-handlerDone
_, err = raw.Read(make([]byte, 1))
require.ErrorIs(t, err, io.ErrClosedPipe)
})
}
+1 -1
View File
@@ -14,7 +14,7 @@
package version
var version = "0.70.1"
var version = "0.71.1"
func Full() string {
return version
+23 -3
View File
@@ -206,13 +206,29 @@ func checkRouteAuthByRequest(req *http.Request, rc *RouteConfig) bool {
}
func (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Request) {
if req.ContentLength > 0 || len(req.TransferEncoding) > 0 {
http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
hj, ok := rw.(http.Hijacker)
if !ok {
rw.WriteHeader(http.StatusInternalServerError)
return
}
client, _, err := hj.Hijack()
// Forward only CONNECT headers; the original Body must not be used after Hijack.
outreq := req.Clone(req.Context())
outreq.Body = nil
outreq.GetBody = nil
outreq.ContentLength = 0
outreq.TransferEncoding = nil
outreq.Trailer = nil
outreq.Header.Del("Content-Length")
outreq.Header.Del("Transfer-Encoding")
outreq.Header.Del("Trailer")
client, buffered, err := hj.Hijack()
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
@@ -224,8 +240,12 @@ func (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Req
client.Close()
return
}
_ = req.Write(remote)
go libio.Join(remote, client)
if err := outreq.Write(remote); err != nil {
_ = remote.Close()
_ = client.Close()
return
}
go libio.Join(remote, libio.WrapReadWriteCloser(buffered.Reader, client, client.Close))
}
func getRequestRouteUser(req *http.Request) string {
+249
View File
@@ -3,17 +3,266 @@ package vhost
import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
httppkg "github.com/fatedier/frp/pkg/util/http"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func TestHTTPConnectTunnel(t *testing.T) {
for _, tc := range []struct {
name string
framing string
pipelined bool
}{
{name: "plain"},
{name: "plain-early", pipelined: true},
{name: "cl0", framing: "Content-Length: 0\r\n"},
{name: "cl0-early", framing: "Content-Length: 0\r\n", pipelined: true},
} {
t.Run(tc.name, func(t *testing.T) {
remote, backend := net.Pipe()
defer remote.Close()
defer backend.Close()
require.NoError(t, backend.SetDeadline(time.Now().Add(5*time.Second)))
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
require.NoError(t, rp.Register(RouteConfig{
Domain: "target.example.com",
Username: "alice",
Password: "secret",
CreateConnFn: func(string) (net.Conn, error) {
return remote, nil
},
}))
listener := netpkg.NewInternalListener()
server := &http.Server{Handler: rp, ReadHeaderTimeout: time.Second}
serveErr := make(chan error, 1)
go func() {
serveErr <- server.Serve(listener)
}()
defer func() {
require.NoError(t, server.Close())
require.ErrorIs(t, <-serveErr, http.ErrServerClosed)
}()
client, incoming := net.Pipe()
defer client.Close()
defer incoming.Close()
require.NoError(t, client.SetDeadline(time.Now().Add(5*time.Second)))
require.NoError(t, listener.PutConn(incoming))
proxyAuth := httppkg.BasicAuth("alice", "secret")
request := "CONNECT target.example.com:443 HTTP/1.1\r\nHost: target.example.com:443\r\n" +
"X-Connect-Test: tunnel\r\nProxy-Authorization: " + proxyAuth + "\r\n" + tc.framing + "\r\n"
if tc.pipelined {
// One pipe write puts the tunnel data in net/http's request buffer.
request += "early data"
}
_, err := io.WriteString(client, request)
require.NoError(t, err)
backendReader := bufio.NewReader(backend)
req, err := http.ReadRequest(backendReader)
require.NoError(t, err)
defer req.Body.Close()
require.Equal(t, http.MethodConnect, req.Method)
require.Equal(t, "target.example.com:443", req.Host)
require.Equal(t, "target.example.com:443", req.URL.Host)
require.Equal(t, "tunnel", req.Header.Get("X-Connect-Test"))
require.Equal(t, proxyAuth, req.Header.Get("Proxy-Authorization"))
require.Zero(t, req.ContentLength)
require.Empty(t, req.TransferEncoding)
if tc.pipelined {
data := make([]byte, len("early data"))
_, err = io.ReadFull(backendReader, data)
require.NoError(t, err)
require.Equal(t, "early data", string(data))
}
writeErr := make(chan error, 1)
go func() {
_, err := io.WriteString(backend, "HTTP/1.1 200 Connection Established\r\n\r\nreply")
writeErr <- err
}()
clientReader := bufio.NewReader(client)
response, err := http.ReadResponse(clientReader, req)
require.NoError(t, err)
defer response.Body.Close()
require.Equal(t, http.StatusOK, response.StatusCode)
reply := make([]byte, len("reply"))
_, err = io.ReadFull(clientReader, reply)
require.NoError(t, err)
require.Equal(t, "reply", string(reply))
require.NoError(t, <-writeErr)
_, err = io.WriteString(client, "later data")
require.NoError(t, err)
data := make([]byte, len("later data"))
_, err = io.ReadFull(backendReader, data)
require.NoError(t, err)
require.Equal(t, "later data", string(data))
})
}
}
func TestHTTPConnectRejectsContent(t *testing.T) {
for _, tc := range []struct {
name string
framing string
body string
}{
{name: "content-length", framing: "Content-Length: 1\r\n", body: "X"},
{name: "chunked", framing: "Transfer-Encoding: chunked\r\n", body: "1\r\nX\r\n0\r\n\r\n"},
} {
t.Run(tc.name, func(t *testing.T) {
backendCalls := 0
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
require.NoError(t, rp.Register(RouteConfig{
Domain: "target.example.com",
CreateConnFn: func(string) (net.Conn, error) {
backendCalls++
return nil, io.ErrClosedPipe
},
}))
req, err := http.ReadRequest(bufio.NewReader(strings.NewReader(
"CONNECT target.example.com:443 HTTP/1.1\r\nHost: target.example.com:443\r\n" + tc.framing + "\r\n" + tc.body)))
require.NoError(t, err)
defer req.Body.Close()
rw := &connectTestResponseWriter{ResponseRecorder: httptest.NewRecorder()}
rp.ServeHTTP(rw, req)
require.Equal(t, http.StatusBadRequest, rw.Code)
require.Zero(t, rw.hijackCalls, "reject content before Hijack")
require.Zero(t, backendCalls, "reject content before creating a backend")
})
}
}
func TestHTTPConnectHeaderWriteFailure(t *testing.T) {
for _, originalBody := range []bool{false, true} {
t.Run(fmt.Sprintf("originalBody=%v", originalBody), func(t *testing.T) {
client, incoming := net.Pipe()
defer client.Close()
defer incoming.Close()
remote, backend := net.Pipe()
defer remote.Close()
defer backend.Close()
clientConn := &connectTestConn{Conn: incoming}
remoteConn := &connectTestConn{Conn: remote, failWrites: true}
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
require.NoError(t, rp.Register(RouteConfig{
Domain: "target.example.com",
CreateConnFn: func(string) (net.Conn, error) {
return remoteConn, nil
},
}))
req := rp.injectRequestInfoToCtx(httptest.NewRequest(http.MethodConnect, "target.example.com:443", nil))
require.Equal(t, "target.example.com:443", req.Host)
body := &connectTestBody{}
if originalBody {
req.Body = body
}
requestBody := req.Body
getBodyCalls := 0
req.GetBody = func() (io.ReadCloser, error) {
getBodyCalls++
return body, nil
}
req.Header.Set("Content-Length", "0")
req.Header.Set("Trailer", "X-Connect-Trailer")
req.Trailer = http.Header{"X-Connect-Trailer": {"value"}}
originalHeader, originalTrailer := req.Header.Clone(), req.Trailer.Clone()
rw := &connectTestResponseWriter{
ResponseRecorder: httptest.NewRecorder(),
conn: clientConn,
buffered: bufio.NewReadWriter(bufio.NewReader(clientConn), bufio.NewWriter(clientConn)),
}
rp.connectHandler(rw, req)
require.Equal(t, 1, rw.hijackCalls)
require.Zero(t, body.reads, "do not read the original Body after Hijack")
require.Zero(t, body.closes, "do not close the original Body after Hijack")
require.Positive(t, remoteConn.writes.Load(), "exercise CONNECT header write failure")
require.True(t, remoteConn.closed.Load(), "close backend on CONNECT header write failure")
require.True(t, clientConn.closed.Load(), "close client on CONNECT header write failure")
require.Zero(t, remoteConn.reads.Load(), "do not start Join after header write failure")
require.Zero(t, clientConn.reads.Load(), "do not start Join after header write failure")
require.Zero(t, getBodyCalls)
require.Equal(t, requestBody, req.Body)
require.NotNil(t, req.GetBody)
require.Equal(t, originalHeader, req.Header)
require.Equal(t, originalTrailer, req.Trailer)
})
}
}
type connectTestResponseWriter struct {
*httptest.ResponseRecorder
conn net.Conn
buffered *bufio.ReadWriter
hijackCalls int
}
func (rw *connectTestResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
rw.hijackCalls++
if rw.conn == nil {
return nil, nil, io.ErrClosedPipe
}
return rw.conn, rw.buffered, nil
}
type connectTestConn struct {
net.Conn
failWrites bool
closed atomic.Bool
reads atomic.Int32
writes atomic.Int32
}
func (c *connectTestConn) Read(p []byte) (int, error) {
c.reads.Add(1)
return c.Conn.Read(p)
}
func (c *connectTestConn) Write(p []byte) (int, error) {
c.writes.Add(1)
if c.failWrites {
return 0, io.ErrClosedPipe
}
return c.Conn.Write(p)
}
func (c *connectTestConn) Close() error {
c.closed.Store(true)
return c.Conn.Close()
}
type connectTestBody struct {
reads int
closes int
}
func (b *connectTestBody) Read([]byte) (int, error) {
b.reads++
return 0, io.ErrUnexpectedEOF
}
func (b *connectTestBody) Close() error {
b.closes++
return nil
}
func TestHTTPServerProtocols(t *testing.T) {
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
protocols := new(http.Protocols)
+5 -5
View File
@@ -96,21 +96,21 @@ func (l *Logger) Spawn() *Logger {
}
func (l *Logger) Errorf(format string, v ...any) {
log.Logger.Errorf(l.prefixString+format, v...)
log.Logger.WithPrefix(l.prefixString).Errorf(format, v...)
}
func (l *Logger) Warnf(format string, v ...any) {
log.Logger.Warnf(l.prefixString+format, v...)
log.Logger.WithPrefix(l.prefixString).Warnf(format, v...)
}
func (l *Logger) Infof(format string, v ...any) {
log.Logger.Infof(l.prefixString+format, v...)
log.Logger.WithPrefix(l.prefixString).Infof(format, v...)
}
func (l *Logger) Debugf(format string, v ...any) {
log.Logger.Debugf(l.prefixString+format, v...)
log.Logger.WithPrefix(l.prefixString).Debugf(format, v...)
}
func (l *Logger) Tracef(format string, v ...any) {
log.Logger.Tracef(l.prefixString+format, v...)
log.Logger.WithPrefix(l.prefixString).Tracef(format, v...)
}
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package xlog
import (
"bytes"
"testing"
goliblog "github.com/fatedier/golib/log"
"github.com/stretchr/testify/require"
frplog "github.com/fatedier/frp/pkg/util/log"
)
func TestPrefixIsNotPartOfFormatString(t *testing.T) {
tests := []struct {
name string
log func(*Logger)
}{
{name: "error", log: func(xl *Logger) { xl.Errorf("value [%s]", "ok") }},
{name: "warn", log: func(xl *Logger) { xl.Warnf("value [%s]", "ok") }},
{name: "info", log: func(xl *Logger) { xl.Infof("value [%s]", "ok") }},
{name: "debug", log: func(xl *Logger) { xl.Debugf("value [%s]", "ok") }},
{name: "trace", log: func(xl *Logger) { xl.Tracef("value [%s]", "ok") }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := captureLogs(t)
tt.log(New().AppendPrefix("%1000000s"))
require.Contains(t, output.String(), "[%1000000s] value [ok]")
require.Less(t, output.Len(), 1024)
})
}
}
func TestFormattingSemanticsArePreserved(t *testing.T) {
output := captureLogs(t)
xl := New().AppendPrefix("run")
xl.Infof("%[2]s %[1]s", "first", "second")
xl.Infof("100% complete")
require.Contains(t, output.String(), "[run] second first")
require.Contains(t, output.String(), "[run] 100% complete")
}
func captureLogs(t *testing.T) *bytes.Buffer {
t.Helper()
output := bytes.NewBuffer(nil)
oldLogger := frplog.Logger
frplog.Logger = goliblog.New(
goliblog.WithOutput(output),
goliblog.WithLevel(goliblog.TraceLevel),
goliblog.WithCaller(false),
)
t.Cleanup(func() {
frplog.Logger = oldLogger
})
return output
}
+9 -4
View File
@@ -246,9 +246,9 @@ func (c *Controller) RegisterClientRoute(ctx context.Context, name string, route
go c.readLoopClient(ctx, conn)
}
// UnregisterClientRoute Remove client route from routing table
func (c *Controller) UnregisterClientRoute(name string) {
c.clientRouter.delRoute(name)
// UnregisterClientRoute removes a client route only when it is still owned by conn.
func (c *Controller) UnregisterClientRoute(name string, conn io.Writer) bool {
return c.clientRouter.delRoute(name, conn)
}
// StartServerConnReadLoop starts the read loop for a server connection
@@ -304,10 +304,15 @@ func (r *clientRouter) findConn(dst net.IP) (io.Writer, error) {
return nil, fmt.Errorf("no route found for destination %s", dst)
}
func (r *clientRouter) delRoute(name string) {
func (r *clientRouter) delRoute(name string, conn io.Writer) bool {
r.mu.Lock()
defer r.mu.Unlock()
re, ok := r.routes[name]
if !ok || re.conn != conn {
return false
}
delete(r.routes, name)
return true
}
func (r *clientRouter) removeConnRoute(conn io.Writer) {
+64
View File
@@ -0,0 +1,64 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package vnet
import (
"net"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// TestClientRouterDeleteRouteRequiresMatchingConnection verifies that a stale
// visitor cannot remove a replacement route registered under the same name.
func TestClientRouterDeleteRouteRequiresMatchingConnection(t *testing.T) {
require := require.New(t)
controller := NewController(v1.VirtualNetConfig{})
_, route, err := net.ParseCIDR("10.1.0.1/32")
require.NoError(err)
oldConn, oldPeer := net.Pipe()
t.Cleanup(func() {
_ = oldConn.Close()
_ = oldPeer.Close()
})
replacementConn, replacementPeer := net.Pipe()
t.Cleanup(func() {
_ = replacementConn.Close()
_ = replacementPeer.Close()
})
controller.clientRouter.addRoute("vnet-visitor", []net.IPNet{*route}, oldConn)
controller.clientRouter.addRoute("vnet-visitor", []net.IPNet{*route}, replacementConn)
require.False(controller.UnregisterClientRoute("vnet-visitor", oldConn))
got, err := controller.clientRouter.findConn(net.ParseIP("10.1.0.1"))
require.NoError(err)
require.Same(replacementConn, got)
// The read loop for an old connection can exit after a replacement route
// has already been registered. Its deferred cleanup must keep the new owner.
controller.clientRouter.removeConnRoute(oldConn)
got, err = controller.clientRouter.findConn(net.ParseIP("10.1.0.1"))
require.NoError(err)
require.Same(replacementConn, got)
require.True(controller.UnregisterClientRoute("vnet-visitor", replacementConn))
_, err = controller.clientRouter.findConn(net.ParseIP("10.1.0.1"))
require.Error(err)
}
+27 -5
View File
@@ -17,6 +17,7 @@ package server
import (
"context"
"fmt"
"math"
"net"
"runtime/debug"
"sync"
@@ -45,6 +46,8 @@ type ControlID uint64
var nextControlID atomic.Uint64
const workConnPoolCapacityOffset = 10
type controlEntry struct {
ctl *Control
id ControlID
@@ -283,7 +286,7 @@ func (cm *ControlManager) GetByID(runID string) (ctl *Control, ok bool) {
// admitVisitorByRunID commits a visitor admission against the current running
// control while its run and lifecycle ownership are held. The callback must
// only perform the in-memory, buffered visitor admission.
func (cm *ControlManager) admitVisitorByRunID(runID string, admit func(user string) error) (bool, error) {
func (cm *ControlManager) admitVisitorByRunID(runID string, admit func(user, wireProtocol, udpPacketCodec string) error) (bool, error) {
entry, ok := cm.lockCurrentRun(runID, false)
if !ok {
return false, nil
@@ -296,7 +299,7 @@ func (cm *ControlManager) admitVisitorByRunID(runID string, admit func(user stri
if ctl.state != controlStateRunning {
return false, nil
}
return true, admit(ctl.sessionCtx.LoginMsg.User)
return true, admit(ctl.sessionCtx.LoginMsg.User, ctl.sessionCtx.WireProtocol, ctl.sessionCtx.UDPPacketCodec)
}
// RegisterWorkConn transfers conn to ctl only if ctl is still the current
@@ -368,7 +371,8 @@ type SessionContext struct {
// server configuration
ServerCfg *v1.ServerConfig
// negotiated wire protocol for this client session
WireProtocol string
WireProtocol string
UDPPacketCodec string
}
type controlState uint8
@@ -430,10 +434,27 @@ type Control struct {
}
func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, error) {
poolCount := min(sessionCtx.LoginMsg.PoolCount, int(sessionCtx.ServerCfg.Transport.MaxPoolCount))
if sessionCtx.LoginMsg.PoolCount < 0 {
return nil, fmt.Errorf("invalid pool count %d, must be non-negative", sessionCtx.LoginMsg.PoolCount)
}
if sessionCtx.ServerCfg.Transport.MaxPoolCount < 0 {
return nil, fmt.Errorf(
"invalid max pool count %d, must be non-negative",
sessionCtx.ServerCfg.Transport.MaxPoolCount,
)
}
effectivePoolCount := min(int64(sessionCtx.LoginMsg.PoolCount), sessionCtx.ServerCfg.Transport.MaxPoolCount)
maxPoolCountForChannel := int64(math.MaxInt) - int64(workConnPoolCapacityOffset)
if effectivePoolCount > maxPoolCountForChannel {
return nil, fmt.Errorf(
"invalid effective pool count %d, cannot safely add %d for work connection pool capacity",
effectivePoolCount, workConnPoolCapacityOffset,
)
}
poolCount := int(effectivePoolCount)
ctl := &Control{
sessionCtx: sessionCtx,
workConnCh: make(chan *proxy.WorkConn, poolCount+10),
workConnCh: make(chan *proxy.WorkConn, poolCount+workConnPoolCapacityOffset),
proxies: make(map[string]proxy.Proxy),
poolCount: poolCount,
portsUsedNum: 0,
@@ -821,6 +842,7 @@ func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err
ServerCfg: ctl.sessionCtx.ServerCfg,
EncryptionKey: ctl.sessionCtx.EncryptionKey,
WireProtocol: ctl.sessionCtx.WireProtocol,
UDPPacketCodec: ctl.sessionCtx.UDPPacketCodec,
})
if err != nil {
return remoteAddr, err
+52
View File
@@ -17,6 +17,7 @@ package server
import (
"context"
"errors"
"math"
"net"
"os"
"sync"
@@ -52,6 +53,57 @@ func TestControlPendingReplacementFinishesWithoutStarting(t *testing.T) {
require.Equal(t, int64(0), metrics.closedClients())
}
func TestNewControlPoolCountBoundaries(t *testing.T) {
for _, tc := range []struct {
name string
poolCount int
maxPoolCount int64
wantErr string
wantPoolCount int
wantCapacity int
}{
{name: "negative pool count below offset", poolCount: -11, maxPoolCount: 5, wantErr: "invalid pool count"},
{name: "negative pool count at offset", poolCount: -10, maxPoolCount: 5, wantErr: "invalid pool count"},
{name: "negative pool count", poolCount: -1, maxPoolCount: 5, wantErr: "invalid pool count"},
{name: "zero pool count", poolCount: 0, maxPoolCount: 5, wantPoolCount: 0, wantCapacity: 10},
{name: "pool count capped", poolCount: 10, maxPoolCount: 5, wantPoolCount: 5, wantCapacity: 15},
{name: "maximum int pool count capped", poolCount: math.MaxInt, maxPoolCount: 5, wantPoolCount: 5, wantCapacity: 15},
{name: "negative maximum", poolCount: 1, maxPoolCount: -1, wantErr: "invalid max pool count"},
{name: "maximum int64 with small client pool", poolCount: 1, maxPoolCount: math.MaxInt64, wantPoolCount: 1, wantCapacity: 11},
{name: "maximum int client and server overflow", poolCount: math.MaxInt, maxPoolCount: math.MaxInt64, wantErr: "cannot safely add"},
} {
t.Run(tc.name, func(t *testing.T) {
conn := newDeadlineReadConn()
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
cfg := &v1.ServerConfig{}
cfg.Transport.MaxPoolCount = tc.maxPoolCount
ctl, err := NewControl(context.Background(), &SessionContext{
RC: &controller.ResourceController{},
PxyManager: proxy.NewManager(),
PluginManager: plugin.NewManager(),
AuthVerifier: auth.AlwaysPassVerifier,
Conn: msgConn,
LoginMsg: &msg.Login{
RunID: "pool-count-run",
PoolCount: tc.poolCount,
},
ServerCfg: cfg,
})
if tc.wantErr != "" {
require.Nil(t, ctl)
require.ErrorContains(t, err, tc.wantErr)
return
}
require.NoError(t, err)
require.Equal(t, tc.wantPoolCount, ctl.poolCount)
require.Equal(t, tc.wantCapacity, cap(ctl.workConnCh))
require.NoError(t, ctl.Close())
})
}
}
func TestControlRunningReplacementFinishesInWorker(t *testing.T) {
clientRegistry := registry.NewClientRegistry()
manager := NewControlManager(clientRegistry)
+3 -7
View File
@@ -615,7 +615,7 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
name: "tcp remote port",
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
Type: "tcp",
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
TCP: &model.V2TCPProxySpec{RemotePort: new(6000)},
}},
q: "6000",
want: true,
@@ -624,7 +624,7 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
name: "udp remote port",
item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{
Type: "udp",
UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)},
UDP: &model.V2UDPProxySpec{RemotePort: new(7000)},
}},
q: "7000",
want: true,
@@ -633,7 +633,7 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
name: "remote port does not match colon form",
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
Type: "tcp",
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
TCP: &model.V2TCPProxySpec{RemotePort: new(6000)},
}},
q: ":6000",
want: false,
@@ -767,10 +767,6 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
}
}
func v2TestIntPtr(value int) *int {
return &value
}
func newV2TestController(t *testing.T) *Controller {
t.Helper()
+1 -3
View File
@@ -139,9 +139,7 @@ func (pxy *HTTPProxy) GetRealConn(remoteAddr string) (workConn net.Conn, err err
}
if pxy.GetLimiter() != nil {
rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), func() error {
return rwc.Close()
})
rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), rwc.Close)
}
workConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn)
+94 -37
View File
@@ -82,19 +82,20 @@ type Proxy interface {
}
type BaseProxy struct {
name string
rc *controller.ResourceController
listeners []net.Listener
usedPortsNum int
poolCount int
getWorkConnFn GetWorkConnFn
serverCfg *v1.ServerConfig
encryptionKey []byte
limiter *rate.Limiter
userInfo plugin.UserInfo
loginMsg *msg.Login
configurer v1.ProxyConfigurer
wireProtocol string
name string
rc *controller.ResourceController
listeners []net.Listener
usedPortsNum int
poolCount int
getWorkConnFn GetWorkConnFn
serverCfg *v1.ServerConfig
encryptionKey []byte
limiter *rate.Limiter
userInfo plugin.UserInfo
loginMsg *msg.Login
configurer v1.ProxyConfigurer
wireProtocol string
udpPacketCodec string
mu sync.RWMutex
xl *xlog.Logger
@@ -307,9 +308,7 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
}
if pxy.GetLimiter() != nil {
local = libio.WrapReadWriteCloser(limit.NewReader(local, pxy.GetLimiter()), limit.NewWriter(local, pxy.GetLimiter()), func() error {
return local.Close()
})
local = libio.WrapReadWriteCloser(limit.NewReader(local, pxy.GetLimiter()), limit.NewWriter(local, pxy.GetLimiter()), local.Close)
}
xl.Debugf("join connections, workConn(l[%s] r[%s]) userConn(l[%s] r[%s])", workConn.LocalAddr().String(),
@@ -327,10 +326,18 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
func (pxy *BaseProxy) joinUserConnection(local io.ReadWriteCloser, userConn net.Conn, proxyType string, xl *xlog.Logger) (int64, int64, []error) {
visitorWireProtocol := wireProtocolFromConn(userConn)
if proxyType == string(v1.ProxyTypeSUDP) && isMixedWireProtocol(pxy.wireProtocol, visitorWireProtocol) {
xl.Infof("bridge mixed SUDP payload codecs, proxy wireProtocol [%s], visitor wireProtocol [%s]",
normalizeWireProtocol(pxy.wireProtocol), normalizeWireProtocol(visitorWireProtocol))
return joinSUDPMessageBridge(local, userConn, pxy.wireProtocol, visitorWireProtocol, xl)
visitorUDPPacketCodec := udpPacketCodecFromConn(userConn)
if proxyType == string(v1.ProxyTypeSUDP) {
mixed, err := isMixedSUDPPacketEncoding(pxy.wireProtocol, pxy.udpPacketCodec, visitorWireProtocol, visitorUDPPacketCodec)
if err != nil {
return 0, 0, []error{err}
}
if mixed {
xl.Infof("bridge mixed SUDP payload codecs, proxy [%s/%s], visitor [%s/%s]",
normalizeWireProtocol(pxy.wireProtocol), pxy.udpPacketCodec,
normalizeWireProtocol(visitorWireProtocol), visitorUDPPacketCodec)
return joinSUDPMessageBridge(local, userConn, pxy.wireProtocol, pxy.udpPacketCodec, visitorWireProtocol, visitorUDPPacketCodec, xl)
}
}
return libio.Join(local, userConn)
}
@@ -339,6 +346,10 @@ type wireProtocolGetter interface {
WireProtocol() string
}
type udpPacketCodecGetter interface {
UDPPacketCodec() string
}
func wireProtocolFromConn(conn net.Conn) string {
if getter, ok := conn.(wireProtocolGetter); ok {
return getter.WireProtocol()
@@ -346,10 +357,46 @@ func wireProtocolFromConn(conn net.Conn) string {
return ""
}
func udpPacketCodecFromConn(conn net.Conn) string {
if getter, ok := conn.(udpPacketCodecGetter); ok {
return getter.UDPPacketCodec()
}
return ""
}
func isMixedWireProtocol(left, right string) bool {
return normalizeWireProtocol(left) != normalizeWireProtocol(right)
}
func isMixedSUDPPacketEncoding(leftWire, leftCodec, rightWire, rightCodec string) (bool, error) {
leftCodec, err := normalizeUDPPacketCodec(leftWire, leftCodec)
if err != nil {
return false, fmt.Errorf("invalid left SUDP packet encoding: %w", err)
}
rightCodec, err = normalizeUDPPacketCodec(rightWire, rightCodec)
if err != nil {
return false, fmt.Errorf("invalid right SUDP packet encoding: %w", err)
}
return normalizeWireProtocol(leftWire) != normalizeWireProtocol(rightWire) || leftCodec != rightCodec, nil
}
func normalizeUDPPacketCodec(wireProtocol, codec string) (string, error) {
switch wireProtocol {
case "", wire.ProtocolV1:
if codec != "" {
return "", fmt.Errorf("UDP packet codec %q requires wire protocol v2", codec)
}
return "", nil
case wire.ProtocolV2:
if codec == "" || codec == wire.UDPPacketCodecBinary {
return codec, nil
}
return "", fmt.Errorf("unsupported UDP packet codec %q", codec)
default:
return "", fmt.Errorf("unsupported wire protocol %q", wireProtocol)
}
}
func normalizeWireProtocol(wireProtocol string) string {
if wireProtocol == wire.ProtocolV2 {
return wire.ProtocolV2
@@ -361,13 +408,21 @@ func joinSUDPMessageBridge(
proxyConn io.ReadWriteCloser,
visitorConn io.ReadWriteCloser,
proxyWireProtocol string,
proxyUDPPacketCodec string,
visitorWireProtocol string,
visitorUDPPacketCodec string,
xl *xlog.Logger,
) (inCount int64, outCount int64, errs []error) {
// The mixed bridge decodes and re-encodes messages, so raw framed byte counts
// are not available. Count UDP payload bytes and ignore heartbeat traffic.
proxyRW := msg.NewReadWriter(proxyConn, proxyWireProtocol)
visitorRW := msg.NewReadWriter(visitorConn, visitorWireProtocol)
proxyRW, err := msg.NewUDPPacketReadWriter(proxyConn, proxyWireProtocol, proxyUDPPacketCodec)
if err != nil {
return 0, 0, []error{err}
}
visitorRW, err := msg.NewUDPPacketReadWriter(visitorConn, visitorWireProtocol, visitorUDPPacketCodec)
if err != nil {
return 0, 0, []error{err}
}
var (
once sync.Once
@@ -469,6 +524,7 @@ type Options struct {
ServerCfg *v1.ServerConfig
EncryptionKey []byte
WireProtocol string
UDPPacketCodec string
}
func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
@@ -478,24 +534,25 @@ func NewProxy(ctx context.Context, options *Options) (pxy Proxy, err error) {
var limiter *rate.Limiter
limitBytes := configurer.GetBaseConfig().Transport.BandwidthLimit.Bytes()
if limitBytes > 0 && configurer.GetBaseConfig().Transport.BandwidthLimitMode == types.BandwidthLimitModeServer {
limiter = rate.NewLimiter(rate.Limit(float64(limitBytes)), int(limitBytes))
limiter = limit.NewBandwidthLimiter(limitBytes)
}
basePxy := BaseProxy{
name: configurer.GetBaseConfig().Name,
rc: options.ResourceController,
listeners: make([]net.Listener, 0),
poolCount: options.PoolCount,
getWorkConnFn: options.GetWorkConnFn,
serverCfg: options.ServerCfg,
encryptionKey: options.EncryptionKey,
limiter: limiter,
xl: xl,
ctx: xlog.NewContext(ctx, xl),
userInfo: options.UserInfo,
loginMsg: options.LoginMsg,
configurer: configurer,
wireProtocol: options.WireProtocol,
name: configurer.GetBaseConfig().Name,
rc: options.ResourceController,
listeners: make([]net.Listener, 0),
poolCount: options.PoolCount,
getWorkConnFn: options.GetWorkConnFn,
serverCfg: options.ServerCfg,
encryptionKey: options.EncryptionKey,
limiter: limiter,
xl: xl,
ctx: xlog.NewContext(ctx, xl),
userInfo: options.UserInfo,
loginMsg: options.LoginMsg,
configurer: configurer,
wireProtocol: options.WireProtocol,
udpPacketCodec: options.UDPPacketCodec,
}
factory := proxyFactoryRegistry[reflect.TypeOf(configurer)]
+232
View File
@@ -0,0 +1,232 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package proxy
import (
"bytes"
"fmt"
"io"
"net"
"testing"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
)
type sudpPathBenchmarkCase struct {
name string
packet *msg.UDPPacket
}
var sudpPathBenchmarkBytesSink []byte
func sudpPathBenchmarkCases(payloadSize int) []sudpPathBenchmarkCase {
content := bytes.Repeat([]byte{0x5a}, payloadSize)
return []sudpPathBenchmarkCase{
{
name: "ipv4-remote",
packet: &msg.UDPPacket{
Content: content,
RemoteAddr: &net.UDPAddr{
IP: net.ParseIP("192.0.2.1"), Port: 12345,
},
},
},
{
name: "ipv4-local-remote",
packet: &msg.UDPPacket{
Content: content,
LocalAddr: &net.UDPAddr{
IP: net.ParseIP("192.0.2.2"), Port: 23456,
},
RemoteAddr: &net.UDPAddr{
IP: net.ParseIP("192.0.2.1"), Port: 12345,
},
},
},
{
name: "ipv6-remote",
packet: &msg.UDPPacket{
Content: content,
RemoteAddr: &net.UDPAddr{
IP: net.ParseIP("2001:db8::1"), Port: 12345,
},
},
},
{
name: "ipv6-local-remote",
packet: &msg.UDPPacket{
Content: content,
LocalAddr: &net.UDPAddr{
IP: net.ParseIP("2001:db8::2"), Port: 23456, Zone: "bench0",
},
RemoteAddr: &net.UDPAddr{
IP: net.ParseIP("2001:db8::1"), Port: 12345, Zone: "bench1",
},
},
},
}
}
type sudpPathReadWriter struct {
reader bytes.Reader
}
func (rw *sudpPathReadWriter) Read(p []byte) (int, error) { return rw.reader.Read(p) }
func (rw *sudpPathReadWriter) Write(p []byte) (int, error) { return len(p), nil }
func (rw *sudpPathReadWriter) Reset(p []byte) { rw.reader.Reset(p) }
func sudpPathWireBytes(b testing.TB, packet *msg.UDPPacket, codec string) []byte {
b.Helper()
var buf bytes.Buffer
rw, err := msg.NewUDPPacketReadWriter(&buf, wire.ProtocolV2, codec)
if err != nil {
b.Fatal(err)
}
if err := rw.WriteMsg(packet); err != nil {
b.Fatal(err)
}
return append([]byte(nil), buf.Bytes()...)
}
func sudpPathCopyFrame(dst, src []byte) []byte {
copy(dst, src)
return dst
}
func BenchmarkSUDPInMemoryFrameCopy(b *testing.B) {
// This is an in-memory copy of an already encoded frame. It is a proxy for
// frame-size-dependent copy work, not a benchmark of libio.Join or sockets.
for _, payloadSize := range []int{64, 512, 1200, 1472} {
for _, tc := range sudpPathBenchmarkCases(payloadSize) {
for _, codec := range []struct {
name string
value string
}{
{name: "json", value: ""},
{name: "binary", value: wire.UDPPacketCodecBinary},
} {
b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, codec.name), func(b *testing.B) {
encoded := sudpPathWireBytes(b, tc.packet, codec.value)
dst := make([]byte, len(encoded))
b.SetBytes(int64(len(encoded)))
for b.Loop() {
dst = sudpPathCopyFrame(dst, encoded)
}
if !bytes.Equal(dst, encoded) {
b.Fatal("copied frame does not match source")
}
sudpPathBenchmarkBytesSink = dst
})
}
}
}
}
func BenchmarkSUDPEndpointCodecPair(b *testing.B) {
// This measures an in-memory decode and re-encode with the same codec. It
// does not include the live SUDP server path, sockets, goroutines, or I/O.
for _, payloadSize := range []int{64, 512, 1200, 1472} {
for _, tc := range sudpPathBenchmarkCases(payloadSize) {
for _, codec := range []struct {
name string
value string
}{
{name: "json", value: ""},
{name: "binary", value: wire.UDPPacketCodecBinary},
} {
b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, codec.name), func(b *testing.B) {
encoded := sudpPathWireBytes(b, tc.packet, codec.value)
from := &sudpPathReadWriter{}
to := &bytes.Buffer{}
fromRW, err := msg.NewUDPPacketReadWriter(from, wire.ProtocolV2, codec.value)
if err != nil {
b.Fatal(err)
}
toRW, err := msg.NewUDPPacketReadWriter(to, wire.ProtocolV2, codec.value)
if err != nil {
b.Fatal(err)
}
b.SetBytes(int64(len(encoded)))
for b.Loop() {
from.Reset(encoded)
to.Reset()
m, err := fromRW.ReadMsg()
if err != nil {
b.Fatal(err)
}
if err := toRW.WriteMsg(m); err != nil {
b.Fatal(err)
}
}
if !bytes.Equal(to.Bytes(), encoded) {
b.Fatalf("re-encoded packet mismatch: got %d bytes, want %d", to.Len(), len(encoded))
}
sudpPathBenchmarkBytesSink = to.Bytes()
})
}
}
}
}
func BenchmarkSUDPMixedCodecTranscodeModel(b *testing.B) {
// This exercises the real codec decode/re-encode pair used by the mixed
// bridge, excluding sockets, goroutines, crypto, compression, and framing I/O.
for _, payloadSize := range []int{64, 512, 1200, 1472} {
for _, tc := range sudpPathBenchmarkCases(payloadSize) {
for _, direction := range []struct {
name string
from string
to string
}{
{name: "json-to-binary", from: "", to: wire.UDPPacketCodecBinary},
{name: "binary-to-json", from: wire.UDPPacketCodecBinary, to: ""},
} {
b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, direction.name), func(b *testing.B) {
encoded := sudpPathWireBytes(b, tc.packet, direction.from)
expected := sudpPathWireBytes(b, tc.packet, direction.to)
from := &sudpPathReadWriter{}
to := &bytes.Buffer{}
fromRW, err := msg.NewUDPPacketReadWriter(from, wire.ProtocolV2, direction.from)
if err != nil {
b.Fatal(err)
}
toRW, err := msg.NewUDPPacketReadWriter(to, wire.ProtocolV2, direction.to)
if err != nil {
b.Fatal(err)
}
b.SetBytes(int64(len(encoded)))
for b.Loop() {
from.Reset(encoded)
to.Reset()
m, err := fromRW.ReadMsg()
if err != nil {
b.Fatal(err)
}
if err := toRW.WriteMsg(m); err != nil {
b.Fatal(err)
}
}
if !bytes.Equal(to.Bytes(), expected) {
b.Fatalf("transcoded packet mismatch: got %d bytes, want %d", to.Len(), len(expected))
}
sudpPathBenchmarkBytesSink = to.Bytes()
})
}
}
}
}
var _ io.ReadWriter = (*sudpPathReadWriter)(nil)
+238 -21
View File
@@ -18,22 +18,27 @@ import (
"bufio"
"bytes"
"encoding/binary"
"io"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
"github.com/fatedier/frp/pkg/util/xlog"
)
func TestSUDPBridgeTranscodesProxyV1ToVisitorV2(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, &msg.UDPPacket{Content: []byte("proxy-to-visitor")})
writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, "", &msg.UDPPacket{Content: []byte("proxy-to-visitor")})
var count int64
err := bridgeSUDPProxyToVisitor(
msg.NewReadWriter(&in, wire.ProtocolV1),
msg.NewReadWriter(&out, wire.ProtocolV2),
newSUDPBridgeRW(t, &in, wire.ProtocolV1, ""),
newSUDPBridgeRW(t, &out, wire.ProtocolV2, ""),
&count,
nil,
)
@@ -53,12 +58,12 @@ func TestSUDPBridgeTranscodesProxyV1ToVisitorV2(t *testing.T) {
func TestSUDPBridgeTranscodesVisitorV2ToProxyV1(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.UDPPacket{Content: []byte("visitor-to-proxy")})
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, "", &msg.UDPPacket{Content: []byte("visitor-to-proxy")})
var count int64
err := bridgeSUDPVisitorToProxy(
msg.NewReadWriter(&in, wire.ProtocolV2),
msg.NewReadWriter(&out, wire.ProtocolV1),
newSUDPBridgeRW(t, &in, wire.ProtocolV2, ""),
newSUDPBridgeRW(t, &out, wire.ProtocolV1, ""),
&count,
nil,
)
@@ -76,33 +81,67 @@ func TestSUDPBridgeTranscodesVisitorV2ToProxyV1(t *testing.T) {
require.Equal(t, []byte("visitor-to-proxy"), got.Content)
}
func TestSUDPBridgeForwardsProxyPing(t *testing.T) {
func TestSUDPBridgeTranscodesProxyV2BinaryToVisitorV2JSON(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, &msg.Ping{})
packet := newSUDPBridgeUDPPacket("proxy-binary-to-json")
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, wire.UDPPacketCodecBinary, packet)
var count int64
err := bridgeSUDPProxyToVisitor(
msg.NewReadWriter(&in, wire.ProtocolV1),
msg.NewReadWriter(&out, wire.ProtocolV2),
newSUDPBridgeRW(t, &in, wire.ProtocolV2, wire.UDPPacketCodecBinary),
newSUDPBridgeRW(t, &out, wire.ProtocolV2, ""),
&count,
nil,
)
require.NoError(t, err)
require.Equal(t, int64(len(packet.Content)), count)
requireV2UDPPacketFrame(t, &out, msg.V2TypeUDPPacket, packet)
}
func TestSUDPBridgeTranscodesVisitorV2JSONToProxyV2Binary(t *testing.T) {
var in, out bytes.Buffer
packet := newSUDPBridgeUDPPacket("visitor-json-to-binary")
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, "", packet)
var count int64
err := bridgeSUDPVisitorToProxy(
newSUDPBridgeRW(t, &in, wire.ProtocolV2, ""),
newSUDPBridgeRW(t, &out, wire.ProtocolV2, wire.UDPPacketCodecBinary),
&count,
nil,
)
require.NoError(t, err)
require.Equal(t, int64(len(packet.Content)), count)
requireV2UDPPacketFrame(t, &out, msg.V2TypeUDPPacketBinary, packet)
}
func TestSUDPBridgeForwardsProxyPing(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV1, "", &msg.Ping{})
var count int64
err := bridgeSUDPProxyToVisitor(
newSUDPBridgeRW(t, &in, wire.ProtocolV1, ""),
newSUDPBridgeRW(t, &out, wire.ProtocolV2, ""),
&count,
nil,
)
require.NoError(t, err)
require.Zero(t, count)
rawMsg, err := msg.NewReadWriter(&out, wire.ProtocolV2).ReadMsg()
rawMsg, err := newSUDPBridgeRW(t, &out, wire.ProtocolV2, "").ReadMsg()
require.NoError(t, err)
require.IsType(t, &msg.Ping{}, rawMsg)
}
func TestSUDPBridgeDropsVisitorPing(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.Ping{})
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, "", &msg.Ping{})
var count int64
err := bridgeSUDPVisitorToProxy(
msg.NewReadWriter(&in, wire.ProtocolV2),
msg.NewReadWriter(&out, wire.ProtocolV1),
newSUDPBridgeRW(t, &in, wire.ProtocolV2, ""),
newSUDPBridgeRW(t, &out, wire.ProtocolV1, ""),
&count,
nil,
)
@@ -113,12 +152,12 @@ func TestSUDPBridgeDropsVisitorPing(t *testing.T) {
func TestSUDPBridgeRejectsUnknownVisitorMessage(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, &msg.Pong{})
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, "", &msg.Pong{})
var count int64
err := bridgeSUDPVisitorToProxy(
msg.NewReadWriter(&in, wire.ProtocolV2),
msg.NewReadWriter(&out, wire.ProtocolV1),
newSUDPBridgeRW(t, &in, wire.ProtocolV2, ""),
newSUDPBridgeRW(t, &out, wire.ProtocolV1, ""),
&count,
nil,
)
@@ -127,6 +166,22 @@ func TestSUDPBridgeRejectsUnknownVisitorMessage(t *testing.T) {
require.Empty(t, out.Bytes())
}
func TestSUDPBridgeRejectsMismatchedPacketCodecOnStream(t *testing.T) {
var in, out bytes.Buffer
writeSUDPBridgeMsg(t, &in, wire.ProtocolV2, "", newSUDPBridgeUDPPacket("json-on-binary-stream"))
var count int64
err := bridgeSUDPProxyToVisitor(
newSUDPBridgeRW(t, &in, wire.ProtocolV2, wire.UDPPacketCodecBinary),
newSUDPBridgeRW(t, &out, wire.ProtocolV2, ""),
&count,
nil,
)
require.ErrorContains(t, err, "received JSON UDP packet after binary codec negotiation")
require.Zero(t, count)
require.Empty(t, out.Bytes())
}
func TestSUDPBridgeDetectsMixedWireProtocol(t *testing.T) {
require.False(t, isMixedWireProtocol("", wire.ProtocolV1))
require.False(t, isMixedWireProtocol(wire.ProtocolV2, wire.ProtocolV2))
@@ -134,8 +189,170 @@ func TestSUDPBridgeDetectsMixedWireProtocol(t *testing.T) {
require.True(t, isMixedWireProtocol(wire.ProtocolV2, wire.ProtocolV1))
}
func writeSUDPBridgeMsg(t *testing.T, buf *bytes.Buffer, wireProtocol string, m msg.Message) {
t.Helper()
require.NoError(t, msg.NewReadWriter(buf, wireProtocol).WriteMsg(m))
func TestSUDPBridgeDetectsMixedPacketEncoding(t *testing.T) {
for _, tc := range []struct {
name string
leftWire string
leftCodec string
rightWire string
rightCodec string
mixed bool
}{
{name: "legacy v1 aliases explicit v1", leftWire: "", rightWire: wire.ProtocolV1},
{name: "v2 json matches v2 json", leftWire: wire.ProtocolV2, rightWire: wire.ProtocolV2},
{
name: "v2 binary matches v2 binary",
leftWire: wire.ProtocolV2,
leftCodec: wire.UDPPacketCodecBinary,
rightWire: wire.ProtocolV2,
rightCodec: wire.UDPPacketCodecBinary,
},
{name: "v1 json differs from v2 json", leftWire: wire.ProtocolV1, rightWire: wire.ProtocolV2, mixed: true},
{
name: "v2 json differs from v2 binary",
leftWire: wire.ProtocolV2,
rightWire: wire.ProtocolV2,
rightCodec: wire.UDPPacketCodecBinary,
mixed: true,
},
{
name: "v2 binary differs from v1 json",
leftWire: wire.ProtocolV2,
leftCodec: wire.UDPPacketCodecBinary,
rightWire: wire.ProtocolV1,
mixed: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
mixed, err := isMixedSUDPPacketEncoding(tc.leftWire, tc.leftCodec, tc.rightWire, tc.rightCodec)
require.NoError(t, err)
require.Equal(t, tc.mixed, mixed)
})
}
}
func TestSUDPBridgeRejectsInvalidEncodingMetadata(t *testing.T) {
for _, tc := range []struct {
name string
leftWire string
leftCodec string
rightWire string
rightCodec string
wantErr string
}{
{
name: "left v1 binary",
leftWire: wire.ProtocolV1,
leftCodec: wire.UDPPacketCodecBinary,
rightWire: wire.ProtocolV1,
wantErr: "invalid left SUDP packet encoding",
},
{
name: "right unknown v2 codec",
leftWire: wire.ProtocolV2,
rightWire: wire.ProtocolV2,
rightCodec: "snappy",
wantErr: "invalid right SUDP packet encoding",
},
{name: "left unknown wire", leftWire: "v3", rightWire: wire.ProtocolV2, wantErr: "unsupported wire protocol"},
} {
t.Run(tc.name, func(t *testing.T) {
mixed, err := isMixedSUDPPacketEncoding(tc.leftWire, tc.leftCodec, tc.rightWire, tc.rightCodec)
require.False(t, mixed)
require.ErrorContains(t, err, tc.wantErr)
})
}
}
func TestSUDPJoinUsesRawPathForSameEncodingState(t *testing.T) {
proxyClient, proxyServer := net.Pipe()
visitorClient, visitorServer := net.Pipe()
t.Cleanup(func() {
_ = proxyClient.Close()
_ = proxyServer.Close()
_ = visitorClient.Close()
_ = visitorServer.Close()
})
deadline := time.Now().Add(3 * time.Second)
require.NoError(t, proxyClient.SetDeadline(deadline))
require.NoError(t, proxyServer.SetDeadline(deadline))
require.NoError(t, visitorClient.SetDeadline(deadline))
require.NoError(t, visitorServer.SetDeadline(deadline))
pxy := &BaseProxy{
configurer: &v1.SUDPProxyConfig{},
wireProtocol: wire.ProtocolV2,
udpPacketCodec: wire.UDPPacketCodecBinary,
}
visitorConn := &metadataConn{Conn: visitorServer, wireProtocol: wire.ProtocolV2, udpPacketCodec: wire.UDPPacketCodecBinary}
joinDone := make(chan []error, 1)
go func() {
_, _, errs := pxy.joinUserConnection(proxyServer, visitorConn, string(v1.ProxyTypeSUDP), xlog.New())
joinDone <- errs
}()
raw := []byte{0, 16, 0, 0, 0, 4, 0xde, 0xad, 0xbe, 0xef}
_, err := proxyClient.Write(raw)
require.NoError(t, err)
got := make([]byte, len(raw))
_, err = io.ReadFull(visitorClient, got)
require.NoError(t, err)
require.Equal(t, raw, got)
_ = proxyClient.Close()
_ = visitorClient.Close()
<-joinDone
}
func newSUDPBridgeRW(t *testing.T, buf *bytes.Buffer, wireProtocol, udpPacketCodec string) msg.ReadWriter {
t.Helper()
rw, err := msg.NewUDPPacketReadWriter(buf, wireProtocol, udpPacketCodec)
require.NoError(t, err)
return rw
}
func writeSUDPBridgeMsg(t *testing.T, buf *bytes.Buffer, wireProtocol, udpPacketCodec string, m msg.Message) {
t.Helper()
require.NoError(t, newSUDPBridgeRW(t, buf, wireProtocol, udpPacketCodec).WriteMsg(m))
}
func newSUDPBridgeUDPPacket(content string) *msg.UDPPacket {
return &msg.UDPPacket{
Content: []byte(content),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345},
}
}
func requireV2UDPPacketFrame(t *testing.T, buf *bytes.Buffer, wantType uint16, want *msg.UDPPacket) {
t.Helper()
frame, err := wire.NewConn(buf).ReadFrame()
require.NoError(t, err)
require.Equal(t, wire.FrameTypeMessage, frame.Type)
require.GreaterOrEqual(t, len(frame.Payload), 2)
require.Equal(t, wantType, binary.BigEndian.Uint16(frame.Payload[:2]))
var got *msg.UDPPacket
if wantType == msg.V2TypeUDPPacketBinary {
got, err = msg.DecodeUDPPacketBinary(frame.Payload[2:])
} else {
var decoded msg.UDPPacket
err = msg.DecodeV2MessageFrameInto(frame, &decoded)
got = &decoded
}
require.NoError(t, err)
require.Equal(t, want.Content, got.Content)
require.Equal(t, want.RemoteAddr.String(), got.RemoteAddr.String())
}
type metadataConn struct {
net.Conn
wireProtocol string
udpPacketCodec string
}
func (c *metadataConn) WireProtocol() string {
return c.wireProtocol
}
func (c *metadataConn) UDPPacketCodec() string {
return c.udpPacketCodec
}
+8 -4
View File
@@ -217,14 +217,18 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
}
if pxy.GetLimiter() != nil {
rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), func() error {
return rwc.Close()
})
rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), rwc.Close)
}
pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn)
// Plain UDP payload follows the negotiated wire protocol for message framing.
payloadConn := msg.NewConn(pxy.workConn, msg.NewReadWriter(pxy.workConn, pxy.wireProtocol))
payloadRW, err := msg.NewUDPPacketReadWriter(pxy.workConn, pxy.wireProtocol, pxy.udpPacketCodec)
if err != nil {
xl.Errorf("create UDP packet read writer: %v", err)
pxy.workConn.Close()
continue
}
payloadConn := msg.NewConn(pxy.workConn, payloadRW)
ctx, cancel := context.WithCancel(context.Background())
go workConnReaderFn(payloadConn)
go workConnSenderFn(payloadConn, ctx)
+108 -30
View File
@@ -25,16 +25,18 @@ import (
"net/http"
"os"
"strconv"
"sync"
"time"
"github.com/fatedier/golib/crypto"
"github.com/fatedier/golib/net/mux"
fmux "github.com/hashicorp/yamux"
fmux "github.com/fatedier/yamux"
quic "github.com/quic-go/quic-go"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/auth"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
modelmetrics "github.com/fatedier/frp/pkg/metrics"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/nathole"
@@ -119,6 +121,9 @@ type Service struct {
// web server for dashboard UI and apis
webServer *httppkg.Server
// HTTP vhost resources are started and owned by NewService.
httpVhostServer *http.Server
httpVhostListener net.Listener
sshTunnelGateway *ssh.Gateway
@@ -132,7 +137,10 @@ type Service struct {
// service context
ctx context.Context
// call cancel to stop service
cancel context.CancelFunc
cancel context.CancelFunc
lifecycleMu sync.Mutex
closed bool
closeOnce sync.Once
}
func NewService(cfg *v1.ServerConfig) (*Service, error) {
@@ -318,6 +326,8 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
return nil, fmt.Errorf("create vhost http listener error, %v", err)
}
}
svr.httpVhostServer = server
svr.httpVhostListener = l
go func() {
_ = server.Serve(l)
}()
@@ -364,8 +374,22 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
func (svr *Service) Run(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
svr.lifecycleMu.Lock()
if svr.closed {
svr.lifecycleMu.Unlock()
cancel()
return
}
svr.ctx = ctx
svr.cancel = cancel
svr.lifecycleMu.Unlock()
defer svr.Close()
if ctx.Err() != nil {
return
}
// Cancellation must unblock the synchronous main listener's Accept.
stopClose := context.AfterFunc(ctx, func() { _ = svr.Close() })
defer stopClose()
// run dashboard web server.
if svr.webServer != nil {
@@ -399,13 +423,21 @@ func (svr *Service) Run(ctx context.Context) {
svr.HandleListener(svr.listener, false)
<-svr.ctx.Done()
// service context may not be canceled by svr.Close(), we should call it here to release resources
if svr.listener != nil {
svr.Close()
}
}
func (svr *Service) Close() error {
svr.lifecycleMu.Lock()
svr.closed = true
cancel := svr.cancel
svr.lifecycleMu.Unlock()
if cancel != nil {
cancel()
}
svr.closeOnce.Do(svr.closeResources)
return nil
}
func (svr *Service) closeResources() {
if svr.kcpListener != nil {
svr.kcpListener.Close()
}
@@ -427,16 +459,17 @@ func (svr *Service) Close() error {
if svr.webServer != nil {
svr.webServer.Close()
}
if svr.httpVhostServer != nil {
svr.httpVhostServer.Close()
// Close even if Serve has not registered its listener yet.
svr.httpVhostListener.Close()
}
if svr.sshTunnelGateway != nil {
svr.sshTunnelGateway.Close()
}
svr.rc.Close()
svr.muxer.Close()
svr.ctlManager.Close()
if svr.cancel != nil {
svr.cancel()
}
return nil
}
func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, internal bool) {
@@ -470,7 +503,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
}
}
if err == nil {
ctl, err = svr.RegisterControl(controlConn, m, internal, acceptedConn.wireProtocol)
ctl, err = svr.RegisterControl(controlConn, m, internal, acceptedConn.wireProtocol, acceptedConn.udpPacketCodec)
}
}
@@ -509,7 +542,12 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
return
}
case *msg.NewWorkConn:
if err := svr.RegisterWorkConn(acceptedConn.conn, m); err != nil {
if err := svr.RegisterWorkConn(
acceptedConn.conn,
m,
acceptedConn.wireProtocol,
acceptedConn.clientHelloPresent,
); err != nil {
_ = acceptedConn.conn.WriteMsg(&msg.StartWorkConn{
Error: util.GenerateResponseErrorString("invalid NewWorkConn", err, lo.FromPtr(svr.cfg.DetailedErrorsToClient)),
})
@@ -547,10 +585,12 @@ func (svr *Service) completeControlLogin(ctl *Control, writeSuccess func() error
}
type acceptedConnection struct {
conn *msg.Conn
wireProtocol string
cryptoContext *wire.CryptoContext
firstMsg msg.Message
conn *msg.Conn
wireProtocol string
clientHelloPresent bool
udpPacketCodec string
cryptoContext *wire.CryptoContext
firstMsg msg.Message
}
func (svr *Service) acceptConnection(ctx context.Context, conn net.Conn) (*acceptedConnection, error) {
@@ -618,6 +658,7 @@ func (ac *acceptedConnection) readFirstV2Msg(conn net.Conn, wireConn *wire.Conn)
return nil, fmt.Errorf("read v2 frame: %w", err)
}
if frame.Type == wire.FrameTypeClientHello {
ac.clientHelloPresent = true
if err := ac.handleClientHello(conn, wireConn, frame); err != nil {
return nil, err
}
@@ -666,6 +707,7 @@ func (ac *acceptedConnection) handleClientHello(conn net.Conn, wireConn *wire.Co
return fmt.Errorf("write ServerHello: %w", err)
}
ac.cryptoContext = cryptoContext
ac.udpPacketCodec = serverHello.Selected.Message.UDPPacketCodec
return nil
}
@@ -759,7 +801,20 @@ func (svr *Service) RegisterControl(
loginMsg *msg.Login,
internal bool,
wireProtocol string,
udpPacketCodec string,
) (*Control, error) {
switch wireProtocol {
case wire.ProtocolV1:
if udpPacketCodec != "" {
return nil, fmt.Errorf("UDP packet codec %q requires wire protocol v2", udpPacketCodec)
}
case wire.ProtocolV2:
if udpPacketCodec != "" && udpPacketCodec != wire.UDPPacketCodecBinary {
return nil, fmt.Errorf("unsupported UDP packet codec selection: %s", udpPacketCodec)
}
default:
return nil, fmt.Errorf("unsupported wire protocol: %s", wireProtocol)
}
// If client's RunID is empty, it's a new client, we just create a new controller.
// Otherwise, we check if there is one controller has the same run id. If so, we release previous controller and start new one.
var err error
@@ -769,6 +824,9 @@ func (svr *Service) RegisterControl(
return nil, err
}
}
if err := validation.ValidateRunID(loginMsg.RunID); err != nil {
return nil, fmt.Errorf("invalid run id: %w", err)
}
ctx := netpkg.NewContextFromConn(ctlConn)
xl := xlog.FromContextSafe(ctx)
@@ -787,15 +845,16 @@ func (svr *Service) RegisterControl(
}
ctl, err := NewControl(ctx, &SessionContext{
RC: svr.rc,
PxyManager: svr.pxyManager,
PluginManager: svr.pluginManager,
AuthVerifier: authVerifier,
EncryptionKey: svr.auth.EncryptionKey(),
Conn: ctlConn,
LoginMsg: loginMsg,
ServerCfg: svr.cfg,
WireProtocol: wireProtocol,
RC: svr.rc,
PxyManager: svr.pxyManager,
PluginManager: svr.pluginManager,
AuthVerifier: authVerifier,
EncryptionKey: svr.auth.EncryptionKey(),
Conn: ctlConn,
LoginMsg: loginMsg,
ServerCfg: svr.cfg,
WireProtocol: wireProtocol,
UDPPacketCodec: udpPacketCodec,
})
if err != nil {
xl.Warnf("create new controller error: %v", err)
@@ -820,13 +879,24 @@ func (svr *Service) RegisterControl(
}
// RegisterWorkConn register a new work connection to control and proxies need it.
func (svr *Service) RegisterWorkConn(workConn *msg.Conn, newMsg *msg.NewWorkConn) error {
func (svr *Service) RegisterWorkConn(
workConn *msg.Conn,
newMsg *msg.NewWorkConn,
workWireProtocol string,
workClientHelloPresent bool,
) error {
if workClientHelloPresent {
return fmt.Errorf("ClientHello is not allowed on work connections")
}
xl := netpkg.NewLogFromConn(workConn)
ctl, exist := svr.ctlManager.GetByID(newMsg.RunID)
if !exist {
xl.Warnf("no client control found for run id [%s]", newMsg.RunID)
return fmt.Errorf("no client control found for run id [%s]", newMsg.RunID)
}
if workWireProtocol != ctl.sessionCtx.WireProtocol {
return fmt.Errorf("work connection wire protocol mismatch: got %s want %s", workWireProtocol, ctl.sessionCtx.WireProtocol)
}
// server plugin hook
content := &plugin.NewWorkConnContent{
@@ -851,14 +921,22 @@ func (svr *Service) RegisterWorkConn(workConn *msg.Conn, newMsg *msg.NewWorkConn
}
func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVisitorConn, wireProtocol string) error {
admit := func(visitorUser string) error {
admit := func(visitorUser, visitorWireProtocol, visitorUDPPacketCodec string) error {
if visitorWireProtocol == "" {
visitorWireProtocol = wireProtocol
}
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
newMsg.UseEncryption, newMsg.UseCompression, visitorUser, wireProtocol)
newMsg.UseEncryption, newMsg.UseCompression, visitorUser, visitorWireProtocol, visitorUDPPacketCodec)
}
// TODO(deprecation): Compatible with old versions, can be without runID, user is empty. In later versions, it will be mandatory to include runID.
// If runID is required, it is not compatible with versions prior to v0.50.0.
if newMsg.RunID != "" {
admitted, err := svr.ctlManager.admitVisitorByRunID(newMsg.RunID, admit)
admitted, err := svr.ctlManager.admitVisitorByRunID(newMsg.RunID, func(visitorUser, controlWireProtocol, controlUDPPacketCodec string) error {
if wireProtocol != controlWireProtocol {
return fmt.Errorf("visitor connection wire protocol mismatch: got %s want %s", wireProtocol, controlWireProtocol)
}
return admit(visitorUser, controlWireProtocol, controlUDPPacketCodec)
})
if err != nil {
return err
}
@@ -867,5 +945,5 @@ func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVis
}
return nil
}
return admit("")
return admit("", wireProtocol, "")
}
+151
View File
@@ -0,0 +1,151 @@
package server
import (
"context"
"fmt"
"net"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
)
type lifecycleListener struct {
net.Listener
accepted, closeEntered, closeResume chan struct{}
acceptOnce, closeOnce sync.Once
accepts, closes atomic.Int64
}
func (l *lifecycleListener) Accept() (net.Conn, error) {
l.accepts.Add(1)
l.acceptOnce.Do(func() { close(l.accepted) })
return l.Listener.Accept()
}
func (l *lifecycleListener) Close() error {
l.closes.Add(1)
err := l.Listener.Close()
if l.closeEntered != nil {
l.closeOnce.Do(func() { close(l.closeEntered) })
<-l.closeResume
}
return err
}
func freeLifecycleTCPPort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
func newLifecycleTestService(t *testing.T, web bool) (*Service, *lifecycleListener, []string) {
t.Helper()
cfg := &v1.ServerConfig{
BindAddr: "127.0.0.1", ProxyBindAddr: "127.0.0.1",
}
if web {
cfg.WebServer = v1.WebServerConfig{Addr: "127.0.0.1", Port: freeLifecycleTCPPort(t)}
cfg.VhostHTTPPort = freeLifecycleTCPPort(t)
}
require.NoError(t, cfg.Complete())
cfg.BindPort = 0
_, err := validation.NewConfigValidator(nil).ValidateServerConfig(cfg)
require.NoError(t, err)
svr, err := NewService(cfg)
require.NoError(t, err)
l := &lifecycleListener{Listener: svr.listener, accepted: make(chan struct{})}
svr.listener = l
addresses := []string{l.Addr().String()}
if web {
addresses = append(addresses, svr.webServer.Address())
addresses = append(addresses, net.JoinHostPort(cfg.ProxyBindAddr, fmt.Sprint(cfg.VhostHTTPPort)))
}
assertLifecyclePorts(t, addresses, false)
return svr, l, addresses
}
func assertLifecyclePorts(t *testing.T, addresses []string, released bool) {
t.Helper()
for _, address := range addresses {
ln, err := net.Listen("tcp", address)
if ln != nil {
_ = ln.Close()
}
if released {
require.NoError(t, err, "listener %s was not released", address)
} else {
require.Error(t, err, "listener %s was not bound", address)
}
}
}
func TestServiceRunAfterEarlyShutdown(t *testing.T) {
for _, mode := range []string{"close before Run", "close before Run with web", "parent already cancelled"} {
t.Run(mode, func(t *testing.T) {
svr, listener, addresses := newLifecycleTestService(t, mode == "close before Run with web")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
defer svr.Close()
if mode != "parent already cancelled" {
require.NoError(t, svr.Close())
assertLifecyclePorts(t, addresses, true)
} else {
cancel()
}
done := make(chan struct{})
go func() { svr.Run(ctx); close(done) }()
waitForSignal(t, done, "Run after early shutdown")
require.Zero(t, listener.accepts.Load(), "closed service started accepting")
require.Equal(t, int64(1), listener.closes.Load())
assertLifecyclePorts(t, addresses, true)
})
}
}
func TestServiceCloseMarksClosedBeforeCleanup(t *testing.T) {
svr, listener, addresses := newLifecycleTestService(t, false)
listener.closeEntered, listener.closeResume = make(chan struct{}), make(chan struct{})
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(listener.closeResume) }) }
defer release()
closeDone := make(chan struct{})
go func() { _ = svr.Close(); close(closeDone) }()
waitForSignal(t, listener.closeEntered, "Close cleanup barrier")
runDone := make(chan struct{})
go func() { svr.Run(context.Background()); close(runDone) }()
waitForSignal(t, runDone, "Run to observe closed before cleanup finishes")
require.Zero(t, listener.accepts.Load(), "Run started during cleanup")
assertLifecyclePorts(t, addresses, false)
release()
waitForSignal(t, closeDone, "Close cleanup to finish")
assertLifecyclePorts(t, addresses, true)
}
func TestServiceParentCancellationAndCloseShareCleanup(t *testing.T) {
svr, listener, addresses := newLifecycleTestService(t, false)
listener.closeEntered, listener.closeResume = make(chan struct{}), make(chan struct{})
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(listener.closeResume) }) }
defer release()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runDone := make(chan struct{})
go func() { svr.Run(ctx); close(runDone) }()
waitForSignal(t, listener.accepted, "main listener Accept")
cancel()
waitForSignal(t, listener.closeEntered, "parent cancellation to close main listener")
closeDone := make(chan struct{})
go func() { _ = svr.Close(); close(closeDone) }()
release()
waitForSignal(t, closeDone, "external Close to finish")
waitForSignal(t, runDone, "Run cleanup to finish")
require.Equal(t, int64(1), listener.closes.Load(), "cleanup ran more than once")
assertLifecyclePorts(t, addresses, true)
}
+344 -14
View File
@@ -17,9 +17,12 @@ package server
import (
"context"
"errors"
"fmt"
"math"
"net"
"net/http"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
@@ -30,6 +33,7 @@ import (
"github.com/fatedier/frp/pkg/auth"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/msg"
plugin "github.com/fatedier/frp/pkg/plugin/server"
"github.com/fatedier/frp/pkg/proto/wire"
@@ -79,6 +83,70 @@ func TestWriteWithDeadlineTimesOutAndClearsDeadline(t *testing.T) {
}
}
func TestServiceAcceptConnectionTracksClientHelloPresence(t *testing.T) {
for _, tc := range []struct {
name string
clientHelloPresent bool
offeredCodecs []string
expectedCodec string
}{
{
name: "absent Hello",
},
{
name: "present Hello with JSON fallback",
clientHelloPresent: true,
},
{
name: "present Hello with binary codec",
clientHelloPresent: true,
offeredCodecs: []string{wire.UDPPacketCodecBinary},
expectedCodec: wire.UDPPacketCodecBinary,
},
} {
t.Run(tc.name, func(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
clientErrCh := make(chan error, 1)
go func() {
if err := wire.WriteMagic(clientConn); err != nil {
clientErrCh <- err
return
}
wireConn := wire.NewConn(clientConn)
if tc.clientHelloPresent {
hello, err := wire.NewClientHello(wire.BootstrapInfo{})
if err != nil {
clientErrCh <- err
return
}
hello.Capabilities.Message.UDPPacketCodecs = tc.offeredCodecs
if err := wireConn.WriteJSONFrame(wire.FrameTypeClientHello, hello); err != nil {
clientErrCh <- err
return
}
var serverHello wire.ServerHello
if err := wireConn.ReadJSONFrame(wire.FrameTypeServerHello, &serverHello); err != nil {
clientErrCh <- err
return
}
}
clientErrCh <- msg.NewV2ReadWriterWithConn(wireConn).WriteMsg(&msg.NewWorkConn{RunID: "shared-run"})
}()
acceptedConn, err := (&Service{}).acceptConnection(t.Context(), serverConn)
require.NoError(t, err)
require.NoError(t, <-clientErrCh)
require.Equal(t, tc.clientHelloPresent, acceptedConn.clientHelloPresent)
require.Equal(t, tc.expectedCodec, acceptedConn.udpPacketCodec)
require.IsType(t, &msg.NewWorkConn{}, acceptedConn.firstMsg)
require.NoError(t, acceptedConn.conn.Close())
})
}
}
func TestSharedPortHTTPListenerProtocols(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
@@ -363,20 +431,24 @@ func TestServiceVisitorAdmissionSerializesReplacement(t *testing.T) {
}
t.Cleanup(resume)
type admissionResult struct {
admitted bool
user string
err error
admitted bool
user string
wireProtocol string
udpPacketCodec string
err error
}
admissionDone := make(chan admissionResult, 1)
go func() {
var admittedUser string
admitted, admitErr := svr.ctlManager.admitVisitorByRunID("shared-run", func(user string) error {
admittedUser = user
result := admissionResult{}
result.admitted, result.err = svr.ctlManager.admitVisitorByRunID("shared-run", func(user, wireProtocol, udpPacketCodec string) error {
result.user = user
result.wireProtocol = wireProtocol
result.udpPacketCodec = udpPacketCodec
close(admissionEntered)
<-resumeAdmission
return nil
})
admissionDone <- admissionResult{admitted: admitted, user: admittedUser, err: admitErr}
admissionDone <- result
}()
waitForSignal(t, admissionEntered, "visitor admission callback")
runMu := currentRunGateForTest(svr.ctlManager, "shared-run")
@@ -412,6 +484,8 @@ func TestServiceVisitorAdmissionSerializesReplacement(t *testing.T) {
require.NoError(t, admission.err)
require.True(t, admission.admitted)
require.Equal(t, "old-user", admission.user)
require.Equal(t, wire.ProtocolV1, admission.wireProtocol)
require.Empty(t, admission.udpPacketCodec)
replacement := waitForResult(t, replacementDone, "replacement")
require.NoError(t, replacement.err)
ctlB := replacement.ctl
@@ -428,7 +502,7 @@ func TestServiceWorkConnRoutingRequiresCurrentRunningControl(t *testing.T) {
pendingConn := newCountingCloseConn()
pendingMsgConn := msg.NewConn(pendingConn, msg.NewV1ReadWriter(pendingConn))
err = registerWorkConnAsCaller(svr, pendingMsgConn, &msg.NewWorkConn{RunID: "shared-run"})
err = registerWorkConnAsCaller(svr, pendingMsgConn, &msg.NewWorkConn{RunID: "shared-run"}, wire.ProtocolV1, false)
require.Error(t, err)
require.Equal(t, int64(1), pendingConn.closeCount.Load())
require.Len(t, ctl.workConnCh, 0)
@@ -442,7 +516,7 @@ func TestServiceWorkConnRoutingRequiresCurrentRunningControl(t *testing.T) {
runningConn := newCountingCloseConn()
runningMsgConn := msg.NewConn(runningConn, msg.NewV1ReadWriter(runningConn))
require.NoError(t, svr.RegisterWorkConn(runningMsgConn, &msg.NewWorkConn{RunID: "shared-run"}))
require.NoError(t, svr.RegisterWorkConn(runningMsgConn, &msg.NewWorkConn{RunID: "shared-run"}, wire.ProtocolV1, false))
require.Len(t, ctl.workConnCh, 1)
require.NoError(t, ctl.Close())
@@ -450,6 +524,181 @@ func TestServiceWorkConnRoutingRequiresCurrentRunningControl(t *testing.T) {
require.Equal(t, int64(1), runningConn.closeCount.Load())
}
func TestServiceWorkConnRoutingRejectsWireProtocolMismatch(t *testing.T) {
svr := newControlTestService(t)
ctl, controlConn, err := registerLifecycleTestControl(svr)
require.NoError(t, err)
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
waitForSignal(t, controlConn.readStarted, "control reader to start")
workConn := newCountingCloseConn()
workMsgConn := msg.NewConn(workConn, msg.NewV2ReadWriter(workConn))
err = svr.RegisterWorkConn(workMsgConn, &msg.NewWorkConn{RunID: "shared-run"}, wire.ProtocolV2, false)
require.ErrorContains(t, err, "wire protocol mismatch")
require.Len(t, ctl.workConnCh, 0)
_ = workMsgConn.Close()
require.NoError(t, ctl.Close())
}
func TestServiceWorkConnRoutingClientHelloPolicy(t *testing.T) {
for _, tc := range []struct {
name string
controlUDPPacketCodec string
workClientHelloPresent bool
errorSubstring string
}{
{
name: "JSON control allows work connection without Hello",
},
{
name: "binary control allows work connection without Hello",
controlUDPPacketCodec: wire.UDPPacketCodecBinary,
},
{
name: "JSON control rejects work connection with Hello",
workClientHelloPresent: true,
errorSubstring: "ClientHello is not allowed",
},
{
name: "binary control rejects work connection with Hello",
controlUDPPacketCodec: wire.UDPPacketCodecBinary,
workClientHelloPresent: true,
errorSubstring: "ClientHello is not allowed",
},
} {
t.Run(tc.name, func(t *testing.T) {
svr := newControlTestService(t)
controlConn := newDeadlineReadConn()
controlMsgConn := msg.NewConn(controlConn, msg.NewV2ReadWriter(controlConn))
ctl, err := svr.RegisterControl(controlMsgConn, &msg.Login{
RunID: "shared-run",
ClientID: "client",
ClientSpec: msg.ClientSpec{
AlwaysAuthPass: true,
},
}, true, wire.ProtocolV2, tc.controlUDPPacketCodec)
require.NoError(t, err)
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
waitForSignal(t, controlConn.readStarted, "control reader to start")
workConn := newCountingCloseConn()
workMsgConn := msg.NewConn(workConn, msg.NewV2ReadWriter(workConn))
err = svr.RegisterWorkConn(
workMsgConn,
&msg.NewWorkConn{RunID: "shared-run"},
wire.ProtocolV2,
tc.workClientHelloPresent,
)
if tc.errorSubstring != "" {
require.ErrorContains(t, err, tc.errorSubstring)
require.Len(t, ctl.workConnCh, 0)
require.NoError(t, workMsgConn.Close())
} else {
require.NoError(t, err)
require.Len(t, ctl.workConnCh, 1)
}
require.NoError(t, ctl.Close())
waitForControlDone(t, ctl)
require.Equal(t, int64(1), workConn.closeCount.Load())
})
}
}
func TestServiceRegisterControlRejectsInvalidCodecSelection(t *testing.T) {
for _, tc := range []struct {
name string
wireProtocol string
udpPacketCodec string
errorSubstring string
}{
{
name: "binary codec over v1",
wireProtocol: wire.ProtocolV1,
udpPacketCodec: wire.UDPPacketCodecBinary,
errorSubstring: "requires wire protocol v2",
},
{
name: "unknown v2 codec",
wireProtocol: wire.ProtocolV2,
udpPacketCodec: "unknown",
errorSubstring: "unsupported UDP packet codec",
},
{
name: "unknown wire protocol",
wireProtocol: "unknown",
errorSubstring: "unsupported wire protocol",
},
} {
t.Run(tc.name, func(t *testing.T) {
svr := newControlTestService(t)
conn := newDeadlineReadConn()
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
ctl, err := svr.RegisterControl(msgConn, &msg.Login{}, true, tc.wireProtocol, tc.udpPacketCodec)
require.Nil(t, ctl)
require.ErrorContains(t, err, tc.errorSubstring)
})
}
}
func TestServiceRegisterControlRejectsInvalidRunID(t *testing.T) {
for _, runID := range []string{
"run\nforged",
strings.Repeat("a", validation.MaxRunIDLength+1),
} {
t.Run(fmt.Sprintf("run_id_%d", len(runID)), func(t *testing.T) {
svr := newControlTestService(t)
conn := newDeadlineReadConn()
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
ctl, err := svr.RegisterControl(msgConn, &msg.Login{RunID: runID}, true, wire.ProtocolV1, "")
require.Nil(t, ctl)
require.ErrorContains(t, err, "invalid run id")
})
}
}
func TestServiceRegisterControlPoolCountBoundaries(t *testing.T) {
for _, tc := range []struct {
name string
poolCount int
wantErr bool
wantPool int
}{
{name: "less than channel offset", poolCount: -11, wantErr: true},
{name: "channel offset", poolCount: -10, wantErr: true},
{name: "negative", poolCount: -1, wantErr: true},
{name: "zero", poolCount: 0, wantPool: 0},
{name: "capped by server maximum", poolCount: 10, wantPool: 5},
{name: "maximum int capped", poolCount: math.MaxInt, wantPool: 5},
} {
t.Run(tc.name, func(t *testing.T) {
svr := newControlTestService(t)
svr.cfg.Transport.MaxPoolCount = 5
conn := newDeadlineReadConn()
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
const timestamp = int64(1)
ctl, err := svr.RegisterControl(msgConn, &msg.Login{
RunID: "pool-count-run",
ClientID: "client",
Timestamp: timestamp,
PrivilegeKey: util.GetAuthKey("", timestamp),
PoolCount: tc.poolCount,
}, false, wire.ProtocolV1, "")
if tc.wantErr {
require.Nil(t, ctl)
require.ErrorContains(t, err, "unexpected error when creating new controller")
return
}
require.NoError(t, err)
require.Equal(t, tc.wantPool, ctl.poolCount)
require.NoError(t, ctl.Close())
waitForControlDone(t, ctl)
})
}
}
func TestServiceWorkConnRoutingRejectsLostGeneration(t *testing.T) {
for _, action := range []string{"replace", "close"} {
t.Run(action, func(t *testing.T) {
@@ -465,7 +714,7 @@ func TestServiceWorkConnRoutingRejectsLostGeneration(t *testing.T) {
workMsgConn := msg.NewConn(workConn, msg.NewV1ReadWriter(workConn))
routeDone := make(chan error, 1)
go func() {
routeDone <- registerWorkConnAsCaller(svr, workMsgConn, &msg.NewWorkConn{RunID: "shared-run"})
routeDone <- registerWorkConnAsCaller(svr, workMsgConn, &msg.NewWorkConn{RunID: "shared-run"}, wire.ProtocolV1, false)
}()
waitForSignal(t, barrier.entered, "work connection plugin barrier")
@@ -509,7 +758,7 @@ func TestServiceVisitorRoutingExcludesPendingUser(t *testing.T) {
ClientSpec: msg.ClientSpec{
AlwaysAuthPass: true,
},
}, true, wire.ProtocolV1)
}, true, wire.ProtocolV1, "")
require.NoError(t, err)
timestamp := time.Now().Unix()
@@ -538,6 +787,81 @@ func TestServiceVisitorRoutingExcludesPendingUser(t *testing.T) {
waitForControlDone(t, ctl)
}
func TestServiceVisitorRoutingCarriesControlPacketCodec(t *testing.T) {
svr := newControlTestService(t)
listener, err := svr.rc.VisitorManager.Listen("visitor", "secret", []string{"visitor-user"})
require.NoError(t, err)
t.Cleanup(func() { _ = listener.Close() })
controlConn := newDeadlineReadConn()
controlMsgConn := msg.NewConn(controlConn, msg.NewV2ReadWriter(controlConn))
ctl, err := svr.RegisterControl(controlMsgConn, &msg.Login{
RunID: "visitor-binary-run",
User: "visitor-user",
ClientID: "visitor-client",
ClientSpec: msg.ClientSpec{
AlwaysAuthPass: true,
},
}, true, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
timestamp := time.Now().Unix()
visitorMsg := &msg.NewVisitorConn{
RunID: "visitor-binary-run",
ProxyName: "visitor",
Timestamp: timestamp,
SignKey: util.GetAuthKey("secret", timestamp),
}
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
waitForSignal(t, controlConn.readStarted, "binary visitor control reader to start")
runningConn := newCountingCloseConn()
require.NoError(t, svr.RegisterVisitorConn(runningConn, visitorMsg, wire.ProtocolV2))
accepted, err := listener.Accept()
require.NoError(t, err)
metadata, ok := accepted.(interface {
WireProtocol() string
UDPPacketCodec() string
})
require.True(t, ok)
require.Equal(t, wire.ProtocolV2, metadata.WireProtocol())
require.Equal(t, wire.UDPPacketCodecBinary, metadata.UDPPacketCodec())
require.NoError(t, accepted.Close())
require.Equal(t, int64(1), runningConn.closeCount.Load())
mismatchConn := newCountingCloseConn()
err = svr.RegisterVisitorConn(mismatchConn, visitorMsg, wire.ProtocolV1)
require.ErrorContains(t, err, "visitor connection wire protocol mismatch")
require.NoError(t, mismatchConn.Close())
require.Equal(t, int64(1), mismatchConn.closeCount.Load())
require.NoError(t, ctl.Close())
waitForControlDone(t, ctl)
}
func TestServiceVisitorRoutingLegacyFallsBackToJSONPacketCodec(t *testing.T) {
svr := newControlTestService(t)
listener, err := svr.rc.VisitorManager.Listen("visitor", "secret", []string{""})
require.NoError(t, err)
t.Cleanup(func() { _ = listener.Close() })
timestamp := time.Now().Unix()
visitorMsg := &msg.NewVisitorConn{
ProxyName: "visitor",
Timestamp: timestamp,
SignKey: util.GetAuthKey("secret", timestamp),
}
visitorConn := newCountingCloseConn()
require.NoError(t, svr.RegisterVisitorConn(visitorConn, visitorMsg, wire.ProtocolV2))
accepted, err := listener.Accept()
require.NoError(t, err)
metadata, ok := accepted.(interface{ UDPPacketCodec() string })
require.True(t, ok)
require.Empty(t, metadata.UDPPacketCodec())
require.NoError(t, accepted.Close())
require.Equal(t, int64(1), visitorConn.closeCount.Load())
}
func newControlTestService(t *testing.T) *Service {
t.Helper()
cfg := &v1.ServerConfig{}
@@ -567,7 +891,7 @@ func registerLifecycleTestControl(svr *Service) (*Control, *deadlineReadConn, er
ClientSpec: msg.ClientSpec{
AlwaysAuthPass: true,
},
}, true, wire.ProtocolV1)
}, true, wire.ProtocolV1, "")
return ctl, conn, err
}
@@ -584,8 +908,14 @@ func waitForDifferentCurrentControl(t *testing.T, manager *ControlManager, runID
return nil
}
func registerWorkConnAsCaller(svr *Service, workConn *msg.Conn, newMsg *msg.NewWorkConn) error {
err := svr.RegisterWorkConn(workConn, newMsg)
func registerWorkConnAsCaller(
svr *Service,
workConn *msg.Conn,
newMsg *msg.NewWorkConn,
wireProtocol string,
clientHelloPresent bool,
) error {
err := svr.RegisterWorkConn(workConn, newMsg, wireProtocol, clientHelloPresent)
if err != nil {
_ = workConn.Close()
}
+14 -4
View File
@@ -65,8 +65,12 @@ func (vm *Manager) Listen(name string, sk string, allowUsers []string) (*netpkg.
func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey string,
useEncryption bool, useCompression bool, visitorUser string,
wireProtocol string,
wireProtocol string, udpPacketCodecs ...string,
) (err error) {
udpPacketCodec := ""
if len(udpPacketCodecs) > 0 {
udpPacketCodec = udpPacketCodecs[0]
}
vm.mu.RLock()
defer vm.mu.RUnlock()
@@ -93,8 +97,9 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
}
visitorConn := netpkg.WrapReadWriteCloserToConn(rwc, conn)
err = l.l.PutConn(&wireProtocolConn{
Conn: visitorConn,
wireProtocol: wireProtocol,
Conn: visitorConn,
wireProtocol: wireProtocol,
udpPacketCodec: udpPacketCodec,
})
} else {
err = fmt.Errorf("custom listener for [%s] doesn't exist", name)
@@ -105,13 +110,18 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey
type wireProtocolConn struct {
net.Conn
wireProtocol string
wireProtocol string
udpPacketCodec string
}
func (c *wireProtocolConn) WireProtocol() string {
return c.wireProtocol
}
func (c *wireProtocolConn) UDPPacketCodec() string {
return c.udpPacketCodec
}
func (vm *Manager) CloseListener(name string) {
vm.mu.Lock()
defer vm.mu.Unlock()
+8 -3
View File
@@ -25,7 +25,7 @@ import (
"github.com/fatedier/frp/pkg/util/util"
)
func TestManagerNewConnCarriesWireProtocol(t *testing.T) {
func TestManagerNewConnCarriesWireProtocolAndUDPPacketCodec(t *testing.T) {
vm := NewManager()
listener, err := vm.Listen("sudp", "secret", []string{"*"})
require.NoError(t, err)
@@ -47,6 +47,7 @@ func TestManagerNewConnCarriesWireProtocol(t *testing.T) {
false,
"user",
wire.ProtocolV2,
wire.UDPPacketCodecBinary,
)
}()
@@ -54,8 +55,12 @@ func TestManagerNewConnCarriesWireProtocol(t *testing.T) {
require.NoError(t, err)
defer acceptedConn.Close()
getter, ok := acceptedConn.(interface{ WireProtocol() string })
metadata, ok := acceptedConn.(interface {
WireProtocol() string
UDPPacketCodec() string
})
require.True(t, ok)
require.Equal(t, wire.ProtocolV2, getter.WireProtocol())
require.Equal(t, wire.ProtocolV2, metadata.WireProtocol())
require.Equal(t, wire.UDPPacketCodecBinary, metadata.UDPPacketCodec())
require.NoError(t, <-errCh)
}
+125 -1
View File
@@ -192,6 +192,74 @@ transport.wireProtocol = "v2"
})
})
var _ = ginkgo.Describe("[Compatibility: UDPDefault]", func() {
f := framework.NewDefaultFramework()
ginkgo.It("current frps exchanges UDP with baseline frpc using the default wire protocol", func() {
portName := port.GenName("CompatDefaultUDPBaselineFRPC")
f.RunProcessesWithBinaries(
compatCtx.CurrentFRPSPath,
compatCtx.BaselineFRPCPath,
consts.DefaultServerConfig,
[]string{udpClientConfig(portName, "")},
)
framework.NewRequestExpect(f).Protocol("udp").PortName(portName).Ensure()
})
ginkgo.It("baseline frps exchanges UDP with current frpc defaulting to v1", func() {
portName := port.GenName("CompatDefaultUDPBaselineFRPS")
f.RunProcessesWithBinaries(
compatCtx.BaselineFRPSPath,
compatCtx.CurrentFRPCPath,
consts.DefaultServerConfig,
[]string{udpClientConfig(portName, "")},
)
framework.NewRequestExpect(f).Protocol("udp").PortName(portName).Ensure()
})
})
var _ = ginkgo.Describe("[Compatibility: UDPWireV2]", func() {
f := framework.NewDefaultFramework()
ginkgo.BeforeEach(func() {
supportsV2, knownVersion := baselineSupportsUDPWireProtocolV2(compatCtx.BaselineVersion)
if !knownVersion {
ginkgo.Skip(fmt.Sprintf("baseline version %q has unknown ordinary UDP wire v2 framing support", compatCtx.BaselineVersion))
}
if !supportsV2 {
// v0.69.0 supports control v2 but still uses legacy ordinary UDP framing.
// Mixing that early v2 implementation with the v0.69.1 framing was not supported.
ginkgo.Skip(fmt.Sprintf("baseline %q predates ordinary UDP wire v2 framing (v0.69.1); default UDP is tested separately",
compatCtx.BaselineVersion))
}
})
// The negotiated payload codec may be JSON or binary-v1; both use v2 framing.
ginkgo.It("current frps exchanges v2-framed UDP with baseline frpc", func() {
portName := port.GenName("CompatV2UDPBaselineFRPC")
clientConf := udpClientConfig(portName, `transport.wireProtocol = "v2"`)
f.RunProcessesWithBinaries(
compatCtx.CurrentFRPSPath,
compatCtx.BaselineFRPCPath,
consts.DefaultServerConfig,
[]string{clientConf},
)
framework.NewRequestExpect(f).Protocol("udp").PortName(portName).Ensure()
})
ginkgo.It("current frpc exchanges v2-framed UDP with baseline frps", func() {
portName := port.GenName("CompatV2UDPBaselineFRPS")
clientConf := udpClientConfig(portName, `transport.wireProtocol = "v2"`)
f.RunProcessesWithBinaries(
compatCtx.BaselineFRPSPath,
compatCtx.CurrentFRPCPath,
consts.DefaultServerConfig,
[]string{clientConf},
)
framework.NewRequestExpect(f).Protocol("udp").PortName(portName).Ensure()
})
})
func tcpClientConfig(proxyName string, remotePortName string, extra string) string {
return fmt.Sprintf(`
serverAddr = "127.0.0.1"
@@ -208,6 +276,22 @@ remotePort = {{ .%s }}
`, consts.PortServerName, extra, proxyName, framework.TCPEchoServerPort, remotePortName)
}
func udpClientConfig(remotePortName string, extra string) string {
return fmt.Sprintf(`
serverAddr = "127.0.0.1"
serverPort = {{ .%s }}
loginFailExit = true
log.level = "trace"
%s
[[proxies]]
name = "udp"
type = "udp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
`, consts.PortServerName, extra, framework.UDPEchoServerPort, remotePortName)
}
func expectProcessExit(p *process.Process, timeout time.Duration) {
select {
case <-p.Done():
@@ -217,6 +301,14 @@ func expectProcessExit(p *process.Process, timeout time.Duration) {
}
func baselineSupportsControlWireProtocolV2(version string) (supports bool, known bool) {
return baselineVersionAtLeast(version, [3]int{0, 69, 0})
}
func baselineSupportsUDPWireProtocolV2(version string) (supports bool, known bool) {
return baselineVersionAtLeast(version, [3]int{0, 69, 1})
}
func baselineVersionAtLeast(version string, minimum [3]int) (supports bool, known bool) {
version = strings.TrimPrefix(version, "v")
parts := strings.Split(version, ".")
if len(parts) != 3 {
@@ -236,7 +328,39 @@ func baselineSupportsControlWireProtocolV2(version string) (supports bool, known
return false, false
}
return compareSemanticVersion(major, minor, patch, 0, 69, 0) >= 0, true
return compareSemanticVersion(major, minor, patch, minimum[0], minimum[1], minimum[2]) >= 0, true
}
func TestBaselineWireProtocolSupport(t *testing.T) {
for _, tc := range []struct {
version string
known bool
controlV2 bool
udpV2 bool
}{
{version: "0.61.0", known: true},
{version: "0.68.1", known: true},
{version: "0.69.0", known: true, controlV2: true},
{version: "v0.69.0", known: true, controlV2: true},
{version: "0.69.1", known: true, controlV2: true, udpV2: true},
{version: "v0.69.1", known: true, controlV2: true, udpV2: true},
{version: "0.70.0", known: true, controlV2: true, udpV2: true},
{version: "0.71.0", known: true, controlV2: true, udpV2: true},
{version: "1.0.0", known: true, controlV2: true, udpV2: true},
{version: "custom"},
{version: "0.69"},
{version: "0.69.x"},
{version: "0.69.1-rc.1"},
} {
t.Run(tc.version, func(t *testing.T) {
if supports, known := baselineSupportsControlWireProtocolV2(tc.version); supports != tc.controlV2 || known != tc.known {
t.Errorf("control v2 support = (%v, %v), want (%v, %v)", supports, known, tc.controlV2, tc.known)
}
if supports, known := baselineSupportsUDPWireProtocolV2(tc.version); supports != tc.udpV2 || known != tc.known {
t.Errorf("UDP v2 framing support = (%v, %v), want (%v, %v)", supports, known, tc.udpV2, tc.known)
}
})
}
}
func compareSemanticVersion(major, minor, patch int, baseMajor, baseMinor, basePatch int) int {
+85 -3
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"time"
"github.com/onsi/ginkgo/v2"
@@ -109,12 +110,12 @@ var _ = ginkgo.Describe("[Feature: WireProtocol]", func() {
name: "default sudp visitor",
},
{
name: "v2 sudp visitor",
name: "v2 binary raw sudp visitor",
proxyWireConfig: `transport.wireProtocol = "v2"`,
visitorWireConfig: `transport.wireProtocol = "v2"`,
},
{
name: "mixed sudp proxy v1 visitor v2",
name: "v1 JSON proxy -> v2 Binary visitor transcode",
proxyWireConfig: `transport.wireProtocol = "v1"`,
visitorWireConfig: `transport.wireProtocol = "v2"`,
extraProxyConfig: `
@@ -127,7 +128,7 @@ var _ = ginkgo.Describe("[Feature: WireProtocol]", func() {
`,
},
{
name: "mixed sudp proxy v2 visitor v1",
name: "v2 Binary proxy -> v1 JSON visitor transcode",
proxyWireConfig: `transport.wireProtocol = "v2"`,
visitorWireConfig: `transport.wireProtocol = "v1"`,
},
@@ -204,6 +205,87 @@ var _ = ginkgo.Describe("[Feature: WireProtocol]", func() {
})
})
var _ = ginkgo.Describe("[Feature: BinaryUDPPacket]", func() {
f := framework.NewDefaultFramework()
for _, tc := range []struct {
name string
protocol string
extraServer string
extraTransport string
}{
{name: "tcp mux on", protocol: "tcp", extraTransport: "transport.tcpMux = true"},
{name: "tcp mux off", protocol: "tcp", extraServer: "transport.tcpMux = false", extraTransport: "transport.tcpMux = false"},
{name: "kcp", protocol: "kcp"},
{name: "quic stream", protocol: "quic"},
{name: "websocket", protocol: "websocket"},
} {
ginkgo.It(tc.name, func() {
runClientServerTest(f, &generalTestConfigures{
server: renderBindPortConfig(tc.protocol) + "\n" + tc.extraServer,
client: fmt.Sprintf(`
transport.wireProtocol = "v2"
transport.protocol = %q
%s
`, tc.protocol, tc.extraTransport),
})
})
}
ginkgo.It("wss", func() {
wssPort := f.AllocPort()
runClientServerTest(f, &generalTestConfigures{
clientPrefix: fmt.Sprintf(`
serverAddr = "127.0.0.1"
serverPort = %d
loginFailExit = false
transport.protocol = "wss"
transport.wireProtocol = "v2"
log.level = "trace"
`, wssPort),
client2: fmt.Sprintf(`
[[proxies]]
name = "wss2ws"
type = "tcp"
remotePort = %d
[proxies.plugin]
type = "https2http"
localAddr = "127.0.0.1:{{ .%s }}"
`, wssPort, consts.PortServerName),
testDelay: 10 * time.Second,
})
})
for _, tc := range []struct {
name string
transport string
}{
{name: "plain"},
{name: "aes-cfb", transport: "transport.useEncryption = true"},
{name: "snappy", transport: "transport.useCompression = true"},
{name: "snappy and aes-cfb", transport: "transport.useEncryption = true\ntransport.useCompression = true"},
{name: "limiter", transport: "transport.bandwidthLimit = \"1MB\""},
} {
ginkgo.It(tc.name, func() {
serverConf := consts.DefaultServerConfig
udpPortName := port.GenName("BinaryUDPPacket")
clientConf := consts.DefaultClientConfig + fmt.Sprintf(`
transport.wireProtocol = "v2"
[[proxies]]
name = "udp"
type = "udp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
%s
`, framework.UDPEchoServerPort, udpPortName, tc.transport)
f.RunProcesses(serverConf, []string{clientConf})
framework.NewRequestExpect(f).Protocol("udp").PortName(udpPortName).Ensure()
})
}
})
type wireClientInfo struct {
ClientID string `json:"clientID"`
WireProtocol string `json:"wireProtocol"`
+81
View File
@@ -2,6 +2,9 @@ package features
import (
"fmt"
"io"
"net"
"strconv"
"strings"
"time"
@@ -105,4 +108,82 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
framework.ExpectTrue(duration.Seconds() > 8, "100Kb with 10KB limit, want > 8 seconds, but got %s", duration.String())
})
ginkgo.It("Server Bandwidth Limit closes work connection when user disconnects", func() {
newFunc := func() *plugin.Request {
var r plugin.Request
r.Content = &plugin.NewProxyContent{}
return &r
}
pluginPort := f.AllocPort()
handler := func(req *plugin.Request) *plugin.Response {
var ret plugin.Response
content := req.Content.(*plugin.NewProxyContent)
content.BandwidthLimit = "10MB"
content.BandwidthLimitMode = "server"
ret.Content = content
return &ret
}
pluginServer := pluginpkg.NewHTTPPluginServer(pluginPort, newFunc, handler, nil)
f.RunServer("", pluginServer)
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
[[httpPlugins]]
name = "test"
addr = "127.0.0.1:%d"
path = "/handler"
ops = ["NewProxy"]
`, pluginPort)
clientConf := consts.DefaultClientConfig
// The local server echoes the first message, then stays idle until the
// connection from frpc is closed.
localClosed := make(chan struct{})
localPort := f.AllocPort()
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),
streamserver.WithCustomHandler(func(c net.Conn) {
defer c.Close()
buf := make([]byte, 1024)
n, err := c.Read(buf)
if err != nil {
return
}
if _, err := c.Write(buf[:n]); err != nil {
return
}
_, _ = io.Copy(io.Discard, c)
close(localClosed)
}))
f.RunServer("", localServer)
remotePort := f.AllocPort()
clientConf += fmt.Sprintf(`
[[proxies]]
name = "tcp"
type = "tcp"
localPort = %d
remotePort = %d
`, localPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf})
conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(remotePort)))
framework.ExpectNoError(err)
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
_, err = conn.Write([]byte("ping"))
framework.ExpectNoError(err)
buf := make([]byte, 4)
_, err = io.ReadFull(conn, buf)
framework.ExpectNoError(err)
framework.ExpectEqual(string(buf), "ping")
conn.Close()
select {
case <-localClosed:
case <-time.After(5 * time.Second):
framework.Failf("local connection is not closed after user connection is closed")
}
})
})

Some files were not shown because too many files have changed in this diff Show More