mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-12 16:39:09 +02:00
[Client] Surface readable Authz errors and add profile claim command (#7540)
* [client] Surface error messages for IPC authz in UI (#7553)
This commit is contained in:
+25
-16
@@ -38,17 +38,20 @@ type LogLevel struct {
|
||||
}
|
||||
|
||||
type Debug struct {
|
||||
conn DaemonConn
|
||||
conn DaemonConn
|
||||
classifier errorClassifier
|
||||
}
|
||||
|
||||
func NewDebug(conn DaemonConn) *Debug {
|
||||
return &Debug{conn: conn}
|
||||
// NewDebug wires up a Debug service. translator or prefs may be nil, in which
|
||||
// case classification falls back to the bare error key.
|
||||
func NewDebug(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Debug {
|
||||
return &Debug{conn: conn, classifier: errorClassifier{translator: translator, prefs: prefs}}
|
||||
}
|
||||
|
||||
func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleResult, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return DebugBundleResult{}, err
|
||||
return DebugBundleResult{}, s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.DebugBundle(ctx, &proto.DebugBundleRequest{
|
||||
Anonymize: p.Anonymize,
|
||||
@@ -59,7 +62,7 @@ func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleRes
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
})
|
||||
if err != nil {
|
||||
return DebugBundleResult{}, err
|
||||
return DebugBundleResult{}, s.classifier.classify(err)
|
||||
}
|
||||
return DebugBundleResult{
|
||||
Path: resp.GetPath(),
|
||||
@@ -71,11 +74,11 @@ func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleRes
|
||||
func (s *Debug) GetLogLevel(ctx context.Context) (LogLevel, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return LogLevel{}, err
|
||||
return LogLevel{}, s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.GetLogLevel(ctx, &proto.GetLogLevelRequest{})
|
||||
if err != nil {
|
||||
return LogLevel{}, err
|
||||
return LogLevel{}, s.classifier.classify(err)
|
||||
}
|
||||
return LogLevel{Level: resp.GetLevel().String()}, nil
|
||||
}
|
||||
@@ -104,29 +107,33 @@ func (s *Debug) RegisterUILog(ctx context.Context, path string) error {
|
||||
func (s *Debug) StartBundleCapture(ctx context.Context, timeoutSeconds int32) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
req := &proto.StartBundleCaptureRequest{}
|
||||
if timeoutSeconds > 0 {
|
||||
req.Timeout = durationpb.New(time.Duration(timeoutSeconds) * time.Second)
|
||||
}
|
||||
_, err = cli.StartBundleCapture(ctx, req)
|
||||
return err
|
||||
if _, err := cli.StartBundleCapture(ctx, req); err != nil {
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Debug) StopBundleCapture(ctx context.Context) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
_, err = cli.StopBundleCapture(ctx, &proto.StopBundleCaptureRequest{})
|
||||
return err
|
||||
if _, err := cli.StopBundleCapture(ctx, &proto.StopBundleCaptureRequest{}); err != nil {
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
// proto.LogLevel_value keys are upper-case enum names; callers pass
|
||||
// lowercase logrus names. Upper-case before lookup or a valid level
|
||||
@@ -135,6 +142,8 @@ func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error {
|
||||
if !ok {
|
||||
level = int32(proto.LogLevel_INFO)
|
||||
}
|
||||
_, err = cli.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel(level)})
|
||||
return err
|
||||
if _, err := cli.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel(level)}); err != nil {
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
gcodes "google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
@@ -15,19 +14,27 @@ import (
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error
|
||||
// carries one.
|
||||
func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) {
|
||||
for _, detail := range gstatus.Convert(err).Details() {
|
||||
info, ok := detail.(*errdetails.ErrorInfo)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain {
|
||||
return info, true
|
||||
}
|
||||
// denialCode maps a refusal to the code the frontend presents it by, reporting
|
||||
// false for a reason this build does not know. An unknown reason keeps the
|
||||
// summary the daemon wrote and loses only the tailored presentation, which is
|
||||
// what makes adding a reason daemon-side safe.
|
||||
func denialCode(reason string) (string, bool) {
|
||||
switch reason {
|
||||
case ipcauth.ErrorReasonPrivilegeRequired:
|
||||
return "privilege_required", true
|
||||
case ipcauth.ErrorReasonSessionHeld:
|
||||
return "session_held", true
|
||||
case ipcauth.ErrorReasonNotProfileOwner:
|
||||
return "not_profile_owner", true
|
||||
default:
|
||||
return "permission_denied", false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// privilegeRefused reports whether the daemon refused for want of privileges.
|
||||
func privilegeRefused(err error) bool {
|
||||
denial, ok := ipcauth.DenialFrom(err)
|
||||
return ok && denial.Reason == ipcauth.ErrorReasonPrivilegeRequired
|
||||
}
|
||||
|
||||
// ErrorTranslator localises daemon errors; runtime impl is *i18n.Bundle.
|
||||
@@ -40,9 +47,10 @@ type LanguagePreference interface {
|
||||
Get() preferences.UIPreferences
|
||||
}
|
||||
|
||||
// ClientError is a structured error returned to the frontend. The frontend
|
||||
// translates Code via i18n; Short is an English fallback; Long carries the
|
||||
// unwrapped daemon message.
|
||||
// ClientError is a structured error returned to the frontend. Short is the
|
||||
// localised headline, Long the unwrapped daemon message shown under it, and Code
|
||||
// the stable identifier Short was resolved from. The frontend reads Short, Long
|
||||
// and Command; it does not translate Code itself.
|
||||
type ClientError struct {
|
||||
Code string `json:"code"`
|
||||
Short string `json:"short"`
|
||||
@@ -94,21 +102,8 @@ func (c errorClassifier) classify(err error) *ClientError {
|
||||
grpcCode = st.Code()
|
||||
}
|
||||
|
||||
// A refusal for want of privileges carries its own summary and the command
|
||||
// that performs the operation, both written for the user. Surface them
|
||||
// verbatim: no substring guessing, and no localisation of a message the
|
||||
// daemon composed.
|
||||
if info, ok := privilegeErrorInfo(err); ok {
|
||||
summary := info.GetMetadata()[ipcauth.ErrorMetaSummary]
|
||||
if summary == "" {
|
||||
summary = msg
|
||||
}
|
||||
return &ClientError{
|
||||
Code: "privilege_required",
|
||||
Short: summary,
|
||||
Long: summary,
|
||||
Command: info.GetMetadata()[ipcauth.ErrorMetaCommand],
|
||||
}
|
||||
if denial, ok := ipcauth.DenialFrom(err); ok {
|
||||
return c.classifyDenial(denial)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(msg)
|
||||
@@ -155,6 +150,24 @@ func (c errorClassifier) classify(err error) *ClientError {
|
||||
}
|
||||
}
|
||||
|
||||
// classifyDenial presents a refusal the daemon explained: a localised headline
|
||||
// for the reasons this build knows, with the daemon's own sentence as the
|
||||
// detail the frontend shows under it. An unrecognised reason keeps that sentence
|
||||
// as the headline too, so a reason added daemon-side still reaches the user.
|
||||
func (c errorClassifier) classifyDenial(denial ipcauth.Denial) *ClientError {
|
||||
code, known := denialCode(denial.Reason)
|
||||
short := denial.Summary
|
||||
if known {
|
||||
short = c.translateShort(code)
|
||||
}
|
||||
return &ClientError{
|
||||
Code: code,
|
||||
Short: short,
|
||||
Long: denial.Summary,
|
||||
Command: denial.Command,
|
||||
}
|
||||
}
|
||||
|
||||
// translateShort resolves the localised short message for code, returning the
|
||||
// bare "error.<code>" key when no translation is available so the gap stays visible.
|
||||
func (c errorClassifier) translateShort(code string) string {
|
||||
|
||||
@@ -4,11 +4,17 @@ package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
gcodes "google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
)
|
||||
|
||||
func TestErrorClassifier_Classify(t *testing.T) {
|
||||
@@ -48,3 +54,106 @@ func TestErrorClassifier_Classify(t *testing.T) {
|
||||
require.Nil(t, c.classify(nil))
|
||||
})
|
||||
}
|
||||
|
||||
// Every reason the daemon explains gets its own code, and the headline is looked
|
||||
// up from that code rather than repeating the daemon's sentence, so a held
|
||||
// session reads differently from a privilege refusal.
|
||||
func TestClassifyMapsEveryDaemonReason(t *testing.T) {
|
||||
c := errorClassifier{} // nil translator → Short is the bare "error.<code>" key
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
code string
|
||||
command bool
|
||||
}{
|
||||
{"privilege", ipcauth.PrivilegeError("Claiming a profile requires root.", "sudo netbird profile claim"), "privilege_required", true},
|
||||
{"session held", ipcauth.SessionHeldError("switching profile"), "session_held", true},
|
||||
{"not owner", ipcauth.NotOwnerError("reading the profile configuration"), "not_profile_owner", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := c.classify(tc.err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, tc.code, got.Code)
|
||||
assert.Equal(t, "error."+tc.code, got.Short, "Short comes from the locale bundle, not the daemon")
|
||||
assert.NotEmpty(t, got.Long, "the daemon's sentence has to reach the user")
|
||||
assert.NotContains(t, got.Long, "rpc error")
|
||||
assert.Equal(t, tc.command, got.Command != "")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Every denial code needs an entry in the shipped bundle, or the dialog shows a
|
||||
// bare "error.<code>" key where the headline should be. Resolved against the real
|
||||
// locale tree so a reason added without a translation fails here, not on screen.
|
||||
func TestDenialHeadlinesResolveInTheShippedBundle(t *testing.T) {
|
||||
bundle, err := i18n.NewBundle(os.DirFS("../i18n/locales"))
|
||||
require.NoError(t, err, "the shipped locale tree must load")
|
||||
|
||||
c := errorClassifier{translator: bundle}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
short string
|
||||
long string
|
||||
}{
|
||||
{
|
||||
"privilege",
|
||||
ipcauth.PrivilegeError("Claiming a profile requires root.", "sudo netbird profile claim"),
|
||||
"This action requires elevated privileges.",
|
||||
"Claiming a profile requires root.",
|
||||
},
|
||||
{
|
||||
"session held",
|
||||
ipcauth.SessionHeldError("switching profile"),
|
||||
"Another user has this machine connected.",
|
||||
"Switching profile is refused while another user has this machine connected.",
|
||||
},
|
||||
{
|
||||
"not owner",
|
||||
ipcauth.NotOwnerError("reading the profile configuration"),
|
||||
"This profile belongs to another user.",
|
||||
"Reading the profile configuration is refused because the profile it addresses belongs to another user.",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := c.classify(tc.err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, tc.short, got.Short, "Short should be the localised headline for the code")
|
||||
assert.Contains(t, got.Long, tc.long, "Long should carry the daemon's own sentence")
|
||||
assert.NotEqual(t, got.Short, got.Long, "a repeated sentence costs the frontend its detail line")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A reason added daemon-side must still reach the user, losing only the tailored
|
||||
// presentation. It must not borrow another code's headline: "permission_denied"
|
||||
// is the sign-in rejection, which has nothing to do with an IPC refusal.
|
||||
func TestClassifyKeepsTheSentenceForAnUnknownReason(t *testing.T) {
|
||||
code, known := denialCode("SOMETHING_NEW")
|
||||
assert.False(t, known, "an unrecognised reason must not claim a tailored headline")
|
||||
assert.Equal(t, "permission_denied", code)
|
||||
|
||||
const summary = "Doing something new is refused for a reason this build predates."
|
||||
st, err := gstatus.New(gcodes.PermissionDenied, summary).WithDetails(&errdetails.ErrorInfo{
|
||||
Reason: "SOMETHING_NEW",
|
||||
Domain: ipcauth.ErrorDomain,
|
||||
Metadata: map[string]string{ipcauth.ErrorMetaSummary: summary},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got := errorClassifier{}.classify(st.Err())
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, summary, got.Short, "the daemon's sentence stands in for the headline")
|
||||
assert.Equal(t, summary, got.Long)
|
||||
}
|
||||
|
||||
// Only a privilege refusal is answered by offering to elevate. Offering it for
|
||||
// a session another user holds would be nonsense.
|
||||
func TestPrivilegeRefusedIsNarrow(t *testing.T) {
|
||||
assert.True(t, privilegeRefused(ipcauth.PrivilegeError("x", "y")))
|
||||
assert.False(t, privilegeRefused(ipcauth.SessionHeldError("connecting")))
|
||||
assert.False(t, privilegeRefused(ipcauth.NotOwnerError("connecting")))
|
||||
assert.False(t, privilegeRefused(errors.New("connection refused")))
|
||||
}
|
||||
|
||||
@@ -54,11 +54,14 @@ type RenameProfileParams struct {
|
||||
}
|
||||
|
||||
type Profiles struct {
|
||||
conn DaemonConn
|
||||
conn DaemonConn
|
||||
classifier errorClassifier
|
||||
}
|
||||
|
||||
func NewProfiles(conn DaemonConn) *Profiles {
|
||||
return &Profiles{conn: conn}
|
||||
// NewProfiles wires up a Profiles service. translator or prefs may be nil, in
|
||||
// which case classification falls back to the bare error key.
|
||||
func NewProfiles(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Profiles {
|
||||
return &Profiles{conn: conn, classifier: errorClassifier{translator: translator, prefs: prefs}}
|
||||
}
|
||||
|
||||
// Username returns the OS username the daemon expects for profile lookups.
|
||||
@@ -73,11 +76,11 @@ func (s *Profiles) Username() (string, error) {
|
||||
func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, s.classifier.classify(err)
|
||||
}
|
||||
pm := profilemanager.NewProfileManager()
|
||||
out := make([]Profile, 0, len(resp.GetProfiles()))
|
||||
@@ -94,11 +97,11 @@ func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error)
|
||||
func (s *Profiles) GetActive(ctx context.Context) (ActiveProfile, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return ActiveProfile{}, err
|
||||
return ActiveProfile{}, s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{})
|
||||
if err != nil {
|
||||
return ActiveProfile{}, err
|
||||
return ActiveProfile{}, s.classifier.classify(err)
|
||||
}
|
||||
return ActiveProfile{
|
||||
ID: resp.GetId(),
|
||||
@@ -114,7 +117,7 @@ func (s *Profiles) GetActive(ctx context.Context) (ActiveProfile, error) {
|
||||
func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", s.classifier.classify(err)
|
||||
}
|
||||
req := &proto.SwitchProfileRequest{}
|
||||
if p.ProfileName != "" {
|
||||
@@ -125,7 +128,7 @@ func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) {
|
||||
}
|
||||
resp, err := cli.SwitchProfile(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", s.classifier.classify(err)
|
||||
}
|
||||
return resp.GetId(), nil
|
||||
}
|
||||
@@ -136,14 +139,14 @@ func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) {
|
||||
func (s *Profiles) Add(ctx context.Context, p ProfileRef) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.AddProfile(ctx, &proto.AddProfileRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", s.classifier.classify(err)
|
||||
}
|
||||
return resp.GetId(), nil
|
||||
}
|
||||
@@ -151,14 +154,14 @@ func (s *Profiles) Add(ctx context.Context, p ProfileRef) (string, error) {
|
||||
func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
|
||||
// The daemon deletes what it owns but runs as root, so it leaves the
|
||||
@@ -188,7 +191,7 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
|
||||
func (s *Profiles) Rename(ctx context.Context, p RenameProfileParams) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.RenameProfile(ctx, &proto.RenameProfileRequest{
|
||||
Username: p.Username,
|
||||
@@ -196,7 +199,7 @@ func (s *Profiles) Rename(ctx context.Context, p RenameProfileParams) (string, e
|
||||
NewProfileName: p.NewName,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", s.classifier.classify(err)
|
||||
}
|
||||
return resp.GetOldProfileName(), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"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/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// stubProfileDaemon refuses SwitchProfile the way the daemon refuses a caller
|
||||
// who does not hold the session. The embedded interface is nil, so any other
|
||||
// call panics rather than passing quietly.
|
||||
type stubProfileDaemon struct {
|
||||
proto.DaemonServiceClient
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *stubProfileDaemon) SwitchProfile(_ context.Context, _ *proto.SwitchProfileRequest, _ ...grpc.CallOption) (*proto.SwitchProfileResponse, error) {
|
||||
return nil, d.err
|
||||
}
|
||||
|
||||
// sessionHeldRefusal is the error the daemon raises when another user holds the
|
||||
// connection, detail and all: see ipcauth.SessionHeldError.
|
||||
func sessionHeldRefusal(t *testing.T) error {
|
||||
t.Helper()
|
||||
|
||||
st, err := gstatus.New(codes.PermissionDenied, sessionHeldSummaryText+"\n\nsudo netbird down").
|
||||
WithDetails(&errdetails.ErrorInfo{
|
||||
Reason: ipcauth.ErrorReasonSessionHeld,
|
||||
Domain: ipcauth.ErrorDomain,
|
||||
Metadata: map[string]string{
|
||||
ipcauth.ErrorMetaSummary: sessionHeldSummaryText,
|
||||
ipcauth.ErrorMetaCommand: "sudo netbird down",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "build the refusal detail")
|
||||
return st.Err()
|
||||
}
|
||||
|
||||
const sessionHeldSummaryText = "Switching profiles is refused while another user has this machine connected."
|
||||
|
||||
func profilesRefusingSwitch(t *testing.T) *Profiles {
|
||||
t.Helper()
|
||||
// nil translator → Short is the bare "error.<code>" key, which is enough to
|
||||
// tell a resolved headline from the daemon's own sentence.
|
||||
return NewProfiles(stubConn{client: &stubProfileDaemon{err: sessionHeldRefusal(t)}}, nil, nil)
|
||||
}
|
||||
|
||||
// A refused switch has to reach the caller as the classified value, since that is
|
||||
// the only thing carrying the headline and the command the frontend renders.
|
||||
func TestProfilesSwitchClassifiesRefusal(t *testing.T) {
|
||||
_, err := profilesRefusingSwitch(t).Switch(context.Background(), ProfileRef{ProfileName: "work"})
|
||||
|
||||
clientErr, ok := err.(*ClientError)
|
||||
require.True(t, ok, "Switch must return the classified error, got %T", err)
|
||||
assert.Equal(t, "session_held", clientErr.Code, "the refusal reason decides the code")
|
||||
assert.Equal(t, sessionHeldSummaryText, clientErr.Long, "the daemon's sentence is the detail")
|
||||
assert.Equal(t, "sudo netbird down", clientErr.Command, "the suggested command survives")
|
||||
}
|
||||
|
||||
// The switcher used to wrap this in fmt.Errorf, which left the Wails binding
|
||||
// nothing to marshal and put the raw "switch profile %q: rpc error: ..." string
|
||||
// in front of the user instead of the headline and the copyable command.
|
||||
func TestProfileSwitcherReturnsClassifiedRefusal(t *testing.T) {
|
||||
switcher := NewProfileSwitcher(profilesRefusingSwitch(t), nil, nil)
|
||||
|
||||
err := switcher.SwitchActive(context.Background(), ProfileRef{ProfileName: "01HZY0000000000000000000"})
|
||||
|
||||
clientErr, ok := err.(*ClientError)
|
||||
require.True(t, ok, "the switcher must pass the classified error through, got %T", err)
|
||||
assert.Equal(t, "session_held", clientErr.Code, "the refusal reason decides the code")
|
||||
assert.Equal(t, "sudo netbird down", clientErr.Command, "the suggested command survives")
|
||||
assert.Equal(t, "error.session_held", err.Error(),
|
||||
"no wrapping prefix and no gRPC dump in front of the headline")
|
||||
}
|
||||
@@ -4,7 +4,6 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -68,9 +67,12 @@ func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connec
|
||||
s.feed.BeginProfileSwitch()
|
||||
}
|
||||
|
||||
// Returned unwrapped: the Wails binding marshals the outermost error, so a
|
||||
// wrapper replaces the classified headline and the daemon's suggested
|
||||
// command with a raw gRPC string. The Infof above names the profile.
|
||||
resolvedID, err := s.profiles.Switch(ctx, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch profile %q: %w", p.ProfileName, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Mirror into the user-side ProfileManager state: the CLI's `netbird up`
|
||||
@@ -90,8 +92,9 @@ func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connec
|
||||
}
|
||||
|
||||
if connect {
|
||||
// Unwrapped for the same reason as the switch above.
|
||||
if err := s.connection.Up(ctx, UpParams(p)); err != nil {
|
||||
return fmt.Errorf("connect %q: %w", p.ProfileName, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,14 +133,16 @@ func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePref
|
||||
func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
return Config{}, s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.GetConfig(ctx, &proto.GetConfigRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
// Reading another user's profile is refused here, and the settings
|
||||
// screen puts the result straight in front of the user.
|
||||
return Config{}, s.classifier.classify(err)
|
||||
}
|
||||
return Config{
|
||||
ManagementURL: resp.GetManagementUrl(),
|
||||
@@ -175,7 +177,7 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
|
||||
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcome, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return SaveOutcome{}, err
|
||||
return SaveOutcome{}, s.classifier.classify(err)
|
||||
}
|
||||
req := &proto.SetConfigRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
@@ -207,7 +209,7 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcom
|
||||
SshJWTCacheTTL: p.SSHJWTCacheTTL,
|
||||
}
|
||||
if _, err := cli.SetConfig(ctx, req); err != nil {
|
||||
if _, refused := privilegeErrorInfo(err); refused {
|
||||
if privilegeRefused(err) {
|
||||
return s.setConfigElevated(ctx, p, req, err)
|
||||
}
|
||||
// Classified so the frontend gets the daemon's guidance instead of the
|
||||
@@ -251,7 +253,7 @@ func (s *Settings) setConfigElevated(ctx context.Context, p SetConfigParams, req
|
||||
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return SaveOutcome{}, err
|
||||
return SaveOutcome{}, s.classifier.classify(err)
|
||||
}
|
||||
if _, err := cli.SetConfig(ctx, req); err != nil {
|
||||
return SaveOutcome{}, s.classifier.classify(err)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"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/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
const notOwnerSummaryText = "Reading the profile configuration is refused because the profile it addresses belongs to another user."
|
||||
|
||||
// stubConfigDaemon refuses GetConfig the way the daemon refuses a caller who
|
||||
// does not own the profile the request names. The embedded interface is nil, so
|
||||
// any other call panics rather than passing quietly.
|
||||
type stubConfigDaemon struct {
|
||||
proto.DaemonServiceClient
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *stubConfigDaemon) GetConfig(_ context.Context, _ *proto.GetConfigRequest, _ ...grpc.CallOption) (*proto.GetConfigResponse, error) {
|
||||
return nil, d.err
|
||||
}
|
||||
|
||||
// notOwnerRefusal is the error the gate raises for a profile owned by somebody
|
||||
// else: see ipcauth.NotOwnerError. It carries no command on purpose — privilege
|
||||
// is not what the method asked for.
|
||||
func notOwnerRefusal(t *testing.T) error {
|
||||
t.Helper()
|
||||
|
||||
st, err := gstatus.New(codes.PermissionDenied, notOwnerSummaryText).
|
||||
WithDetails(&errdetails.ErrorInfo{
|
||||
Reason: ipcauth.ErrorReasonNotProfileOwner,
|
||||
Domain: ipcauth.ErrorDomain,
|
||||
Metadata: map[string]string{ipcauth.ErrorMetaSummary: notOwnerSummaryText},
|
||||
})
|
||||
require.NoError(t, err, "build the refusal detail")
|
||||
return st.Err()
|
||||
}
|
||||
|
||||
// The settings screen reads the config on mount and shows whatever comes back,
|
||||
// so an unclassified refusal there is a raw gRPC dump in front of the user.
|
||||
func TestSettingsGetConfigClassifiesRefusal(t *testing.T) {
|
||||
// nil translator → Short is the bare "error.<code>" key.
|
||||
settings := NewSettings(stubConn{client: &stubConfigDaemon{err: notOwnerRefusal(t)}}, nil, nil, testDaemonAddr)
|
||||
|
||||
_, err := settings.GetConfig(context.Background(), ConfigParams{ProfileName: "01HZY0000000000000000000"})
|
||||
|
||||
clientErr, ok := err.(*ClientError)
|
||||
require.True(t, ok, "GetConfig must return the classified error, got %T", err)
|
||||
assert.Equal(t, "not_profile_owner", clientErr.Code, "the refusal reason decides the code")
|
||||
assert.Equal(t, notOwnerSummaryText, clientErr.Long, "the daemon's sentence is the detail")
|
||||
assert.Empty(t, clientErr.Command, "this refusal has no command to offer")
|
||||
}
|
||||
@@ -22,12 +22,15 @@ type UpdateResult struct {
|
||||
// Update is the Wails-bound facade over the daemon's update RPCs. The state
|
||||
// machine and push event live in client/ui/updater.
|
||||
type Update struct {
|
||||
conn DaemonConn
|
||||
holder *updater.Holder
|
||||
conn DaemonConn
|
||||
holder *updater.Holder
|
||||
classifier errorClassifier
|
||||
}
|
||||
|
||||
func NewUpdate(conn DaemonConn, holder *updater.Holder) *Update {
|
||||
return &Update{conn: conn, holder: holder}
|
||||
// NewUpdate wires up an Update service. translator or prefs may be nil, in
|
||||
// which case classification falls back to the bare error key.
|
||||
func NewUpdate(conn DaemonConn, holder *updater.Holder, translator ErrorTranslator, prefs LanguagePreference) *Update {
|
||||
return &Update{conn: conn, holder: holder, classifier: errorClassifier{translator: translator, prefs: prefs}}
|
||||
}
|
||||
|
||||
func (s *Update) GetState() updater.State {
|
||||
@@ -52,11 +55,11 @@ func (s *Update) Quit() {
|
||||
func (s *Update) Trigger(ctx context.Context) (UpdateResult, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return UpdateResult{}, err
|
||||
return UpdateResult{}, s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.TriggerUpdate(ctx, &proto.TriggerUpdateRequest{})
|
||||
if err != nil {
|
||||
return UpdateResult{}, err
|
||||
return UpdateResult{}, s.classifier.classify(err)
|
||||
}
|
||||
return UpdateResult{
|
||||
Success: resp.GetSuccess(),
|
||||
|
||||
@@ -269,7 +269,15 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
|
||||
// settingsWindowURL is the start URL for a settings window showing tab. The tab
|
||||
// travels in the URL so the first render already has it. EventSettingsOpen
|
||||
// reaches the frontend only after it reports ready, by which point a tab that
|
||||
// reads the daemon config has mounted and sent its read.
|
||||
func settingsWindowURL(tab string) string {
|
||||
return "/#/settings?tab=" + url.QueryEscape(tab)
|
||||
}
|
||||
|
||||
func (s *WindowManager) newSettingsWindow(tab string) *application.WebviewWindow {
|
||||
a := CurrentAppearance()
|
||||
w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: windowSettings,
|
||||
@@ -282,7 +290,7 @@ func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
CloseButtonState: application.ButtonEnabled,
|
||||
BackgroundColour: WindowBackgroundColour(a),
|
||||
URL: "/#/settings",
|
||||
URL: settingsWindowURL(tab),
|
||||
Mac: AppleMacOSAppearanceOptions(a),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(a),
|
||||
Linux: LinuxAppearanceOptions(s.linuxIcon),
|
||||
@@ -305,7 +313,8 @@ func (s *WindowManager) OpenSettings(tab string) {
|
||||
target = "general"
|
||||
}
|
||||
|
||||
s.withWindow(windowSettings, &s.settings, s.newSettingsWindow, func(w *application.WebviewWindow, _ bool) {
|
||||
factory := func() *application.WebviewWindow { return s.newSettingsWindow(target) }
|
||||
s.withWindow(windowSettings, &s.settings, factory, func(w *application.WebviewWindow, _ bool) {
|
||||
s.mu.Lock()
|
||||
ready := s.ready[w.ID()]
|
||||
if !ready {
|
||||
|
||||
@@ -348,3 +348,12 @@ func TestCloseRenewFlowDuringBrowserLoginCreationRestoresHiddenWindows(t *testin
|
||||
require.Empty(t, s.creating)
|
||||
require.Empty(t, s.pendingClose)
|
||||
}
|
||||
|
||||
// The settings window opens on whichever tab the caller asked for, so a tab that
|
||||
// does not read the daemon configuration never mounts the one that does.
|
||||
func TestSettingsWindowURLCarriesTab(t *testing.T) {
|
||||
require.Equal(t, "/#/settings?tab=profiles", settingsWindowURL("profiles"))
|
||||
require.Equal(t, "/#/settings?tab=general", settingsWindowURL("general"))
|
||||
require.Equal(t, "/#/settings?tab=a%2Fb+c", settingsWindowURL("a/b c"),
|
||||
"a tab name is escaped rather than trusted to be URL-safe")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user