forked from Mxmilu666/frp
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) },
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user