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 + }) + }) +})