Merge pull request #5605 from fatedier/dev

Release v0.72.0
This commit is contained in:
fatedier
2026-11-05 01:07:59 +08:00
committed by GitHub
70 changed files with 4883 additions and 376 deletions
+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'
+3 -3
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.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
+3 -3
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.26'
- uses: actions/setup-node@v6
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
- name: Build web assets (frps)
+4
View File
@@ -81,6 +81,10 @@ linters:
- linters:
- unparam
text: is always false
- linters:
- staticcheck
path: ^pkg/util/net/kcp\.go$
text: "SA1019: .*SetStreamMode"
paths:
- .*\.pb\.go
- .*\.gen\.go
+43 -24
View File
@@ -12,17 +12,15 @@ frp is an open source project with its ongoing development made possible entirel
<h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start-->
<p align="center">
<a href="https://www.rapidproxy.io/?ref=frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_rapidproxy.png">
<br>
<b>High-performance residential and ISP proxies for developers</b>
</a>
<br>
<sub>90M+ residential IPs worldwide. Rotating IPs, sticky sessions, and traffic that never expires.</sub>
<br>
<sub>From $0.55/GB. Use RAPID10 for 10% off. Try it for free.</sub>
</p>
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<p align="center">
<a href="https://jb.gg/frp" target="_blank">
@@ -42,16 +40,6 @@ frp is an open source project with its ongoing development made possible entirel
</a>
</p>
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<!--gold sponsors end-->
## What is frp?
@@ -628,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
@@ -724,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
@@ -817,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).
+9 -21
View File
@@ -14,17 +14,15 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
<h3 align="center">Gold Sponsors</h3>
<!--gold sponsors start-->
<p align="center">
<a href="https://www.rapidproxy.io/?ref=frp" target="_blank">
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_rapidproxy.png">
<br>
<b>High-performance residential and ISP proxies for developers</b>
</a>
<br>
<sub>90M+ residential IPs worldwide. Rotating IPs, sticky sessions, and traffic that never expires.</sub>
<br>
<sub>From $0.55/GB. Use RAPID10 for 10% off. Try it for free.</sub>
</p>
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<p align="center">
<a href="https://jb.gg/frp" target="_blank">
@@ -44,16 +42,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
</a>
</p>
<div align="center">
## Recall.ai - API for meeting recordings
If you're looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp),
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
</div>
<!--gold sponsors end-->
## 为什么使用 frp ?
+11 -9
View File
@@ -1,13 +1,15 @@
## Improvements
## Features
* Building frp from source now requires Go 1.26.0 or later.
* 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
* Updated dependencies to fix SSH tunnel gateway channel deadlocks and panics caused by malformed challenges from NTLM-authenticated HTTP proxies.
* Fixed VirtualNet routing issues during reconnection and shutdown, and excessive retries after repeated connection failures.
* Health checks now reset the failure count after success, so `healthCheck.maxFailed` applies to consecutive failures.
* Fixed control connections getting stuck after message write failures, which could block reconnection.
* Fixed incorrect daily traffic statistics around daylight-saving transitions and skipped local midnights.
* Fixed early-data loss in HTTP virtual-host CONNECT tunnels. Requests with a positive content length or transfer encoding now return HTTP 400.
* Fixed work connections remaining open when server-side bandwidth limiting is enabled for TCP, HTTP, and UDP proxies.
* Fixed proxy and visitor configuration changes being lost when reloading frpc during reconnection.
* Updated yamux to fix a 100% CPU busy loop after a session shuts down and a session close that could hang when the underlying connection (for example WebSocket) blocks on a write, which could also delay reconnection.
* frps now closes the multiplexed session of a replaced or failed control connection immediately, releasing its streams and work connections instead of keeping them until keepalive times out.
* Fixed a data race when frpc updates log prefixes while other goroutines are logging, which could crash the process.
* Fixed HTTPS and tcpmux route listeners leaving a pending connection open when the listener is removed during connection handoff; the connection is now closed instead of leaving the client to wait for a read timeout.
+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)
})
}
}
+6 -4
View File
@@ -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
+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 {
+30
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"
@@ -277,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()
@@ -288,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
}
+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()
}
+69 -11
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.
@@ -188,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)
@@ -198,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,
@@ -263,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())
@@ -272,6 +296,16 @@ 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 {
@@ -354,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()
@@ -368,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()
@@ -390,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
@@ -413,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")
@@ -477,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
@@ -556,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
+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)
}
+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).
+8 -6
View File
@@ -1,12 +1,14 @@
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.26 AS building
+8 -6
View File
@@ -1,12 +1,14 @@
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.26 AS building
+45 -47
View File
@@ -4,72 +4,70 @@ 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/coreos/go-oidc/v3 v3.21.0
github.com/fatedier/golib v0.8.2
github.com/fatedier/yamux v0.2.0
github.com/fatedier/yamux v0.3.0
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.0
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/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/onsi/ginkgo/v2 v2.34.0
github.com/onsi/gomega v1.44.0
github.com/pelletier/go-toml/v2 v2.4.3
github.com/pires/go-proxyproto v0.15.0
github.com/prometheus/client_golang v1.19.1
github.com/quic-go/quic-go v0.60.0
github.com/rodaine/table v1.2.0
github.com/samber/lo v1.47.0
github.com/prometheus/client_golang v1.25.0
github.com/prometheus/client_model v0.6.3
github.com/prometheus/common v0.72.0
github.com/quic-go/quic-go v0.65.0
github.com/rodaine/table v1.4.1
github.com/samber/lo v1.53.0
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
github.com/spf13/cobra v1.8.0
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.11.1
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.56.0
golang.org/x/net v0.57.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/time v0.10.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
gopkg.in/ini.v1 v1.67.0
k8s.io/apimachinery v0.28.8
k8s.io/client-go v0.28.8
k8s.io/utils v0.0.0-20230406110748-d93618cff8a2
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.12.1
github.com/tidwall/gjson v1.20.0
github.com/vishvananda/netlink v1.3.1
github.com/xtaci/kcp-go/v5 v5.6.73
golang.org/x/crypto v0.58.0
golang.org/x/net v0.62.0
golang.org/x/oauth2 v0.37.0
golang.org/x/sync v0.24.0
golang.org/x/sys v0.49.0
golang.org/x/time v0.16.0
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee
gopkg.in/ini.v1 v1.67.3
k8s.io/apimachinery v0.37.2
k8s.io/client-go v0.37.2
k8s.io/utils v0.0.0-20261023123148-ca04ba58ecab
)
require (
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
github.com/klauspost/reedsolomon v1.12.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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
github.com/prometheus/procfs v0.22.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
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.41.0 // indirect
golang.org/x/tools v0.48.0 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/text v0.43.0 // indirect
golang.org/x/tools v0.51.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
gopkg.in/yaml.v3 v3.0.1 // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
+127 -109
View File
@@ -2,34 +2,45 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
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/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
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=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatedier/golib v0.8.2 h1:02n2Dg7KJ7rR7p7n4/6hBUjaLQf2J7EiHYZQsgGTvww=
github.com/fatedier/golib v0.8.2/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw=
github.com/fatedier/yamux v0.2.0 h1:H+2A9iBVh7aJlEOc1Ws1FXWOaecBf2nRv9zpFMPUWg8=
github.com/fatedier/yamux v0.2.0/go.mod h1:d4FtRDrC9sHvRpiDL6J5EnfjLhqzZZplHe5yToSn2Ac=
github.com/fatedier/yamux v0.3.0 h1:NHcAbiv2YMZJjV413Ibw0OccvRVWP1nTTQjdRdFKI/0=
github.com/fatedier/yamux v0.3.0/go.mod h1:d4FtRDrC9sHvRpiDL6J5EnfjLhqzZZplHe5yToSn2Ac=
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -43,25 +54,28 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/reedsolomon v1.12.0 h1:I5FEp3xSwVCcEh3F5A7dofEfhXdF/bWhQWPH+XwBFno=
@@ -70,50 +84,54 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.34.0 h1:aN3Us823mHHTSWMIi8WIQTdYuSPhl2lWY46E/w3SiAk=
github.com/onsi/ginkgo/v2 v2.34.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.44.0 h1:eAiGl3Pw5jz5GQdDff0BcxYpAX1JxW8xD7mFUuwNfZQ=
github.com/onsi/gomega v1.44.0/go.mod h1:e/C2HwaZ1DhvjzXXuFhcR7hY7Sh9pl7MmoWKEjzwcdA=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0=
github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4=
github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk=
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rodaine/table v1.2.0 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA=
github.com/rodaine/table v1.2.0/go.mod h1:wejb/q/Yd4T/SVmBSRMr7GCq3KlcZp3gyNYdLSBhkaE=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/quic-go/quic-go v0.65.0 h1:BJQSjdTvLPLmWtaCupUphRbgxgpZhdO/GMpr9Eoe3B4=
github.com/quic-go/quic-go v0.65.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
github.com/rodaine/table v1.4.1 h1:E6biWCzTV91H1sGNlW/cUtqqgxUJnmcThmWAyhPiToo=
github.com/rodaine/table v1.4.1/go.mod h1:bpG+S1IN2VGfI0vC1MymHEX8fTDK5LIsxd+rrpKSQPA=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -121,58 +139,63 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/templexxx/cpu v0.1.1 h1:isxHaxBXpYFWnk2DReuKkigaZyrjs2+9ypIdGP4h+HI=
github.com/templexxx/cpu v0.1.1/go.mod h1:w7Tb+7qgcAlIyX4NhLuDKt78AHA5SzPmq0Wj6HiEnnk=
github.com/templexxx/xorsimd v0.4.3 h1:9AQTFHd7Bhk3dIT7Al2XeBX5DWOvsUPZCuhyAtNbHjU=
github.com/templexxx/xorsimd v0.4.3/go.mod h1:oZQcD6RFDisW2Am58dSAGwwL6rHjbzrlu25VDqfWkQg=
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tidwall/gjson v1.20.0 h1:+agJ3rEzKcCXKDKo0ml26UROcpcAlnZyjq+TjbY5Fto=
github.com/tidwall/gjson v1.20.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk=
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk=
github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/xtaci/kcp-go/v5 v5.6.73 h1:Ejk1O0GyjaEQOxTeZUn0jIss0jkk2BJU8VOeJKdOh+Y=
github.com/xtaci/kcp-go/v5 v5.6.73/go.mod h1:FRqA8EUPgnBlP4UcXlcCWZ9if7XaqmZMfXOTynYEmc4=
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E=
github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37/go.mod h1:HpMP7DB2CyokmAh4lp0EQnnWhmycP/TvwBGzvuie+H0=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
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.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
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=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -180,28 +203,28 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
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.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/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
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.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
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=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee h1:hByBPN/P/BVzJ591rJfvNhdoL94vmBtRbnVw77QJL20=
golang.zx2c4.com/wireguard v0.0.0-20261024220952-b3bc704ef0ee/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
@@ -216,29 +239,24 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 h1:TbRPT0HtzFP3Cno1zZo7yPzEEnfu8EjLfl6IU9VfqkQ=
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259/go.mod h1:AVgIgHMwK63XvmAzWG9vLQ41YnVHN0du0tEC46fI7yY=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
k8s.io/apimachinery v0.28.8 h1:hi/nrxHwk4QLV+W/SHve1bypTE59HCDorLY1stBIxKQ=
k8s.io/apimachinery v0.28.8/go.mod h1:cBnwIM3fXoRo28SqbV/Ihxf/iviw85KyXOrzxvZQ83U=
k8s.io/client-go v0.28.8 h1:TE59Tjd87WKvS2FPBTfIKLFX0nQJ4SSHsnDo5IHjgOw=
k8s.io/client-go v0.28.8/go.mod h1:uDVQ/rPzWpWIy40c6lZ4mUwaEvRWGnpoqSO4FM65P3o=
k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 h1:qY1Ad8PODbnymg2pRbkyMT/ylpTrCM8P2RJ0yroCyIk=
k8s.io/utils v0.0.0-20230406110748-d93618cff8a2/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
k8s.io/apimachinery v0.37.2 h1:G4t9RGoPDbQML38hz/kQQiltttgd3egg2ODCR8aIc2o=
k8s.io/apimachinery v0.37.2/go.mod h1:qaAvSNvgU2x07wRM95MSHSJTPQ/ob/LeNAGbeDgqQJg=
k8s.io/client-go v0.37.2 h1:sgFtyRZukGkOrsu+QgS7LYnbFFJ5ut3MchdBNduD//E=
k8s.io/client-go v0.37.2/go.mod h1:bo3LFC8YbCtKFPX21Yj8puMsoJLAv/+SvuOODma0s54=
k8s.io/utils v0.0.0-20261023123148-ca04ba58ecab h1:pjQrNLaF//ytZtWwCAHtW+pqwSKjtWDqzA8+TCX37RI=
k8s.io/utils v0.0.0-20261023123148-ca04ba58ecab/go.mod h1:DdQ0BJxlQtuMlKDqdg7X5IMMQCXIk2b3d1/4M9OhUYo=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+6 -4
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.
@@ -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
+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"`
+3 -1
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")
+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)
}
}
}
+2 -3
View File
@@ -20,9 +20,8 @@ import (
"slices"
"strings"
"k8s.io/apimachinery/pkg/util/validation"
v1 "github.com/fatedier/frp/pkg/config/v1"
utilvalidation "github.com/fatedier/frp/pkg/util/validation"
)
func validateProxyBaseConfigForClient(c *v1.ProxyBaseConfig) error {
@@ -248,7 +247,7 @@ func ValidateAnnotations(annotations map[string]string) error {
var errs error
for k := range annotations {
for _, msg := range validation.IsQualifiedName(strings.ToLower(k)) {
for _, msg := range utilvalidation.IsQualifiedName(strings.ToLower(k)) {
errs = AppendError(errs, fmt.Errorf("annotation key %s is invalid: %s", k, msg))
}
}
+15
View File
@@ -74,3 +74,18 @@ func TestValidateDomainConfigForServerRejectsSubdomainHostCaseInsensitively(t *t
})
}
}
func TestValidateAnnotationsQualifiedName(t *testing.T) {
require.NoError(t, ValidateAnnotations(map[string]string{
"frp.e2e.test/foo": "value1",
"app.kubernetes.io/name": "value2",
}))
// The call site lowercases keys before validation.
require.NoError(t, ValidateAnnotations(map[string]string{"Example.COM/MyName": "value"}))
err := ValidateAnnotations(map[string]string{"bad key": "value"})
require.ErrorContains(t, err, "annotation key bad key is invalid: name part must consist of alphanumeric characters")
err = ValidateAnnotations(map[string]string{"example.com/": "value"})
require.ErrorContains(t, err, "name part must be non-empty")
}
+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
}
+424
View File
@@ -0,0 +1,424 @@
// 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 in heap and currently in use. Equals to /memory/classes/heap/objects:bytes.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.Alloc) },
},
{
name: "go_memstats_alloc_bytes_total",
help: "Total number of bytes allocated in heap until now, even if released already. Equals to /gc/heap/allocs:bytes.",
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. Equals to /memory/classes/profiling/buckets:bytes.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.BuckHashSys) },
},
{
name: "go_memstats_frees_total",
help: "Total number of heap objects frees. Equals to /gc/heap/frees:objects + /gc/heap/tiny/allocs:objects.",
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. Equals to /memory/classes/metadata/other:bytes.",
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 currently in use, same as go_memstats_alloc_bytes. Equals to /memory/classes/heap/objects:bytes.",
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. Equals to /memory/classes/heap/released:bytes + /memory/classes/heap/free:bytes.",
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. Equals to /memory/classes/heap/objects:bytes + /memory/classes/heap/unused:bytes",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapInuse) },
},
{
name: "go_memstats_heap_objects",
help: "Number of currently allocated objects. Equals to /gc/heap/objects: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. Equals to /memory/classes/heap/released:bytes.",
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. Equals to /memory/classes/heap/objects:bytes + /memory/classes/heap/unused:bytes + /memory/classe" +
"s/heap/released:bytes + /memory/classes/heap/free:bytes.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapSys) },
},
{
name: "go_memstats_mallocs_total",
help: "Total number of heap objects allocated, both live and gc-ed. Semantically a counter version for go_memstats_heap_objects gauge. Equals to /g" +
"c/heap/allocs:objects + /gc/heap/tiny/allocs:objects.",
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. Equals to /memory/classes/metadata/mcache/inuse:bytes.",
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. Equals to /memory/classes/metadata/mcache/inuse:bytes + /memory/classes/met" +
"adata/mcache/free:bytes.",
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. Equals to /memory/classes/metadata/mspan/inuse:bytes.",
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. Equals to /memory/classes/metadata/mspan/inuse:bytes + /memory/classes/metad" +
"ata/mspan/free:bytes.",
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. Equals to /gc/heap/goal:bytes.",
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. Equals to /memory/classes/other:bytes.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.OtherSys) },
},
{
name: "go_memstats_stack_inuse_bytes",
help: "Number of bytes obtained from system for stack allocator in non-CGO environments. Equals to /memory/classes/heap/stacks:bytes.",
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. Equals to /memory/classes/heap/stacks:bytes + /memory/classes/os-stacks:bytes.",
typ: typeGauge,
get: func(ms *runtime.MemStats) float64 { return float64(ms.StackSys) },
},
{
name: "go_memstats_sys_bytes",
help: "Number of bytes obtained from system. Equals to /memory/classes/total:byte.",
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 wall-time pause (stop-the-world) duration in 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())}},
},
family{
name: "go_gc_gogc_percent",
help: "Heap size target percentage configured by the user, otherwise 100. This value is set by the GOGC environment variable, and the runtime/debug" +
".SetGCPercent function. Sourced from /gc/gogc:percent.",
typ: typeGauge,
samples: []sample{{value: float64(readRuntimeMetric("/gc/gogc:percent"))}},
},
family{
name: "go_gc_gomemlimit_bytes",
help: "Go runtime memory limit configured by the user, otherwise math.MaxInt64. This value is set by the GOMEMLIMIT environment variable, and the r" +
"untime/debug.SetMemoryLimit function. Sourced from /gc/gomemlimit:bytes.",
typ: typeGauge,
samples: []sample{{value: float64(readRuntimeMetric("/gc/gomemlimit:bytes"))}},
},
family{
name: "go_sched_gomaxprocs_threads",
help: "The current runtime.GOMAXPROCS setting, or the number of operating system threads that can execute user-level Go code simultaneously. Source" +
"d from /sched/gomaxprocs:threads.",
typ: typeGauge,
samples: []sample{{value: float64(runtime.GOMAXPROCS(0))}},
},
)
}
// readRuntimeMetric reads one runtime/metrics gauge.
func readRuntimeMetric(name string) uint64 {
s := []metrics.Sample{{Name: name}}
metrics.Read(s)
return s[0].Value.Uint64()
}
func threadCount() int {
n, _ := runtime.ThreadCreateProfile(nil)
return n
}
+192
View File
@@ -0,0 +1,192 @@
// 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_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)
}
+125
View File
@@ -0,0 +1,125 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
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/prometheus/common/model"
"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.NewTextParser(model.UTF8Validation)
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())
}
+36 -16
View File
@@ -18,6 +18,7 @@ import (
"errors"
"io"
"net"
"os"
"sync"
"sync/atomic"
"testing"
@@ -30,18 +31,45 @@ import (
)
// Delay one real TCP write without injecting an error. The stream's write
// failure must come from yamux's ConnectionWriteTimeout.
// failure must come from yamux's ConnectionWriteTimeout, which yamux enforces
// as a write deadline on the underlying connection, so the delayed write has
// to observe that deadline the way a real socket does.
type delayedTCPConn struct {
net.Conn
armed atomic.Bool
entered chan struct{}
release chan struct{}
deadlineMu sync.Mutex
deadline time.Time
}
func (c *delayedTCPConn) SetWriteDeadline(t time.Time) error {
c.deadlineMu.Lock()
c.deadline = t
c.deadlineMu.Unlock()
return c.Conn.SetWriteDeadline(t)
}
func (c *delayedTCPConn) Write(p []byte) (int, error) {
if c.armed.Swap(false) {
close(c.entered)
<-c.release
c.deadlineMu.Lock()
deadline := c.deadline
c.deadlineMu.Unlock()
var timeout <-chan time.Time
if !deadline.IsZero() {
timer := time.NewTimer(time.Until(deadline))
defer timer.Stop()
timeout = timer.C
}
select {
case <-c.release:
case <-timeout:
// Same value a real socket returns; yamux maps any timeout error
// to ErrConnectionWriteTimeout.
return 0, os.ErrDeadlineExceeded
}
}
return c.Conn.Write(p)
}
@@ -206,20 +234,12 @@ func TestDispatcherYamuxWriteTimeoutInterruptsRead(t *testing.T) {
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")
// yamux enforces ConnectionWriteTimeout on the transport, so the
// failed write ends the whole session instead of leaving it blocked
// but alive.
regressionAwait(t, client.CloseChan(), "client yamux session close")
regressionAwait(t, server.CloseChan(), "server yamux session close")
t.Log("Done/readLoop exited without peer FIN; Close once; write timeout closed both yamux sessions")
})
}
}
+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])
}
}
+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())
}
+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)
})
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package validation
import (
"fmt"
"regexp"
"strings"
)
// Annotation keys follow the Kubernetes "qualified name" (label key) format.
// The constants below mirror k8s.io/apimachinery/pkg/util/validation's
// qualified-name validation (v0.28.8 semantics and messages) so that frp does
// not need to import that package; newer versions of the package link klog.
const (
qualifiedNameCharFmt = "[A-Za-z0-9]"
qualifiedNameExtCharFmt = "[-A-Za-z0-9_.]"
qualifiedNameFmt = "(" + qualifiedNameCharFmt + qualifiedNameExtCharFmt + "*)?" + qualifiedNameCharFmt
qualifiedNameErrMsg = "must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character"
qualifiedNameMaxLength = 63
dns1123LabelFmt = "[a-z0-9]([-a-z0-9]*[a-z0-9])?"
dns1123SubdomainFmt = dns1123LabelFmt + `(\.` + dns1123LabelFmt + ")*"
dns1123SubdomainErrorMsg = "a lowercase RFC 1123 subdomain must consist of lower case " +
"alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character"
dns1123SubdomainMaxLength = 253
)
var (
qualifiedNameRegexp = regexp.MustCompile("^" + qualifiedNameFmt + "$")
dns1123SubdomainRegexp = regexp.MustCompile("^" + dns1123SubdomainFmt + "$")
)
// IsQualifiedName reports whether value is a valid Kubernetes qualified name
// (label key), the format used by frp for proxy annotation keys. If value is
// invalid, it returns one message per violation; an empty result means valid.
//
// The function does not normalize case: callers that accept case-insensitive
// keys must lowercase value themselves (frp's annotation validation passes
// strings.ToLower(key)).
func IsQualifiedName(value string) []string {
var errs []string
parts := strings.Split(value, "/")
var name string
switch len(parts) {
case 1:
name = parts[0]
case 2:
var prefix string
prefix, name = parts[0], parts[1]
if len(prefix) == 0 {
errs = append(errs, "prefix part "+emptyError())
} else if msgs := dns1123SubdomainErrors(prefix); len(msgs) != 0 {
errs = append(errs, prefixEach(msgs, "prefix part ")...)
}
default:
return append(errs, "a qualified name "+regexError(qualifiedNameErrMsg, qualifiedNameFmt, "MyName", "my.name", "123-abc")+
" with an optional DNS subdomain prefix and '/' (e.g. 'example.com/MyName')")
}
if len(name) == 0 {
errs = append(errs, "name part "+emptyError())
} else if len(name) > qualifiedNameMaxLength {
errs = append(errs, "name part "+maxLenError(qualifiedNameMaxLength))
}
if !qualifiedNameRegexp.MatchString(name) {
errs = append(errs, "name part "+regexError(qualifiedNameErrMsg, qualifiedNameFmt, "MyName", "my.name", "123-abc"))
}
return errs
}
func dns1123SubdomainErrors(value string) []string {
var errs []string
if len(value) > dns1123SubdomainMaxLength {
errs = append(errs, maxLenError(dns1123SubdomainMaxLength))
}
if !dns1123SubdomainRegexp.MatchString(value) {
errs = append(errs, regexError(dns1123SubdomainErrorMsg, dns1123SubdomainFmt, "example.com"))
}
return errs
}
func maxLenError(length int) string {
return fmt.Sprintf("must be no more than %d characters", length)
}
func emptyError() string {
return "must be non-empty"
}
func regexError(msg string, re string, examples ...string) string {
if len(examples) == 0 {
return msg + " (regex used for validation is '" + re + "')"
}
msg += " (e.g. "
for i := range examples {
if i > 0 {
msg += " or "
}
msg += "'" + examples[i] + "', "
}
msg += "regex used for validation is '" + re + "')"
return msg
}
func prefixEach(msgs []string, prefix string) []string {
for i := range msgs {
msgs[i] = prefix + msgs[i]
}
return msgs
}
+102
View File
@@ -0,0 +1,102 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package validation
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestIsQualifiedNameValidity(t *testing.T) {
tests := []struct {
name string
value string
valid bool
}{
{name: "single letter", value: "a", valid: true},
{name: "single digit", value: "0", valid: true},
{name: "uppercase name", value: "MyName", valid: true},
{name: "allowed punctuation", value: "a-._b", valid: true},
{name: "name at max length", value: strings.Repeat("a", 63), valid: true},
{name: "name over max length", value: strings.Repeat("a", 64), valid: false},
{name: "prefix and name", value: "example.com/MyName", valid: true},
{name: "prefix at max length", value: strings.Repeat("a", 253) + "/key", valid: true},
{name: "prefix over max length", value: strings.Repeat("a", 254) + "/key", valid: false},
{name: "name at max length with prefix", value: "example.com/" + strings.Repeat("a", 63), valid: true},
{name: "name over max length with prefix", value: "example.com/" + strings.Repeat("a", 64), valid: false},
{name: "empty", value: "", valid: false},
{name: "leading hyphen", value: "-a", valid: false},
{name: "trailing hyphen", value: "a-", valid: false},
{name: "leading underscore", value: "_a", valid: false},
{name: "trailing dot", value: "a.", valid: false},
{name: "only dot", value: ".", valid: false},
{name: "double dot in name", value: "a..b", valid: true},
{name: "space", value: "a b", valid: false},
{name: "tab", value: "a\tb", valid: false},
{name: "newline", value: "a\nb", valid: false},
{name: "nul byte", value: "a\x00b", valid: false},
{name: "non ascii name", value: "é", valid: false},
{name: "non ascii prefix", value: "é/key", valid: false},
{name: "empty prefix", value: "/key", valid: false},
{name: "empty name", value: "example.com/", valid: false},
{name: "two slashes", value: "a//b", valid: false},
{name: "three segments", value: "a/b/c", valid: false},
{name: "uppercase prefix", value: "Example.com/key", valid: false},
{name: "prefix with empty label", value: "example..com/key", valid: false},
{name: "prefix with leading hyphen", value: "-example.com/key", valid: false},
{name: "prefix with trailing dot", value: "example.com./key", valid: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
errs := IsQualifiedName(tc.value)
if tc.valid {
require.Empty(t, errs)
} else {
require.NotEmpty(t, errs)
}
})
}
}
func TestIsQualifiedNameMessages(t *testing.T) {
t.Run("name too long", func(t *testing.T) {
errs := IsQualifiedName(strings.Repeat("a", 64))
require.Contains(t, errs, "name part must be no more than 63 characters")
})
t.Run("prefix too long", func(t *testing.T) {
errs := IsQualifiedName(strings.Repeat("a", 254) + "/key")
require.Contains(t, errs, "prefix part must be no more than 253 characters")
})
t.Run("too many segments", func(t *testing.T) {
errs := IsQualifiedName("a/b/c")
require.Len(t, errs, 1)
require.Contains(t, errs[0], "a qualified name must consist of alphanumeric characters")
require.Contains(t, errs[0], "with an optional DNS subdomain prefix and '/'")
})
t.Run("invalid character", func(t *testing.T) {
errs := IsQualifiedName("a b")
require.Contains(t, errs[0], "name part must consist of alphanumeric characters")
})
t.Run("empty prefix", func(t *testing.T) {
errs := IsQualifiedName("/key")
require.Equal(t, []string{"prefix part must be non-empty"}, errs)
})
t.Run("empty name", func(t *testing.T) {
errs := IsQualifiedName("example.com/")
require.Contains(t, errs[0], "name part must be non-empty")
})
}
+1 -1
View File
@@ -14,7 +14,7 @@
package version
var version = "0.71.1"
var version = "0.72.0"
func Full() string {
return version
+17 -10
View File
@@ -17,10 +17,9 @@ import (
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/fatedier/golib/errors"
"github.com/fatedier/frp/pkg/util/log"
netpkg "github.com/fatedier/frp/pkg/util/net"
"github.com/fatedier/frp/pkg/util/xlog"
@@ -138,6 +137,7 @@ func (v *Muxer) Listen(ctx context.Context, cfg *RouteConfig) (l *Listener, err
password: cfg.Password,
mux: v,
accept: make(chan net.Conn),
done: make(chan struct{}),
ctx: ctx,
}
err = v.registryRouter.Add(cfg.Domain, cfg.Location, cfg.RouteByHTTPUser, l)
@@ -215,11 +215,12 @@ func (v *Muxer) handle(c net.Conn) {
c = sConn
xl.Debugf("new request host [%s] path [%s] httpUser [%s]", name, path, httpUser)
err = errors.PanicToError(func() {
l.accept <- c
})
if err != nil {
select {
case l.accept <- c:
// The connection is now owned by the Accept caller.
case <-l.done:
xl.Warnf("listener is already closed, ignore this request")
_ = c.Close()
}
}
@@ -232,13 +233,17 @@ type Listener struct {
password string
mux *Muxer // for closing Muxer
accept chan net.Conn
done chan struct{}
closeOnce sync.Once
ctx context.Context
}
func (l *Listener) Accept() (net.Conn, error) {
xl := xlog.FromContextSafe(l.ctx)
conn, ok := <-l.accept
if !ok {
var conn net.Conn
select {
case conn = <-l.accept:
case <-l.done:
return nil, fmt.Errorf("listener closed")
}
@@ -258,8 +263,10 @@ func (l *Listener) Accept() (net.Conn, error) {
}
func (l *Listener) Close() error {
l.mux.registryRouter.Del(l.name, l.location, l.routeByHTTPUser)
close(l.accept)
l.closeOnce.Do(func() {
l.mux.registryRouter.Del(l.name, l.location, l.routeByHTTPUser)
close(l.done)
})
return nil
}
+116
View File
@@ -0,0 +1,116 @@
// 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 vhost
import (
"context"
"net"
"testing"
"time"
)
func newCloseTestMuxer() *Muxer {
return &Muxer{
timeout: time.Second,
registryRouter: NewRouters(),
vhostFunc: func(c net.Conn) (net.Conn, map[string]string, error) {
return c, map[string]string{"Host": "example.com"}, nil
},
}
}
func testPendingHandoffIsClosed(t *testing.T, client, server net.Conn) {
t.Helper()
m := newCloseTestMuxer()
entered := make(chan struct{})
release := make(chan struct{})
m.SetSuccessHookFunc(func(net.Conn, map[string]string) error {
close(entered)
<-release
return nil
})
l, err := m.Listen(context.Background(), &RouteConfig{Domain: "example.com"})
if err != nil {
t.Fatal(err)
}
handled := make(chan struct{})
go func() {
defer close(handled)
m.handle(server)
}()
<-entered
if err := client.SetReadDeadline(time.Now().Add(300 * time.Millisecond)); err != nil {
t.Fatal(err)
}
// Close the route after resolution but before the handoff. The pending
// connection must be closed, not abandoned.
if err := l.Close(); err != nil {
t.Fatal(err)
}
close(release)
<-handled
if _, err := client.Read(make([]byte, 1)); err == nil {
t.Fatal("connection was not closed by the muxer")
} else if ne, ok := err.(net.Error); ok && ne.Timeout() {
t.Fatalf("connection was not closed by the muxer: %v", err)
}
}
func TestListenerCloseReleasesPendingConn(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
testPendingHandoffIsClosed(t, client, server)
}
func TestListenerCloseReleasesPendingTCPConn(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
client, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer client.Close()
server, err := ln.Accept()
if err != nil {
t.Fatal(err)
}
testPendingHandoffIsClosed(t, client, server)
}
func TestListenerCloseIsIdempotent(t *testing.T) {
m := newCloseTestMuxer()
l, err := m.Listen(context.Background(), &RouteConfig{Domain: "example.com"})
if err != nil {
t.Fatal(err)
}
if err := l.Close(); err != nil {
t.Fatal(err)
}
if err := l.Close(); err != nil {
t.Fatal(err)
}
if _, err := l.Accept(); err == nil {
t.Fatal("Accept after Close should return an error")
}
}
+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()
}
+8
View File
@@ -17,6 +17,7 @@ package server
import (
"context"
"fmt"
"io"
"math"
"net"
"runtime/debug"
@@ -366,6 +367,9 @@ type SessionContext struct {
EncryptionKey []byte
// control connection
Conn *msg.Conn
// A mux session carries exactly one control. Close it when the control is
// replaced or exits fatally to release all remaining streams and work connections.
Session io.Closer
// login message
LoginMsg *msg.Login
// server configuration
@@ -575,6 +579,10 @@ func (ctl *Control) markReplaced() <-chan struct{} {
func (ctl *Control) interruptReadAndClose() error {
ctl.interruptOnce.Do(func() {
_ = ctl.sessionCtx.Conn.SetReadDeadline(time.Now())
if ctl.sessionCtx.Session != nil {
ctl.interruptErr = ctl.sessionCtx.Session.Close()
return
}
ctl.interruptErr = ctl.sessionCtx.Conn.Close()
})
return ctl.interruptErr
+186
View File
@@ -0,0 +1,186 @@
// Copyright 2026 fatedier, fatedier@gmail.com
//
// 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"
"net"
"testing"
"time"
fmux "github.com/fatedier/yamux"
"github.com/stretchr/testify/require"
"github.com/fatedier/frp/pkg/msg"
"github.com/fatedier/frp/pkg/proto/wire"
"github.com/fatedier/frp/pkg/util/util"
)
func TestControlReplacementClosesYamuxSession(t *testing.T) {
svr := newControlTestService(t)
_, clientA, serverA, _ := newYamuxTestControl(t, svr)
_, _, _, connB := newYamuxTestControl(t, svr)
waitForSignal(t, serverA.CloseChan(), "replaced control's server yamux session to close")
waitForSignal(t, clientA.CloseChan(), "replaced control's client yamux session to close")
_, err := clientA.OpenStream()
require.ErrorIs(t, err, fmux.ErrSessionShutdown)
require.NoError(t, connB.SetDeadline(time.Now().Add(3*time.Second)))
require.NoError(t, connB.WriteMsg(&msg.Ping{}))
response, err := connB.ReadMsg()
require.NoError(t, err, "replacement control must still receive a Pong")
require.Equal(t, &msg.Pong{}, response)
}
func TestControlCloseClosesYamuxSession(t *testing.T) {
ctl, client, server, _ := newYamuxTestControl(t, newControlTestService(t))
require.NoError(t, ctl.Close())
waitForSignal(t, server.CloseChan(), "closed control's server yamux session to close")
waitForSignal(t, client.CloseChan(), "closed control's client yamux session to close")
_, err := client.OpenStream()
require.ErrorIs(t, err, fmux.ErrSessionShutdown)
}
func TestControlConcurrentReplacementClosesLosingSessions(t *testing.T) {
svr := newControlTestService(t)
ctlA, clientA, serverA, connA := newYamuxTestControl(t, svr)
type result struct {
ctl *Control
client, server *fmux.Session
conn *msg.Conn
err error
}
start := make(chan struct{})
results := make(chan result, 2)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
for range 2 {
client, server, clientConn, serverConn := newYamuxTestSession(t)
go func() {
r := result{client: client, server: server, conn: clientConn}
select {
case <-start:
r.ctl, r.err = svr.RegisterControl(serverConn, &msg.Login{
RunID: "shared-run",
ClientID: "client",
Timestamp: 1,
PrivilegeKey: util.GetAuthKey("", 1),
}, false, wire.ProtocolV1, "", server)
if r.err == nil {
r.err = svr.completeControlLogin(r.ctl, func() error { return nil })
}
if r.err != nil && r.ctl != nil {
svr.ctlManager.Remove(r.ctl)
_ = r.ctl.Close()
}
case <-ctx.Done():
r.err = ctx.Err()
}
results <- r
}()
}
close(start)
controls := [3]result{{ctl: ctlA, client: clientA, server: serverA, conn: connA}}
for i := range 2 {
r := waitForResult(t, results, "concurrent control login")
if r.ctl != nil {
t.Cleanup(func() {
_ = r.ctl.Close()
waitForControlDone(t, r.ctl)
})
}
if r.err != nil {
require.ErrorIs(t, r.err, errControlReplaced)
}
controls[i+1] = r
}
current, ok := svr.ctlManager.GetByID("shared-run")
require.True(t, ok, "a running current control must remain")
require.NotSame(t, ctlA, current)
svr.ctlManager.mu.RLock()
currentCount := len(svr.ctlManager.ctlsByRunID)
svr.ctlManager.mu.RUnlock()
require.Equal(t, 1, currentCount)
winners := 0
for _, r := range controls {
if r.ctl == current {
winners++
require.NoError(t, r.err)
require.False(t, r.server.IsClosed(), "winner's server session must stay open")
require.False(t, r.client.IsClosed(), "winner's client session must stay open")
require.NoError(t, r.conn.SetDeadline(time.Now().Add(3*time.Second)))
require.NoError(t, r.conn.WriteMsg(&msg.Ping{}))
response, err := r.conn.ReadMsg()
require.NoError(t, err, "winner must still receive a Pong")
require.Equal(t, &msg.Pong{}, response)
continue
}
waitForSignal(t, r.server.CloseChan(), "losing control's server yamux session to close")
waitForSignal(t, r.client.CloseChan(), "losing control's client yamux session to close")
_, err := r.client.OpenStream()
require.ErrorIs(t, err, fmux.ErrSessionShutdown)
}
require.Equal(t, 1, winners)
}
func newYamuxTestSession(t *testing.T) (*fmux.Session, *fmux.Session, *msg.Conn, *msg.Conn) {
t.Helper()
listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)})
require.NoError(t, err)
t.Cleanup(func() { _ = listener.Close() })
require.NoError(t, listener.SetDeadline(time.Now().Add(3*time.Second)))
clientTCP, err := net.DialTimeout("tcp", listener.Addr().String(), 3*time.Second)
require.NoError(t, err)
t.Cleanup(func() { _ = clientTCP.Close() })
serverTCP, err := listener.Accept()
require.NoError(t, err)
t.Cleanup(func() { _ = serverTCP.Close() })
client, err := fmux.Client(clientTCP, fmux.DefaultConfig())
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
server, err := fmux.Server(serverTCP, fmux.DefaultConfig())
require.NoError(t, err)
t.Cleanup(func() { _ = server.Close() })
clientStream, err := client.OpenStream()
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
serverStream, err := server.AcceptStreamWithContext(ctx)
require.NoError(t, err)
return client, server, msg.NewConn(clientStream, msg.NewV1ReadWriter(clientStream)),
msg.NewConn(serverStream, msg.NewV1ReadWriter(serverStream))
}
func newYamuxTestControl(t *testing.T, svr *Service) (*Control, *fmux.Session, *fmux.Session, *msg.Conn) {
t.Helper()
client, server, clientConn, serverConn := newYamuxTestSession(t)
ctl, err := svr.RegisterControl(serverConn, &msg.Login{
RunID: "shared-run",
ClientID: "client",
Timestamp: 1,
PrivilegeKey: util.GetAuthKey("", 1),
}, false, wire.ProtocolV1, "", server)
require.NoError(t, err)
t.Cleanup(func() {
_ = ctl.Close()
waitForControlDone(t, ctl)
})
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
return ctl, client, server, clientConn
}
+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")
})
}
}
+80 -29
View File
@@ -31,6 +31,7 @@ import (
"github.com/fatedier/golib/crypto"
"github.com/fatedier/golib/net/mux"
fmux "github.com/fatedier/yamux"
"github.com/prometheus/client_golang/prometheus/promhttp"
quic "github.com/quic-go/quic-go"
"github.com/samber/lo"
@@ -120,7 +121,8 @@ 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
@@ -152,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
@@ -179,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 {
@@ -217,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)
@@ -369,9 +348,58 @@ 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()
@@ -393,6 +421,15 @@ func (svr *Service) Run(ctx context.Context) {
// 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 {
@@ -401,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 {
@@ -459,6 +505,9 @@ func (svr *Service) closeResources() {
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.
@@ -472,7 +521,7 @@ func (svr *Service) closeResources() {
svr.ctlManager.Close()
}
func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, internal bool) {
func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, internal bool, session io.Closer) {
xl := xlog.FromContextSafe(ctx)
acceptedConn, err := svr.acceptConnection(ctx, conn)
@@ -503,7 +552,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, interna
}
}
if err == nil {
ctl, err = svr.RegisterControl(controlConn, m, internal, acceptedConn.wireProtocol, acceptedConn.udpPacketCodec)
ctl, err = svr.RegisterControl(controlConn, m, internal, acceptedConn.wireProtocol, acceptedConn.udpPacketCodec, session)
}
}
@@ -764,10 +813,10 @@ func (svr *Service) HandleListener(l net.Listener, internal bool) {
session.Close()
return
}
go svr.handleConnection(ctx, stream, internal)
go svr.handleConnection(ctx, stream, internal, session)
}
} else {
svr.handleConnection(ctx, frpConn, internal)
svr.handleConnection(ctx, frpConn, internal, nil)
}
}(ctx, c)
}
@@ -790,7 +839,7 @@ func (svr *Service) HandleQUICListener(l *quic.Listener) {
_ = frpConn.CloseWithError(0, "")
return
}
go svr.handleConnection(ctx, netpkg.QuicStreamToNetConn(stream, frpConn), false)
go svr.handleConnection(ctx, netpkg.QuicStreamToNetConn(stream, frpConn), false, nil)
}
}(context.Background(), c)
}
@@ -802,6 +851,7 @@ func (svr *Service) RegisterControl(
internal bool,
wireProtocol string,
udpPacketCodec string,
session io.Closer,
) (*Control, error) {
switch wireProtocol {
case wire.ProtocolV1:
@@ -851,6 +901,7 @@ func (svr *Service) RegisterControl(
AuthVerifier: authVerifier,
EncryptionKey: svr.auth.EncryptionKey(),
Conn: ctlConn,
Session: session,
LoginMsg: loginMsg,
ServerCfg: svr.cfg,
WireProtocol: wireProtocol,
+211
View File
@@ -1,9 +1,14 @@
package server
import (
"bytes"
"context"
"fmt"
"net"
"os"
"path/filepath"
"runtime/pprof"
"strings"
"sync"
"sync/atomic"
"testing"
@@ -86,6 +91,168 @@ func assertLifecyclePorts(t *testing.T, addresses []string, released bool) {
}
}
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) {
@@ -109,6 +276,50 @@ func TestServiceRunAfterEarlyShutdown(t *testing.T) {
}
}
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{})
+7 -7
View File
@@ -576,7 +576,7 @@ func TestServiceWorkConnRoutingClientHelloPolicy(t *testing.T) {
ClientSpec: msg.ClientSpec{
AlwaysAuthPass: true,
},
}, true, wire.ProtocolV2, tc.controlUDPPacketCodec)
}, true, wire.ProtocolV2, tc.controlUDPPacketCodec, nil)
require.NoError(t, err)
require.NoError(t, svr.completeControlLogin(ctl, func() error { return nil }))
waitForSignal(t, controlConn.readStarted, "control reader to start")
@@ -634,7 +634,7 @@ func TestServiceRegisterControlRejectsInvalidCodecSelection(t *testing.T) {
svr := newControlTestService(t)
conn := newDeadlineReadConn()
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
ctl, err := svr.RegisterControl(msgConn, &msg.Login{}, true, tc.wireProtocol, tc.udpPacketCodec)
ctl, err := svr.RegisterControl(msgConn, &msg.Login{}, true, tc.wireProtocol, tc.udpPacketCodec, nil)
require.Nil(t, ctl)
require.ErrorContains(t, err, tc.errorSubstring)
})
@@ -650,7 +650,7 @@ func TestServiceRegisterControlRejectsInvalidRunID(t *testing.T) {
svr := newControlTestService(t)
conn := newDeadlineReadConn()
msgConn := msg.NewConn(conn, msg.NewV1ReadWriter(conn))
ctl, err := svr.RegisterControl(msgConn, &msg.Login{RunID: runID}, true, wire.ProtocolV1, "")
ctl, err := svr.RegisterControl(msgConn, &msg.Login{RunID: runID}, true, wire.ProtocolV1, "", nil)
require.Nil(t, ctl)
require.ErrorContains(t, err, "invalid run id")
})
@@ -684,7 +684,7 @@ func TestServiceRegisterControlPoolCountBoundaries(t *testing.T) {
Timestamp: timestamp,
PrivilegeKey: util.GetAuthKey("", timestamp),
PoolCount: tc.poolCount,
}, false, wire.ProtocolV1, "")
}, false, wire.ProtocolV1, "", nil)
if tc.wantErr {
require.Nil(t, ctl)
require.ErrorContains(t, err, "unexpected error when creating new controller")
@@ -758,7 +758,7 @@ func TestServiceVisitorRoutingExcludesPendingUser(t *testing.T) {
ClientSpec: msg.ClientSpec{
AlwaysAuthPass: true,
},
}, true, wire.ProtocolV1, "")
}, true, wire.ProtocolV1, "", nil)
require.NoError(t, err)
timestamp := time.Now().Unix()
@@ -802,7 +802,7 @@ func TestServiceVisitorRoutingCarriesControlPacketCodec(t *testing.T) {
ClientSpec: msg.ClientSpec{
AlwaysAuthPass: true,
},
}, true, wire.ProtocolV2, wire.UDPPacketCodecBinary)
}, true, wire.ProtocolV2, wire.UDPPacketCodecBinary, nil)
require.NoError(t, err)
timestamp := time.Now().Unix()
@@ -891,7 +891,7 @@ func registerLifecycleTestControl(svr *Service) (*Control, *deadlineReadConn, er
ClientSpec: msg.ClientSpec{
AlwaysAuthPass: true,
},
}, true, wire.ProtocolV1, "")
}, true, wire.ProtocolV1, "", nil)
return ctl, conn, err
}
+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() {
+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
})
})
})