From 031716afd104dce6dc2cd06ecca0a76f145b744f Mon Sep 17 00:00:00 2001 From: fatedier Date: Thu, 15 Oct 2026 13:01:59 +0800 Subject: [PATCH 01/25] Merge pull request #5569 from fatedier/fix/docker-workspace-lockfile fix(docker): install web dependencies from the workspace lockfile --- dockerfiles/Dockerfile-for-frpc | 14 ++++++++------ dockerfiles/Dockerfile-for-frps | 14 ++++++++------ 2 files changed, 16 insertions(+), 12 deletions(-) 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 From 954f918d0cabdf09cc5c15030ef6d7dc2914198f Mon Sep 17 00:00:00 2001 From: fatedier Date: Thu, 15 Oct 2026 23:25:45 +0800 Subject: [PATCH 02/25] docs: clear release notes (#5570) --- README.md | 30 +++++++++--------------------- README_zh.md | 30 +++++++++--------------------- Release.md | 14 +------------- 3 files changed, 19 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 1c72a7bf..042a5a20 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? 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..304a5d83 100644 --- a/Release.md +++ b/Release.md @@ -1,13 +1 @@ -## Improvements - -* Building frp from source now requires Go 1.26.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. +## Features From 7441443134651ba7bf77f88a8e0e4c9662f672c7 Mon Sep 17 00:00:00 2001 From: Alexey Sviridov Date: Sun, 18 Oct 2026 08:08:05 +0200 Subject: [PATCH 03/25] feat(client): verify server certificates against the system root store (#5546) transport.tls.trustedCaFile is installed as RootCAs, and omitting it sets InsecureSkipVerify, so system-root verification cannot be selected directly. Add an opt-in transport.tls.useSystemRoots that enables certificate verification with RootCAs nil when no trusted CA file is set, allowing Go to use system roots and native verification on macOS and Windows. Defaults are unchanged. --- README.md | 2 + client/connector.go | 10 ++- conf/frpc_full_example.toml | 4 + pkg/config/v1/client.go | 3 + pkg/config/v1/validation/client.go | 4 + pkg/transport/tls.go | 16 +++- pkg/transport/tls_test.go | 139 +++++++++++++++++++++++++++++ test/e2e/v1/basic/client_server.go | 4 + 8 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 pkg/transport/tls_test.go diff --git a/README.md b/README.md index 042a5a20..3510fa86 100644 --- a/README.md +++ b/README.md @@ -712,6 +712,8 @@ transport.tls.keyFile = "certificate.key" transport.tls.trustedCaFile = "ca.crt" ``` +By default, when neither `transport.tls.trustedCaFile` nor `transport.tls.useSystemRoots` is set, `frpc` does not verify the server certificate. Set `transport.tls.useSystemRoots = true` to verify it against the host's root store instead. With `wss`, verification applies to the TLS endpoint, which may be a reverse proxy. `useSystemRoots` is ignored when `transport.tls.trustedCaFile` is set. + **`frps` TLS settings:** ```toml diff --git a/client/connector.go b/client/connector.go index 41332378..7ffc6860 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.NewClientTLSConfigWithSystemRoots( c.cfg.Transport.TLS.CertFile, c.cfg.Transport.TLS.KeyFile, c.cfg.Transport.TLS.TrustedCaFile, - sn) + sn, + c.cfg.Transport.TLS.UseSystemRoots) } 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.NewClientTLSConfigWithSystemRoots( c.cfg.Transport.TLS.CertFile, c.cfg.Transport.TLS.KeyFile, c.cfg.Transport.TLS.TrustedCaFile, - sn) + sn, + c.cfg.Transport.TLS.UseSystemRoots) if err != nil { xl.Warnf("fail to build tls configuration, err: %v", err) return nil, err diff --git a/conf/frpc_full_example.toml b/conf/frpc_full_example.toml index b141b0ed..f779cc9a 100644 --- a/conf/frpc_full_example.toml +++ b/conf/frpc_full_example.toml @@ -131,6 +131,10 @@ transport.tls.enable = true # transport.tls.trustedCaFile = "ca.crt" # transport.tls.serverName = "example.com" +# If useSystemRoots is true and trustedCaFile is not set, frpc verifies the server certificate +# against the host's root store. +# transport.tls.useSystemRoots = 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/pkg/config/v1/client.go b/pkg/config/v1/client.go index 6736030a..e30a65c6 100644 --- a/pkg/config/v1/client.go +++ b/pkg/config/v1/client.go @@ -175,6 +175,9 @@ type TLSClientConfig struct { // 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. DisableCustomTLSFirstByte *bool `json:"disableCustomTLSFirstByte,omitempty"` + // UseSystemRoots enables server certificate verification using system roots. + // TrustedCaFile takes precedence. If neither is configured, verification is disabled. + UseSystemRoots bool `json:"useSystemRoots,omitempty"` TLSConfig } diff --git a/pkg/config/v1/validation/client.go b/pkg/config/v1/validation/client.go index 28409c4b..d51e4384 100644 --- a/pkg/config/v1/validation/client.go +++ b/pkg/config/v1/validation/client.go @@ -163,6 +163,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.UseSystemRoots && c.Protocol != "wss" { + warnings = AppendError(warnings, fmt.Errorf("transport.tls.useSystemRoots is ignored when transport.tls.enable is false")) + } } if !slices.Contains(SupportedTransportProtocols, c.Protocol) { diff --git a/pkg/transport/tls.go b/pkg/transport/tls.go index 19ebca73..4ffbde93 100644 --- a/pkg/transport/tls.go +++ b/pkg/transport/tls.go @@ -126,6 +126,13 @@ func NewServerTLSConfig(certPath, keyPath, caPath string) (*tls.Config, error) { } func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Config, error) { + return NewClientTLSConfigWithSystemRoots(certPath, keyPath, caPath, serverName, false) +} + +// NewClientTLSConfigWithSystemRoots behaves like NewClientTLSConfig, except that when +// caPath is empty and useSystemRoots is true, the server certificate is verified against +// the host's root store. +func NewClientTLSConfigWithSystemRoots(certPath, keyPath, caPath, serverName string, useSystemRoots bool) (*tls.Config, error) { base := &tls.Config{} if certPath != "" && keyPath != "" { @@ -139,7 +146,8 @@ func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Conf base.ServerName = serverName - if caPath != "" { + switch { + case caPath != "": pool, err := newCertPool(caPath) if err != nil { return nil, err @@ -147,7 +155,11 @@ func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Conf base.RootCAs = pool base.InsecureSkipVerify = false - } else { + case useSystemRoots: + // 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..d1a32486 --- /dev/null +++ b/pkg/transport/tls_test.go @@ -0,0 +1,139 @@ +// 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" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func newSelfSignedCert(t *testing.T) (tls.Certificate, []byte) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + DNSNames: []string{"localhost"}, + 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) { + _, caPEM := newSelfSignedCert(t) + caFile := filepath.Join(t.TempDir(), "ca.crt") + require.NoError(t, os.WriteFile(caFile, caPEM, 0o600)) + + tests := []struct { + name string + caPath string + useSystemRoots bool + insecureSkipVerify bool + rootCAsSet bool + }{ + { + name: "no trusted ca file, no system roots", + insecureSkipVerify: true, + }, + { + name: "system roots", + useSystemRoots: true, + }, + { + name: "trusted ca file", + caPath: caFile, + rootCAsSet: true, + }, + { + name: "trusted ca file takes precedence over system roots", + caPath: caFile, + useSystemRoots: true, + rootCAsSet: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + + cfg, err := NewClientTLSConfigWithSystemRoots("", "", test.caPath, "example.com", test.useSystemRoots) + require.NoError(err) + require.Equal("example.com", cfg.ServerName) + require.Equal(test.insecureSkipVerify, cfg.InsecureSkipVerify) + require.Equal(test.rootCAsSet, cfg.RootCAs != nil) + }) + } +} + +func TestClientTLSConfigHandshakeWithUntrustedServer(t *testing.T) { + require := require.New(t) + + cert, _ := newSelfSignedCert(t) + ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}}) + require.NoError(err) + t.Cleanup(func() { _ = ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + _ = conn.(*tls.Conn).Handshake() + _ = conn.Close() + } + }() + + dial := func(useSystemRoots bool) error { + cfg, err := NewClientTLSConfigWithSystemRoots("", "", "", "localhost", useSystemRoots) + require.NoError(err) + conn, err := tls.Dial("tcp", ln.Addr().String(), cfg) + if err != nil { + return err + } + return conn.Close() + } + + require.NoError(dial(false)) + + err = dial(true) + require.Error(err) + var verificationErr *tls.CertificateVerificationError + require.True(errors.As(err, &verificationErr), "expected a certificate verification error, got %v", err) +} diff --git a/test/e2e/v1/basic/client_server.go b/test/e2e/v1/basic/client_server.go index 3a2fa28d..e8256cdb 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("useSystemRoots rejects the default self-signed server certificate", f, &generalTestConfigures{ + client: "transport.tls.useSystemRoots = true", + expectError: true, + }) }) ginkgo.Describe("TLS with custom certificate", func() { From 26742060b1e55a64509f4d0bfb073470f81f1af3 Mon Sep 17 00:00:00 2001 From: fatedier Date: Sun, 18 Oct 2026 16:00:02 +0800 Subject: [PATCH 04/25] feat(client): add useSystemCa and combine it with trustedCaFile (#5572) Move the frpc-only TLS option into the shared TLSConfig as transport.tls.useSystemCa, replacing the unpublished useSystemRoots. When set, frpc verifies the server with the system CAs; when trustedCaFile is also set, its CAs are appended to the system pool so both are trusted. Failures to load either source abort the connection instead of falling back. Enabling the option without effective TLS is a configuration error, and the field is rejected in frps transport.tls and in both webServer.tls sections. Fixes #5545 --- README.md | 4 +- Release.md | 2 + client/connector.go | 8 +- conf/frpc_full_example.toml | 8 +- pkg/config/v1/client.go | 3 - pkg/config/v1/common.go | 4 + pkg/config/v1/validation/client.go | 4 +- pkg/config/v1/validation/common.go | 3 + pkg/config/v1/validation/server.go | 3 + pkg/config/v1/validation/tls_test.go | 146 ++++++++++++++++ pkg/transport/tls.go | 50 +++++- pkg/transport/tls_test.go | 249 +++++++++++++++++++-------- test/e2e/v1/basic/client_server.go | 4 +- 13 files changed, 395 insertions(+), 93 deletions(-) create mode 100644 pkg/config/v1/validation/tls_test.go diff --git a/README.md b/README.md index 3510fa86..5e893ba3 100644 --- a/README.md +++ b/README.md @@ -712,7 +712,9 @@ transport.tls.keyFile = "certificate.key" transport.tls.trustedCaFile = "ca.crt" ``` -By default, when neither `transport.tls.trustedCaFile` nor `transport.tls.useSystemRoots` is set, `frpc` does not verify the server certificate. Set `transport.tls.useSystemRoots = true` to verify it against the host's root store instead. With `wss`, verification applies to the TLS endpoint, which may be a reverse proxy. `useSystemRoots` is ignored when `transport.tls.trustedCaFile` is set. +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` is a configuration error. **`frps` TLS settings:** diff --git a/Release.md b/Release.md index 304a5d83..6e94d4a8 100644 --- a/Release.md +++ b/Release.md @@ -1 +1,3 @@ ## Features + +* frpc can verify server certificates using system CAs, optionally combined with CAs from `trustedCaFile`. diff --git a/client/connector.go b/client/connector.go index 7ffc6860..95138dda 100644 --- a/client/connector.go +++ b/client/connector.go @@ -109,12 +109,12 @@ func (c *defaultConnectorImpl) Open() error { sn = c.cfg.ServerAddr } if lo.FromPtr(c.cfg.Transport.TLS.Enable) { - tlsConfig, err = transport.NewClientTLSConfigWithSystemRoots( + tlsConfig, err = transport.NewClientTLSConfigWithSystemCA( c.cfg.Transport.TLS.CertFile, c.cfg.Transport.TLS.KeyFile, c.cfg.Transport.TLS.TrustedCaFile, sn, - c.cfg.Transport.TLS.UseSystemRoots) + c.cfg.Transport.TLS.UseSystemCa) } else { tlsConfig, err = transport.NewClientTLSConfig("", "", "", sn) } @@ -195,12 +195,12 @@ func (c *defaultConnectorImpl) realConnect() (net.Conn, error) { sn = c.cfg.ServerAddr } - tlsConfig, err = transport.NewClientTLSConfigWithSystemRoots( + tlsConfig, err = transport.NewClientTLSConfigWithSystemCA( c.cfg.Transport.TLS.CertFile, c.cfg.Transport.TLS.KeyFile, c.cfg.Transport.TLS.TrustedCaFile, sn, - c.cfg.Transport.TLS.UseSystemRoots) + c.cfg.Transport.TLS.UseSystemCa) if err != nil { xl.Warnf("fail to build tls configuration, err: %v", err) return nil, err diff --git a/conf/frpc_full_example.toml b/conf/frpc_full_example.toml index f779cc9a..7c553615 100644 --- a/conf/frpc_full_example.toml +++ b/conf/frpc_full_example.toml @@ -131,9 +131,11 @@ transport.tls.enable = true # transport.tls.trustedCaFile = "ca.crt" # transport.tls.serverName = "example.com" -# If useSystemRoots is true and trustedCaFile is not set, frpc verifies the server certificate -# against the host's root store. -# transport.tls.useSystemRoots = false +# 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. diff --git a/pkg/config/v1/client.go b/pkg/config/v1/client.go index e30a65c6..6736030a 100644 --- a/pkg/config/v1/client.go +++ b/pkg/config/v1/client.go @@ -175,9 +175,6 @@ type TLSClientConfig struct { // 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. DisableCustomTLSFirstByte *bool `json:"disableCustomTLSFirstByte,omitempty"` - // UseSystemRoots enables server certificate verification using system roots. - // TrustedCaFile takes precedence. If neither is configured, verification is disabled. - UseSystemRoots bool `json:"useSystemRoots,omitempty"` TLSConfig } diff --git a/pkg/config/v1/common.go b/pkg/config/v1/common.go index 41bd9a4c..f24f55bd 100644 --- a/pkg/config/v1/common.go +++ b/pkg/config/v1/common.go @@ -79,6 +79,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/validation/client.go b/pkg/config/v1/validation/client.go index d51e4384..e02dcf12 100644 --- a/pkg/config/v1/validation/client.go +++ b/pkg/config/v1/validation/client.go @@ -164,8 +164,8 @@ func validateTransportConfig(c *v1.ClientTransportConfig) (Warning, error) { 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.UseSystemRoots && c.Protocol != "wss" { - warnings = AppendError(warnings, fmt.Errorf("transport.tls.useSystemRoots is ignored when transport.tls.enable is false")) + 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")) } } diff --git a/pkg/config/v1/validation/common.go b/pkg/config/v1/validation/common.go index c7b93a32..d791c0d4 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") } diff --git a/pkg/config/v1/validation/server.go b/pkg/config/v1/validation/server.go index 54bd8348..a7615aa9 100644 --- a/pkg/config/v1/validation/server.go +++ b/pkg/config/v1/validation/server.go @@ -44,6 +44,9 @@ func (v *ConfigValidator) ValidateServerConfig(c *v1.ServerConfig) (Warning, err if err := validateWebServerConfig(&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/transport/tls.go b/pkg/transport/tls.go index 4ffbde93..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,13 +132,21 @@ func NewServerTLSConfig(certPath, keyPath, caPath string) (*tls.Config, error) { } func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Config, error) { - return NewClientTLSConfigWithSystemRoots(certPath, keyPath, caPath, serverName, false) + return NewClientTLSConfigWithSystemCA(certPath, keyPath, caPath, serverName, false) } -// NewClientTLSConfigWithSystemRoots behaves like NewClientTLSConfig, except that when -// caPath is empty and useSystemRoots is true, the server certificate is verified against -// the host's root store. -func NewClientTLSConfigWithSystemRoots(certPath, keyPath, caPath, serverName string, useSystemRoots bool) (*tls.Config, error) { +// 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 != "" { @@ -147,6 +161,24 @@ func NewClientTLSConfigWithSystemRoots(certPath, keyPath, caPath, serverName str base.ServerName = serverName 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 { @@ -155,7 +187,7 @@ func NewClientTLSConfigWithSystemRoots(certPath, keyPath, caPath, serverName str base.RootCAs = pool base.InsecureSkipVerify = false - case useSystemRoots: + case useSystemCa: // A nil RootCAs makes crypto/tls use the host root store, through the // platform verifier on macOS and Windows. base.InsecureSkipVerify = false diff --git a/pkg/transport/tls_test.go b/pkg/transport/tls_test.go index d1a32486..894ec37e 100644 --- a/pkg/transport/tls_test.go +++ b/pkg/transport/tls_test.go @@ -23,6 +23,7 @@ import ( "encoding/pem" "errors" "math/big" + "net" "os" "path/filepath" "testing" @@ -31,18 +32,21 @@ import ( "github.com/stretchr/testify/require" ) -func newSelfSignedCert(t *testing.T) (tls.Certificate, []byte) { +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), - Subject: pkix.Name{CommonName: "localhost"}, - DNSNames: []string{"localhost"}, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(time.Hour), + 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) @@ -56,84 +60,191 @@ func newSelfSignedCert(t *testing.T) (tls.Certificate, []byte) { } func TestNewClientTLSConfigVerification(t *testing.T) { - _, caPEM := newSelfSignedCert(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, caPEM, 0o600)) + require.NoError(t, os.WriteFile(caFile, filePEM, 0o600)) - tests := []struct { + 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 - useSystemRoots bool + useSystemCa bool insecureSkipVerify bool - rootCAsSet bool + wantPool *x509.CertPool }{ - { - name: "no trusted ca file, no system roots", - insecureSkipVerify: true, - }, - { - name: "system roots", - useSystemRoots: true, - }, - { - name: "trusted ca file", - caPath: caFile, - rootCAsSet: true, - }, - { - name: "trusted ca file takes precedence over system roots", - caPath: caFile, - useSystemRoots: true, - rootCAsSet: true, - }, + {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") + } + }) } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - require := require.New(t) + // 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)) +} - cfg, err := NewClientTLSConfigWithSystemRoots("", "", test.caPath, "example.com", test.useSystemRoots) - require.NoError(err) - require.Equal("example.com", cfg.ServerName) - require.Equal(test.insecureSkipVerify, cfg.InsecureSkipVerify) - require.Equal(test.rootCAsSet, cfg.RootCAs != nil) +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 TestClientTLSConfigHandshakeWithUntrustedServer(t *testing.T) { - require := require.New(t) +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)) - cert, _ := newSelfSignedCert(t) - ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}}) - require.NoError(err) - t.Cleanup(func() { _ = ln.Close() }) + 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() + } + }() - go func() { - for { - conn, err := ln.Accept() - if err != nil { - return + 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) + } + }) } - _ = conn.(*tls.Conn).Handshake() - _ = conn.Close() - } - }() - - dial := func(useSystemRoots bool) error { - cfg, err := NewClientTLSConfigWithSystemRoots("", "", "", "localhost", useSystemRoots) - require.NoError(err) - conn, err := tls.Dial("tcp", ln.Addr().String(), cfg) - if err != nil { - return err - } - return conn.Close() + }) } - - require.NoError(dial(false)) - - err = dial(true) - require.Error(err) - var verificationErr *tls.CertificateVerificationError - require.True(errors.As(err, &verificationErr), "expected a certificate verification error, got %v", err) } diff --git a/test/e2e/v1/basic/client_server.go b/test/e2e/v1/basic/client_server.go index e8256cdb..85c0d647 100644 --- a/test/e2e/v1/basic/client_server.go +++ b/test/e2e/v1/basic/client_server.go @@ -177,8 +177,8 @@ var _ = ginkgo.Describe("[Feature: Client-Server]", func() { client: "transport.tls.enable = false", expectError: true, }) - defineClientServerTest("useSystemRoots rejects the default self-signed server certificate", f, &generalTestConfigures{ - client: "transport.tls.useSystemRoots = true", + defineClientServerTest("useSystemCa rejects the default self-signed server certificate", f, &generalTestConfigures{ + client: "transport.tls.useSystemCa = true", expectError: true, }) }) From 399bf2ce4c1ee9c804cdb728af872e7d93523c2c Mon Sep 17 00:00:00 2001 From: fatedier Date: Mon, 19 Oct 2026 12:20:25 +0800 Subject: [PATCH 05/25] fix: release listeners when frps fails to start (#5574) A constructor that binds a port before its fallible preparation leaks the listener when a later step returns an error, and no one can close it because the returned service is nil. pkg/util/http: load the TLS keypair and build the auth middleware before net.Listen, so binding is the last step that can run and a failure leaves no bound port behind. server: create the web server after auth, the tcpmux, main, kcp, quic, ssh gateway and vhost listeners, and the nat hole controller, following what frpc already does. If the web server itself fails, close the resources created so far before returning. The port managers start their cleanup workers on creation, so they are created after every fallible step too, and the metrics collectors keep being enabled only when the web server is configured. Add regression tests for all three failure paths. --- pkg/util/http/server.go | 13 +-- pkg/util/http/server_test.go | 55 ++++++++++ server/service.go | 49 ++++----- server/service_lifecycle_test.go | 167 +++++++++++++++++++++++++++++++ 4 files changed, 255 insertions(+), 29 deletions(-) create mode 100644 pkg/util/http/server_test.go diff --git a/pkg/util/http/server.go b/pkg/util/http/server.go index 0bca8993..dac51779 100644 --- a/pkg/util/http/server.go +++ b/pkg/util/http/server.go @@ -53,11 +53,6 @@ func NewServer(cfg v1.WebServerConfig) (*Server, error) { addr = ":http" } - ln, err := net.Listen("tcp", addr) - if err != nil { - return nil, err - } - router := mux.NewRouter() hs := &http.Server{ Addr: addr, @@ -67,7 +62,6 @@ func NewServer(cfg v1.WebServerConfig) (*Server, error) { } s := &Server{ addr: addr, - ln: ln, hs: hs, router: router, } @@ -84,6 +78,13 @@ func NewServer(cfg v1.WebServerConfig) (*Server, error) { } } s.authMiddleware = netpkg.NewHTTPAuthMiddleware(cfg.User, cfg.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/server/service.go b/server/service.go index 4556358b..a21c4f69 100644 --- a/server/service.go +++ b/server/service.go @@ -152,20 +152,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 +165,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 +197,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,6 +346,32 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) { return nil, fmt.Errorf("create nat hole controller error, %v", err) } svr.rc.NatHoleController = nc + + // Create the web server after all fallible steps so its listener is not + // leaked when an earlier error causes NewService to return. + 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 + + modelmetrics.EnableMem() + if cfg.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 } diff --git a/server/service_lifecycle_test.go b/server/service_lifecycle_test.go index 61481b32..bf74ba69 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) { From 228d23b3d563534c9006d9a20d88020b2b87a87c Mon Sep 17 00:00:00 2001 From: fatedier Date: Mon, 19 Oct 2026 13:20:00 +0800 Subject: [PATCH 06/25] fix: release pending kcp sessions when the listener is closed (#5575) KCPListener hands accepted sessions to a consumer through an unbuffered channel, but its accept goroutine wrote to that channel without watching for shutdown. A Close racing with a handoff left the goroutine blocked forever, holding the session it was about to deliver, because closing the underlying listener does not unblock a send. The close flag it read was also written by Close without synchronisation. Watch a done channel in the handoff and close the connection that was never delivered, so the goroutine can exit and the session is released. Accept now returns ErrKCPListenerClosed as soon as the listener is closed instead of blocking on the channel, and the close state is a done channel guarded by sync.Once. A ready handoff can still win the race with Close; the session then belongs to the caller of Accept, matching how WebsocketListener behaves. Tests cover a pending handoff that is cancelled by Close and a blocked Accept that is woken by it. --- pkg/util/net/kcp.go | 55 ++++++++++------ pkg/util/net/kcp_test.go | 131 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 18 deletions(-) create mode 100644 pkg/util/net/kcp_test.go 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) + }) +} From 585fdc5b83ee13df78f21fbfaa909eba96c79094 Mon Sep 17 00:00:00 2001 From: fatedier Date: Mon, 19 Oct 2026 16:07:07 +0800 Subject: [PATCH 07/25] fix(client): serialize config updates with control handoff (#5577) --- Release.md | 4 + client/service.go | 27 ++-- client/service_reload_test.go | 232 ++++++++++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 8 deletions(-) create mode 100644 client/service_reload_test.go diff --git a/Release.md b/Release.md index 6e94d4a8..b717568c 100644 --- a/Release.md +++ b/Release.md @@ -1,3 +1,7 @@ ## Features * frpc can verify server certificates using system CAs, optionally combined with CAs from `trustedCaFile`. + +## Fixes + +* Fixed proxy and visitor configuration changes being lost when reloading frpc during reconnection. diff --git a/client/service.go b/client/service.go index 055de40d..e364dd11 100644 --- a/client/service.go +++ b/client/service.go @@ -128,8 +128,8 @@ type Service struct { 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. @@ -354,11 +354,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 +363,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 +394,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 @@ -556,7 +567,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) +} From 00e4fc4dd37a0bdc9b7053917691e5c86f1e7100 Mon Sep 17 00:00:00 2001 From: fatedier Date: Mon, 19 Oct 2026 17:16:55 +0800 Subject: [PATCH 08/25] feat(client): reload plugin certificates on frpc reload (#5576) The https2http and https2https plugins read their certificate and key once when the proxy starts, so a certificate that an external tool renews on disk is only picked up after the proxy itself is recreated. frpc reload rebuilt proxies only when their configuration changed, and a rebuild closes the work connections that are already in use. Let the plugin own the refresh. The HTTPS plugin server keeps the active keypair and the last refresh error in one immutable snapshot published through an atomic pointer, so a TLS handshake only loads that pointer and never touches the disk. Reload rereads both files, and when their bytes match the published pair it returns without parsing or replacing anything; otherwise it parses the pair and swaps the snapshot in. A read or parse failure keeps the previous certificate serving and records the error next to it. frpc reload collects the reloadable plugins of the live proxies and applies the new configuration inside the same reload transaction that also serializes control handoff, so a reload that races a reconnect still refreshes the plugins of the published control. The certificate files are read only after that transaction has been released. The config store API keeps applying configuration without refreshing certificates, because only an explicit reload may reread files. Proxy status reports a refresh failure next to the still running proxy instead of dropping its remote address. No configuration field is added, and frps and the wire protocol are unchanged. Without a reload nothing changes: certificates are still read once at startup, generated self-signed certificates still never reload, and a proxy whose configuration changed still goes through the existing rebuild path. --- README.md | 9 + Release.md | 1 + client/config_manager.go | 21 +- client/config_manager_certificate_test.go | 316 ++++++++++++++++++ client/http/controller.go | 2 +- client/http/controller_test.go | 29 ++ client/proxy/plugin_reload_test.go | 148 ++++++++ client/proxy/proxy.go | 3 + client/proxy/proxy_manager.go | 35 ++ client/proxy/proxy_wrapper.go | 30 ++ client/service.go | 9 + pkg/plugin/client/http_common.go | 23 +- .../client/internal/httpsserver/server.go | 83 ++++- .../internal/httpsserver/server_test.go | 153 +++++++++ 14 files changed, 854 insertions(+), 8 deletions(-) create mode 100644 client/config_manager_certificate_test.go create mode 100644 client/proxy/plugin_reload_test.go create mode 100644 pkg/plugin/client/internal/httpsserver/server_test.go diff --git a/README.md b/README.md index 5e893ba3..5080e5e9 100644 --- a/README.md +++ b/README.md @@ -809,6 +809,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/Release.md b/Release.md index b717568c..0e441d06 100644 --- a/Release.md +++ b/Release.md @@ -1,6 +1,7 @@ ## Features * 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. ## Fixes 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/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/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/service.go b/client/service.go index e364dd11..bfeca8ae 100644 --- a/client/service.go +++ b/client/service.go @@ -424,6 +424,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") 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]) + } +} From f13fdd58d4e71345baf9cef7852c357bc3586d9d Mon Sep 17 00:00:00 2001 From: fatedier Date: Mon, 19 Oct 2026 20:08:39 +0800 Subject: [PATCH 09/25] feat: add metricsServer for health checks, metrics and pprof (#5578) Add a separate HTTP listener for observability, configured through metricsServer in both frps and frpc. It serves /healthz and, when pprofEnable is set, the pprof handlers except /debug/pprof/cmdline. It never serves the dashboard, static assets or the admin API, and it does not touch the global assets filesystem. frps also serves /metrics on this listener with both runtime and business metrics, enabled by metricsServer.port alone. The in-memory collector still starts only with the dashboard, and the Prometheus collector starts either with metricsServer or with enablePrometheus plus webServer, so a configuration that collected nothing before still collects nothing. The /healthz, /metrics and pprof endpoints on webServer, and enablePrometheus, keep working but are deprecated and log a hint at startup. frpc does not serve /metrics yet. --- README.md | 26 +++- Release.md | 3 + client/metrics_server_test.go | 91 +++++++++++++ client/service.go | 35 ++++- conf/frpc_full_example.toml | 13 ++ conf/frps_full_example.toml | 16 ++- pkg/config/v1/client.go | 10 +- pkg/config/v1/common.go | 20 +++ pkg/config/v1/server.go | 4 +- pkg/config/v1/validation/client.go | 1 + pkg/config/v1/validation/common.go | 21 +++ .../v1/validation/metrics_server_test.go | 119 +++++++++++++++++ pkg/config/v1/validation/server.go | 3 + pkg/util/http/metrics_server.go | 54 ++++++++ pkg/util/http/metrics_server_test.go | 111 ++++++++++++++++ pkg/util/http/server.go | 22 +++- server/metrics_server_test.go | 108 +++++++++++++++ server/service.go | 60 ++++++++- server/service_lifecycle_test.go | 44 +++++++ test/e2e/v1/features/metrics_server.go | 124 ++++++++++++++++++ 20 files changed, 858 insertions(+), 27 deletions(-) create mode 100644 client/metrics_server_test.go create mode 100644 pkg/config/v1/validation/metrics_server_test.go create mode 100644 pkg/util/http/metrics_server.go create mode 100644 pkg/util/http/metrics_server_test.go create mode 100644 server/metrics_server_test.go create mode 100644 test/e2e/v1/features/metrics_server.go diff --git a/README.md b/README.md index 5080e5e9..0b115b07 100644 --- a/README.md +++ b/README.md @@ -616,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 supports the same health and profiling settings, but does not provide `/metrics` yet. + +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 @@ -714,7 +732,7 @@ 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` is a configuration error. +`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:** diff --git a/Release.md b/Release.md index 0e441d06..53e7816c 100644 --- a/Release.md +++ b/Release.md @@ -2,6 +2,9 @@ * 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 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. In frps, setting the port also serves `/metrics` with both runtime and business metrics, so `enablePrometheus` is not needed there. + + The `/healthz`, `/metrics` and pprof endpoints on `webServer`, and `enablePrometheus`, still work but are deprecated: configure `metricsServer` instead. They will be removed in a later release. ## Fixes diff --git a/client/metrics_server_test.go b/client/metrics_server_test.go new file mode 100644 index 00000000..22160558 --- /dev/null +++ b/client/metrics_server_test.go @@ -0,0 +1,91 @@ +// 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" + "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} + for path, status := range map[string]int{"/healthz": http.StatusOK, "/metrics": http.StatusNotFound} { + resp, err := client.Get("http://" + address + path) + require.NoError(t, err) + require.Equal(t, status, resp.StatusCode) + require.NoError(t, resp.Body.Close()) + } +} diff --git a/client/service.go b/client/service.go index bfeca8ae..c797be60 100644 --- a/client/service.go +++ b/client/service.go @@ -123,7 +123,8 @@ 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 @@ -188,8 +189,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 +199,19 @@ func NewService(options ServiceOptions) (*Service, error) { } webServer = ws } + metricsServer, err := httppkg.NewMetricsServer(options.Common.MetricsServer, nil) + 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 +272,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 +287,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 { @@ -497,6 +522,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 diff --git a/conf/frpc_full_example.toml b/conf/frpc_full_example.toml index 7c553615..f4885cb7 100644 --- a/conf/frpc_full_example.toml +++ b/conf/frpc_full_example.toml @@ -79,9 +79,22 @@ 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 and optional profiling listener. frpc does not serve /metrics. +# 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 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/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 f24f55bd..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"` 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 e02dcf12..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) }, } diff --git a/pkg/config/v1/validation/common.go b/pkg/config/v1/validation/common.go index d791c0d4..6767bf7e 100644 --- a/pkg/config/v1/validation/common.go +++ b/pkg/config/v1/validation/common.go @@ -37,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/server.go b/pkg/config/v1/validation/server.go index a7615aa9..d7cf242c 100644 --- a/pkg/config/v1/validation/server.go +++ b/pkg/config/v1/validation/server.go @@ -44,6 +44,9 @@ 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")) } 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 dac51779..20dc3650 100644 --- a/pkg/util/http/server.go +++ b/pkg/util/http/server.go @@ -48,7 +48,18 @@ type Server struct { func NewServer(cfg v1.WebServerConfig) (*Server, error) { assets.Load(cfg.AssetsDir) - addr := net.JoinHostPort(cfg.Addr, strconv.Itoa(cfg.Port)) + 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" } @@ -65,11 +76,8 @@ func NewServer(cfg v1.WebServerConfig) (*Server, error) { 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 } @@ -77,7 +85,7 @@ 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) 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 a21c4f69..3bc20340 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 @@ -347,8 +349,8 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) { } svr.rc.NatHoleController = nc - // 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 listeners + // if either server cannot be created. if cfg.WebServer.Port > 0 { ws, err := httppkg.NewServer(cfg.WebServer) if err != nil { @@ -357,11 +359,24 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) { return nil, err } svr.webServer = ws - - modelmetrics.EnableMem() - if cfg.EnablePrometheus { - modelmetrics.EnablePrometheus() + } + 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. @@ -375,6 +390,16 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) { 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() @@ -396,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 { @@ -404,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 { @@ -462,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. diff --git a/server/service_lifecycle_test.go b/server/service_lifecycle_test.go index bf74ba69..af562a83 100644 --- a/server/service_lifecycle_test.go +++ b/server/service_lifecycle_test.go @@ -276,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/test/e2e/v1/features/metrics_server.go b/test/e2e/v1/features/metrics_server.go new file mode 100644 index 00000000..62619849 --- /dev/null +++ b/test/e2e/v1/features/metrics_server.go @@ -0,0 +1,124 @@ +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()) + } + + 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) + framework.NewRequestExpect(f).Request(metricsRequest(clientPort, "/metrics", true)).Ensure(framework.ExpectResponseCode(http.StatusNotFound)) + }) + + 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 + }) + }) +}) From b0bf32571a3d4d4f9bb77223ce0d0e5d32ea1216 Mon Sep 17 00:00:00 2001 From: fatedier Date: Tue, 20 Oct 2026 11:22:34 +0800 Subject: [PATCH 10/25] ci: pin checkout, setup-go and setup-node to v7 commit SHAs (#5581) --- .github/workflows/build-and-push-image.yml | 2 +- .github/workflows/golangci-lint.yml | 6 +++--- .github/workflows/goreleaser.yml | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) 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) From 69ec4f014590efeca59c166e388dc11c813c8ad0 Mon Sep 17 00:00:00 2001 From: fatedier Date: Tue, 20 Oct 2026 16:13:38 +0800 Subject: [PATCH 11/25] feat(client): serve Go runtime metrics on metricsServer (#5579) Add pkg/metrics/text, a small Prometheus text-format writer, and use it for frpc's /metrics. The endpoint lives only on metricsServer and is covered by its Basic Auth, like the other endpoints there. The package emits the Go runtime families and, on Linux, the process families read from /proc. Their names, types and help strings match client_golang's default collectors, so existing Go runtime dashboards keep working. It deliberately emits no frp_ business metrics and none of client_golang's promhttp_ handler metrics. Writing the subset ourselves keeps client_golang and protobuf out of the frpc binary; both are still used by frps and by the tests here. --- README.md | 2 +- Release.md | 2 +- client/metrics_server_test.go | 22 +- client/service.go | 11 +- conf/frpc_full_example.toml | 3 +- go.mod | 4 +- pkg/metrics/text/registry.go | 80 +++++ pkg/metrics/text/runtime.go | 398 +++++++++++++++++++++++++ pkg/metrics/text/runtime_test.go | 193 ++++++++++++ pkg/metrics/text/text.go | 163 ++++++++++ pkg/metrics/text/text_test.go | 124 ++++++++ test/e2e/v1/features/metrics_server.go | 17 +- 12 files changed, 1006 insertions(+), 13 deletions(-) create mode 100644 pkg/metrics/text/registry.go create mode 100644 pkg/metrics/text/runtime.go create mode 100644 pkg/metrics/text/runtime_test.go create mode 100644 pkg/metrics/text/text.go create mode 100644 pkg/metrics/text/text_test.go diff --git a/README.md b/README.md index 0b115b07..57116f20 100644 --- a/README.md +++ b/README.md @@ -638,7 +638,7 @@ metricsServer.password = "change-me" 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 supports the same health and profiling settings, but does not provide `/metrics` yet. +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`. diff --git a/Release.md b/Release.md index 53e7816c..ee1c07c6 100644 --- a/Release.md +++ b/Release.md @@ -2,7 +2,7 @@ * 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 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. In frps, setting the port also serves `/metrics` with both runtime and business metrics, so `enablePrometheus` is not needed there. +* 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`, and `enablePrometheus`, still work but are deprecated: configure `metricsServer` instead. They will be removed in a later release. diff --git a/client/metrics_server_test.go b/client/metrics_server_test.go index 22160558..2bd395f4 100644 --- a/client/metrics_server_test.go +++ b/client/metrics_server_test.go @@ -16,6 +16,7 @@ package client import ( "context" + "io" "net" "net/http" "path/filepath" @@ -82,10 +83,19 @@ func TestServiceMetricsServerLifecycle(t *testing.T) { assertLifecycleAdminPort(t, address, true) }() client := &http.Client{Timeout: 3 * time.Second} - for path, status := range map[string]int{"/healthz": http.StatusOK, "/metrics": http.StatusNotFound} { - resp, err := client.Get("http://" + address + path) - require.NoError(t, err) - require.Equal(t, status, resp.StatusCode) - require.NoError(t, resp.Body.Close()) - } + 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/service.go b/client/service.go index c797be60..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" @@ -199,7 +200,15 @@ func NewService(options ServiceOptions) (*Service, error) { } webServer = ws } - metricsServer, err := httppkg.NewMetricsServer(options.Common.MetricsServer, nil) + // 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() diff --git a/conf/frpc_full_example.toml b/conf/frpc_full_example.toml index f4885cb7..6108e3aa 100644 --- a/conf/frpc_full_example.toml +++ b/conf/frpc_full_example.toml @@ -83,7 +83,8 @@ webServer.password = "admin" # Enable golang pprof handlers in admin listener. webServer.pprofEnable = false -# Separate health and optional profiling listener. frpc does not serve /metrics. +# 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 diff --git a/go.mod b/go.mod index 838ec8d1..228b3b21 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,8 @@ require ( github.com/pelletier/go-toml/v2 v2.2.0 github.com/pires/go-proxyproto v0.15.0 github.com/prometheus/client_golang v1.19.1 + github.com/prometheus/client_model v0.5.0 + github.com/prometheus/common v0.48.0 github.com/quic-go/quic-go v0.60.0 github.com/rodaine/table v1.2.0 github.com/samber/lo v1.47.0 @@ -54,8 +56,6 @@ require ( github.com/klauspost/reedsolomon v1.12.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_model v0.5.0 // indirect - github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/templexxx/cpu v0.1.1 // indirect github.com/templexxx/xorsimd v0.4.3 // indirect 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..88ede04a --- /dev/null +++ b/pkg/metrics/text/runtime.go @@ -0,0 +1,398 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package text + +import ( + "runtime" + "runtime/debug" + "runtime/metrics" + "sync" + "time" +) + +// memstats is one go_memstats_* family. The values are read the same way +// client_golang reads them since Go 1.17: from runtime/metrics, which does not +// stop the world, and then mapped onto runtime.MemStats fields. +type memstats struct { + name string + help string + typ metricType + get func(*runtime.MemStats) float64 +} + +// memstatsFamilies matches the go_memstats_* families of client_golang's default +// registry, including their help strings and types. +var memstatsFamilies = []memstats{ + { + name: "go_memstats_alloc_bytes", + help: "Number of bytes allocated and still in use.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.Alloc) }, + }, + { + name: "go_memstats_alloc_bytes_total", + help: "Total number of bytes allocated, even if freed.", + typ: typeCounter, + get: func(ms *runtime.MemStats) float64 { return float64(ms.TotalAlloc) }, + }, + { + name: "go_memstats_buck_hash_sys_bytes", + help: "Number of bytes used by the profiling bucket hash table.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.BuckHashSys) }, + }, + { + name: "go_memstats_frees_total", + help: "Total number of frees.", + typ: typeCounter, + get: func(ms *runtime.MemStats) float64 { return float64(ms.Frees) }, + }, + { + name: "go_memstats_gc_sys_bytes", + help: "Number of bytes used for garbage collection system metadata.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.GCSys) }, + }, + { + name: "go_memstats_heap_alloc_bytes", + help: "Number of heap bytes allocated and still in use.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapAlloc) }, + }, + { + name: "go_memstats_heap_idle_bytes", + help: "Number of heap bytes waiting to be used.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapIdle) }, + }, + { + name: "go_memstats_heap_inuse_bytes", + help: "Number of heap bytes that are in use.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapInuse) }, + }, + { + name: "go_memstats_heap_objects", + help: "Number of allocated objects.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapObjects) }, + }, + { + name: "go_memstats_heap_released_bytes", + help: "Number of heap bytes released to OS.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapReleased) }, + }, + { + name: "go_memstats_heap_sys_bytes", + help: "Number of heap bytes obtained from system.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapSys) }, + }, + { + name: "go_memstats_lookups_total", + help: "Total number of pointer lookups.", + typ: typeCounter, + get: func(ms *runtime.MemStats) float64 { return float64(ms.Lookups) }, + }, + { + name: "go_memstats_mallocs_total", + help: "Total number of mallocs.", + typ: typeCounter, + get: func(ms *runtime.MemStats) float64 { return float64(ms.Mallocs) }, + }, + { + name: "go_memstats_mcache_inuse_bytes", + help: "Number of bytes in use by mcache structures.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.MCacheInuse) }, + }, + { + name: "go_memstats_mcache_sys_bytes", + help: "Number of bytes used for mcache structures obtained from system.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.MCacheSys) }, + }, + { + name: "go_memstats_mspan_inuse_bytes", + help: "Number of bytes in use by mspan structures.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.MSpanInuse) }, + }, + { + name: "go_memstats_mspan_sys_bytes", + help: "Number of bytes used for mspan structures obtained from system.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.MSpanSys) }, + }, + { + name: "go_memstats_next_gc_bytes", + help: "Number of heap bytes when next garbage collection will take place.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.NextGC) }, + }, + { + name: "go_memstats_other_sys_bytes", + help: "Number of bytes used for other system allocations.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.OtherSys) }, + }, + { + name: "go_memstats_stack_inuse_bytes", + help: "Number of bytes in use by the stack allocator.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.StackInuse) }, + }, + { + name: "go_memstats_stack_sys_bytes", + help: "Number of bytes obtained from system for stack allocator.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.StackSys) }, + }, + { + name: "go_memstats_sys_bytes", + help: "Number of bytes obtained from system.", + typ: typeGauge, + get: func(ms *runtime.MemStats) float64 { return float64(ms.Sys) }, + }, +} + +// The runtime/metrics names below are the ones client_golang v1.19.1 reads to +// build the go_memstats_* families. See prometheus/go_collector_latest.go in +// that module (Apache-2.0). +const ( + rmGCHeapTinyAllocsObjects = "/gc/heap/tiny/allocs:objects" + rmGCHeapAllocsObjects = "/gc/heap/allocs:objects" + rmGCHeapFreesObjects = "/gc/heap/frees:objects" + rmGCHeapAllocsBytes = "/gc/heap/allocs:bytes" + rmGCHeapObjects = "/gc/heap/objects:objects" + rmGCHeapGoalBytes = "/gc/heap/goal:bytes" + rmMemoryClassesTotalBytes = "/memory/classes/total:bytes" + rmMemoryClassesHeapObjectsBytes = "/memory/classes/heap/objects:bytes" + rmMemoryClassesHeapUnusedBytes = "/memory/classes/heap/unused:bytes" + rmMemoryClassesHeapReleasedBytes = "/memory/classes/heap/released:bytes" + rmMemoryClassesHeapFreeBytes = "/memory/classes/heap/free:bytes" + rmMemoryClassesHeapStacksBytes = "/memory/classes/heap/stacks:bytes" + rmMemoryClassesOSStacksBytes = "/memory/classes/os-stacks:bytes" + rmMemoryClassesMetadataMSpanInuseBytes = "/memory/classes/metadata/mspan/inuse:bytes" + rmMemoryClassesMetadataMSpanFreeBytes = "/memory/classes/metadata/mspan/free:bytes" + rmMemoryClassesMetadataMCacheInuseBytes = "/memory/classes/metadata/mcache/inuse:bytes" + rmMemoryClassesMetadataMCacheFreeBytes = "/memory/classes/metadata/mcache/free:bytes" + rmMemoryClassesProfilingBucketsBytes = "/memory/classes/profiling/buckets:bytes" + rmMemoryClassesMetadataOtherBytes = "/memory/classes/metadata/other:bytes" + rmMemoryClassesOtherBytes = "/memory/classes/other:bytes" +) + +// memoryNames lists every runtime/metrics name memStatsFromRM reads. +var memoryNames = []string{ + rmGCHeapTinyAllocsObjects, + rmGCHeapAllocsObjects, + rmGCHeapFreesObjects, + rmGCHeapAllocsBytes, + rmGCHeapObjects, + rmGCHeapGoalBytes, + rmMemoryClassesTotalBytes, + rmMemoryClassesHeapObjectsBytes, + rmMemoryClassesHeapUnusedBytes, + rmMemoryClassesHeapReleasedBytes, + rmMemoryClassesHeapFreeBytes, + rmMemoryClassesHeapStacksBytes, + rmMemoryClassesOSStacksBytes, + rmMemoryClassesMetadataMSpanInuseBytes, + rmMemoryClassesMetadataMSpanFreeBytes, + rmMemoryClassesMetadataMCacheInuseBytes, + rmMemoryClassesMetadataMCacheFreeBytes, + rmMemoryClassesProfilingBucketsBytes, + rmMemoryClassesMetadataOtherBytes, + rmMemoryClassesOtherBytes, +} + +// memorySamples holds the sample buffer of one scrape and the index that maps a +// runtime/metrics name to its slot. +type memorySamples struct { + samples []metrics.Sample + index map[string]int +} + +// memStatsSnapshot takes the Go memory statistics frpc reports. It reads the +// runtime/metrics package instead of calling runtime.ReadMemStats, which stops +// the world on every call, and memStatsFromRM maps the samples onto the old +// field names. +// +// The sample buffer is reused between scrapes, so concurrent scrapes take turns: +// runtime/metrics.Read writes into the samples it is handed. +func memStatsSnapshot() runtime.MemStats { + memoryMu.Lock() + defer memoryMu.Unlock() + + if memoryBuffer == nil { + memoryBuffer = newMemorySamples() + } + // metrics.Read indexes into the slice, so an empty buffer has to be skipped. + if len(memoryBuffer.samples) > 0 { + metrics.Read(memoryBuffer.samples) + } + + var ms runtime.MemStats + memStatsFromRM(&ms, memoryBuffer.value) + return ms +} + +var ( + memoryMu sync.Mutex + memoryBuffer *memorySamples +) + +// newMemorySamples builds the sample buffer from the names this runtime supports. +// A name the runtime does not know is left out and reads as zero, which is what +// client_golang does as well. +func newMemorySamples() *memorySamples { + supported := make(map[string]struct{}, len(memoryNames)) + for _, d := range metrics.All() { + supported[d.Name] = struct{}{} + } + s := &memorySamples{index: make(map[string]int, len(memoryNames))} + for _, name := range memoryNames { + if _, ok := supported[name]; !ok { + continue + } + s.index[name] = len(s.samples) + s.samples = append(s.samples, metrics.Sample{Name: name}) + } + return s +} + +// value returns the sampled value of name, or zero when the runtime does not +// expose it. +func (s *memorySamples) value(name string) uint64 { + i, ok := s.index[name] + if !ok { + return 0 + } + return s.samples[i].Value.Uint64() +} + +// memStatsFromRM maps runtime/metrics samples onto a runtime.MemStats, using the +// same field combinations as client_golang v1.19.1, so the go_memstats_* values +// match the ones the official collector reports for the same process. GCCPUFraction +// is left at zero, as client_golang omits it too. +func memStatsFromRM(ms *runtime.MemStats, lookup func(string) uint64) { + // MemStats counts tiny allocations in both Mallocs and Frees. client_golang + // keeps that behavior because Mallocs-Frees is then a live object count. + tinyAllocs := lookup(rmGCHeapTinyAllocsObjects) + ms.Mallocs = lookup(rmGCHeapAllocsObjects) + tinyAllocs + ms.Frees = lookup(rmGCHeapFreesObjects) + tinyAllocs + + ms.TotalAlloc = lookup(rmGCHeapAllocsBytes) + ms.Sys = lookup(rmMemoryClassesTotalBytes) + ms.Lookups = 0 // Already always zero. + ms.HeapAlloc = lookup(rmMemoryClassesHeapObjectsBytes) + ms.Alloc = ms.HeapAlloc + ms.HeapInuse = ms.HeapAlloc + lookup(rmMemoryClassesHeapUnusedBytes) + ms.HeapReleased = lookup(rmMemoryClassesHeapReleasedBytes) + ms.HeapIdle = ms.HeapReleased + lookup(rmMemoryClassesHeapFreeBytes) + ms.HeapSys = ms.HeapInuse + ms.HeapIdle + ms.HeapObjects = lookup(rmGCHeapObjects) + ms.StackInuse = lookup(rmMemoryClassesHeapStacksBytes) + ms.StackSys = ms.StackInuse + lookup(rmMemoryClassesOSStacksBytes) + ms.MSpanInuse = lookup(rmMemoryClassesMetadataMSpanInuseBytes) + ms.MSpanSys = ms.MSpanInuse + lookup(rmMemoryClassesMetadataMSpanFreeBytes) + ms.MCacheInuse = lookup(rmMemoryClassesMetadataMCacheInuseBytes) + ms.MCacheSys = ms.MCacheInuse + lookup(rmMemoryClassesMetadataMCacheFreeBytes) + ms.BuckHashSys = lookup(rmMemoryClassesProfilingBucketsBytes) + ms.GCSys = lookup(rmMemoryClassesMetadataOtherBytes) + ms.OtherSys = lookup(rmMemoryClassesOtherBytes) + ms.NextGC = lookup(rmGCHeapGoalBytes) +} + +// gcQuantileLabels matches the quantiles client_golang reports in the +// go_gc_duration_seconds summary. +var gcQuantileLabels = []string{"0", "0.25", "0.5", "0.75", "1"} + +// runtimeFamilies reads the runtime once per scrape rather than once per metric. +func runtimeFamilies() []family { + ms := memStatsSnapshot() + + out := make([]family, 0, len(memstatsFamilies)+5) + for _, m := range memstatsFamilies { + out = append(out, family{ + name: m.name, + help: m.help, + typ: m.typ, + samples: []sample{{value: m.get(&ms)}}, + }) + } + + // debug.ReadGCStats only takes the mheap lock; it does not stop the world. + var gc debug.GCStats + gc.PauseQuantiles = make([]time.Duration, len(gcQuantileLabels)) + debug.ReadGCStats(&gc) + + gcSamples := make([]sample, 0, len(gcQuantileLabels)+2) + for i, q := range gcQuantileLabels { + var pause time.Duration + if i < len(gc.PauseQuantiles) { + pause = gc.PauseQuantiles[i] + } + gcSamples = append(gcSamples, sample{ + labels: []label{{name: "quantile", value: q}}, + value: pause.Seconds(), + }) + } + gcSamples = append(gcSamples, + sample{name: "go_gc_duration_seconds_sum", value: gc.PauseTotal.Seconds()}, + sample{name: "go_gc_duration_seconds_count", value: float64(gc.NumGC)}, + ) + + return append(out, + family{ + name: "go_gc_duration_seconds", + help: "A summary of the pause duration of garbage collection cycles.", + typ: typeSummary, + samples: gcSamples, + }, + family{ + name: "go_goroutines", + help: "Number of goroutines that currently exist.", + typ: typeGauge, + samples: []sample{{value: float64(runtime.NumGoroutine())}}, + }, + family{ + name: "go_info", + help: "Information about the Go environment.", + typ: typeGauge, + samples: []sample{{labels: []label{{name: "version", value: runtime.Version()}}, value: 1}}, + }, + family{ + name: "go_memstats_last_gc_time_seconds", + help: "Number of seconds since 1970 of last garbage collection.", + typ: typeGauge, + samples: []sample{{ + value: float64(gc.LastGC.UnixNano()) / float64(time.Second), + }}, + }, + family{ + name: "go_threads", + help: "Number of OS threads created.", + typ: typeGauge, + samples: []sample{{value: float64(threadCount())}}, + }, + ) +} + +func threadCount() int { + n, _ := runtime.ThreadCreateProfile(nil) + return n +} diff --git a/pkg/metrics/text/runtime_test.go b/pkg/metrics/text/runtime_test.go new file mode 100644 index 00000000..2484e041 --- /dev/null +++ b/pkg/metrics/text/runtime_test.go @@ -0,0 +1,193 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package text + +import ( + "math" + "runtime" + "runtime/metrics" + "strings" + "sync" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + dto "github.com/prometheus/client_model/go" + "github.com/stretchr/testify/require" +) + +// TestMemStatsMapping pins the runtime/metrics to MemStats mapping field by +// field. The sample values are made up so that every combination shows up: tiny +// allocations are added to both Mallocs and Frees, and the combined fields +// (HeapInuse, HeapIdle, HeapSys, StackSys, MSpanSys, MCacheSys) add their parts. +func TestMemStatsMapping(t *testing.T) { + samples := map[string]uint64{ + rmGCHeapTinyAllocsObjects: 3, + rmGCHeapAllocsObjects: 100, + rmGCHeapFreesObjects: 40, + rmGCHeapAllocsBytes: 1 << 20, + rmGCHeapObjects: 900, + rmGCHeapGoalBytes: 4 << 20, + rmMemoryClassesTotalBytes: 50 << 20, + rmMemoryClassesHeapObjectsBytes: 2 << 20, + rmMemoryClassesHeapUnusedBytes: 3 << 20, + rmMemoryClassesHeapReleasedBytes: 5 << 20, + rmMemoryClassesHeapFreeBytes: 7 << 20, + rmMemoryClassesHeapStacksBytes: 1 << 20, + rmMemoryClassesOSStacksBytes: 2 << 20, + rmMemoryClassesMetadataMSpanInuseBytes: 1 << 19, + rmMemoryClassesMetadataMSpanFreeBytes: 1 << 18, + rmMemoryClassesMetadataMCacheInuseBytes: 1 << 17, + rmMemoryClassesMetadataMCacheFreeBytes: 1 << 16, + rmMemoryClassesProfilingBucketsBytes: 1 << 15, + rmMemoryClassesMetadataOtherBytes: 1 << 14, + rmMemoryClassesOtherBytes: 1 << 13, + } + lookup := func(name string) uint64 { return samples[name] } + + var ms runtime.MemStats + memStatsFromRM(&ms, lookup) + + // Expectations are keyed by family name, so this also checks that each family + // reads the field the mapping fills. + want := map[string]float64{ + "go_memstats_alloc_bytes": 2 << 20, + "go_memstats_alloc_bytes_total": 1 << 20, + "go_memstats_buck_hash_sys_bytes": 1 << 15, + "go_memstats_frees_total": 43, // 40 frees + 3 tiny allocs + "go_memstats_gc_sys_bytes": 1 << 14, + "go_memstats_heap_alloc_bytes": 2 << 20, + "go_memstats_heap_idle_bytes": 12 << 20, // released + free + "go_memstats_heap_inuse_bytes": 5 << 20, // objects + unused + "go_memstats_heap_objects": 900, + "go_memstats_heap_released_bytes": 5 << 20, + "go_memstats_heap_sys_bytes": 17 << 20, // inuse + idle + "go_memstats_lookups_total": 0, + "go_memstats_mallocs_total": 103, // 100 mallocs + 3 tiny allocs + "go_memstats_mcache_inuse_bytes": 1 << 17, + "go_memstats_mcache_sys_bytes": 3 << 16, // inuse + free + "go_memstats_mspan_inuse_bytes": 1 << 19, + "go_memstats_mspan_sys_bytes": 3 << 18, // inuse + free + "go_memstats_next_gc_bytes": 4 << 20, + "go_memstats_other_sys_bytes": 1 << 13, + "go_memstats_stack_inuse_bytes": 1 << 20, + "go_memstats_stack_sys_bytes": 3 << 20, // inuse + os-stacks + "go_memstats_sys_bytes": 50 << 20, + } + require.Len(t, want, len(memstatsFamilies), "every family needs an expectation") + for _, m := range memstatsFamilies { + expected, ok := want[m.name] + require.True(t, ok, "%s has no expectation", m.name) + require.Equal(t, expected, m.get(&ms), "%s", m.name) + } +} + +// TestMemorySamplesMissingName checks that a runtime which does not expose a +// name reads it as zero instead of failing, the way client_golang behaves. +func TestMemorySamplesMissingName(t *testing.T) { + s := &memorySamples{ + samples: []metrics.Sample{{Name: rmGCHeapObjects}}, + index: map[string]int{rmGCHeapObjects: 0}, + } + require.Equal(t, uint64(0), s.value(rmMemoryClassesTotalBytes)) +} + +// TestMemStatsSameOrderAsClientGolang compares what frpc reports with what +// client_golang reads from the same process at about the same time. The check is +// deliberately loose: the two collectors sample the runtime one after the other, +// so counters move by whatever the test itself allocates in between, and an exact +// comparison would flake. +func TestMemStatsSameOrderAsClientGolang(t *testing.T) { + reg := prometheus.NewRegistry() + reg.MustRegister(collectors.NewGoCollector()) + mfs, err := reg.Gather() + require.NoError(t, err) + + ours := parseFamilies(t, gather(t)) + checked := 0 + for _, mf := range mfs { + name := mf.GetName() + if !strings.HasPrefix(name, "go_memstats_") { + continue + } + if name == "go_memstats_last_gc_time_seconds" { + // A collection that starts between the two reads jumps this value from + // zero to the current time; that is not a difference in behavior. + continue + } + want, ok := ours[name] + require.True(t, ok, "%s is missing", name) + reference := metricValue(t, mf.GetMetric()[0]) + got := metricValue(t, want.GetMetric()[0]) + require.True(t, sameOrder(reference, got), + "%s differs too much: ours %v, client_golang %v", name, got, reference) + checked++ + } + require.Greater(t, checked, 15, "comparison covered too few families") +} + +func metricValue(t *testing.T, m *dto.Metric) float64 { + t.Helper() + if g := m.GetGauge(); g != nil { + return g.GetValue() + } + if c := m.GetCounter(); c != nil { + return c.GetValue() + } + t.Fatalf("unexpected metric type for %s", m.String()) + return 0 +} + +// sameOrder reports whether two readings of the same quantity are close enough +// that they cannot be different quantities: equal, within 64 KiB (drift while +// the test allocates), or within a factor of ten. +func sameOrder(a, b float64) bool { + if a == b { + return true + } + if math.Abs(a-b) <= 1<<16 { + return true + } + if a <= 0 || b <= 0 { + return false + } + ratio := a / b + return ratio > 0.1 && ratio < 10 +} + +// TestConcurrentScrapes covers the shared runtime/metrics sample buffer: scrapes +// run on the listener's goroutines and must not race. Run with -race to check it. +func TestConcurrentScrapes(t *testing.T) { + reg := NewRegistry() + reg.RegisterRuntime() + + var wg sync.WaitGroup + for range 8 { + wg.Go(func() { + for range 20 { + var b strings.Builder + if err := reg.WritePrometheus(&b); err != nil { + t.Errorf("scrape failed: %v", err) + return + } + if !strings.Contains(b.String(), "go_memstats_heap_objects") { + t.Error("scrape is missing go_memstats_heap_objects") + return + } + } + }) + } + wg.Wait() +} 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..3bc1bbb3 --- /dev/null +++ b/pkg/metrics/text/text_test.go @@ -0,0 +1,124 @@ +// Copyright 2026 The frp Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package text + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + dto "github.com/prometheus/client_model/go" + "github.com/prometheus/common/expfmt" + "github.com/stretchr/testify/require" +) + +// gather returns what frpc would serve. +func gather(t *testing.T) string { + t.Helper() + reg := NewRegistry() + reg.RegisterRuntime() + var b strings.Builder + require.NoError(t, reg.WritePrometheus(&b)) + return b.String() +} + +// parseFamilies decodes the text exposition format with the Prometheus parser, +// so anything a scraper would reject fails the test. +func parseFamilies(t *testing.T, body string) map[string]*dto.MetricFamily { + t.Helper() + parser := expfmt.TextParser{} + families, err := parser.TextToMetricFamilies(strings.NewReader(body)) + require.NoError(t, err, "output must be valid text exposition format") + return families +} + +// TestMatchesClientGolang checks that every go_* family this package emits has +// the same name, type and help text as client_golang's default Go collector. The +// official collector is used as the oracle, so a difference shows up here rather +// than in someone's dashboard. +func TestMatchesClientGolang(t *testing.T) { + reg := prometheus.NewRegistry() + reg.MustRegister(collectors.NewGoCollector()) + mfs, err := reg.Gather() + require.NoError(t, err) + + reference := make(map[string]*dto.MetricFamily, len(mfs)) + for _, mf := range mfs { + reference[mf.GetName()] = mf + } + ours := parseFamilies(t, gather(t)) + + checked := 0 + for name, want := range reference { + if !strings.HasPrefix(name, "go_") { + continue + } + got, ok := ours[name] + require.True(t, ok, "%s is missing", name) + require.Equal(t, want.GetType(), got.GetType(), "%s type", name) + require.Equal(t, want.GetHelp(), got.GetHelp(), "%s help", name) + checked++ + } + require.Greater(t, checked, 20, "comparison covered too few families") +} + +// TestNoBusinessMetrics pins what frpc must not expose: no frp_ business metrics, +// none of the handler instrumentation client_golang adds, and no process_ +// families. frpc reports Go runtime metrics only, on every platform. +func TestNoBusinessMetrics(t *testing.T) { + body := gather(t) + require.NotContains(t, body, "frp_") + require.NotContains(t, body, "promhttp_") + require.NotContains(t, body, "process_") +} + +func TestHandler(t *testing.T) { + reg := NewRegistry() + reg.RegisterRuntime() + rec := httptest.NewRecorder() + reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, ContentType, rec.Header().Get("Content-Type")) + + families := parseFamilies(t, rec.Body.String()) + require.Contains(t, families, "go_goroutines") + gc, ok := families["go_gc_duration_seconds"] + require.True(t, ok) + require.Equal(t, dto.MetricType_SUMMARY, gc.GetType()) + require.Len(t, gc.GetMetric()[0].GetSummary().GetQuantile(), 5) +} + +func TestEmptyRegistry(t *testing.T) { + var b strings.Builder + require.NoError(t, NewRegistry().WritePrometheus(&b)) + require.Empty(t, b.String()) +} + +func TestEscaping(t *testing.T) { + var b strings.Builder + require.NoError(t, encode(&b, []family{{ + name: "test_labels", + help: "help with \\ and\nnewline", + typ: typeGauge, + samples: []sample{{labels: []label{{name: "v", value: "a\"b\\c\nd"}}, value: 1}}, + }})) + require.Equal(t, "# HELP test_labels help with \\\\ and\\nnewline\n"+ + "# TYPE test_labels gauge\n"+ + "test_labels{v=\"a\\\"b\\\\c\\nd\"} 1\n", b.String()) +} diff --git a/test/e2e/v1/features/metrics_server.go b/test/e2e/v1/features/metrics_server.go index 62619849..777532df 100644 --- a/test/e2e/v1/features/metrics_server.go +++ b/test/e2e/v1/features/metrics_server.go @@ -47,6 +47,21 @@ var _ = ginkgo.Describe("[Feature: Metrics Server]", func() { }, 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() @@ -102,7 +117,7 @@ webServer.password = "admin-secret" framework.NewRequestExpect(f).Request(metricsRequest(port, "/healthz", false)).Ensure(framework.ExpectResponseCode(http.StatusOK)) } expectBusinessMetrics(serverPort) - framework.NewRequestExpect(f).Request(metricsRequest(clientPort, "/metrics", true)).Ensure(framework.ExpectResponseCode(http.StatusNotFound)) + expectRuntimeMetrics(clientPort) }) ginkgo.It("collects once when both metrics endpoints are enabled", func() { From a73e8a14fccdb2e0ec49ff32cabb5371365d2723 Mon Sep 17 00:00:00 2001 From: fatedier Date: Tue, 20 Oct 2026 16:51:31 +0800 Subject: [PATCH 12/25] docs: track deprecated observability endpoints on webServer (#5582) Record the deprecation of the observability endpoints on webServer in doc/deprecations.md, so the removal target is in one place next to the other active deprecations. The webServer /healthz, the frps webServer /metrics with its top-level enablePrometheus switch, and the pprof handlers with webServer.pprofEnable are deprecated since v0.72.0, when metricsServer was added. They are replaced by metricsServer, whose /healthz, /metrics and pprofEnable are independent of the dashboard listener. --- Release.md | 2 +- doc/deprecations.md | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Release.md b/Release.md index ee1c07c6..678449d2 100644 --- a/Release.md +++ b/Release.md @@ -4,7 +4,7 @@ * `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`, and `enablePrometheus`, still work but are deprecated: configure `metricsServer` instead. They will be removed in a later release. + 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 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). From 8d5e9d2b21e573f2bcac068a8a438571171c4920 Mon Sep 17 00:00:00 2001 From: fatedier Date: Fri, 23 Oct 2026 14:03:25 +0800 Subject: [PATCH 13/25] fix(client): use generation-local channels in UDPProxy.InWorkConn (#5585) InWorkConn published readCh and sendCh into pxy fields while holding pxy.mu, then read those same fields again after unlocking to start the sender, reader and heartbeat goroutines and to call udp.Forwarder. Two work connections installed concurrently made that unlocked read race with the next generation's write to the same fields. Create the channels as locals, publish them under the existing lock, and use only those locals for the goroutines and the forwarder. The fields stay guarded by pxy.mu, so Close still closes the current generation. No behavior change for a single generation. Add TestUDPProxyInWorkConnGenerationChannels, which drives two InWorkConn calls concurrently so the race detector covers the field accesses. --- client/proxy/udp.go | 14 +++++---- client/proxy/udp_test.go | 63 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 client/proxy/udp_test.go 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() +} From 93b52799bb7e6dad7ae1152c8b593eefcc17d595 Mon Sep 17 00:00:00 2001 From: fatedier Date: Fri, 23 Oct 2026 15:52:11 +0800 Subject: [PATCH 14/25] fix(xlog): make Logger prefix updates safe for concurrent logging (#5586) Logger kept its rendered prefix in a plain string field that was written by AddPrefix, AppendPrefix and ResetPrefixes and read without synchronization by Tracef, Spawn and the other logging methods. In frpc the race is reachable on the service-level logger, which every component of a session shares through its context: adding the run ID after a login writes the prefix while control, proxy manager, connector and visitor goroutines keep logging through the same logger, and a reload that creates proxies calls Spawn on it. A torn string header can pair a nil data pointer with a non-zero length, and formatting such a prefix can crash the process when the log call's level is enabled. Serialize the writers with a per-Logger mutex and publish the rendered prefix as an immutable string through an atomic pointer, so the log path takes no lock. Spawn copies the parent prefix list under the same mutex, keeping children independent snapshots. Logging output is unchanged. --- pkg/util/xlog/xlog.go | 39 +++++++++++++++------ pkg/util/xlog/xlog_concurrency_test.go | 47 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 pkg/util/xlog/xlog_concurrency_test.go 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() +} From e6e0aaf9d48d359c0abe3dd47e8d9a5e65b2d7d0 Mon Sep 17 00:00:00 2001 From: fatedier Date: Mon, 2 Nov 2026 14:56:32 +0800 Subject: [PATCH 15/25] fix(vhost): close pending connections when a route listener closes (#5592) When an HTTPS or tcpmux route listener is closed after a connection has been resolved to it but before the handoff, Muxer.handle clears the deadline and then sends on the closed accept channel. The recovered panic returns without closing the connection, so the peer waits until its own read timeout. Add a done channel and sync.Once to Listener. Close closes done and is idempotent; handle and Accept select on accept/done, and a pending connection is closed when done is closed. Connections already handed to Accept keep their current ownership. Remove the PanicToError-based control flow. Cover the pending handoff with net.Pipe and real TCP tests, plus an idempotent Close test. Fixes #5573 --- pkg/util/vhost/vhost.go | 27 ++++--- pkg/util/vhost/vhost_close_test.go | 116 +++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 pkg/util/vhost/vhost_close_test.go 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") + } +} From 967a7505aef0af02d0392b65a74e392fcd4406e2 Mon Sep 17 00:00:00 2001 From: fatedier Date: Tue, 3 Nov 2026 21:28:54 +0800 Subject: [PATCH 16/25] deps: bump fatedier/yamux to v0.3.0 (#5595) Update github.com/fatedier/yamux from v0.2.0 to v0.3.0. go.mod and go.sum are the only dependency changes. v0.3.0 adds a public Stream.Reset() and fixes several stream and session lifecycle problems: a read or write blocked on a shutting-down session returns instead of spinning, the stream close lifecycle is safe to retry, failed stream opens release their tracking state, and ConnectionWriteTimeout is now enforced as a write deadline on the underlying transport instead of only bounding how long a caller waits for its own frame. The last change is observable in frp. When a tcpMux connection stalls, the write now fails at the transport and the session is torn down, so a new stream returns ErrSessionShutdown; before, the caller timed out while the session stayed blocked but alive until a keepalive ping also timed out. All frp transports implement SetWriteDeadline (TCP, TLS, WebSocket, WSS and KCP); KCP reports its own timeout error instead of the mapped one. Reads on an established stream now return ErrSessionShutdown instead of io.EOF when the session dies; a graceful stream close still returns io.EOF, and frp data paths already treat any read error as the end of the stream. Adapt TestDispatcherYamuxWriteTimeoutInterruptsRead to the new behaviour: the delayed test connection now honours SetWriteDeadline, and the test asserts that both yamux sessions close after the timed-out write instead of expecting the session to stay usable. --- go.mod | 2 +- go.sum | 4 +-- pkg/msg/handler_yamux_test.go | 52 ++++++++++++++++++++++++----------- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/go.mod b/go.mod index 228b3b21..4920212f 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 github.com/coreos/go-oidc/v3 v3.18.0 github.com/fatedier/golib v0.8.2 - github.com/fatedier/yamux v0.2.0 + github.com/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 diff --git a/go.sum b/go.sum index e5b5ba7e..80da683e 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatedier/golib v0.8.2 h1:02n2Dg7KJ7rR7p7n4/6hBUjaLQf2J7EiHYZQsgGTvww= github.com/fatedier/golib v0.8.2/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw= -github.com/fatedier/yamux v0.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/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= 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") }) } } From f32adb3bbb72809ba43d6f4c9d85ee1ba87803c0 Mon Sep 17 00:00:00 2001 From: fatedier Date: Tue, 3 Nov 2026 23:31:44 +0800 Subject: [PATCH 17/25] fix(server): close the mux session when a control is replaced or exits (#5596) When a client logs in again with the same run ID, frps replaces the old control and closes its control stream, but the yamux session that carried it stays open until keepalive reclaims it. The replaced control's work connections and streams share that session, so they are left behind for up to tcpMuxKeepaliveInterval plus the ping budget. A mux session carries exactly one control connection, so the control now records its session and closes it from the single teardown point shared by replacement, fatal exit and shutdown. QUIC and non-multiplexed transports keep the previous path. Closing the session ends every stream that still belongs to the replaced control. Those proxies are already being torn down, so the cost is that in-flight connections of the old control are interrupted immediately instead of at the keepalive deadline. A replacement control is not affected, and the ownership rules for a still-online control do not change. Tests: replacement and fatal exit close the session while the replacement control keeps working, and concurrent logins with the same run ID leave exactly one live session. --- server/control.go | 8 ++ server/control_session_test.go | 186 +++++++++++++++++++++++++++++++++ server/service.go | 12 ++- server/service_test.go | 14 +-- 4 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 server/control_session_test.go 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/service.go b/server/service.go index 3bc20340..203bef14 100644 --- a/server/service.go +++ b/server/service.go @@ -521,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) @@ -552,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) } } @@ -813,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) } @@ -839,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) } @@ -851,6 +851,7 @@ func (svr *Service) RegisterControl( internal bool, wireProtocol string, udpPacketCodec string, + session io.Closer, ) (*Control, error) { switch wireProtocol { case wire.ProtocolV1: @@ -900,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_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 } From 331abd5aa144e3f105abba29a22af701acf1a298 Mon Sep 17 00:00:00 2001 From: fatedier Date: Wed, 4 Nov 2026 00:56:43 +0800 Subject: [PATCH 18/25] chore: prepare v0.72.0 (#5597) Bump the version to 0.72.0 and update Release.md for the v0.72.0 release. --- Release.md | 4 ++++ pkg/util/version/version.go | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Release.md b/Release.md index 678449d2..837971af 100644 --- a/Release.md +++ b/Release.md @@ -9,3 +9,7 @@ ## Fixes * 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/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 From 16dffb22bdef16c2b63b07d0dc21805c6324ba04 Mon Sep 17 00:00:00 2001 From: fatedier Date: Wed, 4 Nov 2026 13:37:13 +0800 Subject: [PATCH 19/25] deps: bump Go dependencies (#5598) Upgrade golang.org/x/net v0.57.0 -> v0.62.0 and github.com/gorilla/websocket v1.5.0 -> v1.5.3 to clear the reported HTTP/2 server and websocket advisories, along with patch/minor updates to the other direct dependencies: x/crypto, x/sys, x/sync, x/oauth2, x/time, go-oidc, go-toml/v2, samber/lo, cobra, pflag, rodaine/table, netlink, ini, and the test-only testify, ginkgo, gomega and gjson modules. go.mod keeps `go 1.26.0` and has no toolchain line. Only go.mod and go.sum change. --- go.mod | 59 +++++++++++----------- go.sum | 153 ++++++++++++++++++++++++++++++++------------------------- 2 files changed, 114 insertions(+), 98 deletions(-) diff --git a/go.mod b/go.mod index 4920212f..df6034e5 100644 --- a/go.mod +++ b/go.mod @@ -4,37 +4,37 @@ 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.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.3 + 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/prometheus/client_model v0.5.0 github.com/prometheus/common v0.48.0 github.com/quic-go/quic-go v0.60.0 - github.com/rodaine/table v1.2.0 - github.com/samber/lo v1.47.0 + 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/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.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.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-20231211153847-12269c276173 - gopkg.in/ini.v1 v1.67.0 + gopkg.in/ini.v1 v1.67.3 k8s.io/apimachinery v0.28.8 k8s.io/client-go v0.28.8 k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 @@ -42,34 +42,33 @@ require ( 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/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/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.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/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/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 + google.golang.org/protobuf v1.36.7 // 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 ) diff --git a/go.sum b/go.sum index 80da683e..05fd703c 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ 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= @@ -10,10 +12,12 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA 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/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= @@ -24,12 +28,20 @@ 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.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= @@ -49,19 +61,20 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a 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.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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/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,22 +83,23 @@ 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/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/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_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -99,21 +113,20 @@ github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4 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/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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 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 +134,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/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= 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/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/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.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk= github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM= github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E= 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/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +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,23 +198,23 @@ 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= @@ -216,17 +234,16 @@ 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.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 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/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.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= From 5c0a125c16a85af922ed09f39bab626036ba9406 Mon Sep 17 00:00:00 2001 From: fatedier Date: Wed, 4 Nov 2026 14:43:00 +0800 Subject: [PATCH 20/25] deps: bump prometheus/client_golang to v1.25.0 (#5599) Move client_golang to v1.25.0 with its required module updates (client_model v0.6.3, common v0.72.0, procfs v0.22.0, xxhash v2.3.0, protobuf v1.36.12, munnerz/goautoneg). go.mod keeps go 1.26.0 and has no toolchain line. Sync the frpc text metrics clone with the new default Go collector: add go_gc_gogc_percent, go_gc_gomemlimit_bytes and go_sched_gomaxprocs_threads, update the go_memstats help texts, and remove go_memstats_lookups_total. frpc still exposes go_* metrics only. --- go.mod | 13 +++-- go.sum | 34 +++++++---- pkg/metrics/text/runtime.go | 98 ++++++++++++++++++++------------ pkg/metrics/text/runtime_test.go | 3 +- pkg/metrics/text/text_test.go | 3 +- 5 files changed, 94 insertions(+), 57 deletions(-) diff --git a/go.mod b/go.mod index df6034e5..5ad0bdbe 100644 --- a/go.mod +++ b/go.mod @@ -14,9 +14,9 @@ require ( 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/prometheus/client_model v0.5.0 - github.com/prometheus/common v0.48.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.60.0 github.com/rodaine/table v1.4.1 github.com/samber/lo v1.53.0 @@ -44,7 +44,7 @@ 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/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.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect @@ -54,8 +54,9 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/cpuid/v2 v2.2.6 // indirect github.com/klauspost/reedsolomon v1.12.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/prometheus/procfs v0.12.0 // indirect + github.com/prometheus/procfs v0.22.0 // indirect github.com/templexxx/cpu v0.1.1 // indirect github.com/templexxx/xorsimd v0.4.3 // indirect github.com/tidwall/match v1.1.1 // indirect @@ -67,7 +68,7 @@ require ( 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.7 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/yaml v1.3.0 // indirect diff --git a/go.sum b/go.sum index 05fd703c..3bd3c183 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY 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= @@ -75,6 +75,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 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= @@ -83,12 +85,16 @@ 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/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= @@ -100,15 +106,15 @@ github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -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= @@ -159,8 +165,12 @@ github.com/xtaci/kcp-go/v5 v5.6.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4H github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM= github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37 h1:EWU6Pktpas0n8lLQwDsRyZfmkPeRbdgPtW609es+/9E= github.com/xtaci/lossyconn v0.0.0-20200209145036-adba10fffc37/go.mod h1:HpMP7DB2CyokmAh4lp0EQnnWhmycP/TvwBGzvuie+H0= +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= @@ -234,8 +244,8 @@ 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.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +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= diff --git a/pkg/metrics/text/runtime.go b/pkg/metrics/text/runtime.go index 88ede04a..77483780 100644 --- a/pkg/metrics/text/runtime.go +++ b/pkg/metrics/text/runtime.go @@ -37,133 +37,131 @@ type memstats struct { var memstatsFamilies = []memstats{ { name: "go_memstats_alloc_bytes", - help: "Number of bytes allocated and still in use.", + 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, even if freed.", + 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.", + 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 frees.", + 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.", + 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 still in use.", + 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.", + 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.", + 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 allocated 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.", + 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.", - typ: typeGauge, - get: func(ms *runtime.MemStats) float64 { return float64(ms.HeapSys) }, - }, - { - name: "go_memstats_lookups_total", - help: "Total number of pointer lookups.", - typ: typeCounter, - get: func(ms *runtime.MemStats) float64 { return float64(ms.Lookups) }, + 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 mallocs.", - typ: typeCounter, - get: func(ms *runtime.MemStats) float64 { return float64(ms.Mallocs) }, + 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.", + 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.", - typ: typeGauge, - get: func(ms *runtime.MemStats) float64 { return float64(ms.MCacheSys) }, + 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.", + 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.", - typ: typeGauge, - get: func(ms *runtime.MemStats) float64 { return float64(ms.MSpanSys) }, + 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.", + 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.", + 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 in use by the stack allocator.", + 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.", + 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.", + 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) }, }, @@ -359,7 +357,7 @@ func runtimeFamilies() []family { return append(out, family{ name: "go_gc_duration_seconds", - help: "A summary of the pause duration of garbage collection cycles.", + help: "A summary of the wall-time pause (stop-the-world) duration in garbage collection cycles.", typ: typeSummary, samples: gcSamples, }, @@ -389,9 +387,37 @@ func runtimeFamilies() []family { 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 index 2484e041..04911a42 100644 --- a/pkg/metrics/text/runtime_test.go +++ b/pkg/metrics/text/runtime_test.go @@ -74,8 +74,7 @@ func TestMemStatsMapping(t *testing.T) { "go_memstats_heap_objects": 900, "go_memstats_heap_released_bytes": 5 << 20, "go_memstats_heap_sys_bytes": 17 << 20, // inuse + idle - "go_memstats_lookups_total": 0, - "go_memstats_mallocs_total": 103, // 100 mallocs + 3 tiny allocs + "go_memstats_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, diff --git a/pkg/metrics/text/text_test.go b/pkg/metrics/text/text_test.go index 3bc1bbb3..e574aa0b 100644 --- a/pkg/metrics/text/text_test.go +++ b/pkg/metrics/text/text_test.go @@ -24,6 +24,7 @@ import ( "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" ) @@ -41,7 +42,7 @@ func gather(t *testing.T) string { // so anything a scraper would reject fails the test. func parseFamilies(t *testing.T, body string) map[string]*dto.MetricFamily { t.Helper() - parser := expfmt.TextParser{} + 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 From 0368148cc3de0837abaf84c6b9af501d7fe505c6 Mon Sep 17 00:00:00 2001 From: fatedier Date: Wed, 4 Nov 2026 16:08:03 +0800 Subject: [PATCH 21/25] deps: bump quic-go to v0.65.0 (#5600) Upgrade github.com/quic-go/quic-go from v0.60.0 to v0.65.0. Only go.mod and go.sum change; the go and toolchain lines are unchanged and no other dependencies move. This brings the QUIC transport fixes from v0.61-v0.65: connection migration and NAT rebinding handling, congestion control and PMTU recovery, and stream error handling. frp uses the core QUIC API only: Dial/Listen, config timeouts and stream limits, Conn and Stream. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5ad0bdbe..c9785fad 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( 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.60.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 diff --git a/go.sum b/go.sum index 3bd3c183..9ae2ac40 100644 --- a/go.sum +++ b/go.sum @@ -117,8 +117,8 @@ github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0o 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/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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= From 4c07ebda5b8119f8825e5870027c147aea0d9b68 Mon Sep 17 00:00:00 2001 From: fatedier Date: Wed, 4 Nov 2026 19:59:26 +0800 Subject: [PATCH 22/25] refactor(validation): inline annotation qualified name check (#5601) Replace the single use of k8s.io/apimachinery/pkg/util/validation .IsQualifiedName (annotation key validation in pkg/config/v1/validation) with a local implementation in pkg/util/validation. Newer apimachinery versions link klog only for IP/CIDR warning helpers, which grows frpc and frps, and this was the only frp import of that package. The local IsQualifiedName keeps the exact behavior and error messages of apimachinery v0.28.8, including the qualified-name (label key) format and the DNS-1123 subdomain prefix rules. Annotation keys are still lowercased before validation, so the accepted set is unchanged. Verified against apimachinery v0.28.8 on 228,220 inputs (exhaustive short strings, boundary lengths and seeded random data, including lowercased variants): every error message matches. No dependency versions change; go.mod and go.sum are untouched. --- pkg/config/v1/validation/proxy.go | 5 +- pkg/config/v1/validation/proxy_test.go | 15 +++ pkg/util/validation/qualified_name.go | 123 +++++++++++++++++++++ pkg/util/validation/qualified_name_test.go | 102 +++++++++++++++++ 4 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 pkg/util/validation/qualified_name.go create mode 100644 pkg/util/validation/qualified_name_test.go 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/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") + }) +} From 143b69b5b3d6e068378c085c579af051d768c644 Mon Sep 17 00:00:00 2001 From: fatedier Date: Wed, 4 Nov 2026 20:49:33 +0800 Subject: [PATCH 23/25] deps: bump k8s.io libraries to v0.37.2 (#5602) Upgrade k8s.io/apimachinery and k8s.io/client-go from v0.28.8 to v0.37.2, the latest stable minor, and k8s.io/utils from its 2023 pseudo-version to v0.0.0-20261023123148-ca04ba58ecab. The go and toolchain lines are unchanged. The upgrade is possible now because the annotation key check no longer imports apimachinery's util/validation package, so apimachinery's klog dependency does not reach frpc/frps; both binaries stay free of klog and client-go remains test-only. Indirect changes: sigs.k8s.io/yaml 1.3.0 -> 1.6.0 on top of go.yaml.in/yaml/v2, sigs.k8s.io/json updated to 2025-07-30, and gorilla/websocket moved by MVS from v1.5.3 to the 2025-03 pseudo-version v1.5.4-0.20250319132907-e064f32e3674, which apimachinery v0.37.2 requires. gopkg.in/yaml.v2 leaves the module graph. YAML and configuration parsing behave the same before and after: the apimachinery YAML helper produces identical output on sample documents, and frp's LoadConfigure output is byte-identical for YAML client/server configs, strict dot-field handling, and JSON input. This is not a security fix: govulncheck results are unchanged (11 reachable standard-library advisories only). Binary size changes by +368 bytes for frpc and +496 bytes for frps with embedded web assets. --- go.mod | 14 +++++++------- go.sum | 37 ++++++++++++++++--------------------- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/go.mod b/go.mod index c9785fad..c09179de 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( 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.3 + 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 @@ -35,9 +35,9 @@ require ( golang.org/x/time v0.16.0 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 gopkg.in/ini.v1 v1.67.3 - k8s.io/apimachinery v0.28.8 - k8s.io/client-go v0.28.8 - k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 + k8s.io/apimachinery v0.37.2 + k8s.io/client-go v0.37.2 + k8s.io/utils v0.0.0-20261023123148-ca04ba58ecab ) require ( @@ -63,13 +63,13 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tjfoc/gmsm v1.4.1 // 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.12 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect - sigs.k8s.io/yaml v1.3.0 // 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 9ae2ac40..525173d6 100644 --- a/go.sum +++ b/go.sum @@ -19,7 +19,6 @@ github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBq 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= @@ -55,8 +54,8 @@ 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= @@ -69,8 +68,8 @@ 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.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/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= @@ -121,8 +120,8 @@ github.com/quic-go/quic-go v0.65.0 h1:BJQSjdTvLPLmWtaCupUphRbgxgpZhdO/GMpr9Eoe3B 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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +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.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= @@ -247,25 +246,21 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 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.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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= 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= From 53e24b51b2817de2ea5e8ec5b4fdf06144a4d3be Mon Sep 17 00:00:00 2001 From: fatedier Date: Wed, 4 Nov 2026 21:41:30 +0800 Subject: [PATCH 24/25] deps: bump wireguard-go to 2026-10-24 snapshot (#5603) Upgrade golang.zx2c4.com/wireguard from v0.0.0-20231211153847-12269c276173 (2023-12-11) to v0.0.0-20261024220952-b3bc704ef0ee (2026-10-24). Only go.mod and go.sum change; the go and toolchain lines are unchanged. The new snapshot requires a newer gvisor snapshot (v0.0.0-20250503011706-39ed1f5ac29c), which moves through MVS and is reflected in go.sum. frp uses wireguard-go only through its tun package for the virtual net feature. The tun changes in this range are the Linux GRO path (clear the virtioNetHdr in the receive buffer), checksum add-with-carry handling, and darwin reading the interface flags and MTU directly from route events. The Device API is unchanged and no code changes are needed. This is not a security fix: govulncheck reports the same result before and after. Binary size is unchanged for both frpc and frps. Validation: - go build ./... - go test -count=1 ./... (all packages pass except the pre-existing test/e2e/compatibility failure, which needs explicit baseline binary flags and fails on a clean checkout as well) - make e2e with the real binaries: 261 passed, 2 skipped - repo-wide golangci-lint run: 0 issues - govulncheck ./...: unchanged (11 reachable standard-library advisories, no wireguard or gvisor entries) - Binary size with embedded web assets (CGO_ENABLED=0, -trimpath, -ldflags "-s -w"): frpc 15,607,842 bytes and frps 19,307,122 bytes, identical before and after - Manual virtual net smoke test on linux/arm64 (frps, an frpc virtual-net server and an frpc visitor over the built-in TUN): TUN name, MTU and routes match the old version, ICMP has 0% loss both ways, a 256 MB TCP transfer matches sha256, throughput is about 41 MB/s on both versions, and concurrent connections, high-rate pings, a 45s idle connection and restart recovery all pass without panics. The repository e2e suite has no virtual net coverage, so this is a one-off manual check. --- go.mod | 2 +- go.sum | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index c09179de..8dc2448b 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( 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-20231211153847-12269c276173 + 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 diff --git a/go.sum b/go.sum index 525173d6..fefc5e1c 100644 --- a/go.sum +++ b/go.sum @@ -227,8 +227,8 @@ 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= @@ -250,8 +250,8 @@ 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/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.37.2 h1:G4t9RGoPDbQML38hz/kQQiltttgd3egg2ODCR8aIc2o= From 7c522ee205f9b8a2bf215525241935ea988b3b1f Mon Sep 17 00:00:00 2001 From: fatedier Date: Thu, 5 Nov 2026 00:42:03 +0800 Subject: [PATCH 25/25] deps: bump kcp-go to v5.6.73 (#5604) Upgrade github.com/xtaci/kcp-go/v5 from v5.6.13 to v5.6.73. The templexxx/cpu and templexxx/xorsimd indirect dependencies are no longer required and are dropped from go.mod and go.sum. The go and toolchain lines are unchanged and no other module changes. This release carries the KCP-side fixes frp relies on: write backpressure and duplicate fast-retransmit suppression, ACK and fast-retransmit flush timing, the zero-window probe interval, and MTU accounting for FEC overhead. The wire format is unchanged and frp interoperates with peers on the old version in both directions. kcp-go marks UDPSession.SetStreamMode as deprecated, but frp still needs stream mode and there is no replacement. The calls stay as they are and a staticcheck exclusion scoped to pkg/util/net/kcp.go is added to .golangci.yml instead of //nolint comments. No code changes. This is not a security fix: govulncheck reports the same result before and after. Binary size with embedded web assets (CGO_ENABLED=0, -trimpath, -ldflags "-s -w"): darwin/arm64 frpc 15,607,842 -> 15,591,282 (-16,560) and frps 19,307,122 -> 19,290,434 (-16,688); linux/amd64 frpc 17,416,354 -> 17,449,122 (+32,768) and frps 21,274,786 -> 21,311,650 (+36,864). Validation: - go build ./... - go test -count=1 ./... (all packages pass except the pre-existing test/e2e/compatibility failure, which needs explicit baseline binary flags and fails on a clean checkout as well) - make e2e with the real binaries (263 specs: 261 passed, 2 skipped) - repo-wide golangci-lint run: 2 SA1019 reports without the exclusion, 0 issues with it - govulncheck ./...: unchanged (11 reachable standard-library advisories, no kcp-go or xtaci entries) - Manual KCP throughput check on linux/arm64 loopback (frpc <-> frps over transport.protocol=kcp with TLS, yamux and FEC 10/3): a single 256 MB transfer goes from 7.8 MB/s to about 153 MB/s and 8 x 16 MB concurrent transfers from 7.8 MB/s to about 470 MB/s with matching sha256, and old and new peers interoperate in both directions. The repository e2e suite has no KCP throughput assertions, so this is a one-off manual check. --- .golangci.yml | 4 ++++ go.mod | 4 +--- go.sum | 8 ++------ 3 files changed, 7 insertions(+), 9 deletions(-) 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/go.mod b/go.mod index 8dc2448b..5c198f9d 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( 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.13 + 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 @@ -57,8 +57,6 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/procfs v0.22.0 // indirect - github.com/templexxx/cpu v0.1.1 // indirect - github.com/templexxx/xorsimd v0.4.3 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect diff --git a/go.sum b/go.sum index fefc5e1c..93cccb78 100644 --- a/go.sum +++ b/go.sum @@ -142,10 +142,6 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -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.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= @@ -160,8 +156,8 @@ github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW 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.13 h1:FEjtz9+D4p8t2x4WjciGt/jsIuhlWjjgPCCWjrVR4Hk= -github.com/xtaci/kcp-go/v5 v5.6.13/go.mod h1:75S1AKYYzNUSXIv30h+jPKJYZUwqpfvLshu63nCNSOM= +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/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=