mirror of
https://github.com/fatedier/frp.git
synced 2026-10-17 05: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 | ||
|
|
8dd26c6961 | ||
|
|
9ea1d86f03 | ||
|
|
ac3e82db4e | ||
|
|
0773938d70 | ||
|
|
9bacce22a2 | ||
|
|
7f8d68b666 |
+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
-18
@@ -1,21 +1,13 @@
|
||||
## Compatibility Policy
|
||||
## Improvements
|
||||
|
||||
Starting with v0.69.0, each minor release is supported until there are nine newer minor releases. For example, v0.69.0 will be supported until v0.78.0 is released. Within this window, frpc v0.69.0 is guaranteed to work with any frps from v0.61.0 to v0.77.0, and vice versa. Patch releases within the same minor are always compatible. Versions outside the support window may continue to work on a best-effort basis, but compatibility is no longer guaranteed.
|
||||
* Building frp from source now requires Go 1.26.0 or later.
|
||||
|
||||
For mixed-version deployments, upgrade frps first, then upgrade frpc. This keeps the server side ready for newer client-side protocol behavior before clients start using it.
|
||||
## Fixes
|
||||
|
||||
## Notes
|
||||
|
||||
This release introduces wire protocol v2 as a transition path for future frpc/frps protocol changes. The existing wire protocol is difficult to extend without compatibility risk, and upcoming changes, including replacing deprecated stream encryption methods, require a versioned protocol.
|
||||
|
||||
**The default value of `transport.wireProtocol` remains `v1` in this release.** Users can keep the default for now. To test v2 early, upgrade both frpc and frps to versions that support it, then set `transport.wireProtocol = "v2"` in frpc. A v2-enabled frpc cannot connect to an older frps.
|
||||
|
||||
When `transport.wireProtocol = "v2"` is enabled, the control channel uses negotiated AEAD encryption after the login handshake. Both frpc and frps must be upgraded to this release to use v2.
|
||||
|
||||
v1 will be deprecated when v2 becomes the default in a future release. It will continue to be supported until v0.78.0 is released, and may be removed in v0.78.0 or later.
|
||||
|
||||
## Features
|
||||
|
||||
* Added `transport.wireProtocol` for frpc to select the internal message protocol used between frpc and frps. Supported values are `v1` and `v2`.
|
||||
* Added client protocol visibility in the frps dashboard and `/api/clients` API. Online clients now report their negotiated protocol as `v1` or `v2`.
|
||||
* Wire protocol v2 now negotiates AEAD control-channel encryption. Supported algorithms are `xchacha20-poly1305` and `aes-256-gcm`; frpc advertises its preferred order based on local AES-GCM hardware support, and frps selects the first supported algorithm from that list.
|
||||
* 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")
|
||||
}
|
||||
}
|
||||
+14
-7
@@ -87,6 +87,13 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
|
||||
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
|
||||
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
|
||||
@@ -109,7 +116,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
|
||||
// udp service <- frpc <- frps <- frpc visitor <- user
|
||||
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
|
||||
workConnReaderFn := func(payloadConn *msg.Conn, readCh chan *msg.UDPPacket) {
|
||||
defer closeFn()
|
||||
|
||||
for {
|
||||
@@ -122,7 +129,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
|
||||
var udpMsg msg.UDPPacket
|
||||
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
|
||||
if errRet := payloadConn.ReadMsgInto(&udpMsg); errRet != nil {
|
||||
xl.Warnf("read from workConn for sudp error: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -137,7 +144,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
|
||||
// udp service -> frpc -> frps -> frpc visitor -> user
|
||||
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
|
||||
workConnSenderFn := func(payloadConn *msg.Conn, sendCh chan msg.Message) {
|
||||
defer func() {
|
||||
closeFn()
|
||||
xl.Infof("writer goroutine for sudp work connection closed")
|
||||
@@ -148,12 +155,12 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
switch m := rawMsg.(type) {
|
||||
case *msg.UDPPacket:
|
||||
xl.Tracef("frpc send udp package to frpc visitor, [udp local: %v, remote: %v], [tcp work conn local: %v, remote: %v]",
|
||||
m.LocalAddr.String(), m.RemoteAddr.String(), conn.LocalAddr().String(), conn.RemoteAddr().String())
|
||||
m.LocalAddr.String(), m.RemoteAddr.String(), payloadConn.LocalAddr().String(), payloadConn.RemoteAddr().String())
|
||||
case *msg.Ping:
|
||||
xl.Tracef("frpc send ping message to frpc visitor")
|
||||
}
|
||||
|
||||
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
|
||||
if errRet = payloadConn.WriteMsg(rawMsg); errRet != nil {
|
||||
xl.Errorf("sudp work write error: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -184,8 +191,8 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
}
|
||||
|
||||
go workConnSenderFn(workConn, sendCh)
|
||||
go workConnReaderFn(workConn, readCh)
|
||||
go workConnSenderFn(payloadConn, sendCh)
|
||||
go workConnReaderFn(payloadConn, readCh)
|
||||
go heartbeatFn(sendCh)
|
||||
|
||||
udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
|
||||
|
||||
+16
-7
@@ -97,17 +97,26 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
return
|
||||
}
|
||||
|
||||
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
|
||||
// Plain UDP payload follows the configured wire protocol for message framing.
|
||||
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 = netpkg.WrapReadWriteCloserToConn(remote, conn)
|
||||
pxy.workConn = workConn
|
||||
pxy.readCh = make(chan *msg.UDPPacket, 1024)
|
||||
pxy.sendCh = make(chan msg.Message, 1024)
|
||||
pxy.closed = false
|
||||
pxy.mu.Unlock()
|
||||
|
||||
workConnReaderFn := func(conn net.Conn, readCh chan *msg.UDPPacket) {
|
||||
workConnReaderFn := func(rw msg.ReadWriter, readCh chan *msg.UDPPacket) {
|
||||
for {
|
||||
var udpMsg msg.UDPPacket
|
||||
if errRet := msg.ReadMsgInto(conn, &udpMsg); errRet != nil {
|
||||
if errRet := rw.ReadMsgInto(&udpMsg); errRet != nil {
|
||||
xl.Warnf("read from workConn for udp error: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -120,7 +129,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
}
|
||||
}
|
||||
workConnSenderFn := func(conn net.Conn, sendCh chan msg.Message) {
|
||||
workConnSenderFn := func(rw msg.ReadWriter, sendCh chan msg.Message) {
|
||||
defer func() {
|
||||
xl.Infof("writer goroutine for udp work connection closed")
|
||||
}()
|
||||
@@ -132,7 +141,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
case *msg.Ping:
|
||||
xl.Tracef("send ping message to udp workConn")
|
||||
}
|
||||
if errRet = msg.WriteMsg(conn, rawMsg); errRet != nil {
|
||||
if errRet = rw.WriteMsg(rawMsg); errRet != nil {
|
||||
xl.Errorf("udp work write error: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -151,8 +160,8 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
|
||||
}
|
||||
}
|
||||
|
||||
go workConnSenderFn(pxy.workConn, pxy.sendCh)
|
||||
go workConnReaderFn(pxy.workConn, pxy.readCh)
|
||||
go workConnSenderFn(payloadRW, pxy.sendCh)
|
||||
go workConnReaderFn(payloadRW, pxy.readCh)
|
||||
go heartbeatFn(pxy.sendCh)
|
||||
|
||||
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
|
||||
|
||||
+11
-3
@@ -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"
|
||||
@@ -57,8 +57,7 @@ func NewXTCPProxy(baseProxy *BaseProxy, cfg v1.ProxyConfigurer) Proxy {
|
||||
func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkConn) {
|
||||
xl := pxy.xl
|
||||
defer conn.Close()
|
||||
var natHoleSidMsg msg.NatHoleSid
|
||||
err := msg.ReadMsgInto(conn, &natHoleSidMsg)
|
||||
natHoleSidMsg, err := readNatHoleSid(conn, pxy.clientCfg.Transport.WireProtocol)
|
||||
if err != nil {
|
||||
xl.Errorf("xtcp read from workConn error: %v", err)
|
||||
return
|
||||
@@ -131,6 +130,15 @@ func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkC
|
||||
pxy.listenByQUIC(listenConn, raddr, startWorkConnMsg)
|
||||
}
|
||||
|
||||
func readNatHoleSid(conn net.Conn, wireProtocol string) (*msg.NatHoleSid, error) {
|
||||
workMsgConn := msg.NewConn(conn, msg.NewReadWriter(conn, wireProtocol))
|
||||
var natHoleSidMsg msg.NatHoleSid
|
||||
if err := workMsgConn.ReadMsgInto(&natHoleSidMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &natHoleSidMsg, nil
|
||||
}
|
||||
|
||||
func (pxy *XTCPProxy) listenByKCP(listenConn *net.UDPConn, raddr *net.UDPAddr, startWorkConnMsg *msg.StartWorkConn) {
|
||||
xl := pxy.xl
|
||||
listenConn.Close()
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// 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 (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
"github.com/fatedier/frp/pkg/proto/wire"
|
||||
)
|
||||
|
||||
func TestReadNatHoleSidUsesSelectedWireProtocol(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
wireProtocol string
|
||||
}{
|
||||
{name: "v2", wireProtocol: wire.ProtocolV2},
|
||||
{name: "v1", wireProtocol: wire.ProtocolV1},
|
||||
{name: "default", wireProtocol: ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
setPipeDeadline(t, client, server)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
writer := msg.NewConn(server, msg.NewReadWriter(server, tc.wireProtocol))
|
||||
errCh <- writer.WriteMsg(&msg.NatHoleSid{Sid: "sid"})
|
||||
}()
|
||||
|
||||
out, err := readNatHoleSid(client, tc.wireProtocol)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "sid", out.Sid)
|
||||
require.NoError(t, <-errCh)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setPipeDeadline(t *testing.T, conns ...net.Conn) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for _, conn := range conns {
|
||||
require.NoError(t, conn.SetDeadline(deadline))
|
||||
}
|
||||
}
|
||||
+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,
|
||||
|
||||
+18
-11
@@ -113,15 +113,22 @@ 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")
|
||||
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)
|
||||
closeCh := make(chan struct{})
|
||||
|
||||
// udp service -> frpc -> frps -> frpc visitor -> user
|
||||
workConnReaderFn := func(conn net.Conn) {
|
||||
workConnReaderFn := func(payloadConn *msg.Conn) {
|
||||
defer func() {
|
||||
conn.Close()
|
||||
payloadConn.Close()
|
||||
close(closeCh)
|
||||
wg.Done()
|
||||
}()
|
||||
@@ -133,13 +140,13 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
)
|
||||
|
||||
// frpc will send heartbeat in workConn to frpc visitor for keeping alive
|
||||
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
if rawMsg, errRet = msg.ReadMsg(conn); errRet != nil {
|
||||
_ = payloadConn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
if rawMsg, errRet = payloadConn.ReadMsg(); errRet != nil {
|
||||
xl.Warnf("read from workconn for user udp conn error: %v", errRet)
|
||||
return
|
||||
}
|
||||
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
_ = payloadConn.SetReadDeadline(time.Time{})
|
||||
switch m := rawMsg.(type) {
|
||||
case *msg.Ping:
|
||||
xl.Debugf("frpc visitor get ping message from frpc")
|
||||
@@ -157,15 +164,15 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
}
|
||||
|
||||
// udp service <- frpc <- frps <- frpc visitor <- user
|
||||
workConnSenderFn := func(conn net.Conn) {
|
||||
workConnSenderFn := func(payloadConn *msg.Conn) {
|
||||
defer func() {
|
||||
conn.Close()
|
||||
payloadConn.Close()
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
var errRet error
|
||||
if firstPacket != nil {
|
||||
if errRet = msg.WriteMsg(conn, firstPacket); errRet != nil {
|
||||
if errRet = payloadConn.WriteMsg(firstPacket); errRet != nil {
|
||||
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -180,7 +187,7 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
return
|
||||
}
|
||||
|
||||
if errRet = msg.WriteMsg(conn, udpMsg); errRet != nil {
|
||||
if errRet = payloadConn.WriteMsg(udpMsg); errRet != nil {
|
||||
xl.Warnf("sender goroutine for udp work connection closed: %v", errRet)
|
||||
return
|
||||
}
|
||||
@@ -191,8 +198,8 @@ func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
|
||||
}
|
||||
}
|
||||
|
||||
go workConnReaderFn(workConn)
|
||||
go workConnSenderFn(workConn)
|
||||
go workConnReaderFn(payloadConn)
|
||||
go workConnSenderFn(payloadConn)
|
||||
|
||||
wg.Wait()
|
||||
xl.Infof("sudp worker is closed")
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -43,9 +43,28 @@ func TestV2ReadWriterRoundTrip(t *testing.T) {
|
||||
func TestNewReadWriter(t *testing.T) {
|
||||
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, ""))
|
||||
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV1))
|
||||
require.IsType(t, &V1ReadWriter{}, NewReadWriter(&bytes.Buffer{}, "unknown"))
|
||||
require.IsType(t, &V2ReadWriter{}, NewReadWriter(&bytes.Buffer{}, wire.ProtocolV2))
|
||||
}
|
||||
|
||||
func TestNewReadWriterEncoding(t *testing.T) {
|
||||
for _, wireProtocol := range []string{"", wire.ProtocolV1} {
|
||||
var legacy bytes.Buffer
|
||||
legacyRW := NewReadWriter(&legacy, wireProtocol)
|
||||
require.NoError(t, legacyRW.WriteMsg(&UDPPacket{Content: []byte("legacy")}))
|
||||
require.NotEmpty(t, legacy.Bytes())
|
||||
require.Equal(t, TypeUDPPacket, legacy.Bytes()[0])
|
||||
}
|
||||
|
||||
var v2 bytes.Buffer
|
||||
v2RW := NewReadWriter(&v2, wire.ProtocolV2)
|
||||
require.NoError(t, v2RW.WriteMsg(&UDPPacket{Content: []byte("v2")}))
|
||||
frame, err := wire.NewConn(&v2).ReadFrame()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, wire.FrameTypeMessage, frame.Type)
|
||||
require.Equal(t, V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
|
||||
}
|
||||
|
||||
func TestV2MessageTypeIDsAreStable(t *testing.T) {
|
||||
require.Equal(t, uint16(1), V2TypeLogin)
|
||||
require.Equal(t, uint16(2), V2TypeLoginResp)
|
||||
@@ -65,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.0"
|
||||
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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user