Compare commits

...
86 Commits
Author SHA1 Message Date
fatedierandGitHub d8cf1800dc Merge pull request #5568 from fatedier/dev
Release v0.71.1
2026-10-14 16:40:54 +08:00
fatedierandGitHub 41ed2aecf8 test: align UDP compatibility protocol boundaries (#5567) 2026-10-14 10:49:55 +08:00
fatedierandGitHub 12b0093ccf chore: prepare v0.71.1 release notes (#5564) 2026-10-13 10:46:29 +08:00
fatedierandGitHub 2542d11a62 fix(net): coordinate websocket handoff with listener shutdown (#5563) 2026-10-10 19:58:05 +08:00
Abdellatif AnaflousandGitHub c4d2db4177 fix(websocket): release a pending Accept when the listener is closed (#5552)
the websocket listener kept its accept channel open on Close, so an Accept call that was already waiting blocked forever

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

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

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

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

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

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

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

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

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


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

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

* test: remove fixed virtual net helper parameters
2026-08-28 10:56:05 +08:00
flyingandGitHub 40bb45d192 vnet: fix route cleanup and reconnect backoff (#5492) 2026-08-26 23:33:45 +08:00
fatedierandGitHub 758f07d59e deps: migrate to fatedier/yamux v0.2.0 (#5498) 2026-08-18 00:24:11 +08:00
fatedierandGitHub 25a4ba306a ci: pin golangci-lint to v2.12.2 (#5496) 2026-08-17 00:48:09 +08:00
4a23aa181c Release v0.71.0 (#5495)
* web/frpc: support virtual net visitor plugin (#5414)

* web/frpc: fix visitor plugin validation for sudp (#5448)

* web: patch vulnerable transitive dependencies (#5449)

* web: fix Vite config tsconfig includes (#5450)

* test(web): add frontend unit test baseline (#5451)

* build(web): upgrade ESLint to v10 (#5452)

* build(web): upgrade auto import plugins (#5453)

* chore(web): upgrade frontend dependencies (#5454)

* feat: add negotiated binary UDP packets (#5456)

* docs: add release note for #5456 (#5458)

* server: validate control pool counts (#5459)

* feat: use binary codec for SUDP packets (#5461)

* update version to 0.71.0 (#5464)

* fix(frpc): respect feature gates in verify (#5465)

* remove retired Go Report Card badge (#5467)

* udp: handle closed forwarding channel (#5470)

* limit: clamp bandwidth limiter burst (#5471)

* client: reject invalid work connection addresses (#5472)

* ssh: serialize tunnel channel writes (#5473)

* config: reject case-insensitive subdomain domains (#5474)

* docs: add release note for domain validation fix (#5475)

* log: improve prefix handling (#5489)

* web: patch vulnerable transitive dependencies (#5490)

---------

Co-authored-by: barkure <43804451+barkure@users.noreply.github.com>
Co-authored-by: Yarden Shoham <git@yardenshoham.com>
2026-08-14 14:13:20 +08:00
fatedierandGitHub 6c8a8d0a97 web: patch vulnerable transitive dependencies (#5490) 2026-08-13 01:12:34 +08:00
fatedierandGitHub da04e1e07a log: improve prefix handling (#5489) 2026-08-12 23:56:00 +08:00
fatedierandGitHub 71a2bf30f9 docs: add release note for domain validation fix (#5475) 2026-08-09 23:04:09 +08:00
fatedierandGitHub a6a782bed4 config: reject case-insensitive subdomain domains (#5474) 2026-08-09 22:52:30 +08:00
fatedierandGitHub 223b44336c ssh: serialize tunnel channel writes (#5473) 2026-08-09 19:12:12 +08:00
fatedierandGitHub f6688e2a0d client: reject invalid work connection addresses (#5472) 2026-08-09 18:39:21 +08:00
fatedierandGitHub f666d97b64 limit: clamp bandwidth limiter burst (#5471) 2026-08-09 17:30:39 +08:00
fatedierandGitHub 5b68148f11 udp: handle closed forwarding channel (#5470) 2026-08-09 15:31:11 +08:00
Yarden ShohamandGitHub d1928f9689 remove retired Go Report Card badge (#5467) 2026-08-05 10:52:16 +08:00
fatedierandGitHub 1a3a872bd2 fix(frpc): respect feature gates in verify (#5465) 2026-08-04 12:53:10 +08:00
fatedierandGitHub 290017cb53 update version to 0.71.0 (#5464) 2026-08-03 23:37:36 +08:00
fatedierandGitHub 2291e8835f feat: use binary codec for SUDP packets (#5461) 2026-07-31 16:53:16 +08:00
fatedierandGitHub 1ab59e763c server: validate control pool counts (#5459) 2026-07-30 23:30:16 +08:00
fatedierandGitHub 9d45a55720 docs: add release note for #5456 (#5458) 2026-07-30 14:40:54 +08:00
fatedierandGitHub effa496859 feat: add negotiated binary UDP packets (#5456) 2026-07-30 13:19:55 +08:00
fatedierandGitHub 5c6d761c12 chore(web): upgrade frontend dependencies (#5454) 2026-07-28 19:36:44 +08:00
fatedierandGitHub 3d89ab7ff1 build(web): upgrade auto import plugins (#5453) 2026-07-28 17:09:46 +08:00
fatedierandGitHub f3828cbfdb build(web): upgrade ESLint to v10 (#5452) 2026-07-28 16:10:29 +08:00
fatedierandGitHub 00c24e39bb test(web): add frontend unit test baseline (#5451) 2026-07-28 01:27:35 +08:00
fatedierandGitHub 2175557d48 web: fix Vite config tsconfig includes (#5450) 2026-07-27 19:21:28 +08:00
fatedierandGitHub de4b483710 web: patch vulnerable transitive dependencies (#5449) 2026-07-27 14:57:58 +08:00
fatedierandGitHub 2d63f6b1f9 web/frpc: fix visitor plugin validation for sudp (#5448) 2026-07-27 12:04:00 +08:00
barkureandGitHub 40274d8c92 web/frpc: support virtual net visitor plugin (#5414) 2026-07-23 22:02:49 +08:00
fatedierandGitHub fa3bcca2b0 Merge pull request #5444 from fatedier/dev
Release v0.70.1
2026-07-23 15:34:31 +08:00
fatedierandGitHub 18eef83b69 fix(client): synchronize graceful shutdown duration (#5442) 2026-07-23 13:55:00 +08:00
fatedierandGitHub 466a94f5d1 update quic-go dependency to v0.60.0 (#5443) 2026-07-23 12:28:10 +08:00
barkureandGitHub 898bcf73d5 web: restore Vite client declarations for both dashboards (#5415)
The 2024 bundler-mode migration changed the dashboard tsconfig include
lists to src/**, leaving the root env.d.ts files outside the TypeScript
programs. Move the Vite client declarations into src/ for both the frpc
and frps dashboards so they are part of the programs again.

Drop the *.vue wildcard module shim: the current vue-tsc toolchain
resolves SFC types without it, and the shim would only mask real
component prop types under plain tsc.
2026-07-23 11:14:56 +08:00
fatedierandGitHub 0e53833a2c refactor: use standard library HKDF (#5440) 2026-07-23 01:17:37 +08:00
fatedierandGitHub c23934bb67 web: update vulnerable transitive dependencies (#5441) 2026-07-23 01:16:40 +08:00
fatedierandGitHub 23512f577e fix: upgrade go-oidc to v3.18.0 (#5439) 2026-07-23 00:00:47 +08:00
fatedierandGitHub 84b907a198 deps: bump golib to v0.8.1 (#5437) 2026-07-22 21:47:36 +08:00
fatedierandGitHub f8fc3c6b1b server: drop HTTP/1.1 h2c upgrade handling (#5436)
Use net/http Server.Protocols and the merged golib listener path instead of the deprecated h2c handler. Keep HTTP/1.1 and cleartext HTTP/2 prior-knowledge support, while intentionally dropping HTTP/1.1 Upgrade: h2c.
2026-07-22 19:55:42 +08:00
fatedierandGitHub 7dc7be930e ssh: fix malformed exec payload panic (#5428) 2026-07-21 23:04:56 +08:00
fatedierandGitHub d486018885 fix(server): prevent control replacement lifecycle leaks (#5424) 2026-07-21 18:09:09 +08:00
fatedierandGitHub fe79598ee4 fix: fail fast on cross-compile errors (#5420) 2026-07-16 16:40:48 +08:00
fatedierandGitHub 269a26c5d5 fix(nathole): migrate STUN client to golib (#5419) 2026-07-16 13:11:45 +08:00
fatedierandGitHub 2886393f5b ci: remove unsupported s390x image target (#5412) 2026-07-11 22:44:45 +08:00
fatedierandGitHub 7b6e01f04f Merge pull request #5411 from fatedier/dev
Release v0.70.0
2026-07-11 18:50:35 +08:00
fatedierandGitHub 5396947c7c remove retired Go Report Card badge (#5409) 2026-07-11 15:59:51 +08:00
fatedierandGitHub fe61093bc2 bump version to 0.70.0 (#5406) 2026-07-11 14:28:19 +08:00
fatedierandGitHub 54aeb2a7b0 feat(server): add typed frps v2 proxy specs (#5405) 2026-07-11 10:34:04 +08:00
fatedierandGitHub 84be1938e4 api: expose v2 proxy timestamps as unix seconds (#5402) 2026-07-09 14:47:19 +08:00
fatedierandGitHub becee40715 docs: update API v2 release notes (#5400) 2026-07-08 15:54:16 +08:00
fatedierandGitHub 17e788d43b refactor: clean up frps v2 frontend models (#5399) 2026-07-08 13:10:55 +08:00
fatedierandGitHub 68509f5d44 Add frps proxy traffic API v2 (#5398) 2026-07-08 02:05:28 +08:00
fatedierandGitHub 5cd722b177 feat: add system prune API v2 (#5395) 2026-07-07 13:00:53 +08:00
fatedierandGitHub 5876beceac feat: add system info API v2 (#5394) 2026-07-07 02:00:44 +08:00
fatedierandGitHub 7fe152e3aa web/frps: use API v2 for client and proxy details (#5386) 2026-06-30 01:33:57 +08:00
fatedierandGitHub 7c343fc6e7 web: bump esbuild to 0.28.1 and remove unused ElPopover type (#5385) 2026-06-29 23:21:22 +08:00
fatedierandGitHub a3b3b35b69 feat(ui): default proxies view to all tab (#5384) 2026-06-29 22:49:08 +08:00
fatedierandGitHub ae1c0504ec feat(dashboard): add v2 client detail status (#5381) 2026-06-26 21:15:30 +08:00
fatedierandGitHub 393a533744 docs: add release note for duplicate config names (#5379) 2026-06-24 15:03:22 +08:00
MAAZIZ Adel AyoubandGitHub 035889c360 fix(client): reject duplicate proxy and visitor names (#5378) 2026-06-24 13:37:29 +08:00
fatedierandGitHub 940bde5c46 docs: update release notes (#5377) 2026-06-23 17:06:16 +08:00
fatedierandGitHub 14628df63c test: cover tls2raw proxy protocol header (#5376) 2026-06-23 00:04:38 +08:00
Shani PathakandGitHub ba7adcab8f fix(websocket): send tunnel payload as binary frames (#5363)
The ws/wss transport carries a raw byte stream (yamux), but the
golang.org/x/net/websocket Conn defaults to text frames (PayloadType
TextFrame). Per RFC 6455 §5.6 a text frame must contain valid UTF-8, so
RFC-compliant intermediaries (API gateways / reverse proxies) validate
the payload and close the connection when the binary tunnel data is not
valid UTF-8.

This goes unnoticed peer-to-peer because x/net/websocket does not
validate UTF-8 on read, but it breaks the connection through a compliant
validating proxy. Set PayloadType to BinaryFrame on both the server
listener and the client dialer so the tunnel is framed as binary.
2026-06-22 23:34:02 +08:00
4cc826e236 fix(client): write proxy protocol header in tls2raw plugin (#5362)
Co-authored-by: futrobo <futrobo@163.com>
2026-06-22 23:08:50 +08:00
fatedierandGitHub 54c6ccdfec feat: remove proxies client filter (#5375) 2026-06-22 22:53:57 +08:00
fatedierandGitHub 9bde0b07de feat: paginate dashboard clients and proxies via API v2 (#5354)
Move the frps dashboard Clients and Proxies views to the paginated
/api/v2/clients and /api/v2/proxies endpoints instead of fetching all
data at once, and extend server-side proxy search so the search box
keeps working under pagination.

Frontend:
- Add V2Envelope/V2Page types and getV2 HTTP helper to api/http.ts
- Add v2 paginated fetch functions to api/client.ts and api/proxy.ts
- Add ClientV2Info and ProxyV2Info types for v2 API responses
- Rewrite Clients.vue with server-side pagination, status/user search
  filtering, and ElPagination component
- Rewrite Proxies.vue with server-side pagination, type tabs, client
  dropdown filter, and a search box that passes q to the API
- Default page size 10, selectable sizes [10, 20, 50, 100]

Backend:
- Extend /api/v2/proxies q matching to also cover online proxy spec
  fields: TCP/UDP remotePort and HTTP/HTTPS/TCPMux customDomains and
  subdomain, so dashboard search no longer needs to scan every page
- Add controller_v2 tests for the new spec-field matching
2026-06-03 14:08:45 +08:00
fatedierandGitHub c6c545289c fix: normalize web package lockfile (#5353) 2026-06-02 13:39:37 +08:00
fatedier 503afe78b7 feat: add dashboard API v2 pagination endpoints (#5351) 2026-06-01 20:09:25 +08:00
170 changed files with 16003 additions and 2524 deletions
+13 -11
View File
@@ -1,24 +1,26 @@
version: 2
version: 2.1
jobs:
go-version-latest:
docker:
- image: cimg/go:1.25-node
- image: cimg/go:1.26-node
resource_class: large
steps:
- checkout
- run:
name: Build web assets (frps)
command: make install build
working_directory: web/frps
name: Test and build web assets
command: make web-ci
- run:
name: Build web assets (frpc)
command: make install build
working_directory: web/frpc
- run: make
- run: make alltest
name: Check Go formatting and build binaries
command: |
set -e
make env fmt
git diff --exit-code
make build
- run:
name: Run all tests
command: make alltest
workflows:
version: 2
build_and_test:
jobs:
- go-version-latest
+2 -2
View File
@@ -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 }}
+4 -8
View File
@@ -17,19 +17,15 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.25'
go-version: '1.26'
cache: false
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Build web assets (frps)
run: make build
working-directory: web/frps
- name: Build web assets (frpc)
run: make build
working-directory: web/frpc
- name: Test and build web assets
run: make web-ci
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
version: v2.11
version: v2.12.2
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.25'
go-version: '1.26'
- uses: actions/setup-node@v6
with:
node-version: '22'
+4 -1
View File
@@ -5,7 +5,7 @@ NOWEB_TAG = $(shell [ ! -d web/frps/dist ] || [ ! -d web/frpc/dist ] && echo ',n
FRP_COMPAT_BASELINE_COUNT ?= 8
FRP_COMPAT_FLOOR_VERSION ?= 0.61.0
.PHONY: web frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor
.PHONY: web web-ci frps-web frpc-web frps frpc e2e-compatibility-smoke e2e-compatibility e2e-compatibility-floor
all: env fmt web build
@@ -16,6 +16,9 @@ env:
web: frps-web frpc-web
web-ci:
cd web && npm ci && npm run lint:check --workspace frps && npm run lint:check --workspace frpc && npm run test:unit && npm run build --workspace frps && npm run build --workspace frpc
frps-web:
$(MAKE) -C web/frps build
+1 -1
View File
@@ -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); \
+13 -1
View File
@@ -2,7 +2,6 @@
[![Build Status](https://circleci.com/gh/fatedier/frp.svg?style=shield)](https://circleci.com/gh/fatedier/frp)
[![GitHub release](https://img.shields.io/github/tag/fatedier/frp.svg?label=release)](https://github.com/fatedier/frp/releases)
[![Go Report Card](https://goreportcard.com/badge/github.com/fatedier/frp)](https://goreportcard.com/report/github.com/fatedier/frp)
[![GitHub Releases Stats](https://img.shields.io/github/downloads/fatedier/frp/total.svg?logo=github)](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
[README](README.md) | [中文文档](README_zh.md)
@@ -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
View File
@@ -2,7 +2,6 @@
[![Build Status](https://circleci.com/gh/fatedier/frp.svg?style=shield)](https://circleci.com/gh/fatedier/frp)
[![GitHub release](https://img.shields.io/github/tag/fatedier/frp.svg?label=release)](https://github.com/fatedier/frp/releases)
[![Go Report Card](https://goreportcard.com/badge/github.com/fatedier/frp)](https://goreportcard.com/report/github.com/fatedier/frp)
[![GitHub Releases Stats](https://img.shields.io/github/downloads/fatedier/frp/total.svg?logo=github)](https://somsubhra.github.io/github-release-stats/?username=fatedier&repository=frp)
[README](README.md) | [中文文档](README_zh.md)
@@ -15,6 +14,18 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
<h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start-->
<p align="center">
<a href="https://www.rapidproxy.io/?ref=frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_rapidproxy.png">
<br>
<b>High-performance residential and ISP proxies for developers</b>
</a>
<br>
<sub>90M+ residential IPs worldwide. Rotating IPs, sticky sessions, and traffic that never expires.</sub>
<br>
<sub>From $0.55/GB. Use RAPID10 for 10% off. Try it for free.</sub>
</p>
<p align="center">
<a href="https://jb.gg/frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
@@ -42,6 +53,7 @@ If you're looking for a meeting recording API, consider checking out [Recall.ai]
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<!--gold sponsors end-->
## 为什么使用 frp ?
+10 -6
View File
@@ -1,9 +1,13 @@
## Features
## Improvements
* `transport.wireProtocol = "v2"` now also applies to UDP-based proxy payloads, including ordinary UDP and SUDP, so their payload framing is consistent with the selected wire protocol.
* Improved SUDP compatibility during mixed `transport.wireProtocol` deployments, allowing frps to bridge payloads between v1/default and v2 SUDP clients.
* XTCP work connection `NatHoleSid` messages now follow the selected `transport.wireProtocol`.
* Building frp from source now requires Go 1.26.0 or later.
## Compatibility Notes
## Fixes
* When enabling `transport.wireProtocol = "v2"` for SUDP, upgrade both the proxy and visitor frpc instances first, or keep them on `v1` until both sides are upgraded.
* Updated dependencies to fix SSH tunnel gateway channel deadlocks and panics caused by malformed challenges from NTLM-authenticated HTTP proxies.
* Fixed VirtualNet routing issues during reconnection and shutdown, and excessive retries after repeated connection failures.
* Health checks now reset the failure count after success, so `healthCheck.maxFailed` applies to consecutive failures.
* Fixed control connections getting stuck after message write failures, which could block reconnection.
* Fixed incorrect daily traffic statistics around daylight-saving transitions and skipped local midnights.
* Fixed early-data loss in HTTP virtual-host CONNECT tunnels. Requests with a positive content length or transfer encoding now return HTTP 400.
* Fixed work connections remaining open when server-side bandwidth limiting is enabled for TCP, HTTP, and UDP proxies.
+254
View File
@@ -2,12 +2,16 @@ package client
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/fatedier/frp/client/configmgmt"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/policy/security"
"github.com/fatedier/frp/pkg/vnet"
)
func newTestRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
@@ -22,6 +26,256 @@ func newTestRawTCPProxyConfig(name string) *v1.TCPProxyConfig {
}
}
func newTestVirtualNetProxyConfig(name string) *v1.STCPProxyConfig {
return &v1.STCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
Name: name,
Type: "stcp",
ProxyBackend: v1.ProxyBackend{
Plugin: v1.TypedClientPluginOptions{
Type: v1.PluginVirtualNet,
ClientPluginOptions: &v1.VirtualNetPluginOptions{Type: v1.PluginVirtualNet},
},
},
},
}
}
func newTestVirtualNetVisitorConfig(name string) *v1.STCPVisitorConfig {
return &v1.STCPVisitorConfig{
VisitorBaseConfig: v1.VisitorBaseConfig{
Name: name,
Type: "stcp",
ServerName: "vnet-server",
SecretKey: "secret",
BindPort: -1,
Plugin: v1.TypedVisitorPluginOptions{
Type: v1.VisitorPluginVirtualNet,
VisitorPluginOptions: &v1.VirtualNetVisitorPluginOptions{
Type: v1.VisitorPluginVirtualNet,
DestinationIP: "100.86.0.1",
},
},
},
}
}
func TestServiceConfigManagerReloadVirtualNetRuntimeDependency(t *testing.T) {
const runtimeErr = "VirtualNet-dependent configuration requires a VirtualNet runtime enabled at startup"
tests := []struct {
name string
startupVirtualNetAddr string
nextConfig string
wantRuntimeDependency bool
}{
{
name: "unrelated common config",
nextConfig: `serverAddr = "0.0.0.0"`,
},
{
name: "VirtualNet address without startup runtime",
nextConfig: `featureGates = { VirtualNet = true }
virtualNet.address = "100.86.0.4/24"
`,
wantRuntimeDependency: true,
},
{
name: "VirtualNet proxy without startup runtime",
nextConfig: `[[proxies]]
name = "vnet-proxy"
type = "stcp"
secretKey = "secret"
[proxies.plugin]
type = "virtual_net"
`,
wantRuntimeDependency: true,
},
{
name: "VirtualNet visitor without startup runtime",
nextConfig: `[[visitors]]
name = "vnet-visitor"
type = "stcp"
serverName = "vnet-server"
secretKey = "secret"
bindPort = -1
[visitors.plugin]
type = "virtual_net"
destinationIP = "100.86.0.1"
`,
wantRuntimeDependency: true,
},
{
name: "existing VirtualNet startup runtime",
startupVirtualNetAddr: "100.86.0.4/24",
nextConfig: `featureGates = { VirtualNet = true }
virtualNet.address = "100.86.0.5/24"
`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
current := &v1.ClientCommonConfig{}
if tc.startupVirtualNetAddr != "" {
current.FeatureGates = map[string]bool{"VirtualNet": true}
current.VirtualNet.Address = tc.startupVirtualNetAddr
}
if err := current.Complete(); err != nil {
t.Fatalf("complete current config: %v", err)
}
configFile := filepath.Join(t.TempDir(), "frpc.toml")
if err := os.WriteFile(configFile, []byte(tc.nextConfig), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
configSource := source.NewConfigSource()
aggregator := source.NewAggregator(configSource)
svr := &Service{
common: current,
reloadCommon: current,
configFilePath: configFile,
unsafeFeatures: security.NewUnsafeFeatures(nil),
aggregator: aggregator,
configSource: configSource,
}
if tc.startupVirtualNetAddr != "" {
svr.vnetController = vnet.NewController(current.VirtualNet)
}
err := (&serviceConfigManager{svr: svr}).ReloadFromFile(true)
if tc.wantRuntimeDependency {
if !errors.Is(err, configmgmt.ErrApplyConfig) || !strings.Contains(err.Error(), runtimeErr) {
t.Fatalf("expected VirtualNet runtime dependency error, got: %v", err)
}
return
}
if err != nil {
t.Fatalf("reload config: %v", err)
}
if svr.common != current {
t.Fatal("reload should not replace startup common config")
}
if tc.startupVirtualNetAddr == "" && svr.vnetController != nil {
t.Fatal("reload should not enable startup-only VirtualNet runtime state")
}
})
}
}
func TestServiceConfigManagerReloadVirtualNetRuntimeDependencyUsesMergedSources(t *testing.T) {
const runtimeErr = "VirtualNet-dependent configuration requires a VirtualNet runtime enabled at startup"
tests := []struct {
name string
nextConfig string
storeProxy v1.ProxyConfigurer
storeVisitor v1.VisitorConfigurer
wantRuntimeDependency bool
wantProxyPlugin string
}{
{
name: "Store VirtualNet proxy is rejected",
nextConfig: `serverAddr = "0.0.0.0"`,
storeProxy: newTestVirtualNetProxyConfig("store-vnet"),
wantRuntimeDependency: true,
},
{
name: "Store VirtualNet visitor is rejected",
nextConfig: `serverAddr = "0.0.0.0"`,
storeVisitor: newTestVirtualNetVisitorConfig("store-vnet"),
wantRuntimeDependency: true,
},
{
name: "Store VirtualNet proxy overrides file proxy",
nextConfig: `[[proxies]]
name = "shared"
type = "tcp"
localPort = 10080
remotePort = 10081
`,
storeProxy: newTestVirtualNetProxyConfig("shared"),
wantRuntimeDependency: true,
},
{
name: "Store non-VirtualNet proxy overrides file VirtualNet proxy",
nextConfig: `[[proxies]]
name = "shared"
type = "stcp"
secretKey = "secret"
[proxies.plugin]
type = "virtual_net"
`,
storeProxy: newTestRawTCPProxyConfig("shared"),
wantProxyPlugin: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
current := &v1.ClientCommonConfig{}
if err := current.Complete(); err != nil {
t.Fatalf("complete current config: %v", err)
}
configFile := filepath.Join(t.TempDir(), "frpc.toml")
if err := os.WriteFile(configFile, []byte(tc.nextConfig), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
Path: filepath.Join(t.TempDir(), "store.json"),
})
if err != nil {
t.Fatalf("new store source: %v", err)
}
if tc.storeProxy != nil {
if err := storeSource.AddProxy(tc.storeProxy); err != nil {
t.Fatalf("add store proxy: %v", err)
}
}
if tc.storeVisitor != nil {
if err := storeSource.AddVisitor(tc.storeVisitor); err != nil {
t.Fatalf("add store visitor: %v", err)
}
}
configSource := source.NewConfigSource()
aggregator := source.NewAggregator(configSource)
aggregator.SetStoreSource(storeSource)
svr := &Service{
common: current,
reloadCommon: current,
configFilePath: configFile,
unsafeFeatures: security.NewUnsafeFeatures(nil),
aggregator: aggregator,
configSource: configSource,
storeSource: storeSource,
}
err = (&serviceConfigManager{svr: svr}).ReloadFromFile(true)
if tc.wantRuntimeDependency {
if !errors.Is(err, configmgmt.ErrApplyConfig) || !strings.Contains(err.Error(), runtimeErr) {
t.Fatalf("expected VirtualNet runtime dependency error, got: %v", err)
}
return
}
if err != nil {
t.Fatalf("reload config: %v", err)
}
if len(svr.proxyCfgs) != 1 {
t.Fatalf("expected one applied proxy, got %d", len(svr.proxyCfgs))
}
if got := svr.proxyCfgs[0].GetBaseConfig().Plugin.Type; got != tc.wantProxyPlugin {
t.Fatalf("unexpected applied proxy plugin: %q", got)
}
})
}
}
func TestServiceConfigManagerCreateStoreProxyConflict(t *testing.T) {
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{
Path: filepath.Join(t.TempDir(), "store.json"),
+1 -1
View File
@@ -24,7 +24,7 @@ import (
"time"
libnet "github.com/fatedier/golib/net"
fmux "github.com/hashicorp/yamux"
fmux "github.com/fatedier/yamux"
quic "github.com/quic-go/quic-go"
"github.com/samber/lo"
+11 -2
View File
@@ -47,6 +47,8 @@ type SessionContext struct {
Connector MessageConnector
// Virtual net controller
VnetController *vnet.Controller
// UDPPacketCodec is immutable for the lifetime of this negotiated session.
UDPPacketCodec string
}
type Control struct {
@@ -92,9 +94,16 @@ func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, erro
ctl.registerMsgHandlers()
ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher)
ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, sessionCtx.Auth.EncryptionKey(), ctl.msgTransporter, sessionCtx.VnetController)
ctl.pm = proxy.NewManager(
ctl.ctx,
sessionCtx.Common,
sessionCtx.Auth.EncryptionKey(),
ctl.msgTransporter,
sessionCtx.VnetController,
sessionCtx.UDPPacketCodec,
)
ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common,
ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController)
ctl.connectServer, ctl.msgTransporter, sessionCtx.VnetController, sessionCtx.UDPPacketCodec)
return ctl, nil
}
+9 -4
View File
@@ -99,6 +99,7 @@ func (d *controlSessionDialer) Dial(previousRunID string) (*SessionContext, erro
Auth: d.auth,
Connector: newMessageConnector(connector, d.common.Transport.WireProtocol),
VnetController: d.vnetController,
UDPPacketCodec: loginResult.udpPacketCodec,
}, nil
}
@@ -127,8 +128,9 @@ func (d *controlSessionDialer) buildLoginMsg(previousRunID string) (*msg.Login,
}
type loginExchangeResult struct {
resp *msg.LoginResp
crypto *wire.CryptoContext
resp *msg.LoginResp
crypto *wire.CryptoContext
udpPacketCodec string
}
func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login) (*loginExchangeResult, error) {
@@ -172,6 +174,7 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
}()
var cryptoContext *wire.CryptoContext
var udpPacketCodec string
if wireConn != nil {
serverHelloFrame, err := wireConn.ReadFrame()
if err != nil {
@@ -191,6 +194,7 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
if err != nil {
return nil, err
}
udpPacketCodec = serverHello.Selected.Message.UDPPacketCodec
}
var loginRespMsg msg.LoginResp
@@ -198,8 +202,9 @@ func (d *controlSessionDialer) exchangeLogin(conn net.Conn, loginMsg *msg.Login)
return nil, err
}
return &loginExchangeResult{
resp: &loginRespMsg,
crypto: cryptoContext,
resp: &loginRespMsg,
crypto: cryptoContext,
udpPacketCodec: udpPacketCodec,
}, nil
}
+2
View File
@@ -117,6 +117,7 @@ func TestControlSessionDialerDialV1(t *testing.T) {
defer sessionCtx.Connector.Close()
require.Equal(t, "run-v1", sessionCtx.RunID)
require.Empty(t, sessionCtx.UDPPacketCodec)
require.NotNil(t, sessionCtx.Conn)
require.NotNil(t, sessionCtx.Connector)
require.False(t, connector.closed.Load())
@@ -225,6 +226,7 @@ func TestControlSessionDialerDialV2(t *testing.T) {
defer sessionCtx.Connector.Close()
require.Equal(t, "run-v2", sessionCtx.RunID)
require.Equal(t, wire.UDPPacketCodecBinary, sessionCtx.UDPPacketCodec)
require.NotNil(t, sessionCtx.Conn)
require.NotNil(t, sessionCtx.Connector)
require.False(t, connector.closed.Load())
+125
View File
@@ -0,0 +1,125 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !frps
package client
import (
"context"
"encoding/binary"
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
clientproxy "github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/pkg/auth"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestControlPropagatesBinaryUDPPacketCodecToWorkConn(t *testing.T) {
echoConn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
require.NoError(t, err)
t.Cleanup(func() { _ = echoConn.Close() })
echoDone := make(chan error, 1)
go func() {
buf := make([]byte, 64)
n, addr, err := echoConn.ReadFromUDP(buf)
if err == nil {
_, err = echoConn.WriteToUDP(buf[:n], addr)
}
echoDone <- err
}()
authRuntime, err := auth.BuildClientAuth(&v1.AuthClientConfig{
Method: v1.AuthMethodToken,
Token: "token",
})
require.NoError(t, err)
controlConn, controlPeer := net.Pipe()
t.Cleanup(func() {
_ = controlConn.Close()
_ = controlPeer.Close()
})
common := &v1.ClientCommonConfig{
Transport: v1.ClientTransportConfig{WireProtocol: wire.ProtocolV2},
UDPPacketSize: 1500,
}
ctl, err := NewControl(context.Background(), &SessionContext{
Common: common,
RunID: "binary-udp-test",
Conn: msg.NewConn(controlConn, msg.NewV2ReadWriter(controlConn)),
Auth: authRuntime,
UDPPacketCodec: wire.UDPPacketCodecBinary,
})
require.NoError(t, err)
t.Cleanup(ctl.pm.Close)
echoAddr := echoConn.LocalAddr().(*net.UDPAddr)
proxyCfg := &v1.UDPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
Name: "udp",
Type: string(v1.ProxyTypeUDP),
ProxyBackend: v1.ProxyBackend{
LocalIP: "127.0.0.1",
LocalPort: echoAddr.Port,
},
},
}
ctl.pm.UpdateAll([]v1.ProxyConfigurer{proxyCfg})
require.Eventually(t, func() bool {
status, ok := ctl.pm.GetProxyStatus("udp")
return ok && status.Phase == clientproxy.ProxyPhaseWaitStart
}, time.Second, 10*time.Millisecond)
require.NoError(t, ctl.pm.StartProxy("udp", "", ""))
workClient, workServer := net.Pipe()
t.Cleanup(func() {
_ = workClient.Close()
_ = workServer.Close()
})
deadline := time.Now().Add(3 * time.Second)
require.NoError(t, workClient.SetDeadline(deadline))
require.NoError(t, workServer.SetDeadline(deadline))
ctl.pm.HandleWorkConn("udp", workClient, &msg.StartWorkConn{ProxyName: "udp"})
serverRW, err := msg.NewUDPPacketReadWriter(workServer, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
writeDone := make(chan error, 1)
in := &msg.UDPPacket{
Content: []byte("binary udp"),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345},
}
go func() {
writeDone <- serverRW.WriteMsg(in)
}()
frame, err := wire.NewConn(workServer).ReadFrame()
require.NoError(t, err)
require.Equal(t, wire.FrameTypeMessage, frame.Type)
require.GreaterOrEqual(t, len(frame.Payload), 2)
require.Equal(t, msg.V2TypeUDPPacketBinary, binary.BigEndian.Uint16(frame.Payload[:2]))
out, err := msg.DecodeUDPPacketBinary(frame.Payload[2:])
require.NoError(t, err)
require.Equal(t, in.Content, out.Content)
require.Equal(t, in.RemoteAddr.String(), out.RemoteAddr.String())
require.NoError(t, <-writeDone)
require.NoError(t, <-echoDone)
}
+59 -22
View File
@@ -30,6 +30,11 @@ import (
var ErrHealthCheckType = errors.New("error health check type")
func newHealthTimer(interval time.Duration) (<-chan time.Time, func()) {
timer := time.NewTimer(interval)
return timer.C, func() { timer.Stop() }
}
type Monitor struct {
checkType string
interval time.Duration
@@ -49,6 +54,9 @@ type Monitor struct {
ctx context.Context
cancel context.CancelFunc
doneCh chan struct{}
timerFactory func(time.Duration) (<-chan time.Time, func())
}
func NewMonitor(ctx context.Context, cfg v1.HealthCheckConfig, addr string,
@@ -91,6 +99,8 @@ func NewMonitor(ctx context.Context, cfg v1.HealthCheckConfig, addr string,
statusFailedFn: statusFailedFn,
ctx: newctx,
cancel: cancel,
doneCh: make(chan struct{}),
timerFactory: newHealthTimer,
}
}
@@ -102,39 +112,66 @@ func (monitor *Monitor) Stop() {
monitor.cancel()
}
// Done is closed when the worker launched by Start has exited.
func (monitor *Monitor) Done() <-chan struct{} {
return monitor.doneCh
}
func (monitor *Monitor) checkWorker() {
xl := xlog.FromContextSafe(monitor.ctx)
defer close(monitor.doneCh)
for {
if monitor.ctx.Err() != nil {
return
}
doCtx, cancel := context.WithDeadline(monitor.ctx, time.Now().Add(monitor.timeout))
err := monitor.doCheck(doCtx)
cancel()
// check if this monitor has been closed
select {
case <-monitor.ctx.Done():
cancel()
if monitor.ctx.Err() != nil {
return
default:
cancel()
}
monitor.handleCheckResult(err)
if err == nil {
xl.Tracef("do one health check success")
if !monitor.statusOK && monitor.statusNormalFn != nil {
xl.Infof("health check status change to success")
monitor.statusOK = true
monitor.statusNormalFn()
}
} else {
xl.Warnf("do one health check failed: %v", err)
monitor.failedTimes++
if monitor.statusOK && int(monitor.failedTimes) >= monitor.maxFailedTimes && monitor.statusFailedFn != nil {
xl.Warnf("health check status change to failed")
monitor.statusOK = false
monitor.statusFailedFn()
}
if !monitor.waitForNextCheck() {
return
}
}
}
time.Sleep(monitor.interval)
func (monitor *Monitor) handleCheckResult(err error) {
xl := xlog.FromContextSafe(monitor.ctx)
if err == nil {
xl.Tracef("do one health check success")
monitor.failedTimes = 0
if !monitor.statusOK && monitor.statusNormalFn != nil {
xl.Infof("health check status change to success")
monitor.statusOK = true
monitor.statusNormalFn()
}
return
}
xl.Warnf("do one health check failed: %v", err)
monitor.failedTimes++
if monitor.statusOK && int(monitor.failedTimes) >= monitor.maxFailedTimes && monitor.statusFailedFn != nil {
xl.Warnf("health check status change to failed")
monitor.statusOK = false
monitor.statusFailedFn()
}
}
func (monitor *Monitor) waitForNextCheck() bool {
timerC, stopTimer := monitor.timerFactory(monitor.interval)
defer stopTimer()
select {
case <-monitor.ctx.Done():
return false
case <-timerC:
return monitor.ctx.Err() == nil
}
}
+575
View File
@@ -0,0 +1,575 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package health
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
type tcpHealthBackend struct {
listener net.Listener
accepted chan struct{}
done chan struct{}
}
func newTCPHealthBackend(t *testing.T, addr string, accepted chan struct{}) *tcpHealthBackend {
listener, err := net.Listen("tcp", addr)
require.NoError(t, err)
backend := &tcpHealthBackend{
listener: listener,
accepted: accepted,
done: make(chan struct{}),
}
go func() {
defer close(backend.done)
for {
conn, err := backend.listener.Accept()
if err != nil {
return
}
_ = conn.Close()
select {
case backend.accepted <- struct{}{}:
default:
}
}
}()
return backend
}
func (backend *tcpHealthBackend) Close() {
_ = backend.listener.Close()
<-backend.done
}
func TestMonitorConsecutiveFailureWindows(t *testing.T) {
tests := []struct {
name string
setup func(*testing.T, func(), func()) (*Monitor, func(bool))
}{
{
name: "HTTP",
setup: func(t *testing.T, normalFn, failedFn func()) (*Monitor, func(bool)) {
var healthy atomic.Bool
healthy.Store(true)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if !healthy.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
}
}))
t.Cleanup(server.Close)
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{
Type: "http",
Path: "/health",
TimeoutSeconds: 1,
IntervalSeconds: 1,
MaxFailed: 3,
},
strings.TrimPrefix(server.URL, "http://"),
normalFn,
failedFn,
)
return monitor, healthy.Store
},
},
{
name: "TCP",
setup: func(t *testing.T, normalFn, failedFn func()) (*Monitor, func(bool)) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = listener.Close() })
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
_ = conn.Close()
}
}()
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{
Type: "tcp",
TimeoutSeconds: 1,
IntervalSeconds: 1,
MaxFailed: 3,
},
listener.Addr().String(),
normalFn,
failedFn,
)
healthyAddr := monitor.addr
return monitor, func(healthy bool) {
if healthy {
monitor.addr = healthyAddr
} else {
monitor.addr = "127.0.0.1:0"
}
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var events []string
monitor, setHealthy := test.setup(
t,
func() { events = append(events, "normal") },
func() { events = append(events, "failed") },
)
t.Cleanup(monitor.Stop)
runCheck := func(healthy bool) {
t.Helper()
setHealthy(healthy)
ctx, cancel := context.WithTimeout(monitor.ctx, time.Second)
err := monitor.doCheck(ctx)
cancel()
if healthy {
require.NoError(t, err)
} else {
require.Error(t, err)
}
monitor.handleCheckResult(err)
}
runCheck(true)
require.True(t, monitor.statusOK)
require.Zero(t, monitor.failedTimes)
require.Equal(t, []string{"normal"}, events)
runCheck(false)
runCheck(false)
require.True(t, monitor.statusOK)
require.Equal(t, uint64(2), monitor.failedTimes)
require.Equal(t, []string{"normal"}, events)
runCheck(true)
require.True(t, monitor.statusOK)
require.Zero(t, monitor.failedTimes)
require.Equal(t, []string{"normal"}, events)
runCheck(false)
require.True(t, monitor.statusOK)
require.Equal(t, uint64(1), monitor.failedTimes)
require.Equal(t, []string{"normal"}, events)
runCheck(false)
require.True(t, monitor.statusOK)
require.Equal(t, uint64(2), monitor.failedTimes)
runCheck(false)
require.False(t, monitor.statusOK)
require.Equal(t, uint64(3), monitor.failedTimes)
require.Equal(t, []string{"normal", "failed"}, events)
runCheck(true)
require.True(t, monitor.statusOK)
require.Zero(t, monitor.failedTimes)
require.Equal(t, []string{"normal", "failed", "normal"}, events)
})
}
}
type workerStatus struct {
failedTimes uint64
statusOK bool
}
type workerInterval struct {
status workerStatus
timer chan time.Time
}
func observeWorkerIntervals(monitor *Monitor) <-chan workerInterval {
intervals := make(chan workerInterval, 1)
monitor.timerFactory = func(time.Duration) (<-chan time.Time, func()) {
timer := make(chan time.Time, 1)
// The worker takes this snapshot after handleCheckResult. Sending it
// publishes the state to the test; no test goroutine reads live fields.
interval := workerInterval{
status: workerStatus{failedTimes: monitor.failedTimes, statusOK: monitor.statusOK},
timer: timer,
}
select {
case intervals <- interval:
case <-monitor.ctx.Done():
}
return timer, func() {}
}
return intervals
}
func awaitWorkerInterval(t *testing.T, intervals <-chan workerInterval, want workerStatus) chan time.Time {
t.Helper()
select {
case interval := <-intervals:
require.Equal(t, want, interval.status)
return interval.timer
case <-time.After(time.Second):
t.Fatal("health worker did not reach the interval barrier")
return nil
}
}
func stopMonitorWorker(t *testing.T, monitor *Monitor) {
t.Helper()
monitor.Stop()
select {
case <-monitor.Done():
case <-time.After(time.Second):
t.Error("health worker did not exit after Stop")
}
}
func TestMonitorWorkerProcessesResults(t *testing.T) {
requestReady := make(chan struct{}, 1)
responses := make(chan int)
requestCanceled := make(chan struct{})
var cancelOnce sync.Once
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case requestReady <- struct{}{}:
case <-r.Context().Done():
return
}
select {
case code := <-responses:
w.WriteHeader(code)
case <-r.Context().Done():
cancelOnce.Do(func() { close(requestCanceled) })
}
}))
t.Cleanup(server.Close)
events := make(chan string, 3)
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{
Type: "http",
Path: "/health",
TimeoutSeconds: 5,
MaxFailed: 3,
},
strings.TrimPrefix(server.URL, "http://"),
func() { events <- "normal" },
func() { events <- "failed" },
)
intervals := observeWorkerIntervals(monitor)
t.Cleanup(func() { stopMonitorWorker(t, monitor) })
awaitRequest := func() {
t.Helper()
select {
case <-requestReady:
case <-time.After(time.Second):
t.Fatal("health check request did not start")
}
}
awaitEvent := func(want string) {
t.Helper()
select {
case got := <-events:
require.Equal(t, want, got)
case <-time.After(time.Second):
t.Fatalf("health check callback %q was not called", want)
}
}
checkResult := func(code int, want workerStatus, event string) {
t.Helper()
awaitRequest()
select {
case responses <- code:
case <-time.After(time.Second):
t.Fatal("health check handler did not accept the response")
}
timer := awaitWorkerInterval(t, intervals, want)
if event != "" {
awaitEvent(event)
}
// The worker has finished this result and cannot emit another
// callback until the test releases the next check.
select {
case got := <-events:
t.Fatalf("unexpected health check callback %q", got)
default:
}
timer <- time.Now()
}
monitor.Start()
checkResult(http.StatusOK, workerStatus{statusOK: true}, "normal")
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 1, statusOK: true}, "")
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 2, statusOK: true}, "")
checkResult(http.StatusOK, workerStatus{statusOK: true}, "")
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 1, statusOK: true}, "")
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 2, statusOK: true}, "")
checkResult(http.StatusServiceUnavailable, workerStatus{failedTimes: 3}, "failed")
checkResult(http.StatusOK, workerStatus{statusOK: true}, "normal")
awaitRequest()
stopMonitorWorker(t, monitor)
select {
case <-requestCanceled:
case <-time.After(time.Second):
t.Fatal("health check request context was not canceled")
}
}
func TestMonitorTCPWorkerProcessesResults(t *testing.T) {
initialAccepted := make(chan struct{}, 1)
initialBackend := newTCPHealthBackend(t, "127.0.0.1:0", initialAccepted)
addr := initialBackend.listener.Addr().String()
recoveryAccepted := make(chan struct{}, 1)
normalCallbacks := make(chan workerStatus, 2)
failedCallbacks := make(chan workerStatus, 2)
var monitor *Monitor
monitor = NewMonitor(
context.Background(),
v1.HealthCheckConfig{
Type: "tcp",
TimeoutSeconds: 1,
MaxFailed: 3,
},
addr,
func() {
normalCallbacks <- workerStatus{failedTimes: monitor.failedTimes, statusOK: monitor.statusOK}
},
func() {
failedCallbacks <- workerStatus{failedTimes: monitor.failedTimes, statusOK: monitor.statusOK}
},
)
intervals := observeWorkerIntervals(monitor)
recoveryBackend := (*tcpHealthBackend)(nil)
t.Cleanup(func() {
stopMonitorWorker(t, monitor)
initialBackend.Close()
if recoveryBackend != nil {
recoveryBackend.Close()
}
})
awaitStatus := func(ch <-chan workerStatus, want workerStatus, message string) {
t.Helper()
select {
case got := <-ch:
require.Equal(t, want, got)
case <-time.After(time.Second):
t.Fatal(message)
}
}
monitor.Start()
select {
case <-initialAccepted:
case <-time.After(time.Second):
t.Fatal("TCP health check did not reach the initial backend")
}
awaitStatus(normalCallbacks, workerStatus{failedTimes: 0, statusOK: true}, "TCP worker did not report the initial success")
initialTimer := awaitWorkerInterval(t, intervals, workerStatus{statusOK: true})
initialBackend.Close()
initialTimer <- time.Now()
firstFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 1, statusOK: true})
firstFailureTimer <- time.Now()
secondFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 2, statusOK: true})
secondFailureTimer <- time.Now()
awaitStatus(failedCallbacks, workerStatus{failedTimes: 3, statusOK: false}, "TCP worker did not report the third failed health check")
thirdFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 3})
recoveryBackend = newTCPHealthBackend(t, addr, recoveryAccepted)
thirdFailureTimer <- time.Now()
select {
case <-recoveryAccepted:
case <-time.After(time.Second):
t.Fatal("TCP health check did not reach the recovery backend")
}
awaitStatus(normalCallbacks, workerStatus{failedTimes: 0, statusOK: true}, "TCP worker did not report recovery")
recoveryTimer := awaitWorkerInterval(t, intervals, workerStatus{statusOK: true})
recoveryBackend.Close()
recoveryTimer <- time.Now()
firstRecoveryFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 1, statusOK: true})
firstRecoveryFailureTimer <- time.Now()
secondRecoveryFailureTimer := awaitWorkerInterval(t, intervals, workerStatus{failedTimes: 2, statusOK: true})
secondRecoveryFailureTimer <- time.Now()
awaitStatus(failedCallbacks, workerStatus{failedTimes: 3, statusOK: false}, "TCP worker did not report the third failed health check after recovery")
stopMonitorWorker(t, monitor)
}
func TestMonitorMaxFailedOne(t *testing.T) {
var events []string
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{Type: "tcp", MaxFailed: 1},
"",
func() { events = append(events, "normal") },
func() { events = append(events, "failed") },
)
t.Cleanup(monitor.Stop)
checkErr := errors.New("health check failed")
monitor.handleCheckResult(nil)
monitor.handleCheckResult(checkErr)
require.Equal(t, []string{"normal", "failed"}, events)
require.False(t, monitor.statusOK)
require.Equal(t, uint64(1), monitor.failedTimes)
monitor.handleCheckResult(checkErr)
require.Equal(t, []string{"normal", "failed"}, events)
monitor.handleCheckResult(nil)
monitor.handleCheckResult(checkErr)
require.Equal(t, []string{"normal", "failed", "normal", "failed"}, events)
}
func TestMonitorStopCancelsWork(t *testing.T) {
t.Run("in-flight check", func(t *testing.T) {
requestStarted := make(chan struct{})
requestCanceled := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
close(requestStarted)
<-r.Context().Done()
close(requestCanceled)
}))
t.Cleanup(server.Close)
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{Type: "http", Path: "/health"},
strings.TrimPrefix(server.URL, "http://"),
func() {},
func() {},
)
t.Cleanup(func() { stopMonitorWorker(t, monitor) })
monitor.Start()
select {
case <-requestStarted:
case <-time.After(time.Second):
t.Fatal("health check request did not start")
}
stopMonitorWorker(t, monitor)
select {
case <-requestCanceled:
case <-time.After(time.Second):
t.Fatal("health check request context was not canceled")
}
})
t.Run("interval wait", func(t *testing.T) {
monitor := NewMonitor(context.Background(), v1.HealthCheckConfig{Type: "tcp"}, "", nil, nil)
intervals := observeWorkerIntervals(monitor)
t.Cleanup(func() { stopMonitorWorker(t, monitor) })
monitor.Start()
// A real worker has completed its first check and installed a timer
// that will never fire. Stop must release the wait and exit the loop.
awaitWorkerInterval(t, intervals, workerStatus{})
stopMonitorWorker(t, monitor)
})
}
type gatedErrContext struct {
context.Context
errCalled chan struct{}
releaseErr chan struct{}
}
func (ctx *gatedErrContext) Err() error {
select {
case ctx.errCalled <- struct{}{}:
default:
}
<-ctx.releaseErr
return ctx.Context.Err()
}
func TestMonitorTimerCancellationAfterTimerFires(t *testing.T) {
baseCtx, cancel := context.WithCancel(context.Background())
ctx := &gatedErrContext{
Context: baseCtx,
errCalled: make(chan struct{}, 1),
releaseErr: make(chan struct{}),
}
monitor := NewMonitor(
context.Background(),
v1.HealthCheckConfig{Type: "tcp"},
"",
nil,
nil,
)
monitor.ctx = ctx
monitor.cancel = cancel
timerReady := make(chan time.Time, 1)
timerReady <- time.Now()
var timerStopped atomic.Bool
monitor.timerFactory = func(time.Duration) (<-chan time.Time, func()) {
return timerReady, func() { timerStopped.Store(true) }
}
waitResult := make(chan bool, 1)
go func() {
waitResult <- monitor.waitForNextCheck()
}()
// ctx.Done is not ready when select runs, so receiving timerReady is the
// only possible branch. Err signals after that receive and blocks until the
// test cancels the context, deterministically exercising the cancellation
// re-check in the timer branch.
select {
case <-ctx.errCalled:
case <-time.After(time.Second):
t.Fatal("timer branch did not re-check the monitor context")
}
cancel()
close(ctx.releaseErr)
select {
case shouldContinue := <-waitResult:
require.False(t, shouldContinue)
case <-time.After(time.Second):
t.Fatal("timer wait did not observe context cancellation")
}
require.True(t, timerStopped.Load())
}
+27 -9
View File
@@ -61,11 +61,12 @@ func NewProxy(
encryptionKey []byte,
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
udpPacketCodec string,
) (pxy Proxy) {
var limiter *rate.Limiter
limitBytes := pxyConf.GetBaseConfig().Transport.BandwidthLimit.Bytes()
if limitBytes > 0 && pxyConf.GetBaseConfig().Transport.BandwidthLimitMode == types.BandwidthLimitModeClient {
limiter = rate.NewLimiter(rate.Limit(float64(limitBytes)), int(limitBytes))
limiter = limit.NewBandwidthLimiter(limitBytes)
}
baseProxy := BaseProxy{
@@ -77,6 +78,7 @@ func NewProxy(
vnetController: vnetController,
xl: xlog.FromContextSafe(ctx),
ctx: ctx,
udpPacketCodec: udpPacketCodec,
}
factory := proxyFactoryRegistry[reflect.TypeOf(pxyConf)]
@@ -98,9 +100,10 @@ type BaseProxy struct {
proxyPlugin plugin.Plugin
inWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) /* continue */ bool
mu sync.RWMutex
xl *xlog.Logger
ctx context.Context
mu sync.RWMutex
xl *xlog.Logger
ctx context.Context
udpPacketCodec string
}
func (pxy *BaseProxy) Run() error {
@@ -171,6 +174,26 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
xl.Tracef("handle tcp work connection, useEncryption: %t, useCompression: %t",
baseCfg.Transport.UseEncryption, baseCfg.Transport.UseCompression)
var srcAddr, dstAddr *net.TCPAddr
if m.SrcAddr != "" && m.SrcPort != 0 {
if m.DstAddr == "" {
m.DstAddr = "127.0.0.1"
}
var err error
srcAddr, err = net.ResolveTCPAddr("tcp", net.JoinHostPort(m.SrcAddr, strconv.Itoa(int(m.SrcPort))))
if err != nil {
xl.Warnf("resolve source address [%s] error: %v", m.SrcAddr, err)
_ = workConn.Close()
return
}
dstAddr, err = net.ResolveTCPAddr("tcp", net.JoinHostPort(m.DstAddr, strconv.Itoa(int(m.DstPort))))
if err != nil {
xl.Warnf("resolve destination address [%s] error: %v", m.DstAddr, err)
_ = workConn.Close()
return
}
}
remote, recycleFn, err := pxy.wrapWorkConn(workConn, encKey)
if err != nil {
xl.Errorf("wrap work connection: %v", err)
@@ -180,11 +203,6 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor
// check if we need to send proxy protocol info
var connInfo plugin.ConnectionInfo
if m.SrcAddr != "" && m.SrcPort != 0 {
if m.DstAddr == "" {
m.DstAddr = "127.0.0.1"
}
srcAddr, _ := net.ResolveTCPAddr("tcp", net.JoinHostPort(m.SrcAddr, strconv.Itoa(int(m.SrcPort))))
dstAddr, _ := net.ResolveTCPAddr("tcp", net.JoinHostPort(m.DstAddr, strconv.Itoa(int(m.DstPort))))
connInfo.SrcAddr = srcAddr
connInfo.DstAddr = dstAddr
}
+5 -2
View File
@@ -43,7 +43,8 @@ type Manager struct {
encryptionKey []byte
clientCfg *v1.ClientCommonConfig
ctx context.Context
ctx context.Context
udpPacketCodec string
}
func NewManager(
@@ -52,6 +53,7 @@ func NewManager(
encryptionKey []byte,
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
udpPacketCodec string,
) *Manager {
return &Manager{
proxies: make(map[string]*Wrapper),
@@ -61,6 +63,7 @@ func NewManager(
encryptionKey: encryptionKey,
clientCfg: clientCfg,
ctx: ctx,
udpPacketCodec: udpPacketCodec,
}
}
@@ -166,7 +169,7 @@ func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
for _, cfg := range proxyCfgs {
name := cfg.GetBaseConfig().Name
if _, ok := pm.proxies[name]; !ok {
pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController)
pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.encryptionKey, pm.HandleEvent, pm.msgTransporter, pm.vnetController, pm.udpPacketCodec)
if pm.inWorkConnCallback != nil {
pxy.SetInWorkConnCallback(pm.inWorkConnCallback)
}
+47
View File
@@ -0,0 +1,47 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !frps
package proxy
import (
"io"
"net"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/util/xlog"
)
func TestHandleTCPWorkConnectionRejectsInvalidAddress(t *testing.T) {
workConn, peerConn := net.Pipe()
defer peerConn.Close()
pxy := &BaseProxy{
baseCfg: &v1.ProxyBaseConfig{},
xl: xlog.New(),
}
pxy.HandleTCPWorkConnection(workConn, &msg.StartWorkConn{
SrcAddr: "[",
SrcPort: 1,
}, nil)
buffer := make([]byte, 1)
_, err := peerConn.Read(buffer)
require.ErrorIs(t, err, io.EOF)
}
+4 -2
View File
@@ -99,6 +99,7 @@ func NewWrapper(
eventHandler event.Handler,
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
udpPacketCodec string,
) *Wrapper {
baseInfo := cfg.GetBaseConfig()
xl := xlog.FromContextSafe(ctx).Spawn().AppendPrefix(baseInfo.Name)
@@ -127,7 +128,7 @@ func NewWrapper(
xl.Tracef("enable health check monitor")
}
pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController)
pw.pxy = NewProxy(pw.ctx, pw.Cfg, clientCfg, encryptionKey, pw.msgTransporter, pw.vnetController, udpPacketCodec)
return pw
}
@@ -266,8 +267,9 @@ func (pw *Wrapper) InWorkConn(workConn net.Conn, m *msg.StartWorkConn) {
xl := pw.xl
pw.mu.RLock()
pxy := pw.pxy
running := pw.Phase == ProxyPhaseRunning
pw.mu.RUnlock()
if pxy != nil && pw.Phase == ProxyPhaseRunning {
if pxy != nil && running {
xl.Debugf("start a new work connection, localAddr: %s remoteAddr: %s", workConn.LocalAddr().String(), workConn.RemoteAddr().String())
go pxy.InWorkConn(workConn, m)
} else {
+76
View File
@@ -0,0 +1,76 @@
//go:build !frps
package proxy
import (
"io"
"net"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/util/xlog"
)
type closingWorkProxy struct {
Proxy
accepted atomic.Int64
}
func (p *closingWorkProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
p.accepted.Add(1)
_ = conn.Close()
}
func assertWrapperWorkConnClosed(t *testing.T, pw *Wrapper) {
t.Helper()
conn, peer := net.Pipe()
defer conn.Close()
defer peer.Close()
require.NoError(t, peer.SetReadDeadline(time.Now().Add(time.Second)))
pw.InWorkConn(conn, &msg.StartWorkConn{})
_, err := peer.Read(make([]byte, 1))
require.ErrorIs(t, err, io.EOF)
}
func TestWrapperInWorkConnPhaseSnapshot(t *testing.T) {
pxy := &closingWorkProxy{}
pw := &Wrapper{pxy: pxy, xl: xlog.New()}
for _, phase := range []string{ProxyPhaseNew, ProxyPhaseWaitStart, ProxyPhaseStartErr, ProxyPhaseCheckFailed, ProxyPhaseClosed} {
pw.Phase = phase
assertWrapperWorkConnClosed(t, pw)
}
require.Zero(t, pxy.accepted.Load(), "inactive proxy received a work connection")
pw.Phase = ProxyPhaseRunning
pw.pxy = nil
assertWrapperWorkConnClosed(t, pw)
pw.pxy = pxy
assertWrapperWorkConnClosed(t, pw)
require.Equal(t, int64(1), pxy.accepted.Load())
start, done := make(chan struct{}), make(chan struct{})
go func() {
<-start
for range 20000 {
pw.mu.Lock()
pw.Phase = ProxyPhaseRunning
pw.mu.Unlock()
pw.mu.Lock()
pw.Phase = ProxyPhaseClosed
pw.mu.Unlock()
}
close(done)
}()
close(start)
for range 10000 {
assertWrapperWorkConnClosed(t, pw)
}
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("phase writer did not finish")
}
}
+7 -1
View File
@@ -87,7 +87,13 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol))
payloadRW, err := msg.NewUDPPacketReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol, pxy.udpPacketCodec)
if err != nil {
xl.Errorf("create SUDP packet read writer: %v", err)
_ = workConn.Close()
return
}
payloadConn := msg.NewConn(workConn, payloadRW)
readCh := make(chan *msg.UDPPacket, 1024)
sendCh := make(chan msg.Message, 1024)
isClose := false
+10 -3
View File
@@ -97,10 +97,17 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
return
}
pxy.mu.Lock()
pxy.workConn = netpkg.WrapReadWriteCloserToConn(remote, conn)
workConn := netpkg.WrapReadWriteCloserToConn(remote, conn)
// Plain UDP payload follows the configured wire protocol for message framing.
payloadRW := msg.NewReadWriter(pxy.workConn, pxy.clientCfg.Transport.WireProtocol)
payloadRW, err := msg.NewUDPPacketReadWriter(workConn, pxy.clientCfg.Transport.WireProtocol, pxy.udpPacketCodec)
if err != nil {
xl.Errorf("create UDP packet read writer: %v", err)
workConn.Close()
return
}
pxy.mu.Lock()
pxy.workConn = workConn
pxy.readCh = make(chan *msg.UDPPacket, 1024)
pxy.sendCh = make(chan msg.Message, 1024)
pxy.closed = false
+1 -1
View File
@@ -22,7 +22,7 @@ import (
"reflect"
"time"
fmux "github.com/hashicorp/yamux"
fmux "github.com/fatedier/yamux"
"github.com/quic-go/quic-go"
v1 "github.com/fatedier/frp/pkg/config/v1"
+60 -12
View File
@@ -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 {
+186
View File
@@ -0,0 +1,186 @@
package client
import (
"context"
"io"
"net"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/msg"
)
func newLifecycleTestService(t *testing.T, creator func(context.Context, *v1.ClientCommonConfig) Connector) (*Service, string) {
t.Helper()
svr, err := NewService(ServiceOptions{
Common: &v1.ClientCommonConfig{
LoginFailExit: new(false),
WebServer: v1.WebServerConfig{Addr: "127.0.0.1", Port: getFreeTCPPort(t)},
},
ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
ConnectorCreator: creator,
})
require.NoError(t, err)
_, err = validation.NewConfigValidator(nil).ValidateClientCommonConfig(svr.common)
require.NoError(t, err)
address := svr.webServer.Address()
assertLifecycleAdminPort(t, address, false)
return svr, address
}
func assertLifecycleAdminPort(t *testing.T, address string, released bool) {
t.Helper()
ln, err := net.Listen("tcp", address)
if ln != nil {
_ = ln.Close()
}
if released {
require.NoError(t, err, "admin listener was not released")
} else {
require.Error(t, err, "admin listener was not bound")
}
}
func waitLifecycleSignal(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("service lifecycle operation did not finish")
}
}
func TestServiceRunAfterEarlyShutdown(t *testing.T) {
for _, mode := range []string{"close before Run", "parent already cancelled"} {
t.Run(mode, func(t *testing.T) {
var attempts atomic.Int64
svr, address := newLifecycleTestService(t, func(context.Context, *v1.ClientCommonConfig) Connector {
attempts.Add(1)
return &failingConnector{err: net.ErrClosed}
})
// Detect startup's global DNS side effect without making DNS requests.
resolver := net.DefaultResolver
t.Cleanup(func() { net.DefaultResolver = resolver })
svr.common.DNSServer = "127.0.0.1:53"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if mode == "close before Run" {
svr.Close()
assertLifecycleAdminPort(t, address, true)
} else {
cancel()
}
done := make(chan struct{})
var runErr error
go func() {
runErr = svr.Run(ctx)
close(done)
}()
waitLifecycleSignal(t, done)
require.ErrorIs(t, runErr, context.Canceled)
require.Zero(t, attempts.Load(), "closed service attempted login")
require.Same(t, resolver, net.DefaultResolver, "closed service changed DNS")
assertLifecycleAdminPort(t, address, true)
})
}
}
func TestServiceStartupShutdownLeavesCleanupToRun(t *testing.T) {
for _, mode := range []string{"Close", "parent cancellation"} {
t.Run(mode, func(t *testing.T) {
entered, resume := make(chan struct{}), make(chan struct{})
svr, address := newLifecycleTestService(t, func(ctx context.Context, _ *v1.ClientCommonConfig) Connector {
close(entered)
<-resume
return &failingConnector{err: ctx.Err()}
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
defer close(resume)
runDone := make(chan struct{})
go func() { _ = svr.Run(ctx); close(runDone) }()
waitLifecycleSignal(t, entered)
closeDone := make(chan struct{})
go func() {
if mode == "Close" {
svr.Close()
} else {
cancel()
}
close(closeDone)
}()
waitLifecycleSignal(t, closeDone)
select {
case <-runDone:
t.Fatal("Run exited while startup still owned its resources")
default:
}
assertLifecycleAdminPort(t, address, false)
resume <- struct{}{}
waitLifecycleSignal(t, runDone)
assertLifecycleAdminPort(t, address, true)
require.ErrorIs(t, svr.ctx.Err(), context.Canceled)
})
}
}
func TestServiceShutdownAfterLogin(t *testing.T) {
for _, mode := range []string{"Close", "parent cancellation"} {
t.Run(mode, func(t *testing.T) {
clientConn, serverConn := net.Pipe()
defer clientConn.Close()
defer serverConn.Close()
connector := &testConnector{conn: clientConn}
svr, address := newLifecycleTestService(t, func(context.Context, *v1.ClientCommonConfig) Connector {
return connector
})
defer svr.Close()
peerDone := make(chan error, 1)
go func() {
rw := msg.NewV1ReadWriter(serverConn)
var login msg.Login
if err := rw.ReadMsgInto(&login); err != nil {
peerDone <- err
return
}
if err := rw.WriteMsg(&msg.LoginResp{RunID: "lifecycle-test"}); err != nil {
peerDone <- err
return
}
_, err := io.Copy(io.Discard, serverConn)
peerDone <- err
}()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runDone := make(chan struct{})
var runErr error
go func() { runErr = svr.Run(ctx); close(runDone) }()
require.Eventually(t, func() bool {
svr.ctlMu.RLock()
defer svr.ctlMu.RUnlock()
return svr.ctl != nil
}, 3*time.Second, time.Millisecond, "login did not establish control")
if mode == "Close" {
svr.Close()
} else {
cancel()
}
waitLifecycleSignal(t, runDone)
require.NoError(t, runErr)
require.True(t, connector.closed.Load())
select {
case err := <-peerDone:
require.NoError(t, err)
case <-time.After(3 * time.Second):
t.Fatal("control connection was not released")
}
assertLifecycleAdminPort(t, address, true)
})
}
}
+95
View File
@@ -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)
}
}
+1 -3
View File
@@ -9,8 +9,6 @@ import (
"strings"
"testing"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
@@ -49,7 +47,7 @@ func TestRunStopsStartedComponentsOnInitialLoginFailure(t *testing.T) {
svr, err := NewService(ServiceOptions{
Common: &v1.ClientCommonConfig{
LoginFailExit: lo.ToPtr(true),
LoginFailExit: new(true),
WebServer: v1.WebServerConfig{
Addr: "127.0.0.1",
Port: port,
+7 -1
View File
@@ -113,7 +113,13 @@ func (sv *SUDPVisitor) dispatcher() {
func (sv *SUDPVisitor) worker(workConn net.Conn, firstPacket *msg.UDPPacket) {
xl := xlog.FromContextSafe(sv.ctx)
xl.Debugf("starting sudp proxy worker")
payloadConn := msg.NewConn(workConn, msg.NewReadWriter(workConn, sv.clientCfg.Transport.WireProtocol))
payloadRW, err := msg.NewUDPPacketReadWriter(workConn, sv.clientCfg.Transport.WireProtocol, udpPacketCodecFromHelper(sv.helper))
if err != nil {
xl.Errorf("create SUDP packet read writer: %v", err)
_ = workConn.Close()
return
}
payloadConn := msg.NewConn(workConn, payloadRW)
wg := &sync.WaitGroup{}
wg.Add(2)
+11
View File
@@ -50,6 +50,17 @@ type Helper interface {
RunID() string
}
type udpPacketCodecProvider interface {
UDPPacketCodec() string
}
func udpPacketCodecFromHelper(helper Helper) string {
if provider, ok := helper.(udpPacketCodecProvider); ok {
return provider.UDPPacketCodec()
}
return ""
}
// Visitor is used for forward traffics from local port tot remote service.
type Visitor interface {
Run() error
+11
View File
@@ -53,7 +53,12 @@ func NewManager(
connectServer func() (*msg.Conn, error),
msgTransporter transport.MessageTransporter,
vnetController *vnet.Controller,
udpPacketCodecs ...string,
) *Manager {
udpPacketCodec := ""
if len(udpPacketCodecs) > 0 {
udpPacketCodec = udpPacketCodecs[0]
}
m := &Manager{
clientCfg: clientCfg,
cfgs: make(map[string]v1.VisitorConfigurer),
@@ -68,6 +73,7 @@ func NewManager(
vnetController: vnetController,
transferConnFn: m.TransferConn,
runID: runID,
udpPacketCodec: udpPacketCodec,
}
return m
}
@@ -205,6 +211,7 @@ type visitorHelperImpl struct {
vnetController *vnet.Controller
transferConnFn func(name string, conn net.Conn) error
runID string
udpPacketCodec string
}
func (v *visitorHelperImpl) ConnectServer() (*msg.Conn, error) {
@@ -226,3 +233,7 @@ func (v *visitorHelperImpl) VNetController() *vnet.Controller {
func (v *visitorHelperImpl) RunID() string {
return v.runID
}
func (v *visitorHelperImpl) UDPPacketCodec() string {
return v.udpPacketCodec
}
+1 -1
View File
@@ -25,7 +25,7 @@ import (
"time"
libio "github.com/fatedier/golib/io"
fmux "github.com/hashicorp/yamux"
fmux "github.com/fatedier/yamux"
quic "github.com/quic-go/quic-go"
"golang.org/x/time/rate"
-7
View File
@@ -33,7 +33,6 @@ import (
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/policy/featuregate"
"github.com/fatedier/frp/pkg/policy/security"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/version"
@@ -131,12 +130,6 @@ func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) erro
"please use yaml/json/toml format instead!\n")
}
if len(result.Common.FeatureGates) > 0 {
if err := featuregate.SetFromMap(result.Common.FeatureGates); err != nil {
return err
}
}
return runClientWithAggregator(result, unsafeFeatures, cfgFilePath)
}
+13 -6
View File
@@ -29,6 +29,18 @@ func init() {
rootCmd.AddCommand(verifyCmd)
}
func verifyClientConfig(
configFile string,
strict bool,
unsafeFeatures *security.UnsafeFeatures,
) (validation.Warning, error) {
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(configFile, strict)
if err != nil {
return nil, err
}
return validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures)
}
var verifyCmd = &cobra.Command{
Use: "verify",
Short: "Verify that the configures is valid",
@@ -38,13 +50,8 @@ var verifyCmd = &cobra.Command{
return nil
}
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures)
warning, err := verifyClientConfig(cfgFile, strictConfigMode, unsafeFeatures)
if warning != nil {
fmt.Printf("WARNING: %v\n", warning)
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package sub
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/policy/security"
)
func TestVerifyClientConfigFeatureGates(t *testing.T) {
tests := []struct {
name string
content string
wantErr string
}{
{
name: "VirtualNet enabled",
content: `featureGates = { VirtualNet = true }
virtualNet.address = "100.86.0.4/24"
`,
},
{
name: "VirtualNet disabled",
content: `featureGates = { VirtualNet = false }
virtualNet.address = "100.86.0.4/24"
`,
wantErr: "VirtualNet feature is not enabled",
},
{
name: "unknown feature gate",
content: `featureGates = { UnknownFeature = true }`,
wantErr: "unrecognized feature gate: UnknownFeature",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
configFile := filepath.Join(t.TempDir(), "frpc.toml")
require.NoError(t, os.WriteFile(configFile, []byte(tc.content), 0o600))
warning, err := verifyClientConfig(configFile, true, security.NewUnsafeFeatures(nil))
require.NoError(t, warning)
if tc.wantErr == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tc.wantErr)
})
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+15 -24
View File
@@ -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
+32 -42
View File
@@ -1,6 +1,6 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
@@ -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=
+6 -8
View File
@@ -17,8 +17,6 @@ package legacy
import (
"strings"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/config/types"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
@@ -49,7 +47,7 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
out.Transport.ConnectServerLocalIP = conf.ConnectServerLocalIP
out.Transport.ProxyURL = conf.HTTPProxy
out.Transport.PoolCount = conf.PoolCount
out.Transport.TCPMux = lo.ToPtr(conf.TCPMux)
out.Transport.TCPMux = new(conf.TCPMux)
out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
out.Transport.Protocol = conf.Protocol
out.Transport.HeartbeatInterval = conf.HeartbeatInterval
@@ -57,8 +55,8 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
out.Transport.QUIC.KeepalivePeriod = conf.QUICKeepalivePeriod
out.Transport.QUIC.MaxIdleTimeout = conf.QUICMaxIdleTimeout
out.Transport.QUIC.MaxIncomingStreams = conf.QUICMaxIncomingStreams
out.Transport.TLS.Enable = lo.ToPtr(conf.TLSEnable)
out.Transport.TLS.DisableCustomTLSFirstByte = lo.ToPtr(conf.DisableCustomTLSFirstByte)
out.Transport.TLS.Enable = new(conf.TLSEnable)
out.Transport.TLS.DisableCustomTLSFirstByte = new(conf.DisableCustomTLSFirstByte)
out.Transport.TLS.CertFile = conf.TLSCertFile
out.Transport.TLS.KeyFile = conf.TLSKeyFile
out.Transport.TLS.TrustedCaFile = conf.TLSTrustedCaFile
@@ -77,7 +75,7 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
out.WebServer.PprofEnable = conf.PprofEnable
out.DNSServer = conf.DNSServer
out.LoginFailExit = lo.ToPtr(conf.LoginFailExit)
out.LoginFailExit = new(conf.LoginFailExit)
out.Start = conf.Start
out.UDPPacketSize = conf.UDPPacketSize
out.Metadatas = conf.Metas
@@ -134,14 +132,14 @@ func Convert_ServerCommonConf_To_v1(conf *ServerCommonConf) *v1.ServerConfig {
out.Log.MaxDays = conf.LogMaxDays
out.Log.DisablePrintColor = conf.DisableLogColor
out.DetailedErrorsToClient = lo.ToPtr(conf.DetailedErrorsToClient)
out.DetailedErrorsToClient = new(conf.DetailedErrorsToClient)
out.SubDomainHost = conf.SubDomainHost
out.Custom404Page = conf.Custom404Page
out.UserConnTimeout = conf.UserConnTimeout
out.UDPPacketSize = conf.UDPPacketSize
out.NatHoleAnalysisDataReserveHours = conf.NatHoleAnalysisDataReserveHours
out.Transport.TCPMux = lo.ToPtr(conf.TCPMux)
out.Transport.TCPMux = new(conf.TCPMux)
out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
out.Transport.TCPKeepAlive = conf.TCPKeepAlive
out.Transport.MaxPoolCount = conf.MaxPoolCount
+29
View File
@@ -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 {
+107
View File
@@ -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)
+4 -4
View File
@@ -85,7 +85,7 @@ type ClientCommonConfig struct {
func (c *ClientCommonConfig) Complete() error {
c.ServerAddr = util.EmptyOr(c.ServerAddr, "0.0.0.0")
c.ServerPort = util.EmptyOr(c.ServerPort, 7000)
c.LoginFailExit = util.EmptyOr(c.LoginFailExit, lo.ToPtr(true))
c.LoginFailExit = util.EmptyOr(c.LoginFailExit, new(true))
c.NatHoleSTUNServer = util.EmptyOr(c.NatHoleSTUNServer, "stun.easyvoip.com:3478")
if err := c.Auth.Complete(); err != nil {
@@ -151,7 +151,7 @@ func (c *ClientTransportConfig) Complete() {
c.DialServerKeepAlive = util.EmptyOr(c.DialServerKeepAlive, 7200)
c.ProxyURL = util.EmptyOr(c.ProxyURL, os.Getenv("http_proxy"))
c.PoolCount = util.EmptyOr(c.PoolCount, 1)
c.TCPMux = util.EmptyOr(c.TCPMux, lo.ToPtr(true))
c.TCPMux = util.EmptyOr(c.TCPMux, new(true))
c.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30)
if lo.FromPtr(c.TCPMux) {
// If TCPMux is enabled, heartbeat of application layer is unnecessary because we can rely on heartbeat in tcpmux.
@@ -180,8 +180,8 @@ type TLSClientConfig struct {
}
func (c *TLSClientConfig) Complete() {
c.Enable = util.EmptyOr(c.Enable, lo.ToPtr(true))
c.DisableCustomTLSFirstByte = util.EmptyOr(c.DisableCustomTLSFirstByte, lo.ToPtr(true))
c.Enable = util.EmptyOr(c.Enable, new(true))
c.DisableCustomTLSFirstByte = util.EmptyOr(c.DisableCustomTLSFirstByte, new(true))
}
type AuthClientConfig struct {
+2 -4
View File
@@ -17,8 +17,6 @@ package v1
import (
"reflect"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/util/jsonx"
"github.com/fatedier/frp/pkg/util/util"
)
@@ -125,7 +123,7 @@ type HTTPS2HTTPPluginOptions struct {
}
func (o *HTTPS2HTTPPluginOptions) Complete() {
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, new(true))
}
func (o *HTTPS2HTTPPluginOptions) Clone() ClientPluginOptions {
@@ -149,7 +147,7 @@ type HTTPS2HTTPSPluginOptions struct {
}
func (o *HTTPS2HTTPSPluginOptions) Complete() {
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, lo.ToPtr(true))
o.EnableHTTP2 = util.EmptyOr(o.EnableHTTP2, new(true))
}
func (o *HTTPS2HTTPSPluginOptions) Clone() ClientPluginOptions {
+3 -3
View File
@@ -118,7 +118,7 @@ func (c *ServerConfig) Complete() error {
}
c.VhostHTTPTimeout = util.EmptyOr(c.VhostHTTPTimeout, 60)
c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, lo.ToPtr(true))
c.DetailedErrorsToClient = util.EmptyOr(c.DetailedErrorsToClient, new(true))
c.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10)
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
c.NatHoleAnalysisDataReserveHours = util.EmptyOr(c.NatHoleAnalysisDataReserveHours, 7*24)
@@ -167,7 +167,7 @@ type ServerTransportConfig struct {
// If negative, keep-alive probes are disabled.
TCPKeepAlive int64 `json:"tcpKeepalive,omitempty"`
// MaxPoolCount specifies the maximum pool size for each proxy. By default,
// this value is 5.
// this value is 5. Negative values are invalid.
MaxPoolCount int64 `json:"maxPoolCount,omitempty"`
// HeartBeatTimeout specifies the maximum time to wait for a heartbeat
// before terminating the connection. It is not recommended to change this
@@ -180,7 +180,7 @@ type ServerTransportConfig struct {
}
func (c *ServerTransportConfig) Complete() {
c.TCPMux = util.EmptyOr(c.TCPMux, lo.ToPtr(true))
c.TCPMux = util.EmptyOr(c.TCPMux, new(true))
c.TCPMuxKeepaliveInterval = util.EmptyOr(c.TCPMuxKeepaliveInterval, 30)
c.TCPKeepAlive = util.EmptyOr(c.TCPKeepAlive, 7200)
c.MaxPoolCount = util.EmptyOr(c.MaxPoolCount, 5)
+38 -1
View File
@@ -51,14 +51,51 @@ func (v *ConfigValidator) ValidateClientCommonConfig(c *v1.ClientCommonConfig) (
}
func validateFeatureGates(c *v1.ClientCommonConfig) (Warning, error) {
gates := featuregate.NewFeatureGate()
if err := gates.SetFromMap(c.FeatureGates); err != nil {
return nil, err
}
if c.VirtualNet.Address != "" {
if !featuregate.Enabled(featuregate.VirtualNet) {
if !gates.Enabled(featuregate.VirtualNet) {
return nil, fmt.Errorf("VirtualNet feature is not enabled; enable it by setting the appropriate feature gate flag")
}
}
return nil, nil
}
// ClientConfigRequirements describes runtime capabilities needed by a client configuration.
type ClientConfigRequirements struct {
VirtualNet bool
}
// GetClientConfigRequirements returns the runtime capabilities needed by a client configuration.
func GetClientConfigRequirements(
common *v1.ClientCommonConfig,
proxyCfgs []v1.ProxyConfigurer,
visitorCfgs []v1.VisitorConfigurer,
) ClientConfigRequirements {
requirements := ClientConfigRequirements{}
if common != nil && common.VirtualNet.Address != "" {
requirements.VirtualNet = true
}
for _, cfg := range proxyCfgs {
if cfg.GetBaseConfig().Plugin.Type == v1.PluginVirtualNet {
requirements.VirtualNet = true
break
}
}
if !requirements.VirtualNet {
for _, cfg := range visitorCfgs {
if cfg.GetBaseConfig().Plugin.Type == v1.VisitorPluginVirtualNet {
requirements.VirtualNet = true
break
}
}
}
return requirements
}
func (v *ConfigValidator) validateAuthConfig(c *v1.AuthClientConfig) (Warning, error) {
var errs error
if !slices.Contains(SupportedAuthMethods, c.Method) {
+140
View File
@@ -0,0 +1,140 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package validation
import (
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/policy/featuregate"
"github.com/fatedier/frp/pkg/policy/security"
)
func validateClientFeatureGates(t *testing.T, gates map[string]bool, virtualNetAddress string) error {
t.Helper()
cfg := &v1.ClientCommonConfig{
FeatureGates: gates,
VirtualNet: v1.VirtualNetConfig{
Address: virtualNetAddress,
},
}
require.NoError(t, cfg.Complete())
_, err := NewConfigValidator(security.NewUnsafeFeatures(nil)).ValidateClientCommonConfig(cfg)
return err
}
func TestValidateClientFeatureGates(t *testing.T) {
tests := []struct {
name string
featureGates map[string]bool
virtualNetAddress string
wantErr string
}{
{
name: "VirtualNet enabled",
featureGates: map[string]bool{"VirtualNet": true},
virtualNetAddress: "100.86.0.4/24",
},
{
name: "VirtualNet explicitly disabled",
featureGates: map[string]bool{"VirtualNet": false},
virtualNetAddress: "100.86.0.4/24",
wantErr: "VirtualNet feature is not enabled",
},
{
name: "VirtualNet disabled by default",
virtualNetAddress: "100.86.0.4/24",
wantErr: "VirtualNet feature is not enabled",
},
{
name: "unknown feature gate",
featureGates: map[string]bool{"UnknownFeature": true},
wantErr: "unrecognized feature gate: UnknownFeature",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateClientFeatureGates(t, tc.featureGates, tc.virtualNetAddress)
if tc.wantErr == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tc.wantErr)
})
}
}
func TestGetClientConfigRequirements(t *testing.T) {
virtualNetProxy := &v1.STCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
ProxyBackend: v1.ProxyBackend{
Plugin: v1.TypedClientPluginOptions{Type: v1.PluginVirtualNet},
},
},
}
virtualNetVisitor := &v1.STCPVisitorConfig{
VisitorBaseConfig: v1.VisitorBaseConfig{
Plugin: v1.TypedVisitorPluginOptions{Type: v1.VisitorPluginVirtualNet},
},
}
tests := []struct {
name string
common *v1.ClientCommonConfig
proxies []v1.ProxyConfigurer
visitors []v1.VisitorConfigurer
wantVNet bool
}{
{name: "no requirements"},
{
name: "common VirtualNet address",
common: &v1.ClientCommonConfig{VirtualNet: v1.VirtualNetConfig{Address: "100.86.0.4/24"}},
wantVNet: true,
},
{name: "VirtualNet proxy", proxies: []v1.ProxyConfigurer{virtualNetProxy}, wantVNet: true},
{name: "VirtualNet visitor", visitors: []v1.VisitorConfigurer{virtualNetVisitor}, wantVNet: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := GetClientConfigRequirements(tc.common, tc.proxies, tc.visitors)
require.Equal(t, tc.wantVNet, got.VirtualNet)
})
}
}
func TestValidateClientFeatureGatesAreConfigScoped(t *testing.T) {
defaultGatesBefore := featuregate.DefaultFeatureGates.String()
require.NoError(t, validateClientFeatureGates(
t,
map[string]bool{"VirtualNet": true},
"100.86.0.4/24",
))
require.Equal(t, defaultGatesBefore, featuregate.DefaultFeatureGates.String())
err := validateClientFeatureGates(
t,
map[string]bool{"VirtualNet": false},
"100.86.0.4/24",
)
require.ErrorContains(t, err, "VirtualNet feature is not enabled")
require.Equal(t, defaultGatesBefore, featuregate.DefaultFeatureGates.String())
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package validation
import (
"fmt"
"unicode"
"unicode/utf8"
)
const (
// MaxRunIDLength is the maximum number of bytes accepted for a control run ID.
MaxRunIDLength = 64
)
func validateIdentifier(value, kind string, maxLength int) error {
if value == "" {
return fmt.Errorf("%s cannot be empty", kind)
}
if len(value) > maxLength {
return fmt.Errorf("%s is too long: length %d exceeds maximum %d", kind, len(value), maxLength)
}
if !utf8.ValidString(value) {
return fmt.Errorf("%s must be valid UTF-8", kind)
}
for _, r := range value {
if !unicode.IsPrint(r) {
return fmt.Errorf("%s contains non-printable character", kind)
}
}
return nil
}
func ValidateRunID(runID string) error {
return validateIdentifier(runID, "run id", MaxRunIDLength)
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package validation
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestValidateIdentifiers(t *testing.T) {
tests := []struct {
name string
validate func(string) error
value string
wantError string
}{
{name: "run id accepts printable values", validate: ValidateRunID, value: "run-%1000s-中文"},
{name: "run id rejects empty", validate: ValidateRunID, wantError: "cannot be empty"},
{name: "run id rejects control character", validate: ValidateRunID, value: "run\nforged", wantError: "non-printable"},
{name: "run id rejects invalid utf8", validate: ValidateRunID, value: string([]byte{0xff}), wantError: "valid UTF-8"},
{name: "run id rejects excessive length", validate: ValidateRunID, value: strings.Repeat("a", MaxRunIDLength+1), wantError: "too long"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.validate(tt.value)
if tt.wantError == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tt.wantError)
})
}
}
+4 -2
View File
@@ -79,9 +79,11 @@ func validateDomainConfigForClient(c *v1.DomainConfig) error {
}
func validateDomainConfigForServer(c *v1.DomainConfig, s *v1.ServerConfig) error {
subDomainHost := strings.ToLower(s.SubDomainHost)
for _, domain := range c.CustomDomains {
if s.SubDomainHost != "" && len(strings.Split(s.SubDomainHost, ".")) < len(strings.Split(domain, ".")) {
if strings.HasSuffix(domain, "."+s.SubDomainHost) {
canonicalDomain := strings.ToLower(domain)
if subDomainHost != "" && len(strings.Split(subDomainHost, ".")) < len(strings.Split(canonicalDomain, ".")) {
if strings.HasSuffix(canonicalDomain, "."+subDomainHost) {
return fmt.Errorf("custom domain [%s] should not belong to subdomain host [%s]", domain, s.SubDomainHost)
}
}
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package validation
import (
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestValidateDomainConfigForServerRejectsSubdomainHostCaseInsensitively(t *testing.T) {
tests := []struct {
name string
subDomainHost string
customDomain string
wantErr bool
}{
{
name: "lowercase subdomain",
subDomainHost: "frp.example.com",
customDomain: "victim.frp.example.com",
wantErr: true,
},
{
name: "mixed case custom domain",
subDomainHost: "frp.example.com",
customDomain: "victim.FRP.example.com",
wantErr: true,
},
{
name: "mixed case wildcard domain",
subDomainHost: "frp.example.com",
customDomain: "*.FRP.example.com",
wantErr: true,
},
{
name: "mixed case subdomain host",
subDomainHost: "FRP.Example.Com",
customDomain: "victim.frp.example.com",
wantErr: true,
},
{
name: "external domain",
subDomainHost: "frp.example.com",
customDomain: "victim.example.net",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateDomainConfigForServer(
&v1.DomainConfig{CustomDomains: []string{tt.customDomain}},
&v1.ServerConfig{SubDomainHost: tt.subDomainHost},
)
if tt.wantErr {
require.ErrorContains(t, err, "should not belong to subdomain host")
return
}
require.NoError(t, err)
})
}
}
+3
View File
@@ -51,6 +51,9 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err
errs = AppendError(errs, ValidatePort(c.VhostHTTPPort, "vhostHTTPPort"))
errs = AppendError(errs, ValidatePort(c.VhostHTTPSPort, "vhostHTTPSPort"))
errs = AppendError(errs, ValidatePort(c.TCPMuxHTTPConnectPort, "tcpMuxHTTPConnectPort"))
if c.Transport.MaxPoolCount < 0 {
errs = AppendError(errs, fmt.Errorf("invalid transport.maxPoolCount, must be non-negative"))
}
for _, p := range c.HTTPPlugins {
if !lo.Every(SupportedHTTPPluginOps, p.Ops) {
+51
View File
@@ -0,0 +1,51 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package validation
import (
"math"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestValidateServerConfigMaxPoolCount(t *testing.T) {
for _, tc := range []struct {
name string
maxPoolCount int64
wantErr bool
}{
{name: "negative", maxPoolCount: -1, wantErr: true},
{name: "zero", maxPoolCount: 0},
{name: "positive", maxPoolCount: 5},
{name: "maximum int64", maxPoolCount: math.MaxInt64},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := validServerConfigWithAuth(v1.AuthServerConfig{Method: v1.AuthMethodToken})
cfg.Transport.MaxPoolCount = tc.maxPoolCount
require.NoError(t, cfg.Complete())
_, err := NewConfigValidator(nil).ValidateServerConfig(cfg)
if tc.wantErr {
require.ErrorContains(t, err, "invalid transport.maxPoolCount")
require.ErrorContains(t, err, "must be non-negative")
return
}
require.NoError(t, err)
})
}
}
+13 -3
View File
@@ -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
}
+70
View File
@@ -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)
+3
View File
@@ -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
View File
@@ -19,6 +19,8 @@ import (
"io"
"net"
"reflect"
"sync"
"time"
"github.com/fatedier/frp/pkg/proto/wire"
)
@@ -111,14 +113,22 @@ type Dispatcher struct {
sendCh chan Message
doneCh chan struct{}
stopCh chan struct{}
stopOnce sync.Once
msgHandlers map[reflect.Type]func(Message)
}
// NewDispatcher uses SetReadDeadline, when supported, and Close, when rw
// implements io.Closer, to interrupt ReadMsg after a write failure. Without a
// working read deadline, Close or the caller must arrange for ReadMsg to return.
// These methods must return in bounded time and must not wait for Done: Done
// closes only after ReadMsg, any synchronous handler, and stop have returned.
func NewDispatcher(rw ReadWriter) *Dispatcher {
return &Dispatcher{
rw: rw,
sendCh: make(chan Message, 100),
doneCh: make(chan struct{}),
stopCh: make(chan struct{}),
msgHandlers: make(map[reflect.Type]func(Message)),
}
}
@@ -129,37 +139,78 @@ func (d *Dispatcher) Run() {
go d.readLoop()
}
// stop rejects new sends and interrupts ReadMsg when supported by rw. Done remains
// owned by readLoop so the control owner cannot clean up while a handler is running.
func (d *Dispatcher) stop() {
d.stopOnce.Do(func() {
close(d.stopCh)
if conn, ok := d.rw.(interface{ SetReadDeadline(time.Time) error }); ok {
_ = conn.SetReadDeadline(time.Now())
}
if closer, ok := d.rw.(io.Closer); ok {
_ = closer.Close()
}
})
}
func (d *Dispatcher) sendLoop() {
for {
select {
case <-d.doneCh:
case <-d.stopCh:
return
case m := <-d.sendCh:
_ = d.rw.WriteMsg(m)
if err := d.rw.WriteMsg(m); err != nil {
d.stop()
return
}
}
}
}
func (d *Dispatcher) readLoop() {
defer close(d.doneCh)
defer d.stop()
for {
m, err := d.rw.ReadMsg()
if err != nil {
close(d.doneCh)
return
}
select {
case <-d.stopCh:
return
default:
}
if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok {
handler(m)
}
}
}
// Send queues m for writing. A nil error does not guarantee delivery.
func (d *Dispatcher) Send(m Message) error {
select {
case <-d.doneCh:
case <-d.stopCh:
return io.EOF
default:
}
return d.enqueue(m)
}
func (d *Dispatcher) enqueue(m Message) error {
select {
case <-d.stopCh:
return io.EOF
case d.sendCh <- m:
return nil
// Both cases can be ready if shutdown races with enqueueing. Do not
// report success when shutdown is already observable after the send.
select {
case <-d.stopCh:
return io.EOF
default:
return nil
}
}
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package msg
import (
"bytes"
"errors"
"io"
"testing"
)
// Preserve the original exported constructor function type.
var _ func(ReadWriter) *Dispatcher = NewDispatcher
func TestDispatcherEnqueueRejectsStoppedDispatcher(t *testing.T) {
c := newRegressionControl(t)
d := NewDispatcher(c)
d.stop()
// Model shutdown after Send's initial check: the stop and buffered send
// cases are both ready. A send selected here must still return EOF.
if err := d.enqueue(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
t.Fatalf("enqueue racing with shutdown: %v, want EOF", err)
}
// This single call checks that a stopped dispatcher's enqueue returns EOF; it
// does not guarantee exercising the send case and post-enqueue check on every run.
if len(d.sendCh) > 0 {
t.Log("selected the send case; post-enqueue check returned EOF")
}
}
func TestDispatcherAcceptsReadWriterWithoutClose(t *testing.T) {
t.Run("codec factory", func(t *testing.T) {
d := NewDispatcher(NewV1ReadWriter(&bytes.Buffer{}))
d.Run()
regressionAwait(t, d.Done(), "non-closable codec EOF")
})
t.Run("write failure", func(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
// Hide Close to model an existing caller implementing only ReadWriter.
d := NewDispatcher(struct{ ReadWriter }{c})
d.Run()
t.Cleanup(func() {
c.transport.close()
regressionAwait(t, d.Done(), "externally interrupted reader")
})
c.transport.arm()
if err := d.Send(&ReqWorkConn{}); err != nil && !errors.Is(err, io.EOF) {
t.Fatal(err)
}
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatalf("injected write failure: %v", err)
}
regressionAwait(t, d.stopCh, "write failure stops sends")
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
t.Fatalf("Send after write failure: %v, want EOF", err)
}
if c.closeCalls.Load() != 0 {
t.Fatal("dispatcher closed a connection hidden by ReadWriter")
}
select {
case <-d.Done():
t.Fatal("Done closed before the non-closable reader returned")
default:
}
})
}
+383
View File
@@ -0,0 +1,383 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package msg
import (
"bytes"
"errors"
"io"
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"time"
libcrypto "github.com/fatedier/golib/crypto"
)
var (
errRegressionWriteTimeout = errors.New("injected recoverable connection write timeout")
regressionKey = []byte("isolated-public-test-key")
)
// regressionTransport fails exactly one Write, then accepts later writes. This
// models an individual mux stream write timing out while its session survives.
type regressionTransport struct {
mu sync.Mutex
buffer bytes.Buffer
calls int
failures int
failNext bool
closed chan struct{}
once sync.Once
}
func (w *regressionTransport) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.calls++
if w.failNext {
w.failNext = false
w.failures++
return 0, errRegressionWriteTimeout
}
select {
case <-w.closed:
return 0, io.ErrClosedPipe
default:
}
return w.buffer.Write(p)
}
func (w *regressionTransport) arm() {
w.mu.Lock()
w.failNext = true
w.mu.Unlock()
}
func (w *regressionTransport) snapshot() (calls, failures, size int) {
w.mu.Lock()
defer w.mu.Unlock()
return w.calls, w.failures, w.buffer.Len()
}
func (w *regressionTransport) close() {
w.once.Do(func() { close(w.closed) })
}
// regressionControl uses the production FRP message encoder and production
// golib crypto.Writer. Only the final transport and incoming messages are fake.
type regressionControl struct {
w *libcrypto.Writer
transport *regressionTransport
writes chan error
incoming chan Message
beforeRead func() error
beforeWrite func()
closeCalls atomic.Int32
}
func newRegressionControl(t *testing.T) *regressionControl {
t.Helper()
transport := &regressionTransport{closed: make(chan struct{})}
w, err := libcrypto.NewWriter(transport, regressionKey)
if err != nil {
t.Fatal(err)
}
return &regressionControl{
w: w, transport: transport, writes: make(chan error, 100), incoming: make(chan Message, 1),
}
}
func (c *regressionControl) ReadMsg() (Message, error) {
if c.beforeRead != nil {
if err := c.beforeRead(); err != nil {
return nil, err
}
}
select {
case m := <-c.incoming:
return m, nil
case <-c.transport.closed:
return nil, io.EOF
}
}
func (c *regressionControl) ReadMsgInto(Message) error {
_, err := c.ReadMsg()
return err
}
func (c *regressionControl) WriteMsg(m Message) error {
if c.beforeWrite != nil {
c.beforeWrite()
}
err := WriteMsg(c.w, m)
c.writes <- err
return err
}
func (c *regressionControl) Close() error {
c.closeCalls.Add(1)
c.transport.close()
return nil
}
func regressionReceiveWrite(t *testing.T, c *regressionControl) error {
t.Helper()
select {
case err := <-c.writes:
return err
case <-time.After(time.Second):
t.Fatal("dispatcher did not attempt WriteMsg")
return nil
}
}
func regressionAwait(t *testing.T, ch <-chan struct{}, what string) {
t.Helper()
select {
case <-ch:
case <-time.After(time.Second):
t.Fatalf("timed out waiting for %s", what)
}
}
func regressionWarm(t *testing.T, c *regressionControl) {
t.Helper()
if err := c.WriteMsg(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
<-c.writes
c.transport.mu.Lock()
raw := append([]byte(nil), c.transport.buffer.Bytes()...)
c.transport.mu.Unlock()
m, err := ReadMsg(libcrypto.NewReader(bytes.NewReader(raw), regressionKey))
if err != nil {
t.Fatalf("encrypted control roundtrip: %v", err)
}
if _, ok := m.(*ReqWorkConn); !ok {
t.Fatalf("decoded %T", m)
}
}
func TestControlCryptoWriterKeepsErrorAfterTransportRecovery(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
defer c.transport.close()
c.transport.arm()
if err := c.WriteMsg(&ReqWorkConn{}); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatalf("want timeout, got %v", err)
}
<-c.writes
beforeCalls, failures, beforeBytes := c.transport.snapshot()
for range 5 {
if err := c.WriteMsg(&ReqWorkConn{}); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatalf("sticky error missing: %v", err)
}
<-c.writes
}
afterCalls, _, afterBytes := c.transport.snapshot()
if beforeCalls != afterCalls || beforeBytes != afterBytes || failures != 1 {
t.Fatalf("unexpected retry: calls %d -> %d, failures %d, bytes %d -> %d",
beforeCalls, afterCalls, failures, beforeBytes, afterBytes)
}
if n, err := c.transport.Write([]byte("healthy underlying transport")); err != nil || n == 0 {
t.Fatalf("underlying transport did not recover: %v", err)
}
t.Log("one recoverable write error permanently invalidates this crypto writer; recovered underlying transport is never retried")
}
func TestDispatcherWriteErrorClosesControlAndFreshSessionWorks(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
defer c.transport.close()
d := NewDispatcher(c)
d.Run()
c.transport.arm()
if err := d.Send(&ReqWorkConn{}); err != nil && !errors.Is(err, io.EOF) {
t.Fatal(err)
}
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatal(err)
}
regressionAwait(t, c.transport.closed, "failed control connection close")
regressionAwait(t, d.Done(), "failed dispatcher completion")
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
t.Fatalf("Send after completed shutdown: %v", err)
}
if c.closeCalls.Load() != 1 {
t.Fatalf("connection closed %d times", c.closeCalls.Load())
}
fresh := newRegressionControl(t)
defer fresh.transport.close()
d2 := NewDispatcher(fresh)
d2.Run()
for range 5 {
if err := d2.Send(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
if err := regressionReceiveWrite(t, fresh); err != nil {
t.Fatalf("fresh session write failed: %v", err)
}
}
fresh.transport.mu.Lock()
raw := append([]byte(nil), fresh.transport.buffer.Bytes()...)
fresh.transport.mu.Unlock()
r := libcrypto.NewReader(bytes.NewReader(raw), regressionKey)
for i := range 5 {
m, err := ReadMsg(r)
if err != nil {
t.Fatalf("new stream decode %d: %v", i, err)
}
if _, ok := m.(*ReqWorkConn); !ok {
t.Fatalf("decoded %T", m)
}
}
fresh.transport.close()
regressionAwait(t, d2.Done(), "new-session cleanup")
t.Log("write error closes failed control; new session sends and decrypts five valid ReqWorkConn messages")
}
func TestDispatcherWriteErrorWaitsForSynchronousHandler(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
defer c.transport.close()
d := NewDispatcher(c)
entered, release := make(chan struct{}), make(chan struct{})
var releaseOnce sync.Once
defer releaseOnce.Do(func() { close(release) })
d.RegisterHandler(&ReqWorkConn{}, func(Message) {
close(entered)
<-release
})
d.Run()
c.incoming <- &ReqWorkConn{}
regressionAwait(t, entered, "synchronous handler entry")
c.transport.arm()
if err := d.Send(&ReqWorkConn{}); err != nil && !errors.Is(err, io.EOF) {
t.Fatal(err)
}
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatal(err)
}
regressionAwait(t, c.transport.closed, "write failure closes control during handler")
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
t.Fatalf("Send after stop while handler is active: %v", err)
}
// Let the writer finish stopping while the handler remains blocked.
synctest.Wait()
select {
case <-d.Done():
t.Fatal("Done closed before synchronous handler returned; owner cleanup could race proxy registration")
default:
}
releaseOnce.Do(func() { close(release) })
regressionAwait(t, d.Done(), "completion after handler returned")
})
}
func TestDispatcherWriteErrorUnblocksSenderWithFullQueue(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
defer c.transport.close()
entered, release := make(chan struct{}), make(chan struct{})
var releaseOnce sync.Once
defer releaseOnce.Do(func() { close(release) })
c.beforeWrite = func() { close(entered); <-release }
c.transport.arm()
d := NewDispatcher(c)
if err := d.Send(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
d.Run()
regressionAwait(t, entered, "blocked transport write")
for i := 0; i < cap(d.sendCh); i++ {
if err := d.Send(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
}
sent := make(chan error, 1)
go func() { sent <- d.Send(&ReqWorkConn{}) }()
// Wait until the sender has reached its full-queue channel wait.
synctest.Wait()
select {
case err := <-sent:
t.Fatalf("Send did not block on full queue: %v", err)
default:
}
releaseOnce.Do(func() { close(release) })
select {
case err := <-sent:
if !errors.Is(err, io.EOF) {
t.Fatalf("blocked Send after write failure: %v", err)
}
case <-time.After(time.Second):
t.Fatal("write failure did not unblock full-queue sender")
}
regressionAwait(t, d.Done(), "full-queue dispatcher cleanup")
})
}
func TestDispatcherConcurrentReadWriteFailureClosesOnce(t *testing.T) {
c := newRegressionControl(t)
regressionWarm(t, c)
defer c.transport.close()
readEntered, writeEntered := make(chan struct{}), make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
defer releaseOnce.Do(func() { close(release) })
c.beforeRead = func() error {
close(readEntered)
<-release
return io.EOF
}
c.beforeWrite = func() {
close(writeEntered)
<-release
}
d := NewDispatcher(c)
c.transport.arm()
if err := d.Send(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
readExited, writeExited := make(chan struct{}), make(chan struct{})
go func() {
defer close(readExited)
d.readLoop()
}()
go func() {
defer close(writeExited)
d.sendLoop()
}()
regressionAwait(t, readEntered, "read path before injected failure")
regressionAwait(t, writeEntered, "write path before injected failure")
releaseOnce.Do(func() { close(release) })
regressionAwait(t, readExited, "failed read loop exit")
regressionAwait(t, writeExited, "failed write loop exit")
regressionAwait(t, d.Done(), "concurrent read/write shutdown")
if err := regressionReceiveWrite(t, c); !errors.Is(err, errRegressionWriteTimeout) {
t.Fatalf("concurrent WriteMsg failure: %v", err)
}
if _, failures, _ := c.transport.snapshot(); failures != 1 {
t.Fatalf("injected transport failed %d times", failures)
}
if c.closeCalls.Load() != 1 {
t.Fatalf("connection closed %d times", c.closeCalls.Load())
}
}
+225
View File
@@ -0,0 +1,225 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package msg
import (
"errors"
"io"
"net"
"sync"
"sync/atomic"
"testing"
"time"
fmux "github.com/fatedier/yamux"
"github.com/fatedier/frp/pkg/proto/wire"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
// Delay one real TCP write without injecting an error. The stream's write
// failure must come from yamux's ConnectionWriteTimeout.
type delayedTCPConn struct {
net.Conn
armed atomic.Bool
entered chan struct{}
release chan struct{}
}
func (c *delayedTCPConn) Write(p []byte) (int, error) {
if c.armed.Swap(false) {
close(c.entered)
<-c.release
}
return c.Conn.Write(p)
}
type observedYamuxConn struct {
net.Conn
writes chan error
readEntered chan struct{}
readOnce sync.Once
closed chan struct{}
closeCalls atomic.Int32
}
func (c *observedYamuxConn) Read(p []byte) (int, error) {
c.readOnce.Do(func() { close(c.readEntered) })
return c.Conn.Read(p)
}
func (c *observedYamuxConn) Write(p []byte) (int, error) {
n, err := c.Conn.Write(p)
c.writes <- err
return n, err
}
func (c *observedYamuxConn) Close() error {
c.closeCalls.Add(1)
err := c.Conn.Close()
close(c.closed)
return err
}
func newLoopbackYamuxPair(t *testing.T) (*fmux.Session, *fmux.Session, *delayedTCPConn) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
clientTCP, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { clientTCP.Close() })
serverTCP, err := listener.Accept()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { serverTCP.Close() })
gate := &delayedTCPConn{Conn: clientTCP, entered: make(chan struct{}), release: make(chan struct{})}
config := fmux.DefaultConfig()
config.ConnectionWriteTimeout = 80 * time.Millisecond
config.LogOutput = io.Discard
client, err := fmux.Client(gate, config)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { client.Close() })
server, err := fmux.Server(serverTCP, config)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { server.Close() })
return client, server, gate
}
func loopbackCryptoConn(t *testing.T, conn net.Conn, protocol string, role netpkg.AEADCryptoRole) *Conn {
t.Helper()
var rw io.ReadWriter
var err error
if protocol == wire.ProtocolV2 {
rw, err = netpkg.NewAEADCryptoReadWriter(
conn, regressionKey, role, wire.AEADAlgorithmAES256GCM, []byte("isolated-test-transcript"))
} else {
rw, err = netpkg.NewCryptoReadWriter(conn, regressionKey)
}
if err != nil {
t.Fatal(err)
}
return NewConn(conn, NewReadWriter(rw, protocol))
}
func loopbackControlPair(t *testing.T, client, server *fmux.Session, protocol string) (*Conn, *Conn, *observedYamuxConn) {
t.Helper()
local, err := client.OpenStream()
if err != nil {
t.Fatal(err)
}
remote, err := server.AcceptStream()
if err != nil {
t.Fatal(err)
}
observed := &observedYamuxConn{
Conn: local, writes: make(chan error, 16), readEntered: make(chan struct{}), closed: make(chan struct{}),
}
return loopbackCryptoConn(t, observed, protocol, netpkg.AEADCryptoRoleClient),
loopbackCryptoConn(t, remote, protocol, netpkg.AEADCryptoRoleServer), observed
}
func loopbackReadReq(t *testing.T, conn *Conn) {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatal(err)
}
defer conn.SetReadDeadline(time.Time{})
m, err := conn.ReadMsg()
if err != nil {
t.Fatal(err)
}
if _, ok := m.(*ReqWorkConn); !ok {
t.Fatalf("decoded %T, want ReqWorkConn", m)
}
}
func TestDispatcherYamuxWriteTimeoutInterruptsRead(t *testing.T) {
for _, protocol := range []string{wire.ProtocolV1, wire.ProtocolV2} {
t.Run(protocol, func(t *testing.T) {
client, server, gate := newLoopbackYamuxPair(t)
var releaseOnce sync.Once
defer releaseOnce.Do(func() { close(gate.release) })
control, peer, observed := loopbackControlPair(t, client, server, protocol)
if err := control.WriteMsg(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
loopbackReadReq(t, peer)
// Warm-up has completed, including all crypto header writes.
for len(observed.writes) > 0 {
<-observed.writes
}
d := NewDispatcher(control)
readExited := make(chan struct{})
go func() {
defer close(readExited)
d.readLoop()
}()
regressionAwait(t, observed.readEntered, "control reader entry")
gate.armed.Store(true)
// Queue before starting the writer so this Send cannot race its failure.
if err := d.Send(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
go d.sendLoop()
regressionAwait(t, gate.entered, "TCP write delay barrier")
select {
case err := <-observed.writes:
if !errors.Is(err, fmux.ErrConnectionWriteTimeout) {
t.Fatalf("stream write error: %v, want yamux connection write timeout", err)
}
t.Logf("production yamux write failed: %v", err)
case <-time.After(time.Second):
t.Fatal("yamux write did not time out")
}
regressionAwait(t, d.stopCh, "write failure stops sends")
if err := d.Send(&ReqWorkConn{}); !errors.Is(err, io.EOF) {
t.Fatalf("Send after stop: %v, want EOF", err)
}
releaseOnce.Do(func() { close(gate.release) })
regressionAwait(t, observed.closed, "control Close returns")
// peer never closes its write half: local yamux Close alone cannot
// interrupt Read. Neither a peer FIN nor session cleanup may help.
regressionAwait(t, d.Done(), "Done without peer FIN")
regressionAwait(t, readExited, "control readLoop exit without peer FIN")
if observed.closeCalls.Load() != 1 {
t.Fatalf("control closed %d times, want once", observed.closeCalls.Load())
}
if client.IsClosed() || server.IsClosed() {
t.Fatal("yamux session unexpectedly closed")
}
fresh, freshPeer, freshObserved := loopbackControlPair(t, client, server, protocol)
for range 5 {
if err := fresh.WriteMsg(&ReqWorkConn{}); err != nil {
t.Fatal(err)
}
loopbackReadReq(t, freshPeer)
for len(freshObserved.writes) > 0 {
<-freshObserved.writes
}
}
t.Log("Done/readLoop exited without peer FIN; Close once; same yamux session decrypted five fresh messages")
})
}
}
+199
View File
@@ -0,0 +1,199 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package msg
import (
"bytes"
"fmt"
"net"
"runtime"
"testing"
"github.com/fatedier/frp/pkg/proto/wire"
)
type udpBenchmarkCase struct {
name string
packet *UDPPacket
}
var (
udpBenchmarkBytesSink []byte
udpBenchmarkMessageSink Message
)
func udpBenchmarkCases(payloadSize int) []udpBenchmarkCase {
content := bytes.Repeat([]byte{0x5a}, payloadSize)
return []udpBenchmarkCase{
{
name: "ipv4-remote",
packet: &UDPPacket{
Content: content,
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345},
},
},
{
name: "ipv4-local-remote",
packet: &UDPPacket{
Content: content,
LocalAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.2"), Port: 23456},
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 12345},
},
},
{
name: "ipv6-remote",
packet: &UDPPacket{
Content: content,
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345},
},
},
{
name: "ipv6-local-remote",
packet: &UDPPacket{
Content: content,
LocalAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::2"), Port: 23456, Zone: "bench0"},
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 12345, Zone: "bench1"},
},
},
}
}
func TestUDPPacketV2FrameSizes(t *testing.T) {
t.Logf("environment go=%s goos=%s goarch=%s gomaxprocs=%d", runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.GOMAXPROCS(0))
for _, payloadSize := range []int{64, 512, 1200, 1472} {
for _, tc := range udpBenchmarkCases(payloadSize) {
jsonFrame := udpBenchmarkWireBytes(t, tc.packet, "")
binaryFrame := udpBenchmarkWireBytes(t, tc.packet, wire.UDPPacketCodecBinary)
saving := 100 * float64(len(jsonFrame)-len(binaryFrame)) / float64(len(jsonFrame))
t.Logf("frame payload=%d case=%s json_bytes=%d binary_bytes=%d binary_saving_pct=%.2f", payloadSize, tc.name, len(jsonFrame), len(binaryFrame), saving)
}
}
}
func udpBenchmarkWireBytes(t testing.TB, packet *UDPPacket, codec string) []byte {
t.Helper()
var buf bytes.Buffer
rw, err := NewUDPPacketReadWriter(&buf, wire.ProtocolV2, codec)
if err != nil {
t.Fatalf("create UDP read writer: %v", err)
}
if err := rw.WriteMsg(packet); err != nil {
t.Fatalf("write UDP packet: %v", err)
}
return append([]byte(nil), buf.Bytes()...)
}
type udpBenchmarkReadWriter struct {
reader bytes.Reader
}
func (rw *udpBenchmarkReadWriter) Read(p []byte) (int, error) {
return rw.reader.Read(p)
}
func (rw *udpBenchmarkReadWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func (rw *udpBenchmarkReadWriter) Reset(p []byte) {
rw.reader.Reset(p)
}
func udpBenchmarkValidatePacket(b testing.TB, got, want *UDPPacket) {
b.Helper()
if !bytes.Equal(got.Content, want.Content) || !udpBenchmarkUDPAddrEqual(got.LocalAddr, want.LocalAddr) ||
!udpBenchmarkUDPAddrEqual(got.RemoteAddr, want.RemoteAddr) {
b.Fatalf("decoded packet mismatch: got %+v, want %+v", got, want)
}
}
func udpBenchmarkUDPAddrEqual(got, want *net.UDPAddr) bool {
if got == nil || want == nil {
return got == want
}
return got.IP.Equal(want.IP) && got.Port == want.Port && got.Zone == want.Zone
}
func BenchmarkUDPPacketV2CodecWrite(b *testing.B) {
for _, payloadSize := range []int{64, 512, 1200, 1472} {
for _, tc := range udpBenchmarkCases(payloadSize) {
for _, codec := range []struct {
name string
value string
}{
{name: "json", value: ""},
{name: "binary", value: wire.UDPPacketCodecBinary},
} {
b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, codec.name), func(b *testing.B) {
var buf bytes.Buffer
rw, err := NewUDPPacketReadWriter(&buf, wire.ProtocolV2, codec.value)
if err != nil {
b.Fatal(err)
}
expected := udpBenchmarkWireBytes(b, tc.packet, codec.value)
b.SetBytes(int64(len(expected)))
for b.Loop() {
buf.Reset()
if err := rw.WriteMsg(tc.packet); err != nil {
b.Fatal(err)
}
}
if !bytes.Equal(buf.Bytes(), expected) {
b.Fatalf("encoded packet mismatch: got %d bytes, want %d", buf.Len(), len(expected))
}
udpBenchmarkBytesSink = buf.Bytes()
})
}
}
}
}
func BenchmarkUDPPacketV2CodecRead(b *testing.B) {
for _, payloadSize := range []int{64, 512, 1200, 1472} {
for _, tc := range udpBenchmarkCases(payloadSize) {
for _, codec := range []struct {
name string
value string
}{
{name: "json", value: ""},
{name: "binary", value: wire.UDPPacketCodecBinary},
} {
b.Run(fmt.Sprintf("payload-%d/%s/%s", payloadSize, tc.name, codec.name), func(b *testing.B) {
encoded := udpBenchmarkWireBytes(b, tc.packet, codec.value)
stream := &udpBenchmarkReadWriter{}
rw, err := NewUDPPacketReadWriter(stream, wire.ProtocolV2, codec.value)
if err != nil {
b.Fatal(err)
}
var decoded Message
b.SetBytes(int64(len(encoded)))
for b.Loop() {
stream.Reset(encoded)
decoded, err = rw.ReadMsg()
if err != nil {
b.Fatal(err)
}
}
packet, ok := decoded.(*UDPPacket)
if !ok {
b.Fatalf("decoded message type %T, want *UDPPacket", decoded)
}
udpBenchmarkValidatePacket(b, packet, tc.packet)
udpBenchmarkMessageSink = decoded
})
}
}
}
}
+338
View File
@@ -0,0 +1,338 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package msg
import (
"encoding/binary"
"fmt"
"io"
"net"
"unicode/utf8"
"github.com/fatedier/frp/pkg/proto/wire"
)
const MaxUDPPayloadSize = 65507
const (
udpPacketFlagLocalAddr byte = 1 << 0
udpPacketFlagRemoteAddr byte = 1 << 1
udpPacketValidFlags = udpPacketFlagLocalAddr | udpPacketFlagRemoteAddr
)
type binaryUDPAddr struct {
family byte
ip []byte
port uint16
zone string
}
// EncodeUDPPacketBinary encodes the body of a V2 binary UDP packet message.
// RemoteAddr is required by the UDP forwarding path.
func EncodeUDPPacketBinary(packet *UDPPacket) ([]byte, error) {
if packet == nil {
return nil, fmt.Errorf("nil UDP packet")
}
if packet.RemoteAddr == nil {
return nil, fmt.Errorf("UDP packet missing remote address")
}
if len(packet.Content) > MaxUDPPayloadSize {
return nil, fmt.Errorf("UDP payload length %d exceeds limit %d", len(packet.Content), MaxUDPPayloadSize)
}
var flags byte
var localAddr, remoteAddr binaryUDPAddr
bodyLen := 1 + 2 + len(packet.Content)
if packet.LocalAddr != nil {
flags |= udpPacketFlagLocalAddr
var err error
localAddr, err = validateBinaryUDPAddr(packet.LocalAddr)
if err != nil {
return nil, fmt.Errorf("local address: %w", err)
}
bodyLen += binaryUDPAddrLen(localAddr)
}
flags |= udpPacketFlagRemoteAddr
var err error
remoteAddr, err = validateBinaryUDPAddr(packet.RemoteAddr)
if err != nil {
return nil, fmt.Errorf("remote address: %w", err)
}
bodyLen += binaryUDPAddrLen(remoteAddr)
if 2+bodyLen > wire.DefaultMaxFramePayloadSize {
return nil, fmt.Errorf("v2 frame payload length %d exceeds limit %d", 2+bodyLen, wire.DefaultMaxFramePayloadSize)
}
body := make([]byte, bodyLen)
body[0] = flags
offset := 1
if flags&udpPacketFlagLocalAddr != 0 {
offset = putBinaryUDPAddr(body, offset, localAddr)
}
offset = putBinaryUDPAddr(body, offset, remoteAddr)
binary.BigEndian.PutUint16(body[offset:offset+2], uint16(len(packet.Content)))
offset += 2
copy(body[offset:], packet.Content)
return body, nil
}
// DecodeUDPPacketBinary decodes a V2 binary UDP packet body and returns data
// that does not alias the input frame buffer.
func DecodeUDPPacketBinary(body []byte) (*UDPPacket, error) {
if len(body) < 3 {
return nil, fmt.Errorf("UDP packet body too short: %d", len(body))
}
if 2+len(body) > wire.DefaultMaxFramePayloadSize {
return nil, fmt.Errorf("v2 frame payload length %d exceeds limit %d", 2+len(body), wire.DefaultMaxFramePayloadSize)
}
flags := body[0]
if flags&^udpPacketValidFlags != 0 {
return nil, fmt.Errorf("reserved UDP packet flags set: 0x%02x", flags)
}
if flags&udpPacketFlagRemoteAddr == 0 {
return nil, fmt.Errorf("UDP packet missing remote address")
}
packet := &UDPPacket{}
offset := 1
var err error
if flags&udpPacketFlagLocalAddr != 0 {
packet.LocalAddr, offset, err = readBinaryUDPAddr(body, offset)
if err != nil {
return nil, fmt.Errorf("local address: %w", err)
}
}
if flags&udpPacketFlagRemoteAddr != 0 {
packet.RemoteAddr, offset, err = readBinaryUDPAddr(body, offset)
if err != nil {
return nil, fmt.Errorf("remote address: %w", err)
}
}
if len(body)-offset < 2 {
return nil, fmt.Errorf("truncated UDP payload length")
}
payloadLen := int(binary.BigEndian.Uint16(body[offset : offset+2]))
offset += 2
if payloadLen > MaxUDPPayloadSize {
return nil, fmt.Errorf("UDP payload length %d exceeds limit %d", payloadLen, MaxUDPPayloadSize)
}
remaining := len(body) - offset
if remaining < payloadLen {
return nil, fmt.Errorf("truncated UDP payload: have %d want %d", remaining, payloadLen)
}
if remaining > payloadLen {
return nil, fmt.Errorf("trailing UDP packet bytes: %d", remaining-payloadLen)
}
packet.Content = append([]byte(nil), body[offset:offset+payloadLen]...)
return packet, nil
}
func validateBinaryUDPAddr(addr *net.UDPAddr) (binaryUDPAddr, error) {
if addr.Port < 0 || addr.Port > 65535 {
return binaryUDPAddr{}, fmt.Errorf("port out of range: %d", addr.Port)
}
if ip := addr.IP.To4(); ip != nil {
if addr.Zone != "" {
return binaryUDPAddr{}, fmt.Errorf("IPv4 zone is forbidden")
}
return binaryUDPAddr{family: 4, ip: ip, port: uint16(addr.Port)}, nil
}
ip := addr.IP.To16()
if ip == nil {
return binaryUDPAddr{}, fmt.Errorf("invalid IP")
}
if len(addr.Zone) > 255 {
return binaryUDPAddr{}, fmt.Errorf("zone exceeds 255 bytes")
}
if !utf8.ValidString(addr.Zone) {
return binaryUDPAddr{}, fmt.Errorf("zone is not valid UTF-8")
}
return binaryUDPAddr{family: 6, ip: ip, port: uint16(addr.Port), zone: addr.Zone}, nil
}
func binaryUDPAddrLen(addr binaryUDPAddr) int {
return 1 + len(addr.ip) + 2 + 1 + len(addr.zone)
}
func putBinaryUDPAddr(body []byte, offset int, addr binaryUDPAddr) int {
body[offset] = addr.family
offset++
copy(body[offset:], addr.ip)
offset += len(addr.ip)
binary.BigEndian.PutUint16(body[offset:offset+2], addr.port)
offset += 2
body[offset] = byte(len(addr.zone))
offset++
copy(body[offset:], addr.zone)
return offset + len(addr.zone)
}
func readBinaryUDPAddr(body []byte, offset int) (*net.UDPAddr, int, error) {
if offset >= len(body) {
return nil, offset, fmt.Errorf("truncated address family")
}
family := body[offset]
offset++
var ipLen int
switch family {
case 4:
ipLen = net.IPv4len
case 6:
ipLen = net.IPv6len
default:
return nil, offset, fmt.Errorf("unknown address family %d", family)
}
if len(body)-offset < ipLen+3 {
return nil, offset, fmt.Errorf("truncated address")
}
ip := append(net.IP(nil), body[offset:offset+ipLen]...)
offset += ipLen
port := binary.BigEndian.Uint16(body[offset : offset+2])
offset += 2
zoneLen := int(body[offset])
offset++
if len(body)-offset < zoneLen {
return nil, offset, fmt.Errorf("truncated zone")
}
zoneBytes := body[offset : offset+zoneLen]
if family == 4 && zoneLen != 0 {
return nil, offset, fmt.Errorf("IPv4 zone is forbidden")
}
if !utf8.Valid(zoneBytes) {
return nil, offset, fmt.Errorf("zone is not valid UTF-8")
}
offset += zoneLen
return &net.UDPAddr{IP: ip, Port: int(port), Zone: string(zoneBytes)}, offset, nil
}
type V2BinaryUDPPacketReadWriter struct {
conn *wire.Conn
}
func NewV2BinaryUDPPacketReadWriter(rw io.ReadWriter) *V2BinaryUDPPacketReadWriter {
return &V2BinaryUDPPacketReadWriter{conn: wire.NewConn(rw)}
}
func (rw *V2BinaryUDPPacketReadWriter) ReadMsg() (Message, error) {
frame, err := rw.conn.ReadFrame()
if err != nil {
return nil, err
}
if isV2MessageType(frame, V2TypeUDPPacketBinary) {
return decodeV2BinaryUDPPacketFrame(frame)
}
if isV2MessageType(frame, V2TypeUDPPacket) {
return nil, fmt.Errorf("received JSON UDP packet after binary codec negotiation")
}
return DecodeV2MessageFrame(frame)
}
func (rw *V2BinaryUDPPacketReadWriter) ReadMsgInto(out Message) error {
frame, err := rw.conn.ReadFrame()
if err != nil {
return err
}
if packetOut, ok := out.(*UDPPacket); ok {
if !isV2MessageType(frame, V2TypeUDPPacketBinary) {
return unexpectedV2UDPPacketType(frame)
}
packet, err := decodeV2BinaryUDPPacketFrame(frame)
if err != nil {
return err
}
*packetOut = *packet
return nil
}
return DecodeV2MessageFrameInto(frame, out)
}
func (rw *V2BinaryUDPPacketReadWriter) WriteMsg(message Message) error {
var packet *UDPPacket
switch typed := message.(type) {
case *UDPPacket:
packet = typed
case UDPPacket:
packet = &typed
default:
frame, err := EncodeV2MessageFrame(message)
if err != nil {
return err
}
return rw.conn.WriteFrame(frame)
}
body, err := EncodeUDPPacketBinary(packet)
if err != nil {
return err
}
payload := make([]byte, 2+len(body))
binary.BigEndian.PutUint16(payload[:2], V2TypeUDPPacketBinary)
copy(payload[2:], body)
return rw.conn.WriteFrame(&wire.Frame{Type: wire.FrameTypeMessage, Payload: payload})
}
func decodeV2BinaryUDPPacketFrame(frame *wire.Frame) (*UDPPacket, error) {
if frame.Type != wire.FrameTypeMessage {
return nil, fmt.Errorf("unexpected frame type %d, want %d", frame.Type, wire.FrameTypeMessage)
}
if len(frame.Payload) < 2 {
return nil, fmt.Errorf("message frame payload too short")
}
if binary.BigEndian.Uint16(frame.Payload[:2]) != V2TypeUDPPacketBinary {
return nil, unexpectedV2UDPPacketType(frame)
}
return DecodeUDPPacketBinary(frame.Payload[2:])
}
func isV2MessageType(frame *wire.Frame, typeID uint16) bool {
return frame.Type == wire.FrameTypeMessage && len(frame.Payload) >= 2 && binary.BigEndian.Uint16(frame.Payload[:2]) == typeID
}
func unexpectedV2UDPPacketType(frame *wire.Frame) error {
if frame.Type != wire.FrameTypeMessage {
return fmt.Errorf("unexpected frame type %d, want %d", frame.Type, wire.FrameTypeMessage)
}
if len(frame.Payload) < 2 {
return fmt.Errorf("message frame payload too short")
}
typeID := binary.BigEndian.Uint16(frame.Payload[:2])
if typeID == V2TypeUDPPacket {
return fmt.Errorf("received JSON UDP packet after binary codec negotiation")
}
return fmt.Errorf("unexpected message type %d, want %d", typeID, V2TypeUDPPacketBinary)
}
// NewUDPPacketReadWriter selects the negotiated packet codec without changing
// the framing or codecs used by non-UDP messages on the work connection.
func NewUDPPacketReadWriter(rw io.ReadWriter, wireProtocol, udpPacketCodec string) (ReadWriter, error) {
switch wireProtocol {
case "", wire.ProtocolV1:
if udpPacketCodec != "" {
return nil, fmt.Errorf("UDP packet codec %q requires wire protocol v2", udpPacketCodec)
}
return NewV1ReadWriter(rw), nil
case wire.ProtocolV2:
switch udpPacketCodec {
case "":
return NewV2ReadWriter(rw), nil
case wire.UDPPacketCodecBinary:
return NewV2BinaryUDPPacketReadWriter(rw), nil
default:
return nil, fmt.Errorf("unsupported UDP packet codec %q", udpPacketCodec)
}
default:
return nil, fmt.Errorf("unsupported wire protocol %q", wireProtocol)
}
}
+248
View File
@@ -0,0 +1,248 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
package msg
import (
"bytes"
"encoding/binary"
"net"
"strconv"
"testing"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/proto/wire"
)
func TestUDPPacketBinaryRoundTrip(t *testing.T) {
payload := bytes.Repeat([]byte{0xa5}, 1472)
in := &UDPPacket{
Content: payload,
LocalAddr: &net.UDPAddr{
IP: net.ParseIP("2001:db8::1"),
Port: 1234,
Zone: "en0",
},
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
}
body, err := EncodeUDPPacketBinary(in)
require.NoError(t, err)
out, err := DecodeUDPPacketBinary(body)
require.NoError(t, err)
require.Equal(t, in.Content, out.Content)
require.Equal(t, in.LocalAddr.String(), out.LocalAddr.String())
require.Equal(t, in.RemoteAddr.String(), out.RemoteAddr.String())
body[len(body)-1] ^= 0xff
body[25] ^= 0xff
require.Equal(t, byte(0xa5), out.Content[len(out.Content)-1], "decoded payload must own frame bytes")
require.Equal(t, byte(203), out.RemoteAddr.IP.To4()[0], "decoded address must own frame bytes")
}
func TestUDPPacketBinarySizesAndOptionalLocalAddress(t *testing.T) {
for _, size := range []int{0, 32, 128, 512, 1200, 1472, 4096, 49107, 65507} {
t.Run(strconv.Itoa(size), func(t *testing.T) {
in := &UDPPacket{
Content: bytes.Repeat([]byte{byte(size)}, size),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
}
body, err := EncodeUDPPacketBinary(in)
require.NoError(t, err)
out, err := DecodeUDPPacketBinary(body)
require.NoError(t, err)
require.Equal(t, len(in.Content), len(out.Content))
if size == 0 {
require.Empty(t, out.Content)
} else {
require.Equal(t, in.Content, out.Content)
}
})
}
}
func TestUDPPacketBinaryMalformed(t *testing.T) {
valid, err := EncodeUDPPacketBinary(&UDPPacket{
Content: []byte("payload"),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
})
require.NoError(t, err)
tests := [][]byte{
{0x80, 0, 0},
{0x02, 4, 1, 2},
{0x02, 4, 1, 2, 3, 4, 0xd4},
append(append([]byte(nil), valid...), 0),
}
for _, malformed := range tests {
_, err := DecodeUDPPacketBinary(malformed)
require.Error(t, err)
}
_, err = DecodeUDPPacketBinary([]byte{0, 0, 0})
require.ErrorContains(t, err, "missing remote address")
payloadLengthOffset := len(valid) - len("payload") - 2
invalidPayloadLength := append([]byte(nil), valid...)
binary.BigEndian.PutUint16(invalidPayloadLength[payloadLengthOffset:payloadLengthOffset+2], 0xffff)
_, err = DecodeUDPPacketBinary(invalidPayloadLength)
require.ErrorContains(t, err, "payload length")
truncatedPayload := append([]byte(nil), valid[:payloadLengthOffset+2]...)
binary.BigEndian.PutUint16(truncatedPayload[payloadLengthOffset:payloadLengthOffset+2], 1)
_, err = DecodeUDPPacketBinary(truncatedPayload)
require.ErrorContains(t, err, "truncated UDP payload")
_, err = DecodeUDPPacketBinary(make([]byte, wire.DefaultMaxFramePayloadSize))
require.ErrorContains(t, err, "frame payload length")
badIPv4Zone := []byte{2, 4, 203, 0, 113, 9, 0xd4, 0x31, 1, 'z', 0, 0}
_, err = DecodeUDPPacketBinary(badIPv4Zone)
require.ErrorContains(t, err, "IPv4 zone")
badFamily := []byte{2, 9, 0, 0}
_, err = DecodeUDPPacketBinary(badFamily)
require.ErrorContains(t, err, "unknown address family")
badUTF8 := make([]byte, 0, 24)
badUTF8 = append(badUTF8, 2, 6)
badUTF8 = append(badUTF8, make([]byte, 16)...)
badUTF8 = append(badUTF8, 0, 1, 1, 0xff, 0, 0)
_, err = DecodeUDPPacketBinary(badUTF8)
require.ErrorContains(t, err, "UTF-8")
}
func TestUDPPacketBinaryEncodeRejectsInvalidPackets(t *testing.T) {
_, err := EncodeUDPPacketBinary(&UDPPacket{})
require.ErrorContains(t, err, "missing remote address")
_, err = EncodeUDPPacketBinary(&UDPPacket{
LocalAddr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 1234},
})
require.ErrorContains(t, err, "missing remote address")
_, err = EncodeUDPPacketBinary(&UDPPacket{
Content: make([]byte, MaxUDPPayloadSize+1),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321},
})
require.ErrorContains(t, err, "exceeds limit")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 1, Zone: "bad"}})
require.ErrorContains(t, err, "IPv4 zone")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1, Zone: string(bytes.Repeat([]byte{'z'}, 256))}})
require.ErrorContains(t, err, "zone exceeds")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1, Zone: string([]byte{0xff})}})
require.ErrorContains(t, err, "UTF-8")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{Port: -1}})
require.ErrorContains(t, err, "port out of range")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{Port: 65536}})
require.ErrorContains(t, err, "port out of range")
_, err = EncodeUDPPacketBinary(&UDPPacket{RemoteAddr: &net.UDPAddr{IP: net.IP{1, 2, 3}}})
require.ErrorContains(t, err, "invalid IP")
_, err = EncodeUDPPacketBinary(&UDPPacket{
Content: make([]byte, MaxUDPPayloadSize),
RemoteAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Zone: string(bytes.Repeat([]byte{'z'}, 255))},
})
require.ErrorContains(t, err, "frame payload length")
}
func TestV2BinaryUDPPacketReadWriterPreservesOtherMessages(t *testing.T) {
var buf bytes.Buffer
rw := NewV2BinaryUDPPacketReadWriter(&buf)
in := &UDPPacket{Content: []byte("udp"), RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321}}
require.NoError(t, rw.WriteMsg(in))
require.NoError(t, rw.WriteMsg(&Ping{Timestamp: 7}))
frameConn := wire.NewConn(&buf)
frame, err := frameConn.ReadFrame()
require.NoError(t, err)
require.Equal(t, V2TypeUDPPacketBinary, binary.BigEndian.Uint16(frame.Payload[:2]))
frame, err = frameConn.ReadFrame()
require.NoError(t, err)
require.Equal(t, V2TypePing, binary.BigEndian.Uint16(frame.Payload[:2]))
}
func TestV2BinaryUDPPacketReadWriterRoundTripAndCodecInvariant(t *testing.T) {
in := &UDPPacket{Content: []byte("udp"), RemoteAddr: &net.UDPAddr{IP: net.ParseIP("203.0.113.9"), Port: 54321}}
var binaryStream bytes.Buffer
binaryWriter, err := NewUDPPacketReadWriter(&binaryStream, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
require.NoError(t, binaryWriter.WriteMsg(in))
binaryReader, err := NewUDPPacketReadWriter(&binaryStream, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
out, err := binaryReader.ReadMsg()
require.NoError(t, err)
require.Equal(t, in.Content, out.(*UDPPacket).Content)
for _, read := range []func(ReadWriter) error{
func(rw ReadWriter) error {
_, err := rw.ReadMsg()
return err
},
func(rw ReadWriter) error {
return rw.ReadMsgInto(&UDPPacket{})
},
} {
var jsonStream bytes.Buffer
require.NoError(t, NewReadWriter(&jsonStream, wire.ProtocolV2).WriteMsg(in))
negotiatedReader, err := NewUDPPacketReadWriter(&jsonStream, wire.ProtocolV2, wire.UDPPacketCodecBinary)
require.NoError(t, err)
require.ErrorContains(t, read(negotiatedReader), "JSON UDP packet after binary codec negotiation")
}
var fallbackStream bytes.Buffer
fallbackWriter, err := NewUDPPacketReadWriter(&fallbackStream, wire.ProtocolV2, "")
require.NoError(t, err)
require.NoError(t, fallbackWriter.WriteMsg(in))
frame, err := wire.NewConn(&fallbackStream).ReadFrame()
require.NoError(t, err)
require.Equal(t, V2TypeUDPPacket, binary.BigEndian.Uint16(frame.Payload[:2]))
}
func TestNewUDPPacketReadWriterDefaultProtocolUsesV1(t *testing.T) {
var stream bytes.Buffer
rw, err := NewUDPPacketReadWriter(&stream, "", "")
require.NoError(t, err)
require.IsType(t, &V1ReadWriter{}, rw)
require.NoError(t, rw.WriteMsg(&UDPPacket{Content: []byte("legacy")}))
require.Equal(t, TypeUDPPacket, stream.Bytes()[0])
}
func TestNewUDPPacketReadWriterRejectsInvalidSelection(t *testing.T) {
for _, tc := range []struct {
name string
wireProtocol string
udpPacketCodec string
errorSubstring string
}{
{
name: "binary codec over v1",
wireProtocol: wire.ProtocolV1,
udpPacketCodec: wire.UDPPacketCodecBinary,
errorSubstring: "requires wire protocol v2",
},
{
name: "binary codec over default protocol",
udpPacketCodec: wire.UDPPacketCodecBinary,
errorSubstring: "requires wire protocol v2",
},
{
name: "unknown v2 codec",
wireProtocol: wire.ProtocolV2,
udpPacketCodec: "unknown",
errorSubstring: "unsupported UDP packet codec",
},
{
name: "unknown wire protocol",
wireProtocol: "unknown",
errorSubstring: "unsupported wire protocol",
},
} {
t.Run(tc.name, func(t *testing.T) {
rw, err := NewUDPPacketReadWriter(&bytes.Buffer{}, tc.wireProtocol, tc.udpPacketCodec)
require.Nil(t, rw)
require.ErrorContains(t, err, tc.errorSubstring)
})
}
}
func FuzzDecodeUDPPacketBinary(f *testing.F) {
f.Add([]byte{0, 0, 0})
f.Add([]byte{2, 4, 203, 0, 113, 9, 0xd4, 0x31, 0, 0, 1})
f.Fuzz(func(t *testing.T, body []byte) {
_, _ = DecodeUDPPacketBinary(body)
})
}
+1
View File
@@ -43,6 +43,7 @@ const (
V2TypeNatHoleResp uint16 = 16
V2TypeNatHoleSid uint16 = 17
V2TypeNatHoleReport uint16 = 18
V2TypeUDPPacketBinary uint16 = 19
)
var v2MsgTypeMap = map[uint16]any{
+3
View File
@@ -84,6 +84,9 @@ func TestV2MessageTypeIDsAreStable(t *testing.T) {
require.Equal(t, uint16(16), V2TypeNatHoleResp)
require.Equal(t, uint16(17), V2TypeNatHoleSid)
require.Equal(t, uint16(18), V2TypeNatHoleReport)
require.Equal(t, uint16(19), V2TypeUDPPacketBinary)
_, registered := v2MsgTypeMap[V2TypeUDPPacketBinary]
require.False(t, registered, "binary UDP has a dedicated codec and must not alter generic type registry")
}
func TestV2MessageFrameEncoding(t *testing.T) {
+25 -65
View File
@@ -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
}
+382
View File
@@ -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)
}
-16
View File
@@ -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 {
+9
View File
@@ -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)
}
+86 -29
View File
@@ -20,6 +20,7 @@ import (
"context"
"errors"
"fmt"
"io"
"net"
"sync"
"time"
@@ -33,10 +34,16 @@ func init() {
Register(v1.VisitorPluginVirtualNet, NewVirtualNetPlugin)
}
type clientRouteController interface {
RegisterClientRoute(context.Context, string, []net.IPNet, io.ReadWriteCloser)
UnregisterClientRoute(string, io.Writer) bool
}
type VirtualNetPlugin struct {
pluginCtx PluginContext
routes []net.IPNet
routeController clientRouteController
routes []net.IPNet
mu sync.Mutex
controllerConn net.Conn
@@ -48,6 +55,11 @@ type VirtualNetPlugin struct {
cancel context.CancelFunc
}
const (
virtualNetReconnectBaseDelay = 60 * time.Second
virtualNetReconnectMaxDelay = 300 * time.Second
)
func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOptions) (Plugin, error) {
opts := options.(*v1.VirtualNetVisitorPluginOptions)
@@ -55,6 +67,9 @@ func NewVirtualNetPlugin(pluginCtx PluginContext, options v1.VisitorPluginOption
pluginCtx: pluginCtx,
routes: make([]net.IPNet, 0),
}
if pluginCtx.VnetController != nil {
p.routeController = pluginCtx.VnetController
}
p.ctx, p.cancel = context.WithCancel(pluginCtx.Ctx)
@@ -85,7 +100,7 @@ func (p *VirtualNetPlugin) Name() string {
func (p *VirtualNetPlugin) Start() {
xl := xlog.FromContextSafe(p.pluginCtx.Ctx)
if p.pluginCtx.VnetController == nil {
if p.routeController == nil {
return
}
@@ -111,16 +126,17 @@ func (p *VirtualNetPlugin) run() {
select {
case <-p.ctx.Done():
xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before pipe creation).", p.pluginCtx.Name)
p.cleanupControllerConn(xl)
p.cleanupCurrentControllerConn(xl)
return
default:
}
controllerConn, pluginConn := net.Pipe()
p.mu.Lock()
p.controllerConn = controllerConn
p.mu.Unlock()
xl.Infof("attempting to register client route for visitor [%s]", p.pluginCtx.Name)
if !p.registerControllerConn(controllerConn, pluginConn) {
xl.Infof("VirtualNetPlugin run loop for visitor [%s] stopping (context cancelled before route registration).", p.pluginCtx.Name)
return
}
// Wrap with CloseNotifyConn which supports both close notification and error recording
var closeErr error
@@ -129,8 +145,6 @@ func (p *VirtualNetPlugin) run() {
close(currentCloseSignal) // Signal the run loop on close.
})
xl.Infof("attempting to register client route for visitor [%s]", p.pluginCtx.Name)
p.pluginCtx.VnetController.RegisterClientRoute(p.ctx, p.pluginCtx.Name, p.routes, controllerConn)
xl.Infof("successfully registered client route for visitor [%s]. Starting connection handler with CloseNotifyConn.", p.pluginCtx.Name)
// Pass the CloseNotifyConn to the visitor for handling.
@@ -141,7 +155,7 @@ func (p *VirtualNetPlugin) run() {
select {
case <-p.ctx.Done():
xl.Infof("VirtualNetPlugin run loop stopping for visitor [%s] (context cancelled while waiting).", p.pluginCtx.Name)
p.cleanupControllerConn(xl)
p.cleanupControllerConn(xl, controllerConn)
return
case <-currentCloseSignal:
// Determine reconnect delay based on error with exponential backoff
@@ -152,8 +166,7 @@ func (p *VirtualNetPlugin) run() {
p.pluginCtx.Name, p.consecutiveErrors, closeErr)
// Exponential backoff: 60s, 120s, 240s, 300s (capped)
baseDelay := 60 * time.Second
reconnectDelay = min(baseDelay*time.Duration(1<<uint(p.consecutiveErrors-1)), 300*time.Second)
reconnectDelay = virtualNetReconnectDelay(p.consecutiveErrors)
} else {
// Reset consecutive errors on successful connection
if p.consecutiveErrors > 0 {
@@ -167,7 +180,7 @@ func (p *VirtualNetPlugin) run() {
}
// The visitor closed the plugin side. Close the controller side.
p.cleanupControllerConn(xl)
p.cleanupControllerConn(xl, controllerConn)
xl.Infof("waiting %v before attempting reconnection for visitor [%s]...", reconnectDelay, p.pluginCtx.Name)
select {
@@ -182,16 +195,66 @@ func (p *VirtualNetPlugin) run() {
}
}
// cleanupControllerConn closes the current controllerConn (if it exists) under lock.
func (p *VirtualNetPlugin) cleanupControllerConn(xl *xlog.Logger) {
// registerControllerConn publishes and registers controllerConn atomically with
// respect to Close. A canceled plugin cannot register a new route.
func (p *VirtualNetPlugin) registerControllerConn(controllerConn, pluginConn net.Conn) bool {
p.mu.Lock()
if p.ctx.Err() != nil || p.routeController == nil {
p.mu.Unlock()
_ = controllerConn.Close()
_ = pluginConn.Close()
return false
}
p.controllerConn = controllerConn
p.routeController.RegisterClientRoute(p.ctx, p.pluginCtx.Name, p.routes, controllerConn)
p.mu.Unlock()
return true
}
// virtualNetReconnectDelay returns a bounded reconnect delay without allowing
// the exponential shift to overflow for large consecutive error counts.
func virtualNetReconnectDelay(consecutiveErrors int) time.Duration {
if consecutiveErrors <= 1 {
return virtualNetReconnectBaseDelay
}
if consecutiveErrors >= 4 {
return virtualNetReconnectMaxDelay
}
return virtualNetReconnectBaseDelay * time.Duration(1<<uint(consecutiveErrors-1))
}
// cleanupControllerConn unregisters and closes one connection round without
// affecting a replacement route owned by another connection.
func (p *VirtualNetPlugin) cleanupControllerConn(xl *xlog.Logger, controllerConn net.Conn) {
p.mu.Lock()
defer p.mu.Unlock()
if p.controllerConn != nil {
xl.Debugf("cleaning up controllerConn for visitor [%s]", p.pluginCtx.Name)
p.controllerConn.Close()
p.controllerConn = nil
p.cleanupControllerConnLocked(xl, controllerConn)
}
func (p *VirtualNetPlugin) cleanupCurrentControllerConn(xl *xlog.Logger) {
p.mu.Lock()
defer p.mu.Unlock()
p.cleanupControllerConnLocked(xl, p.controllerConn)
}
// cleanupControllerConnLocked must be called with p.mu held.
func (p *VirtualNetPlugin) cleanupControllerConnLocked(xl *xlog.Logger, controllerConn net.Conn) {
if controllerConn == nil {
p.closeSignal = nil
return
}
if p.routeController != nil &&
p.routeController.UnregisterClientRoute(p.pluginCtx.Name, controllerConn) {
xl.Infof("unregistered client route for visitor [%s]", p.pluginCtx.Name)
}
xl.Debugf("cleaning up controllerConn for visitor [%s]", p.pluginCtx.Name)
_ = controllerConn.Close()
if p.controllerConn == controllerConn {
p.controllerConn = nil
p.closeSignal = nil
}
p.closeSignal = nil
}
// Close initiates the plugin shutdown.
@@ -202,15 +265,9 @@ func (p *VirtualNetPlugin) Close() error {
// Signal the run loop goroutine to stop.
p.cancel()
// Unregister the route from the controller.
if p.pluginCtx.VnetController != nil {
p.pluginCtx.VnetController.UnregisterClientRoute(p.pluginCtx.Name)
xl.Infof("unregistered client route for visitor [%s]", p.pluginCtx.Name)
}
// Explicitly close the controller side of the pipe.
// This ensures the pipe is broken even if the run loop is stuck or the visitor hasn't closed its end.
p.cleanupControllerConn(xl)
// Unregister and close the current connection while holding the same lock
// used to check cancellation and register a route in run.
p.cleanupCurrentControllerConn(xl)
xl.Infof("finished cleaning up connections during close for visitor [%s]", p.pluginCtx.Name)
return nil
+245
View File
@@ -0,0 +1,245 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !frps
package visitor
import (
"context"
"io"
"net"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/util/xlog"
)
const testVirtualNetVisitorName = "vnet-visitor"
type fakeClientRouteController struct {
mu sync.Mutex
routes map[string]io.Writer
beforeRegister func()
registerCalls int
unregisterCalls int
}
func newFakeClientRouteController() *fakeClientRouteController {
return &fakeClientRouteController{
routes: make(map[string]io.Writer),
}
}
func (c *fakeClientRouteController) RegisterClientRoute(
_ context.Context,
name string,
_ []net.IPNet,
conn io.ReadWriteCloser,
) {
if c.beforeRegister != nil {
c.beforeRegister()
}
c.mu.Lock()
defer c.mu.Unlock()
c.registerCalls++
c.routes[name] = conn
}
func (c *fakeClientRouteController) UnregisterClientRoute(name string, conn io.Writer) bool {
c.mu.Lock()
defer c.mu.Unlock()
c.unregisterCalls++
owner, ok := c.routes[name]
if !ok || owner != conn {
return false
}
delete(c.routes, name)
return true
}
func (c *fakeClientRouteController) owner() io.Writer {
c.mu.Lock()
defer c.mu.Unlock()
return c.routes[testVirtualNetVisitorName]
}
func (c *fakeClientRouteController) callCounts() (register, unregister int) {
c.mu.Lock()
defer c.mu.Unlock()
return c.registerCalls, c.unregisterCalls
}
type trackedConn struct {
net.Conn
closed atomic.Bool
}
func (c *trackedConn) Close() error {
c.closed.Store(true)
return c.Conn.Close()
}
func newTrackedPipe(t *testing.T) (*trackedConn, *trackedConn) {
t.Helper()
left, right := net.Pipe()
trackedLeft := &trackedConn{Conn: left}
trackedRight := &trackedConn{Conn: right}
t.Cleanup(func() {
_ = trackedLeft.Close()
_ = trackedRight.Close()
})
return trackedLeft, trackedRight
}
func newTestVirtualNetPlugin(t *testing.T, controller *fakeClientRouteController) *VirtualNetPlugin {
t.Helper()
pluginCtx := context.Background()
ctx, cancel := context.WithCancel(pluginCtx)
p := &VirtualNetPlugin{
pluginCtx: PluginContext{
Name: testVirtualNetVisitorName,
Ctx: pluginCtx,
},
routeController: controller,
routes: []net.IPNet{{
IP: net.ParseIP("10.1.0.1"),
Mask: net.CIDRMask(32, 32),
}},
ctx: ctx,
cancel: cancel,
}
t.Cleanup(func() {
_ = p.Close()
})
return p
}
func waitResult[T any](t *testing.T, ch <-chan T) T {
t.Helper()
select {
case result := <-ch:
return result
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for concurrent operation")
var zero T
return zero
}
}
// TestVirtualNetReconnectDelay verifies the documented exponential backoff and
// ensures large error counts remain capped instead of overflowing to zero.
func TestVirtualNetReconnectDelay(t *testing.T) {
tests := []struct {
name string
consecutiveErrors int
want time.Duration
}{
{name: "first error", consecutiveErrors: 1, want: 60 * time.Second},
{name: "second error", consecutiveErrors: 2, want: 120 * time.Second},
{name: "third error", consecutiveErrors: 3, want: 240 * time.Second},
{name: "fourth error", consecutiveErrors: 4, want: 300 * time.Second},
{name: "shift width boundary", consecutiveErrors: 64, want: 300 * time.Second},
{name: "observed retry storm", consecutiveErrors: 329769, want: 300 * time.Second},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, virtualNetReconnectDelay(tt.consecutiveErrors))
})
}
}
func TestVirtualNetPluginCloseBeforeRegisterDoesNotReplaceNewRoute(t *testing.T) {
controller := newFakeClientRouteController()
oldPlugin := newTestVirtualNetPlugin(t, controller)
newPlugin := newTestVirtualNetPlugin(t, controller)
oldControllerConn, oldPluginConn := newTrackedPipe(t)
newControllerConn, newPluginConn := newTrackedPipe(t)
allowOldRegister := make(chan struct{})
oldRegisterResult := make(chan bool, 1)
go func() {
<-allowOldRegister
oldRegisterResult <- oldPlugin.registerControllerConn(oldControllerConn, oldPluginConn)
}()
require.NoError(t, oldPlugin.Close())
require.True(t, newPlugin.registerControllerConn(newControllerConn, newPluginConn))
close(allowOldRegister)
require.False(t, waitResult(t, oldRegisterResult))
require.Same(t, newControllerConn, controller.owner())
registerCalls, _ := controller.callCounts()
require.Equal(t, 1, registerCalls)
require.True(t, oldControllerConn.closed.Load())
require.True(t, oldPluginConn.closed.Load())
}
func TestVirtualNetPluginRegisterBeforeCloseIsCleanedUp(t *testing.T) {
controller := newFakeClientRouteController()
p := newTestVirtualNetPlugin(t, controller)
controllerConn, pluginConn := newTrackedPipe(t)
registerEntered := make(chan struct{})
var registerEnteredOnce sync.Once
controller.beforeRegister = func() {
registerEnteredOnce.Do(func() {
close(registerEntered)
})
<-p.ctx.Done()
}
registerResult := make(chan bool, 1)
go func() {
registerResult <- p.registerControllerConn(controllerConn, pluginConn)
}()
waitResult(t, registerEntered)
closeResult := make(chan error, 1)
go func() {
closeResult <- p.Close()
}()
require.NoError(t, waitResult(t, closeResult))
require.True(t, waitResult(t, registerResult))
require.Nil(t, controller.owner())
registerCalls, unregisterCalls := controller.callCounts()
require.Equal(t, 1, registerCalls)
require.Equal(t, 1, unregisterCalls)
require.True(t, controllerConn.closed.Load())
}
func TestVirtualNetPluginOldConnectionCleanupKeepsReplacementRoute(t *testing.T) {
controller := newFakeClientRouteController()
oldPlugin := newTestVirtualNetPlugin(t, controller)
newPlugin := newTestVirtualNetPlugin(t, controller)
oldControllerConn, oldPluginConn := newTrackedPipe(t)
newControllerConn, newPluginConn := newTrackedPipe(t)
require.True(t, oldPlugin.registerControllerConn(oldControllerConn, oldPluginConn))
require.True(t, newPlugin.registerControllerConn(newControllerConn, newPluginConn))
require.Same(t, newControllerConn, controller.owner())
oldPlugin.cleanupControllerConn(xlog.FromContextSafe(oldPlugin.ctx), oldControllerConn)
require.Same(t, newControllerConn, controller.owner())
require.True(t, oldControllerConn.closed.Load())
}
+7 -3
View File
@@ -63,9 +63,13 @@ func ForwardUserConn(udpConn *net.UDPConn, readCh <-chan *msg.UDPPacket, sendCh
// NewUDPPacket copies buf[:n], so the read buffer can be reused
udpMsg := NewUDPPacket(buf[:n], nil, remoteAddr)
select {
case sendCh <- udpMsg:
default:
if err = errors.PanicToError(func() {
select {
case sendCh <- udpMsg:
default:
}
}); err != nil {
return
}
}
}
+34
View File
@@ -1,9 +1,13 @@
package udp
import (
"net"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/msg"
)
func TestUdpPacket(t *testing.T) {
@@ -16,3 +20,33 @@ func TestUdpPacket(t *testing.T) {
require.NoError(err)
require.EqualValues(buf, newBuf)
}
func TestForwardUserConnReturnsWhenSendChannelIsClosed(t *testing.T) {
listener, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
require.NoError(t, err)
t.Cleanup(func() { _ = listener.Close() })
readCh := make(chan *msg.UDPPacket)
sendCh := make(chan *msg.UDPPacket)
close(sendCh)
t.Cleanup(func() { close(readCh) })
done := make(chan struct{})
go func() {
ForwardUserConn(listener, readCh, sendCh, 1500)
close(done)
}()
sender, err := net.DialUDP("udp4", nil, listener.LocalAddr().(*net.UDPAddr))
require.NoError(t, err)
t.Cleanup(func() { _ = sender.Close() })
_, err = sender.Write([]byte("trigger"))
require.NoError(t, err)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("ForwardUserConn did not return after sending to a closed channel")
}
}
+18 -1
View File
@@ -68,7 +68,8 @@ func NewServerHello(clientHello ClientHello) (ServerHello, error) {
return ServerHello{
Selected: ServerSelection{
Message: MessageSelection{
Codec: MessageCodecJSON,
Codec: MessageCodecJSON,
UDPPacketCodec: selectUDPPacketCodec(clientHello.Capabilities.Message.UDPPacketCodecs),
},
Crypto: CryptoSelection{
Algorithm: algorithm,
@@ -92,6 +93,15 @@ func ValidateServerHelloForClient(clientHello ClientHello, serverHello ServerHel
if serverHello.Selected.Message.Codec != MessageCodecJSON {
return fmt.Errorf("unsupported selected message codec: %s", serverHello.Selected.Message.Codec)
}
udpPacketCodec := serverHello.Selected.Message.UDPPacketCodec
if udpPacketCodec != "" {
if udpPacketCodec != UDPPacketCodecBinary {
return fmt.Errorf("unsupported selected UDP packet codec: %s", udpPacketCodec)
}
if !Supports(clientHello.Capabilities.Message.UDPPacketCodecs, udpPacketCodec) {
return fmt.Errorf("selected UDP packet codec was not advertised by client: %s", udpPacketCodec)
}
}
cryptoSelection := serverHello.Selected.Crypto
if !IsSupportedAEADAlgorithm(cryptoSelection.Algorithm) {
return fmt.Errorf("unknown selected crypto algorithm: %s", cryptoSelection.Algorithm)
@@ -105,6 +115,13 @@ func ValidateServerHelloForClient(clientHello ClientHello, serverHello ServerHel
return nil
}
func selectUDPPacketCodec(codecs []string) string {
if Supports(codecs, UDPPacketCodecBinary) {
return UDPPacketCodecBinary
}
return ""
}
func NewCryptoContext(algorithm string, clientHelloPayload, serverHelloPayload []byte) *CryptoContext {
return &CryptoContext{
Algorithm: algorithm,
+7 -3
View File
@@ -36,6 +36,7 @@ const (
FrameTypeMessage uint16 = 16
MessageCodecJSON = "json"
UDPPacketCodecBinary = "binary-v1"
DefaultMaxFramePayloadSize = 64 * 1024
MagicV2 = "FRP\x00\x02\r\n"
@@ -182,7 +183,8 @@ type ClientCapabilities struct {
}
type MessageCapabilities struct {
Codecs []string `json:"codecs,omitempty"`
Codecs []string `json:"codecs,omitempty"`
UDPPacketCodecs []string `json:"udpPacketCodecs,omitempty"`
}
type CryptoCapabilities struct {
@@ -201,7 +203,8 @@ type ServerSelection struct {
}
type MessageSelection struct {
Codec string `json:"codec,omitempty"`
Codec string `json:"codec,omitempty"`
UDPPacketCodec string `json:"udpPacketCodec,omitempty"`
}
type CryptoSelection struct {
@@ -214,7 +217,8 @@ func clientHelloWithCryptoRandom(bootstrap BootstrapInfo, clientRandom []byte) C
Bootstrap: bootstrap,
Capabilities: ClientCapabilities{
Message: MessageCapabilities{
Codecs: []string{MessageCodecJSON},
Codecs: []string{MessageCodecJSON},
UDPPacketCodecs: []string{UDPPacketCodecBinary},
},
Crypto: CryptoCapabilities{
Algorithms: PreferredAEADAlgorithms(),
+30
View File
@@ -148,10 +148,40 @@ func TestNewServerHelloSelectsFirstSupportedAEADAlgorithm(t *testing.T) {
serverHello, err := NewServerHello(hello)
require.NoError(t, err)
require.Equal(t, MessageCodecJSON, serverHello.Selected.Message.Codec)
require.Equal(t, UDPPacketCodecBinary, serverHello.Selected.Message.UDPPacketCodec)
require.Equal(t, AEADAlgorithmXChaCha20Poly1305, serverHello.Selected.Crypto.Algorithm)
require.Len(t, serverHello.Selected.Crypto.ServerRandom, CryptoRandomSize)
}
func TestUDPPacketCodecNegotiationFallbackAndValidation(t *testing.T) {
hello := mustClientHello(t, BootstrapInfo{})
serverHello, err := NewServerHello(hello)
require.NoError(t, err)
require.Equal(t, UDPPacketCodecBinary, serverHello.Selected.Message.UDPPacketCodec)
require.NoError(t, ValidateServerHelloForClient(hello, serverHello))
legacyHello := hello
legacyHello.Capabilities.Message.UDPPacketCodecs = nil
legacyServerHello, err := NewServerHello(legacyHello)
require.NoError(t, err)
require.Empty(t, legacyServerHello.Selected.Message.UDPPacketCodec)
require.NoError(t, ValidateServerHelloForClient(legacyHello, legacyServerHello))
unknownOffer := hello
unknownOffer.Capabilities.Message.UDPPacketCodecs = []string{"unknown"}
unknownServerHello, err := NewServerHello(unknownOffer)
require.NoError(t, err)
require.Empty(t, unknownServerHello.Selected.Message.UDPPacketCodec)
rejected := serverHello
rejected.Selected.Message.UDPPacketCodec = "unknown"
require.ErrorContains(t, ValidateServerHelloForClient(hello, rejected), "unsupported selected UDP packet codec")
unadvertised := serverHello
unadvertised.Selected.Message.UDPPacketCodec = UDPPacketCodecBinary
require.ErrorContains(t, ValidateServerHelloForClient(legacyHello, unadvertised), "was not advertised")
}
func TestNewClientCryptoContextValidatesServerHello(t *testing.T) {
hello := mustClientHello(t, BootstrapInfo{})
serverHello, err := NewServerHello(hello)
+21 -5
View File
@@ -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()
+115
View File
@@ -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")
}
}
+30
View File
@@ -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})
}
}
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package limit
import (
"fmt"
"golang.org/x/time/rate"
)
// NewBandwidthLimiter creates a limiter whose rate preserves the configured
// byte limit while keeping the burst representable as an int on all targets.
func NewBandwidthLimiter(bytes int64) *rate.Limiter {
if bytes <= 0 {
return nil
}
maxInt := int64(^uint(0) >> 1)
burst := min(bytes, maxInt)
return rate.NewLimiter(rate.Limit(float64(bytes)), int(burst))
}
func invalidBurstError(burst int) error {
return fmt.Errorf("invalid limiter burst: %d", burst)
}
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package limit
import (
"bytes"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/time/rate"
)
func TestNewBandwidthLimiterClampsBurstToTargetInt(t *testing.T) {
const bytesPerSecond = int64(1 << 31)
limiter := NewBandwidthLimiter(bytesPerSecond)
require.NotNil(t, limiter)
wantBurst := bytesPerSecond
maxInt := int64(^uint(0) >> 1)
if wantBurst > maxInt {
wantBurst = maxInt
}
require.Equal(t, int(wantBurst), limiter.Burst())
require.Equal(t, rate.Limit(float64(bytesPerSecond)), limiter.Limit())
}
func TestNewBandwidthLimiterDisablesNonPositiveLimit(t *testing.T) {
require.Nil(t, NewBandwidthLimiter(0))
require.Nil(t, NewBandwidthLimiter(-1))
}
func TestReaderAndWriterRejectInvalidBurst(t *testing.T) {
for _, burst := range []int{0, -1} {
t.Run("reader/"+strconv.Itoa(burst), func(t *testing.T) {
reader := NewReader(strings.NewReader("payload"), rate.NewLimiter(rate.Limit(1), burst))
n, err := reader.Read(make([]byte, 1))
require.Zero(t, n)
require.EqualError(t, err, "invalid limiter burst: "+strconv.Itoa(burst))
})
t.Run("writer/"+strconv.Itoa(burst), func(t *testing.T) {
var dst bytes.Buffer
writer := NewWriter(&dst, rate.NewLimiter(rate.Limit(1), burst))
n, err := writer.Write([]byte("payload"))
require.Zero(t, n)
require.EqualError(t, err, "invalid limiter burst: "+strconv.Itoa(burst))
require.Empty(t, dst.Bytes())
})
}
}
+6
View File
@@ -35,6 +35,12 @@ func NewReader(r io.Reader, limiter *rate.Limiter) *Reader {
func (r *Reader) Read(p []byte) (n int, err error) {
b := r.limiter.Burst()
if b <= 0 {
if len(p) == 0 {
return 0, nil
}
return 0, invalidBurstError(b)
}
if b < len(p) {
p = p[:b]
}
+7
View File
@@ -34,8 +34,15 @@ func NewWriter(w io.Writer, limiter *rate.Limiter) *Writer {
}
func (w *Writer) Write(p []byte) (n int, err error) {
if len(p) == 0 {
return 0, nil
}
var nn int
b := w.limiter.Burst()
if b <= 0 {
return 0, invalidBurstError(b)
}
for {
end := len(p)
if end == 0 {
+6 -5
View File
@@ -58,7 +58,7 @@ func newStandardDateCounterWithClock(reserveDays int64, clk clock.PassiveClock)
reserveDays: reserveDays,
counts: make([]int64, reserveDays),
clock: clk,
lastUpdateDate: startOfDay(clk.Now()),
lastUpdateDate: calendarDate(clk.Now()),
}
}
@@ -114,7 +114,7 @@ func (c *StandardDateCounter) Clear() {
// rotate
// Must hold the lock before calling this function.
func (c *StandardDateCounter) rotate(now time.Time) {
now = startOfDay(now)
now = calendarDate(now)
days := int(now.Sub(c.lastUpdateDate).Hours() / 24)
reserveDays := int(c.reserveDays)
@@ -132,7 +132,8 @@ func (c *StandardDateCounter) rotate(now time.Time) {
c.lastUpdateDate = now
}
// startOfDay returns midnight in t's location.
func startOfDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
// calendarDate represents t's local date as midnight UTC for day arithmetic.
// This avoids variable day lengths and nonexistent local midnights during DST.
func calendarDate(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
+64 -4
View File
@@ -31,7 +31,7 @@ func TestDateCounter(t *testing.T) {
func TestDateCounterRotate(t *testing.T) {
loc := time.FixedZone("test", 8*60*60)
lastUpdateDate := time.Date(2026, time.May, 8, 0, 0, 0, 0, loc)
lastUpdateDate := time.Date(2026, time.May, 8, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
@@ -55,19 +55,19 @@ func TestDateCounterRotate(t *testing.T) {
name: "one day",
now: time.Date(2026, time.May, 9, 12, 30, 0, 0, loc),
want: []int64{0, 10, 7},
wantLastUpdateDate: time.Date(2026, time.May, 9, 0, 0, 0, 0, loc),
wantLastUpdateDate: time.Date(2026, time.May, 9, 0, 0, 0, 0, time.UTC),
},
{
name: "two days",
now: time.Date(2026, time.May, 10, 12, 30, 0, 0, loc),
want: []int64{0, 0, 10},
wantLastUpdateDate: time.Date(2026, time.May, 10, 0, 0, 0, 0, loc),
wantLastUpdateDate: time.Date(2026, time.May, 10, 0, 0, 0, 0, time.UTC),
},
{
name: "all reserved days elapsed",
now: time.Date(2026, time.May, 11, 12, 30, 0, 0, loc),
want: []int64{0, 0, 0},
wantLastUpdateDate: time.Date(2026, time.May, 11, 0, 0, 0, 0, loc),
wantLastUpdateDate: time.Date(2026, time.May, 11, 0, 0, 0, 0, time.UTC),
},
}
@@ -132,3 +132,63 @@ func TestDateCounterConcurrentAccess(t *testing.T) {
}
wg.Wait()
}
func TestDateCounterCalendarDaysAcrossDST(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
require.NoError(t, err)
midnightLoc, err := time.LoadLocation("America/Sao_Paulo")
require.NoError(t, err)
tests := []struct {
name string
start time.Time
end time.Time
want []int64
}{
{
name: "spring next day",
start: time.Date(2026, time.March, 8, 12, 0, 0, 0, loc),
end: time.Date(2026, time.March, 9, 12, 0, 0, 0, loc),
want: []int64{0, 10, 0},
},
{
name: "spring two days",
start: time.Date(2026, time.March, 8, 12, 0, 0, 0, loc),
end: time.Date(2026, time.March, 10, 12, 0, 0, 0, loc),
want: []int64{0, 0, 10},
},
{
name: "spring retention expiry",
start: time.Date(2026, time.March, 8, 12, 0, 0, 0, loc),
end: time.Date(2026, time.March, 11, 12, 0, 0, 0, loc),
want: []int64{0, 0, 0},
},
{
name: "fall next day",
start: time.Date(2026, time.November, 1, 12, 0, 0, 0, loc),
end: time.Date(2026, time.November, 2, 12, 0, 0, 0, loc),
want: []int64{0, 10, 0},
},
{
name: "skipped midnight",
start: time.Date(2018, time.November, 3, 12, 0, 0, 0, midnightLoc),
end: time.Date(2018, time.November, 4, 12, 0, 0, 0, midnightLoc),
want: []int64{0, 10, 0},
},
{
name: "day after skipped midnight",
start: time.Date(2018, time.November, 4, 12, 0, 0, 0, midnightLoc),
end: time.Date(2018, time.November, 5, 12, 0, 0, 0, midnightLoc),
want: []int64{0, 10, 0},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clk := clocktesting.NewFakeClock(tt.start)
dc := newStandardDateCounterWithClock(3, clk)
dc.Inc(10)
clk.SetTime(tt.end)
require.Equal(t, tt.want[0], dc.TodayCount())
require.Equal(t, tt.want, dc.GetLastDaysCount(3))
})
}
}
+3 -8
View File
@@ -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)
}
+6
View File
@@ -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)
}
+5
View File
@@ -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
}
}
+28 -4
View File
@@ -4,6 +4,7 @@ import (
"errors"
"net"
"net/http"
"sync"
"time"
"golang.org/x/net/websocket"
@@ -18,8 +19,11 @@ const (
type WebsocketListener struct {
ln net.Listener
acceptCh chan net.Conn
done chan struct{}
server *http.Server
closeOnce sync.Once
}
// NewWebsocketListener to handle websocket connections
@@ -28,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()
}
+197
View File
@@ -0,0 +1,197 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package net
import (
"bufio"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"sync"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/net/websocket"
)
type closeStubListener struct {
closed chan struct{}
once sync.Once
err error
}
func (l *closeStubListener) Accept() (net.Conn, error) {
<-l.closed
return nil, net.ErrClosed
}
func (l *closeStubListener) Close() error {
l.once.Do(func() { close(l.closed) })
return l.err
}
func (l *closeStubListener) Addr() net.Addr {
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}
}
func TestWebsocketListenerAcceptUnblocksOnClose(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
wl := NewWebsocketListener(&closeStubListener{closed: make(chan struct{})})
t.Cleanup(func() { wl.Close() })
errCh := make(chan error, 3)
for range 3 {
go func() {
c, err := wl.Accept()
if c != nil {
t.Error("Accept returned a connection without a sender")
}
errCh <- err
}()
}
synctest.Wait() // All Accept calls are blocked before Close.
require.Empty(t, errCh)
var wg sync.WaitGroup
for range 3 {
wg.Go(func() {
if err := wl.Close(); err != nil {
t.Errorf("Close: %v", err)
}
})
}
wg.Wait()
synctest.Wait()
require.Len(t, errCh, 3)
for range 3 {
require.ErrorIs(t, <-errCh, ErrWebsocketListenerClosed)
}
c, err := wl.Accept()
require.Nil(t, c)
require.ErrorIs(t, err, ErrWebsocketListenerClosed)
})
}
func TestWebsocketListenerCloseError(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
closeErr := errors.New("listener close failed")
wl := NewWebsocketListener(&closeStubListener{closed: make(chan struct{}), err: closeErr})
synctest.Wait() // Let Serve register its listener before Close.
require.ErrorIs(t, wl.Close(), closeErr)
require.NoError(t, wl.Close())
})
}
type websocketPipeHijacker struct {
*httptest.ResponseRecorder
conn net.Conn
rw *bufio.ReadWriter
}
func (h *websocketPipeHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return h.conn, h.rw, nil
}
func newWebsocketPipe(t *testing.T) (*WebsocketListener, *websocket.Conn, net.Conn, <-chan struct{}) {
t.Helper()
wl := NewWebsocketListener(&closeStubListener{closed: make(chan struct{})})
serverConn, clientConn := net.Pipe()
t.Cleanup(func() {
wl.Close()
serverConn.Close()
clientConn.Close()
})
require.NoError(t, serverConn.SetDeadline(time.Now().Add(time.Second)))
require.NoError(t, clientConn.SetDeadline(time.Now().Add(time.Second)))
handlerDone := make(chan struct{})
go func() {
defer close(handlerDone)
rw := bufio.NewReadWriter(bufio.NewReader(serverConn), bufio.NewWriter(serverConn))
req, err := http.ReadRequest(rw.Reader)
if err != nil {
t.Errorf("ReadRequest: %v", err)
return
}
wl.server.Handler.ServeHTTP(&websocketPipeHijacker{httptest.NewRecorder(), serverConn, rw}, req)
}()
cfg, err := websocket.NewConfig("ws://localhost"+FrpWebsocketPath, "http://localhost")
require.NoError(t, err)
peer, err := websocket.NewClient(cfg, clientConn)
require.NoError(t, err)
return wl, peer, serverConn, handlerDone
}
func TestWebsocketListenerUnacceptedConnectionClose(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
wl, peer, raw, handlerDone := newWebsocketPipe(t)
peerErr := make(chan error, 1)
go func() {
_, err := peer.Read(make([]byte, 1))
peerErr <- err
}()
// No Accept consumer exists. Do not Wait here: it would synchronize
// the handler's send with Close and hide the baseline send/close race.
require.NoError(t, wl.Close())
require.ErrorIs(t, <-peerErr, io.EOF)
<-handlerDone
_, err := raw.Read(make([]byte, 1))
require.ErrorIs(t, err, io.ErrClosedPipe)
})
}
func TestWebsocketListenerAcceptedConnectionOwnership(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
wl, peer, raw, handlerDone := newWebsocketPipe(t)
owner, err := wl.Accept()
require.NoError(t, err)
t.Cleanup(func() {
raw.Close()
owner.Close()
})
require.NoError(t, wl.Close())
payload := []byte{0, 255, 128, 42}
reply := []byte("reply")
gotReply := make([]byte, len(reply))
peerErr := make(chan error, 2)
go func() {
if _, err := peer.Write(payload); err != nil {
peerErr <- err
return
}
_, err := io.ReadFull(peer, gotReply)
peerErr <- err
_, err = peer.Read(make([]byte, 1))
peerErr <- err
}()
got := make([]byte, len(payload))
_, err = io.ReadFull(owner, got)
require.NoError(t, err)
require.Equal(t, payload, got)
_, err = owner.Write(reply)
require.NoError(t, err)
require.NoError(t, <-peerErr)
require.Equal(t, reply, gotReply)
require.NoError(t, owner.Close())
require.NoError(t, owner.Close())
require.ErrorIs(t, <-peerErr, io.EOF)
<-handlerDone
_, err = raw.Read(make([]byte, 1))
require.ErrorIs(t, err, io.ErrClosedPipe)
})
}
+1 -1
View File
@@ -14,7 +14,7 @@
package version
var version = "0.69.1"
var version = "0.71.1"
func Full() string {
return version
+24 -6
View File
@@ -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 {
+329
View File
@@ -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",
+5 -5
View File
@@ -96,21 +96,21 @@ func (l *Logger) Spawn() *Logger {
}
func (l *Logger) Errorf(format string, v ...any) {
log.Logger.Errorf(l.prefixString+format, v...)
log.Logger.WithPrefix(l.prefixString).Errorf(format, v...)
}
func (l *Logger) Warnf(format string, v ...any) {
log.Logger.Warnf(l.prefixString+format, v...)
log.Logger.WithPrefix(l.prefixString).Warnf(format, v...)
}
func (l *Logger) Infof(format string, v ...any) {
log.Logger.Infof(l.prefixString+format, v...)
log.Logger.WithPrefix(l.prefixString).Infof(format, v...)
}
func (l *Logger) Debugf(format string, v ...any) {
log.Logger.Debugf(l.prefixString+format, v...)
log.Logger.WithPrefix(l.prefixString).Debugf(format, v...)
}
func (l *Logger) Tracef(format string, v ...any) {
log.Logger.Tracef(l.prefixString+format, v...)
log.Logger.WithPrefix(l.prefixString).Tracef(format, v...)
}
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package xlog
import (
"bytes"
"testing"
goliblog "github.com/fatedier/golib/log"
"github.com/stretchr/testify/require"
frplog "github.com/fatedier/frp/pkg/util/log"
)
func TestPrefixIsNotPartOfFormatString(t *testing.T) {
tests := []struct {
name string
log func(*Logger)
}{
{name: "error", log: func(xl *Logger) { xl.Errorf("value [%s]", "ok") }},
{name: "warn", log: func(xl *Logger) { xl.Warnf("value [%s]", "ok") }},
{name: "info", log: func(xl *Logger) { xl.Infof("value [%s]", "ok") }},
{name: "debug", log: func(xl *Logger) { xl.Debugf("value [%s]", "ok") }},
{name: "trace", log: func(xl *Logger) { xl.Tracef("value [%s]", "ok") }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := captureLogs(t)
tt.log(New().AppendPrefix("%1000000s"))
require.Contains(t, output.String(), "[%1000000s] value [ok]")
require.Less(t, output.Len(), 1024)
})
}
}
func TestFormattingSemanticsArePreserved(t *testing.T) {
output := captureLogs(t)
xl := New().AppendPrefix("run")
xl.Infof("%[2]s %[1]s", "first", "second")
xl.Infof("100% complete")
require.Contains(t, output.String(), "[run] second first")
require.Contains(t, output.String(), "[run] 100% complete")
}
func captureLogs(t *testing.T) *bytes.Buffer {
t.Helper()
output := bytes.NewBuffer(nil)
oldLogger := frplog.Logger
frplog.Logger = goliblog.New(
goliblog.WithOutput(output),
goliblog.WithLevel(goliblog.TraceLevel),
goliblog.WithCaller(false),
)
t.Cleanup(func() {
frplog.Logger = oldLogger
})
return output
}
+9 -4
View File
@@ -246,9 +246,9 @@ func (c *Controller) RegisterClientRoute(ctx context.Context, name string, route
go c.readLoopClient(ctx, conn)
}
// UnregisterClientRoute Remove client route from routing table
func (c *Controller) UnregisterClientRoute(name string) {
c.clientRouter.delRoute(name)
// UnregisterClientRoute removes a client route only when it is still owned by conn.
func (c *Controller) UnregisterClientRoute(name string, conn io.Writer) bool {
return c.clientRouter.delRoute(name, conn)
}
// StartServerConnReadLoop starts the read loop for a server connection
@@ -304,10 +304,15 @@ func (r *clientRouter) findConn(dst net.IP) (io.Writer, error) {
return nil, fmt.Errorf("no route found for destination %s", dst)
}
func (r *clientRouter) delRoute(name string) {
func (r *clientRouter) delRoute(name string, conn io.Writer) bool {
r.mu.Lock()
defer r.mu.Unlock()
re, ok := r.routes[name]
if !ok || re.conn != conn {
return false
}
delete(r.routes, name)
return true
}
func (r *clientRouter) removeConnRoute(conn io.Writer) {
+64
View File
@@ -0,0 +1,64 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package vnet
import (
"net"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// TestClientRouterDeleteRouteRequiresMatchingConnection verifies that a stale
// visitor cannot remove a replacement route registered under the same name.
func TestClientRouterDeleteRouteRequiresMatchingConnection(t *testing.T) {
require := require.New(t)
controller := NewController(v1.VirtualNetConfig{})
_, route, err := net.ParseCIDR("10.1.0.1/32")
require.NoError(err)
oldConn, oldPeer := net.Pipe()
t.Cleanup(func() {
_ = oldConn.Close()
_ = oldPeer.Close()
})
replacementConn, replacementPeer := net.Pipe()
t.Cleanup(func() {
_ = replacementConn.Close()
_ = replacementPeer.Close()
})
controller.clientRouter.addRoute("vnet-visitor", []net.IPNet{*route}, oldConn)
controller.clientRouter.addRoute("vnet-visitor", []net.IPNet{*route}, replacementConn)
require.False(controller.UnregisterClientRoute("vnet-visitor", oldConn))
got, err := controller.clientRouter.findConn(net.ParseIP("10.1.0.1"))
require.NoError(err)
require.Same(replacementConn, got)
// The read loop for an old connection can exit after a replacement route
// has already been registered. Its deferred cleanup must keep the new owner.
controller.clientRouter.removeConnRoute(oldConn)
got, err = controller.clientRouter.findConn(net.ParseIP("10.1.0.1"))
require.NoError(err)
require.Same(replacementConn, got)
require.True(controller.UnregisterClientRoute("vnet-visitor", replacementConn))
_, err = controller.clientRouter.findConn(net.ParseIP("10.1.0.1"))
require.Error(err)
}
+11
View File
@@ -48,6 +48,17 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
subRouter.HandleFunc("/api/v2/system/info", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemInfo)).Methods("GET")
subRouter.HandleFunc("/api/v2/system/prune", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2SystemPrune)).Methods("POST")
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
v2EncodedPathRouter.UseEncodedPath()
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}/traffic", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyTraffic)).Methods("GET")
v2EncodedPathRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")
// view
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
subRouter.PathPrefix("/static/").Handler(

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