Compare commits

...
37 Commits
Author SHA1 Message Date
fatedierandGitHub 93b52799bb fix(xlog): make Logger prefix updates safe for concurrent logging (#5586)
Logger kept its rendered prefix in a plain string field that was written by
AddPrefix, AppendPrefix and ResetPrefixes and read without synchronization by
Tracef, Spawn and the other logging methods. In frpc the race is reachable on
the service-level logger, which every component of a session shares through
its context: adding the run ID after a login writes the prefix while control,
proxy manager, connector and visitor goroutines keep logging through the same
logger, and a reload that creates proxies calls Spawn on it. A torn string
header can pair a nil data pointer with a non-zero length, and formatting such
a prefix can crash the process when the log call's level is enabled.

Serialize the writers with a per-Logger mutex and publish the rendered prefix
as an immutable string through an atomic pointer, so the log path takes no
lock. Spawn copies the parent prefix list under the same mutex, keeping
children independent snapshots. Logging output is unchanged.
2026-10-23 15:52:11 +08:00
fatedierandGitHub 8d5e9d2b21 fix(client): use generation-local channels in UDPProxy.InWorkConn (#5585)
InWorkConn published readCh and sendCh into pxy fields while holding
pxy.mu, then read those same fields again after unlocking to start the
sender, reader and heartbeat goroutines and to call udp.Forwarder. Two
work connections installed concurrently made that unlocked read race
with the next generation's write to the same fields.

Create the channels as locals, publish them under the existing lock,
and use only those locals for the goroutines and the forwarder. The
fields stay guarded by pxy.mu, so Close still closes the current
generation. No behavior change for a single generation.

Add TestUDPProxyInWorkConnGenerationChannels, which drives two
InWorkConn calls concurrently so the race detector covers the field
accesses.
2026-10-23 14:03:25 +08:00
fatedierandGitHub a73e8a14fc docs: track deprecated observability endpoints on webServer (#5582)
Record the deprecation of the observability endpoints on webServer in
doc/deprecations.md, so the removal target is in one place next to the
other active deprecations.

The webServer /healthz, the frps webServer /metrics with its top-level
enablePrometheus switch, and the pprof handlers with webServer.pprofEnable
are deprecated since v0.72.0, when metricsServer was added. They are
replaced by metricsServer, whose /healthz, /metrics and pprofEnable are
independent of the dashboard listener.
2026-10-20 16:51:31 +08:00
fatedierandGitHub 69ec4f0145 feat(client): serve Go runtime metrics on metricsServer (#5579)
Add pkg/metrics/text, a small Prometheus text-format writer, and use it
for frpc's /metrics. The endpoint lives only on metricsServer and is
covered by its Basic Auth, like the other endpoints there.

The package emits the Go runtime families and, on Linux, the process
families read from /proc. Their names, types and help strings match
client_golang's default collectors, so existing Go runtime dashboards
keep working. It deliberately emits no frp_ business metrics and none of
client_golang's promhttp_ handler metrics.

Writing the subset ourselves keeps client_golang and protobuf out of the
frpc binary; both are still used by frps and by the tests here.
2026-10-20 16:13:38 +08:00
fatedierandGitHub b0bf32571a ci: pin checkout, setup-go and setup-node to v7 commit SHAs (#5581) 2026-10-20 11:22:34 +08:00
fatedierandGitHub f13fdd58d4 feat: add metricsServer for health checks, metrics and pprof (#5578)
Add a separate HTTP listener for observability, configured through
metricsServer in both frps and frpc. It serves /healthz and, when
pprofEnable is set, the pprof handlers except /debug/pprof/cmdline. It
never serves the dashboard, static assets or the admin API, and it does
not touch the global assets filesystem.

frps also serves /metrics on this listener with both runtime and business
metrics, enabled by metricsServer.port alone. The in-memory collector
still starts only with the dashboard, and the Prometheus collector starts
either with metricsServer or with enablePrometheus plus webServer, so a
configuration that collected nothing before still collects nothing.

The /healthz, /metrics and pprof endpoints on webServer, and
enablePrometheus, keep working but are deprecated and log a hint at
startup. frpc does not serve /metrics yet.
2026-10-19 20:08:39 +08:00
fatedierandGitHub 00e4fc4dd3 feat(client): reload plugin certificates on frpc reload (#5576)
The https2http and https2https plugins read their certificate and key
once when the proxy starts, so a certificate that an external tool renews
on disk is only picked up after the proxy itself is recreated. frpc
reload rebuilt proxies only when their configuration changed, and a
rebuild closes the work connections that are already in use.

Let the plugin own the refresh. The HTTPS plugin server keeps the active
keypair and the last refresh error in one immutable snapshot published
through an atomic pointer, so a TLS handshake only loads that pointer and
never touches the disk. Reload rereads both files, and when their bytes
match the published pair it returns without parsing or replacing
anything; otherwise it parses the pair and swaps the snapshot in. A read
or parse failure keeps the previous certificate serving and records the
error next to it.

frpc reload collects the reloadable plugins of the live proxies and
applies the new configuration inside the same reload transaction that
also serializes control handoff, so a reload that races a reconnect
still refreshes the plugins of the published control. The certificate
files are read only after that transaction has been released. The config
store API keeps applying configuration without refreshing certificates,
because only an explicit reload may reread files. Proxy status reports a
refresh failure next to the still running proxy instead of dropping its
remote address.

No configuration field is added, and frps and the wire protocol are
unchanged. Without a reload nothing changes: certificates are still read
once at startup, generated self-signed certificates still never reload,
and a proxy whose configuration changed still goes through the existing
rebuild path.
2026-10-19 17:16:55 +08:00
fatedierandGitHub 585fdc5b83 fix(client): serialize config updates with control handoff (#5577) 2026-10-19 16:07:07 +08:00
fatedierandGitHub 228d23b3d5 fix: release pending kcp sessions when the listener is closed (#5575)
KCPListener hands accepted sessions to a consumer through an unbuffered
channel, but its accept goroutine wrote to that channel without watching for
shutdown. A Close racing with a handoff left the goroutine blocked forever,
holding the session it was about to deliver, because closing the underlying
listener does not unblock a send. The close flag it read was also written by
Close without synchronisation.

Watch a done channel in the handoff and close the connection that was never
delivered, so the goroutine can exit and the session is released. Accept now
returns ErrKCPListenerClosed as soon as the listener is closed instead of
blocking on the channel, and the close state is a done channel guarded by
sync.Once.

A ready handoff can still win the race with Close; the session then belongs to
the caller of Accept, matching how WebsocketListener behaves.

Tests cover a pending handoff that is cancelled by Close and a blocked Accept
that is woken by it.
2026-10-19 13:20:00 +08:00
fatedierandGitHub 399bf2ce4c fix: release listeners when frps fails to start (#5574)
A constructor that binds a port before its fallible preparation leaks the
listener when a later step returns an error, and no one can close it because
the returned service is nil.

pkg/util/http: load the TLS keypair and build the auth middleware before
net.Listen, so binding is the last step that can run and a failure leaves no
bound port behind.

server: create the web server after auth, the tcpmux, main, kcp, quic, ssh
gateway and vhost listeners, and the nat hole controller, following what frpc
already does. If the web server itself fails, close the resources created so
far before returning. The port managers start their cleanup workers on
creation, so they are created after every fallible step too, and the metrics
collectors keep being enabled only when the web server is configured.

Add regression tests for all three failure paths.
2026-10-19 12:20:25 +08:00
fatedierandGitHub 26742060b1 feat(client): add useSystemCa and combine it with trustedCaFile (#5572)
Move the frpc-only TLS option into the shared TLSConfig as
transport.tls.useSystemCa, replacing the unpublished useSystemRoots.
When set, frpc verifies the server with the system CAs; when
trustedCaFile is also set, its CAs are appended to the system pool so
both are trusted. Failures to load either source abort the connection
instead of falling back. Enabling the option without effective TLS is a
configuration error, and the field is rejected in frps transport.tls
and in both webServer.tls sections.

Fixes #5545
2026-10-18 16:00:02 +08:00
Alexey SviridovandGitHub 7441443134 feat(client): verify server certificates against the system root store (#5546)
transport.tls.trustedCaFile is installed as RootCAs, and omitting it sets
InsecureSkipVerify, so system-root verification cannot be selected directly.
Add an opt-in transport.tls.useSystemRoots that enables certificate
verification with RootCAs nil when no trusted CA file is set, allowing Go to
use system roots and native verification on macOS and Windows. Defaults are
unchanged.
2026-10-18 14:08:05 +08:00
fatedierandGitHub 954f918d0c docs: clear release notes (#5570) 2026-10-15 23:25:45 +08:00
fatedierandGitHub 031716afd1 Merge pull request #5569 from fatedier/fix/docker-workspace-lockfile
fix(docker): install web dependencies from the workspace lockfile
2026-10-15 13:01:59 +08:00
fatedierandGitHub 41ed2aecf8 test: align UDP compatibility protocol boundaries (#5567) 2026-10-14 10:49:55 +08:00
fatedierandGitHub 12b0093ccf chore: prepare v0.71.1 release notes (#5564) 2026-10-13 10:46:29 +08:00
fatedierandGitHub 2542d11a62 fix(net): coordinate websocket handoff with listener shutdown (#5563) 2026-10-10 19:58:05 +08:00
Abdellatif AnaflousandGitHub c4d2db4177 fix(websocket): release a pending Accept when the listener is closed (#5552)
the websocket listener kept its accept channel open on Close, so an Accept call that was already waiting blocked forever

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

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

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

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

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

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

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

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

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


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

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

* test: remove fixed virtual net helper parameters
2026-08-28 10:56:05 +08:00
flyingandGitHub 40bb45d192 vnet: fix route cleanup and reconnect backoff (#5492) 2026-08-26 23:33:45 +08:00
fatedierandGitHub 758f07d59e deps: migrate to fatedier/yamux v0.2.0 (#5498) 2026-08-18 00:24:11 +08:00
fatedierandGitHub 25a4ba306a ci: pin golangci-lint to v2.12.2 (#5496) 2026-08-17 00:48:09 +08:00
91 changed files with 7362 additions and 374 deletions
+5 -4
View File
@@ -1,8 +1,8 @@
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
@@ -16,10 +16,11 @@ jobs:
make env fmt
git diff --exit-code
make build
- run: make alltest
- run:
name: Run all tests
command: make alltest
workflows:
version: 2
build_and_test:
jobs:
- go-version-latest
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
steps:
# environment
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: '0'
+5 -5
View File
@@ -14,12 +14,12 @@ jobs:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.25'
go-version: '1.26'
cache: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
- name: Test and build web assets
@@ -28,4 +28,4 @@ jobs:
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
+4 -4
View File
@@ -8,15 +8,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.25'
- uses: actions/setup-node@v6
go-version: '1.26'
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
- name: Build web assets (frps)
+45 -13
View File
@@ -12,16 +12,6 @@ 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://github.com/beclab/Olares" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
<br>
<b>The sovereign cloud that puts you in control</b>
<br>
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<div align="center">
## Recall.ai - API for meeting recordings
@@ -39,6 +29,17 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
<b>The complete IDE crafted for professional Go developers</b>
</a>
</p>
<p align="center">
<a href="https://github.com/beclab/Olares" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
<br>
<b>The sovereign cloud that puts you in control</b>
<br>
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<!--gold sponsors end-->
## What is frp?
@@ -615,13 +616,31 @@ Proxies and visitors managed through the Store are saved to disk and automatical
When web server is enabled, frps will save monitor data in cache for 7 days. It will be cleared after process restart.
Prometheus is also supported.
Both frps and frpc support a separate `metricsServer` listener for health checks and optional profiling, without enabling the dashboard or admin API. It is disabled by default (`metricsServer.port = 0`) and binds to `127.0.0.1` unless configured otherwise.
`/healthz` returns an empty HTTP 200 response without authentication. It checks that the HTTP server is responding; it does not indicate whether frpc is connected to frps or whether proxies are ready.
#### Prometheus
Enable dashboard first, then configure `enablePrometheus = true` in `frps.toml`.
Configure a dedicated listener in `frps.toml`:
`http://{dashboard_addr}/metrics` will provide prometheus monitor data.
```toml
metricsServer.addr = "127.0.0.1"
metricsServer.port = 7501
metricsServer.user = "monitor"
metricsServer.password = "change-me"
# Optional TLS, independent of webServer:
# metricsServer.tls.certFile = "metrics.crt"
# metricsServer.tls.keyFile = "metrics.key"
# Profiling is disabled unless explicitly enabled:
# metricsServer.pprofEnable = true
```
Scrape `http://127.0.0.1:7501/metrics` using these Basic Auth credentials. This automatically enables frps business metrics, even when `webServer` is disabled and `enablePrometheus` is unset. Credentials and TLS are independent of `webServer`; when credentials are configured, both `/metrics` and profiling endpoints require authentication. `/healthz` remains anonymous.
Set `metricsServer.pprofEnable = true` to expose `/debug/pprof/` and profiling endpoints on this listener. `/debug/pprof/cmdline` is excluded. frpc serves `/metrics` on this listener too, with Go runtime metrics only: no proxy, traffic or client data, and the same set of metrics on every platform.
The `/healthz`, `/metrics`, and pprof endpoints on `webServer` are deprecated but remain available. `enablePrometheus = true` still enables the legacy frps `/metrics` endpoint on the dashboard listener. Migrate health probes, Prometheus scrapes, and profiling to `metricsServer`; the dashboard and admin APIs continue to use `webServer`.
### Authenticating the Client
@@ -711,6 +730,10 @@ transport.tls.keyFile = "certificate.key"
transport.tls.trustedCaFile = "ca.crt"
```
By default, when neither `transport.tls.trustedCaFile` nor `transport.tls.useSystemCa` is set, `frpc` does not verify the server certificate. Set `transport.tls.useSystemCa = true` to verify it using the host's system CAs. If `trustedCaFile` is also set, its CA certificates are added to the system CA pool; setting only `trustedCaFile` trusts only the CAs in that file. CA loading or certificate verification failures cause the connection to fail without falling back to another trust source or skipping verification.
`useSystemCa` is supported only in `frpc`'s `transport.tls`, and requires `transport.tls.enable = true` unless `transport.protocol = "wss"`. With `wss`, the outer TLS connection is always enabled and verification applies to the TLS endpoint, which may be a reverse proxy. Setting `useSystemCa = true` in `frps`'s `transport.tls` or either process's `webServer.tls` or `metricsServer.tls` is a configuration error.
**`frps` TLS settings:**
```toml
@@ -804,6 +827,15 @@ webServer.port = 7400
Then run command `frpc reload -c ./frpc.toml` and wait for about 10 seconds to let `frpc` create or update or remove proxies.
For unchanged proxies using `https2http` or `https2https`, `frpc reload` also rereads
certificate and key files so externally renewed certificates take effect on new
TLS handshakes without interrupting existing connections. Unchanged file contents
leave the certificate untouched. If either file cannot be read or the pair is
invalid, the proxy keeps its previous certificate and reports the error in its
status and logs; reload still succeeds. A successful retry clears the error.
Generated self-signed certificates are unchanged, and Store API updates do not
refresh certificates on unchanged proxies.
**Note that global client parameters won't be modified except 'start'.**
`start` is a global allowlist evaluated after all sources are merged (config file/include/store).
+11 -10
View File
@@ -14,16 +14,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
<h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start-->
<p align="center">
<a href="https://github.com/beclab/Olares" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
<br>
<b>The sovereign cloud that puts you in control</b>
<br>
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<div align="center">
## Recall.ai - API for meeting recordings
@@ -41,6 +31,17 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
<b>The complete IDE crafted for professional Go developers</b>
</a>
</p>
<p align="center">
<a href="https://github.com/beclab/Olares" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
<br>
<b>The sovereign cloud that puts you in control</b>
<br>
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
</a>
</p>
<!--gold sponsors end-->
## 为什么使用 frp ?
+6 -4
View File
@@ -1,9 +1,11 @@
## Features
* UDP packet payloads for ordinary UDP proxies and SUDP now use a dedicated binary codec when frpc and frps successfully negotiate the capability under wire protocol v2, using a more compact wire representation. Wire protocol v1 remains JSON; wire protocol v2 falls back to JSON `UDPPacket` when the peer does not support or did not negotiate the capability.
* frpc can verify server certificates using system CAs, optionally combined with CAs from `trustedCaFile`.
* `frpc reload` now refreshes certificate files for unchanged `https2http` and `https2https` proxies without interrupting existing connections. Failed refreshes keep the previous certificate and report the error in proxy status and logs while reload still succeeds.
* Added `metricsServer` to both frps and frpc, a separate listener for health checks, metrics and optional profiling that does not expose the dashboard or the admin API. It is disabled unless `metricsServer.port` is set, defaults to `127.0.0.1`, and has its own credentials and TLS. Setting the port serves `/metrics` on both: frps exposes runtime and business metrics, so `enablePrometheus` is not needed there, and frpc exposes Go runtime metrics only, without linking an extra metrics library.
The `/healthz`, `/metrics` and pprof endpoints on `webServer`, `webServer.pprofEnable` and `enablePrometheus` still work but are deprecated: configure `metricsServer` instead. They will continue to work until v0.80.0 is released, and may be removed in v0.80.0 or later.
## Fixes
* Fixed a server panic and remote denial of service caused by a client sending a negative `pool_count`. Negative values are now rejected before work-connection pool resources are allocated.
* Fixed `frpc verify` ignoring configured `featureGates`, which caused VirtualNet configurations to be rejected even when the feature was enabled.
* Fixed a case-insensitive validation bypass that allowed `customDomains` under the configured `subDomainHost` to be registered using mixed-case domain names.
* Fixed proxy and visitor configuration changes being lost when reloading frpc during reconnection.
+20 -1
View File
@@ -45,10 +45,29 @@ func (m *serviceConfigManager) ReloadFromFile(strict bool) error {
return fmt.Errorf("%w: %v", configmgmt.ErrInvalidArgument, err)
}
if err := m.svr.UpdateConfigSource(result.Common, result.Proxies, result.Visitors); err != nil {
var reloadPlugins func()
err = func() error {
m.svr.reloadMu.Lock()
defer m.svr.reloadMu.Unlock()
// Snapshot plugins and apply config in the same transaction as control handoff.
m.svr.ctlMu.RLock()
ctl := m.svr.ctl
m.svr.ctlMu.RUnlock()
if ctl != nil {
reloadPlugins = ctl.pm.PreparePluginReload()
}
return m.svr.updateConfigSourceLocked(result.Common, result.Proxies, result.Visitors)
}()
if err != nil {
return fmt.Errorf("%w: %v", configmgmt.ErrApplyConfig, err)
}
// Certificate file I/O must not hold the reload transaction lock.
if reloadPlugins != nil {
reloadPlugins()
}
log.Infof("success reload conf")
return nil
}
+316
View File
@@ -0,0 +1,316 @@
// 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 (
"bufio"
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/client/visitor"
"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/msg"
"github.com/fatedier/frp/pkg/policy/security"
"github.com/fatedier/frp/pkg/transport"
)
type certificateReloadSender struct{ started chan string }
func (s *certificateReloadSender) Send(m msg.Message) error {
if start, ok := m.(*msg.NewProxy); ok {
s.started <- start.ProxyName
}
return nil
}
func writeReloadCertificate(t *testing.T, crtPath, keyPath string) []byte {
t.Helper()
cfg, err := transport.NewServerTLSConfig("", "", "")
require.NoError(t, err)
cert := cfg.Certificates[0]
key, err := x509.MarshalPKCS8PrivateKey(cert.PrivateKey)
require.NoError(t, err)
require.NoError(t, os.WriteFile(crtPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]}), 0o600))
require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}), 0o600))
return cert.Certificate[0]
}
func TestFileReloadCertificatesAndStoreMutations(t *testing.T) {
for _, pluginType := range []string{v1.PluginHTTPS2HTTP, v1.PluginHTTPS2HTTPS} {
t.Run(pluginType, func(t *testing.T) {
dir := t.TempDir()
crtPath, keyPath := filepath.Join(dir, "tls.crt"), filepath.Join(dir, "tls.key")
original := writeReloadCertificate(t, crtPath, keyPath)
configPath := filepath.Join(dir, "frpc.toml")
content := fmt.Sprintf(`[[proxies]]
name = "https"
type = "tcp"
remotePort = 8443
[proxies.plugin]
type = %q
localAddr = "127.0.0.1:80"
crtPath = %q
keyPath = %q
`, pluginType, crtPath, keyPath)
require.NoError(t, os.WriteFile(configPath, []byte(content), 0o600))
result, err := config.LoadClientConfigResult(configPath, true)
require.NoError(t, err)
configSource := source.NewConfigSource()
storeSource, err := source.NewStoreSource(source.StoreSourceConfig{Path: filepath.Join(dir, "store.json")})
require.NoError(t, err)
aggregator := source.NewAggregator(configSource)
aggregator.SetStoreSource(storeSource)
sender := &certificateReloadSender{started: make(chan string, 4)}
transporter := transport.NewMessageTransporter(sender)
ctx := context.Background()
pm := proxy.NewManager(ctx, result.Common, nil, transporter, nil, "")
vm := visitor.NewManager(ctx, "", result.Common, nil, transporter, nil)
t.Cleanup(pm.Close)
t.Cleanup(vm.Close)
svr := &Service{
common: result.Common,
configFilePath: configPath,
unsafeFeatures: security.NewUnsafeFeatures(nil),
aggregator: aggregator,
configSource: configSource,
storeSource: storeSource,
ctl: &Control{pm: pm, vm: vm},
}
require.NoError(t, svr.UpdateConfigSource(result.Common, result.Proxies, nil))
select {
case name := <-sender.started:
require.Equal(t, "https", name)
case <-time.After(5 * time.Second):
t.Fatal("proxy did not request startup")
}
require.NoError(t, pm.StartProxy("https", ":8443", ""))
manager := &serviceConfigManager{svr: svr}
connect := func(wantCert []byte) *tls.Conn {
t.Helper()
clientRaw, serverRaw := net.Pipe()
t.Cleanup(func() { _ = clientRaw.Close(); _ = serverRaw.Close() })
require.NoError(t, clientRaw.SetDeadline(time.Now().Add(5*time.Second)))
pm.HandleWorkConn("https", serverRaw, &msg.StartWorkConn{})
conn := tls.Client(clientRaw, &tls.Config{ServerName: "localhost", InsecureSkipVerify: true})
require.NoError(t, conn.Handshake())
require.Equal(t, wantCert, conn.ConnectionState().PeerCertificates[0].Raw)
return conn
}
oldConn := connect(original)
reader := bufio.NewReader(oldConn)
checkExistingConnection := func() {
t.Helper()
require.NoError(t, oldConn.SetDeadline(time.Now().Add(5*time.Second)))
// A mismatched Host is handled locally, so no upstream network is needed.
_, err := io.WriteString(oldConn, "GET / HTTP/1.1\r\nHost: other.example\r\n\r\n")
require.NoError(t, err)
resp, err := http.ReadResponse(reader, nil)
require.NoError(t, err)
require.Equal(t, http.StatusMisdirectedRequest, resp.StatusCode)
_, err = io.Copy(io.Discard, resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
}
checkExistingConnection()
require.NoError(t, manager.ReloadFromFile(true))
require.NoError(t, connect(original).NetConn().Close())
next := writeReloadCertificate(t, crtPath, keyPath)
// Both store mutation helpers must leave unchanged proxies' certificates alone.
_, err = manager.CreateStoreProxy(result.Proxies[0].Clone())
require.NoError(t, err)
require.NoError(t, connect(original).NetConn().Close())
_, err = manager.UpdateStoreProxy("https", result.Proxies[0].Clone())
require.NoError(t, err)
require.NoError(t, connect(original).NetConn().Close())
require.NoError(t, manager.DeleteStoreProxy("https"))
require.NoError(t, connect(original).NetConn().Close())
require.NoError(t, manager.ReloadFromFile(true))
require.NoError(t, connect(next).NetConn().Close())
checkExistingConnection()
for _, badFile := range []string{"missing", "invalid"} {
saved, err := os.ReadFile(crtPath)
require.NoError(t, err)
if badFile == "missing" {
require.NoError(t, os.Remove(crtPath))
} else {
require.NoError(t, os.WriteFile(crtPath, []byte("invalid"), 0o600))
}
require.NoError(t, manager.ReloadFromFile(true), "certificate errors must not fail reload")
status, ok := pm.GetProxyStatus("https")
require.True(t, ok)
require.Equal(t, proxy.ProxyPhaseRunning, status.Phase)
require.Contains(t, status.Err, "reload plugin ")
require.NoError(t, connect(next).NetConn().Close())
checkExistingConnection()
require.NoError(t, os.WriteFile(crtPath, saved, 0o600))
require.NoError(t, manager.ReloadFromFile(true))
status, _ = pm.GetProxyStatus("https")
require.Empty(t, status.Err, "restoring the active bytes must clear the reload error")
}
// A config change still replaces the proxy and uses normal startup errors.
require.NoError(t, oldConn.NetConn().Close())
require.NoError(t, os.WriteFile(crtPath, []byte("invalid"), 0o600))
changedConfig := strings.Replace(content, "remotePort = 8443", "remotePort = 8444", 1)
require.NoError(t, os.WriteFile(configPath, []byte(changedConfig), 0o600))
require.NoError(t, manager.ReloadFromFile(true))
select {
case name := <-sender.started:
require.Equal(t, "https", name)
case <-time.After(5 * time.Second):
t.Fatal("replacement proxy did not request startup")
}
require.Error(t, pm.StartProxy("https", ":8444", ""))
status, ok := pm.GetProxyStatus("https")
require.True(t, ok)
require.Equal(t, proxy.ProxyPhaseStartErr, status.Phase)
require.NotEmpty(t, status.Err)
})
}
}
func TestFileReloadCertificatesDuringControlHandoff(t *testing.T) {
for _, pluginType := range []string{v1.PluginHTTPS2HTTP, v1.PluginHTTPS2HTTPS} {
t.Run(pluginType, func(t *testing.T) {
dir := t.TempDir()
crtPath, keyPath := filepath.Join(dir, "tls.crt"), filepath.Join(dir, "tls.key")
original := writeReloadCertificate(t, crtPath, keyPath)
configPath := filepath.Join(dir, "frpc.toml")
require.NoError(t, os.WriteFile(configPath, fmt.Appendf(nil, `[[proxies]]
name = "https"
type = "tcp"
remotePort = 8443
[proxies.plugin]
type = %q
localAddr = "127.0.0.1:80"
crtPath = %q
keyPath = %q
`, pluginType, crtPath, keyPath), 0o600))
result, err := config.LoadClientConfigResult(configPath, true)
require.NoError(t, err)
configSource := source.NewConfigSource()
svr := &Service{
common: result.Common,
configFilePath: configPath,
unsafeFeatures: security.NewUnsafeFeatures(nil),
aggregator: source.NewAggregator(configSource),
configSource: configSource,
}
require.NoError(t, svr.UpdateConfigSource(result.Common, result.Proxies, nil))
sender := &certificateReloadSender{started: make(chan string, 1)}
transporter := transport.NewMessageTransporter(sender)
newControl := func() *Control {
ctx := context.Background()
pm := proxy.NewManager(ctx, result.Common, nil, transporter, nil, "")
vm := visitor.NewManager(ctx, "", result.Common, nil, transporter, nil)
t.Cleanup(pm.Close)
t.Cleanup(vm.Close)
return &Control{pm: pm, vm: vm}
}
previous := newControl()
previous.pm.Close()
svr.ctl = previous
next := newControl()
// Model the handoff transaction: start the new control's proxies while
// the published control is still the closed, empty previous manager.
svr.reloadMu.Lock()
releaseTransaction := sync.OnceFunc(svr.reloadMu.Unlock)
defer releaseTransaction()
next.pm.UpdateAll(svr.proxyCfgs)
select {
case name := <-sender.started:
require.Equal(t, "https", name)
case <-time.After(5 * time.Second):
t.Fatal("replacement proxy did not request startup")
}
require.NoError(t, next.pm.StartProxy("https", ":8443", ""))
checkCertificate := func(want []byte) {
t.Helper()
clientRaw, serverRaw := net.Pipe()
defer clientRaw.Close()
defer serverRaw.Close()
require.NoError(t, clientRaw.SetDeadline(time.Now().Add(5*time.Second)))
next.pm.HandleWorkConn("https", serverRaw, &msg.StartWorkConn{})
conn := tls.Client(clientRaw, &tls.Config{ServerName: "localhost", InsecureSkipVerify: true})
require.NoError(t, conn.Handshake())
require.Equal(t, want, conn.ConnectionState().PeerCertificates[0].Raw)
}
checkCertificate(original)
renewed := writeReloadCertificate(t, crtPath, keyPath)
svr.ctlMu.Lock()
releaseControl := sync.OnceFunc(svr.ctlMu.Unlock)
defer releaseControl()
reloadStarted, reloadDone := make(chan struct{}), make(chan struct{})
var reloadErr error
go func() {
close(reloadStarted)
reloadErr = (&serviceConfigManager{svr: svr}).ReloadFromFile(true)
close(reloadDone)
}()
t.Cleanup(func() {
releaseTransaction()
releaseControl()
waitLifecycleSignal(t, reloadDone)
})
waitLifecycleSignal(t, reloadStarted)
svr.ctl = next
releaseTransaction()
// Keep ctlMu as a read barrier after publication. Reload must acquire
// reloadMu before selecting ctl, independently of goroutine scheduling.
// The deadline only detects a broken lock order; it does not stage the race.
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
for svr.reloadMu.TryLock() {
svr.reloadMu.Unlock()
select {
case <-deadline.C:
t.Fatal("reload selected ctl without first acquiring reloadMu")
default:
runtime.Gosched()
}
}
releaseControl()
waitLifecycleSignal(t, reloadDone)
require.NoError(t, reloadErr)
checkCertificate(renewed)
})
}
}
+7 -5
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"
@@ -109,11 +109,12 @@ func (c *defaultConnectorImpl) Open() error {
sn = c.cfg.ServerAddr
}
if lo.FromPtr(c.cfg.Transport.TLS.Enable) {
tlsConfig, err = transport.NewClientTLSConfig(
tlsConfig, err = transport.NewClientTLSConfigWithSystemCA(
c.cfg.Transport.TLS.CertFile,
c.cfg.Transport.TLS.KeyFile,
c.cfg.Transport.TLS.TrustedCaFile,
sn)
sn,
c.cfg.Transport.TLS.UseSystemCa)
} else {
tlsConfig, err = transport.NewClientTLSConfig("", "", "", sn)
}
@@ -194,11 +195,12 @@ func (c *defaultConnectorImpl) realConnect() (net.Conn, error) {
sn = c.cfg.ServerAddr
}
tlsConfig, err = transport.NewClientTLSConfig(
tlsConfig, err = transport.NewClientTLSConfigWithSystemCA(
c.cfg.Transport.TLS.CertFile,
c.cfg.Transport.TLS.KeyFile,
c.cfg.Transport.TLS.TrustedCaFile,
sn)
sn,
c.cfg.Transport.TLS.UseSystemCa)
if err != nil {
xl.Warnf("fail to build tls configuration, err: %v", err)
return nil, err
+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())
}
+1 -1
View File
@@ -149,7 +149,7 @@ func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) model.Pro
}
psr.Plugin = baseCfg.Plugin.Type
if status.Err == "" {
if status.Err == "" || status.Phase == proxy.ProxyPhaseRunning {
psr.RemoteAddr = status.RemoteAddr
if slices.Contains([]string{"tcp", "udp"}, status.Type) {
psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
+29
View File
@@ -213,6 +213,35 @@ func TestBuildProxyStatusRespStoreSourceEnabled(t *testing.T) {
}
}
func TestBuildProxyStatusRespRunningWithErrorKeepsRemoteAddr(t *testing.T) {
const certificateErr = "reload plugin https2http: invalid certificate"
controller := &Controller{serverAddr: "127.0.0.1", manager: &fakeConfigManager{}}
for _, tc := range []struct {
name, phase, err, remoteAddr string
}{
{"running", proxy.ProxyPhaseRunning, "", "127.0.0.1:43210"},
{"running with error", proxy.ProxyPhaseRunning, certificateErr, "127.0.0.1:43210"},
{"start error", proxy.ProxyPhaseStartErr, "start failed", ""},
{"closed with error", proxy.ProxyPhaseClosed, certificateErr, ""},
{"closed without error", proxy.ProxyPhaseClosed, "", "127.0.0.1:43210"},
} {
t.Run(tc.name, func(t *testing.T) {
status := &proxy.WorkingStatus{
Name: "https", Type: "tcp", Phase: tc.phase, Err: tc.err,
RemoteAddr: ":43210",
Cfg: newRawTCPProxyConfig("https"), // RemotePort remains zero (allocated by frps).
}
resp := controller.buildProxyStatusResp(status)
if resp.RemoteAddr != tc.remoteAddr {
t.Fatalf("unexpected remote addr: got %q, want %q", resp.RemoteAddr, tc.remoteAddr)
}
if resp.Err != tc.err || resp.Status != tc.phase {
t.Fatalf("status or error changed: %+v", resp)
}
})
}
}
func TestReloadErrorMapping(t *testing.T) {
tests := []struct {
name string
+101
View File
@@ -0,0 +1,101 @@
// 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 client
import (
"context"
"io"
"net"
"net/http"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestNewServiceReleasesWebServerOnMetricsServerFailure(t *testing.T) {
for _, mode := range []string{"port conflict", "invalid TLS certificate"} {
t.Run(mode, func(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer ln.Close()
cfg := &v1.ClientCommonConfig{
WebServer: v1.WebServerConfig{Port: getFreeTCPPort(t)},
MetricsServer: v1.MetricsServerConfig{Port: ln.Addr().(*net.TCPAddr).Port},
}
if mode == "invalid TLS certificate" {
cfg.MetricsServer.TLS = &v1.TLSConfig{
CertFile: filepath.Join(t.TempDir(), "missing.crt"), KeyFile: "missing.key",
}
require.NoError(t, ln.Close())
}
svr, err := NewService(ServiceOptions{
Common: cfg, ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
})
if svr != nil {
svr.Close()
}
require.Error(t, err)
require.Nil(t, svr)
assertLifecycleAdminPort(t, net.JoinHostPort(cfg.WebServer.Addr, strconv.Itoa(cfg.WebServer.Port)), true)
if mode == "invalid TLS certificate" {
assertLifecycleAdminPort(t, ln.Addr().String(), true)
}
})
}
}
func TestServiceMetricsServerLifecycle(t *testing.T) {
svr, err := NewService(ServiceOptions{
Common: &v1.ClientCommonConfig{
LoginFailExit: new(false), MetricsServer: v1.MetricsServerConfig{Port: getFreeTCPPort(t)},
},
ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
return &failingConnector{err: net.ErrClosed}
},
})
require.NoError(t, err)
require.Nil(t, svr.webServer)
address := svr.metricsServer.Address()
done := make(chan struct{})
go func() { _ = svr.Run(context.Background()); close(done) }()
defer func() {
svr.Close()
waitLifecycleSignal(t, done)
assertLifecycleAdminPort(t, address, true)
}()
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get("http://" + address + "/healthz")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NoError(t, resp.Body.Close())
// frpc serves Go runtime metrics only, and no credentials are configured
// here, so this endpoint is reachable without authentication.
resp, err = client.Get("http://" + address + "/metrics")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Contains(t, string(body), "go_goroutines")
require.NotContains(t, string(body), "frp_")
}
+148
View File
@@ -0,0 +1,148 @@
// 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 (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
plugin "github.com/fatedier/frp/pkg/plugin/client"
"github.com/fatedier/frp/pkg/util/xlog"
)
type reloadError struct {
err error
}
type testReloadPlugin struct {
calls atomic.Int32
state atomic.Pointer[reloadError]
reload func() error
}
func (p *testReloadPlugin) Name() string { return "test" }
func (p *testReloadPlugin) Handle(context.Context, *plugin.ConnectionInfo) {}
func (p *testReloadPlugin) Close() error { return nil }
func (p *testReloadPlugin) Reload() error {
p.calls.Add(1)
err := p.reload()
p.state.Store(&reloadError{err: err})
return err
}
func (p *testReloadPlugin) ReloadError() error {
if s := p.state.Load(); s != nil {
return s.err
}
return nil
}
func reloadFixture(p *testReloadPlugin) (*Manager, *Wrapper, *BaseProxy) {
base := &BaseProxy{proxyPlugin: p}
pw := &Wrapper{
WorkingStatus: WorkingStatus{Name: "same", Phase: ProxyPhaseRunning},
pxy: &GeneralTCPProxy{BaseProxy: base},
xl: xlog.New(),
}
return &Manager{proxies: map[string]*Wrapper{"same": pw}}, pw, base
}
func TestPluginSnapshotSkipsReplacedAndStopped(t *testing.T) {
p := &testReloadPlugin{reload: func() error { return nil }}
pm, pw, base := reloadFixture(p)
run := pm.PreparePluginReload()
q := &testReloadPlugin{reload: func() error { return nil }}
pw.mu.Lock()
base.proxyPlugin = q
pw.mu.Unlock()
run()
require.Zero(t, p.calls.Load())
require.Zero(t, q.calls.Load())
run = pm.PreparePluginReload()
pw.mu.Lock()
pw.Phase = ProxyPhaseClosed
pw.mu.Unlock()
run()
require.Zero(t, q.calls.Load())
}
func TestPluginErrorOwnershipAndLockIsolation(t *testing.T) {
started, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
p := &testReloadPlugin{reload: func() error {
close(started)
<-release
return errors.New("bad certificate")
}}
pm, pw, base := reloadFixture(p)
other := &testReloadPlugin{reload: func() error { return nil }}
pm2, _, _ := reloadFixture(other)
run := pm.PreparePluginReload()
releaseReload := sync.OnceFunc(func() { close(release) })
t.Cleanup(func() {
releaseReload()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Error("reload did not finish")
}
})
go func() {
run()
close(done)
}()
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("not started")
}
require.True(t, pm.mu.TryLock())
pm.mu.Unlock()
require.True(t, pw.mu.TryLock())
pw.mu.Unlock()
require.Empty(t, pw.GetStatus().Err)
require.Zero(t, other.calls.Load(), "same name in another manager must be isolated")
pm2.PreparePluginReload()()
require.EqualValues(t, 1, other.calls.Load())
q := &testReloadPlugin{reload: func() error { return nil }}
pw.mu.Lock()
base.proxyPlugin = q
pw.mu.Unlock()
releaseReload()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("not done")
}
require.Error(t, p.ReloadError())
require.Empty(t, pw.GetStatus().Err, "retired plugin errors must not attach to its replacement")
q.state.Store(&reloadError{err: errors.New("new plugin error")})
require.Contains(t, pw.GetStatus().Err, "new plugin error")
require.Empty(t, pw.Err, "plugin state must not overwrite lifecycle errors")
pw.mu.Lock()
pw.Err = "lifecycle"
pw.mu.Unlock()
require.Contains(t, pw.GetStatus().Err, "lifecycle; reload plugin")
require.NoError(t, q.Reload())
require.Equal(t, "lifecycle", pw.GetStatus().Err)
}
+3
View File
@@ -120,6 +120,9 @@ func (pxy *BaseProxy) Run() error {
return nil
}
// getPlugin is accessed while the owning Wrapper holds its lifecycle lock.
func (pxy *BaseProxy) getPlugin() plugin.Plugin { return pxy.proxyPlugin }
func (pxy *BaseProxy) Close() {
if pxy.proxyPlugin != nil {
pxy.proxyPlugin.Close()
+35
View File
@@ -139,6 +139,41 @@ func (pm *Manager) GetProxyStatus(name string) (*WorkingStatus, bool) {
return nil, false
}
// PreparePluginReload snapshots live plugin instances before applying config.
// Invoke the returned function only after config application succeeds and all
// configuration locks have been released. Replaced instances are skipped.
func (pm *Manager) PreparePluginReload() func() {
type target struct {
pw *Wrapper
p reloadablePlugin
}
pm.mu.RLock()
targets := make([]target, 0, len(pm.proxies))
for _, pw := range pm.proxies {
pw.mu.RLock()
if pw.Phase == ProxyPhaseRunning {
if p := pw.reloadablePluginLocked(); p != nil {
targets = append(targets, target{pw: pw, p: p})
}
}
pw.mu.RUnlock()
}
pm.mu.RUnlock()
return func() {
for _, t := range targets {
t.pw.mu.RLock()
current := t.pw.Phase == ProxyPhaseRunning && t.pw.reloadablePluginLocked() == t.p
t.pw.mu.RUnlock()
if !current {
continue
}
if err := t.p.Reload(); err != nil {
t.pw.xl.Warnf("reload plugin %s: %v", t.p.Name(), err)
}
}
}
}
func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) {
xl := xlog.FromContextSafe(pm.ctx)
proxyCfgsMap := lo.KeyBy(proxyCfgs, func(c v1.ProxyConfigurer) string {
+32 -1
View File
@@ -30,6 +30,7 @@ import (
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/naming"
plugin "github.com/fatedier/frp/pkg/plugin/client"
"github.com/fatedier/frp/pkg/transport"
"github.com/fatedier/frp/pkg/util/xlog"
"github.com/fatedier/frp/pkg/vnet"
@@ -267,8 +268,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 {
@@ -276,6 +278,25 @@ func (pw *Wrapper) InWorkConn(workConn net.Conn, m *msg.StartWorkConn) {
}
}
// reloadablePlugin owns its reload serialization and error snapshot. ReloadError
// must not block on I/O. Reload must be safe during Close, and retain usable
// resources on failure. Existing plugins need not implement this capability.
type reloadablePlugin interface {
plugin.Plugin
Reload() error
ReloadError() error
}
// Caller holds pw.mu. Concrete reloadable plugins are pointer instances.
func (pw *Wrapper) reloadablePluginLocked() reloadablePlugin {
owner, ok := pw.pxy.(interface{ getPlugin() plugin.Plugin })
if !ok {
return nil
}
p, _ := owner.getPlugin().(reloadablePlugin)
return p
}
func (pw *Wrapper) GetStatus() *WorkingStatus {
pw.mu.RLock()
defer pw.mu.RUnlock()
@@ -287,5 +308,15 @@ func (pw *Wrapper) GetStatus() *WorkingStatus {
Cfg: pw.Cfg,
RemoteAddr: pw.RemoteAddr,
}
if pw.Phase == ProxyPhaseRunning {
if p := pw.reloadablePluginLocked(); p != nil {
if err := p.ReloadError(); err != nil {
if ps.Err != "" {
ps.Err += "; "
}
ps.Err += fmt.Sprintf("reload plugin %s: %v", p.Name(), err)
}
}
}
return ps
}
+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")
}
}
+8 -6
View File
@@ -108,8 +108,10 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
pxy.mu.Lock()
pxy.workConn = workConn
pxy.readCh = make(chan *msg.UDPPacket, 1024)
pxy.sendCh = make(chan msg.Message, 1024)
readCh := make(chan *msg.UDPPacket, 1024)
sendCh := make(chan msg.Message, 1024)
pxy.readCh = readCh
pxy.sendCh = sendCh
pxy.closed = false
pxy.mu.Unlock()
@@ -160,10 +162,10 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) {
}
}
go workConnSenderFn(payloadRW, pxy.sendCh)
go workConnReaderFn(payloadRW, pxy.readCh)
go heartbeatFn(pxy.sendCh)
go workConnSenderFn(payloadRW, sendCh)
go workConnReaderFn(payloadRW, readCh)
go heartbeatFn(sendCh)
// Call Forwarder with proxy protocol version (empty string means no proxy protocol)
udp.Forwarder(pxy.localAddr, pxy.readCh, pxy.sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
udp.Forwarder(pxy.localAddr, readCh, sendCh, int(pxy.clientCfg.UDPPacketSize), pxy.cfg.Transport.ProxyProtocolVersion)
}
+63
View File
@@ -0,0 +1,63 @@
// 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 (
"context"
"net"
"sync"
"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 TestUDPProxyInWorkConnGenerationChannels(t *testing.T) {
cfg := &v1.UDPProxyConfig{ProxyBaseConfig: v1.ProxyBaseConfig{
Name: "udp", Type: "udp",
ProxyBackend: v1.ProxyBackend{LocalIP: "127.0.0.1", LocalPort: 9},
}}
pxy := NewUDPProxy(&BaseProxy{
baseCfg: &cfg.ProxyBaseConfig, clientCfg: &v1.ClientCommonConfig{UDPPacketSize: 1500},
xl: xlog.New(), ctx: context.Background(),
}, cfg).(*UDPProxy)
require.NoError(t, pxy.Run())
defer pxy.Close()
conn1, peer1 := net.Pipe()
defer conn1.Close()
defer peer1.Close()
conn2, peer2 := net.Pipe()
defer conn2.Close()
defer peer2.Close()
start := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
inWorkConn := func(conn net.Conn) {
defer wg.Done()
<-start
pxy.InWorkConn(conn, &msg.StartWorkConn{})
}
go inWorkConn(conn1)
go inWorkConn(conn2)
close(start)
wg.Wait()
}
+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"
+113 -19
View File
@@ -34,6 +34,7 @@ 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/metrics/text"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/policy/security"
httppkg "github.com/fatedier/frp/pkg/util/http"
@@ -123,13 +124,14 @@ type Service struct {
auth *auth.ClientAuth
// web server for admin UI and apis
webServer *httppkg.Server
webServer *httppkg.Server
metricsServer *httppkg.Server
vnetController *vnet.Controller
cfgMu sync.RWMutex
// reloadMu serializes reload transactions to keep reloadCommon and applied
// config in sync across concurrent API operations.
// reloadMu serializes config updates with control startup and publication,
// keeping reloadCommon and applied config in sync across reloads and reconnects.
reloadMu sync.Mutex
common *v1.ClientCommonConfig
// reloadCommon is used for filtering/defaulting during config-source reloads.
@@ -155,6 +157,9 @@ type Service struct {
ctx context.Context
// call cancel to stop service
cancel context.CancelCauseFunc
// Protect cancel publication and shutdown before the first Run.
lifecycleMu sync.Mutex
closed bool
connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector
handleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool
@@ -185,8 +190,8 @@ func NewService(options ServiceOptions) (*Service, error) {
proxyCfgs = config.CompleteProxyConfigurers(proxyCfgs)
visitorCfgs = config.CompleteVisitorConfigurers(visitorCfgs)
// Create the web server after all fallible steps so its listener is not
// leaked when an earlier error causes NewService to return.
// Create HTTP servers after all other fallible steps, rolling back the web
// listener if the metrics server cannot be created.
var webServer *httppkg.Server
if options.Common.WebServer.Port > 0 {
ws, err := httppkg.NewServer(options.Common.WebServer)
@@ -195,11 +200,27 @@ func NewService(options ServiceOptions) (*Service, error) {
}
webServer = ws
}
// frpc exposes Go runtime metrics only. They are written by pkg/metrics/text
// so that client_golang and protobuf stay out of the frpc binary.
var metricsHandler http.Handler
if options.Common.MetricsServer.Port > 0 {
registry := text.NewRegistry()
registry.RegisterRuntime()
metricsHandler = registry.Handler()
}
metricsServer, err := httppkg.NewMetricsServer(options.Common.MetricsServer, metricsHandler)
if err != nil {
if webServer != nil {
_ = webServer.Close()
}
return nil, err
}
s := &Service{
ctx: context.Background(),
auth: authRuntime,
webServer: webServer,
metricsServer: metricsServer,
common: options.Common,
reloadCommon: options.Common,
configFilePath: options.ConfigFilePath,
@@ -225,8 +246,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 != "" {
@@ -237,7 +270,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() {
@@ -249,6 +281,12 @@ func (svr *Service) Run(ctx context.Context) error {
}
if svr.webServer != nil {
if svr.common.WebServer.PprofEnable {
log.Warnf("webServer pprof endpoints are deprecated; use metricsServer.pprofEnable instead")
}
if svr.common.MetricsServer.Port == 0 {
log.Infof("webServer /healthz is deprecated; health probes can migrate to metricsServer.port")
}
webServer := svr.webServer
go func() {
log.Infof("admin server listen on %s", webServer.Address())
@@ -258,24 +296,40 @@ func (svr *Service) Run(ctx context.Context) error {
}()
}
if svr.metricsServer != nil {
metricsServer := svr.metricsServer
go func() {
log.Infof("metrics server listen on %s", metricsServer.Address())
if err := metricsServer.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Warnf("metrics server exit with error: %v", err)
}
}()
}
// first login to frps
svr.loopLoginUntilSuccess(10*time.Second, lo.FromPtr(svr.common.LoginFailExit))
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.
@@ -285,8 +339,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.
@@ -331,11 +388,6 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
xl.AddPrefix(xlog.LogPrefix{Name: "runID", Value: svr.runID})
xl.Infof("login to server success, get run id [%s]", svr.runID)
svr.cfgMu.RLock()
proxyCfgs := svr.proxyCfgs
visitorCfgs := svr.visitorCfgs
svr.cfgMu.RUnlock()
ctl, err := NewControl(svr.ctx, sessionCtx)
if err != nil {
sessionCtx.Conn.Close()
@@ -345,6 +397,15 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
}
ctl.SetInWorkConnCallback(svr.handleWorkConnCb)
// An update must either precede this snapshot or apply to the published
// control. Keep dialing and login outside the reload transaction.
svr.reloadMu.Lock()
defer svr.reloadMu.Unlock()
svr.cfgMu.RLock()
proxyCfgs := svr.proxyCfgs
visitorCfgs := svr.visitorCfgs
svr.cfgMu.RUnlock()
ctl.Run(proxyCfgs, visitorCfgs)
// close and replace previous control
svr.ctlMu.Lock()
@@ -367,6 +428,13 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE
}
func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error {
svr.reloadMu.Lock()
defer svr.reloadMu.Unlock()
return svr.updateAllConfigurerLocked(proxyCfgs, visitorCfgs)
}
// Caller must hold reloadMu.
func (svr *Service) updateAllConfigurerLocked(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error {
svr.cfgMu.Lock()
svr.proxyCfgs = proxyCfgs
svr.visitorCfgs = visitorCfgs
@@ -377,7 +445,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
}
@@ -390,6 +458,15 @@ func (svr *Service) UpdateConfigSource(
svr.reloadMu.Lock()
defer svr.reloadMu.Unlock()
return svr.updateConfigSourceLocked(common, proxyCfgs, visitorCfgs)
}
// Caller must hold reloadMu.
func (svr *Service) updateConfigSourceLocked(
common *v1.ClientCommonConfig,
proxyCfgs []v1.ProxyConfigurer,
visitorCfgs []v1.VisitorConfigurer,
) error {
cfgSource := svr.configSource
if cfgSource == nil {
return fmt.Errorf("config source is not available")
@@ -417,7 +494,20 @@ func (svr *Service) Close() {
func (svr *Service) GracefulClose(d time.Duration) {
svr.gracefulShutdownDuration.Store(int64(d))
svr.cancel(nil)
svr.lifecycleMu.Lock()
if svr.closed {
svr.lifecycleMu.Unlock()
return
}
svr.closed = true
cancel := svr.cancel
svr.lifecycleMu.Unlock()
if cancel != nil {
// Once Run has claimed startup, it also owns cleanup.
cancel(nil)
} else {
svr.stop()
}
}
func (svr *Service) stop() {
@@ -441,6 +531,10 @@ func (svr *Service) stop() {
svr.webServer.Close()
svr.webServer = nil
}
if svr.metricsServer != nil {
svr.metricsServer.Close()
svr.metricsServer = nil
}
if svr.vnetController != nil {
_ = svr.vnetController.Stop()
svr.vnetController = nil
@@ -520,7 +614,7 @@ func (svr *Service) reloadConfigFromSourcesLocked() error {
}
// Atomically replace the entire configuration
if err := svr.UpdateAllConfigurer(proxies, visitors); err != nil {
if err := svr.updateAllConfigurerLocked(proxies, visitors); err != nil {
return err
}
return 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)
})
}
}
+232
View File
@@ -0,0 +1,232 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package client
import (
"context"
"io"
"net"
"reflect"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/client/proxy"
"github.com/fatedier/frp/pkg/config/source"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/msg"
)
// Block proxy construction after the service has read its config snapshot, but
// before Control.Run returns and the replacement control is published.
type handoffProxyConfig struct {
*v1.TCPProxyConfig
started chan struct{}
resume chan struct{}
}
func init() {
proxy.RegisterProxyFactory(reflect.TypeFor[*handoffProxyConfig](), func(base *proxy.BaseProxy, cfg v1.ProxyConfigurer) proxy.Proxy {
blocking := cfg.(*handoffProxyConfig)
close(blocking.started)
<-blocking.resume
return proxy.NewGeneralTCPProxy(base, blocking.TCPProxyConfig)
})
}
func newReloadTestService(t *testing.T, beforeLogin func()) *Service {
t.Helper()
svr, err := NewService(ServiceOptions{
Common: &v1.ClientCommonConfig{
Transport: v1.ClientTransportConfig{HeartbeatInterval: -1, HeartbeatTimeout: -1},
},
ConfigSourceAggregator: source.NewAggregator(source.NewConfigSource()),
ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) Connector {
clientConn, serverConn := net.Pipe()
peerDone := make(chan struct{})
t.Cleanup(func() {
_ = clientConn.Close()
_ = serverConn.Close()
waitLifecycleSignal(t, peerDone)
})
go func() {
defer close(peerDone)
rw := msg.NewV1ReadWriter(serverConn)
var login msg.Login
if err := rw.ReadMsgInto(&login); err != nil {
t.Errorf("read login: %v", err)
return
}
if beforeLogin != nil {
beforeLogin()
}
if err := rw.WriteMsg(&msg.LoginResp{RunID: "reload-test"}); err != nil {
t.Errorf("write login response: %v", err)
return
}
_, _ = io.Copy(io.Discard, serverConn)
}()
return &testConnector{conn: clientConn}
},
})
require.NoError(t, err)
svr.ctx, svr.cancel = context.WithCancelCause(context.Background())
t.Cleanup(func() {
svr.cancel(nil)
if svr.ctl != nil {
_ = svr.ctl.Close()
waitLifecycleSignal(t, svr.ctl.Done())
}
})
return svr
}
func TestServiceReloadDuringControlHandoff(t *testing.T) {
for _, updateSource := range []bool{false, true} {
name := "direct update"
if updateSource {
name = "config source reload"
}
t.Run(name, func(t *testing.T) {
svr := newReloadTestService(t, nil)
svr.loopLoginUntilSuccess(time.Second, true)
previous := svr.ctl
require.NotNil(t, previous)
require.NoError(t, previous.Close())
waitLifecycleSignal(t, previous.Done())
oldProxy := &handoffProxyConfig{
TCPProxyConfig: &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "proxy", Type: "tcp"},
RemotePort: 18001,
},
started: make(chan struct{}),
resume: make(chan struct{}),
}
oldVisitor := &v1.STCPVisitorConfig{VisitorBaseConfig: v1.VisitorBaseConfig{
Name: "visitor", Type: "stcp", BindPort: -1, ServerName: "old-target",
}}
// Seed the desired config for the next connection without restarting
// proxies on the already closed control.
svr.proxyCfgs = []v1.ProxyConfigurer{oldProxy}
svr.visitorCfgs = []v1.VisitorConfigurer{oldVisitor}
loginDone, updateDone := make(chan struct{}), make(chan struct{})
resume := sync.OnceFunc(func() { close(oldProxy.resume) })
t.Cleanup(func() {
resume()
waitLifecycleSignal(t, loginDone)
})
go func() {
svr.loopLoginUntilSuccess(time.Second, true)
close(loginDone)
}()
waitLifecycleSignal(t, oldProxy.started)
svr.ctlMu.RLock()
current := svr.ctl
svr.ctlMu.RUnlock()
require.Same(t, previous, current, "replacement must still be unpublished")
// Check before starting the updater so it cannot be the lock owner.
unlocked := svr.reloadMu.TryLock()
if unlocked {
svr.reloadMu.Unlock()
}
newProxy := &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "proxy", Type: "tcp"},
RemotePort: 18002,
}
newVisitor := &v1.STCPVisitorConfig{VisitorBaseConfig: v1.VisitorBaseConfig{
Name: "visitor", Type: "stcp", BindPort: -1, ServerName: "new-target",
}}
var updateErr error
go func() {
proxies, visitors := []v1.ProxyConfigurer{newProxy}, []v1.VisitorConfigurer{newVisitor}
if updateSource {
updateErr = svr.UpdateConfigSource(svr.common, proxies, visitors)
} else {
updateErr = svr.UpdateAllConfigurer(proxies, visitors)
}
close(updateDone)
}()
t.Cleanup(func() {
resume()
waitLifecycleSignal(t, updateDone)
})
// The gate fixes the handoff window. On the old implementation the
// update completes against the retired control before this gate opens.
select {
case <-updateDone:
t.Error("config update completed before control handoff")
case <-time.After(20 * time.Millisecond):
}
require.False(t, unlocked, "control startup must hold the reload transaction until publication")
resume()
waitLifecycleSignal(t, loginDone)
waitLifecycleSignal(t, updateDone)
require.NoError(t, updateErr)
require.NotSame(t, previous, svr.ctl)
status, ok := svr.getProxyStatus("proxy")
require.True(t, ok)
require.Equal(t, 18002, status.Cfg.(*v1.TCPProxyConfig).RemotePort)
visitor, ok := svr.getVisitorCfg("visitor")
require.True(t, ok)
require.Equal(t, "new-target", visitor.GetBaseConfig().ServerName)
})
}
}
func TestServiceReloadWhileLoginPending(t *testing.T) {
loginStarted, allowLogin, loginDone := make(chan struct{}), make(chan struct{}), make(chan struct{})
resume := sync.OnceFunc(func() { close(allowLogin) })
svr := newReloadTestService(t, func() {
close(loginStarted)
<-allowLogin
})
t.Cleanup(func() {
resume()
waitLifecycleSignal(t, loginDone)
})
go func() {
svr.loopLoginUntilSuccess(time.Second, true)
close(loginDone)
}()
waitLifecycleSignal(t, loginStarted)
proxyCfg := &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{Name: "during-login", Type: "tcp"},
RemotePort: 18003,
}
updateDone := make(chan struct{})
var updateErr error
go func() {
updateErr = svr.UpdateAllConfigurer([]v1.ProxyConfigurer{proxyCfg}, nil)
close(updateDone)
}()
t.Cleanup(func() {
resume()
waitLifecycleSignal(t, updateDone)
})
// Config updates must complete even while the server has not replied to login.
waitLifecycleSignal(t, updateDone)
require.NoError(t, updateErr)
resume()
waitLifecycleSignal(t, loginDone)
status, ok := svr.getProxyStatus("during-login")
require.True(t, ok)
require.Equal(t, 18003, status.Cfg.(*v1.TCPProxyConfig).RemotePort)
}
+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,
+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"
+20
View File
@@ -79,9 +79,23 @@ webServer.password = "admin"
# Admin assets directory. By default, these assets are bundled with frpc.
# webServer.assetsDir = "./static"
# Deprecated: use metricsServer.pprofEnable for authenticated profiling.
# Enable golang pprof handlers in admin listener.
webServer.pprofEnable = false
# Separate health, metrics and optional profiling listener.
# /metrics serves Go runtime metrics only (no proxy or traffic data).
# Port 0 disables it. The default address only accepts local connections.
metricsServer.addr = "127.0.0.1"
metricsServer.port = 0
# Credentials and TLS are independent of webServer; /healthz stays anonymous.
# metricsServer.user = "monitor"
# metricsServer.password = "change-me"
# metricsServer.tls.certFile = "metrics.crt"
# metricsServer.tls.keyFile = "metrics.key"
# Profiling requires explicit opt-in, uses the above credentials, and excludes cmdline.
metricsServer.pprofEnable = false
# The maximum amount of time a dial to server will wait for a connect to complete. Default value is 10 seconds.
# transport.dialServerTimeout = 10
@@ -131,6 +145,12 @@ transport.tls.enable = true
# transport.tls.trustedCaFile = "ca.crt"
# transport.tls.serverName = "example.com"
# If useSystemCa is true, frpc verifies the server certificate using system CAs.
# If trustedCaFile is also set, its CA certificates are added to the system CA pool.
# Only supported in frpc transport.tls; requires tls.enable = true unless protocol is wss.
# CA loading or certificate verification failures cause the connection to fail.
# transport.tls.useSystemCa = false
# If the disableCustomTLSFirstByte is set to false, frpc will establish a connection with frps using the
# first custom byte when tls is enabled.
# Since v0.50.0, the default value has been changed to true, and the first custom byte is disabled by default.
+15 -1
View File
@@ -75,13 +75,27 @@ webServer.password = "admin"
# dashboard assets directory(only for debug mode)
# webServer.assetsDir = "./static"
# Deprecated: use metricsServer.pprofEnable for authenticated profiling.
# Enable golang pprof handlers in dashboard listener.
# Dashboard port must be set first
webServer.pprofEnable = false
# enablePrometheus will export prometheus metrics on webServer in /metrics api.
# Deprecated: enablePrometheus exports /metrics on webServer only.
# Use metricsServer.port instead; it enables business metrics automatically.
enablePrometheus = true
# Separate health, Prometheus metrics, and optional profiling listener.
# Port 0 disables it. The default address only accepts local connections.
metricsServer.addr = "127.0.0.1"
metricsServer.port = 0
# Credentials and TLS are independent of webServer; /healthz stays anonymous.
# metricsServer.user = "monitor"
# metricsServer.password = "change-me"
# metricsServer.tls.certFile = "metrics.crt"
# metricsServer.tls.keyFile = "metrics.key"
# Profiling requires explicit opt-in, uses the above credentials, and excludes cmdline.
metricsServer.pprofEnable = false
# console or real logFile path like ./frps.log
log.to = "./frps.log"
# trace, debug, info, warn, error
+15
View File
@@ -6,6 +6,21 @@ For the version compatibility policy that bounds these support windows, see the
## Active
### Observability endpoints on `webServer`
- **Deprecated since:** v0.72.0 (when `metricsServer` was added).
- **Removal target:** v0.80.0 or later. v0.71.x is the last line without `metricsServer` and is supported until v0.80.0 is released, so v0.79.x is the last release that must keep these endpoints.
- **Replacement:** `metricsServer`. Point health probes at its `/healthz`, scrapes at its `/metrics` (frps exposes business metrics there without `enablePrometheus`), and use `metricsServer.pprofEnable` for profiling.
- **Deprecated items:** `/healthz` on `webServer` in both frps and frpc; `/metrics` on the frps `webServer` and the top-level `enablePrometheus` that gates it; the pprof handlers on `webServer` and `webServer.pprofEnable` in both frps and frpc.
- **Code references:**
- `server/api_router.go` — `/healthz`, and `/metrics` when `enablePrometheus` is set.
- `client/api_router.go` — `/healthz`.
- `pkg/util/http/server.go` — pprof handlers registered when `webServer.pprofEnable` is set.
- `server/service.go` — startup deprecation warnings and `metricsCollectors`.
- `client/service.go` — startup deprecation warnings.
- `pkg/config/v1/server.go` — `EnablePrometheus`; `pkg/config/v1/common.go` — `WebServerConfig.PprofEnable`.
- **Notes:** Removal drops the startup warnings and the deprecation notes in `README.md` and the two `conf/*_full_example.toml` files together. `webServer` then serves only the dashboard and the admin API.
### Wire protocol v1
- **Deprecated since:** v0.70.0 (planned, when v2 becomes the default).
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+9 -7
View File
@@ -1,14 +1,16 @@
FROM node:22 AS web-builder
COPY web/package.json /web/package.json
COPY web/shared/ /web/shared/
COPY web/frpc/ /web/frpc/
WORKDIR /web
RUN npm install
WORKDIR /web/frpc
RUN npm run build
COPY web/package.json web/package-lock.json ./
COPY web/shared/package.json ./shared/package.json
COPY web/frpc/package.json ./frpc/package.json
COPY web/frps/package.json ./frps/package.json
RUN npm ci
COPY web/shared/ ./shared/
COPY web/frpc/ ./frpc/
RUN npm run build --workspace frpc
FROM golang:1.25 AS building
FROM golang:1.26 AS building
COPY . /building
COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist
+9 -7
View File
@@ -1,14 +1,16 @@
FROM node:22 AS web-builder
COPY web/package.json /web/package.json
COPY web/shared/ /web/shared/
COPY web/frps/ /web/frps/
WORKDIR /web
RUN npm install
WORKDIR /web/frps
RUN npm run build
COPY web/package.json web/package-lock.json ./
COPY web/shared/package.json ./shared/package.json
COPY web/frpc/package.json ./frpc/package.json
COPY web/frps/package.json ./frps/package.json
RUN npm ci
COPY web/shared/ ./shared/
COPY web/frps/ ./frps/
RUN npm run build --workspace frps
FROM golang:1.25 AS building
FROM golang:1.26 AS building
COPY . /building
COPY --from=web-builder /web/frps/dist /building/web/frps/dist
+9 -12
View File
@@ -1,20 +1,22 @@
module github.com/fatedier/frp
go 1.25.0
go 1.26.0
require (
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
github.com/coreos/go-oidc/v3 v3.18.0
github.com/fatedier/golib v0.8.2
github.com/fatedier/yamux v0.2.0
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.0
github.com/hashicorp/yamux v0.1.1
github.com/onsi/ginkgo/v2 v2.23.4
github.com/onsi/gomega v1.36.3
github.com/pelletier/go-toml/v2 v2.2.0
github.com/pires/go-proxyproto v0.15.0
github.com/prometheus/client_golang v1.19.1
github.com/prometheus/client_model v0.5.0
github.com/prometheus/common v0.48.0
github.com/quic-go/quic-go v0.60.0
github.com/rodaine/table v1.2.0
github.com/samber/lo v1.47.0
@@ -25,8 +27,8 @@ require (
github.com/tidwall/gjson v1.17.1
github.com/vishvananda/netlink v1.3.0
github.com/xtaci/kcp-go/v5 v5.6.13
golang.org/x/crypto v0.54.0
golang.org/x/net v0.56.0
golang.org/x/crypto v0.56.0
golang.org/x/net v0.57.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
@@ -39,7 +41,7 @@ require (
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -54,8 +56,6 @@ require (
github.com/klauspost/reedsolomon v1.12.0 // 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
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/templexxx/cpu v0.1.1 // indirect
github.com/templexxx/xorsimd v0.4.3 // indirect
@@ -64,8 +64,8 @@ require (
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/tools v0.48.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
@@ -73,6 +73,3 @@ require (
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6
+12 -12
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=
@@ -22,8 +22,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatedier/golib v0.8.2 h1:02n2Dg7KJ7rR7p7n4/6hBUjaLQf2J7EiHYZQsgGTvww=
github.com/fatedier/golib v0.8.2/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
github.com/fatedier/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=
@@ -151,8 +151,8 @@ go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -163,8 +163,8 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
@@ -186,8 +186,8 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -195,8 +195,8 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
+6 -8
View File
@@ -17,8 +17,6 @@ package legacy
import (
"strings"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/config/types"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
@@ -49,7 +47,7 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
out.Transport.ConnectServerLocalIP = conf.ConnectServerLocalIP
out.Transport.ProxyURL = conf.HTTPProxy
out.Transport.PoolCount = conf.PoolCount
out.Transport.TCPMux = lo.ToPtr(conf.TCPMux)
out.Transport.TCPMux = new(conf.TCPMux)
out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
out.Transport.Protocol = conf.Protocol
out.Transport.HeartbeatInterval = conf.HeartbeatInterval
@@ -57,8 +55,8 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
out.Transport.QUIC.KeepalivePeriod = conf.QUICKeepalivePeriod
out.Transport.QUIC.MaxIdleTimeout = conf.QUICMaxIdleTimeout
out.Transport.QUIC.MaxIncomingStreams = conf.QUICMaxIncomingStreams
out.Transport.TLS.Enable = lo.ToPtr(conf.TLSEnable)
out.Transport.TLS.DisableCustomTLSFirstByte = lo.ToPtr(conf.DisableCustomTLSFirstByte)
out.Transport.TLS.Enable = new(conf.TLSEnable)
out.Transport.TLS.DisableCustomTLSFirstByte = new(conf.DisableCustomTLSFirstByte)
out.Transport.TLS.CertFile = conf.TLSCertFile
out.Transport.TLS.KeyFile = conf.TLSKeyFile
out.Transport.TLS.TrustedCaFile = conf.TLSTrustedCaFile
@@ -77,7 +75,7 @@ func Convert_ClientCommonConf_To_v1(conf *ClientCommonConf) *v1.ClientCommonConf
out.WebServer.PprofEnable = conf.PprofEnable
out.DNSServer = conf.DNSServer
out.LoginFailExit = lo.ToPtr(conf.LoginFailExit)
out.LoginFailExit = new(conf.LoginFailExit)
out.Start = conf.Start
out.UDPPacketSize = conf.UDPPacketSize
out.Metadatas = conf.Metas
@@ -134,14 +132,14 @@ func Convert_ServerCommonConf_To_v1(conf *ServerCommonConf) *v1.ServerConfig {
out.Log.MaxDays = conf.LogMaxDays
out.Log.DisablePrintColor = conf.DisableLogColor
out.DetailedErrorsToClient = lo.ToPtr(conf.DetailedErrorsToClient)
out.DetailedErrorsToClient = new(conf.DetailedErrorsToClient)
out.SubDomainHost = conf.SubDomainHost
out.Custom404Page = conf.Custom404Page
out.UserConnTimeout = conf.UserConnTimeout
out.UDPPacketSize = conf.UDPPacketSize
out.NatHoleAnalysisDataReserveHours = conf.NatHoleAnalysisDataReserveHours
out.Transport.TCPMux = lo.ToPtr(conf.TCPMux)
out.Transport.TCPMux = new(conf.TCPMux)
out.Transport.TCPMuxKeepaliveInterval = conf.TCPMuxKeepaliveInterval
out.Transport.TCPKeepAlive = conf.TCPKeepAlive
out.Transport.MaxPoolCount = conf.MaxPoolCount
+10 -8
View File
@@ -60,10 +60,11 @@ type ClientCommonConfig struct {
// set.
Start []string `json:"start,omitempty"`
Log LogConfig `json:"log,omitempty"`
WebServer WebServerConfig `json:"webServer,omitempty"`
Transport ClientTransportConfig `json:"transport,omitempty"`
VirtualNet VirtualNetConfig `json:"virtualNet,omitempty"`
Log LogConfig `json:"log,omitempty"`
WebServer WebServerConfig `json:"webServer,omitempty"`
MetricsServer MetricsServerConfig `json:"metricsServer,omitempty"`
Transport ClientTransportConfig `json:"transport,omitempty"`
VirtualNet VirtualNetConfig `json:"virtualNet,omitempty"`
// FeatureGates specifies a set of feature gates to enable or disable.
// This can be used to enable alpha/beta features or disable default features.
@@ -85,7 +86,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 {
@@ -94,6 +95,7 @@ func (c *ClientCommonConfig) Complete() error {
c.Log.Complete()
c.Transport.Complete()
c.WebServer.Complete()
c.MetricsServer.Complete()
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
return nil
@@ -151,7 +153,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 +182,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 {
+24
View File
@@ -72,6 +72,26 @@ func (c *WebServerConfig) Complete() {
c.Addr = util.EmptyOr(c.Addr, "127.0.0.1")
}
// MetricsServerConfig configures a separate listener for health checks, metrics
// (frps only), and optional pprof handlers. It does not serve the web interface.
type MetricsServerConfig struct {
// Addr defaults to "127.0.0.1".
Addr string `json:"addr,omitempty"`
// Port disables the listener when set to 0.
Port int `json:"port,omitempty"`
// User and Password protect metrics and pprof; health checks remain anonymous.
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
// PprofEnable explicitly enables profiling, excluding cmdline.
PprofEnable bool `json:"pprofEnable,omitempty"`
// Enable TLS if TLS is not nil.
TLS *TLSConfig `json:"tls,omitempty"`
}
func (c *MetricsServerConfig) Complete() {
c.Addr = util.EmptyOr(c.Addr, "127.0.0.1")
}
type TLSConfig struct {
// CertFile specifies the path of the cert file that client will load.
CertFile string `json:"certFile,omitempty"`
@@ -79,6 +99,10 @@ type TLSConfig struct {
KeyFile string `json:"keyFile,omitempty"`
// TrustedCaFile specifies the path of the trusted ca file that will load.
TrustedCaFile string `json:"trustedCaFile,omitempty"`
// UseSystemCa enables server certificate verification using system CAs.
// If TrustedCaFile is also set, its certificates are added to the system CA pool.
// Only supported in frpc's transport.tls. With neither trust source, frpc skips verification.
UseSystemCa bool `json:"useSystemCa,omitempty"`
// ServerName specifies the custom server name of tls certificate. By
// default, server name if same to ServerAddr.
ServerName string `json:"serverName,omitempty"`
+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 {
+5 -3
View File
@@ -69,7 +69,8 @@ type ServerConfig struct {
SSHTunnelGateway SSHTunnelGateway `json:"sshTunnelGateway,omitempty"`
WebServer WebServerConfig `json:"webServer,omitempty"`
WebServer WebServerConfig `json:"webServer,omitempty"`
MetricsServer MetricsServerConfig `json:"metricsServer,omitempty"`
// EnablePrometheus will export prometheus metrics on webserver address
// in /metrics api.
EnablePrometheus bool `json:"enablePrometheus,omitempty"`
@@ -105,6 +106,7 @@ func (c *ServerConfig) Complete() error {
c.Log.Complete()
c.Transport.Complete()
c.WebServer.Complete()
c.MetricsServer.Complete()
c.SSHTunnelGateway.Complete()
c.BindAddr = util.EmptyOr(c.BindAddr, "0.0.0.0")
@@ -118,7 +120,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)
@@ -180,7 +182,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)
+5
View File
@@ -38,6 +38,7 @@ func (v *ConfigValidator) ValidateClientCommonConfig(c *v1.ClientCommonConfig) (
func() (Warning, error) { return v.validateAuthConfig(&c.Auth) },
func() (Warning, error) { return nil, validateLogConfig(&c.Log) },
func() (Warning, error) { return nil, validateWebServerConfig(&c.WebServer) },
func() (Warning, error) { return nil, validateMetricsServerConfig(&c.MetricsServer, &c.WebServer) },
func() (Warning, error) { return validateTransportConfig(&c.Transport) },
func() (Warning, error) { return validateIncludeFiles(c.IncludeConfigFiles) },
}
@@ -163,6 +164,10 @@ func validateTransportConfig(c *v1.ClientTransportConfig) (Warning, error) {
warnings = AppendError(warnings, checkTLSConfig("transport.tls.certFile", c.TLS.CertFile))
warnings = AppendError(warnings, checkTLSConfig("transport.tls.keyFile", c.TLS.KeyFile))
warnings = AppendError(warnings, checkTLSConfig("transport.tls.trustedCaFile", c.TLS.TrustedCaFile))
// wss keeps TLS on regardless of transport.tls.enable.
if c.TLS.UseSystemCa && c.Protocol != "wss" {
errs = AppendError(errs, fmt.Errorf("transport.tls.useSystemCa requires transport.tls.enable to be true unless transport.protocol is wss"))
}
}
if !slices.Contains(SupportedTransportProtocols, c.Protocol) {
+24
View File
@@ -23,6 +23,9 @@ import (
func validateWebServerConfig(c *v1.WebServerConfig) error {
if c.TLS != nil {
if c.TLS.UseSystemCa {
return fmt.Errorf("webServer.tls.useSystemCa is only supported in frpc transport.tls")
}
if c.TLS.CertFile == "" {
return fmt.Errorf("tls.certFile must be specified when tls is enabled")
}
@@ -34,6 +37,27 @@ func validateWebServerConfig(c *v1.WebServerConfig) error {
return ValidatePort(c.Port, "webServer.port")
}
func validateMetricsServerConfig(c *v1.MetricsServerConfig, web *v1.WebServerConfig) error {
if err := ValidatePort(c.Port, "metricsServer.port"); err != nil {
return err
}
if c.TLS != nil {
if c.TLS.UseSystemCa {
return fmt.Errorf("metricsServer.tls.useSystemCa is only supported in frpc transport.tls")
}
if c.TLS.CertFile == "" || c.TLS.KeyFile == "" {
return fmt.Errorf("metricsServer.tls.certFile and metricsServer.tls.keyFile must both be specified when tls is enabled")
}
}
// Only reject an exact duplicate. Whether two different addresses can share
// a port depends on the address family and the platform, so leave it to
// net.Listen to decide.
if c.Port > 0 && web.Port > 0 && c.Port == web.Port && c.Addr == web.Addr {
return fmt.Errorf("metricsServer.addr:port conflicts with webServer.addr:port")
}
return nil
}
// ValidatePort checks that the network port is in range
func ValidatePort(port int, fieldPath string) error {
if 0 <= port && port <= 65535 {
@@ -0,0 +1,119 @@
// 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 TestValidateMetricsServerConfig(t *testing.T) {
for _, tc := range []struct {
name string
metrics v1.MetricsServerConfig
web v1.WebServerConfig
wantErr string
}{
{name: "disabled"},
{name: "negative port", metrics: v1.MetricsServerConfig{Port: -1}, wantErr: "metricsServer.port"},
{name: "overflow port", metrics: v1.MetricsServerConfig{Port: 65536}, wantErr: "metricsServer.port"},
{name: "maximum port", metrics: v1.MetricsServerConfig{Port: 65535}},
{name: "empty TLS", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{}}, wantErr: "metricsServer.tls.certFile"},
{name: "only certificate", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{CertFile: "cert"}}, wantErr: "metricsServer.tls.keyFile"},
{name: "only key", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{KeyFile: "key"}}, wantErr: "metricsServer.tls.certFile"},
{name: "TLS pair", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{CertFile: "cert", KeyFile: "key"}}},
{name: "system CA", metrics: v1.MetricsServerConfig{TLS: &v1.TLSConfig{UseSystemCa: true}}, wantErr: "metricsServer.tls.useSystemCa"},
{
name: "same address", metrics: v1.MetricsServerConfig{Addr: "127.0.0.1", Port: 7501},
web: v1.WebServerConfig{Addr: "127.0.0.1", Port: 7501}, wantErr: "conflicts with webServer",
},
{
name: "different addresses", metrics: v1.MetricsServerConfig{Addr: "127.0.0.1", Port: 7501},
web: v1.WebServerConfig{Addr: "127.0.0.2", Port: 7501},
},
{
name: "different ports", metrics: v1.MetricsServerConfig{Port: 7501},
web: v1.WebServerConfig{Port: 7500},
},
{
name: "web disabled", metrics: v1.MetricsServerConfig{Port: 7501},
},
{
name: "metrics disabled", web: v1.WebServerConfig{Port: 7501},
},
} {
t.Run(tc.name, func(t *testing.T) {
checkMetricsConfigBoth(t, tc.metrics, tc.web, tc.wantErr)
})
}
// Two different addresses on the same port may or may not coexist depending
// on the address family and the platform, so only an exact duplicate is a
// configuration error here.
for _, wildcard := range []string{"", "0.0.0.0", "::"} {
t.Run("wildcard "+wildcard+" with loopback", func(t *testing.T) {
checkMetricsConfigBoth(t, v1.MetricsServerConfig{Addr: wildcard, Port: 7501},
v1.WebServerConfig{Addr: "127.0.0.1", Port: 7501}, "")
checkMetricsConfigBoth(t, v1.MetricsServerConfig{Addr: "127.0.0.1", Port: 7501},
v1.WebServerConfig{Addr: wildcard, Port: 7501}, "")
})
t.Run("same wildcard "+wildcard, func(t *testing.T) {
checkMetricsConfigBoth(t, v1.MetricsServerConfig{Addr: wildcard, Port: 7501},
v1.WebServerConfig{Addr: wildcard, Port: 7501}, "conflicts with webServer")
})
}
t.Run("different wildcards on the same port", func(t *testing.T) {
checkMetricsConfigBoth(t, v1.MetricsServerConfig{Addr: "0.0.0.0", Port: 7501},
v1.WebServerConfig{Addr: "::", Port: 7501}, "")
})
}
func checkMetricsConfigBoth(t *testing.T, metrics v1.MetricsServerConfig, web v1.WebServerConfig, wantErr string) {
t.Helper()
server := &v1.ServerConfig{}
client := &v1.ClientCommonConfig{}
require.NoError(t, server.Complete())
require.NoError(t, client.Complete())
// Assign after completion to also check raw empty (wildcard) addresses.
server.MetricsServer, server.WebServer = metrics, web
client.MetricsServer, client.WebServer = metrics, web
validator := NewConfigValidator(nil)
_, serverErr := validator.ValidateServerConfig(server)
_, clientErr := validator.ValidateClientCommonConfig(client)
for _, err := range []error{serverErr, clientErr} {
if wantErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, wantErr)
require.ErrorContains(t, err, "metricsServer")
}
}
}
func TestMetricsServerDefaults(t *testing.T) {
for _, port := range []int{0, 7501} {
server := &v1.ServerConfig{MetricsServer: v1.MetricsServerConfig{Port: port}}
client := &v1.ClientCommonConfig{MetricsServer: v1.MetricsServerConfig{Port: port}}
require.NoError(t, server.Complete())
require.NoError(t, client.Complete())
for _, cfg := range []v1.MetricsServerConfig{server.MetricsServer, client.MetricsServer} {
require.Equal(t, "127.0.0.1", cfg.Addr)
require.Equal(t, port, cfg.Port)
require.False(t, cfg.PprofEnable)
}
}
}
+6
View File
@@ -44,6 +44,12 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err
if err := validateWebServerConfig(&c.WebServer); err != nil {
errs = AppendError(errs, err)
}
if err := validateMetricsServerConfig(&c.MetricsServer, &c.WebServer); err != nil {
errs = AppendError(errs, err)
}
if c.Transport.TLS.UseSystemCa {
errs = AppendError(errs, fmt.Errorf("transport.tls.useSystemCa is only supported in frpc transport.tls"))
}
errs = AppendError(errs, ValidatePort(c.BindPort, "bindPort"))
errs = AppendError(errs, ValidatePort(c.KCPBindPort, "kcpBindPort"))
+146
View File
@@ -0,0 +1,146 @@
// 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 (
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestClientTLSCATrustSources(t *testing.T) {
for _, test := range []struct {
name string
tlsJSON string
useSystemCa bool
caFile string
}{
{name: "default", tlsJSON: `{}`},
{name: "file only", tlsJSON: `{"trustedCaFile":"ca.pem"}`, caFile: "ca.pem"},
{name: "system only", tlsJSON: `{"useSystemCa":true}`, useSystemCa: true},
{name: "combined", tlsJSON: `{"useSystemCa":true,"trustedCaFile":"ca.pem"}`, useSystemCa: true, caFile: "ca.pem"},
} {
t.Run(test.name, func(t *testing.T) {
cfg := &v1.ClientCommonConfig{}
require.NoError(t, json.Unmarshal([]byte(`{"transport":{"tls":`+test.tlsJSON+`}}`), cfg))
require.NoError(t, cfg.Complete())
require.Equal(t, test.useSystemCa, cfg.Transport.TLS.UseSystemCa)
require.Equal(t, test.caFile, cfg.Transport.TLS.TrustedCaFile)
warnings, err := NewConfigValidator(nil).ValidateClientCommonConfig(cfg)
require.NoError(t, warnings)
require.NoError(t, err)
})
}
}
func TestClientSystemCARequiresTLS(t *testing.T) {
for _, protocol := range []string{"tcp", "kcp", "quic", "websocket", "wss"} {
for _, state := range []struct {
name string
enable *bool
}{
{name: "default"},
{name: "enabled", enable: new(true)},
{name: "disabled", enable: new(false)},
} {
t.Run(protocol+"/"+state.name, func(t *testing.T) {
cfg := &v1.ClientCommonConfig{}
cfg.Transport.Protocol = protocol
cfg.Transport.TLS.Enable = state.enable
cfg.Transport.TLS.UseSystemCa = true
require.NoError(t, cfg.Complete())
warnings, err := NewConfigValidator(nil).ValidateClientCommonConfig(cfg)
require.NoError(t, warnings, "useSystemCa must produce an error, not a warning")
if state.name == "disabled" && protocol != "wss" {
require.ErrorContains(t, err, "transport.tls.useSystemCa")
require.ErrorContains(t, err, "transport.tls.enable")
} else {
require.NoError(t, err)
}
})
}
}
}
func TestDisabledTLSFileWarnings(t *testing.T) {
for _, protocol := range []string{"tcp", "kcp", "quic", "websocket", "wss"} {
t.Run(protocol, func(t *testing.T) {
cfg := &v1.ClientCommonConfig{}
cfg.Transport.Protocol = protocol
cfg.Transport.TLS.Enable = new(false)
cfg.Transport.TLS.CertFile = "client.pem"
cfg.Transport.TLS.KeyFile = "client.key"
cfg.Transport.TLS.TrustedCaFile = "ca.pem"
require.NoError(t, cfg.Complete())
warnings, err := NewConfigValidator(nil).ValidateClientCommonConfig(cfg)
require.NoError(t, err)
for _, field := range []string{"certFile", "keyFile", "trustedCaFile"} {
require.ErrorContains(t, warnings, "transport.tls."+field+" is invalid when transport.tls.enable is false")
}
})
}
}
func TestSystemCAUnsupportedLocations(t *testing.T) {
for _, test := range []struct {
name string
server bool
configJSON string
fieldPath string
}{
{
name: "frps transport", server: true,
configJSON: `{"transport":{"tls":{"useSystemCa":%t}}}`, fieldPath: "transport.tls.useSystemCa",
},
{
name: "frpc webServer", configJSON: `{"webServer":{"tls":{"certFile":"server.pem","keyFile":"server.key","useSystemCa":%t}}}`,
fieldPath: "webServer.tls.useSystemCa",
},
{
name: "frps webServer", server: true,
configJSON: `{"webServer":{"tls":{"certFile":"server.pem","keyFile":"server.key","useSystemCa":%t}}}`,
fieldPath: "webServer.tls.useSystemCa",
},
} {
for _, enabled := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/%t", test.name, enabled), func(t *testing.T) {
data := fmt.Appendf(nil, test.configJSON, enabled)
var warnings Warning
var err error
if test.server {
cfg := &v1.ServerConfig{}
require.NoError(t, json.Unmarshal(data, cfg))
require.NoError(t, cfg.Complete())
warnings, err = NewConfigValidator(nil).ValidateServerConfig(cfg)
} else {
cfg := &v1.ClientCommonConfig{}
require.NoError(t, json.Unmarshal(data, cfg))
require.NoError(t, cfg.Complete())
warnings, err = NewConfigValidator(nil).ValidateClientCommonConfig(cfg)
}
require.NoError(t, warnings)
if enabled {
require.EqualError(t, err, test.fieldPath+" is only supported in frpc transport.tls")
} else {
require.NoError(t, err)
}
})
}
}
}
+80
View File
@@ -0,0 +1,80 @@
// 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 text
import (
"io"
"net/http"
"sort"
"strings"
"sync"
)
// collector returns the families of one scrape.
type collector func() []family
// familiesPerCollector sizes the gather buffer; the runtime collector is the
// only one today and emits the 22 memstats families plus a handful of others.
const familiesPerCollector = 32
// Registry holds the collectors exposed on one listener.
type Registry struct {
mu sync.RWMutex
collectors []collector
}
// NewRegistry returns a registry with no collectors.
func NewRegistry() *Registry {
return &Registry{}
}
// RegisterRuntime registers the Go runtime collector.
func (r *Registry) RegisterRuntime() {
r.mu.Lock()
defer r.mu.Unlock()
r.collectors = append(r.collectors, runtimeFamilies)
}
// WritePrometheus writes every collected family in the text exposition format.
func (r *Registry) WritePrometheus(w io.Writer) error {
return encode(w, r.gather())
}
// Handler returns an http.Handler serving the registry.
func (r *Registry) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// Encode into memory first so that a failure never sends a truncated body.
var b strings.Builder
if err := r.WritePrometheus(&b); err != nil {
http.Error(w, "metrics unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", ContentType)
_, _ = io.WriteString(w, b.String())
})
}
func (r *Registry) gather() []family {
r.mu.RLock()
collectors := append([]collector(nil), r.collectors...)
r.mu.RUnlock()
out := make([]family, 0, len(collectors)*familiesPerCollector)
for _, c := range collectors {
out = append(out, c()...)
}
sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
return out
}
+398
View File
@@ -0,0 +1,398 @@
// 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 text
import (
"runtime"
"runtime/debug"
"runtime/metrics"
"sync"
"time"
)
// memstats is one go_memstats_* family. The values are read the same way
// client_golang reads them since Go 1.17: from runtime/metrics, which does not
// stop the world, and then mapped onto runtime.MemStats fields.
type memstats struct {
name string
help string
typ metricType
get func(*runtime.MemStats) float64
}
// memstatsFamilies matches the go_memstats_* families of client_golang's default
// registry, including their help strings and types.
var memstatsFamilies = []memstats{
{
name: "go_memstats_alloc_bytes",
help: "Number of bytes allocated and still in use.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.Alloc) },
},
{
name: "go_memstats_alloc_bytes_total",
help: "Total number of bytes allocated, even if freed.",
typ: typeCounter,
get: func(ms *runtime.MemStats) float64 { return float64(ms.TotalAlloc) },
},
{
name: "go_memstats_buck_hash_sys_bytes",
help: "Number of bytes used by the profiling bucket hash table.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.BuckHashSys) },
},
{
name: "go_memstats_frees_total",
help: "Total number of frees.",
typ: typeCounter,
get: func(ms *runtime.MemStats) float64 { return float64(ms.Frees) },
},
{
name: "go_memstats_gc_sys_bytes",
help: "Number of bytes used for garbage collection system metadata.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.GCSys) },
},
{
name: "go_memstats_heap_alloc_bytes",
help: "Number of heap bytes allocated and still in use.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapAlloc) },
},
{
name: "go_memstats_heap_idle_bytes",
help: "Number of heap bytes waiting to be used.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapIdle) },
},
{
name: "go_memstats_heap_inuse_bytes",
help: "Number of heap bytes that are in use.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapInuse) },
},
{
name: "go_memstats_heap_objects",
help: "Number of allocated objects.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapObjects) },
},
{
name: "go_memstats_heap_released_bytes",
help: "Number of heap bytes released to OS.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapReleased) },
},
{
name: "go_memstats_heap_sys_bytes",
help: "Number of heap bytes obtained from system.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapSys) },
},
{
name: "go_memstats_lookups_total",
help: "Total number of pointer lookups.",
typ: typeCounter,
get: func(ms *runtime.MemStats) float64 { return float64(ms.Lookups) },
},
{
name: "go_memstats_mallocs_total",
help: "Total number of mallocs.",
typ: typeCounter,
get: func(ms *runtime.MemStats) float64 { return float64(ms.Mallocs) },
},
{
name: "go_memstats_mcache_inuse_bytes",
help: "Number of bytes in use by mcache structures.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.MCacheInuse) },
},
{
name: "go_memstats_mcache_sys_bytes",
help: "Number of bytes used for mcache structures obtained from system.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.MCacheSys) },
},
{
name: "go_memstats_mspan_inuse_bytes",
help: "Number of bytes in use by mspan structures.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.MSpanInuse) },
},
{
name: "go_memstats_mspan_sys_bytes",
help: "Number of bytes used for mspan structures obtained from system.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.MSpanSys) },
},
{
name: "go_memstats_next_gc_bytes",
help: "Number of heap bytes when next garbage collection will take place.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.NextGC) },
},
{
name: "go_memstats_other_sys_bytes",
help: "Number of bytes used for other system allocations.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.OtherSys) },
},
{
name: "go_memstats_stack_inuse_bytes",
help: "Number of bytes in use by the stack allocator.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.StackInuse) },
},
{
name: "go_memstats_stack_sys_bytes",
help: "Number of bytes obtained from system for stack allocator.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.StackSys) },
},
{
name: "go_memstats_sys_bytes",
help: "Number of bytes obtained from system.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.Sys) },
},
}
// The runtime/metrics names below are the ones client_golang v1.19.1 reads to
// build the go_memstats_* families. See prometheus/go_collector_latest.go in
// that module (Apache-2.0).
const (
rmGCHeapTinyAllocsObjects = "/gc/heap/tiny/allocs:objects"
rmGCHeapAllocsObjects = "/gc/heap/allocs:objects"
rmGCHeapFreesObjects = "/gc/heap/frees:objects"
rmGCHeapAllocsBytes = "/gc/heap/allocs:bytes"
rmGCHeapObjects = "/gc/heap/objects:objects"
rmGCHeapGoalBytes = "/gc/heap/goal:bytes"
rmMemoryClassesTotalBytes = "/memory/classes/total:bytes"
rmMemoryClassesHeapObjectsBytes = "/memory/classes/heap/objects:bytes"
rmMemoryClassesHeapUnusedBytes = "/memory/classes/heap/unused:bytes"
rmMemoryClassesHeapReleasedBytes = "/memory/classes/heap/released:bytes"
rmMemoryClassesHeapFreeBytes = "/memory/classes/heap/free:bytes"
rmMemoryClassesHeapStacksBytes = "/memory/classes/heap/stacks:bytes"
rmMemoryClassesOSStacksBytes = "/memory/classes/os-stacks:bytes"
rmMemoryClassesMetadataMSpanInuseBytes = "/memory/classes/metadata/mspan/inuse:bytes"
rmMemoryClassesMetadataMSpanFreeBytes = "/memory/classes/metadata/mspan/free:bytes"
rmMemoryClassesMetadataMCacheInuseBytes = "/memory/classes/metadata/mcache/inuse:bytes"
rmMemoryClassesMetadataMCacheFreeBytes = "/memory/classes/metadata/mcache/free:bytes"
rmMemoryClassesProfilingBucketsBytes = "/memory/classes/profiling/buckets:bytes"
rmMemoryClassesMetadataOtherBytes = "/memory/classes/metadata/other:bytes"
rmMemoryClassesOtherBytes = "/memory/classes/other:bytes"
)
// memoryNames lists every runtime/metrics name memStatsFromRM reads.
var memoryNames = []string{
rmGCHeapTinyAllocsObjects,
rmGCHeapAllocsObjects,
rmGCHeapFreesObjects,
rmGCHeapAllocsBytes,
rmGCHeapObjects,
rmGCHeapGoalBytes,
rmMemoryClassesTotalBytes,
rmMemoryClassesHeapObjectsBytes,
rmMemoryClassesHeapUnusedBytes,
rmMemoryClassesHeapReleasedBytes,
rmMemoryClassesHeapFreeBytes,
rmMemoryClassesHeapStacksBytes,
rmMemoryClassesOSStacksBytes,
rmMemoryClassesMetadataMSpanInuseBytes,
rmMemoryClassesMetadataMSpanFreeBytes,
rmMemoryClassesMetadataMCacheInuseBytes,
rmMemoryClassesMetadataMCacheFreeBytes,
rmMemoryClassesProfilingBucketsBytes,
rmMemoryClassesMetadataOtherBytes,
rmMemoryClassesOtherBytes,
}
// memorySamples holds the sample buffer of one scrape and the index that maps a
// runtime/metrics name to its slot.
type memorySamples struct {
samples []metrics.Sample
index map[string]int
}
// memStatsSnapshot takes the Go memory statistics frpc reports. It reads the
// runtime/metrics package instead of calling runtime.ReadMemStats, which stops
// the world on every call, and memStatsFromRM maps the samples onto the old
// field names.
//
// The sample buffer is reused between scrapes, so concurrent scrapes take turns:
// runtime/metrics.Read writes into the samples it is handed.
func memStatsSnapshot() runtime.MemStats {
memoryMu.Lock()
defer memoryMu.Unlock()
if memoryBuffer == nil {
memoryBuffer = newMemorySamples()
}
// metrics.Read indexes into the slice, so an empty buffer has to be skipped.
if len(memoryBuffer.samples) > 0 {
metrics.Read(memoryBuffer.samples)
}
var ms runtime.MemStats
memStatsFromRM(&ms, memoryBuffer.value)
return ms
}
var (
memoryMu sync.Mutex
memoryBuffer *memorySamples
)
// newMemorySamples builds the sample buffer from the names this runtime supports.
// A name the runtime does not know is left out and reads as zero, which is what
// client_golang does as well.
func newMemorySamples() *memorySamples {
supported := make(map[string]struct{}, len(memoryNames))
for _, d := range metrics.All() {
supported[d.Name] = struct{}{}
}
s := &memorySamples{index: make(map[string]int, len(memoryNames))}
for _, name := range memoryNames {
if _, ok := supported[name]; !ok {
continue
}
s.index[name] = len(s.samples)
s.samples = append(s.samples, metrics.Sample{Name: name})
}
return s
}
// value returns the sampled value of name, or zero when the runtime does not
// expose it.
func (s *memorySamples) value(name string) uint64 {
i, ok := s.index[name]
if !ok {
return 0
}
return s.samples[i].Value.Uint64()
}
// memStatsFromRM maps runtime/metrics samples onto a runtime.MemStats, using the
// same field combinations as client_golang v1.19.1, so the go_memstats_* values
// match the ones the official collector reports for the same process. GCCPUFraction
// is left at zero, as client_golang omits it too.
func memStatsFromRM(ms *runtime.MemStats, lookup func(string) uint64) {
// MemStats counts tiny allocations in both Mallocs and Frees. client_golang
// keeps that behavior because Mallocs-Frees is then a live object count.
tinyAllocs := lookup(rmGCHeapTinyAllocsObjects)
ms.Mallocs = lookup(rmGCHeapAllocsObjects) + tinyAllocs
ms.Frees = lookup(rmGCHeapFreesObjects) + tinyAllocs
ms.TotalAlloc = lookup(rmGCHeapAllocsBytes)
ms.Sys = lookup(rmMemoryClassesTotalBytes)
ms.Lookups = 0 // Already always zero.
ms.HeapAlloc = lookup(rmMemoryClassesHeapObjectsBytes)
ms.Alloc = ms.HeapAlloc
ms.HeapInuse = ms.HeapAlloc + lookup(rmMemoryClassesHeapUnusedBytes)
ms.HeapReleased = lookup(rmMemoryClassesHeapReleasedBytes)
ms.HeapIdle = ms.HeapReleased + lookup(rmMemoryClassesHeapFreeBytes)
ms.HeapSys = ms.HeapInuse + ms.HeapIdle
ms.HeapObjects = lookup(rmGCHeapObjects)
ms.StackInuse = lookup(rmMemoryClassesHeapStacksBytes)
ms.StackSys = ms.StackInuse + lookup(rmMemoryClassesOSStacksBytes)
ms.MSpanInuse = lookup(rmMemoryClassesMetadataMSpanInuseBytes)
ms.MSpanSys = ms.MSpanInuse + lookup(rmMemoryClassesMetadataMSpanFreeBytes)
ms.MCacheInuse = lookup(rmMemoryClassesMetadataMCacheInuseBytes)
ms.MCacheSys = ms.MCacheInuse + lookup(rmMemoryClassesMetadataMCacheFreeBytes)
ms.BuckHashSys = lookup(rmMemoryClassesProfilingBucketsBytes)
ms.GCSys = lookup(rmMemoryClassesMetadataOtherBytes)
ms.OtherSys = lookup(rmMemoryClassesOtherBytes)
ms.NextGC = lookup(rmGCHeapGoalBytes)
}
// gcQuantileLabels matches the quantiles client_golang reports in the
// go_gc_duration_seconds summary.
var gcQuantileLabels = []string{"0", "0.25", "0.5", "0.75", "1"}
// runtimeFamilies reads the runtime once per scrape rather than once per metric.
func runtimeFamilies() []family {
ms := memStatsSnapshot()
out := make([]family, 0, len(memstatsFamilies)+5)
for _, m := range memstatsFamilies {
out = append(out, family{
name: m.name,
help: m.help,
typ: m.typ,
samples: []sample{{value: m.get(&ms)}},
})
}
// debug.ReadGCStats only takes the mheap lock; it does not stop the world.
var gc debug.GCStats
gc.PauseQuantiles = make([]time.Duration, len(gcQuantileLabels))
debug.ReadGCStats(&gc)
gcSamples := make([]sample, 0, len(gcQuantileLabels)+2)
for i, q := range gcQuantileLabels {
var pause time.Duration
if i < len(gc.PauseQuantiles) {
pause = gc.PauseQuantiles[i]
}
gcSamples = append(gcSamples, sample{
labels: []label{{name: "quantile", value: q}},
value: pause.Seconds(),
})
}
gcSamples = append(gcSamples,
sample{name: "go_gc_duration_seconds_sum", value: gc.PauseTotal.Seconds()},
sample{name: "go_gc_duration_seconds_count", value: float64(gc.NumGC)},
)
return append(out,
family{
name: "go_gc_duration_seconds",
help: "A summary of the pause duration of garbage collection cycles.",
typ: typeSummary,
samples: gcSamples,
},
family{
name: "go_goroutines",
help: "Number of goroutines that currently exist.",
typ: typeGauge,
samples: []sample{{value: float64(runtime.NumGoroutine())}},
},
family{
name: "go_info",
help: "Information about the Go environment.",
typ: typeGauge,
samples: []sample{{labels: []label{{name: "version", value: runtime.Version()}}, value: 1}},
},
family{
name: "go_memstats_last_gc_time_seconds",
help: "Number of seconds since 1970 of last garbage collection.",
typ: typeGauge,
samples: []sample{{
value: float64(gc.LastGC.UnixNano()) / float64(time.Second),
}},
},
family{
name: "go_threads",
help: "Number of OS threads created.",
typ: typeGauge,
samples: []sample{{value: float64(threadCount())}},
},
)
}
func threadCount() int {
n, _ := runtime.ThreadCreateProfile(nil)
return n
}
+193
View File
@@ -0,0 +1,193 @@
// 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 text
import (
"math"
"runtime"
"runtime/metrics"
"strings"
"sync"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
dto "github.com/prometheus/client_model/go"
"github.com/stretchr/testify/require"
)
// TestMemStatsMapping pins the runtime/metrics to MemStats mapping field by
// field. The sample values are made up so that every combination shows up: tiny
// allocations are added to both Mallocs and Frees, and the combined fields
// (HeapInuse, HeapIdle, HeapSys, StackSys, MSpanSys, MCacheSys) add their parts.
func TestMemStatsMapping(t *testing.T) {
samples := map[string]uint64{
rmGCHeapTinyAllocsObjects: 3,
rmGCHeapAllocsObjects: 100,
rmGCHeapFreesObjects: 40,
rmGCHeapAllocsBytes: 1 << 20,
rmGCHeapObjects: 900,
rmGCHeapGoalBytes: 4 << 20,
rmMemoryClassesTotalBytes: 50 << 20,
rmMemoryClassesHeapObjectsBytes: 2 << 20,
rmMemoryClassesHeapUnusedBytes: 3 << 20,
rmMemoryClassesHeapReleasedBytes: 5 << 20,
rmMemoryClassesHeapFreeBytes: 7 << 20,
rmMemoryClassesHeapStacksBytes: 1 << 20,
rmMemoryClassesOSStacksBytes: 2 << 20,
rmMemoryClassesMetadataMSpanInuseBytes: 1 << 19,
rmMemoryClassesMetadataMSpanFreeBytes: 1 << 18,
rmMemoryClassesMetadataMCacheInuseBytes: 1 << 17,
rmMemoryClassesMetadataMCacheFreeBytes: 1 << 16,
rmMemoryClassesProfilingBucketsBytes: 1 << 15,
rmMemoryClassesMetadataOtherBytes: 1 << 14,
rmMemoryClassesOtherBytes: 1 << 13,
}
lookup := func(name string) uint64 { return samples[name] }
var ms runtime.MemStats
memStatsFromRM(&ms, lookup)
// Expectations are keyed by family name, so this also checks that each family
// reads the field the mapping fills.
want := map[string]float64{
"go_memstats_alloc_bytes": 2 << 20,
"go_memstats_alloc_bytes_total": 1 << 20,
"go_memstats_buck_hash_sys_bytes": 1 << 15,
"go_memstats_frees_total": 43, // 40 frees + 3 tiny allocs
"go_memstats_gc_sys_bytes": 1 << 14,
"go_memstats_heap_alloc_bytes": 2 << 20,
"go_memstats_heap_idle_bytes": 12 << 20, // released + free
"go_memstats_heap_inuse_bytes": 5 << 20, // objects + unused
"go_memstats_heap_objects": 900,
"go_memstats_heap_released_bytes": 5 << 20,
"go_memstats_heap_sys_bytes": 17 << 20, // inuse + idle
"go_memstats_lookups_total": 0,
"go_memstats_mallocs_total": 103, // 100 mallocs + 3 tiny allocs
"go_memstats_mcache_inuse_bytes": 1 << 17,
"go_memstats_mcache_sys_bytes": 3 << 16, // inuse + free
"go_memstats_mspan_inuse_bytes": 1 << 19,
"go_memstats_mspan_sys_bytes": 3 << 18, // inuse + free
"go_memstats_next_gc_bytes": 4 << 20,
"go_memstats_other_sys_bytes": 1 << 13,
"go_memstats_stack_inuse_bytes": 1 << 20,
"go_memstats_stack_sys_bytes": 3 << 20, // inuse + os-stacks
"go_memstats_sys_bytes": 50 << 20,
}
require.Len(t, want, len(memstatsFamilies), "every family needs an expectation")
for _, m := range memstatsFamilies {
expected, ok := want[m.name]
require.True(t, ok, "%s has no expectation", m.name)
require.Equal(t, expected, m.get(&ms), "%s", m.name)
}
}
// TestMemorySamplesMissingName checks that a runtime which does not expose a
// name reads it as zero instead of failing, the way client_golang behaves.
func TestMemorySamplesMissingName(t *testing.T) {
s := &memorySamples{
samples: []metrics.Sample{{Name: rmGCHeapObjects}},
index: map[string]int{rmGCHeapObjects: 0},
}
require.Equal(t, uint64(0), s.value(rmMemoryClassesTotalBytes))
}
// TestMemStatsSameOrderAsClientGolang compares what frpc reports with what
// client_golang reads from the same process at about the same time. The check is
// deliberately loose: the two collectors sample the runtime one after the other,
// so counters move by whatever the test itself allocates in between, and an exact
// comparison would flake.
func TestMemStatsSameOrderAsClientGolang(t *testing.T) {
reg := prometheus.NewRegistry()
reg.MustRegister(collectors.NewGoCollector())
mfs, err := reg.Gather()
require.NoError(t, err)
ours := parseFamilies(t, gather(t))
checked := 0
for _, mf := range mfs {
name := mf.GetName()
if !strings.HasPrefix(name, "go_memstats_") {
continue
}
if name == "go_memstats_last_gc_time_seconds" {
// A collection that starts between the two reads jumps this value from
// zero to the current time; that is not a difference in behavior.
continue
}
want, ok := ours[name]
require.True(t, ok, "%s is missing", name)
reference := metricValue(t, mf.GetMetric()[0])
got := metricValue(t, want.GetMetric()[0])
require.True(t, sameOrder(reference, got),
"%s differs too much: ours %v, client_golang %v", name, got, reference)
checked++
}
require.Greater(t, checked, 15, "comparison covered too few families")
}
func metricValue(t *testing.T, m *dto.Metric) float64 {
t.Helper()
if g := m.GetGauge(); g != nil {
return g.GetValue()
}
if c := m.GetCounter(); c != nil {
return c.GetValue()
}
t.Fatalf("unexpected metric type for %s", m.String())
return 0
}
// sameOrder reports whether two readings of the same quantity are close enough
// that they cannot be different quantities: equal, within 64 KiB (drift while
// the test allocates), or within a factor of ten.
func sameOrder(a, b float64) bool {
if a == b {
return true
}
if math.Abs(a-b) <= 1<<16 {
return true
}
if a <= 0 || b <= 0 {
return false
}
ratio := a / b
return ratio > 0.1 && ratio < 10
}
// TestConcurrentScrapes covers the shared runtime/metrics sample buffer: scrapes
// run on the listener's goroutines and must not race. Run with -race to check it.
func TestConcurrentScrapes(t *testing.T) {
reg := NewRegistry()
reg.RegisterRuntime()
var wg sync.WaitGroup
for range 8 {
wg.Go(func() {
for range 20 {
var b strings.Builder
if err := reg.WritePrometheus(&b); err != nil {
t.Errorf("scrape failed: %v", err)
return
}
if !strings.Contains(b.String(), "go_memstats_heap_objects") {
t.Error("scrape is missing go_memstats_heap_objects")
return
}
}
})
}
wg.Wait()
}
+163
View File
@@ -0,0 +1,163 @@
// 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 text writes Prometheus metrics in the text exposition format.
//
// frp uses it for frpc, which only exposes Go runtime metrics. Implementing
// that subset here keeps client_golang and its protobuf dependency out of the
// frpc binary; frps keeps using client_golang, which also gives it the
// business metrics and the HTTP handler instrumentation.
package text
import (
"fmt"
"io"
"strconv"
"strings"
)
// ContentType is the content type of the text exposition format.
const ContentType = "text/plain; version=0.0.4; charset=utf-8"
// metricType is the Prometheus metric type.
type metricType string
const (
typeCounter metricType = "counter"
typeGauge metricType = "gauge"
typeSummary metricType = "summary"
)
type label struct {
name string
value string
}
// sample is one time series. name is empty for plain families, where the
// family name is used; summaries set it to carry the _sum and _count suffixes.
type sample struct {
name string
labels []label
value float64
}
// family is one metric family ready to be encoded.
type family struct {
name string
help string
typ metricType
samples []sample
}
// encode writes families in the order they are given.
func encode(w io.Writer, families []family) error {
for _, f := range families {
if _, err := fmt.Fprintf(w, "# HELP %s %s\n", f.name, escapeHelp(f.help)); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "# TYPE %s %s\n", f.name, f.typ); err != nil {
return err
}
for _, s := range f.samples {
if err := encodeSample(w, f.name, s); err != nil {
return err
}
}
}
return nil
}
func encodeSample(w io.Writer, familyName string, s sample) error {
name := s.name
if name == "" {
name = familyName
}
if _, err := io.WriteString(w, name); err != nil {
return err
}
if len(s.labels) > 0 {
if err := encodeLabels(w, s.labels); err != nil {
return err
}
}
_, err := fmt.Fprintf(w, " %s\n", formatValue(s.value))
return err
}
func encodeLabels(w io.Writer, labels []label) error {
if _, err := io.WriteString(w, "{"); err != nil {
return err
}
for i, l := range labels {
if i > 0 {
if _, err := io.WriteString(w, ","); err != nil {
return err
}
}
if _, err := fmt.Fprintf(w, `%s="%s"`, l.name, escapeLabelValue(l.value)); err != nil {
return err
}
}
_, err := io.WriteString(w, "}")
return err
}
// escapeLabelValue applies the three escapes the text format defines for
// label values.
func escapeLabelValue(v string) string {
if !strings.ContainsAny(v, "\\\"\n") {
return v
}
var b strings.Builder
b.Grow(len(v) + 8)
for _, r := range v {
switch r {
case '\\':
b.WriteString(`\\`)
case '"':
b.WriteString(`\"`)
case '\n':
b.WriteString(`\n`)
default:
b.WriteRune(r)
}
}
return b.String()
}
// escapeHelp applies the two escapes the text format defines for help text.
func escapeHelp(v string) string {
if !strings.ContainsAny(v, "\\\n") {
return v
}
var b strings.Builder
b.Grow(len(v) + 8)
for _, r := range v {
switch r {
case '\\':
b.WriteString(`\\`)
case '\n':
b.WriteString(`\n`)
default:
b.WriteRune(r)
}
}
return b.String()
}
// formatValue renders a float the way the Prometheus text format does, so a
// sample reads the same whichever implementation produced it.
func formatValue(v float64) string {
return strconv.FormatFloat(v, 'g', -1, 64)
}
+124
View File
@@ -0,0 +1,124 @@
// 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 text
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
dto "github.com/prometheus/client_model/go"
"github.com/prometheus/common/expfmt"
"github.com/stretchr/testify/require"
)
// gather returns what frpc would serve.
func gather(t *testing.T) string {
t.Helper()
reg := NewRegistry()
reg.RegisterRuntime()
var b strings.Builder
require.NoError(t, reg.WritePrometheus(&b))
return b.String()
}
// parseFamilies decodes the text exposition format with the Prometheus parser,
// so anything a scraper would reject fails the test.
func parseFamilies(t *testing.T, body string) map[string]*dto.MetricFamily {
t.Helper()
parser := expfmt.TextParser{}
families, err := parser.TextToMetricFamilies(strings.NewReader(body))
require.NoError(t, err, "output must be valid text exposition format")
return families
}
// TestMatchesClientGolang checks that every go_* family this package emits has
// the same name, type and help text as client_golang's default Go collector. The
// official collector is used as the oracle, so a difference shows up here rather
// than in someone's dashboard.
func TestMatchesClientGolang(t *testing.T) {
reg := prometheus.NewRegistry()
reg.MustRegister(collectors.NewGoCollector())
mfs, err := reg.Gather()
require.NoError(t, err)
reference := make(map[string]*dto.MetricFamily, len(mfs))
for _, mf := range mfs {
reference[mf.GetName()] = mf
}
ours := parseFamilies(t, gather(t))
checked := 0
for name, want := range reference {
if !strings.HasPrefix(name, "go_") {
continue
}
got, ok := ours[name]
require.True(t, ok, "%s is missing", name)
require.Equal(t, want.GetType(), got.GetType(), "%s type", name)
require.Equal(t, want.GetHelp(), got.GetHelp(), "%s help", name)
checked++
}
require.Greater(t, checked, 20, "comparison covered too few families")
}
// TestNoBusinessMetrics pins what frpc must not expose: no frp_ business metrics,
// none of the handler instrumentation client_golang adds, and no process_
// families. frpc reports Go runtime metrics only, on every platform.
func TestNoBusinessMetrics(t *testing.T) {
body := gather(t)
require.NotContains(t, body, "frp_")
require.NotContains(t, body, "promhttp_")
require.NotContains(t, body, "process_")
}
func TestHandler(t *testing.T) {
reg := NewRegistry()
reg.RegisterRuntime()
rec := httptest.NewRecorder()
reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, ContentType, rec.Header().Get("Content-Type"))
families := parseFamilies(t, rec.Body.String())
require.Contains(t, families, "go_goroutines")
gc, ok := families["go_gc_duration_seconds"]
require.True(t, ok)
require.Equal(t, dto.MetricType_SUMMARY, gc.GetType())
require.Len(t, gc.GetMetric()[0].GetSummary().GetQuantile(), 5)
}
func TestEmptyRegistry(t *testing.T) {
var b strings.Builder
require.NoError(t, NewRegistry().WritePrometheus(&b))
require.Empty(t, b.String())
}
func TestEscaping(t *testing.T) {
var b strings.Builder
require.NoError(t, encode(&b, []family{{
name: "test_labels",
help: "help with \\ and\nnewline",
typ: typeGauge,
samples: []sample{{labels: []label{{name: "v", value: "a\"b\\c\nd"}}, value: 1}},
}}))
require.Equal(t, "# HELP test_labels help with \\\\ and\\nnewline\n"+
"# TYPE test_labels gauge\n"+
"test_labels{v=\"a\\\"b\\\\c\\nd\"} 1\n", b.String())
}
+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")
})
}
}
+20 -3
View File
@@ -51,8 +51,9 @@ func rewriteHTTPPluginRequest(
}
type httpBridgePlugin struct {
l *Listener
s *http.Server
l *Listener
s *http.Server
httpsServer *httpsserver.Server
useSourceRemoteAddr bool
}
@@ -87,7 +88,8 @@ func newHTTPSBridgePluginServer(
}
p := &httpBridgePlugin{
l: listener,
s: server,
s: server.Server,
httpsServer: server,
useSourceRemoteAddr: useSourceRemoteAddr,
}
go func() {
@@ -124,3 +126,18 @@ func (p *httpBridgePlugin) Close() error {
_ = p.l.Close()
return err
}
// Reload refreshes only resources owned by this plugin; HTTP bridges are no-op.
func (p *httpBridgePlugin) Reload() error {
if p.httpsServer == nil {
return nil
}
return p.httpsServer.Reload()
}
func (p *httpBridgePlugin) ReloadError() error {
if p.httpsServer == nil {
return nil
}
return p.httpsServer.ReloadError()
}
@@ -17,9 +17,13 @@
package httpsserver
import (
"bytes"
"crypto/tls"
"fmt"
"net/http"
"os"
"sync"
"sync/atomic"
"time"
"github.com/samber/lo"
@@ -28,13 +32,38 @@ import (
httppkg "github.com/fatedier/frp/pkg/util/http"
)
func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*http.Server, error) {
tlsConfig, err := transport.NewServerTLSConfig(crtPath, keyPath, "")
// snapshot is immutable after publication. Errors retain the last usable pair.
type snapshot struct {
cert *tls.Certificate
certPEM, keyPEM []byte
err error
}
type Server struct {
*http.Server
crtPath, keyPath string
reloadMu sync.Mutex
state atomic.Pointer[snapshot]
}
func New(handler http.Handler, crtPath, keyPath string, enableHTTP2 *bool) (*Server, error) {
server := &Server{crtPath: crtPath, keyPath: keyPath}
var tlsConfig *tls.Config
var err error
if crtPath == "" || keyPath == "" {
tlsConfig, err = transport.NewServerTLSConfig(crtPath, keyPath, "")
} else {
err = server.Reload()
tlsConfig = &tls.Config{GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
return server.state.Load().cert, nil
}}
}
if err != nil {
return nil, fmt.Errorf("gen TLS config error: %v", err)
}
server := &http.Server{
server.Server = &http.Server{
Handler: withMisdirectedRequestCheck(handler),
ReadHeaderTimeout: 60 * time.Second,
TLSConfig: tlsConfig,
@@ -58,3 +87,51 @@ func withMisdirectedRequestCheck(handler http.Handler) http.Handler {
handler.ServeHTTP(w, r)
})
}
func (s *Server) Reload() (err error) {
if s.crtPath == "" || s.keyPath == "" {
return nil
}
s.reloadMu.Lock()
defer s.reloadMu.Unlock()
old := s.state.Load()
// Publish the error before releasing reloadMu; status never waits for I/O.
defer func() {
if err != nil {
next := snapshot{err: err}
if old != nil {
next.cert, next.certPEM, next.keyPEM = old.cert, old.certPEM, old.keyPEM
}
s.state.Store(&next)
}
}()
certPEM, err := os.ReadFile(s.crtPath)
if err != nil {
return fmt.Errorf("read certificate: %w", err)
}
keyPEM, err := os.ReadFile(s.keyPath)
if err != nil {
return fmt.Errorf("read private key: %w", err)
}
if old != nil && old.cert != nil && bytes.Equal(certPEM, old.certPEM) && bytes.Equal(keyPEM, old.keyPEM) {
if old.err != nil {
next := *old
next.err = nil
s.state.Store(&next)
}
return nil
}
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return fmt.Errorf("parse certificate pair: %w", err)
}
s.state.Store(&snapshot{cert: &cert, certPEM: certPEM, keyPEM: keyPEM})
return nil
}
func (s *Server) ReloadError() error {
if v := s.state.Load(); v != nil {
return v.err
}
return nil
}
@@ -0,0 +1,153 @@
// 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 httpsserver
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"io"
"math/big"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func certificatePEM(t *testing.T, serial int64) ([]byte, []byte) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: big.NewInt(serial),
DNSNames: []string{"localhost"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
require.NoError(t, err)
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
require.NoError(t, err)
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
}
func handshake(t *testing.T, cfg *tls.Config) (*tls.Conn, *tls.Conn) {
t.Helper()
clientRaw, serverRaw := net.Pipe()
t.Cleanup(func() { _ = clientRaw.Close(); _ = serverRaw.Close() })
require.NoError(t, clientRaw.SetDeadline(time.Now().Add(5*time.Second)))
require.NoError(t, serverRaw.SetDeadline(time.Now().Add(5*time.Second)))
client := tls.Client(clientRaw, &tls.Config{InsecureSkipVerify: true})
server := tls.Server(serverRaw, cfg)
done := make(chan error, 1)
go func() { done <- server.Handshake() }()
require.NoError(t, client.Handshake())
require.NoError(t, <-done)
return client, server
}
func TestReloadCertificate(t *testing.T) {
dir := t.TempDir()
crtPath, keyPath := filepath.Join(dir, "tls.crt"), filepath.Join(dir, "tls.key")
certPEM, keyPEM := certificatePEM(t, 1)
require.NoError(t, os.WriteFile(crtPath, certPEM, 0o600))
require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600))
s, err := New(nil, crtPath, keyPath, nil)
require.NoError(t, err)
originalState := s.state.Load()
original := originalState.cert
oldClient, oldServer := handshake(t, s.TLSConfig)
require.Equal(t, int64(1), oldClient.ConnectionState().PeerCertificates[0].SerialNumber.Int64())
require.NoError(t, s.Reload())
require.Same(t, originalState, s.state.Load(), "unchanged bytes must not parse or publish a new pair")
require.NoError(t, s.ReloadError())
for _, tc := range []struct {
name, path string
data []byte
}{
{"missing certificate", crtPath, nil},
{"missing key", keyPath, nil},
{"invalid certificate", crtPath, []byte("invalid")},
{"invalid key", keyPath, []byte("invalid")},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.data == nil {
require.NoError(t, os.Remove(tc.path))
} else {
require.NoError(t, os.WriteFile(tc.path, tc.data, 0o600))
}
require.Error(t, s.Reload())
require.Error(t, s.ReloadError())
require.Same(t, original, s.state.Load().cert)
client, _ := handshake(t, s.TLSConfig)
require.Equal(t, int64(1), client.ConnectionState().PeerCertificates[0].SerialNumber.Int64())
require.NoError(t, os.WriteFile(crtPath, certPEM, 0o600))
require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600))
require.NoError(t, s.Reload())
require.NoError(t, s.ReloadError())
require.Same(t, original, s.state.Load().cert)
})
}
// Even a key-only byte change must be noticed, regardless of parsed identity.
require.NoError(t, os.WriteFile(keyPath, append(keyPEM, '\n'), 0o600))
require.NoError(t, s.Reload())
require.NotSame(t, original, s.state.Load().cert)
nextCert, nextKey := certificatePEM(t, 2)
require.NoError(t, os.WriteFile(crtPath, nextCert, 0o600))
beforeMismatch := s.state.Load().cert
require.Error(t, s.Reload(), "mismatched pair must retain the active certificate")
require.Same(t, beforeMismatch, s.state.Load().cert)
require.NoError(t, os.WriteFile(keyPath, nextKey, 0o600))
beforeReload, _ := handshake(t, s.TLSConfig)
require.Equal(t, int64(1), beforeReload.ConnectionState().PeerCertificates[0].SerialNumber.Int64())
require.NoError(t, s.Reload())
newClient, _ := handshake(t, s.TLSConfig)
require.Equal(t, int64(2), newClient.ConnectionState().PeerCertificates[0].SerialNumber.Int64())
require.NoError(t, oldClient.SetDeadline(time.Now().Add(5*time.Second)))
require.NoError(t, oldServer.SetDeadline(time.Now().Add(5*time.Second)))
done := make(chan error, 1)
go func() { _, err := oldServer.Write([]byte("still connected")); done <- err }()
data := make([]byte, len("still connected"))
_, err = io.ReadFull(oldClient, data)
require.NoError(t, err)
require.NoError(t, <-done)
require.Equal(t, "still connected", string(data))
}
func TestReloadCertificateKeepsSelfSigned(t *testing.T) {
missing := filepath.Join(t.TempDir(), "missing")
for _, paths := range [][2]string{{"", ""}, {missing, ""}, {"", missing}} {
s, err := New(nil, paths[0], paths[1], nil)
require.NoError(t, err)
original := &s.TLSConfig.Certificates[0]
require.Nil(t, s.TLSConfig.GetCertificate)
require.NoError(t, s.Reload())
require.Same(t, original, &s.TLSConfig.Certificates[0])
}
}
+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())
}
+49 -5
View File
@@ -80,17 +80,23 @@ func newRandomTLSKeyPair() (*tls.Certificate, error) {
// Only support one ca file to add
func newCertPool(caPath string) (*x509.CertPool, error) {
pool := x509.NewCertPool()
if err := appendCertsFromFile(pool, caPath); err != nil {
return nil, err
}
return pool, nil
}
func appendCertsFromFile(pool *x509.CertPool, caPath string) error {
caCrt, err := os.ReadFile(caPath)
if err != nil {
return nil, err
return err
}
if !pool.AppendCertsFromPEM(caCrt) {
return nil, fmt.Errorf("failed to parse CA certificate from file %q: no valid PEM certificates found", caPath)
return fmt.Errorf("failed to parse CA certificate from file %q: no valid PEM certificates found", caPath)
}
return pool, nil
return nil
}
func NewServerTLSConfig(certPath, keyPath, caPath string) (*tls.Config, error) {
@@ -126,6 +132,21 @@ func NewServerTLSConfig(certPath, keyPath, caPath string) (*tls.Config, error) {
}
func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Config, error) {
return NewClientTLSConfigWithSystemCA(certPath, keyPath, caPath, serverName, false)
}
// NewClientTLSConfigWithSystemCA enables verification against system CAs when
// useSystemCa is true, adding certificates from caPath if it is also set.
// Without either trust source, server certificate verification remains disabled.
func NewClientTLSConfigWithSystemCA(certPath, keyPath, caPath, serverName string, useSystemCa bool) (*tls.Config, error) {
return newClientTLSConfig(certPath, keyPath, caPath, serverName, useSystemCa, x509.SystemCertPool)
}
func newClientTLSConfig(
certPath, keyPath, caPath, serverName string,
useSystemCa bool,
systemCertPool func() (*x509.CertPool, error),
) (*tls.Config, error) {
base := &tls.Config{}
if certPath != "" && keyPath != "" {
@@ -139,7 +160,26 @@ func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Conf
base.ServerName = serverName
if caPath != "" {
switch {
case caPath != "" && useSystemCa:
// SystemCertPool returns a copy, so appending does not change the system pool.
// Design basis: Go 1.26.7 crypto/x509/verify.go:559-573 first uses the
// Windows/macOS verifier, then falls back to Go verification using only the
// added CAs if platform verification fails. This platform behavior has not been
// tested here; preserve the system pool instead of copying its subjects.
pool, err := systemCertPool()
if err != nil {
return nil, fmt.Errorf("failed to load system CA pool: %w", err)
}
if pool == nil {
return nil, fmt.Errorf("failed to load system CA pool: no pool returned")
}
if err := appendCertsFromFile(pool, caPath); err != nil {
return nil, err
}
base.RootCAs = pool
base.InsecureSkipVerify = false
case caPath != "":
pool, err := newCertPool(caPath)
if err != nil {
return nil, err
@@ -147,7 +187,11 @@ func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Conf
base.RootCAs = pool
base.InsecureSkipVerify = false
} else {
case useSystemCa:
// A nil RootCAs makes crypto/tls use the host root store, through the
// platform verifier on macOS and Windows.
base.InsecureSkipVerify = false
default:
base.InsecureSkipVerify = true
}
+250
View File
@@ -0,0 +1,250 @@
// 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 transport
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"math/big"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func newSelfSignedCert(t *testing.T, name string) (tls.Certificate, []byte) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
template := x509.Certificate{
SerialNumber: big.NewInt(1),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
Subject: pkix.Name{CommonName: name},
DNSNames: []string{name},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
}
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
require.NoError(t, err)
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
cert, err := tls.X509KeyPair(certPEM, keyPEM)
require.NoError(t, err)
return cert, certPEM
}
func TestNewClientTLSConfigVerification(t *testing.T) {
_, systemPEM := newSelfSignedCert(t, "system.example.com")
_, filePEM := newSelfSignedCert(t, "file.example.com")
caFile := filepath.Join(t.TempDir(), "ca.crt")
require.NoError(t, os.WriteFile(caFile, filePEM, 0o600))
systemPool := x509.NewCertPool()
require.True(t, systemPool.AppendCertsFromPEM(systemPEM))
filePool := x509.NewCertPool()
require.True(t, filePool.AppendCertsFromPEM(filePEM))
combinedPool := systemPool.Clone()
require.True(t, combinedPool.AppendCertsFromPEM(filePEM))
for _, test := range []struct {
name string
caPath string
useSystemCa bool
insecureSkipVerify bool
wantPool *x509.CertPool
}{
{name: "no trust source", insecureSkipVerify: true},
{name: "file CA only", caPath: caFile, wantPool: filePool},
{name: "system CA only", useSystemCa: true},
{name: "system and file CAs", caPath: caFile, useSystemCa: true, wantPool: combinedPool},
} {
t.Run(test.name, func(t *testing.T) {
var loadedPool *x509.CertPool
loads := 0
loadSystemPool := func() (*x509.CertPool, error) {
loads++
loadedPool = systemPool.Clone()
return loadedPool, nil
}
cfg, err := newClientTLSConfig("", "", test.caPath, "example.com", test.useSystemCa, loadSystemPool)
require.NoError(t, err)
require.Equal(t, "example.com", cfg.ServerName)
require.Equal(t, test.insecureSkipVerify, cfg.InsecureSkipVerify)
if test.wantPool == nil {
require.Nil(t, cfg.RootCAs)
} else {
require.NotNil(t, cfg.RootCAs)
require.True(t, cfg.RootCAs.Equal(test.wantPool))
}
if test.caPath != "" && test.useSystemCa {
require.Equal(t, 1, loads)
require.Same(t, loadedPool, cfg.RootCAs, "append to the returned system pool")
} else {
require.Zero(t, loads, "only combined trust needs an explicit system pool")
}
})
}
// The four-argument API keeps the original skip/file-only semantics for
// xtcp visitors and plugins, regardless of the new transport option.
cfg, err := NewClientTLSConfig("", "", "", "example.com")
require.NoError(t, err)
require.True(t, cfg.InsecureSkipVerify)
require.Nil(t, cfg.RootCAs)
cfg, err = NewClientTLSConfig("", "", caFile, "example.com")
require.NoError(t, err)
require.False(t, cfg.InsecureSkipVerify)
require.True(t, cfg.RootCAs.Equal(filePool))
}
func TestNewClientTLSConfigCAErrors(t *testing.T) {
_, caPEM := newSelfSignedCert(t, "localhost")
dir := t.TempDir()
caFile := filepath.Join(dir, "ca.crt")
badFile := filepath.Join(dir, "bad.crt")
require.NoError(t, os.WriteFile(caFile, caPEM, 0o600))
require.NoError(t, os.WriteFile(badFile, []byte("not a PEM certificate"), 0o600))
systemErr := errors.New("system trust store unavailable")
for _, test := range []struct {
name string
caPath string
useSystemCa bool
pool *x509.CertPool
poolErr error
wantErr string
}{
{name: "file missing", caPath: filepath.Join(dir, "missing.crt"), wantErr: "missing.crt"},
{name: "file invalid", caPath: badFile, wantErr: "no valid PEM certificates found"},
{
name: "combined file missing", caPath: filepath.Join(dir, "missing.crt"), useSystemCa: true,
pool: x509.NewCertPool(), wantErr: "missing.crt",
},
{
name: "combined file invalid", caPath: badFile, useSystemCa: true,
pool: x509.NewCertPool(), wantErr: "no valid PEM certificates found",
},
{
name: "system pool error", caPath: caFile, useSystemCa: true,
pool: x509.NewCertPool(), poolErr: systemErr, wantErr: "failed to load system CA pool",
},
{name: "nil system pool", caPath: caFile, useSystemCa: true, wantErr: "no pool returned"},
} {
t.Run(test.name, func(t *testing.T) {
cfg, err := newClientTLSConfig("", "", test.caPath, "localhost", test.useSystemCa,
func() (*x509.CertPool, error) { return test.pool, test.poolErr })
require.ErrorContains(t, err, test.wantErr)
require.Nil(t, cfg, "do not return a partially configured TLS client or fall back")
if test.poolErr != nil {
require.ErrorIs(t, err, test.poolErr)
}
})
}
}
func TestClientTLSConfigHandshake(t *testing.T) {
systemCert, systemPEM := newSelfSignedCert(t, "localhost")
fileCert, filePEM := newSelfSignedCert(t, "localhost")
untrustedCert, _ := newSelfSignedCert(t, "localhost")
caFile := filepath.Join(t.TempDir(), "ca.crt")
require.NoError(t, os.WriteFile(caFile, filePEM, 0o600))
systemPool := x509.NewCertPool()
require.True(t, systemPool.AppendCertsFromPEM(systemPEM))
for _, server := range []struct {
name string
cert tls.Certificate
}{
{name: "system CA", cert: systemCert},
{name: "file CA", cert: fileCert},
{name: "untrusted CA", cert: untrustedCert},
} {
t.Run(server.name, func(t *testing.T) {
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{server.cert}})
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
_ = conn.(*tls.Conn).Handshake()
_ = conn.Close()
}
}()
for _, test := range []struct {
name string
caPath string
useSystemCa bool
serverName string
wantErr bool
}{
{name: "no trust source"},
{name: "file CA only", caPath: caFile, wantErr: server.name != "file CA"},
// All certificates were just generated and are absent from the real
// host store, even on hosts without any installed system roots.
{name: "system CA only rejects unknown certificate", useSystemCa: true, wantErr: true},
{name: "combined CAs", caPath: caFile, useSystemCa: true, wantErr: server.name == "untrusted CA"},
{
name: "combined CAs reject wrong name", caPath: caFile, useSystemCa: true,
serverName: "other.example.com", wantErr: true,
},
} {
t.Run(test.name, func(t *testing.T) {
serverName := test.serverName
if serverName == "" {
serverName = "localhost"
}
var cfg *tls.Config
var err error
if test.caPath != "" && test.useSystemCa {
cfg, err = newClientTLSConfig("", "", test.caPath, serverName, true,
func() (*x509.CertPool, error) { return systemPool.Clone(), nil })
} else {
cfg, err = NewClientTLSConfigWithSystemCA("", "", test.caPath, serverName, test.useSystemCa)
}
require.NoError(t, err)
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", ln.Addr().String(), cfg)
if conn != nil {
_ = conn.Close()
}
if test.wantErr {
var verificationErr *tls.CertificateVerificationError
require.ErrorAs(t, err, &verificationErr)
} else {
require.NoError(t, err)
}
})
}
})
}
}
+54
View File
@@ -0,0 +1,54 @@
// 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 http
import (
"net/http"
"net/http/pprof"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// NewMetricsServer creates a listener with only health, optional metrics, and
// opt-in profiling endpoints. A zero port disables it and returns a nil server.
func NewMetricsServer(cfg v1.MetricsServerConfig, metricsHandler http.Handler) (*Server, error) {
if cfg.Port == 0 {
return nil, nil
}
s, err := newServer(cfg.Addr, cfg.Port, cfg.User, cfg.Password, cfg.TLS)
if err != nil {
return nil, err
}
s.router.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
authed := s.router.NewRoute().Subrouter()
authed.Use(s.authMiddleware)
if metricsHandler != nil {
authed.Handle("/metrics", metricsHandler)
}
if cfg.PprofEnable {
// Use exact routes rather than a prefix so cmdline and unknown endpoints
// cannot reach pprof.Index's fallback dispatch.
authed.HandleFunc("/debug/pprof/", pprof.Index)
authed.HandleFunc("/debug/pprof/profile", pprof.Profile)
authed.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
authed.HandleFunc("/debug/pprof/trace", pprof.Trace)
for _, name := range []string{"allocs", "block", "goroutine", "heap", "mutex", "threadcreate"} {
authed.Handle("/debug/pprof/"+name, pprof.Handler(name))
}
}
return s, nil
}
+111
View File
@@ -0,0 +1,111 @@
// 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 http
import (
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/assets"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func newTestMetricsServer(t *testing.T, pprofEnabled bool, handler http.Handler) *Server {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
port := ln.Addr().(*net.TCPAddr).Port
require.NoError(t, ln.Close())
s, err := NewMetricsServer(v1.MetricsServerConfig{
Addr: "127.0.0.1", Port: port, User: "monitor", Password: "secret", PprofEnable: pprofEnabled,
}, handler)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, s.Close()) })
return s
}
func TestMetricsServerRoutes(t *testing.T) {
for _, enabled := range []bool{false, true} {
name := "pprof disabled"
if enabled {
name = "pprof enabled"
}
t.Run(name, func(t *testing.T) {
calls := 0
s := newTestMetricsServer(t, enabled, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
_, _ = w.Write([]byte("business metrics"))
}))
for _, authed := range []bool{false, true} {
for _, path := range []string{
"/healthz", "/metrics", "/debug/pprof/", "/debug/pprof/goroutine",
"/debug/pprof/cmdline", "/debug/pprof/unknown", "/", "/static/app.js", "/api/serverinfo", "/api/config",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
if authed {
req.SetBasicAuth("monitor", "secret")
}
resp := httptest.NewRecorder()
s.router.ServeHTTP(resp, req)
expected := http.StatusNotFound
switch path {
case "/healthz":
expected = http.StatusOK
require.Empty(t, resp.Body.String())
case "/metrics":
expected = http.StatusUnauthorized
if authed {
expected = http.StatusOK
require.Equal(t, "business metrics", resp.Body.String())
}
case "/debug/pprof/", "/debug/pprof/goroutine":
if enabled {
expected = http.StatusUnauthorized
if authed {
expected = http.StatusOK
}
}
}
require.Equal(t, expected, resp.Code, "path=%s authenticated=%v", path, authed)
}
}
require.Equal(t, 1, calls, "unauthenticated requests must not reach the metrics handler")
})
}
}
func TestMetricsServerWithoutMetricsPreservesAssets(t *testing.T) {
assets.Load(t.TempDir())
before := assets.FileSystem
t.Cleanup(func() { assets.Load("") })
s := newTestMetricsServer(t, false, nil)
// assets.Load uses http.Dir for local assets, a value rather than a pointer.
require.Equal(t, before, assets.FileSystem)
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
req.SetBasicAuth("monitor", "secret")
resp := httptest.NewRecorder()
s.router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
}
func TestMetricsServerDisabled(t *testing.T) {
s, err := NewMetricsServer(v1.MetricsServerConfig{TLS: &v1.TLSConfig{CertFile: "nonexistent"}}, nil)
require.NoError(t, err)
require.Nil(t, s)
}
+22 -13
View File
@@ -48,15 +48,21 @@ type Server struct {
func NewServer(cfg v1.WebServerConfig) (*Server, error) {
assets.Load(cfg.AssetsDir)
addr := net.JoinHostPort(cfg.Addr, strconv.Itoa(cfg.Port))
if addr == ":" {
addr = ":http"
}
ln, err := net.Listen("tcp", addr)
s, err := newServer(cfg.Addr, cfg.Port, cfg.User, cfg.Password, cfg.TLS)
if err != nil {
return nil, err
}
if cfg.PprofEnable {
s.registerPprofHandlers()
}
return s, nil
}
func newServer(bindAddr string, port int, user, password string, tlsConfig *v1.TLSConfig) (*Server, error) {
addr := net.JoinHostPort(bindAddr, strconv.Itoa(port))
if addr == ":" {
addr = ":http"
}
router := mux.NewRouter()
hs := &http.Server{
@@ -67,15 +73,11 @@ func NewServer(cfg v1.WebServerConfig) (*Server, error) {
}
s := &Server{
addr: addr,
ln: ln,
hs: hs,
router: router,
}
if cfg.PprofEnable {
s.registerPprofHandlers()
}
if cfg.TLS != nil {
cert, err := tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile)
if tlsConfig != nil {
cert, err := tls.LoadX509KeyPair(tlsConfig.CertFile, tlsConfig.KeyFile)
if err != nil {
return nil, err
}
@@ -83,7 +85,14 @@ func NewServer(cfg v1.WebServerConfig) (*Server, error) {
Certificates: []tls.Certificate{cert},
}
}
s.authMiddleware = netpkg.NewHTTPAuthMiddleware(cfg.User, cfg.Password).SetAuthFailDelay(200 * time.Millisecond).Middleware
s.authMiddleware = netpkg.NewHTTPAuthMiddleware(user, password).SetAuthFailDelay(200 * time.Millisecond).Middleware
// Bind only after all fallible preparation so an error cannot leak the listener.
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
s.ln = ln
return s, nil
}
+55
View File
@@ -0,0 +1,55 @@
// 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 http
import (
"net"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestNewServerDoesNotLeakListenerOnTLSFailure(t *testing.T) {
dir := t.TempDir()
certFile := filepath.Join(dir, "cert.pem")
keyFile := filepath.Join(dir, "key.pem")
require.NoError(t, os.WriteFile(certFile, []byte("invalid certificate"), 0o600))
require.NoError(t, os.WriteFile(keyFile, []byte("invalid key"), 0o600))
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
address := ln.Addr().String()
port := ln.Addr().(*net.TCPAddr).Port
require.NoError(t, ln.Close())
svr, err := NewServer(v1.WebServerConfig{
Addr: "127.0.0.1",
Port: port,
TLS: &v1.TLSConfig{CertFile: certFile, KeyFile: keyFile},
})
if svr != nil {
defer svr.Close()
}
require.ErrorContains(t, err, "failed to find any PEM data in certificate input")
require.Nil(t, svr)
ln, err = net.Listen("tcp", address)
require.NoError(t, err, "web server port was not released")
require.NoError(t, ln.Close())
}
+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))
})
}
}
+37 -18
View File
@@ -15,16 +15,21 @@
package net
import (
"fmt"
"errors"
"net"
"sync"
kcp "github.com/xtaci/kcp-go/v5"
)
var ErrKCPListenerClosed = errors.New("kcp listener closed")
type KCPListener struct {
listener net.Listener
acceptCh chan net.Conn
closeFlag bool
listener net.Listener
acceptCh chan net.Conn
done chan struct{}
closeOnce sync.Once
}
func ListenKcp(address string) (l *KCPListener, err error) {
@@ -36,20 +41,21 @@ func ListenKcp(address string) (l *KCPListener, err error) {
_ = listener.SetWriteBuffer(4194304)
l = &KCPListener{
listener: listener,
acceptCh: make(chan net.Conn),
closeFlag: false,
listener: listener,
acceptCh: make(chan net.Conn),
done: make(chan struct{}),
}
go func() {
for {
conn, err := listener.AcceptKCP()
if err != nil {
if l.closeFlag {
close(l.acceptCh)
select {
case <-l.done:
return
default:
continue
}
continue
}
conn.SetStreamMode(true)
conn.SetWriteDelay(true)
@@ -58,25 +64,38 @@ func ListenKcp(address string) (l *KCPListener, err error) {
conn.SetWindowSize(1024, 1024)
conn.SetACKNoDelay(false)
l.acceptCh <- conn
select {
case <-l.done:
conn.Close()
return
case l.acceptCh <- conn:
// A ready handoff may win when Close races with it. Once delivered,
// the connection is owned by the caller of Accept.
}
}
}()
return l, err
}
func (l *KCPListener) Accept() (net.Conn, error) {
conn, ok := <-l.acceptCh
if !ok {
return conn, fmt.Errorf("channel for kcp listener closed")
select {
case <-l.done:
return nil, ErrKCPListenerClosed
default:
}
select {
case <-l.done:
return nil, ErrKCPListenerClosed
case conn := <-l.acceptCh:
return conn, nil
}
return conn, nil
}
func (l *KCPListener) Close() error {
if !l.closeFlag {
l.closeFlag = true
l.closeOnce.Do(func() {
close(l.done)
l.listener.Close()
}
})
return nil
}
+131
View File
@@ -0,0 +1,131 @@
// 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 (
"bytes"
"errors"
"net"
"runtime/pprof"
"strings"
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/require"
kcp "github.com/xtaci/kcp-go/v5"
)
func kcpGoroutineDump(t *testing.T) string {
t.Helper()
var buf bytes.Buffer
require.NoError(t, pprof.Lookup("goroutine").WriteTo(&buf, 2))
return buf.String()
}
func TestKCPListenerCloseCancelsPendingHandoff(t *testing.T) {
l, err := ListenKcp("127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() {
_ = l.Close()
// Also release a stuck handoff when this test runs against the old code.
select {
case conn := <-l.acceptCh:
if conn != nil {
_ = conn.Close()
}
default:
}
})
sessionsBefore := atomic.LoadUint64(&kcp.DefaultSnmp.CurrEstab)
peer, err := kcp.DialWithOptions(l.Addr().String(), nil, 10, 3)
require.NoError(t, err)
t.Cleanup(func() { _ = peer.Close() })
peer.SetNoDelay(1, 10, 2, 1)
require.NoError(t, peer.SetWriteDeadline(time.Now().Add(5*time.Second)))
_, err = peer.Write([]byte("pending handoff"))
require.NoError(t, err)
var loopID string
require.Eventually(t, func() bool {
for stack := range strings.SplitSeq(kcpGoroutineDump(t), "\n\n") {
if !strings.Contains(stack, "github.com/fatedier/frp/pkg/util/net.ListenKcp.func1()") ||
strings.Contains(stack, "github.com/xtaci/kcp-go/v5.(*Listener).AcceptKCP(") {
continue
}
header, _, _ := strings.Cut(stack, "\n")
if strings.Contains(header, "[select]") || strings.Contains(header, "[chan send]") {
loopID = strings.Fields(header)[1]
return true
}
}
return false
}, 5*time.Second, time.Millisecond, "accept loop did not reach the unconsumed handoff")
require.Equal(t, sessionsBefore+2, atomic.LoadUint64(&kcp.DefaultSnmp.CurrEstab))
require.NoError(t, l.Close())
require.Eventually(t, func() bool {
return !strings.Contains(kcpGoroutineDump(t), "goroutine "+loopID+" [")
}, 5*time.Second, time.Millisecond, "accept loop remained blocked after Close")
// kcp-go has no close frame. Its session counter decreases only when the
// session itself is closed; closing the listener socket is not sufficient.
// Keep the client open so this decrement must belong to the pending server session.
require.Equal(t, sessionsBefore+1, atomic.LoadUint64(&kcp.DefaultSnmp.CurrEstab))
require.NoError(t, peer.Close())
require.Equal(t, sessionsBefore, atomic.LoadUint64(&kcp.DefaultSnmp.CurrEstab))
}
func TestKCPListenerAcceptUnblocksOnClose(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
l := &KCPListener{
listener: &closeStubListener{closed: make(chan struct{}), err: errors.New("listener close failed")},
acceptCh: make(chan net.Conn),
done: make(chan struct{}),
}
t.Cleanup(func() { _ = l.Close() })
acceptErr := make(chan error, 1)
go func() {
conn, err := l.Accept()
if conn != nil {
t.Error("Accept returned a connection without a sender")
}
acceptErr <- err
}()
synctest.Wait() // Accept is blocked before Close.
require.Empty(t, acceptErr)
var wg sync.WaitGroup
for range 2 {
wg.Go(func() {
if err := l.Close(); err != nil {
t.Errorf("Close: %v", err)
}
})
}
wg.Wait()
synctest.Wait()
require.Len(t, acceptErr, 1)
require.ErrorIs(t, <-acceptErr, ErrKCPListenerClosed)
require.NoError(t, l.Close())
conn, err := l.Accept()
require.Nil(t, conn)
require.ErrorIs(t, err, ErrKCPListenerClosed)
})
}
+23 -4
View File
@@ -4,6 +4,7 @@ import (
"errors"
"net"
"net/http"
"sync"
"time"
"golang.org/x/net/websocket"
@@ -18,8 +19,11 @@ const (
type WebsocketListener struct {
ln net.Listener
acceptCh chan net.Conn
done chan struct{}
server *http.Server
closeOnce sync.Once
}
// NewWebsocketListener to handle websocket connections
@@ -28,6 +32,7 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
wl = &WebsocketListener{
ln: ln,
acceptCh: make(chan net.Conn),
done: make(chan struct{}),
}
muxer := http.NewServeMux()
@@ -41,7 +46,14 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
conn := WrapCloseNotifyConn(c, func(_ error) {
close(notifyCh)
})
wl.acceptCh <- conn
select {
case <-wl.done:
conn.Close()
return
case wl.acceptCh <- conn:
// A ready handoff may win when Close races with it. Once delivered,
// the connection is owned by the caller of Accept.
}
<-notifyCh
}))
@@ -58,14 +70,21 @@ func NewWebsocketListener(ln net.Listener) (wl *WebsocketListener) {
}
func (p *WebsocketListener) Accept() (net.Conn, error) {
c, ok := <-p.acceptCh
if !ok {
select {
case <-p.done:
return nil, ErrWebsocketListenerClosed
default:
}
select {
case <-p.done:
return nil, ErrWebsocketListenerClosed
case c := <-p.acceptCh:
return c, nil
}
return c, nil
}
func (p *WebsocketListener) Close() error {
p.closeOnce.Do(func() { close(p.done) })
return p.server.Close()
}
+197
View File
@@ -0,0 +1,197 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package net
import (
"bufio"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"sync"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/net/websocket"
)
type closeStubListener struct {
closed chan struct{}
once sync.Once
err error
}
func (l *closeStubListener) Accept() (net.Conn, error) {
<-l.closed
return nil, net.ErrClosed
}
func (l *closeStubListener) Close() error {
l.once.Do(func() { close(l.closed) })
return l.err
}
func (l *closeStubListener) Addr() net.Addr {
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}
}
func TestWebsocketListenerAcceptUnblocksOnClose(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
wl := NewWebsocketListener(&closeStubListener{closed: make(chan struct{})})
t.Cleanup(func() { wl.Close() })
errCh := make(chan error, 3)
for range 3 {
go func() {
c, err := wl.Accept()
if c != nil {
t.Error("Accept returned a connection without a sender")
}
errCh <- err
}()
}
synctest.Wait() // All Accept calls are blocked before Close.
require.Empty(t, errCh)
var wg sync.WaitGroup
for range 3 {
wg.Go(func() {
if err := wl.Close(); err != nil {
t.Errorf("Close: %v", err)
}
})
}
wg.Wait()
synctest.Wait()
require.Len(t, errCh, 3)
for range 3 {
require.ErrorIs(t, <-errCh, ErrWebsocketListenerClosed)
}
c, err := wl.Accept()
require.Nil(t, c)
require.ErrorIs(t, err, ErrWebsocketListenerClosed)
})
}
func TestWebsocketListenerCloseError(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
closeErr := errors.New("listener close failed")
wl := NewWebsocketListener(&closeStubListener{closed: make(chan struct{}), err: closeErr})
synctest.Wait() // Let Serve register its listener before Close.
require.ErrorIs(t, wl.Close(), closeErr)
require.NoError(t, wl.Close())
})
}
type websocketPipeHijacker struct {
*httptest.ResponseRecorder
conn net.Conn
rw *bufio.ReadWriter
}
func (h *websocketPipeHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return h.conn, h.rw, nil
}
func newWebsocketPipe(t *testing.T) (*WebsocketListener, *websocket.Conn, net.Conn, <-chan struct{}) {
t.Helper()
wl := NewWebsocketListener(&closeStubListener{closed: make(chan struct{})})
serverConn, clientConn := net.Pipe()
t.Cleanup(func() {
wl.Close()
serverConn.Close()
clientConn.Close()
})
require.NoError(t, serverConn.SetDeadline(time.Now().Add(time.Second)))
require.NoError(t, clientConn.SetDeadline(time.Now().Add(time.Second)))
handlerDone := make(chan struct{})
go func() {
defer close(handlerDone)
rw := bufio.NewReadWriter(bufio.NewReader(serverConn), bufio.NewWriter(serverConn))
req, err := http.ReadRequest(rw.Reader)
if err != nil {
t.Errorf("ReadRequest: %v", err)
return
}
wl.server.Handler.ServeHTTP(&websocketPipeHijacker{httptest.NewRecorder(), serverConn, rw}, req)
}()
cfg, err := websocket.NewConfig("ws://localhost"+FrpWebsocketPath, "http://localhost")
require.NoError(t, err)
peer, err := websocket.NewClient(cfg, clientConn)
require.NoError(t, err)
return wl, peer, serverConn, handlerDone
}
func TestWebsocketListenerUnacceptedConnectionClose(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
wl, peer, raw, handlerDone := newWebsocketPipe(t)
peerErr := make(chan error, 1)
go func() {
_, err := peer.Read(make([]byte, 1))
peerErr <- err
}()
// No Accept consumer exists. Do not Wait here: it would synchronize
// the handler's send with Close and hide the baseline send/close race.
require.NoError(t, wl.Close())
require.ErrorIs(t, <-peerErr, io.EOF)
<-handlerDone
_, err := raw.Read(make([]byte, 1))
require.ErrorIs(t, err, io.ErrClosedPipe)
})
}
func TestWebsocketListenerAcceptedConnectionOwnership(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
wl, peer, raw, handlerDone := newWebsocketPipe(t)
owner, err := wl.Accept()
require.NoError(t, err)
t.Cleanup(func() {
raw.Close()
owner.Close()
})
require.NoError(t, wl.Close())
payload := []byte{0, 255, 128, 42}
reply := []byte("reply")
gotReply := make([]byte, len(reply))
peerErr := make(chan error, 2)
go func() {
if _, err := peer.Write(payload); err != nil {
peerErr <- err
return
}
_, err := io.ReadFull(peer, gotReply)
peerErr <- err
_, err = peer.Read(make([]byte, 1))
peerErr <- err
}()
got := make([]byte, len(payload))
_, err = io.ReadFull(owner, got)
require.NoError(t, err)
require.Equal(t, payload, got)
_, err = owner.Write(reply)
require.NoError(t, err)
require.NoError(t, <-peerErr)
require.Equal(t, reply, gotReply)
require.NoError(t, owner.Close())
require.NoError(t, owner.Close())
require.ErrorIs(t, <-peerErr, io.EOF)
<-handlerDone
_, err = raw.Read(make([]byte, 1))
require.ErrorIs(t, err, io.ErrClosedPipe)
})
}
+1 -1
View File
@@ -14,7 +14,7 @@
package version
var version = "0.71.0"
var version = "0.71.1"
func Full() string {
return version
+23 -3
View File
@@ -206,13 +206,29 @@ func checkRouteAuthByRequest(req *http.Request, rc *RouteConfig) bool {
}
func (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Request) {
if req.ContentLength > 0 || len(req.TransferEncoding) > 0 {
http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
hj, ok := rw.(http.Hijacker)
if !ok {
rw.WriteHeader(http.StatusInternalServerError)
return
}
client, _, err := hj.Hijack()
// Forward only CONNECT headers; the original Body must not be used after Hijack.
outreq := req.Clone(req.Context())
outreq.Body = nil
outreq.GetBody = nil
outreq.ContentLength = 0
outreq.TransferEncoding = nil
outreq.Trailer = nil
outreq.Header.Del("Content-Length")
outreq.Header.Del("Transfer-Encoding")
outreq.Header.Del("Trailer")
client, buffered, err := hj.Hijack()
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
@@ -224,8 +240,12 @@ func (rp *HTTPReverseProxy) connectHandler(rw http.ResponseWriter, req *http.Req
client.Close()
return
}
_ = req.Write(remote)
go libio.Join(remote, client)
if err := outreq.Write(remote); err != nil {
_ = remote.Close()
_ = client.Close()
return
}
go libio.Join(remote, libio.WrapReadWriteCloser(buffered.Reader, client, client.Close))
}
func getRequestRouteUser(req *http.Request) string {
+249
View File
@@ -3,17 +3,266 @@ package vhost
import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
httppkg "github.com/fatedier/frp/pkg/util/http"
netpkg "github.com/fatedier/frp/pkg/util/net"
)
func TestHTTPConnectTunnel(t *testing.T) {
for _, tc := range []struct {
name string
framing string
pipelined bool
}{
{name: "plain"},
{name: "plain-early", pipelined: true},
{name: "cl0", framing: "Content-Length: 0\r\n"},
{name: "cl0-early", framing: "Content-Length: 0\r\n", pipelined: true},
} {
t.Run(tc.name, func(t *testing.T) {
remote, backend := net.Pipe()
defer remote.Close()
defer backend.Close()
require.NoError(t, backend.SetDeadline(time.Now().Add(5*time.Second)))
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
require.NoError(t, rp.Register(RouteConfig{
Domain: "target.example.com",
Username: "alice",
Password: "secret",
CreateConnFn: func(string) (net.Conn, error) {
return remote, nil
},
}))
listener := netpkg.NewInternalListener()
server := &http.Server{Handler: rp, ReadHeaderTimeout: time.Second}
serveErr := make(chan error, 1)
go func() {
serveErr <- server.Serve(listener)
}()
defer func() {
require.NoError(t, server.Close())
require.ErrorIs(t, <-serveErr, http.ErrServerClosed)
}()
client, incoming := net.Pipe()
defer client.Close()
defer incoming.Close()
require.NoError(t, client.SetDeadline(time.Now().Add(5*time.Second)))
require.NoError(t, listener.PutConn(incoming))
proxyAuth := httppkg.BasicAuth("alice", "secret")
request := "CONNECT target.example.com:443 HTTP/1.1\r\nHost: target.example.com:443\r\n" +
"X-Connect-Test: tunnel\r\nProxy-Authorization: " + proxyAuth + "\r\n" + tc.framing + "\r\n"
if tc.pipelined {
// One pipe write puts the tunnel data in net/http's request buffer.
request += "early data"
}
_, err := io.WriteString(client, request)
require.NoError(t, err)
backendReader := bufio.NewReader(backend)
req, err := http.ReadRequest(backendReader)
require.NoError(t, err)
defer req.Body.Close()
require.Equal(t, http.MethodConnect, req.Method)
require.Equal(t, "target.example.com:443", req.Host)
require.Equal(t, "target.example.com:443", req.URL.Host)
require.Equal(t, "tunnel", req.Header.Get("X-Connect-Test"))
require.Equal(t, proxyAuth, req.Header.Get("Proxy-Authorization"))
require.Zero(t, req.ContentLength)
require.Empty(t, req.TransferEncoding)
if tc.pipelined {
data := make([]byte, len("early data"))
_, err = io.ReadFull(backendReader, data)
require.NoError(t, err)
require.Equal(t, "early data", string(data))
}
writeErr := make(chan error, 1)
go func() {
_, err := io.WriteString(backend, "HTTP/1.1 200 Connection Established\r\n\r\nreply")
writeErr <- err
}()
clientReader := bufio.NewReader(client)
response, err := http.ReadResponse(clientReader, req)
require.NoError(t, err)
defer response.Body.Close()
require.Equal(t, http.StatusOK, response.StatusCode)
reply := make([]byte, len("reply"))
_, err = io.ReadFull(clientReader, reply)
require.NoError(t, err)
require.Equal(t, "reply", string(reply))
require.NoError(t, <-writeErr)
_, err = io.WriteString(client, "later data")
require.NoError(t, err)
data := make([]byte, len("later data"))
_, err = io.ReadFull(backendReader, data)
require.NoError(t, err)
require.Equal(t, "later data", string(data))
})
}
}
func TestHTTPConnectRejectsContent(t *testing.T) {
for _, tc := range []struct {
name string
framing string
body string
}{
{name: "content-length", framing: "Content-Length: 1\r\n", body: "X"},
{name: "chunked", framing: "Transfer-Encoding: chunked\r\n", body: "1\r\nX\r\n0\r\n\r\n"},
} {
t.Run(tc.name, func(t *testing.T) {
backendCalls := 0
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
require.NoError(t, rp.Register(RouteConfig{
Domain: "target.example.com",
CreateConnFn: func(string) (net.Conn, error) {
backendCalls++
return nil, io.ErrClosedPipe
},
}))
req, err := http.ReadRequest(bufio.NewReader(strings.NewReader(
"CONNECT target.example.com:443 HTTP/1.1\r\nHost: target.example.com:443\r\n" + tc.framing + "\r\n" + tc.body)))
require.NoError(t, err)
defer req.Body.Close()
rw := &connectTestResponseWriter{ResponseRecorder: httptest.NewRecorder()}
rp.ServeHTTP(rw, req)
require.Equal(t, http.StatusBadRequest, rw.Code)
require.Zero(t, rw.hijackCalls, "reject content before Hijack")
require.Zero(t, backendCalls, "reject content before creating a backend")
})
}
}
func TestHTTPConnectHeaderWriteFailure(t *testing.T) {
for _, originalBody := range []bool{false, true} {
t.Run(fmt.Sprintf("originalBody=%v", originalBody), func(t *testing.T) {
client, incoming := net.Pipe()
defer client.Close()
defer incoming.Close()
remote, backend := net.Pipe()
defer remote.Close()
defer backend.Close()
clientConn := &connectTestConn{Conn: incoming}
remoteConn := &connectTestConn{Conn: remote, failWrites: true}
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
require.NoError(t, rp.Register(RouteConfig{
Domain: "target.example.com",
CreateConnFn: func(string) (net.Conn, error) {
return remoteConn, nil
},
}))
req := rp.injectRequestInfoToCtx(httptest.NewRequest(http.MethodConnect, "target.example.com:443", nil))
require.Equal(t, "target.example.com:443", req.Host)
body := &connectTestBody{}
if originalBody {
req.Body = body
}
requestBody := req.Body
getBodyCalls := 0
req.GetBody = func() (io.ReadCloser, error) {
getBodyCalls++
return body, nil
}
req.Header.Set("Content-Length", "0")
req.Header.Set("Trailer", "X-Connect-Trailer")
req.Trailer = http.Header{"X-Connect-Trailer": {"value"}}
originalHeader, originalTrailer := req.Header.Clone(), req.Trailer.Clone()
rw := &connectTestResponseWriter{
ResponseRecorder: httptest.NewRecorder(),
conn: clientConn,
buffered: bufio.NewReadWriter(bufio.NewReader(clientConn), bufio.NewWriter(clientConn)),
}
rp.connectHandler(rw, req)
require.Equal(t, 1, rw.hijackCalls)
require.Zero(t, body.reads, "do not read the original Body after Hijack")
require.Zero(t, body.closes, "do not close the original Body after Hijack")
require.Positive(t, remoteConn.writes.Load(), "exercise CONNECT header write failure")
require.True(t, remoteConn.closed.Load(), "close backend on CONNECT header write failure")
require.True(t, clientConn.closed.Load(), "close client on CONNECT header write failure")
require.Zero(t, remoteConn.reads.Load(), "do not start Join after header write failure")
require.Zero(t, clientConn.reads.Load(), "do not start Join after header write failure")
require.Zero(t, getBodyCalls)
require.Equal(t, requestBody, req.Body)
require.NotNil(t, req.GetBody)
require.Equal(t, originalHeader, req.Header)
require.Equal(t, originalTrailer, req.Trailer)
})
}
}
type connectTestResponseWriter struct {
*httptest.ResponseRecorder
conn net.Conn
buffered *bufio.ReadWriter
hijackCalls int
}
func (rw *connectTestResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
rw.hijackCalls++
if rw.conn == nil {
return nil, nil, io.ErrClosedPipe
}
return rw.conn, rw.buffered, nil
}
type connectTestConn struct {
net.Conn
failWrites bool
closed atomic.Bool
reads atomic.Int32
writes atomic.Int32
}
func (c *connectTestConn) Read(p []byte) (int, error) {
c.reads.Add(1)
return c.Conn.Read(p)
}
func (c *connectTestConn) Write(p []byte) (int, error) {
c.writes.Add(1)
if c.failWrites {
return 0, io.ErrClosedPipe
}
return c.Conn.Write(p)
}
func (c *connectTestConn) Close() error {
c.closed.Store(true)
return c.Conn.Close()
}
type connectTestBody struct {
reads int
closes int
}
func (b *connectTestBody) Read([]byte) (int, error) {
b.reads++
return 0, io.ErrUnexpectedEOF
}
func (b *connectTestBody) Close() error {
b.closes++
return nil
}
func TestHTTPServerProtocols(t *testing.T) {
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
protocols := new(http.Protocols)
+29 -10
View File
@@ -17,6 +17,8 @@ package xlog
import (
"cmp"
"slices"
"sync"
"sync/atomic"
"github.com/fatedier/frp/pkg/util/log"
)
@@ -30,11 +32,13 @@ type LogPrefix struct {
Priority int
}
// Logger is not thread safety for operations on prefix
// Logger supports concurrent logging and prefix updates.
// A Logger must not be copied after first use.
type Logger struct {
mu sync.Mutex
prefixes []LogPrefix
prefixString string
prefixString atomic.Pointer[string]
}
func New() *Logger {
@@ -44,9 +48,11 @@ func New() *Logger {
}
func (l *Logger) ResetPrefixes() (old []LogPrefix) {
l.mu.Lock()
defer l.mu.Unlock()
old = l.prefixes
l.prefixes = make([]LogPrefix, 0)
l.prefixString = ""
l.prefixString.Store(nil)
return
}
@@ -59,6 +65,8 @@ func (l *Logger) AppendPrefix(prefix string) *Logger {
}
func (l *Logger) AddPrefix(prefix LogPrefix) *Logger {
l.mu.Lock()
defer l.mu.Unlock()
found := false
if prefix.Priority <= 0 {
prefix.Priority = 10
@@ -78,39 +86,50 @@ func (l *Logger) AddPrefix(prefix LogPrefix) *Logger {
return l
}
// The caller must hold l.mu or exclusively own an unpublished logger.
func (l *Logger) renderPrefixString() {
slices.SortStableFunc(l.prefixes, func(a, b LogPrefix) int {
return cmp.Compare(a.Priority, b.Priority)
})
l.prefixString = ""
var prefixString string
for _, v := range l.prefixes {
l.prefixString += "[" + v.Value + "] "
prefixString += "[" + v.Value + "] "
}
l.prefixString.Store(&prefixString)
}
func (l *Logger) Spawn() *Logger {
nl := New()
l.mu.Lock()
nl.prefixes = append(nl.prefixes, l.prefixes...)
l.mu.Unlock()
nl.renderPrefixString()
return nl
}
func (l *Logger) getPrefixString() string {
if prefix := l.prefixString.Load(); prefix != nil {
return *prefix
}
return ""
}
func (l *Logger) Errorf(format string, v ...any) {
log.Logger.WithPrefix(l.prefixString).Errorf(format, v...)
log.Logger.WithPrefix(l.getPrefixString()).Errorf(format, v...)
}
func (l *Logger) Warnf(format string, v ...any) {
log.Logger.WithPrefix(l.prefixString).Warnf(format, v...)
log.Logger.WithPrefix(l.getPrefixString()).Warnf(format, v...)
}
func (l *Logger) Infof(format string, v ...any) {
log.Logger.WithPrefix(l.prefixString).Infof(format, v...)
log.Logger.WithPrefix(l.getPrefixString()).Infof(format, v...)
}
func (l *Logger) Debugf(format string, v ...any) {
log.Logger.WithPrefix(l.prefixString).Debugf(format, v...)
log.Logger.WithPrefix(l.getPrefixString()).Debugf(format, v...)
}
func (l *Logger) Tracef(format string, v ...any) {
log.Logger.WithPrefix(l.prefixString).Tracef(format, v...)
log.Logger.WithPrefix(l.getPrefixString()).Tracef(format, v...)
}
+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.
package xlog
import (
"sync"
"testing"
)
func TestLoggerConcurrentPrefixAccess(t *testing.T) {
captureLogs(t)
xl := New().AddPrefix(LogPrefix{Name: "runID", Value: "old-run"})
start := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
<-start
xl.AddPrefix(LogPrefix{Name: "runID", Value: "new-run"})
xl.AppendPrefix("proxy")
xl.ResetPrefixes()
}()
go func() {
defer wg.Done()
<-start
xl.Spawn().Tracef("spawn")
xl.Errorf("error")
xl.Warnf("warn")
xl.Infof("info")
xl.Debugf("debug")
xl.Tracef("trace")
}()
close(start)
wg.Wait()
}
+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)
}
+3 -7
View File
@@ -615,7 +615,7 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
name: "tcp remote port",
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
Type: "tcp",
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
TCP: &model.V2TCPProxySpec{RemotePort: new(6000)},
}},
q: "6000",
want: true,
@@ -624,7 +624,7 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
name: "udp remote port",
item: model.V2ProxyResp{Name: "udp-proxy", Spec: model.V2ProxySpec{
Type: "udp",
UDP: &model.V2UDPProxySpec{RemotePort: v2TestIntPtr(7000)},
UDP: &model.V2UDPProxySpec{RemotePort: new(7000)},
}},
q: "7000",
want: true,
@@ -633,7 +633,7 @@ func TestMatchV2ProxyQueryMatchesSpecFields(t *testing.T) {
name: "remote port does not match colon form",
item: model.V2ProxyResp{Name: "tcp-proxy", Spec: model.V2ProxySpec{
Type: "tcp",
TCP: &model.V2TCPProxySpec{RemotePort: v2TestIntPtr(6000)},
TCP: &model.V2TCPProxySpec{RemotePort: new(6000)},
}},
q: ":6000",
want: false,
@@ -767,10 +767,6 @@ func TestLegacyAPIResponsesRemainBare(t *testing.T) {
}
}
func v2TestIntPtr(value int) *int {
return &value
}
func newV2TestController(t *testing.T) *Controller {
t.Helper()
+108
View File
@@ -0,0 +1,108 @@
// 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 server
import (
"context"
"fmt"
"io"
"net"
"net/http"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestServiceMetricsServerWithoutWebServer(t *testing.T) {
cfg := &v1.ServerConfig{
BindAddr: "127.0.0.1", ProxyBindAddr: "127.0.0.1",
MetricsServer: v1.MetricsServerConfig{Port: freeLifecycleTCPPort(t)},
}
require.NoError(t, cfg.Complete())
cfg.BindPort = 0
svr, err := NewService(cfg)
require.NoError(t, err)
require.Nil(t, svr.webServer)
address := svr.metricsServer.Address()
done := make(chan struct{})
go func() { svr.Run(context.Background()); close(done) }()
defer func() {
require.NoError(t, svr.Close())
waitForSignal(t, done, "metrics service shutdown")
assertLifecyclePorts(t, []string{address}, true)
}()
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get("http://" + address + "/healthz")
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Empty(t, body)
}
func TestNewServiceReleasesResourcesOnMetricsServerFailure(t *testing.T) {
for _, mode := range []string{"port conflict", "invalid TLS certificate"} {
t.Run(mode, func(t *testing.T) {
cfg := &v1.ServerConfig{BindAddr: "127.0.0.1", ProxyBindAddr: "127.0.0.1"}
require.NoError(t, cfg.Complete())
// Reserve distinct ports together, including the second HTTP listener.
ports := []*int{&cfg.BindPort, &cfg.VhostHTTPPort, &cfg.WebServer.Port, &cfg.MetricsServer.Port}
reservations := make([]net.Listener, 0, len(ports))
addresses := make([]string, 0, len(ports))
for _, port := range ports {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
*port = ln.Addr().(*net.TCPAddr).Port
reservations = append(reservations, ln)
addresses = append(addresses, ln.Addr().String())
}
for _, ln := range reservations[:len(reservations)-1] {
require.NoError(t, ln.Close())
}
if mode == "invalid TLS certificate" {
require.NoError(t, reservations[len(reservations)-1].Close())
cfg.MetricsServer.TLS = &v1.TLSConfig{
CertFile: filepath.Join(t.TempDir(), "missing.crt"), KeyFile: "missing.key",
}
} else {
// This port belongs to the test, not to the failed service.
addresses = addresses[:len(addresses)-1]
}
workersBefore := countPortManagerWorkers(t)
for range 5 {
svr, err := NewService(cfg)
if svr != nil {
_ = svr.Close()
}
require.Error(t, err)
require.Nil(t, svr)
if mode == "port conflict" {
require.ErrorContains(t, err, fmt.Sprint(cfg.MetricsServer.Port))
} else {
require.ErrorContains(t, err, "missing.crt")
}
assertLifecyclePorts(t, addresses, true)
}
require.Equal(t, workersBefore, countPortManagerWorkers(t), "failed construction leaked port-manager workers")
})
}
}
+1 -3
View File
@@ -139,9 +139,7 @@ func (pxy *HTTPProxy) GetRealConn(remoteAddr string) (workConn net.Conn, err err
}
if pxy.GetLimiter() != nil {
rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), func() error {
return rwc.Close()
})
rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), rwc.Close)
}
workConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn)
+1 -3
View File
@@ -308,9 +308,7 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) {
}
if pxy.GetLimiter() != nil {
local = libio.WrapReadWriteCloser(limit.NewReader(local, pxy.GetLimiter()), limit.NewWriter(local, pxy.GetLimiter()), func() error {
return local.Close()
})
local = libio.WrapReadWriteCloser(limit.NewReader(local, pxy.GetLimiter()), limit.NewWriter(local, pxy.GetLimiter()), local.Close)
}
xl.Debugf("join connections, workConn(l[%s] r[%s]) userConn(l[%s] r[%s])", workConn.LocalAddr().String(),
+1 -3
View File
@@ -217,9 +217,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) {
}
if pxy.GetLimiter() != nil {
rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), func() error {
return rwc.Close()
})
rwc = libio.WrapReadWriteCloser(limit.NewReader(rwc, pxy.GetLimiter()), limit.NewWriter(rwc, pxy.GetLimiter()), rwc.Close)
}
pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn)
+115 -34
View File
@@ -25,11 +25,13 @@ import (
"net/http"
"os"
"strconv"
"sync"
"time"
"github.com/fatedier/golib/crypto"
"github.com/fatedier/golib/net/mux"
fmux "github.com/hashicorp/yamux"
fmux "github.com/fatedier/yamux"
"github.com/prometheus/client_golang/prometheus/promhttp"
quic "github.com/quic-go/quic-go"
"github.com/samber/lo"
@@ -119,7 +121,11 @@ type Service struct {
rc *controller.ResourceController
// web server for dashboard UI and apis
webServer *httppkg.Server
webServer *httppkg.Server
metricsServer *httppkg.Server
// HTTP vhost resources are started and owned by NewService.
httpVhostServer *http.Server
httpVhostListener net.Listener
sshTunnelGateway *ssh.Gateway
@@ -133,7 +139,10 @@ type Service struct {
// service context
ctx context.Context
// call cancel to stop service
cancel context.CancelFunc
cancel context.CancelFunc
lifecycleMu sync.Mutex
closed bool
closeOnce sync.Once
}
func NewService(cfg *v1.ServerConfig) (*Service, error) {
@@ -145,20 +154,6 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
return nil, err
}
var webServer *httppkg.Server
if cfg.WebServer.Port > 0 {
ws, err := httppkg.NewServer(cfg.WebServer)
if err != nil {
return nil, err
}
webServer = ws
modelmetrics.EnableMem()
if cfg.EnablePrometheus {
modelmetrics.EnablePrometheus()
}
}
authRuntime, err := auth.BuildServerAuth(&cfg.Auth)
if err != nil {
return nil, err
@@ -172,20 +167,14 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
pluginManager: plugin.NewManager(),
rc: &controller.ResourceController{
VisitorManager: visitor.NewManager(),
TCPPortManager: ports.NewManager("tcp", cfg.ProxyBindAddr, cfg.AllowPorts),
UDPPortManager: ports.NewManager("udp", cfg.ProxyBindAddr, cfg.AllowPorts),
},
sshTunnelListener: netpkg.NewInternalListener(),
httpVhostRouter: vhost.NewRouters(),
auth: authRuntime,
webServer: webServer,
tlsConfig: tlsConfig,
cfg: cfg,
ctx: context.Background(),
}
if webServer != nil {
webServer.RouteRegister(svr.registerRouteHandlers)
}
// Create tcpmux httpconnect multiplexer.
if cfg.TCPMuxHTTPConnectPort > 0 {
@@ -210,9 +199,6 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
}
svr.rc.PluginManager = svr.pluginManager
// Init group controller
svr.rc.TCPGroupCtl = group.NewTCPGroupCtl(svr.rc.TCPPortManager)
// Init HTTP group controller
svr.rc.HTTPGroupCtl = group.NewHTTPGroupController(svr.httpVhostRouter)
@@ -319,6 +305,8 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
return nil, fmt.Errorf("create vhost http listener error, %v", err)
}
}
svr.httpVhostServer = server
svr.httpVhostListener = l
go func() {
_ = server.Serve(l)
}()
@@ -360,16 +348,88 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
return nil, fmt.Errorf("create nat hole controller error, %v", err)
}
svr.rc.NatHoleController = nc
// Create HTTP servers after all other fallible steps, rolling back listeners
// if either server cannot be created.
if cfg.WebServer.Port > 0 {
ws, err := httppkg.NewServer(cfg.WebServer)
if err != nil {
// All other listeners are already created and must be released on failure.
_ = svr.Close()
return nil, err
}
svr.webServer = ws
}
if cfg.MetricsServer.Port > 0 {
ms, err := httppkg.NewMetricsServer(cfg.MetricsServer, promhttp.Handler())
if err != nil {
_ = svr.Close()
return nil, err
}
svr.metricsServer = ms
}
// Collectors are enabled at most once. The in-memory collector is only read
// by the dashboard API, and Prometheus metrics need an endpoint to serve them.
enableMem, enablePrometheus := metricsCollectors(cfg)
if enableMem {
modelmetrics.EnableMem()
}
if enablePrometheus {
modelmetrics.EnablePrometheus()
}
// Start port-manager cleanup workers only after all fallible steps have succeeded.
svr.rc.TCPPortManager = ports.NewManager("tcp", cfg.ProxyBindAddr, cfg.AllowPorts)
svr.rc.UDPPortManager = ports.NewManager("udp", cfg.ProxyBindAddr, cfg.AllowPorts)
svr.rc.TCPGroupCtl = group.NewTCPGroupCtl(svr.rc.TCPPortManager)
if svr.webServer != nil {
svr.webServer.RouteRegister(svr.registerRouteHandlers)
}
return svr, nil
}
// metricsCollectors reports which collectors should be started for cfg. The
// in-memory collector only backs the dashboard API (webServer), while the
// Prometheus collector needs a listener that serves /metrics: either the new
// metricsServer, or the legacy webServer endpoint gated by enablePrometheus.
func metricsCollectors(cfg *v1.ServerConfig) (mem bool, prometheus bool) {
mem = cfg.WebServer.Port > 0
prometheus = cfg.MetricsServer.Port > 0 || (cfg.EnablePrometheus && mem)
return mem, prometheus
}
func (svr *Service) Run(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
svr.lifecycleMu.Lock()
if svr.closed {
svr.lifecycleMu.Unlock()
cancel()
return
}
svr.ctx = ctx
svr.cancel = cancel
svr.lifecycleMu.Unlock()
defer svr.Close()
if ctx.Err() != nil {
return
}
// Cancellation must unblock the synchronous main listener's Accept.
stopClose := context.AfterFunc(ctx, func() { _ = svr.Close() })
defer stopClose()
// run dashboard web server.
if svr.webServer != nil {
if svr.cfg.WebServer.PprofEnable {
log.Warnf("webServer pprof endpoints are deprecated; use metricsServer.pprofEnable instead")
}
if svr.cfg.EnablePrometheus {
log.Warnf("webServer /metrics is deprecated; configure metricsServer.port instead")
}
if svr.cfg.MetricsServer.Port == 0 {
log.Infof("webServer /healthz is deprecated; health probes can migrate to metricsServer.port")
}
go func() {
log.Infof("dashboard listen on %s", svr.webServer.Address())
if err := svr.webServer.Run(); err != nil {
@@ -378,6 +438,15 @@ func (svr *Service) Run(ctx context.Context) {
}()
}
if svr.metricsServer != nil {
go func() {
log.Infof("metrics server listen on %s", svr.metricsServer.Address())
if err := svr.metricsServer.Run(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Warnf("metrics server exit with error: %v", err)
}
}()
}
go svr.HandleListener(svr.sshTunnelListener, true)
if svr.kcpListener != nil {
@@ -400,13 +469,21 @@ func (svr *Service) Run(ctx context.Context) {
svr.HandleListener(svr.listener, false)
<-svr.ctx.Done()
// service context may not be canceled by svr.Close(), we should call it here to release resources
if svr.listener != nil {
svr.Close()
}
}
func (svr *Service) Close() error {
svr.lifecycleMu.Lock()
svr.closed = true
cancel := svr.cancel
svr.lifecycleMu.Unlock()
if cancel != nil {
cancel()
}
svr.closeOnce.Do(svr.closeResources)
return nil
}
func (svr *Service) closeResources() {
if svr.kcpListener != nil {
svr.kcpListener.Close()
}
@@ -428,16 +505,20 @@ func (svr *Service) Close() error {
if svr.webServer != nil {
svr.webServer.Close()
}
if svr.metricsServer != nil {
svr.metricsServer.Close()
}
if svr.httpVhostServer != nil {
svr.httpVhostServer.Close()
// Close even if Serve has not registered its listener yet.
svr.httpVhostListener.Close()
}
if svr.sshTunnelGateway != nil {
svr.sshTunnelGateway.Close()
}
svr.rc.Close()
svr.muxer.Close()
svr.ctlManager.Close()
if svr.cancel != nil {
svr.cancel()
}
return nil
}
func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, internal bool) {
+362
View File
@@ -0,0 +1,362 @@
package server
import (
"bytes"
"context"
"fmt"
"net"
"os"
"path/filepath"
"runtime/pprof"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
)
type lifecycleListener struct {
net.Listener
accepted, closeEntered, closeResume chan struct{}
acceptOnce, closeOnce sync.Once
accepts, closes atomic.Int64
}
func (l *lifecycleListener) Accept() (net.Conn, error) {
l.accepts.Add(1)
l.acceptOnce.Do(func() { close(l.accepted) })
return l.Listener.Accept()
}
func (l *lifecycleListener) Close() error {
l.closes.Add(1)
err := l.Listener.Close()
if l.closeEntered != nil {
l.closeOnce.Do(func() { close(l.closeEntered) })
<-l.closeResume
}
return err
}
func freeLifecycleTCPPort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
func newLifecycleTestService(t *testing.T, web bool) (*Service, *lifecycleListener, []string) {
t.Helper()
cfg := &v1.ServerConfig{
BindAddr: "127.0.0.1", ProxyBindAddr: "127.0.0.1",
}
if web {
cfg.WebServer = v1.WebServerConfig{Addr: "127.0.0.1", Port: freeLifecycleTCPPort(t)}
cfg.VhostHTTPPort = freeLifecycleTCPPort(t)
}
require.NoError(t, cfg.Complete())
cfg.BindPort = 0
_, err := validation.NewConfigValidator(nil).ValidateServerConfig(cfg)
require.NoError(t, err)
svr, err := NewService(cfg)
require.NoError(t, err)
l := &lifecycleListener{Listener: svr.listener, accepted: make(chan struct{})}
svr.listener = l
addresses := []string{l.Addr().String()}
if web {
addresses = append(addresses, svr.webServer.Address())
addresses = append(addresses, net.JoinHostPort(cfg.ProxyBindAddr, fmt.Sprint(cfg.VhostHTTPPort)))
}
assertLifecyclePorts(t, addresses, false)
return svr, l, addresses
}
func assertLifecyclePorts(t *testing.T, addresses []string, released bool) {
t.Helper()
for _, address := range addresses {
ln, err := net.Listen("tcp", address)
if ln != nil {
_ = ln.Close()
}
if released {
require.NoError(t, err, "listener %s was not released", address)
} else {
require.Error(t, err, "listener %s was not bound", address)
}
}
}
func TestNewServiceDoesNotLeakWebServerOnBindFailure(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer ln.Close()
cfg := &v1.ServerConfig{
BindAddr: "127.0.0.1",
BindPort: ln.Addr().(*net.TCPAddr).Port,
ProxyBindAddr: "127.0.0.1",
WebServer: v1.WebServerConfig{Addr: "127.0.0.1", Port: freeLifecycleTCPPort(t)},
}
require.NoError(t, cfg.Complete())
svr, err := NewService(cfg)
if svr != nil {
defer svr.Close()
}
require.ErrorContains(t, err, "create server listener error")
require.ErrorContains(t, err, ln.Addr().String())
require.Nil(t, svr)
assertLifecyclePorts(t, []string{net.JoinHostPort(cfg.WebServer.Addr, fmt.Sprint(cfg.WebServer.Port))}, true)
}
func countPortManagerWorkers(t *testing.T) int {
t.Helper()
var profile bytes.Buffer
require.NoError(t, pprof.Lookup("goroutine").WriteTo(&profile, 2))
count := 0
for stack := range strings.SplitSeq(profile.String(), "\n\n") {
if strings.Contains(stack, "github.com/fatedier/frp/server/ports.(*Manager).cleanReservedPortsWorker") {
count++
}
}
return count
}
func TestNewServiceDoesNotLeakPortManagerWorkersOnWebServerFailure(t *testing.T) {
for _, mode := range []string{"port conflict", "invalid TLS certificate"} {
t.Run(mode, func(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer ln.Close()
cfg := &v1.ServerConfig{
BindAddr: "127.0.0.1",
ProxyBindAddr: "127.0.0.1",
WebServer: v1.WebServerConfig{Addr: "127.0.0.1", Port: ln.Addr().(*net.TCPAddr).Port},
}
require.NoError(t, cfg.Complete())
cfg.BindPort = 0
if mode == "invalid TLS certificate" {
dir := t.TempDir()
certFile := filepath.Join(dir, "cert.pem")
keyFile := filepath.Join(dir, "key.pem")
require.NoError(t, os.WriteFile(certFile, []byte("invalid certificate"), 0o600))
require.NoError(t, os.WriteFile(keyFile, []byte("invalid key"), 0o600))
cfg.WebServer.TLS = &v1.TLSConfig{CertFile: certFile, KeyFile: keyFile}
require.NoError(t, ln.Close())
}
workersBefore := countPortManagerWorkers(t)
for range 5 {
svr, err := NewService(cfg)
if svr != nil {
defer svr.Close()
}
require.Nil(t, svr)
if mode == "port conflict" {
var listenErr *net.OpError
require.ErrorAs(t, err, &listenErr)
require.Equal(t, "listen", listenErr.Op)
require.Equal(t, ln.Addr().String(), listenErr.Addr.String())
} else {
require.ErrorContains(t, err, "failed to find any PEM data in certificate input")
}
}
workersAfter := countPortManagerWorkers(t)
t.Logf("port-manager workers after 5 failed constructions: before=%d after=%d", workersBefore, workersAfter)
require.Equal(t, workersBefore, workersAfter, "failed construction leaked port-manager workers")
})
}
}
func TestNewServiceReleasesListenersOnWebServerFailure(t *testing.T) {
for _, mode := range []string{"port conflict", "invalid TLS certificate"} {
t.Run(mode, func(t *testing.T) {
cfg := &v1.ServerConfig{
BindAddr: "127.0.0.1",
ProxyBindAddr: "127.0.0.1",
WebServer: v1.WebServerConfig{Addr: "127.0.0.1"},
}
require.NoError(t, cfg.Complete())
dir := t.TempDir()
cfg.SSHTunnelGateway.AutoGenPrivateKeyPath = filepath.Join(dir, "ssh_key")
// Hold reservations together so each listener gets a distinct port.
tcpPorts := []*int{
&cfg.BindPort, &cfg.TCPMuxHTTPConnectPort, &cfg.SSHTunnelGateway.BindPort,
&cfg.VhostHTTPPort, &cfg.VhostHTTPSPort, &cfg.WebServer.Port,
}
tcpReservations := make([]net.Listener, 0, len(tcpPorts))
tcpAddresses := make([]string, 0, len(tcpPorts))
for _, port := range tcpPorts {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
*port = ln.Addr().(*net.TCPAddr).Port
tcpReservations = append(tcpReservations, ln)
tcpAddresses = append(tcpAddresses, ln.Addr().String())
}
udpPorts := []*int{&cfg.KCPBindPort, &cfg.QUICBindPort}
udpReservations := make([]net.PacketConn, 0, len(udpPorts))
udpAddresses := make([]string, 0, len(udpPorts))
for _, port := range udpPorts {
conn, err := net.ListenPacket("udp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
*port = conn.LocalAddr().(*net.UDPAddr).Port
udpReservations = append(udpReservations, conn)
udpAddresses = append(udpAddresses, conn.LocalAddr().String())
}
webReservation := tcpReservations[len(tcpReservations)-1]
if mode == "invalid TLS certificate" {
certFile := filepath.Join(dir, "cert.pem")
keyFile := filepath.Join(dir, "key.pem")
require.NoError(t, os.WriteFile(certFile, []byte("invalid certificate"), 0o600))
require.NoError(t, os.WriteFile(keyFile, []byte("invalid key"), 0o600))
cfg.WebServer.TLS = &v1.TLSConfig{CertFile: certFile, KeyFile: keyFile}
require.NoError(t, webReservation.Close())
}
for _, ln := range tcpReservations[:len(tcpReservations)-1] {
require.NoError(t, ln.Close())
}
for _, conn := range udpReservations {
require.NoError(t, conn.Close())
}
svr, err := NewService(cfg)
if svr != nil {
defer svr.Close()
}
require.Nil(t, svr)
if mode == "port conflict" {
var listenErr *net.OpError
require.ErrorAs(t, err, &listenErr)
require.Equal(t, "listen", listenErr.Op)
require.Equal(t, webReservation.Addr().String(), listenErr.Addr.String())
assertLifecyclePorts(t, []string{webReservation.Addr().String()}, false)
require.NoError(t, webReservation.Close())
} else {
require.ErrorContains(t, err, "failed to find any PEM data in certificate input")
}
assertLifecyclePorts(t, tcpAddresses, true)
for _, address := range udpAddresses {
conn, err := net.ListenPacket("udp", address)
require.NoError(t, err, "UDP listener %s was not released", address)
require.NoError(t, conn.Close())
}
})
}
}
func TestServiceRunAfterEarlyShutdown(t *testing.T) {
for _, mode := range []string{"close before Run", "close before Run with web", "parent already cancelled"} {
t.Run(mode, func(t *testing.T) {
svr, listener, addresses := newLifecycleTestService(t, mode == "close before Run with web")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
defer svr.Close()
if mode != "parent already cancelled" {
require.NoError(t, svr.Close())
assertLifecyclePorts(t, addresses, true)
} else {
cancel()
}
done := make(chan struct{})
go func() { svr.Run(ctx); close(done) }()
waitForSignal(t, done, "Run after early shutdown")
require.Zero(t, listener.accepts.Load(), "closed service started accepting")
require.Equal(t, int64(1), listener.closes.Load())
assertLifecyclePorts(t, addresses, true)
})
}
}
func TestMetricsCollectors(t *testing.T) {
for _, tc := range []struct {
name string
webPort, metricsPort int
enablePrometheus bool
wantMem, wantPrometheus bool
}{
{name: "nothing enabled"},
{name: "web server only", webPort: 7500, wantMem: true},
{
name: "web server with enablePrometheus", webPort: 7500, enablePrometheus: true,
wantMem: true, wantPrometheus: true,
},
{
// enablePrometheus alone has no endpoint to serve metrics from, so the
// Prometheus collector must stay off, as it did before metricsServer.
name: "enablePrometheus without listeners", enablePrometheus: true,
},
{
// The in-memory collector only backs the dashboard API.
name: "metrics server only", metricsPort: 7501, wantPrometheus: true,
},
{
name: "both servers", webPort: 7500, metricsPort: 7501,
wantMem: true, wantPrometheus: true,
},
{
name: "both servers with enablePrometheus", webPort: 7500, metricsPort: 7501, enablePrometheus: true,
wantMem: true, wantPrometheus: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := &v1.ServerConfig{
WebServer: v1.WebServerConfig{Port: tc.webPort},
MetricsServer: v1.MetricsServerConfig{Port: tc.metricsPort},
EnablePrometheus: tc.enablePrometheus,
}
mem, prometheus := metricsCollectors(cfg)
require.Equal(t, tc.wantMem, mem, "EnableMem")
require.Equal(t, tc.wantPrometheus, prometheus, "EnablePrometheus")
})
}
}
func TestServiceCloseMarksClosedBeforeCleanup(t *testing.T) {
svr, listener, addresses := newLifecycleTestService(t, false)
listener.closeEntered, listener.closeResume = make(chan struct{}), make(chan struct{})
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(listener.closeResume) }) }
defer release()
closeDone := make(chan struct{})
go func() { _ = svr.Close(); close(closeDone) }()
waitForSignal(t, listener.closeEntered, "Close cleanup barrier")
runDone := make(chan struct{})
go func() { svr.Run(context.Background()); close(runDone) }()
waitForSignal(t, runDone, "Run to observe closed before cleanup finishes")
require.Zero(t, listener.accepts.Load(), "Run started during cleanup")
assertLifecyclePorts(t, addresses, false)
release()
waitForSignal(t, closeDone, "Close cleanup to finish")
assertLifecyclePorts(t, addresses, true)
}
func TestServiceParentCancellationAndCloseShareCleanup(t *testing.T) {
svr, listener, addresses := newLifecycleTestService(t, false)
listener.closeEntered, listener.closeResume = make(chan struct{}), make(chan struct{})
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(listener.closeResume) }) }
defer release()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runDone := make(chan struct{})
go func() { svr.Run(ctx); close(runDone) }()
waitForSignal(t, listener.accepted, "main listener Accept")
cancel()
waitForSignal(t, listener.closeEntered, "parent cancellation to close main listener")
closeDone := make(chan struct{})
go func() { _ = svr.Close(); close(closeDone) }()
release()
waitForSignal(t, closeDone, "external Close to finish")
waitForSignal(t, runDone, "Run cleanup to finish")
require.Equal(t, int64(1), listener.closes.Load(), "cleanup ran more than once")
assertLifecyclePorts(t, addresses, true)
}
+87 -14
View File
@@ -192,19 +192,52 @@ transport.wireProtocol = "v2"
})
})
var _ = ginkgo.Describe("[Compatibility: BinaryUDPPacket]", func() {
var _ = ginkgo.Describe("[Compatibility: UDPDefault]", func() {
f := framework.NewDefaultFramework()
ginkgo.It("current frps exchanges UDP with baseline frpc using the default wire protocol", func() {
portName := port.GenName("CompatDefaultUDPBaselineFRPC")
f.RunProcessesWithBinaries(
compatCtx.CurrentFRPSPath,
compatCtx.BaselineFRPCPath,
consts.DefaultServerConfig,
[]string{udpClientConfig(portName, "")},
)
framework.NewRequestExpect(f).Protocol("udp").PortName(portName).Ensure()
})
ginkgo.It("baseline frps exchanges UDP with current frpc defaulting to v1", func() {
portName := port.GenName("CompatDefaultUDPBaselineFRPS")
f.RunProcessesWithBinaries(
compatCtx.BaselineFRPSPath,
compatCtx.CurrentFRPCPath,
consts.DefaultServerConfig,
[]string{udpClientConfig(portName, "")},
)
framework.NewRequestExpect(f).Protocol("udp").PortName(portName).Ensure()
})
})
var _ = ginkgo.Describe("[Compatibility: UDPWireV2]", func() {
f := framework.NewDefaultFramework()
ginkgo.BeforeEach(func() {
supportsV2, knownVersion := baselineSupportsControlWireProtocolV2(compatCtx.BaselineVersion)
if !knownVersion || !supportsV2 {
ginkgo.Skip(fmt.Sprintf("baseline version %q does not have known wire protocol v2 support", compatCtx.BaselineVersion))
supportsV2, knownVersion := baselineSupportsUDPWireProtocolV2(compatCtx.BaselineVersion)
if !knownVersion {
ginkgo.Skip(fmt.Sprintf("baseline version %q has unknown ordinary UDP wire v2 framing support", compatCtx.BaselineVersion))
}
if !supportsV2 {
// v0.69.0 supports control v2 but still uses legacy ordinary UDP framing.
// Mixing that early v2 implementation with the v0.69.1 framing was not supported.
ginkgo.Skip(fmt.Sprintf("baseline %q predates ordinary UDP wire v2 framing (v0.69.1); default UDP is tested separately",
compatCtx.BaselineVersion))
}
})
ginkgo.It("current frps falls back to JSON for baseline frpc", func() {
portName := port.GenName("CompatBinaryUDPBaselineFRPC")
clientConf := udpClientConfig("udp", portName, `transport.wireProtocol = "v2"`)
// The negotiated payload codec may be JSON or binary-v1; both use v2 framing.
ginkgo.It("current frps exchanges v2-framed UDP with baseline frpc", func() {
portName := port.GenName("CompatV2UDPBaselineFRPC")
clientConf := udpClientConfig(portName, `transport.wireProtocol = "v2"`)
f.RunProcessesWithBinaries(
compatCtx.CurrentFRPSPath,
compatCtx.BaselineFRPCPath,
@@ -214,9 +247,9 @@ var _ = ginkgo.Describe("[Compatibility: BinaryUDPPacket]", func() {
framework.NewRequestExpect(f).Protocol("udp").PortName(portName).Ensure()
})
ginkgo.It("current frpc falls back to JSON for baseline frps", func() {
portName := port.GenName("CompatBinaryUDPBaselineFRPS")
clientConf := udpClientConfig("udp", portName, `transport.wireProtocol = "v2"`)
ginkgo.It("current frpc exchanges v2-framed UDP with baseline frps", func() {
portName := port.GenName("CompatV2UDPBaselineFRPS")
clientConf := udpClientConfig(portName, `transport.wireProtocol = "v2"`)
f.RunProcessesWithBinaries(
compatCtx.BaselineFRPSPath,
compatCtx.CurrentFRPCPath,
@@ -243,7 +276,7 @@ remotePort = {{ .%s }}
`, consts.PortServerName, extra, proxyName, framework.TCPEchoServerPort, remotePortName)
}
func udpClientConfig(proxyName string, remotePortName string, extra string) string {
func udpClientConfig(remotePortName string, extra string) string {
return fmt.Sprintf(`
serverAddr = "127.0.0.1"
serverPort = {{ .%s }}
@@ -252,11 +285,11 @@ log.level = "trace"
%s
[[proxies]]
name = "%s"
name = "udp"
type = "udp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
`, consts.PortServerName, extra, proxyName, framework.UDPEchoServerPort, remotePortName)
`, consts.PortServerName, extra, framework.UDPEchoServerPort, remotePortName)
}
func expectProcessExit(p *process.Process, timeout time.Duration) {
@@ -268,6 +301,14 @@ func expectProcessExit(p *process.Process, timeout time.Duration) {
}
func baselineSupportsControlWireProtocolV2(version string) (supports bool, known bool) {
return baselineVersionAtLeast(version, [3]int{0, 69, 0})
}
func baselineSupportsUDPWireProtocolV2(version string) (supports bool, known bool) {
return baselineVersionAtLeast(version, [3]int{0, 69, 1})
}
func baselineVersionAtLeast(version string, minimum [3]int) (supports bool, known bool) {
version = strings.TrimPrefix(version, "v")
parts := strings.Split(version, ".")
if len(parts) != 3 {
@@ -287,7 +328,39 @@ func baselineSupportsControlWireProtocolV2(version string) (supports bool, known
return false, false
}
return compareSemanticVersion(major, minor, patch, 0, 69, 0) >= 0, true
return compareSemanticVersion(major, minor, patch, minimum[0], minimum[1], minimum[2]) >= 0, true
}
func TestBaselineWireProtocolSupport(t *testing.T) {
for _, tc := range []struct {
version string
known bool
controlV2 bool
udpV2 bool
}{
{version: "0.61.0", known: true},
{version: "0.68.1", known: true},
{version: "0.69.0", known: true, controlV2: true},
{version: "v0.69.0", known: true, controlV2: true},
{version: "0.69.1", known: true, controlV2: true, udpV2: true},
{version: "v0.69.1", known: true, controlV2: true, udpV2: true},
{version: "0.70.0", known: true, controlV2: true, udpV2: true},
{version: "0.71.0", known: true, controlV2: true, udpV2: true},
{version: "1.0.0", known: true, controlV2: true, udpV2: true},
{version: "custom"},
{version: "0.69"},
{version: "0.69.x"},
{version: "0.69.1-rc.1"},
} {
t.Run(tc.version, func(t *testing.T) {
if supports, known := baselineSupportsControlWireProtocolV2(tc.version); supports != tc.controlV2 || known != tc.known {
t.Errorf("control v2 support = (%v, %v), want (%v, %v)", supports, known, tc.controlV2, tc.known)
}
if supports, known := baselineSupportsUDPWireProtocolV2(tc.version); supports != tc.udpV2 || known != tc.known {
t.Errorf("UDP v2 framing support = (%v, %v), want (%v, %v)", supports, known, tc.udpV2, tc.known)
}
})
}
}
func compareSemanticVersion(major, minor, patch int, baseMajor, baseMinor, basePatch int) int {
+4
View File
@@ -177,6 +177,10 @@ var _ = ginkgo.Describe("[Feature: Client-Server]", func() {
client: "transport.tls.enable = false",
expectError: true,
})
defineClientServerTest("useSystemCa rejects the default self-signed server certificate", f, &generalTestConfigures{
client: "transport.tls.useSystemCa = true",
expectError: true,
})
})
ginkgo.Describe("TLS with custom certificate", func() {
+81
View File
@@ -2,6 +2,9 @@ package features
import (
"fmt"
"io"
"net"
"strconv"
"strings"
"time"
@@ -105,4 +108,82 @@ var _ = ginkgo.Describe("[Feature: Bandwidth Limit]", func() {
framework.ExpectTrue(duration.Seconds() > 8, "100Kb with 10KB limit, want > 8 seconds, but got %s", duration.String())
})
ginkgo.It("Server Bandwidth Limit closes work connection when user disconnects", func() {
newFunc := func() *plugin.Request {
var r plugin.Request
r.Content = &plugin.NewProxyContent{}
return &r
}
pluginPort := f.AllocPort()
handler := func(req *plugin.Request) *plugin.Response {
var ret plugin.Response
content := req.Content.(*plugin.NewProxyContent)
content.BandwidthLimit = "10MB"
content.BandwidthLimitMode = "server"
ret.Content = content
return &ret
}
pluginServer := pluginpkg.NewHTTPPluginServer(pluginPort, newFunc, handler, nil)
f.RunServer("", pluginServer)
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
[[httpPlugins]]
name = "test"
addr = "127.0.0.1:%d"
path = "/handler"
ops = ["NewProxy"]
`, pluginPort)
clientConf := consts.DefaultClientConfig
// The local server echoes the first message, then stays idle until the
// connection from frpc is closed.
localClosed := make(chan struct{})
localPort := f.AllocPort()
localServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(localPort),
streamserver.WithCustomHandler(func(c net.Conn) {
defer c.Close()
buf := make([]byte, 1024)
n, err := c.Read(buf)
if err != nil {
return
}
if _, err := c.Write(buf[:n]); err != nil {
return
}
_, _ = io.Copy(io.Discard, c)
close(localClosed)
}))
f.RunServer("", localServer)
remotePort := f.AllocPort()
clientConf += fmt.Sprintf(`
[[proxies]]
name = "tcp"
type = "tcp"
localPort = %d
remotePort = %d
`, localPort, remotePort)
f.RunProcesses(serverConf, []string{clientConf})
conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(remotePort)))
framework.ExpectNoError(err)
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
_, err = conn.Write([]byte("ping"))
framework.ExpectNoError(err)
buf := make([]byte, 4)
_, err = io.ReadFull(conn, buf)
framework.ExpectNoError(err)
framework.ExpectEqual(string(buf), "ping")
conn.Close()
select {
case <-localClosed:
case <-time.After(5 * time.Second):
framework.Failf("local connection is not closed after user connection is closed")
}
})
})
+231 -40
View File
@@ -1,14 +1,20 @@
package features
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"sync"
"time"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/fatedier/frp/pkg/sdk/client"
"github.com/fatedier/frp/pkg/transport"
"github.com/fatedier/frp/test/e2e/framework"
"github.com/fatedier/frp/test/e2e/framework/consts"
@@ -17,6 +23,80 @@ import (
"github.com/fatedier/frp/test/e2e/pkg/request"
)
func waitForProxyStatus(proxyClient *client.Client, proxyName, want string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ticker := time.NewTicker(25 * time.Millisecond)
defer ticker.Stop()
var lastStatus string
var lastErr error
for {
status, err := proxyClient.GetProxyStatus(ctx, proxyName)
if err == nil {
lastErr = nil
lastStatus = status.Status
if status.Status == want {
return nil
}
} else {
lastErr = err
}
select {
case <-ctx.Done():
if lastErr != nil {
return fmt.Errorf("timeout waiting for proxy %q status %q: last error: %w", proxyName, want, lastErr)
}
return fmt.Errorf("timeout waiting for proxy %q status %q: last status %q", proxyName, want, lastStatus)
case <-ticker.C:
}
}
}
func waitForSignal(signal <-chan struct{}) error {
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
select {
case <-signal:
return nil
case <-timer.C:
return fmt.Errorf("timeout waiting for backend health-check signal")
}
}
// Keep this longer than client/proxy.statusCheckInterval (3s). The wrapper's
// health notification is deliberately non-blocking, so the E2E assertion must
// also cover the fallback poll plus scheduling margin while recovery is gated.
const proxyFallbackObservationWindow = 4 * time.Second
func waitForServerProxyStatus(port int, proxyName, want string, timeout time.Duration) error {
return waitForLifecycleCondition(timeout, func() error {
body, err := getLifecycleEndpoint(port, "/api/proxies/"+url.PathEscape(proxyName))
if err != nil {
return err
}
var status struct {
Status string `json:"status"`
}
if err := json.Unmarshal([]byte(body), &status); err != nil {
return err
}
if status.Status != want {
return fmt.Errorf("frps proxy %q status %q, want %q", proxyName, status.Status, want)
}
return nil
})
}
type httpHealthStage struct {
healthy bool
signal chan<- struct{}
release <-chan struct{}
}
var _ = ginkgo.Describe("[Feature: Group]", func() {
f := framework.NewDefaultFramework()
@@ -252,7 +332,10 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
ginkgo.Describe("Health Check", func() {
ginkgo.It("TCP", func() {
serverConf := consts.DefaultServerConfig
dashboardPort := f.AllocPort()
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
webServer.port = %d
`, dashboardPort)
clientConf := consts.DefaultClientConfig
fooPort := f.AllocPort()
@@ -260,11 +343,17 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
f.RunServer("", fooServer)
barPort := f.AllocPort()
barServer := streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte("bar")))
newBarServer := func() *streamserver.Server {
return streamserver.New(streamserver.TCP, streamserver.WithBindPort(barPort), streamserver.WithRespContent([]byte("bar")))
}
barServer := newBarServer()
f.RunServer("", barServer)
remotePort := f.AllocPort()
adminPort := f.AllocPort()
clientConf += fmt.Sprintf(`
webServer.port = %d
[[proxies]]
name = "foo"
type = "tcp"
@@ -284,11 +373,16 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
loadBalancer.groupKey = "123"
healthCheck.type = "tcp"
healthCheck.intervalSeconds = 1
`, fooPort, remotePort, barPort, remotePort)
healthCheck.maxFailed = 3
`, adminPort, fooPort, remotePort, barPort, remotePort)
_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
f.RunProcesses(serverConf, []string{clientConf})
proxyClient := f.APIClientForFrpc(adminPort)
framework.ExpectNoError(waitForProxyStatus(proxyClient, "foo", "running"))
framework.ExpectNoError(waitForProxyStatus(proxyClient, "bar", "running"))
// check foo and bar is ok
// Both requests traverse frps, the load-balancing group, frpc, and the
// corresponding local backend.
results := []string{}
for range 10 {
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
@@ -298,33 +392,38 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
}
framework.ExpectContainElements(results, []string{"foo", "bar"})
// close bar server, check foo is ok
failedCount := clientProcesses[0].CountOutput("[bar] health check failed")
barServer.Close()
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second))
for range 10 {
framework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte("foo")).Ensure()
}
// frps removes the group listener before deleting the proxy from
// its manager. Its offline status is the removal barrier; frpc's
// local check-failed status alone does not acknowledge that work.
for range 2 {
framework.ExpectNoError(barServer.Close())
framework.ExpectNoError(waitForProxyStatus(proxyClient, "bar", "check failed"))
framework.ExpectNoError(waitForServerProxyStatus(dashboardPort, "bar", "offline", 5*time.Second))
for range 10 {
framework.NewRequestExpect(f).Port(remotePort).ExpectResp([]byte("foo")).Ensure()
}
// resume bar server, check foo and bar is ok
successCount := clientProcesses[0].CountOutput("[bar] health check success")
f.RunServer("", barServer)
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second))
results = []string{}
for range 10 {
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
results = append(results, string(resp.Content))
return true
})
barServer = newBarServer()
f.RunServer("", barServer)
framework.ExpectNoError(waitForProxyStatus(proxyClient, "bar", "running"))
results = []string{}
for range 10 {
framework.NewRequestExpect(f).Port(remotePort).Ensure(validateFooBarResponse, func(resp *request.Response) bool {
results = append(results, string(resp.Content))
return true
})
}
framework.ExpectContainElements(results, []string{"foo", "bar"})
}
framework.ExpectContainElements(results, []string{"foo", "bar"})
})
ginkgo.It("HTTP", func() {
vhostPort := f.AllocPort()
dashboardPort := f.AllocPort()
serverConf := consts.DefaultServerConfig + fmt.Sprintf(`
vhostHTTPPort = %d
`, vhostPort)
webServer.port = %d
`, vhostPort, dashboardPort)
clientConf := consts.DefaultClientConfig
fooPort := f.AllocPort()
@@ -332,10 +431,41 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
f.RunServer("", fooServer)
barPort := f.AllocPort()
barServer := newHTTPServer(barPort, "bar")
var stageMu sync.RWMutex
stage := &httpHealthStage{healthy: true, signal: make(chan struct{}, 1)}
barServer := httpserver.New(
httpserver.WithBindPort(barPort),
httpserver.WithHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
stageMu.RLock()
currentStage := stage
stageMu.RUnlock()
if r.URL.Path == "/healthz" {
if !currentStage.healthy {
w.WriteHeader(http.StatusServiceUnavailable)
}
select {
case currentStage.signal <- struct{}{}:
default:
}
if currentStage.release != nil {
select {
case <-currentStage.release:
case <-r.Context().Done():
return
}
}
return
}
_, _ = w.Write([]byte("bar"))
})),
)
f.RunServer("", barServer)
adminPort := f.AllocPort()
clientConf += fmt.Sprintf(`
webServer.port = %d
[[proxies]]
name = "foo"
type = "http"
@@ -356,12 +486,17 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
loadBalancer.groupKey = "123"
healthCheck.type = "http"
healthCheck.intervalSeconds = 1
healthCheck.maxFailed = 3
healthCheck.timeoutSeconds = 10
healthCheck.path = "/healthz"
`, fooPort, barPort)
`, adminPort, fooPort, barPort)
_, clientProcesses := f.RunProcesses(serverConf, []string{clientConf})
f.RunProcesses(serverConf, []string{clientConf})
proxyClient := f.APIClientForFrpc(adminPort)
framework.ExpectNoError(waitForProxyStatus(proxyClient, "foo", "running"))
framework.ExpectNoError(waitForProxyStatus(proxyClient, "bar", "running"))
// send first HTTP request
// Ordinary requests traverse the real HTTP proxy path.
var contents []string
framework.NewRequestExpect(f).Port(vhostPort).
RequestModify(func(r *request.Request) {
@@ -371,8 +506,6 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
contents = append(contents, string(resp.Content))
return true
})
// send second HTTP request, should be forwarded to another service
framework.NewRequestExpect(f).Port(vhostPort).
RequestModify(func(r *request.Request) {
r.HTTP().HTTPHost("example.com")
@@ -381,25 +514,83 @@ var _ = ginkgo.Describe("[Feature: Group]", func() {
contents = append(contents, string(resp.Content))
return true
})
framework.ExpectContainElements(contents, []string{"foo", "bar"})
// check foo and bar is ok
// Every failed health response is gated by the test. This gives each
// stage a request-level barrier and avoids cumulative process logs.
runFailureRecovery := func() {
failureSignals := make(chan struct{}, 1)
release := make(chan struct{}, 1)
stageMu.Lock()
stage = &httpHealthStage{healthy: false, signal: failureSignals, release: release}
stageMu.Unlock()
framework.ExpectNoError(waitForSignal(failureSignals))
release <- struct{}{}
framework.ExpectNoError(waitForSignal(failureSignals))
recoverySignals := make(chan struct{}, 1)
recoveryRelease := make(chan struct{})
stageMu.Lock()
stage = &httpHealthStage{healthy: true, signal: recoverySignals, release: recoveryRelease}
stageMu.Unlock()
release <- struct{}{}
// The next request can only start after the worker consumed the
// second failed response. Hold this success response until the
// assertion finishes, so premature removal cannot be hidden by
// recovery. Consistently also gives the proxy worker time to act
// on an erroneous failure notification without retrying it away.
framework.ExpectNoError(waitForSignal(recoverySignals))
gomega.Consistently(func() string {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
status, err := proxyClient.GetProxyStatus(ctx, "bar")
if err != nil {
return err.Error()
}
return status.Status
}, proxyFallbackObservationWindow, 25*time.Millisecond).Should(gomega.Equal("running"))
close(recoveryRelease)
}
runFailureRecovery()
results := doFooBarHTTPRequest(vhostPort, "example.com")
framework.ExpectContainElements(results, []string{"foo", "bar"})
// close bar server, check foo is ok
failedCount := clientProcesses[0].CountOutput("[bar] health check failed")
barServer.Close()
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check failed", failedCount+1, 5*time.Second))
// Repeat the two-failure window to verify that recovery cleared the
// counter before another failure sequence began.
runFailureRecovery()
results = doFooBarHTTPRequest(vhostPort, "example.com")
framework.ExpectContainElements(results, []string{"foo", "bar"})
// Three failed responses reach MaxFailed and remove bar from the
// group; a healthy response then re-registers it.
failureSignals := make(chan struct{}, 1)
release := make(chan struct{}, 1)
stageMu.Lock()
stage = &httpHealthStage{healthy: false, signal: failureSignals, release: release}
stageMu.Unlock()
for range 3 {
framework.ExpectNoError(waitForSignal(failureSignals))
release <- struct{}{}
}
framework.ExpectNoError(waitForProxyStatus(proxyClient, "bar", "check failed"))
// HTTPProxy.Close unregisters the group route before frps reports
// offline. Only then assert that fresh data-plane requests use foo.
framework.ExpectNoError(waitForServerProxyStatus(dashboardPort, "bar", "offline", 5*time.Second))
results = doFooBarHTTPRequest(vhostPort, "example.com")
framework.ExpectContainElements(results, []string{"foo"})
framework.ExpectNotContainElements(results, []string{"bar"})
// resume bar server, check foo and bar is ok
successCount := clientProcesses[0].CountOutput("[bar] health check success")
f.RunServer("", barServer)
framework.ExpectNoError(clientProcesses[0].WaitForOutput("[bar] health check success", successCount+1, 5*time.Second))
recoverySignals := make(chan struct{}, 1)
stageMu.Lock()
stage = &httpHealthStage{healthy: true, signal: recoverySignals}
stageMu.Unlock()
// Release any additional failed request already waiting while the
// removal and data-plane assertions were in progress.
close(release)
framework.ExpectNoError(waitForSignal(recoverySignals))
framework.ExpectNoError(waitForProxyStatus(proxyClient, "bar", "running"))
results = doFooBarHTTPRequest(vhostPort, "example.com")
framework.ExpectContainElements(results, []string{"foo", "bar"})
})
+139
View File
@@ -0,0 +1,139 @@
package features
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/fatedier/frp/server/http/model"
"github.com/fatedier/frp/test/e2e/framework"
"github.com/fatedier/frp/test/e2e/framework/consts"
"github.com/fatedier/frp/test/e2e/pkg/request"
)
func metricsServerConfig(port int, pprof bool) string {
return fmt.Sprintf(`
metricsServer.port = %d
metricsServer.user = "monitor"
metricsServer.password = "secret"
metricsServer.pprofEnable = %t
`, port, pprof)
}
func metricsRequest(port int, path string, authenticated bool) *request.Request {
r := framework.NewRequest().HTTP().Port(port).HTTPPath(path)
if authenticated {
r.HTTPAuth("monitor", "secret")
}
return r
}
var _ = ginkgo.Describe("[Feature: Metrics Server]", func() {
f := framework.NewDefaultFramework()
// A single connected client must produce exactly one event, including when
// both listeners expose /metrics. Checking names alone would also pass with
// registered collectors that never receive business events.
expectBusinessMetrics := func(port int) {
gomega.Eventually(func() bool {
resp, err := metricsRequest(port, "/metrics", true).Do()
return err == nil && resp.Code == http.StatusOK &&
strings.Contains(string(resp.Content), "\nfrp_server_client_counts 1\n")
}, 5*time.Second, 50*time.Millisecond).Should(gomega.BeTrue())
}
// frpc serves Go runtime metrics only; no process, business or handler
// instrumentation metrics may appear there, on any platform.
expectRuntimeMetrics := func(port int) {
framework.NewRequestExpect(f).Request(metricsRequest(port, "/metrics", false)).
Ensure(framework.ExpectResponseCode(http.StatusUnauthorized))
framework.NewRequestExpect(f).Request(metricsRequest(port, "/metrics", true)).Ensure(func(resp *request.Response) bool {
body := string(resp.Content)
return resp.Code == http.StatusOK &&
strings.Contains(body, "go_goroutines") &&
!strings.Contains(body, "process_") &&
!strings.Contains(body, "frp_") &&
!strings.Contains(body, "promhttp_")
})
}
ginkgo.It("serves anonymous health alongside both web servers", func() {
serverMetrics, clientMetrics := f.AllocPort(), f.AllocPort()
serverWeb, clientWeb := f.AllocPort(), f.AllocPort()
webConfig := func(port int) string {
return fmt.Sprintf(`
webServer.port = %d
webServer.user = "admin"
webServer.password = "admin-secret"
`, port)
}
serverConf := consts.DefaultServerConfig + metricsServerConfig(serverMetrics, false) + webConfig(serverWeb)
clientConf := consts.DefaultClientConfig + metricsServerConfig(clientMetrics, false) + webConfig(clientWeb)
f.RunProcesses(serverConf, []string{clientConf})
for _, port := range []int{serverMetrics, clientMetrics, serverWeb, clientWeb} {
framework.NewRequestExpect(f).Request(metricsRequest(port, "/healthz", false)).ExpectResp([]byte("")).
Ensure(framework.ExpectResponseCode(http.StatusOK), func(resp *request.Response) bool { return len(resp.Content) == 0 })
}
for _, port := range []int{serverMetrics, clientMetrics} {
for _, path := range []string{"/", "/api/serverinfo", "/api/config", "/static/app.js"} {
framework.NewRequestExpect(f).Request(metricsRequest(port, path, true)).Ensure(framework.ExpectResponseCode(http.StatusNotFound))
}
}
})
ginkgo.It("authenticates business metrics without enablePrometheus", func() {
port := f.AllocPort()
serverConf := consts.DefaultServerConfig + metricsServerConfig(port, false) + fmt.Sprintf("\nwebServer.port = %d\n", f.AllocPort())
f.RunProcesses(serverConf, []string{consts.DefaultClientConfig})
framework.NewRequestExpect(f).Request(metricsRequest(port, "/metrics", false)).Ensure(framework.ExpectResponseCode(http.StatusUnauthorized))
expectBusinessMetrics(port)
})
ginkgo.It("requires opt-in and authentication for pprof and excludes cmdline", func() {
serverPort, clientPort := f.AllocPort(), f.AllocPort()
f.RunProcesses(consts.DefaultServerConfig+metricsServerConfig(serverPort, true),
[]string{consts.DefaultClientConfig + metricsServerConfig(clientPort, false)})
for _, path := range []string{"/debug/pprof/", "/debug/pprof/goroutine"} {
framework.NewRequestExpect(f).Request(metricsRequest(serverPort, path, false)).Ensure(framework.ExpectResponseCode(http.StatusUnauthorized))
framework.NewRequestExpect(f).Request(metricsRequest(serverPort, path, true)).Ensure(framework.ExpectResponseCode(http.StatusOK))
framework.NewRequestExpect(f).Request(metricsRequest(clientPort, path, true)).Ensure(framework.ExpectResponseCode(http.StatusNotFound))
}
for _, authenticated := range []bool{false, true} {
framework.NewRequestExpect(f).Request(metricsRequest(serverPort, "/debug/pprof/cmdline", authenticated)).
Ensure(framework.ExpectResponseCode(http.StatusNotFound))
}
})
ginkgo.It("serves health and business metrics without either web server", func() {
serverPort, clientPort := f.AllocPort(), f.AllocPort()
f.RunProcesses(consts.DefaultServerConfig+metricsServerConfig(serverPort, false),
[]string{consts.DefaultClientConfig + metricsServerConfig(clientPort, false)})
for _, port := range []int{serverPort, clientPort} {
framework.NewRequestExpect(f).Request(metricsRequest(port, "/healthz", false)).Ensure(framework.ExpectResponseCode(http.StatusOK))
}
expectBusinessMetrics(serverPort)
expectRuntimeMetrics(clientPort)
})
ginkgo.It("collects once when both metrics endpoints are enabled", func() {
metricsPort, webPort := f.AllocPort(), f.AllocPort()
serverConf := consts.DefaultServerConfig + metricsServerConfig(metricsPort, false) + fmt.Sprintf(`
enablePrometheus = true
webServer.port = %d
webServer.user = "monitor"
webServer.password = "secret"
`, webPort)
f.RunProcesses(serverConf, []string{consts.DefaultClientConfig})
expectBusinessMetrics(metricsPort)
expectBusinessMetrics(webPort)
framework.NewRequestExpect(f).Request(metricsRequest(webPort, "/api/serverinfo", true)).Ensure(func(resp *request.Response) bool {
var info model.ServerInfoResp
return resp.Code == http.StatusOK && json.Unmarshal(resp.Content, &info) == nil && info.ClientCounts == 1
})
})
})