[client] Add local Prometheus metrics endpoint (#6689)

This commit is contained in:
Viktor Liu
2026-08-27 20:53:06 +09:00
committed by GitHub
parent e06c17cf59
commit 63c26be72f
26 changed files with 2265 additions and 91 deletions

View File

@@ -233,6 +233,24 @@ func conflictString(key, got string) conflictCheck {
}
}
// conflictStringPtr is conflictString for optional proto fields, where an
// explicit empty value is still a request to change the setting. If p is
// nil the field is treated as matching (no override requested); otherwise
// the check returns true only when the policy contains the key and its
// value equals *p.
func conflictStringPtr(key string, p *string) conflictCheck {
return conflictCheck{
key: key,
check: func(pol *mdm.Policy) bool {
if p == nil {
return true
}
want, ok := pol.GetString(key)
return ok && want == *p
},
}
}
// conflictInt64 builds a conflictCheck for an integer MDM key. If p is
// nil the field is treated as matching; otherwise the check returns
// true only when the policy contains the key and its int value equals *p.
@@ -301,6 +319,8 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
})
}
@@ -346,7 +366,9 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
msg.EnableSSHLocalPortForwarding != nil ||
msg.EnableSSHRemotePortForwarding != nil ||
msg.DisableSSHAuth != nil ||
msg.SshJWTCacheTTL != nil
msg.SshJWTCacheTTL != nil ||
msg.EnableLocalMetrics != nil ||
msg.LocalMetricsAddress != nil
}
// loginRequestHasConfigOverrides reports whether the LoginRequest
@@ -381,7 +403,9 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
msg.BlockLanAccess != nil ||
msg.DisableNotifications != nil ||
len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
msg.BlockInbound != nil
msg.BlockInbound != nil ||
msg.EnableLocalMetrics != nil ||
msg.LocalMetricsAddress != nil
}
// loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the
@@ -422,6 +446,8 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str
conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
conflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
conflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
})
}

View File

