mirror of
https://github.com/fatedier/frp.git
synced 2026-10-17 04:53:26 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8cf1800dc | ||
|
|
41ed2aecf8 | ||
|
|
12b0093ccf | ||
|
|
2542d11a62 | ||
|
|
c4d2db4177 | ||
|
|
71b31978b3 | ||
|
|
1cc2dd8a23 | ||
|
|
9c7d9898b3 | ||
|
|
ab0959868a | ||
|
|
1dbd44edd3 | ||
|
|
fccc7b0e9f | ||
|
|
55107d003d | ||
|
|
4cd4747525 | ||
|
|
f8be1cdfe2 | ||
|
|
d5636108e6 | ||
|
|
d20a232996 | ||
|
|
b0d840858f | ||
|
|
c5508627c4 | ||
|
|
832df8dff6 | ||
|
|
ebb4773c2c | ||
|
|
23ec9b4979 | ||
|
|
40bb45d192 | ||
|
|
758f07d59e | ||
|
|
25a4ba306a | ||
|
|
4a23aa181c | ||
|
|
6c8a8d0a97 | ||
|
|
da04e1e07a | ||
|
|
71a2bf30f9 | ||
|
|
a6a782bed4 | ||
|
|
223b44336c | ||
|
|
f6688e2a0d | ||
|
|
f666d97b64 | ||
|
|
5b68148f11 | ||
|
|
d1928f9689 | ||
|
|
1a3a872bd2 | ||
|
|
290017cb53 | ||
|
|
2291e8835f | ||
|
|
1ab59e763c | ||
|
|
9d45a55720 | ||
|
|
effa496859 | ||
|
|
5c6d761c12 | ||
|
|
3d89ab7ff1 | ||
|
|
f3828cbfdb | ||
|
|
00c24e39bb | ||
|
|
2175557d48 | ||
|
|
de4b483710 | ||
|
|
2d63f6b1f9 | ||
|
|
40274d8c92 | ||
|
|
fa3bcca2b0 | ||
|
|
18eef83b69 | ||
|
|
466a94f5d1 | ||
|
|
898bcf73d5 | ||
|
|
0e53833a2c | ||
|
|
c23934bb67 | ||
|
|
23512f577e | ||
|
|
84b907a198 | ||
|
|
f8fc3c6b1b | ||
|
|
7dc7be930e | ||
|
|
d486018885 | ||
|
|
fe79598ee4 | ||
|
|
269a26c5d5 | ||
|
|
2886393f5b | ||
|
|
7b6e01f04f | ||
|
|
5396947c7c | ||
|
|
fe61093bc2 | ||
|
|
54aeb2a7b0 | ||
|
|
84be1938e4 | ||
|
|
becee40715 | ||
|
|
17e788d43b | ||
|
|
68509f5d44 | ||
|
|
5cd722b177 | ||
|
|
5876beceac | ||
|
|
7fe152e3aa | ||
|
|
7c343fc6e7 | ||
|
|
a3b3b35b69 | ||
|
|
ae1c0504ec | ||
|
|
393a533744 | ||
|
|
035889c360 | ||
|
|
940bde5c46 | ||
|
|
14628df63c | ||
|
|
ba7adcab8f | ||
|
|
4cc826e236 | ||
|
|
54c6ccdfec | ||
|
|
9bde0b07de | ||
|
|
c6c545289c | ||
|
|
503afe78b7 |
+13
-11
@@ -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
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./dockerfiles/Dockerfile-for-frpc
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.TAG_FRPC }}
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./dockerfiles/Dockerfile-for-frps
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.TAG_FRPS }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ all: build
|
||||
build: app
|
||||
|
||||
app:
|
||||
@$(foreach n, $(os-archs), \
|
||||
@set -e; $(foreach n, $(os-archs), \
|
||||
os=$(shell echo "$(n)" | cut -d : -f 1); \
|
||||
arch=$(shell echo "$(n)" | cut -d : -f 2); \
|
||||
extra=$(shell echo "$(n)" | cut -d : -f 3); \
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
[](https://circleci.com/gh/fatedier/frp)
|
||||
[](https://github.com/fatedier/frp/releases)
|
||||
[](https://goreportcard.com/report/github.com/fatedier/frp)
|
||||
[](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
|
||||
|
||||
[README](README.md) | [中文文档](README_zh.md)
|
||||
@@ -13,6 +12,18 @@ frp is an open source project with its ongoing development made possible entirel
|
||||
|
||||
<h3 align="center">Gold Sponsors</h3>
|
||||
<!--gold sponsors start-->
|
||||
<p align="center">
|
||||
<a href="https://www.rapidproxy.io/?ref=frp" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_rapidproxy.png">
|
||||
<br>
|
||||
<b>High-performance residential and ISP proxies for developers</b>
|
||||
</a>
|
||||
<br>
|
||||
<sub>90M+ residential IPs worldwide. Rotating IPs, sticky sessions, and traffic that never expires.</sub>
|
||||
<br>
|
||||
<sub>From $0.55/GB. Use RAPID10 for 10% off. Try it for free.</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://jb.gg/frp" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||
@@ -40,6 +51,7 @@ If you're looking for a meeting recording API, consider checking out [Recall.ai]
|
||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||
|
||||
</div>
|
||||
|
||||
<!--gold sponsors end-->
|
||||
|
||||
## What is frp?
|
||||
|
||||
+13
-1
@@ -2,7 +2,6 @@
|
||||
|
||||
[](https://circleci.com/gh/fatedier/frp)
|
||||
[](https://github.com/fatedier/frp/releases)
|
||||
[](https://goreportcard.com/report/github.com/fatedier/frp)
|
||||
[](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
|
||||
|
||||
[README](README.md) | [中文文档](README_zh.md)
|
||||
@@ -15,6 +14,18 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
||||
|
||||
<h3 align="center">Gold Sponsors</h3>
|
||||
<!--gold sponsors start-->
|
||||
<p align="center">
|
||||
<a href="https://www.rapidproxy.io/?ref=frp" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_rapidproxy.png">
|
||||
<br>
|
||||
<b>High-performance residential and ISP proxies for developers</b>
|
||||
</a>
|
||||
<br>
|
||||
<sub>90M+ residential IPs worldwide. Rotating IPs, sticky sessions, and traffic that never expires.</sub>
|
||||
<br>
|
||||
<sub>From $0.55/GB. Use RAPID10 for 10% off. Try it for free.</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://jb.gg/frp" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||
@@ -42,6 +53,7 @@ If you're looking for a meeting recording API, consider checking out [Recall.ai]
|
||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||
|
||||
</div>
|
||||
|
||||
<!--gold sponsors end-->
|
||||
|
||||
## 为什么使用 frp ?
|
||||
|
||||
+10
-6
@@ -1,9 +1,13 @@
|
||||
## Features
|
||||
## Improvements
|
||||
|
||||
* `transport.wireProtocol = "v2"` now also applies to UDP-based proxy payloads, including ordinary UDP and SUDP, so their payload framing is consistent with the selected wire protocol.
|
||||
* Improved SUDP compatibility during mixed `transport.wireProtocol` deployments, allowing frps to bridge payloads between v1/default and v2 SUDP clients.
|
||||
* XTCP work connection `NatHoleSid` messages now follow the selected `transport.wireProtocol`.
|
||||
* Building frp from source now requires Go 1.26.0 or later.
|
||||
|
||||
## Compatibility Notes
|
||||
## Fixes
|
||||
|
||||
* When enabling `transport.wireProtocol = "v2"` for SUDP, upgrade both the proxy and visitor frpc instances first, or keep them on `v1` until both sides are upgraded.
|
||||
* Updated dependencies to fix SSH tunnel gateway channel deadlocks and panics caused by malformed challenges from NTLM-authenticated HTTP proxies.
|
||||
* Fixed VirtualNet routing issues during reconnection and shutdown, and excessive retries after repeated connection failures.
|
||||
* Health checks now reset the failure count after success, so `healthCheck.maxFailed` applies to consecutive failures.
|
||||
* Fixed control connections getting stuck after message write failures, which could block reconnection.
|
||||
* Fixed incorrect daily traffic statistics around daylight-saving transitions and skipped local midnights.
|
||||
* Fixed early-data loss in HTTP virtual-host CONNECT tunnels. Requests with a positive content length or transfer encoding now return HTTP 400.
|
||||
* Fixed work connections remaining open when server-side bandwidth limiting is enabled for TCP, HTTP, and UDP proxies.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+60
-12
@@ -22,6 +22,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fatedier/golib/crypto"
|
||||
@@ -32,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"
|
||||
@@ -109,6 +111,9 @@ func setServiceOptionsDefault(options *ServiceOptions) error {
|
||||
// Service is the client service that connects to frps and provides proxy services.
|
||||
type Service struct {
|
||||
ctlMu sync.RWMutex
|
||||
// Stores gracefulShutdownDuration independently from ctlMu, because the
|
||||
// graceful shutdown wait may hold ctlMu for an arbitrary duration.
|
||||
gracefulShutdownDuration atomic.Int64
|
||||
// manager control connection with server
|
||||
ctl *Control
|
||||
// Uniq id got from frps, it will be attached to loginMsg.
|
||||
@@ -149,8 +154,10 @@ type Service struct {
|
||||
// service context
|
||||
ctx context.Context
|
||||
// call cancel to stop service
|
||||
cancel context.CancelCauseFunc
|
||||
gracefulShutdownDuration time.Duration
|
||||
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
|
||||
@@ -221,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 != "" {
|
||||
@@ -233,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() {
|
||||
@@ -259,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.
|
||||
@@ -281,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.
|
||||
@@ -373,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
|
||||
}
|
||||
@@ -412,8 +439,21 @@ func (svr *Service) Close() {
|
||||
}
|
||||
|
||||
func (svr *Service) GracefulClose(d time.Duration) {
|
||||
svr.gracefulShutdownDuration = d
|
||||
svr.cancel(nil)
|
||||
svr.gracefulShutdownDuration.Store(int64(d))
|
||||
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() {
|
||||
@@ -429,7 +469,8 @@ func (svr *Service) stop() {
|
||||
svr.ctlMu.Lock()
|
||||
defer svr.ctlMu.Unlock()
|
||||
if svr.ctl != nil {
|
||||
svr.ctl.GracefulClose(svr.gracefulShutdownDuration)
|
||||
d := time.Duration(svr.gracefulShutdownDuration.Load())
|
||||
svr.ctl.GracefulClose(d)
|
||||
svr.ctl = nil
|
||||
}
|
||||
if svr.webServer != nil {
|
||||
@@ -506,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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fatedier/frp/client/proxy"
|
||||
"github.com/fatedier/frp/client/visitor"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
)
|
||||
|
||||
type gracefulCloseTestConnector struct {
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
func (*gracefulCloseTestConnector) Connect() (*msg.Conn, error) { return nil, net.ErrClosed }
|
||||
func (c *gracefulCloseTestConnector) Close() error { return c.conn.Close() }
|
||||
|
||||
func newGracefulCloseTestService() *Service {
|
||||
ctx := context.Background()
|
||||
common := &v1.ClientCommonConfig{}
|
||||
serverConn, clientConn := net.Pipe()
|
||||
ctl := &Control{
|
||||
ctx: ctx,
|
||||
sessionCtx: &SessionContext{
|
||||
Common: common,
|
||||
RunID: "graceful-close-race",
|
||||
Conn: msg.NewConn(clientConn, msg.NewV1ReadWriter(clientConn)),
|
||||
Connector: &gracefulCloseTestConnector{conn: serverConn},
|
||||
},
|
||||
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, "")
|
||||
return &Service{ctl: ctl, cancel: context.CancelCauseFunc(func(error) {})}
|
||||
}
|
||||
|
||||
func TestGracefulCloseAndStopSynchronizeDuration(t *testing.T) {
|
||||
for i := range 10000 {
|
||||
svr := newGracefulCloseTestService()
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
svr.GracefulClose(time.Duration(i))
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
svr.stop()
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func TestGracefulCloseDoesNotBlockDuringStop(t *testing.T) {
|
||||
const gracefulDuration = 200 * time.Millisecond
|
||||
|
||||
svr := newGracefulCloseTestService()
|
||||
svr.GracefulClose(gracefulDuration)
|
||||
stopDone := make(chan struct{})
|
||||
go func() {
|
||||
svr.stop()
|
||||
close(stopDone)
|
||||
}()
|
||||
defer func() {
|
||||
select {
|
||||
case <-stopDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("stop did not finish")
|
||||
}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for svr.ctlMu.TryLock() {
|
||||
svr.ctlMu.Unlock()
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("stop did not acquire ctlMu")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
svr.GracefulClose(0)
|
||||
if elapsed := time.Since(start); elapsed >= gracefulDuration/2 {
|
||||
t.Fatalf("GracefulClose blocked for %v while stop was waiting", elapsed)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -8,7 +8,7 @@ RUN npm install
|
||||
WORKDIR /web/frpc
|
||||
RUN npm run build
|
||||
|
||||
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
|
||||
|
||||
@@ -8,7 +8,7 @@ RUN npm install
|
||||
WORKDIR /web/frps
|
||||
RUN npm run build
|
||||
|
||||
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
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
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.14.1
|
||||
github.com/fatedier/golib v0.7.0
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
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/pion/stun/v3 v3.1.1
|
||||
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.55.0
|
||||
github.com/quic-go/quic-go v0.60.0
|
||||
github.com/rodaine/table v1.2.0
|
||||
github.com/samber/lo v1.47.0
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
|
||||
@@ -26,11 +25,11 @@ 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.49.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/oauth2 v0.28.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.42.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
|
||||
golang.org/x/time v0.10.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
@@ -40,11 +39,11 @@ 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
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
@@ -53,9 +52,6 @@ require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.10 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
@@ -67,11 +63,9 @@ require (
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.4 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.42.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
|
||||
@@ -79,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
|
||||
|
||||
@@ -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=
|
||||
@@ -11,8 +11,8 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
||||
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -20,12 +20,12 @@ 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.7.0 h1:tMDF9ObcwVt59VUHroJOzHQjVFPLymZVMpGm9WAVwhY=
|
||||
github.com/fatedier/golib v0.7.0/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/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/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=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
@@ -78,16 +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/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
|
||||
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
|
||||
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||
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=
|
||||
@@ -103,8 +95,10 @@ github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSz
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rodaine/table v1.2.0 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA=
|
||||
@@ -146,8 +140,6 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ
|
||||
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
||||
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
|
||||
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
|
||||
@@ -159,30 +151,28 @@ 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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
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=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
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.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -190,14 +180,14 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
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=
|
||||
@@ -205,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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -394,6 +394,10 @@ func LoadClientConfigResult(path string, strict bool) (*ClientConfigLoadResult,
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateNoDuplicateNames(result.Proxies, result.Visitors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -417,6 +421,31 @@ func LoadClientConfig(path string, strict bool) (
|
||||
return result.Common, proxyCfgs, visitorCfgs, result.IsLegacyFormat, nil
|
||||
}
|
||||
|
||||
// validateNoDuplicateNames rejects proxies or visitors that share a name. They are
|
||||
// keyed by name in the config sources, so a duplicate would otherwise be silently
|
||||
// overwritten and never started, with no error or log.
|
||||
func validateNoDuplicateNames(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error {
|
||||
proxyNames := make(map[string]struct{}, len(proxies))
|
||||
for _, p := range proxies {
|
||||
name := p.GetBaseConfig().Name
|
||||
if _, ok := proxyNames[name]; ok {
|
||||
return fmt.Errorf("proxy name [%s] is duplicated", name)
|
||||
}
|
||||
proxyNames[name] = struct{}{}
|
||||
}
|
||||
|
||||
visitorNames := make(map[string]struct{}, len(visitors))
|
||||
for _, v := range visitors {
|
||||
name := v.GetBaseConfig().Name
|
||||
if _, ok := visitorNames[name]; ok {
|
||||
return fmt.Errorf("visitor name [%s] is duplicated", name)
|
||||
}
|
||||
visitorNames[name] = struct{}{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CompleteProxyConfigurers(proxies []v1.ProxyConfigurer) []v1.ProxyConfigurer {
|
||||
proxyCfgs := proxies
|
||||
for _, c := range proxyCfgs {
|
||||
|
||||
@@ -17,6 +17,8 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -462,6 +464,111 @@ func TestFilterClientConfigurers_FilterByStartAndEnabled(t *testing.T) {
|
||||
require.Equal("keep", proxies[0].GetBaseConfig().Name)
|
||||
}
|
||||
|
||||
func TestLoadClientConfigResult_DuplicateNames(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "duplicate proxy names",
|
||||
content: `
|
||||
serverAddr = "127.0.0.1"
|
||||
serverPort = 7000
|
||||
|
||||
[[proxies]]
|
||||
name = "dup"
|
||||
type = "tcp"
|
||||
localPort = 22
|
||||
remotePort = 6000
|
||||
|
||||
[[proxies]]
|
||||
name = "dup"
|
||||
type = "tcp"
|
||||
localPort = 3306
|
||||
remotePort = 6001
|
||||
`,
|
||||
errSubstr: "proxy name [dup] is duplicated",
|
||||
},
|
||||
{
|
||||
name: "duplicate visitor names",
|
||||
content: `
|
||||
serverAddr = "127.0.0.1"
|
||||
serverPort = 7000
|
||||
|
||||
[[visitors]]
|
||||
name = "dup"
|
||||
type = "stcp"
|
||||
serverName = "a"
|
||||
secretKey = "secret"
|
||||
bindPort = 9001
|
||||
|
||||
[[visitors]]
|
||||
name = "dup"
|
||||
type = "stcp"
|
||||
serverName = "b"
|
||||
secretKey = "secret"
|
||||
bindPort = 9002
|
||||
`,
|
||||
errSubstr: "visitor name [dup] is duplicated",
|
||||
},
|
||||
{
|
||||
name: "unique names",
|
||||
content: `
|
||||
serverAddr = "127.0.0.1"
|
||||
serverPort = 7000
|
||||
|
||||
[[proxies]]
|
||||
name = "p1"
|
||||
type = "tcp"
|
||||
localPort = 22
|
||||
remotePort = 6000
|
||||
|
||||
[[proxies]]
|
||||
name = "p2"
|
||||
type = "tcp"
|
||||
localPort = 3306
|
||||
remotePort = 6001
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "same name across proxy and visitor",
|
||||
content: `
|
||||
serverAddr = "127.0.0.1"
|
||||
serverPort = 7000
|
||||
|
||||
[[proxies]]
|
||||
name = "same"
|
||||
type = "tcp"
|
||||
localPort = 22
|
||||
remotePort = 6000
|
||||
|
||||
[[visitors]]
|
||||
name = "same"
|
||||
type = "stcp"
|
||||
serverName = "a"
|
||||
secretKey = "secret"
|
||||
bindPort = 9001
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
path := filepath.Join(t.TempDir(), "frpc.toml")
|
||||
require.NoError(os.WriteFile(path, []byte(tc.content), 0o600))
|
||||
|
||||
_, err := LoadClientConfigResult(path, false)
|
||||
if tc.errSubstr == "" {
|
||||
require.NoError(err)
|
||||
} else {
|
||||
require.ErrorContains(err, tc.errSubstr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestYAMLEdgeCases tests edge cases for YAML parsing, including non-map types
|
||||
func TestYAMLEdgeCases(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -95,9 +95,7 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
||||
defer m.mu.Unlock()
|
||||
total = len(m.info.ProxyStatistics)
|
||||
for name, data := range m.info.ProxyStatistics {
|
||||
if !data.LastCloseTime.IsZero() &&
|
||||
data.LastStartTime.Before(data.LastCloseTime) &&
|
||||
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration {
|
||||
if m.shouldClearProxyStats(data, continuousOfflineDuration) {
|
||||
delete(m.info.ProxyStatistics, name)
|
||||
count++
|
||||
log.Tracef("clear proxy [%s]'s statistics data, lastCloseTime: [%s]", name, data.LastCloseTime.String())
|
||||
@@ -106,10 +104,20 @@ func (m *serverMetrics) clearUselessInfo(continuousOfflineDuration time.Duration
|
||||
return count, total
|
||||
}
|
||||
|
||||
func (m *serverMetrics) shouldClearProxyStats(data *ProxyStatistics, continuousOfflineDuration time.Duration) bool {
|
||||
return !data.LastCloseTime.IsZero() &&
|
||||
data.LastStartTime.Before(data.LastCloseTime) &&
|
||||
m.clock.Since(data.LastCloseTime) > continuousOfflineDuration
|
||||
}
|
||||
|
||||
func (m *serverMetrics) ClearOfflineProxies() (int, int) {
|
||||
return m.clearUselessInfo(0)
|
||||
}
|
||||
|
||||
func (m *serverMetrics) PruneOfflineProxies() (int, int) {
|
||||
return m.clearUselessInfo(0)
|
||||
}
|
||||
|
||||
func (m *serverMetrics) NewClient() {
|
||||
m.info.ClientCounts.Inc(1)
|
||||
}
|
||||
@@ -231,9 +239,11 @@ func toProxyStats(name string, proxyStats *ProxyStatistics) *ProxyStats {
|
||||
}
|
||||
if !proxyStats.LastStartTime.IsZero() {
|
||||
ps.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
|
||||
ps.LastStartAt = proxyStats.LastStartTime.Unix()
|
||||
}
|
||||
if !proxyStats.LastCloseTime.IsZero() {
|
||||
ps.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
|
||||
ps.LastCloseAt = proxyStats.LastCloseTime.Unix()
|
||||
}
|
||||
return ps
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ func TestServerMetricsUsesClockForProxyTimestamps(t *testing.T) {
|
||||
clk.SetTime(closedAt)
|
||||
metrics.CloseProxy("proxy", "tcp")
|
||||
require.Equal(closedAt, metrics.info.ProxyStatistics["proxy"].LastCloseTime)
|
||||
|
||||
stats := metrics.GetProxyByName("proxy")
|
||||
require.Equal(start.Format("01-02 15:04:05"), stats.LastStartTime)
|
||||
require.Equal(closedAt.Format("01-02 15:04:05"), stats.LastCloseTime)
|
||||
require.Equal(start.Unix(), stats.LastStartAt)
|
||||
require.Equal(closedAt.Unix(), stats.LastCloseAt)
|
||||
}
|
||||
|
||||
func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
||||
@@ -43,6 +49,70 @@ func TestServerMetricsClearUselessInfoUsesClock(t *testing.T) {
|
||||
require.Empty(metrics.info.ProxyStatistics)
|
||||
}
|
||||
|
||||
func TestServerMetricsClearOfflineProxiesPreservesLegacyTotal(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
|
||||
metrics := newServerMetricsWithClock(clk)
|
||||
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
|
||||
Name: "offline",
|
||||
LastStartTime: start.Add(-time.Hour),
|
||||
LastCloseTime: start,
|
||||
}
|
||||
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
|
||||
Name: "online",
|
||||
LastStartTime: start,
|
||||
}
|
||||
|
||||
cleared, total := metrics.ClearOfflineProxies()
|
||||
|
||||
require.Equal(1, cleared)
|
||||
require.Equal(2, total)
|
||||
require.False(metrics.hasProxyStatistics("offline"))
|
||||
require.True(metrics.hasProxyStatistics("online"))
|
||||
}
|
||||
|
||||
func TestServerMetricsPruneOfflineProxiesReportsTotalStats(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
start := time.Date(2026, time.May, 8, 12, 30, 0, 0, time.UTC)
|
||||
clk := clocktesting.NewFakeClock(start.Add(time.Minute))
|
||||
metrics := newServerMetricsWithClock(clk)
|
||||
metrics.info.ProxyStatistics["offline"] = &ProxyStatistics{
|
||||
Name: "offline",
|
||||
LastStartTime: start.Add(-time.Hour),
|
||||
LastCloseTime: start,
|
||||
}
|
||||
metrics.info.ProxyStatistics["online"] = &ProxyStatistics{
|
||||
Name: "online",
|
||||
LastStartTime: start,
|
||||
}
|
||||
metrics.info.ProxyStatistics["restarted"] = &ProxyStatistics{
|
||||
Name: "restarted",
|
||||
LastStartTime: start.Add(30 * time.Second),
|
||||
LastCloseTime: start,
|
||||
}
|
||||
metrics.info.ProxyStatistics["same-time"] = &ProxyStatistics{
|
||||
Name: "same-time",
|
||||
LastStartTime: start,
|
||||
LastCloseTime: start,
|
||||
}
|
||||
|
||||
cleared, total := metrics.PruneOfflineProxies()
|
||||
|
||||
require.Equal(1, cleared)
|
||||
require.Equal(4, total)
|
||||
require.False(metrics.hasProxyStatistics("offline"))
|
||||
require.True(metrics.hasProxyStatistics("online"))
|
||||
require.True(metrics.hasProxyStatistics("restarted"))
|
||||
require.True(metrics.hasProxyStatistics("same-time"))
|
||||
|
||||
cleared, total = metrics.PruneOfflineProxies()
|
||||
require.Equal(0, cleared)
|
||||
require.Equal(3, total)
|
||||
}
|
||||
|
||||
func TestServerMetricsRunUsesClockTicker(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ type ProxyStats struct {
|
||||
TodayTrafficOut int64
|
||||
LastStartTime string
|
||||
LastCloseTime string
|
||||
LastStartAt int64
|
||||
LastCloseAt int64
|
||||
CurConns int64
|
||||
}
|
||||
|
||||
@@ -85,4 +87,5 @@ type Collector interface {
|
||||
GetProxyByName(proxyName string) *ProxyStats
|
||||
GetProxyTraffic(name string) *ProxyTrafficInfo
|
||||
ClearOfflineProxies() (int, int)
|
||||
PruneOfflineProxies() (int, int)
|
||||
}
|
||||
|
||||
+56
-5
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 := ®ressionTransport{closed: make(chan struct{})}
|
||||
w, err := libcrypto.NewWriter(transport, regressionKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ®ressionControl{
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -43,6 +43,7 @@ const (
|
||||
V2TypeNatHoleResp uint16 = 16
|
||||
V2TypeNatHoleSid uint16 = 17
|
||||
V2TypeNatHoleReport uint16 = 18
|
||||
V2TypeUDPPacketBinary uint16 = 19
|
||||
)
|
||||
|
||||
var v2MsgTypeMap = map[uint16]any{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+25
-65
@@ -15,31 +15,24 @@
|
||||
package nathole
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/pion/stun/v3"
|
||||
"github.com/fatedier/golib/net/stun"
|
||||
)
|
||||
|
||||
var responseTimeout = 3 * time.Second
|
||||
|
||||
type Message struct {
|
||||
Body []byte
|
||||
Addr string
|
||||
}
|
||||
|
||||
// If the localAddr is empty, it will listen on a random port.
|
||||
func Discover(stunServers []string, localAddr string) ([]string, net.Addr, error) {
|
||||
// create a discoverConn and get response from messageChan
|
||||
discoverConn, err := listen(localAddr)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer discoverConn.Close()
|
||||
|
||||
go discoverConn.readLoop()
|
||||
|
||||
addresses := make([]string, 0, len(stunServers))
|
||||
for _, addr := range stunServers {
|
||||
// get external address from stun server
|
||||
@@ -58,10 +51,9 @@ type stunResponse struct {
|
||||
}
|
||||
|
||||
type discoverConn struct {
|
||||
conn *net.UDPConn
|
||||
|
||||
localAddr net.Addr
|
||||
messageChan chan *Message
|
||||
conn *net.UDPConn
|
||||
client *stun.Client
|
||||
localAddr net.Addr
|
||||
}
|
||||
|
||||
func listen(localAddr string) (*discoverConn, error) {
|
||||
@@ -77,82 +69,50 @@ func listen(localAddr string) (*discoverConn, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := stun.NewClient(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &discoverConn{
|
||||
conn: conn,
|
||||
localAddr: conn.LocalAddr(),
|
||||
messageChan: make(chan *Message, 10),
|
||||
conn: conn,
|
||||
client: client,
|
||||
localAddr: conn.LocalAddr(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *discoverConn) Close() error {
|
||||
if c.messageChan != nil {
|
||||
close(c.messageChan)
|
||||
c.messageChan = nil
|
||||
}
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *discoverConn) readLoop() {
|
||||
for {
|
||||
buf := make([]byte, 1024)
|
||||
n, addr, err := c.conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
buf = buf[:n]
|
||||
|
||||
c.messageChan <- &Message{
|
||||
Body: buf,
|
||||
Addr: addr.String(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *discoverConn) doSTUNRequest(addr string) (*stunResponse, error) {
|
||||
serverAddr, err := net.ResolveUDPAddr("udp4", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request, err := stun.Build(stun.TransactionID, stun.BindingRequest)
|
||||
transaction, err := stun.NewBindingTransaction(serverAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = request.NewTransactionID(); err != nil {
|
||||
if err := c.conn.SetReadDeadline(time.Now().Add(responseTimeout)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := c.conn.WriteTo(request.Raw, serverAddr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var m stun.Message
|
||||
select {
|
||||
case msg := <-c.messageChan:
|
||||
m.Raw = msg.Body
|
||||
if err := m.Decode(); err != nil {
|
||||
return nil, err
|
||||
response, err := c.client.Do(transaction)
|
||||
if err != nil {
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
return nil, fmt.Errorf("wait response from stun server timeout")
|
||||
}
|
||||
case <-time.After(responseTimeout):
|
||||
return nil, fmt.Errorf("wait response from stun server timeout")
|
||||
return nil, err
|
||||
}
|
||||
xorAddrGetter := &stun.XORMappedAddress{}
|
||||
mappedAddrGetter := &stun.MappedAddress{}
|
||||
changedAddrGetter := ChangedAddress{}
|
||||
otherAddrGetter := &stun.OtherAddress{}
|
||||
|
||||
resp := &stunResponse{}
|
||||
if err := mappedAddrGetter.GetFrom(&m); err == nil {
|
||||
resp.externalAddr = mappedAddrGetter.String()
|
||||
if response.MappedAddr != nil {
|
||||
resp.externalAddr = response.MappedAddr.String()
|
||||
}
|
||||
if err := xorAddrGetter.GetFrom(&m); err == nil {
|
||||
resp.externalAddr = xorAddrGetter.String()
|
||||
}
|
||||
if err := changedAddrGetter.GetFrom(&m); err == nil {
|
||||
resp.otherAddr = changedAddrGetter.String()
|
||||
}
|
||||
if err := otherAddrGetter.GetFrom(&m); err == nil {
|
||||
resp.otherAddr = otherAddrGetter.String()
|
||||
if response.OtherAddr != nil {
|
||||
resp.otherAddr = response.OtherAddr.String()
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
package nathole
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fatedier/golib/net/stun"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testBindingRequest = 0x0001
|
||||
testBindingSuccess = 0x0101
|
||||
testBindingError = 0x0111
|
||||
testMagicCookie = 0x2112a442
|
||||
testAttrMapped = 0x0001
|
||||
testAttrChanged = 0x0005
|
||||
testAttrErrorCode = 0x0009
|
||||
testAttrXORMapped = 0x0020
|
||||
testAttrOther = 0x802c
|
||||
testSTUNHeaderSize = 20
|
||||
testSTUNServerLimit = time.Second
|
||||
)
|
||||
|
||||
type testSTUNAttribute struct {
|
||||
typ uint16
|
||||
value []byte
|
||||
}
|
||||
|
||||
type testSTUNExchange struct {
|
||||
source *net.UDPAddr
|
||||
err error
|
||||
}
|
||||
|
||||
func listenTestUDP4(t *testing.T) *net.UDPConn {
|
||||
t.Helper()
|
||||
|
||||
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
func serveOneSTUNRequest(
|
||||
server *net.UDPConn,
|
||||
buildResponse func([]byte, *net.UDPAddr) ([]byte, error),
|
||||
) <-chan testSTUNExchange {
|
||||
done := make(chan testSTUNExchange, 1)
|
||||
go func() {
|
||||
if err := server.SetDeadline(time.Now().Add(testSTUNServerLimit)); err != nil {
|
||||
done <- testSTUNExchange{err: err}
|
||||
return
|
||||
}
|
||||
buffer := make([]byte, 1024)
|
||||
n, source, err := server.ReadFromUDP(buffer)
|
||||
if err == nil && buildResponse != nil {
|
||||
var response []byte
|
||||
response, err = buildResponse(buffer[:n], source)
|
||||
if err == nil && response != nil {
|
||||
_, err = server.WriteToUDP(response, source)
|
||||
}
|
||||
}
|
||||
done <- testSTUNExchange{source: source, err: err}
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
func waitSTUNExchange(t *testing.T, done <-chan testSTUNExchange) *net.UDPAddr {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case exchange := <-done:
|
||||
require.NoError(t, exchange.err)
|
||||
return exchange.source
|
||||
case <-time.After(testSTUNServerLimit):
|
||||
t.Fatal("timed out waiting for local STUN server")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func makeTestSTUNResponse(request []byte, typ uint16, attributes ...testSTUNAttribute) ([]byte, error) {
|
||||
if len(request) != testSTUNHeaderSize || binary.BigEndian.Uint16(request[0:2]) != testBindingRequest ||
|
||||
binary.BigEndian.Uint32(request[4:8]) != testMagicCookie {
|
||||
return nil, fmt.Errorf("invalid Binding request")
|
||||
}
|
||||
|
||||
length := 0
|
||||
for _, attribute := range attributes {
|
||||
length += 4 + (len(attribute.value)+3)&^3
|
||||
}
|
||||
response := make([]byte, testSTUNHeaderSize, testSTUNHeaderSize+length)
|
||||
binary.BigEndian.PutUint16(response[0:2], typ)
|
||||
binary.BigEndian.PutUint16(response[2:4], uint16(length))
|
||||
binary.BigEndian.PutUint32(response[4:8], testMagicCookie)
|
||||
copy(response[8:20], request[8:20])
|
||||
|
||||
for _, attribute := range attributes {
|
||||
start := len(response)
|
||||
paddedLength := (len(attribute.value) + 3) &^ 3
|
||||
response = append(response, make([]byte, 4+paddedLength)...)
|
||||
binary.BigEndian.PutUint16(response[start:start+2], attribute.typ)
|
||||
binary.BigEndian.PutUint16(response[start+2:start+4], uint16(len(attribute.value)))
|
||||
copy(response[start+4:], attribute.value)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func testIPv4AddressValue(ip net.IP, port int, xor bool) []byte {
|
||||
value := make([]byte, 8)
|
||||
value[1] = 0x01
|
||||
binary.BigEndian.PutUint16(value[2:4], uint16(port))
|
||||
copy(value[4:], ip.To4())
|
||||
if xor {
|
||||
binary.BigEndian.PutUint16(value[2:4], binary.BigEndian.Uint16(value[2:4])^uint16(testMagicCookie>>16))
|
||||
for i := range 4 {
|
||||
value[4+i] ^= byte(uint32(testMagicCookie) >> uint(24-8*i))
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func TestDiscoverReusesLocalPortAndPreservesNATClassification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
secondMapped string
|
||||
secondMappedPort int
|
||||
wantNATType string
|
||||
wantBehavior string
|
||||
}{
|
||||
{
|
||||
name: "same mapped address",
|
||||
secondMapped: "198.51.100.10:40000",
|
||||
secondMappedPort: 40000,
|
||||
wantNATType: EasyNAT,
|
||||
wantBehavior: BehaviorNoChange,
|
||||
},
|
||||
{
|
||||
name: "different mapped port",
|
||||
secondMapped: "198.51.100.10:40001",
|
||||
secondMappedPort: 40001,
|
||||
wantNATType: HardNAT,
|
||||
wantBehavior: BehaviorPortChanged,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
primary := listenTestUDP4(t)
|
||||
alternate := listenTestUDP4(t)
|
||||
alternateAddr := alternate.LocalAddr().(*net.UDPAddr)
|
||||
|
||||
primaryDone := serveOneSTUNRequest(primary, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.10"), 40000, true)},
|
||||
testSTUNAttribute{typ: testAttrOther, value: testIPv4AddressValue(alternateAddr.IP, alternateAddr.Port, false)},
|
||||
)
|
||||
})
|
||||
alternateDone := serveOneSTUNRequest(alternate, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.10"), tt.secondMappedPort, true)},
|
||||
)
|
||||
})
|
||||
|
||||
addresses, localAddr, err := Discover([]string{primary.LocalAddr().String()}, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"198.51.100.10:40000", tt.secondMapped}, addresses)
|
||||
|
||||
primarySource := waitSTUNExchange(t, primaryDone)
|
||||
alternateSource := waitSTUNExchange(t, alternateDone)
|
||||
require.Equal(t, primarySource.Port, alternateSource.Port)
|
||||
require.Equal(t, localAddr.(*net.UDPAddr).Port, primarySource.Port)
|
||||
|
||||
feature, err := ClassifyNATFeature(addresses, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantNATType, feature.NatType)
|
||||
require.Equal(t, tt.wantBehavior, feature.Behavior)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoSTUNRequestMapsLegacyAndModernAddresses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
attributes []testSTUNAttribute
|
||||
wantExternal string
|
||||
wantOther string
|
||||
}{
|
||||
{
|
||||
name: "legacy",
|
||||
attributes: []testSTUNAttribute{
|
||||
{typ: testAttrMapped, value: testIPv4AddressValue(net.ParseIP("192.0.2.1"), 1000, false)},
|
||||
{typ: testAttrChanged, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
|
||||
},
|
||||
wantExternal: "192.0.2.1:1000",
|
||||
wantOther: "192.0.2.2:2000",
|
||||
},
|
||||
{
|
||||
name: "modern takes precedence",
|
||||
attributes: []testSTUNAttribute{
|
||||
{typ: testAttrMapped, value: testIPv4AddressValue(net.ParseIP("192.0.2.1"), 1000, false)},
|
||||
{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.1"), 3000, true)},
|
||||
{typ: testAttrChanged, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
|
||||
{typ: testAttrOther, value: testIPv4AddressValue(net.ParseIP("198.51.100.2"), 4000, false)},
|
||||
},
|
||||
wantExternal: "198.51.100.1:3000",
|
||||
wantOther: "198.51.100.2:4000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := listenTestUDP4(t)
|
||||
done := serveOneSTUNRequest(server, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingSuccess, tt.attributes...)
|
||||
})
|
||||
conn, err := listen("")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
response, err := conn.doSTUNRequest(server.LocalAddr().String())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantExternal, response.externalAddr)
|
||||
require.Equal(t, tt.wantOther, response.otherAddr)
|
||||
waitSTUNExchange(t, done)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSTUNResponseErrorsAndMissingAddresses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
buildResponse func([]byte, *net.UDPAddr) ([]byte, error)
|
||||
request func(*discoverConn, string) error
|
||||
checkError func(*testing.T, error)
|
||||
}{
|
||||
{
|
||||
name: "correlated malformed response",
|
||||
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
response, err := makeTestSTUNResponse(request, testBindingSuccess)
|
||||
if err == nil {
|
||||
binary.BigEndian.PutUint16(response[2:4], 4)
|
||||
}
|
||||
return response, err
|
||||
},
|
||||
request: func(conn *discoverConn, server string) error {
|
||||
_, err := conn.doSTUNRequest(server)
|
||||
return err
|
||||
},
|
||||
checkError: func(t *testing.T, err error) {
|
||||
require.ErrorIs(t, err, stun.ErrMalformedResponse)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Binding error response",
|
||||
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingError, testSTUNAttribute{
|
||||
typ: testAttrErrorCode,
|
||||
value: []byte{0, 0, 4, 20, 'U', 'n', 'k', 'n', 'o', 'w', 'n'},
|
||||
})
|
||||
},
|
||||
request: func(conn *discoverConn, server string) error {
|
||||
_, err := conn.doSTUNRequest(server)
|
||||
return err
|
||||
},
|
||||
checkError: func(t *testing.T, err error) {
|
||||
var responseErr *stun.ResponseError
|
||||
require.ErrorAs(t, err, &responseErr)
|
||||
require.Equal(t, 420, responseErr.Code)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing mapped address",
|
||||
buildResponse: func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||
testSTUNAttribute{typ: testAttrOther, value: testIPv4AddressValue(net.ParseIP("192.0.2.2"), 2000, false)},
|
||||
)
|
||||
},
|
||||
request: func(conn *discoverConn, server string) error {
|
||||
_, err := conn.discoverFromStunServer(server)
|
||||
return err
|
||||
},
|
||||
checkError: func(t *testing.T, err error) {
|
||||
require.EqualError(t, err, "no external address found")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := listenTestUDP4(t)
|
||||
done := serveOneSTUNRequest(server, tt.buildResponse)
|
||||
conn, err := listen("")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
err = tt.request(conn, server.LocalAddr().String())
|
||||
tt.checkError(t, err)
|
||||
waitSTUNExchange(t, done)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("missing other address", func(t *testing.T) {
|
||||
server := listenTestUDP4(t)
|
||||
done := serveOneSTUNRequest(server, func(request []byte, _ *net.UDPAddr) ([]byte, error) {
|
||||
return makeTestSTUNResponse(request, testBindingSuccess,
|
||||
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.1"), 3000, true)},
|
||||
)
|
||||
})
|
||||
|
||||
_, err := Prepare([]string{server.LocalAddr().String()}, PrepareOptions{})
|
||||
require.EqualError(t, err, "discover error: not enough addresses")
|
||||
waitSTUNExchange(t, done)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSTUNTimeoutUsesCallerDeadlineWithoutRetry(t *testing.T) {
|
||||
originalTimeout := responseTimeout
|
||||
responseTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() { responseTimeout = originalTimeout })
|
||||
|
||||
server := listenTestUDP4(t)
|
||||
done := serveOneSTUNRequest(server, nil)
|
||||
conn, err := listen("")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
_, err = conn.doSTUNRequest(server.LocalAddr().String())
|
||||
require.EqualError(t, err, "wait response from stun server timeout")
|
||||
waitSTUNExchange(t, done)
|
||||
|
||||
require.NoError(t, server.SetReadDeadline(time.Now().Add(50*time.Millisecond)))
|
||||
_, _, err = server.ReadFromUDP(make([]byte, 1))
|
||||
var netErr net.Error
|
||||
require.ErrorAs(t, err, &netErr)
|
||||
require.True(t, netErr.Timeout())
|
||||
}
|
||||
|
||||
func TestSTUNClientLeavesSocketAndDeadlineWithCaller(t *testing.T) {
|
||||
originalTimeout := responseTimeout
|
||||
responseTimeout = 100 * time.Millisecond
|
||||
t.Cleanup(func() { responseTimeout = originalTimeout })
|
||||
|
||||
server := listenTestUDP4(t)
|
||||
unrelated := listenTestUDP4(t)
|
||||
done := serveOneSTUNRequest(server, func(request []byte, source *net.UDPAddr) ([]byte, error) {
|
||||
response, err := makeTestSTUNResponse(request, testBindingSuccess,
|
||||
testSTUNAttribute{typ: testAttrXORMapped, value: testIPv4AddressValue(net.ParseIP("198.51.100.5"), 5000, true)},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := unrelated.WriteToUDP(response, source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
})
|
||||
conn, err := listen("")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
response, err := conn.doSTUNRequest(server.LocalAddr().String())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "198.51.100.5:5000", response.externalAddr)
|
||||
waitSTUNExchange(t, done)
|
||||
|
||||
_, _, err = conn.conn.ReadFromUDP(make([]byte, 1))
|
||||
var netErr net.Error
|
||||
require.True(t, errors.As(err, &netErr))
|
||||
require.True(t, netErr.Timeout())
|
||||
|
||||
require.NoError(t, conn.conn.SetDeadline(time.Time{}))
|
||||
require.NoError(t, server.SetReadDeadline(time.Now().Add(testSTUNServerLimit)))
|
||||
_, err = conn.conn.WriteToUDP([]byte{1}, server.LocalAddr().(*net.UDPAddr))
|
||||
require.NoError(t, err)
|
||||
_, source, err := server.ReadFromUDP(make([]byte, 1))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, conn.localAddr.(*net.UDPAddr).Port, source.Port)
|
||||
}
|
||||
@@ -18,10 +18,8 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"github.com/fatedier/golib/crypto"
|
||||
"github.com/pion/stun/v3"
|
||||
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
)
|
||||
@@ -48,20 +46,6 @@ func DecodeMessageInto(data, key []byte, m msg.Message) error {
|
||||
return msg.ReadMsgInto(bytes.NewReader(buf), m)
|
||||
}
|
||||
|
||||
type ChangedAddress struct {
|
||||
IP net.IP
|
||||
Port int
|
||||
}
|
||||
|
||||
func (s *ChangedAddress) GetFrom(m *stun.Message) error {
|
||||
a := (*stun.MappedAddress)(s)
|
||||
return a.GetFromAs(m, stun.AttrChangedAddress)
|
||||
}
|
||||
|
||||
func (s *ChangedAddress) String() string {
|
||||
return net.JoinHostPort(s.IP.String(), strconv.Itoa(s.Port))
|
||||
}
|
||||
|
||||
func ListAllLocalIPs() ([]net.IP, error) {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
|
||||
@@ -72,6 +72,15 @@ func (p *TLS2RawPlugin) Handle(ctx context.Context, connInfo *ConnectionInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
if connInfo.ProxyProtocolHeader != nil {
|
||||
if _, err := connInfo.ProxyProtocolHeader.WriteTo(rawConn); err != nil {
|
||||
xl.Warnf("tls2raw write proxy protocol header to local conn error: %v", err)
|
||||
rawConn.Close()
|
||||
tlsConn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
libio.Join(tlsConn, rawConn)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
+21
-5
@@ -16,7 +16,6 @@ package ssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -52,6 +51,11 @@ type tcpipForward struct {
|
||||
Port uint32
|
||||
}
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc4254#section-6.5
|
||||
type execPayload struct {
|
||||
Command string
|
||||
}
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc4254#page-16
|
||||
type forwardedTCPPayload struct {
|
||||
Addr string
|
||||
@@ -66,6 +70,7 @@ type TunnelServer struct {
|
||||
sshConn *ssh.ServerConn
|
||||
sc *ssh.ServerConfig
|
||||
firstChannel ssh.Channel
|
||||
firstChannelMu sync.Mutex
|
||||
|
||||
vc *virtual.Client
|
||||
peerServerListener *netpkg.InternalListener
|
||||
@@ -187,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
|
||||
}
|
||||
@@ -300,23 +307,24 @@ 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 {
|
||||
if req.WantReply {
|
||||
_ = req.Reply(true, nil)
|
||||
}
|
||||
if req.Type != "exec" || len(req.Payload) <= 4 {
|
||||
if req.Type != "exec" {
|
||||
continue
|
||||
}
|
||||
end := 4 + binary.BigEndian.Uint32(req.Payload[:4])
|
||||
if len(req.Payload) < int(end) {
|
||||
extraPayload, ok := parseExecPayload(req.Payload)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
extraPayload := string(req.Payload[4:end])
|
||||
select {
|
||||
case extraPayloadCh <- extraPayload:
|
||||
default:
|
||||
@@ -324,6 +332,14 @@ func (s *TunnelServer) handleNewChannel(channel ssh.NewChannel, extraPayloadCh c
|
||||
}
|
||||
}
|
||||
|
||||
func parseExecPayload(payload []byte) (string, bool) {
|
||||
var msg execPayload
|
||||
if err := ssh.Unmarshal(payload, &msg); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return msg.Command, true
|
||||
}
|
||||
|
||||
func (s *TunnelServer) keepAlive(ch ssh.Channel) {
|
||||
tk := time.NewTicker(time.Second * 30)
|
||||
defer tk.Stop()
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// 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 ssh
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
cryptossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestParseExecPayload(t *testing.T) {
|
||||
payload := cryptossh.Marshal(&execPayload{Command: "tcp --remote_port 6000"})
|
||||
|
||||
got, ok := parseExecPayload(payload)
|
||||
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "tcp --remote_port 6000", got)
|
||||
}
|
||||
|
||||
func TestParseExecPayloadRejectsMalformedPayloads(t *testing.T) {
|
||||
overflowLength := make([]byte, 5)
|
||||
binary.BigEndian.PutUint32(overflowLength[:4], ^uint32(0))
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
payload []byte
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
payload: nil,
|
||||
},
|
||||
{
|
||||
name: "short length prefix",
|
||||
payload: []byte{0, 0, 0},
|
||||
},
|
||||
{
|
||||
name: "declared length exceeds remaining payload",
|
||||
payload: []byte{0, 0, 0, 2, 'x'},
|
||||
},
|
||||
{
|
||||
name: "overflow length",
|
||||
payload: overflowLength,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var (
|
||||
got string
|
||||
ok bool
|
||||
)
|
||||
require.NotPanics(t, func() {
|
||||
got, ok = parseExecPayload(tc.payload)
|
||||
})
|
||||
require.False(t, ok)
|
||||
require.Empty(t, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,12 @@ type GeneralResponse struct {
|
||||
Msg string
|
||||
}
|
||||
|
||||
type V2Response struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
// APIHandler is a handler function that returns a response object or an error.
|
||||
type APIHandler func(ctx *Context) (any, error)
|
||||
|
||||
@@ -64,3 +70,27 @@ func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MakeHTTPHandlerFuncV2 wraps a handler response in the dashboard API v2 envelope.
|
||||
func MakeHTTPHandlerFuncV2(handler APIHandler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := NewContext(w, r)
|
||||
res, err := handler(ctx)
|
||||
if err != nil {
|
||||
log.Warnf("http response [%s]: error: %v", r.URL.Path, err)
|
||||
code := http.StatusInternalServerError
|
||||
if e, ok := err.(*Error); ok {
|
||||
code = e.Code
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(V2Response{Code: code, Msg: err.Error(), Data: nil})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(V2Response{Code: http.StatusOK, Msg: "success", Data: res})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ package net
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hkdf"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -25,7 +26,6 @@ import (
|
||||
|
||||
libcrypto "github.com/fatedier/golib/crypto"
|
||||
quic "github.com/quic-go/quic-go"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
|
||||
"github.com/fatedier/frp/pkg/util/xlog"
|
||||
)
|
||||
@@ -335,11 +335,6 @@ func deriveAEADControlKeys(key []byte, algorithm string, transcriptHash []byte)
|
||||
}
|
||||
|
||||
func deriveAEADControlKey(key []byte, algorithm string, transcriptHash []byte, direction string) ([]byte, error) {
|
||||
info := []byte(aeadControlHKDFInfoPrefix + " " + algorithm + " " + direction)
|
||||
reader := hkdf.New(sha256.New, key, transcriptHash, info)
|
||||
out := make([]byte, libcrypto.AEADKeySize)
|
||||
if _, err := io.ReadFull(reader, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
info := aeadControlHKDFInfoPrefix + " " + algorithm + " " + direction
|
||||
return hkdf.Key(sha256.New, key, transcriptHash, info, libcrypto.AEADKeySize)
|
||||
}
|
||||
|
||||
@@ -114,5 +114,11 @@ func TestDeriveAEADControlKeysUsesDistinctDirections(t *testing.T) {
|
||||
bytes.Repeat([]byte{0x44}, 32),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte{
|
||||
0xa0, 0x58, 0xcd, 0x02, 0x5d, 0x96, 0x98, 0x5f,
|
||||
0xeb, 0xeb, 0xff, 0x79, 0xa1, 0x9f, 0x62, 0xb7,
|
||||
0x15, 0xe0, 0x53, 0x91, 0x3d, 0xfc, 0x74, 0x77,
|
||||
0x05, 0x91, 0x4c, 0x62, 0x4b, 0xf3, 0xd4, 0x95,
|
||||
}, clientToServerKey)
|
||||
require.NotEqual(t, clientToServerKey, serverToClientKey)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,11 @@ func DialHookWebsocket(protocol string, host string) libnet.AfterHookFunc {
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
|
||||
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
|
||||
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
|
||||
// frames and close the connection on invalid bytes.
|
||||
conn.PayloadType = websocket.BinaryFrame
|
||||
return ctx, conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,15 +32,28 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
|
||||
wl = &WebsocketListener{
|
||||
ln: ln,
|
||||
acceptCh: make(chan net.Conn),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
muxer := http.NewServeMux()
|
||||
muxer.Handle(FrpWebsocketPath, websocket.Handler(func(c *websocket.Conn) {
|
||||
// The tunnel payload is a raw byte stream (yamux), not UTF-8 text.
|
||||
// Send it as binary frames; otherwise RFC 6455-compliant intermediaries
|
||||
// (e.g. API gateways/reverse proxies) UTF-8-validate the default text
|
||||
// frames and close the connection on invalid bytes.
|
||||
c.PayloadType = websocket.BinaryFrame
|
||||
notifyCh := make(chan struct{})
|
||||
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
|
||||
}))
|
||||
|
||||
@@ -53,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
package version
|
||||
|
||||
var version = "0.69.1"
|
||||
var version = "0.71.1"
|
||||
|
||||
func Full() string {
|
||||
return version
|
||||
|
||||
+24
-6
@@ -28,8 +28,6 @@ import (
|
||||
|
||||
libio "github.com/fatedier/golib/io"
|
||||
"github.com/fatedier/golib/pool"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
|
||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||
"github.com/fatedier/frp/pkg/util/log"
|
||||
@@ -139,7 +137,7 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) *
|
||||
_, _ = rw.Write(getNotFoundPageContent())
|
||||
},
|
||||
}
|
||||
rp.proxy = h2c.NewHandler(proxy, &http2.Server{})
|
||||
rp.proxy = proxy
|
||||
return rp
|
||||
}
|
||||
|
||||
@@ -208,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
|
||||
@@ -226,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 {
|
||||
|
||||
@@ -1,14 +1,343 @@
|
||||
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)
|
||||
protocols.SetHTTP1(true)
|
||||
protocols.SetUnencryptedHTTP2(true)
|
||||
server := &http.Server{
|
||||
Handler: rp,
|
||||
ReadHeaderTimeout: time.Second,
|
||||
Protocols: protocols,
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() {
|
||||
serveErr <- server.Serve(listener)
|
||||
}()
|
||||
defer func() {
|
||||
require.NoError(t, server.Close())
|
||||
require.ErrorIs(t, <-serveErr, http.ErrServerClosed)
|
||||
}()
|
||||
|
||||
require.True(t, server.Protocols.HTTP1())
|
||||
require.True(t, server.Protocols.UnencryptedHTTP2())
|
||||
|
||||
t.Run("HTTP/1.1", func(t *testing.T) {
|
||||
transport := &http.Transport{Protocols: httpProtocols(true, false)}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := &http.Client{Transport: transport}
|
||||
response, err := client.Get("http://" + listener.Addr().String() + "/")
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
|
||||
require.Equal(t, "HTTP/1.1", response.Proto)
|
||||
require.Equal(t, http.StatusNotFound, response.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("HTTP/2 prior knowledge", func(t *testing.T) {
|
||||
transport := &http.Transport{Protocols: httpProtocols(false, true)}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := &http.Client{Transport: transport}
|
||||
response, err := client.Get("http://" + listener.Addr().String() + "/")
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
|
||||
require.Equal(t, "HTTP/2.0", response.Proto)
|
||||
require.Equal(t, http.StatusNotFound, response.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("HTTP/1.1 Upgrade h2c", func(t *testing.T) {
|
||||
conn, err := net.Dial("tcp", listener.Addr().String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
_, err = fmt.Fprintf(conn,
|
||||
"GET / HTTP/1.1\r\nHost: %s\r\n"+
|
||||
"Connection: Upgrade, HTTP2-Settings\r\nUpgrade: h2c\r\n"+
|
||||
"HTTP2-Settings: AAMAAABkAAQCAAAAAAIAAAAA\r\n\r\n",
|
||||
listener.Addr())
|
||||
require.NoError(t, err)
|
||||
response, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
require.NoError(t, err)
|
||||
defer response.Body.Close()
|
||||
|
||||
require.NotEqual(t, http.StatusSwitchingProtocols, response.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func httpProtocols(http1, unencryptedHTTP2 bool) *http.Protocols {
|
||||
protocols := new(http.Protocols)
|
||||
protocols.SetHTTP1(http1)
|
||||
protocols.SetUnencryptedHTTP2(unencryptedHTTP2)
|
||||
return protocols
|
||||
}
|
||||
|
||||
func TestCheckRouteAuthByRequest(t *testing.T) {
|
||||
rc := &RouteConfig{
|
||||
Username: "alice",
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -48,6 +48,17 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
||||
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
|
||||
|
||||
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemInfo)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemPrune)).Methods("POST")
|
||||
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
|
||||
v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
|
||||
v2EncodedPathRouter.UseEncodedPath()
|
||||
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
|
||||
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyTraffic)).Methods("GET")
|
||||
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
|
||||
|
||||
// view
|
||||
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
||||
subRouter.PathPrefix("/static/").Handler(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user