Merge branch 'main' into file-share

# Conflicts:
#	client/ios/NetBirdSDK/client.go
This commit is contained in:
Zoltán Papp
2026-09-01 18:06:26 +02:00
485 changed files with 36221 additions and 5501 deletions
+200
View File
@@ -0,0 +1,200 @@
package server
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
)
// unreachableManagementURL keeps a test that is expected to stop at a gate from
// reaching the network if the gate ever regresses: the profiles a logout must
// not touch point here, so a leak fails fast instead of contacting a real
// management server.
const unreachableManagementURL = "https://127.0.0.1:9"
// enableSSHOnProfile rewrites the profile config at cfgPath with the SSH server
// enabled. Deregistering an SSH-enabled profile is a privileged change, so an
// unprivileged caller is refused by requirePrivilegeForDeregistration before any
// management connection is attempted, which is what keeps these tests offline.
func enableSSHOnProfile(t *testing.T, cfgPath string) {
t.Helper()
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: cfgPath,
ManagementURL: "https://api.netbird.io:443",
ServerSSHAllowed: boolPtr(true),
})
require.NoError(t, err)
}
// Logging out of the profile the daemon is already running is a deregistration,
// not profile management, so the profiles-disabled kill switch must not block
// it. The desktop UI always addresses logout by profile (both the profile menu
// and the session-expiration dialog), so gating it left users with
// disableProfiles enforced unable to log out at all.
func TestLogout_ActiveProfileAllowedWhenProfilesDisabled(t *testing.T) {
s, _, activeProfile, username, cfgPath := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
enableSSHOnProfile(t, cfgPath)
s.profilesDisabled = true
_, err := s.Logout(userCtx(), &proto.LogoutRequest{
ProfileName: &activeProfile,
Username: &username,
})
require.Error(t, err, "the SSH privilege gate is expected to refuse this unprivileged caller")
require.Equal(t, codes.PermissionDenied, gstatus.Code(err),
"logout of the active profile must reach the deregistration path, not be refused as profile management: %v", err)
require.NotContains(t, gstatus.Convert(err).Message(), errProfilesDisabled)
}
// A profile-addressed logout that targets some *other* profile does manage
// profiles, so it stays gated: with profiles disabled the daemon must not
// deregister a peer the user is not currently running.
func TestLogout_OtherProfileStaysGatedWhenProfilesDisabled(t *testing.T) {
s, _, _, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
other := "other-profile"
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, other+".json"),
ManagementURL: unreachableManagementURL,
})
require.NoError(t, err)
s.profilesDisabled = true
_, err = s.Logout(userCtx(), &proto.LogoutRequest{
ProfileName: &other,
Username: &username,
})
require.Error(t, err)
require.Equal(t, codes.Unavailable, gstatus.Code(err), "want the profiles-disabled refusal, got %v", err)
require.Contains(t, gstatus.Convert(err).Message(), errProfilesDisabled)
}
// A legacy profile ID is a display name, so two users can hold the same ID in
// their own profile directories. Matching on the ID alone would let one user's
// logout pass the gate against the other user's active profile, so the username
// is part of the comparison.
func TestLogout_ForeignUserProfileStaysGatedWhenProfilesDisabled(t *testing.T) {
s, _, _, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
// A legacy-style profile whose ID is its filename stem, and an active state
// claiming that same ID for a different user.
shared := "shared-legacy-name"
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, shared+".json"),
ManagementURL: unreachableManagementURL,
})
require.NoError(t, err)
require.NoError(t, s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
ID: profilemanager.ID(shared),
Username: "someone-else",
}))
s.profilesDisabled = true
_, err = s.Logout(userCtx(), &proto.LogoutRequest{
ProfileName: &shared,
Username: &username,
})
require.Error(t, err)
require.Equal(t, codes.Unavailable, gstatus.Code(err),
"another user's profile must not pass the gate on an ID match alone: %v", err)
}
// Deregistering a namesake profile must not go out with the running config.
// logoutFromProfile reuses the connected client's config when the target is the
// active profile, and on an ID-only match a shared legacy ID made it reuse it
// for another user's profile, deregistering the active peer instead.
func TestLogout_ForeignUserProfileDoesNotUseTheRunningConfig(t *testing.T) {
s, _, _, username, cfgPath := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
// The running config has the SSH server enabled, so reusing it would be
// refused with PermissionDenied. The namesake profile does not, so the
// correct path gets as far as dialing its own unreachable management URL.
enableSSHOnProfile(t, cfgPath)
running, err := profilemanager.GetConfig(cfgPath)
require.NoError(t, err)
s.config = running
s.connectClient = newDummyConnectClient(context.Background())
shared := "shared-legacy-name"
_, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, shared+".json"),
ManagementURL: unreachableManagementURL,
})
require.NoError(t, err)
require.NoError(t, s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
ID: profilemanager.ID(shared),
Username: "someone-else",
}))
// Bounded so the deregistration the fixed path attempts fails on the dial
// rather than sitting in gRPC backoff for the whole test timeout.
ctx, cancel := context.WithTimeout(userCtx(), 2*time.Second)
t.Cleanup(cancel)
_, err = s.Logout(ctx, &proto.LogoutRequest{
ProfileName: &shared,
Username: &username,
})
require.Error(t, err)
require.NotEqual(t, codes.PermissionDenied, gstatus.Code(err),
"the namesake profile was deregistered with the running config: %v", err)
}
// The connection teardown follows the profile that is active when the logout
// completes, not the one seen before it started: Login switches profiles under
// guardedConfigMu, which the logout path does not hold, so a login that landed
// meanwhile must keep its connection.
func TestCleanupAfterProfileLogout_FollowsTheCurrentActiveProfile(t *testing.T) {
s, _, activeProfile, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
state := internal.CtxGetState(s.rootCtx)
s.cleanupAfterProfileLogout("some-other-profile", username)
status, err := state.Status()
require.NoError(t, err)
require.NotEqual(t, internal.StatusNeedsLogin, status,
"logging out of a profile that is not active must not ask for a new login")
s.cleanupAfterProfileLogout(profilemanager.ID(activeProfile), username)
status, err = state.Status()
require.NoError(t, err)
require.Equal(t, internal.StatusNeedsLogin, status,
"logging out of the active profile must ask for a new login")
}
// With profiles enabled the gate is out of the way on both surfaces; the active
// profile still reaches the deregistration path.
func TestLogout_ActiveProfileAllowedWhenProfilesEnabled(t *testing.T) {
s, _, activeProfile, username, cfgPath := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
enableSSHOnProfile(t, cfgPath)
_, err := s.Logout(userCtx(), &proto.LogoutRequest{
ProfileName: &activeProfile,
Username: &username,
})
require.Error(t, err, "the SSH privilege gate is expected to refuse this unprivileged caller")
require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want the privilege refusal, got %v", err)
}
+32 -2
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.
@@ -297,10 +315,13 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
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),
})
}
@@ -332,6 +353,7 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
msg.Mtu != nil ||
msg.DisableAutoConnect != nil ||
msg.ServerSSHAllowed != nil ||
msg.RemoteJobsAllowed != nil ||
msg.NetworkMonitor != nil ||
msg.DisableClientRoutes != nil ||
msg.DisableServerRoutes != nil ||
@@ -346,7 +368,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
@@ -370,6 +394,7 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
msg.WireguardPort != nil ||
msg.DisableAutoConnect != nil ||
msg.ServerSSHAllowed != nil ||
msg.RemoteJobsAllowed != nil ||
msg.RosenpassPermissive != nil ||
len(msg.ExtraIFaceBlacklist) > 0 ||
msg.NetworkMonitor != nil ||
@@ -381,7 +406,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
@@ -418,10 +445,13 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str
conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
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),
})
}
-1
View File
@@ -232,4 +232,3 @@ func toNetIDs(routes []string) []route.NetID {
}
return netIDs
}
+176 -84
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"
@@ -36,6 +39,7 @@ import (
"github.com/netbirdio/netbird/client/internal/statemanager"
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util"
"github.com/netbirdio/netbird/util/capture"
"github.com/netbirdio/netbird/version"
)
@@ -109,6 +113,7 @@ type Server struct {
statusRecorder *peer.Status
sessionWatcher *internal.SessionWatcher
localMetrics *localmetrics.Manager
fileDrop *filedrop.Manager
@@ -174,9 +179,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()
@@ -257,6 +281,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)
@@ -480,11 +505,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
}
@@ -554,8 +586,11 @@ 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.RemoteJobsAllowed = msg.RemoteJobsAllowed
config.NetworkMonitor = msg.NetworkMonitor
config.DisableClientRoutes = msg.DisableClientRoutes
config.DisableServerRoutes = msg.DisableServerRoutes
@@ -660,6 +695,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
@@ -678,54 +715,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
}
if msg.SetupKey == "" {
hint := ""
if msg.Hint != nil {
hint = *msg.Hint
}
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint)
if err != nil {
state.Set(internal.StatusLoginFailed)
return nil, err
}
if s.oauthAuthFlow.flow != nil && s.oauthAuthFlow.flow.GetClientID(ctx) == oAuthFlow.GetClientID(ctx) {
if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) {
log.Debugf("using previous oauth flow info")
state.Set(internal.StatusNeedsLogin)
return &proto.LoginResponse{
NeedsSSOLogin: true,
VerificationURI: s.oauthAuthFlow.info.VerificationURI,
VerificationURIComplete: s.oauthAuthFlow.info.VerificationURIComplete,
UserCode: s.oauthAuthFlow.info.UserCode,
}, nil
} else {
log.Warnf("canceling previous waiting execution")
if s.oauthAuthFlow.waitCancel != nil {
s.oauthAuthFlow.waitCancel()
}
}
}
authInfo, err := oAuthFlow.RequestAuthInfo(ctx)
if err != nil {
log.Errorf("getting a request OAuth flow failed: %v", err)
return nil, err
}
s.mutex.Lock()
s.oauthAuthFlow.flow = oAuthFlow
s.oauthAuthFlow.info = authInfo
s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second)
s.mutex.Unlock()
state.Set(internal.StatusNeedsLogin)
return &proto.LoginResponse{
NeedsSSOLogin: true,
VerificationURI: authInfo.VerificationURI,
VerificationURIComplete: authInfo.VerificationURIComplete,
UserCode: authInfo.UserCode,
}, nil
return s.beginSSOLogin(ctx, config, msg)
}
// Setup-key path: we are about to dial Management with the key, so the
@@ -741,6 +731,76 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
return &proto.LoginResponse{}, nil
}
// beginSSOLogin starts the browser leg of a login that carries no setup key and
// returns the response that parks the caller on it.
func (s *Server) beginSSOLogin(ctx context.Context, config *profilemanager.Config, msg *proto.LoginRequest) (*proto.LoginResponse, error) {
state := internal.CtxGetState(s.rootCtx)
hint := ""
if msg.Hint != nil {
hint = *msg.Hint
}
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsUnixDesktopClient, false, hint)
if err != nil {
state.Set(internal.StatusLoginFailed)
return nil, err
}
if resp := s.pendingOAuthFlowResponse(ctx, oAuthFlow); resp != nil {
state.Set(internal.StatusNeedsLogin)
return resp, nil
}
authInfo, err := oAuthFlow.RequestAuthInfo(ctx)
if err != nil {
log.Errorf("getting a request OAuth flow failed: %v", err)
return nil, err
}
s.mutex.Lock()
s.oauthAuthFlow.flow = oAuthFlow
s.oauthAuthFlow.info = authInfo
s.oauthAuthFlow.expiresAt = time.Now().Add(time.Duration(authInfo.ExpiresIn) * time.Second)
s.mutex.Unlock()
state.Set(internal.StatusNeedsLogin)
return &proto.LoginResponse{
NeedsSSOLogin: true,
VerificationURI: authInfo.VerificationURI,
VerificationURIComplete: authInfo.VerificationURIComplete,
UserCode: authInfo.UserCode,
}, nil
}
// pendingOAuthFlowResponse returns the in-flight flow's response when it
// targets the same IdP client and has enough time left for the user to finish
// the browser leg, so a second login joins the pending flow instead of opening
// a competing one. A flow too close to expiry has its waiter cancelled and nil
// returned, leaving the caller to start a fresh flow.
func (s *Server) pendingOAuthFlowResponse(ctx context.Context, oAuthFlow auth.OAuthFlow) *proto.LoginResponse {
if s.oauthAuthFlow.flow == nil || s.oauthAuthFlow.flow.GetClientID(ctx) != oAuthFlow.GetClientID(ctx) {
return nil
}
if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) {
log.Debugf("using previous oauth flow info")
return &proto.LoginResponse{
NeedsSSOLogin: true,
VerificationURI: s.oauthAuthFlow.info.VerificationURI,
VerificationURIComplete: s.oauthAuthFlow.info.VerificationURIComplete,
UserCode: s.oauthAuthFlow.info.UserCode,
}
}
log.Warnf("canceling previous waiting execution")
if s.oauthAuthFlow.waitCancel != nil {
s.oauthAuthFlow.waitCancel()
}
return nil
}
// WaitSSOLogin validates the supplied userCode against the in-flight OAuth
// device/PKCE flow and blocks until the user finishes the browser leg.
//
@@ -1010,6 +1070,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{})
@@ -1187,6 +1248,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)
@@ -1313,11 +1375,16 @@ func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutReque
return nil, err
}
if err := s.validateProfileOperation(resolved.ID, true); err != nil {
activeProf, err := s.profileManager.GetActiveProfileState()
if err != nil {
return nil, gstatus.Errorf(codes.FailedPrecondition, "failed to get active profile state: %v", err)
}
if err := s.validateProfileLogout(resolved.ID, isActiveProfile(activeProf, resolved.ID, username)); err != nil {
return nil, err
}
if err := s.logoutFromProfile(ctx, resolved); err != nil {
if err := s.logoutFromProfile(ctx, resolved, username); err != nil {
log.Errorf("failed to logout from profile %s: %v", resolved.ID, err)
// A refused deregistration is already a status error carrying the reason
// and the command to run; rewrapping it as Internal would flatten both
@@ -1328,18 +1395,35 @@ func (s *Server) handleProfileLogout(ctx context.Context, msg *proto.LogoutReque
return nil, gstatus.Errorf(codes.Internal, "logout: %v", err)
}
activeProf, _ := s.profileManager.GetActiveProfileState()
if activeProf != nil && activeProf.ID == resolved.ID {
if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) {
log.Errorf("failed to cleanup connection: %v", err)
}
state := internal.CtxGetState(s.rootCtx)
state.Set(internal.StatusNeedsLogin)
}
s.cleanupAfterProfileLogout(resolved.ID, username)
return &proto.LogoutResponse{}, nil
}
// cleanupAfterProfileLogout tears the connection down and asks for a new login
// when the profile that was just deregistered is the one the daemon is running.
// The active profile is read again here rather than reused from the pre-flight
// check: Login switches profiles under guardedConfigMu, which this path does not
// hold, so a login that landed meanwhile must not have its fresh connection
// dropped by a logout that targeted the profile it replaced.
func (s *Server) cleanupAfterProfileLogout(id profilemanager.ID, username string) {
activeProf, err := s.profileManager.GetActiveProfileState()
if err != nil {
log.Errorf("failed to get active profile state after logout from profile %s: %v", id, err)
return
}
if !isActiveProfile(activeProf, id, username) {
return
}
if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) {
log.Errorf("failed to cleanup connection: %v", err)
}
state := internal.CtxGetState(s.rootCtx)
state.Set(internal.StatusNeedsLogin)
}
func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutResponse, error) {
if s.config == nil {
activeProf, err := s.profileManager.GetActiveProfileState()
@@ -1391,40 +1475,47 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof
return config, configExisted, nil
}
func (s *Server) canRemoveProfile(id profilemanager.ID) error {
if id == profilemanager.DefaultProfileName {
return fmt.Errorf("remove profile with reserved name: %s", profilemanager.DefaultProfileName)
}
activeProf, err := s.profileManager.GetActiveProfileState()
if err == nil && activeProf.ID == id {
return fmt.Errorf("remove active profile: %s", id)
}
return nil
}
func (s *Server) validateProfileOperation(id profilemanager.ID, allowActiveProfile bool) error {
if s.checkProfilesDisabled() {
return gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
}
// validateProfileLogout gates a profile-addressed logout. Deregistering the
// profile the daemon already runs is what a plain `netbird logout` does, so the
// profiles-disabled kill switch must not block it. Logging out of any other
// profile is profile management and stays gated.
func (s *Server) validateProfileLogout(id profilemanager.ID, isActive bool) error {
if id == "" {
return gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
}
if !allowActiveProfile {
if err := s.canRemoveProfile(id); err != nil {
return gstatus.Errorf(codes.InvalidArgument, "%v", err)
}
if isActive {
return nil
}
if s.checkProfilesDisabled() {
return gstatus.Errorf(codes.Unavailable, errProfilesDisabled)
}
return nil
}
func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile) error {
// isActiveProfile reports whether id is the profile the daemon runs for
// username. The username is part of the comparison because legacy profile IDs
// are display names, which two users can both hold; the default profile is
// shared by every user and carries no username.
func isActiveProfile(activeProf *profilemanager.ActiveProfileState, id profilemanager.ID, username string) bool {
if activeProf == nil || activeProf.ID != id {
return false
}
return id == profilemanager.DefaultProfileName || activeProf.Username == username
}
// logoutFromProfile deregisters profile, reusing the running config when
// profile is the one the daemon is connected with. The username takes part in
// that decision for the same reason it does in the logout gate: a legacy
// profile ID is a display name two users can share, and sending the running
// config for a namesake would deregister the active peer instead of the
// requested one.
func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile, username string) error {
activeProf, err := s.profileManager.GetActiveProfileState()
if err == nil && activeProf.ID == profile.ID && s.connectClient != nil {
if err == nil && isActiveProfile(activeProf, profile.ID, username) && s.connectClient != nil {
return s.sendLogoutRequest(ctx)
}
@@ -2103,6 +2194,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
Mtu: int64(cfg.MTU),
DisableAutoConnect: cfg.DisableAutoConnect,
ServerSSHAllowed: *cfg.ServerSSHAllowed,
RemoteJobsAllowed: util.ReturnBoolWithDefaultFalse(cfg.RemoteJobsAllowed),
RosenpassEnabled: cfg.RosenpassEnabled,
RosenpassPermissive: cfg.RosenpassPermissive,
BlockInbound: cfg.BlockInbound,
@@ -2193,7 +2285,7 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
return nil, err
}
if err := s.logoutFromProfile(ctx, resolved); err != nil {
if err := s.logoutFromProfile(ctx, resolved, msg.Username); err != nil {
// Deregistration is best-effort here: the local profile is removed
// either way, so an unprivileged caller leaves the peer registered on
// the management server rather than being blocked from removing it.
+1 -1
View File
@@ -200,7 +200,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
peersUpdateManager := update_channel.NewPeersUpdateManager(metrics)
networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil)
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
if err != nil {
return nil, "", err
+45
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).
+16
View File
@@ -61,6 +61,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
rosenpassEnabled := true
rosenpassPermissive := true
serverSSHAllowed := true
remoteJobsAllowed := true
interfaceName := "utun100"
wireguardPort := int64(51820)
preSharedKey := "test-psk"
@@ -76,6 +77,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,
@@ -85,6 +88,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
RosenpassEnabled: &rosenpassEnabled,
RosenpassPermissive: &rosenpassPermissive,
ServerSSHAllowed: &serverSSHAllowed,
RemoteJobsAllowed: &remoteJobsAllowed,
InterfaceName: &interfaceName,
WireguardPort: &wireguardPort,
OptionalPreSharedKey: &preSharedKey,
@@ -107,6 +111,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)
@@ -128,6 +134,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive)
require.NotNil(t, cfg.ServerSSHAllowed)
require.Equal(t, serverSSHAllowed, *cfg.ServerSSHAllowed)
require.NotNil(t, cfg.RemoteJobsAllowed)
require.Equal(t, remoteJobsAllowed, *cfg.RemoteJobsAllowed)
require.Equal(t, interfaceName, cfg.WgIface)
require.Equal(t, int(wireguardPort), cfg.WgPort)
require.Equal(t, preSharedKey, cfg.PreSharedKey)
@@ -153,6 +161,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)
}
@@ -180,6 +190,7 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
"RosenpassEnabled": true,
"RosenpassPermissive": true,
"ServerSSHAllowed": true,
"RemoteJobsAllowed": true,
"InterfaceName": true,
"WireguardPort": true,
"OptionalPreSharedKey": true,
@@ -205,6 +216,8 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
"EnableSSHRemotePortForwarding": true,
"DisableSSHAuth": true,
"SshJWTCacheTTL": true,
"EnableLocalMetrics": true,
"LocalMetricsAddress": true,
}
val := reflect.ValueOf(req).Elem()
@@ -240,6 +253,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
"enable-rosenpass": "RosenpassEnabled",
"rosenpass-permissive": "RosenpassPermissive",
"allow-server-ssh": "ServerSSHAllowed",
"allow-remote-jobs": "RemoteJobsAllowed",
"interface-name": "InterfaceName",
"wireguard-port": "WireguardPort",
"preshared-key": "OptionalPreSharedKey",
@@ -264,6 +278,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).
+81 -12
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,36 @@ 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
remoteJobsAllowed *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,
remoteJobsAllowed: msg.RemoteJobsAllowed,
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,
remoteJobsAllowed: msg.RemoteJobsAllowed,
enableSSHRoot: msg.EnableSSHRoot,
disableSSHAuth: msg.DisableSSHAuth,
enableLocalMetrics: msg.EnableLocalMetrics,
localMetricsAddress: msg.LocalMetricsAddress,
}
}
@@ -83,6 +95,21 @@ func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager
return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh"))
}
// Enabling remote jobs lets the management server run jobs (e.g. debug
// bundles) on this host, so turning it on crosses the user-to-root
// boundary the same way enabling the SSH server does. The stored value
// defaults to off (nil = off), so a legacy config is correctly seen as
// off and turning it on requires privilege.
if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.RemoteJobsAllowed }), change.remoteJobsAllowed) {
return denyPrivileged(ctx, "enabling remote jobs", ipcauth.UpCommand("--allow-remote-jobs"))
}
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 +272,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
+126
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)
@@ -171,6 +173,34 @@ func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) {
stored: &profilemanager.Config{DisableSSHAuth: boolPtr(true)},
change: privilegedConfigChange{disableSSHAuth: boolPtr(false)},
},
{
name: "enabling remote jobs unprivileged is refused",
stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(false)},
change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)},
wantDeny: true,
},
{
name: "enabling remote jobs as root is allowed",
stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(false)},
change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)},
privileged: true,
},
{
name: "a profile with no config yet counts as off, so enabling remote jobs is refused",
stored: nil,
change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)},
wantDeny: true,
},
{
name: "restating already-enabled remote jobs is not a change",
stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(true)},
change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)},
},
{
name: "turning remote jobs off is not guarded",
stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(true)},
change: privilegedConfigChange{remoteJobsAllowed: boolPtr(false)},
},
{
name: "a request that touches none of the guarded fields is allowed",
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
@@ -194,6 +224,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)}