@@ -23,6 +23,9 @@ import (
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/expose"
"github.com/prometheus/client_golang/prometheus"
"github.com/netbirdio/netbird/client/internal/localmetrics"
"github.com/netbirdio/netbird/client/internal/profilemanager"
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
"github.com/netbirdio/netbird/client/mdm"
@@ -108,6 +111,7 @@ type Server struct {
statusRecorder *peer.Status
sessionWatcher *internal.SessionWatcher
localMetrics *localmetrics.Manager
probeThrottle *probeThrottle
persistSyncResponse bool
@@ -171,9 +175,28 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
s.sleepHandler = sleephandler.New(agent)
s.startSleepDetector()
s.localMetrics = localmetrics.NewManager(ctx, s.statusRecorder, s.clientMetricsGatherer)
return s
}
// clientMetricsGatherer returns the Prometheus gatherer of the running
// engine's client metrics, or nil when no engine is running.
func (s *Server) clientMetricsGatherer() prometheus.Gatherer {
s.mutex.Lock()
connectClient := s.connectClient
s.mutex.Unlock()
if connectClient == nil {
return nil
}
engine := connectClient.Engine()
if engine == nil {
return nil
}
return engine.GetClientMetrics().PrometheusGatherer()
}
func (s *Server) Start() error {
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -254,6 +277,7 @@ func (s *Server) Start() error {
s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
if s.sessionWatcher == nil {
s.sessionWatcher = internal.NewSessionWatcher(s.rootCtx, s.statusRecorder)
@@ -477,11 +501,18 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
return nil, err
}
if _, err := profilemanager.UpdateConfig(config); err != nil {
updatedConf, err := profilemanager.UpdateConfig(config)
if err != nil {
log.Errorf("failed to update profile config: %v", err)
return nil, fmt.Errorf("failed to update profile config: %w", err)
}
if activeProf, err := s.profileManager.GetActiveProfileState(); err == nil {
if activePath, err := activeProf.FilePath(); err == nil && activePath == config.ConfigPath {
s.localMetrics.Reconcile(updatedConf.LocalMetricsEnabled, updatedConf.LocalMetricsAddress)
}
}
return &proto.SetConfigResponse{}, nil
}
@@ -551,6 +582,8 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile
config.RosenpassEnabled = msg.RosenpassEnabled
config.RosenpassPermissive = msg.RosenpassPermissive
config.LocalMetricsEnabled = msg.EnableLocalMetrics
config.LocalMetricsAddress = msg.LocalMetricsAddress
config.DisableAutoConnect = msg.DisableAutoConnect
config.ServerSSHAllowed = msg.ServerSSHAllowed
config.NetworkMonitor = msg.NetworkMonitor
@@ -657,6 +690,8 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
s.config = config
s.mutex.Unlock()
s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
// A probe that errors leaves the login undecided: Management unreachable, a
// restart mid-request, an internal error. Those are returned for the caller
// to retry, because turning them into an SSO prompt asks the user to solve
@@ -1007,6 +1042,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String())
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
s.localMetrics.Reconcile(s.config.LocalMetricsEnabled, s.config.LocalMetricsAddress)
s.clientRunning = true
s.clientRunningChan = make(chan struct{})
@@ -1184,6 +1220,7 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
}
s.config = config
s.localMetrics.Reconcile(config.LocalMetricsEnabled, config.LocalMetricsAddress)
if msg != nil && msg.ProfileName != nil {
s.publishProfileListChanged(*msg.ProfileName)

View File

@@ -136,6 +136,51 @@ func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
}, v.GetFields())
}
func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
mdm.KeyEnableLocalMetrics: true,
mdm.KeyLocalMetricsAddress: "127.0.0.1:9191",
}))
s, ctx, profName, username, _ := setupServerWithProfile(t)
enabled := false
addr := "0.0.0.0:9999"
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
EnableLocalMetrics: &enabled,
LocalMetricsAddress: &addr,
})
v := extractViolation(t, err)
assert.ElementsMatch(t, []string{
mdm.KeyEnableLocalMetrics,
mdm.KeyLocalMetricsAddress,
}, v.GetFields())
}
// An explicitly empty address still changes the effective listen address
// (the manager falls back to the default), so presence must be honored
// rather than collapsed to "field not set".
func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(t *testing.T) {
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
mdm.KeyLocalMetricsAddress: "127.0.0.1:9999",
}))
s, ctx, profName, username, _ := setupServerWithProfile(t)
addr := ""
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
LocalMetricsAddress: &addr,
})
v := extractViolation(t, err)
assert.ElementsMatch(t, []string{mdm.KeyLocalMetricsAddress}, v.GetFields())
}
func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
// MDM enforces ManagementURL only; user request touches both the
// enforced field AND a non-enforced field (RosenpassEnabled).

View File

@@ -76,6 +76,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
disableIPv6 := true
mtu := int64(1280)
sshJWTCacheTTL := int32(300)
enableLocalMetrics := true
localMetricsAddress := "127.0.0.1:9292"
req := &proto.SetConfigRequest{
ProfileName: profName,
@@ -107,6 +109,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
DnsRouteInterval: durationpb.New(2 * time.Minute),
Mtu: &mtu,
SshJWTCacheTTL: &sshJWTCacheTTL,
EnableLocalMetrics: &enableLocalMetrics,
LocalMetricsAddress: &localMetricsAddress,
}
_, err = s.SetConfig(ctx, req)
@@ -153,6 +157,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
require.Equal(t, uint16(mtu), cfg.MTU)
require.NotNil(t, cfg.SSHJWTCacheTTL)
require.Equal(t, int(sshJWTCacheTTL), *cfg.SSHJWTCacheTTL)
require.Equal(t, enableLocalMetrics, cfg.LocalMetricsEnabled)
require.Equal(t, localMetricsAddress, cfg.LocalMetricsAddress)
verifyAllFieldsCovered(t, req)
}
@@ -205,6 +211,8 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
"EnableSSHRemotePortForwarding": true,
"DisableSSHAuth": true,
"SshJWTCacheTTL": true,
"EnableLocalMetrics": true,
"LocalMetricsAddress": true,
}
val := reflect.ValueOf(req).Elem()
@@ -264,6 +272,8 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
"enable-ssh-remote-port-forwarding": "EnableSSHRemotePortForwarding",
"disable-ssh-auth": "DisableSSHAuth",
"ssh-jwt-cache-ttl": "SshJWTCacheTTL",
"enable-local-metrics": "EnableLocalMetrics",
"local-metrics-address": "LocalMetricsAddress",
}
// SetConfigRequest fields that don't have CLI flags (settable only via UI or other means).

