//go:build !android && !ios && !freebsd && !js package services import ( "context" "errors" "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc" "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal/elevate" "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/proto" ) // A Unix socket, so the daemon address is one that carries a caller's identity and // elevation is worth offering at all: see Settings.canElevate. const testDaemonAddr = "unix:///var/run/netbird.sock" // storedManagementURL is what the stub daemon already holds, so that a request // naming a different one is a change: see Settings.guardedChanges. const storedManagementURL = "https://stored.example.com" // stubElevator stands in for the platform's prompt: it records what would have run // and answers with a fixed outcome. type stubElevator struct { outcome error available bool calls [][]string } func (e *stubElevator) Run(_ context.Context, args ...string) error { e.calls = append(e.calls, args) return e.outcome } func (e *stubElevator) Available() bool { return e.available } // stubDaemon implements only the RPCs under test. The embedded interface is nil, so // any other call panics rather than passing quietly. type stubDaemon struct { proto.DaemonServiceClient setConfig func(*proto.SetConfigRequest) error // stored is what GetConfig reports, which is what a refused request's guarded // settings are compared against. stored *proto.GetConfigResponse requests []*proto.SetConfigRequest } func (d *stubDaemon) SetConfig(_ context.Context, in *proto.SetConfigRequest, _ ...grpc.CallOption) (*proto.SetConfigResponse, error) { d.requests = append(d.requests, in) if err := d.setConfig(in); err != nil { return nil, err } return &proto.SetConfigResponse{}, nil } func (d *stubDaemon) GetConfig(_ context.Context, _ *proto.GetConfigRequest, _ ...grpc.CallOption) (*proto.GetConfigResponse, error) { return d.stored, nil } type stubConn struct{ client proto.DaemonServiceClient } func (c stubConn) Client() (proto.DaemonServiceClient, error) { return c.client, nil } // privilegeRefusal is the error the daemon raises for a change it restricts to // root, detail and all: see server.privilegeError. func privilegeRefusal(t *testing.T) error { t.Helper() st, err := gstatus.New(codes.PermissionDenied, "Changing the management URL requires root."). WithDetails(&errdetails.ErrorInfo{ Reason: ipcauth.ErrorReasonPrivilegeRequired, Domain: ipcauth.ErrorDomain, Metadata: map[string]string{ ipcauth.ErrorMetaSummary: "Changing the management URL requires root.", ipcauth.ErrorMetaCommand: "sudo netbird down; sudo netbird up -m https://mgmt.example.com", }, }) require.NoError(t, err, "build the refusal detail") return st.Err() } func settingsWithElevation(t *testing.T, outcome error) (*Settings, *stubElevator) { t.Helper() elev := &stubElevator{outcome: outcome, available: true} return &Settings{daemonAddr: testDaemonAddr, elevator: elev}, elev } // settingsRefusingOnce returns a Settings whose daemon refuses the first SetConfig // for want of privileges and accepts anything after it. Its stored config holds // another management server and no SSH grants, so a request naming either is a // change rather than a restatement. func settingsRefusingOnce(t *testing.T, elev *stubElevator) (*Settings, *stubDaemon) { t.Helper() refusal := privilegeRefusal(t) daemon := &stubDaemon{stored: &proto.GetConfigResponse{ManagementUrl: storedManagementURL}} daemon.setConfig = func(*proto.SetConfigRequest) error { if len(daemon.requests) == 1 { return refusal } return nil } return &Settings{conn: stubConn{client: daemon}, daemonAddr: testDaemonAddr, elevator: elev}, daemon } func TestSetGuardedSettingsPassesOnlyTheChangedSettings(t *testing.T) { s, elev := settingsWithElevation(t, nil) root := true outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ ProfileName: "work", Username: "vma", EnableSSHRoot: &root, }) require.NoError(t, err) assert.False(t, outcome.Declined, "the prompt was answered") want := []string{ "--" + FlagApplyPrivilegedSettings, "--" + FlagDaemonAddr, testDaemonAddr, "--" + FlagProfile, "work", "--" + FlagUser, "vma", "--" + FlagLogLevel, log.GetLevel().String(), "--" + FlagEnableSSHRoot + "=true", } require.Len(t, elev.calls, 1, "one prompt for one change") assert.Equal(t, want, elev.calls[0], "elevated arguments") // argv[1] is what the polkit action is pinned to, so the marker has to stay // first however the rest of the line grows. assert.Equal(t, "--"+FlagApplyPrivilegedSettings, elev.calls[0][0], "the flag polkit matches on") } // Turning a setting off has to be as explicit as turning it on: a bare flag would // read as "on" to the one-shot's parser. func TestSetGuardedSettingsSpellsOutFalse(t *testing.T) { s, elev := settingsWithElevation(t, nil) off := false _, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ ProfileName: "default", ServerSSHAllowed: &off, DisableSSHAuth: &off, }) require.NoError(t, err) args := elev.calls[0] assert.Contains(t, args, "--"+FlagAllowServerSSH+"=false", "the setting being switched off") assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=false", "the setting being switched off") assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "no flag for a setting nobody touched") } func TestSetGuardedSettingsPassesTheManagementURL(t *testing.T) { s, elev := settingsWithElevation(t, nil) _, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ ProfileName: "default", ManagementURL: "https://mgmt.example.com:33073", }) require.NoError(t, err) assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com:33073", "the management URL to point the profile at") } func TestSetGuardedSettingsWithoutASettingDoesNotElevate(t *testing.T) { s, elev := settingsWithElevation(t, nil) _, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ProfileName: "default"}) require.Error(t, err, "nothing to apply is not something to prompt for") assert.Empty(t, elev.calls, "no prompt at all") } // A declined prompt is the one ending that is not an error: reporting it as one // would have every cancelled prompt logged as a failure. func TestSetGuardedSettingsReportsADeclinedPromptAsAnOutcome(t *testing.T) { s, _ := settingsWithElevation(t, elevate.ErrDeclined) root := true outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ ProfileName: "default", EnableSSHRoot: &root, }) require.NoError(t, err, "the user was asked and answered; nothing went wrong") assert.True(t, outcome.Declined, "nothing was applied") } func TestSetGuardedSettingsMapsFailures(t *testing.T) { tests := []struct { name string outcome error wantCode string }{ { // Nothing to raise a prompt with: the user needs the command. name: "no mechanism falls back to the command", outcome: elevate.ErrUnavailable, wantCode: CodeElevationUnavailable, }, { name: "a failed run falls back to the command", outcome: errors.New("elevated netbird exited with 1"), wantCode: CodeElevationFailed, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s, _ := settingsWithElevation(t, tt.outcome) root := true _, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ ProfileName: "default", EnableSSHRoot: &root, }) var clientErr *ClientError require.ErrorAs(t, err, &clientErr, "the frontend needs a code to act on") assert.Equal(t, tt.wantCode, clientErr.Code, "error code") assert.Contains(t, clientErr.Command, "--"+FlagEnableSSHRoot+"=true", "the setting in the fallback command") assert.Contains(t, clientErr.Command, "netbird up", "the fallback command") }) } } // Changing the management URL is only privileged while the host runs the SSH // server, which no control can know up front, so the refusal is what triggers the // prompt. The original request goes again afterwards, so the fields the one-shot // does not understand are applied too. func TestSetConfigElevatesAfterARefusalAndRetries(t *testing.T) { elev := &stubElevator{available: true} s, daemon := settingsRefusingOnce(t, elev) mtu := int64(1280) outcome, err := s.SetConfig(context.Background(), SetConfigParams{ ProfileName: "default", ManagementURL: "https://mgmt.example.com", MTU: &mtu, }) require.NoError(t, err) assert.False(t, outcome.Declined, "the prompt was answered") require.Len(t, elev.calls, 1, "one prompt") assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com", "the guarded part of the request") require.Len(t, daemon.requests, 2, "the refused request and the retry") assert.Equal(t, mtu, daemon.requests[1].GetMtu(), "the retry carries the rest of the request, which the one-shot does not understand") } func TestSetConfigDoesNotRetryWhenTheUserDeclines(t *testing.T) { elev := &stubElevator{outcome: elevate.ErrDeclined, available: true} s, daemon := settingsRefusingOnce(t, elev) outcome, err := s.SetConfig(context.Background(), SetConfigParams{ ProfileName: "default", ManagementURL: "https://mgmt.example.com", }) require.NoError(t, err, "a declined prompt is not an error") assert.True(t, outcome.Declined, "nothing was applied") assert.Len(t, daemon.requests, 1, "only the refused request") } // With no prompt to raise, the refusal is reported as the daemon wrote it, which is // the guidance that was there before elevation existed. func TestSetConfigReportsTheRefusalWhenItCannotElevate(t *testing.T) { elev := &stubElevator{available: false} s, _ := settingsRefusingOnce(t, elev) _, err := s.SetConfig(context.Background(), SetConfigParams{ ProfileName: "default", ManagementURL: "https://mgmt.example.com", }) var clientErr *ClientError require.ErrorAs(t, err, &clientErr) assert.Equal(t, "privilege_required", clientErr.Code, "error code") assert.Contains(t, clientErr.Command, "netbird up -m https://mgmt.example.com", "the daemon's own command") assert.Empty(t, elev.calls, "no prompt where there is none to raise") } // One authorization must buy only the change the user made. A settings form // submits every field it holds, so most of a refused request restates what the // daemon already has, and elevating those too would apply a guarded setting the // user never touched — a value gone stale since the form loaded above all. func TestSetConfigElevatesOnlyTheGuardedSettingsThatChange(t *testing.T) { elev := &stubElevator{available: true} s, _ := settingsRefusingOnce(t, elev) on, off := true, false _, err := s.SetConfig(context.Background(), SetConfigParams{ ProfileName: "default", ManagementURL: storedManagementURL, ServerSSHAllowed: &off, EnableSSHRoot: &off, DisableSSHAuth: &on, }) require.NoError(t, err) require.Len(t, elev.calls, 1, "one prompt") args := elev.calls[0] assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=true", "the setting that changes") assert.NotContains(t, args, "--"+FlagManagementURL+"="+storedManagementURL, "a management URL the daemon already holds") assert.NotContains(t, args, "--"+FlagAllowServerSSH+"=false", "a setting already off") assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "a setting already off") } // A request that changes no guarded setting has nothing an elevated run could // apply, so the refusal must have come from somewhere a prompt cannot reach. func TestSetConfigDoesNotElevateWhenNoGuardedSettingChanges(t *testing.T) { elev := &stubElevator{available: true} s, _ := settingsRefusingOnce(t, elev) off := false _, err := s.SetConfig(context.Background(), SetConfigParams{ ProfileName: "default", ManagementURL: storedManagementURL, ServerSSHAllowed: &off, }) var clientErr *ClientError require.ErrorAs(t, err, &clientErr) assert.Equal(t, "privilege_required", clientErr.Code, "error code") assert.Empty(t, elev.calls, "no prompt for a change nobody made") } // A refusal with nothing in the request the one-shot could apply: the daemon // cannot see who is calling, and being root would not help either. func TestSetConfigReportsARefusalWithNothingToElevate(t *testing.T) { elev := &stubElevator{available: true} s, _ := settingsRefusingOnce(t, elev) _, err := s.SetConfig(context.Background(), SetConfigParams{ProfileName: "default"}) var clientErr *ClientError require.ErrorAs(t, err, &clientErr) assert.Equal(t, "privilege_required", clientErr.Code, "error code") assert.Empty(t, elev.calls, "no prompt") }