diff --git a/.github/workflows/build-and-push-image.yml b/.github/workflows/build-and-push-image.yml
index aaa97705..f86fda02 100644
--- a/.github/workflows/build-and-push-image.yml
+++ b/.github/workflows/build-and-push-image.yml
@@ -19,7 +19,7 @@ jobs:
steps:
# environment
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: '0'
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 1e9b9f46..2019a05b 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -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
diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml
index 16fcbc97..82e86911 100644
--- a/.github/workflows/goreleaser.yml
+++ b/.github/workflows/goreleaser.yml
@@ -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)
diff --git a/.golangci.yml b/.golangci.yml
index 886b55fb..dfae3d83 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -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
diff --git a/README.md b/README.md
index 1c72a7bf..57116f20 100644
--- a/README.md
+++ b/README.md
@@ -12,17 +12,15 @@ frp is an open source project with its ongoing development made possible entirel
Gold Sponsors
-
-
-
-
- High-performance residential and ISP proxies for developers
-
-
- 90M+ residential IPs worldwide. Rotating IPs, sticky sessions, and traffic that never expires.
-
- From $0.55/GB. Use RAPID10 for 10% off. Try it for free.
-
+
+
+## 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.
+
+
@@ -42,16 +40,6 @@ frp is an open source project with its ongoing development made possible entirel
-
-
-## 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.
-
-
-
## 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).
diff --git a/README_zh.md b/README_zh.md
index e4d28410..4389ec65 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -14,17 +14,15 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
Gold Sponsors
-
-
-
-
- High-performance residential and ISP proxies for developers
-
-
- 90M+ residential IPs worldwide. Rotating IPs, sticky sessions, and traffic that never expires.
-
- From $0.55/GB. Use RAPID10 for 10% off. Try it for free.
-
+
+
+## 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.
+
+
@@ -44,16 +42,6 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
-
-
-## 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.
-
-
-
## 为什么使用 frp ?
diff --git a/Release.md b/Release.md
index 35d0442c..837971af 100644
--- a/Release.md
+++ b/Release.md
@@ -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.
diff --git a/client/config_manager.go b/client/config_manager.go
index 24512e9f..d7bc985e 100644
--- a/client/config_manager.go
+++ b/client/config_manager.go
@@ -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
}
diff --git a/client/config_manager_certificate_test.go b/client/config_manager_certificate_test.go
new file mode 100644
index 00000000..da080681
--- /dev/null
+++ b/client/config_manager_certificate_test.go
@@ -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)
+ })
+ }
+}
diff --git a/client/connector.go b/client/connector.go
index 41332378..95138dda 100644
--- a/client/connector.go
+++ b/client/connector.go
@@ -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
diff --git a/client/http/controller.go b/client/http/controller.go
index 57e8165d..400e3cb5 100644
--- a/client/http/controller.go
+++ b/client/http/controller.go
@@ -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
diff --git a/client/http/controller_test.go b/client/http/controller_test.go
index 719fbcd4..f1cf572e 100644
--- a/client/http/controller_test.go
+++ b/client/http/controller_test.go
@@ -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
diff --git a/client/metrics_server_test.go b/client/metrics_server_test.go
new file mode 100644
index 00000000..2bd395f4
--- /dev/null
+++ b/client/metrics_server_test.go
@@ -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_")
+}
diff --git a/client/proxy/plugin_reload_test.go b/client/proxy/plugin_reload_test.go
new file mode 100644
index 00000000..902c28b7
--- /dev/null
+++ b/client/proxy/plugin_reload_test.go
@@ -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)
+}
diff --git a/client/proxy/proxy.go b/client/proxy/proxy.go
index e982c03b..a09967c8 100644
--- a/client/proxy/proxy.go
+++ b/client/proxy/proxy.go
@@ -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()
diff --git a/client/proxy/proxy_manager.go b/client/proxy/proxy_manager.go
index 6bf8d4a8..6f525c24 100644
--- a/client/proxy/proxy_manager.go
+++ b/client/proxy/proxy_manager.go
@@ -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 {
diff --git a/client/proxy/proxy_wrapper.go b/client/proxy/proxy_wrapper.go
index 30ea5e33..bfe55504 100644
--- a/client/proxy/proxy_wrapper.go
+++ b/client/proxy/proxy_wrapper.go
@@ -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
}
diff --git a/client/proxy/udp.go b/client/proxy/udp.go
index d01d2c8e..e387da9c 100644
--- a/client/proxy/udp.go
+++ b/client/proxy/udp.go
@@ -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)
}
diff --git a/client/proxy/udp_test.go b/client/proxy/udp_test.go
new file mode 100644
index 00000000..13995172
--- /dev/null
+++ b/client/proxy/udp_test.go
@@ -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()
+}
diff --git a/client/service.go b/client/service.go
index 055de40d..e7dcd8e0 100644
--- a/client/service.go
+++ b/client/service.go
@@ -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
diff --git a/client/service_reload_test.go b/client/service_reload_test.go
new file mode 100644
index 00000000..8dc6ac9a
--- /dev/null
+++ b/client/service_reload_test.go
@@ -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)
+}
diff --git a/conf/frpc_full_example.toml b/conf/frpc_full_example.toml
index b141b0ed..6108e3aa 100644
--- a/conf/frpc_full_example.toml
+++ b/conf/frpc_full_example.toml
@@ -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.
diff --git a/conf/frps_full_example.toml b/conf/frps_full_example.toml
index aba37435..df69f25b 100644
--- a/conf/frps_full_example.toml
+++ b/conf/frps_full_example.toml
@@ -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
diff --git a/doc/deprecations.md b/doc/deprecations.md
index a5d394c5..48d5e425 100644
--- a/doc/deprecations.md
+++ b/doc/deprecations.md
@@ -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).
diff --git a/dockerfiles/Dockerfile-for-frpc b/dockerfiles/Dockerfile-for-frpc
index e83b10f0..8b1ce362 100644
--- a/dockerfiles/Dockerfile-for-frpc
+++ b/dockerfiles/Dockerfile-for-frpc
@@ -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
diff --git a/dockerfiles/Dockerfile-for-frps b/dockerfiles/Dockerfile-for-frps
index c13004d3..aad51e60 100644
--- a/dockerfiles/Dockerfile-for-frps
+++ b/dockerfiles/Dockerfile-for-frps
@@ -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
diff --git a/go.mod b/go.mod
index 838ec8d1..5c198f9d 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index e5b5ba7e..93cccb78 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/pkg/config/v1/client.go b/pkg/config/v1/client.go
index 6736030a..fcf3511c 100644
--- a/pkg/config/v1/client.go
+++ b/pkg/config/v1/client.go
@@ -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
diff --git a/pkg/config/v1/common.go b/pkg/config/v1/common.go
index 41bd9a4c..5177103c 100644
--- a/pkg/config/v1/common.go
+++ b/pkg/config/v1/common.go
@@ -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"`
diff --git a/pkg/config/v1/server.go b/pkg/config/v1/server.go
index 6c1c75c4..a729fbda 100644
--- a/pkg/config/v1/server.go
+++ b/pkg/config/v1/server.go
@@ -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")
diff --git a/pkg/config/v1/validation/client.go b/pkg/config/v1/validation/client.go
index 28409c4b..41e1c175 100644
--- a/pkg/config/v1/validation/client.go
+++ b/pkg/config/v1/validation/client.go
@@ -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) {
diff --git a/pkg/config/v1/validation/common.go b/pkg/config/v1/validation/common.go
index c7b93a32..6767bf7e 100644
--- a/pkg/config/v1/validation/common.go
+++ b/pkg/config/v1/validation/common.go
@@ -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 {
diff --git a/pkg/config/v1/validation/metrics_server_test.go b/pkg/config/v1/validation/metrics_server_test.go
new file mode 100644
index 00000000..18102e24
--- /dev/null
+++ b/pkg/config/v1/validation/metrics_server_test.go
@@ -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)
+ }
+ }
+}
diff --git a/pkg/config/v1/validation/proxy.go b/pkg/config/v1/validation/proxy.go
index 5d430e6b..0eca2663 100644
--- a/pkg/config/v1/validation/proxy.go
+++ b/pkg/config/v1/validation/proxy.go
@@ -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))
}
}
diff --git a/pkg/config/v1/validation/proxy_test.go b/pkg/config/v1/validation/proxy_test.go
index b9411dc8..56bdc72c 100644
--- a/pkg/config/v1/validation/proxy_test.go
+++ b/pkg/config/v1/validation/proxy_test.go
@@ -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")
+}
diff --git a/pkg/config/v1/validation/server.go b/pkg/config/v1/validation/server.go
index 54bd8348..d7cf242c 100644
--- a/pkg/config/v1/validation/server.go
+++ b/pkg/config/v1/validation/server.go
@@ -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"))
diff --git a/pkg/config/v1/validation/tls_test.go b/pkg/config/v1/validation/tls_test.go
new file mode 100644
index 00000000..5c1cd36e
--- /dev/null
+++ b/pkg/config/v1/validation/tls_test.go
@@ -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)
+ }
+ })
+ }
+ }
+}
diff --git a/pkg/metrics/text/registry.go b/pkg/metrics/text/registry.go
new file mode 100644
index 00000000..cef2bc43
--- /dev/null
+++ b/pkg/metrics/text/registry.go
@@ -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
+}
diff --git a/pkg/metrics/text/runtime.go b/pkg/metrics/text/runtime.go
new file mode 100644
index 00000000..77483780
--- /dev/null
+++ b/pkg/metrics/text/runtime.go
@@ -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
+}
diff --git a/pkg/metrics/text/runtime_test.go b/pkg/metrics/text/runtime_test.go
new file mode 100644
index 00000000..04911a42
--- /dev/null
+++ b/pkg/metrics/text/runtime_test.go
@@ -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()
+}
diff --git a/pkg/metrics/text/text.go b/pkg/metrics/text/text.go
new file mode 100644
index 00000000..d4ab57db
--- /dev/null
+++ b/pkg/metrics/text/text.go
@@ -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)
+}
diff --git a/pkg/metrics/text/text_test.go b/pkg/metrics/text/text_test.go
new file mode 100644
index 00000000..e574aa0b
--- /dev/null
+++ b/pkg/metrics/text/text_test.go
@@ -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())
+}
diff --git a/pkg/msg/handler_yamux_test.go b/pkg/msg/handler_yamux_test.go
index 25d91a9e..98e0a850 100644
--- a/pkg/msg/handler_yamux_test.go
+++ b/pkg/msg/handler_yamux_test.go
@@ -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")
})
}
}
diff --git a/pkg/plugin/client/http_common.go b/pkg/plugin/client/http_common.go
index 5010a4e9..5e1eb0f5 100644
--- a/pkg/plugin/client/http_common.go
+++ b/pkg/plugin/client/http_common.go
@@ -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()
+}
diff --git a/pkg/plugin/client/internal/httpsserver/server.go b/pkg/plugin/client/internal/httpsserver/server.go
index a2047b90..f3000c1a 100644
--- a/pkg/plugin/client/internal/httpsserver/server.go
+++ b/pkg/plugin/client/internal/httpsserver/server.go
@@ -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
+}
diff --git a/pkg/plugin/client/internal/httpsserver/server_test.go b/pkg/plugin/client/internal/httpsserver/server_test.go
new file mode 100644
index 00000000..c42488ac
--- /dev/null
+++ b/pkg/plugin/client/internal/httpsserver/server_test.go
@@ -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])
+ }
+}
diff --git a/pkg/transport/tls.go b/pkg/transport/tls.go
index 19ebca73..2bfe4842 100644
--- a/pkg/transport/tls.go
+++ b/pkg/transport/tls.go
@@ -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
}
diff --git a/pkg/transport/tls_test.go b/pkg/transport/tls_test.go
new file mode 100644
index 00000000..894ec37e
--- /dev/null
+++ b/pkg/transport/tls_test.go
@@ -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)
+ }
+ })
+ }
+ })
+ }
+}
diff --git a/pkg/util/http/metrics_server.go b/pkg/util/http/metrics_server.go
new file mode 100644
index 00000000..534ca7e0
--- /dev/null
+++ b/pkg/util/http/metrics_server.go
@@ -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
+}
diff --git a/pkg/util/http/metrics_server_test.go b/pkg/util/http/metrics_server_test.go
new file mode 100644
index 00000000..c9bfc66b
--- /dev/null
+++ b/pkg/util/http/metrics_server_test.go
@@ -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)
+}
diff --git a/pkg/util/http/server.go b/pkg/util/http/server.go
index 0bca8993..20dc3650 100644
--- a/pkg/util/http/server.go
+++ b/pkg/util/http/server.go
@@ -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
}
diff --git a/pkg/util/http/server_test.go b/pkg/util/http/server_test.go
new file mode 100644
index 00000000..20a3b7e4
--- /dev/null
+++ b/pkg/util/http/server_test.go
@@ -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())
+}
diff --git a/pkg/util/net/kcp.go b/pkg/util/net/kcp.go
index b1d1fb93..4193217a 100644
--- a/pkg/util/net/kcp.go
+++ b/pkg/util/net/kcp.go
@@ -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
}
diff --git a/pkg/util/net/kcp_test.go b/pkg/util/net/kcp_test.go
new file mode 100644
index 00000000..886e5be2
--- /dev/null
+++ b/pkg/util/net/kcp_test.go
@@ -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)
+ })
+}
diff --git a/pkg/util/validation/qualified_name.go b/pkg/util/validation/qualified_name.go
new file mode 100644
index 00000000..b8fd95a8
--- /dev/null
+++ b/pkg/util/validation/qualified_name.go
@@ -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
+}
diff --git a/pkg/util/validation/qualified_name_test.go b/pkg/util/validation/qualified_name_test.go
new file mode 100644
index 00000000..0bb6e7ce
--- /dev/null
+++ b/pkg/util/validation/qualified_name_test.go
@@ -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")
+ })
+}
diff --git a/pkg/util/version/version.go b/pkg/util/version/version.go
index 8217f6be..7482afc9 100644
--- a/pkg/util/version/version.go
+++ b/pkg/util/version/version.go
@@ -14,7 +14,7 @@
package version
-var version = "0.71.1"
+var version = "0.72.0"
func Full() string {
return version
diff --git a/pkg/util/vhost/vhost.go b/pkg/util/vhost/vhost.go
index 637c8dcf..f29333f2 100644
--- a/pkg/util/vhost/vhost.go
+++ b/pkg/util/vhost/vhost.go
@@ -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
}
diff --git a/pkg/util/vhost/vhost_close_test.go b/pkg/util/vhost/vhost_close_test.go
new file mode 100644
index 00000000..1e32c6d9
--- /dev/null
+++ b/pkg/util/vhost/vhost_close_test.go
@@ -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")
+ }
+}
diff --git a/pkg/util/xlog/xlog.go b/pkg/util/xlog/xlog.go
index 1021d44a..6627dc2a 100644
--- a/pkg/util/xlog/xlog.go
+++ b/pkg/util/xlog/xlog.go
@@ -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...)
}
diff --git a/pkg/util/xlog/xlog_concurrency_test.go b/pkg/util/xlog/xlog_concurrency_test.go
new file mode 100644
index 00000000..b8d90670
--- /dev/null
+++ b/pkg/util/xlog/xlog_concurrency_test.go
@@ -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()
+}
diff --git a/server/control.go b/server/control.go
index 8009d8ac..35ded591 100644
--- a/server/control.go
+++ b/server/control.go
@@ -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
diff --git a/server/control_session_test.go b/server/control_session_test.go
new file mode 100644
index 00000000..4ab76fd1
--- /dev/null
+++ b/server/control_session_test.go
@@ -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
+}
diff --git a/server/metrics_server_test.go b/server/metrics_server_test.go
new file mode 100644
index 00000000..9a956c0e
--- /dev/null
+++ b/server/metrics_server_test.go
@@ -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")
+ })
+ }
+}
diff --git a/server/service.go b/server/service.go
index 4556358b..203bef14 100644
--- a/server/service.go
+++ b/server/service.go
@@ -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,
diff --git a/server/service_lifecycle_test.go b/server/service_lifecycle_test.go
index 61481b32..af562a83 100644
--- a/server/service_lifecycle_test.go
+++ b/server/service_lifecycle_test.go
@@ -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{})
diff --git a/server/service_test.go b/server/service_test.go
index d4fcbe77..8bc055e2 100644
--- a/server/service_test.go
+++ b/server/service_test.go
@@ -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
}
diff --git a/test/e2e/v1/basic/client_server.go b/test/e2e/v1/basic/client_server.go
index 3a2fa28d..85c0d647 100644
--- a/test/e2e/v1/basic/client_server.go
+++ b/test/e2e/v1/basic/client_server.go
@@ -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() {
diff --git a/test/e2e/v1/features/metrics_server.go b/test/e2e/v1/features/metrics_server.go
new file mode 100644
index 00000000..777532df
--- /dev/null
+++ b/test/e2e/v1/features/metrics_server.go
@@ -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
+ })
+ })
+})