View File

@@ -14,6 +14,7 @@ import (
"github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/internal/localmetrics"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util"
@@ -30,6 +31,8 @@ import (
// management identity hands SSH authorization decisions, including which
// keys and users are accepted, to whoever controls that identity. Changing
// the management URL and deregistering the peer are both ways to do that.
// - Binding the local metrics endpoint to a non-loopback address publishes
// peer names and connectivity state to the network without authentication.
//
// Everything else stays unauthenticated, so this is not an authorization model:
// it only refuses the changes that would let a local user become root. A caller
@@ -39,27 +42,33 @@ import (
// user-to-root boundary. Fields are nil or empty when the request leaves them
// untouched.
type privilegedConfigChange struct {
managementURL string
serverSSHAllowed *bool
enableSSHRoot *bool
disableSSHAuth *bool
managementURL string
serverSSHAllowed *bool
enableSSHRoot *bool
disableSSHAuth *bool
enableLocalMetrics *bool
localMetricsAddress *string
}
func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfigChange {
return privilegedConfigChange{
managementURL: msg.GetManagementUrl(),
serverSSHAllowed: msg.ServerSSHAllowed,
enableSSHRoot: msg.EnableSSHRoot,
disableSSHAuth: msg.DisableSSHAuth,
managementURL: msg.GetManagementUrl(),
serverSSHAllowed: msg.ServerSSHAllowed,
enableSSHRoot: msg.EnableSSHRoot,
disableSSHAuth: msg.DisableSSHAuth,
enableLocalMetrics: msg.EnableLocalMetrics,
localMetricsAddress: msg.LocalMetricsAddress,
}
}
func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange {
return privilegedConfigChange{
managementURL: msg.GetManagementUrl(),
serverSSHAllowed: msg.ServerSSHAllowed,
enableSSHRoot: msg.EnableSSHRoot,
disableSSHAuth: msg.DisableSSHAuth,
managementURL: msg.GetManagementUrl(),
serverSSHAllowed: msg.ServerSSHAllowed,
enableSSHRoot: msg.EnableSSHRoot,
disableSSHAuth: msg.DisableSSHAuth,
enableLocalMetrics: msg.EnableLocalMetrics,
localMetricsAddress: msg.LocalMetricsAddress,
}
}
@@ -83,6 +92,12 @@ func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager
return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh"))
}
if addr, exposes := exposesLocalMetrics(stored, change); exposes {
return denyPrivileged(ctx,
"exposing the local metrics endpoint on a non-loopback address",
ipcauth.UpCommand("--enable-local-metrics --local-metrics-address "+addr))
}
// Only guard the management binding while the SSH server is enabled: that is
// when the management identity decides who may open a shell here.
if !sshServerEnabled(stored) {
@@ -245,6 +260,48 @@ func sshServerCurrentlyAllowed(cfg *profilemanager.Config) *bool {
return &enabled
}
// exposesLocalMetrics reports whether the change would leave the metrics
// endpoint enabled on an address that is not confirmed loopback, and returns
// that address. A request that restates the stored state is not a change, so a
// settings form resubmitted after an administrator opened the endpoint is not
// refused.
func exposesLocalMetrics(stored *profilemanager.Config, change privilegedConfigChange) (string, bool) {
storedEnabled, storedAddr := storedLocalMetrics(stored)
enabled := storedEnabled
if change.enableLocalMetrics != nil {
enabled = *change.enableLocalMetrics
}
addr := storedAddr
if change.localMetricsAddress != nil {
addr = metricsAddrOrDefault(*change.localMetricsAddress)
}
if !enabled || localmetrics.IsLoopback(addr) {
return "", false
}
if storedEnabled && storedAddr == addr {
return "", false
}
return addr, true
}
// storedLocalMetrics reads the metrics settings from the stored config,
// tolerating a config that does not exist yet.
func storedLocalMetrics(cfg *profilemanager.Config) (bool, string) {
if cfg == nil {
return false, localmetrics.DefaultListenAddress
}
return cfg.LocalMetricsEnabled, metricsAddrOrDefault(cfg.LocalMetricsAddress)
}
func metricsAddrOrDefault(addr string) string {
if addr == "" {
return localmetrics.DefaultListenAddress
}
return addr
}
// sameManagementURL reports whether requested addresses the same management
// server as stored, comparing scheme, host and effective port so that an
// equivalent spelling ("https://api.netbird.io" for a stored

View File

@@ -61,6 +61,8 @@ func noIdentityCtx() context.Context { return context.Background() }
func boolPtr(v bool) *bool { return &v }
func strPtr(v string) *string { return &v }
func mustURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
@@ -194,6 +196,102 @@ func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) {
}
}
func TestRequirePrivilegeForConfigChange_LocalMetrics(t *testing.T) {
exposed := &profilemanager.Config{LocalMetricsEnabled: true, LocalMetricsAddress: "0.0.0.0:9191"}
tests := []struct {
name string
stored *profilemanager.Config
change privilegedConfigChange
privileged bool
wantDeny bool
}{
{
name: "binding a non-loopback address unprivileged is refused",
stored: &profilemanager.Config{},
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")},
wantDeny: true,
},
{
name: "binding a non-loopback address as root is allowed",
stored: &profilemanager.Config{},
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")},
privileged: true,
},
{
name: "enabling on the default loopback address is not guarded",
stored: &profilemanager.Config{},
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true)},
},
{
name: "enabling on an explicit loopback address is not guarded",
stored: &profilemanager.Config{},
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("127.0.0.1:9999")},
},
{
name: "enabling on the IPv6 loopback address is not guarded",
stored: &profilemanager.Config{},
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("[::1]:9191")},
},
{
// The address alone does nothing while the endpoint stays off.
name: "a non-loopback address without enabling is not guarded",
stored: &profilemanager.Config{},
change: privilegedConfigChange{localMetricsAddress: strPtr("0.0.0.0:9191")},
},
{
name: "widening an already enabled loopback endpoint is refused",
stored: &profilemanager.Config{LocalMetricsEnabled: true, LocalMetricsAddress: "127.0.0.1:9191"},
change: privilegedConfigChange{localMetricsAddress: strPtr("0.0.0.0:9191")},
wantDeny: true,
},
{
name: "restating an already exposed endpoint is not a change",
stored: exposed,
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")},
},
{
name: "turning an exposed endpoint off is not guarded",
stored: exposed,
change: privilegedConfigChange{enableLocalMetrics: boolPtr(false)},
},
{
name: "re-enabling an exposed endpoint that was turned off is refused",
stored: &profilemanager.Config{LocalMetricsEnabled: false, LocalMetricsAddress: "0.0.0.0:9191"},
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true)},
wantDeny: true,
},
{
// Fail closed: an address that cannot be parsed is not confirmed loopback.
name: "an unparseable address is refused",
stored: &profilemanager.Config{},
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("not-an-address")},
wantDeny: true,
},
{
name: "a profile with no config yet counts as off, so exposing is refused",
stored: nil,
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")},
wantDeny: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := userCtx()
if tt.privileged {
ctx = rootCtx()
}
err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change)
if tt.wantDeny {
assertDenied(t, err)
return
}
assertAllowed(t, err)
})
}
}
func TestRequirePrivilegeForConfigChange_ManagementURL(t *testing.T) {
sshOn := func(raw string) *profilemanager.Config {
return &profilemanager.Config{ServerSSHAllowed: boolPtr(true), ManagementURL: mustURL(t, raw)}