From cec9ee66996968c1cac059d6085c9cf70426f10e Mon Sep 17 00:00:00 2001 From: Theodor Midtlien Date: Thu, 17 Sep 2026 14:36:56 +0200 Subject: [PATCH] [Client] Surface readable Authz errors and add profile claim command (#7540) * [client] Surface error messages for IPC authz in UI (#7553) --- client/cmd/daemon_error.go | 131 ++-- client/cmd/daemon_error_test.go | 240 +++++++ client/cmd/down.go | 14 +- client/cmd/expose.go | 3 +- client/cmd/login.go | 4 +- client/cmd/profile.go | 109 ++- client/cmd/profile_claim_test.go | 29 + client/cmd/root.go | 18 +- client/cmd/up.go | 5 +- client/cmd/update_supported.go | 4 +- client/internal/ipcauth/authz_gate.go | 44 +- client/internal/ipcauth/authz_gate_test.go | 139 ++++ client/internal/ipcauth/authz_level.go | 44 +- client/internal/ipcauth/identity.go | 63 ++ client/internal/ipcauth/methods.go | 118 ++-- .../ipcauth/principal_validate_test.go | 79 +++ .../internal/ipcauth/privilege_denial_test.go | 248 +++++++ client/internal/ipcauth/privileged.go | 146 ++++ client/internal/profilemanager/migration.go | 6 +- client/internal/profilemanager/service.go | 22 + .../internal/profilemanager/service_test.go | 110 +++ client/proto/daemon.pb.go | 645 +++++++++++------- client/proto/daemon.pb.gw.go | 63 ++ client/proto/daemon.proto | 18 + client/proto/daemon_grpc.pb.go | 38 ++ client/server/claim_profile_test.go | 185 +++++ client/server/server.go | 121 +++- client/server/server_ownsprofile_test.go | 22 +- client/server/setconfig_mdm_test.go | 6 +- client/server/ssh_gate.go | 46 +- .../src/components/LanguagePicker.tsx | 7 +- .../src/contexts/ClientVersionContext.tsx | 17 +- .../src/contexts/DebugBundleContext.tsx | 7 +- .../frontend/src/contexts/ProfileContext.tsx | 20 +- .../frontend/src/contexts/SettingsContext.tsx | 47 +- client/ui/frontend/src/hooks/useGuiVersion.ts | 27 + client/ui/frontend/src/lib/connection.ts | 7 +- client/ui/frontend/src/lib/errors.ts | 35 +- .../login/LoginWaitingForBrowserDialog.tsx | 7 +- .../main/MainConnectionStatusSwitch.tsx | 19 +- .../src/modules/profiles/ProfileDropdown.tsx | 7 +- .../src/modules/profiles/ProfilesTab.tsx | 7 +- .../session/SessionExpirationDialog.tsx | 17 +- .../src/modules/settings/SettingsAbout.tsx | 4 +- .../src/modules/settings/SettingsPage.tsx | 30 +- .../src/modules/welcome/WelcomeDialog.tsx | 7 +- client/ui/i18n/locales/de/common.json | 12 + client/ui/i18n/locales/en/common.json | 16 + client/ui/i18n/locales/es/common.json | 12 + client/ui/i18n/locales/fr/common.json | 12 + client/ui/i18n/locales/hu/common.json | 12 + client/ui/i18n/locales/it/common.json | 12 + client/ui/i18n/locales/ja/common.json | 12 + client/ui/i18n/locales/pt/common.json | 12 + client/ui/i18n/locales/ru/common.json | 12 + client/ui/i18n/locales/uk/common.json | 12 + client/ui/i18n/locales/zh-CN/common.json | 12 + client/ui/main.go | 6 +- client/ui/services/debug.go | 41 +- client/ui/services/errors.go | 75 +- client/ui/services/errors_test.go | 109 +++ client/ui/services/profile.go | 33 +- client/ui/services/profile_error_test.go | 85 +++ client/ui/services/profileswitcher.go | 9 +- client/ui/services/settings.go | 12 +- client/ui/services/settings_error_test.go | 63 ++ client/ui/services/update.go | 15 +- client/ui/services/windowmanager.go | 15 +- client/ui/services/windowmanager_test.go | 9 + 69 files changed, 2912 insertions(+), 681 deletions(-) create mode 100644 client/cmd/daemon_error_test.go create mode 100644 client/cmd/profile_claim_test.go create mode 100644 client/internal/ipcauth/authz_gate_test.go create mode 100644 client/internal/ipcauth/principal_validate_test.go create mode 100644 client/internal/ipcauth/privilege_denial_test.go create mode 100644 client/server/claim_profile_test.go create mode 100644 client/ui/frontend/src/hooks/useGuiVersion.ts create mode 100644 client/ui/services/profile_error_test.go create mode 100644 client/ui/services/settings_error_test.go diff --git a/client/cmd/daemon_error.go b/client/cmd/daemon_error.go index 0d5b1307e..5c256774f 100644 --- a/client/cmd/daemon_error.go +++ b/client/cmd/daemon_error.go @@ -1,66 +1,115 @@ package cmd import ( + "context" "errors" "fmt" - "strings" - "google.golang.org/genproto/googleapis/rpc/errdetails" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal/ipcauth" ) -// daemonCallError prepares a daemon error for display. A refusal the daemon -// raised because the operation needs root/administrator is already guidance -// written for the user, so it is surfaced on its own instead of buried under the -// gRPC envelope and the name of the RPC that hit it. Anything else is wrapped -// with context as usual. +// daemonCallError adds the context a failed daemon call happened in. func daemonCallError(context string, err error) error { - if guidance, ok := privilegeGuidance(err); ok { - return errors.New(guidance) - } return fmt.Errorf("%s: %w", context, err) } -// privilegeGuidance renders the daemon's privilege refusal as a summary and the -// command that performs the operation with the privileges it needs. It reports -// false for any other error. -func privilegeGuidance(err error) (string, bool) { - info, ok := privilegeErrorInfo(err) +// denialGuidance renders a refusal the daemon explained: a summary, plus the +// command that satisfies it when there is one. A refusal the caller cannot act +// on, such as another user holding the connection, carries a summary alone. It +// reports false for any other error. +func denialGuidance(err error) (string, bool) { + denial, ok := ipcauth.DenialFrom(err) if !ok { return "", false } - - summary := info.GetMetadata()[ipcauth.ErrorMetaSummary] - command := info.GetMetadata()[ipcauth.ErrorMetaCommand] - if summary == "" { - // Detail without a summary: fall back to the status message, which - // carries the same text. - summary = strings.TrimSpace(gstatus.Convert(err).Message()) + if denial.Command == "" { + return denial.Summary, true } - if command == "" { - return summary, true - } - - return fmt.Sprintf("%s\n\n %s\n", summary, command), true + return fmt.Sprintf("%s\n\n %s\n", denial.Summary, denial.Command), true } -// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error -// carries one. -func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) { - if err == nil { - return nil, false +// daemonDenial is a refusal the daemon explained, carrying its own sentence as +// the error text while keeping the gRPC status underneath. +type daemonDenial struct { + status *gstatus.Status + summary string +} + +func (d daemonDenial) Error() string { return d.summary } +func (d daemonDenial) GRPCStatus() *gstatus.Status { return d.status } + +// asDaemonDenial re-presents a refusal the daemon explained. Anything else is +// returned untouched. +func asDaemonDenial(err error) error { + guidance, ok := denialGuidance(err) + if !ok { + return err + } + return daemonDenial{status: gstatus.Convert(err), summary: guidance} +} + +// denialInterceptor re-presents refusals as they leave the daemon, before any +// command gets a chance to wrap them. +func denialInterceptor(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + return asDaemonDenial(invoker(ctx, method, req, reply, cc, opts...)) +} + +// denialStreamInterceptor does the same for a stream, on the way out and for as +// long as it runs. +func denialStreamInterceptor(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { + stream, err := streamer(ctx, desc, cc, method, opts...) + if err != nil { + return stream, asDaemonDenial(err) + } + return denialStream{ClientStream: stream}, nil +} + +// denialStream re-presents refusals a stream reports after it was opened. +type denialStream struct { + grpc.ClientStream +} + +func (s denialStream) RecvMsg(m any) error { + return asDaemonDenial(s.ClientStream.RecvMsg(m)) +} + +func (s denialStream) SendMsg(m any) error { + return asDaemonDenial(s.ClientStream.SendMsg(m)) +} + +func (s denialStream) Header() (metadata.MD, error) { + md, err := s.ClientStream.Header() + return md, asDaemonDenial(err) +} + +// printCommandError writes a failed command's error, taking over from cobra so a +// refusal the daemon explained is printed as written. +func printCommandError(cmd *cobra.Command, err error) { + // Keep the raw error at debug + log.Debugf("command failed: %v", err) + + // Unwrapped, so a command that added context with %w still prints the + // sentence alone. A command that used %v keeps its prefix, and the sentence + // is still readable because daemonDenial carries no envelope. + var denial daemonDenial + if errors.As(err, &denial) { + cmd.PrintErrln(denial.summary) + return } - 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 - } + // A refusal that reached here as a plain status did not come through the + // dial helper's interceptor. Render it anyway rather than leaking an + // envelope because of where it was dialled. + if guidance, ok := denialGuidance(err); ok { + cmd.PrintErrln(guidance) + return } - return nil, false + + cmd.PrintErrln(cmd.ErrPrefix(), err.Error()) } diff --git a/client/cmd/daemon_error_test.go b/client/cmd/daemon_error_test.go new file mode 100644 index 000000000..3e67d3c3a --- /dev/null +++ b/client/cmd/daemon_error_test.go @@ -0,0 +1,240 @@ +package cmd + +import ( + "bytes" + "errors" + "fmt" + "io" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/ipcauth" +) + +func printed(t *testing.T, err error) string { + t.Helper() + cmd := &cobra.Command{} + var buf bytes.Buffer + cmd.SetErr(&buf) + printCommandError(cmd, err) + return buf.String() +} + +// The daemon writes these sentences for the user, so they must reach the +// terminal as written rather than inside "rpc error: code = ... desc = ...". +func TestPrintCommandErrorStripsTheGRPCEnvelope(t *testing.T) { + out := printed(t, ipcauth.SessionHeldError("disconnecting")) + + assert.Contains(t, out, "Disconnecting is refused while another user has this machine connected.") + assert.Contains(t, out, "netbird down", "the remedy is shown") + assert.NotContains(t, out, "rpc error") + assert.NotContains(t, out, "PermissionDenied") + assert.NotContains(t, out, "Error:", "guidance stands on its own") +} + +// A command that adds context still renders, since the status survives wrapping +// and that is what the backoff loops in up and login read. +func TestPrintCommandErrorSeesThroughWrapping(t *testing.T) { + wrapped := daemonCallError("call service down method", ipcauth.SessionHeldError("disconnecting")) + + st, ok := gstatus.FromError(wrapped) + require.True(t, ok, "wrapping must not hide the status from code checks") + assert.Equal(t, codes.PermissionDenied, st.Code()) + + out := printed(t, wrapped) + assert.NotContains(t, out, "rpc error") + assert.NotContains(t, out, "call service down method") +} + +func TestPrintCommandErrorKeepsOrdinaryErrors(t *testing.T) { + out := printed(t, errors.New("connection refused")) + assert.Contains(t, out, "Error:") + assert.Contains(t, out, "connection refused") +} + +// A status with no daemon detail is not ours to reword. +func TestPrintCommandErrorLeavesForeignStatusAlone(t *testing.T) { + out := printed(t, gstatus.Error(codes.Unavailable, "daemon not initialized")) + assert.Contains(t, out, "Error:") + assert.Contains(t, out, "daemon not initialized") +} + +func TestPrintCommandErrorRendersEveryDaemonReason(t *testing.T) { + for name, err := range map[string]error{ + "privilege": ipcauth.PrivilegeError("Claiming a profile requires root.", "sudo netbird profile claim"), + "session": ipcauth.SessionHeldError("connecting"), + "ownership": ipcauth.NotOwnerError("switching profile"), + } { + t.Run(name, func(t *testing.T) { + out := printed(t, err) + assert.NotContains(t, out, "rpc error", fmt.Sprintf("%s refusal still shows the envelope", name)) + assert.NotContains(t, out, "Error:") + }) + } +} + +// The interceptor is what makes this general: once a refusal leaves the daemon +// it reads correctly however a command wraps it, including with %v, which +// breaks the chain every other approach relies on. +func TestDaemonDenialSurvivesAnyWrapping(t *testing.T) { + denial := asDaemonDenial(ipcauth.SessionHeldError("switching profile")) + + for name, wrapped := range map[string]error{ + "unwrapped": denial, + "wrapped once": fmt.Errorf("switch profile: %w", denial), + "wrapped twice": fmt.Errorf("switch profile: %w", + fmt.Errorf("switch profile failed: %w", denial)), + "wrapped with %v": fmt.Errorf("switch profile: %v", denial), + } { + t.Run(name, func(t *testing.T) { + out := printed(t, wrapped) + assert.NotContains(t, out, "rpc error", "the envelope must never reach the terminal") + assert.NotContains(t, out, "PermissionDenied") + assert.Contains(t, out, "Switching profile is refused") + assert.Contains(t, out, "netbird down") + }) + } +} + +// Re-presenting the error must not cost the code the backoff loops read. +func TestDaemonDenialKeepsItsStatus(t *testing.T) { + denial := asDaemonDenial(ipcauth.SessionHeldError("connecting")) + + st, ok := gstatus.FromError(denial) + require.True(t, ok) + assert.Equal(t, codes.PermissionDenied, st.Code()) + + st, ok = gstatus.FromError(fmt.Errorf("up failed: %w", denial)) + require.True(t, ok, "a %w wrap must still expose the code") + assert.Equal(t, codes.PermissionDenied, st.Code()) +} + +// Anything that is not a daemon refusal is left exactly as it was. +func TestAsDaemonDenialLeavesOtherErrorsAlone(t *testing.T) { + plain := errors.New("connection refused") + assert.Same(t, plain, asDaemonDenial(plain)) + + foreign := gstatus.Error(codes.Unavailable, "daemon not initialized") + assert.Equal(t, foreign, asDaemonDenial(foreign)) + assert.Nil(t, asDaemonDenial(nil)) +} + +// fakeStream reports err from every call, standing in for a stream the daemon +// opened and then refused. +type fakeStream struct { + grpc.ClientStream + err error +} + +func (f fakeStream) RecvMsg(any) error { return f.err } +func (f fakeStream) SendMsg(any) error { return f.err } +func (f fakeStream) Header() (metadata.MD, error) { return nil, f.err } + +// Opening a stream does not wait for the server to accept it, so a refusal +// arrives on the first Recv. capture and expose both read it there. +func TestDenialStreamConvertsRefusalsAfterOpen(t *testing.T) { + s := denialStream{ClientStream: fakeStream{err: ipcauth.SessionHeldError("starting a packet capture")}} + + for name, err := range map[string]error{ + "RecvMsg": s.RecvMsg(nil), + "SendMsg": s.SendMsg(nil), + } { + t.Run(name, func(t *testing.T) { + require.Error(t, err) + assert.NotContains(t, err.Error(), "rpc error", "the envelope must not survive") + assert.Contains(t, err.Error(), "Starting a packet capture is refused") + + st, ok := gstatus.FromError(err) + require.True(t, ok, "the code has to survive for callers that branch on it") + assert.Equal(t, codes.PermissionDenied, st.Code()) + }) + } + + _, err := s.Header() + require.Error(t, err) + assert.NotContains(t, err.Error(), "rpc error") +} + +// A clean end of stream is not an error. Callers compare against io.EOF, so it +// has to come back as the very same value. +func TestDenialStreamPassesEOFThrough(t *testing.T) { + s := denialStream{ClientStream: fakeStream{err: io.EOF}} + + assert.Same(t, io.EOF, s.RecvMsg(nil)) + assert.True(t, errors.Is(s.RecvMsg(nil), io.EOF)) +} + +func TestDenialStreamLeavesOtherErrorsAlone(t *testing.T) { + plain := errors.New("transport closing") + s := denialStream{ClientStream: fakeStream{err: plain}} + + assert.Same(t, plain, s.RecvMsg(nil)) +} + +// captureLog points the standard logger at a buffer for the duration of a test, +// standing in for the console writer every interactive command installs. +func captureLog(t *testing.T, level log.Level) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + logger := log.StandardLogger() + prevOut, prevLevel, prevFmt := logger.Out, logger.Level, logger.Formatter + logger.SetOutput(&buf) + logger.SetLevel(level) + // The default formatter escapes the quotes inside a message, which would let + // a logged error slip past a comparison against the error's own text. + logger.SetFormatter(&log.TextFormatter{DisableQuote: true, DisableTimestamp: true}) + t.Cleanup(func() { + logger.SetOutput(prevOut) + logger.SetLevel(prevLevel) + logger.SetFormatter(prevFmt) + }) + return &buf +} + +// Console logging and PrintErrln both write to os.Stderr, so a command that logs +// the error it is about to return has it printed twice: once by the logger and +// once by Execute. SilenceErrors does not cover this, it only retires cobra's +// own copy. +func TestCommandDoesNotLogTheErrorItReturns(t *testing.T) { + logged := captureLog(t, log.InfoLevel) + + prev := logLevel + logLevel = "bogus" + t.Cleanup(func() { logLevel = prev }) + + err := downCmd.RunE(downCmd, nil) + require.Error(t, err, "an unparseable log level fails before the command dials") + assert.NotContains(t, logged.String(), err.Error(), "Execute renders this error, so the command must not log it") + + assert.Contains(t, printed(t, err), "not a valid logrus Level", "and it is still reported once") +} + +// The rendered sentence drops the envelope and code on purpose, so the raw error +// stays available to a bug report at debug level, below what a user sees. +func TestPrintCommandErrorKeepsTheRawErrorAtDebug(t *testing.T) { + logged := captureLog(t, log.DebugLevel) + + out := printed(t, ipcauth.SessionHeldError("disconnecting")) + + assert.NotContains(t, out, "rpc error", "the user still reads the sentence alone") + assert.Contains(t, logged.String(), "rpc error", "the envelope a bug report needs survives in the log") + assert.Contains(t, logged.String(), "PermissionDenied") +} + +// At the level an interactive command actually runs at, the diagnostic stays out +// of the way, so the failure reaches the terminal exactly once. +func TestPrintCommandErrorLogsNothingAtInfo(t *testing.T) { + logged := captureLog(t, log.InfoLevel) + + printed(t, errors.New("connection refused")) + + assert.NotContains(t, logged.String(), "connection refused") +} diff --git a/client/cmd/down.go b/client/cmd/down.go index 17c152d22..9292a6e41 100644 --- a/client/cmd/down.go +++ b/client/cmd/down.go @@ -2,11 +2,11 @@ package cmd import ( "context" + "fmt" "time" "github.com/netbirdio/netbird/util" - log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/netbirdio/netbird/client/proto" @@ -21,10 +21,8 @@ var downCmd = &cobra.Command{ cmd.SetOut(cmd.OutOrStdout()) - err := util.InitLog(logLevel, util.LogConsole) - if err != nil { - log.Errorf("failed initializing log %v", err) - return err + if err := util.InitLog(logLevel, util.LogConsole); err != nil { + return fmt.Errorf("initialize log: %w", err) } ctx, cancel := context.WithTimeout(context.Background(), time.Second*20) @@ -32,16 +30,14 @@ var downCmd = &cobra.Command{ conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { - log.Errorf("failed to connect to service CLI interface %v", err) - return err + return fmt.Errorf("connect to service CLI interface: %w", err) } defer conn.Close() daemonClient := proto.NewDaemonServiceClient(conn) if _, err := daemonClient.Down(ctx, &proto.DownRequest{}); err != nil { - log.Errorf("call service down method: %v", err) - return err + return daemonCallError("call service down method", err) } cmd.Println("Disconnected") diff --git a/client/cmd/expose.go b/client/cmd/expose.go index c48a6adac..6c3329f0f 100644 --- a/client/cmd/expose.go +++ b/client/cmd/expose.go @@ -147,8 +147,7 @@ func exposeFn(cmd *cobra.Command, args []string) error { SetFlagsFromEnvVars(rootCmd) if err := util.InitLog(logLevel, util.LogConsole); err != nil { - log.Errorf("failed initializing log %v", err) - return err + return fmt.Errorf("initialize log: %w", err) } cmd.Root().SilenceUsage = false diff --git a/client/cmd/login.go b/client/cmd/login.go index 11867be09..a5b5562b5 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -235,7 +235,7 @@ func getActiveProfile(ctx context.Context, pm *profilemanager.ProfileManager, pr if profileName != "" { prof, err := switchProfileOnDaemon(ctx, pm, profileName, username) if err != nil { - return nil, fmt.Errorf("switch profile: %v", err) + return nil, fmt.Errorf("switch profile: %w", err) } return prof, nil } @@ -258,7 +258,7 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage } if err := pm.SwitchProfile(resolvedID); err != nil { - return nil, fmt.Errorf("switch profile: %v", err) + return nil, fmt.Errorf("switch profile: %w", err) } conn, err := DialClientGRPCServer(ctx, daemonAddr) diff --git a/client/cmd/profile.go b/client/cmd/profile.go index 2d6653537..1defe85fc 100644 --- a/client/cmd/profile.go +++ b/client/cmd/profile.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "runtime" "strings" "text/tabwriter" "time" @@ -18,7 +19,10 @@ import ( "github.com/netbirdio/netbird/util" ) -var profileListShowID bool +var ( + profileListShowID bool + profileClaimOwner string +) var profileCmd = &cobra.Command{ Use: "profile", @@ -67,8 +71,27 @@ var profileSelectCmd = &cobra.Command{ RunE: selectProfileFunc, } +var profileClaimCmd = &cobra.Command{ + Use: "claim ", + Short: "Record an owner on a profile", + Long: `Record who owns a profile. Requires root or administrator privileges. + +A profile with no owner is reachable by a privileged caller alone. Claiming is +how ownership is settled on a machine with no console user, such as one set up +from a setup key, and how a profile is handed to a different account. + +The owner is given as a principal ("uid:1000", "sid:S-1-5-21-...") or an account +name, which the daemon resolves. Without --owner the profile is claimed for the +user who ran sudo. On Windows --owner is required, since elevation keeps no +record of who asked for it.`, + Args: cobra.ExactArgs(1), + RunE: claimProfileFunc, +} + func init() { profileListCmd.Flags().BoolVar(&profileListShowID, "show-id", false, "show the profile ID column") + profileClaimCmd.Flags().StringVar(&profileClaimOwner, "owner", "", + "principal (uid:1000, sid:S-1-5-21-...) or account name to record as the owner. Defaults to the user running the command.") } func setupCmd(cmd *cobra.Command) error { @@ -112,9 +135,9 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error { tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) if profileListShowID { - fmt.Fprintln(tw, "ID\tNAME\tACTIVE") + fmt.Fprintln(tw, "ID\tNAME\tACTIVE\tOWNER") } else { - fmt.Fprintln(tw, "NAME\tACTIVE") + fmt.Fprintln(tw, "NAME\tACTIVE\tOWNER") } for _, profile := range resp.Profiles { marker := "" @@ -123,15 +146,78 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error { } name := profilemanager.StripCtrlChars(profile.Name) id := profilemanager.ID(profile.Id) + // An unowned profile is reachable by a privileged caller alone, so say + // so rather than leaving the column blank. + owner := "unowned" + if len(profile.Owners) > 0 { + owner = profilemanager.StripCtrlChars(profile.Owners[0]) + } if profileListShowID { - fmt.Fprintf(tw, "%s\t%s\t%s\n", id.ShortID(), name, marker) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", id.ShortID(), name, marker, owner) } else { - fmt.Fprintf(tw, "%s\t%s\n", name, marker) + fmt.Fprintf(tw, "%s\t%s\t%s\n", name, marker, owner) } } return tw.Flush() } +func claimProfileFunc(cmd *cobra.Command, args []string) error { + if err := setupCmd(cmd); err != nil { + return err + } + + // The daemon resolves and validates the owner. All that happens here is + // filling in who "me" is when the flag is omitted. + owner := profileClaimOwner + if owner == "" { + var err error + if owner, err = defaultClaimOwner(); err != nil { + return err + } + } + + conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr) + if err != nil { + return fmt.Errorf("connect to service CLI interface: %w", err) + } + defer conn.Close() + + daemonClient := proto.NewDaemonServiceClient(conn) + handle := args[0] + + resp, err := daemonClient.ClaimProfile(cmd.Context(), &proto.ClaimProfileRequest{ + Handle: handle, + Owner: owner, + }) + if err != nil { + return daemonCallError("claim profile", wrapAmbiguityError(err, handle, "claim ")) + } + + cmd.Printf("Profile %s claimed for %s\n", profilemanager.ID(resp.Id).ShortID(), resp.Owner) + return nil +} + +// defaultClaimOwner names who to claim for when --owner is omitted. +// +// Unix has SUDO_USER, Windows has no equivalent. +func defaultClaimOwner() (string, error) { + if runtime.GOOS == "windows" { + return "", errors.New("name the owner with --owner, Windows keeps no record of who asked for elevation") + } + + // Plain root has no invoking user to act for, so claiming for "me" would + // silently mean root. + if profilemanager.IsPlainRoot() { + return "", errors.New("no invoking user to claim for, name the owner with --owner") + } + + u, err := profilemanager.InvokingUser() + if err != nil { + return "", fmt.Errorf("get current user: %w", err) + } + return u.Username, nil +} + func addProfileFunc(cmd *cobra.Command, args []string) error { if err := setupCmd(cmd); err != nil { return err @@ -193,7 +279,7 @@ func renameProfileFunc(cmd *cobra.Command, args []string) error { NewProfileName: newProfilename, }) if err != nil { - return wrapAmbiguityError(err, handle) + return wrapAmbiguityError(err, handle, "rename ") } dupCount, _ := countProfilesWithName(cmd.Context(), daemonClient, currUser.Username, newProfilename) @@ -245,7 +331,7 @@ func removeProfileFunc(cmd *cobra.Command, args []string) error { Username: currUser.Username, }) if err != nil { - return wrapAmbiguityError(err, handle) + return wrapAmbiguityError(err, handle, "remove ") } cmd.Printf("Profile removed: %s\n", resp.Id) @@ -280,7 +366,7 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { Username: &currUser.Username, }) if err != nil { - return wrapAmbiguityError(err, handle) + return wrapAmbiguityError(err, handle, "select ") } if err := profileManager.SwitchProfile(profilemanager.ID(switchResp.Id)); err != nil { @@ -305,8 +391,9 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { // wrapAmbiguityError turns the daemon's gRPC InvalidArgument errors // (which carry the resolver's message verbatim) into CLI-friendly text -// that points the user at --show-id. -func wrapAmbiguityError(err error, handle string) error { +// that points the user at --show-id. retry names the command to run again by +// ID prefix, as it would be typed after `netbird profile`. +func wrapAmbiguityError(err error, handle, retry string) error { if err == nil { return nil } @@ -318,7 +405,7 @@ func wrapAmbiguityError(err error, handle string) error { case codes.InvalidArgument: msg := st.Message() if strings.Contains(msg, "ambiguous") { - return errors.New(msg + "\nRun `netbird profile list --show-id` to see IDs, then select by ID prefix:\n netbird profile select|remove ") + return errors.New(msg + "\nRun `netbird profile list --show-id` to see IDs, then retry by ID prefix:\n netbird profile " + retry) } case codes.NotFound: return fmt.Errorf("profile %q not found", handle) diff --git a/client/cmd/profile_claim_test.go b/client/cmd/profile_claim_test.go new file mode 100644 index 000000000..743ef2d2d --- /dev/null +++ b/client/cmd/profile_claim_test.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// Omitting --owner is only allowed where the invoking user can be recovered. +// Everywhere else the admin names the owner rather than having one guessed. +func TestDefaultClaimOwner(t *testing.T) { + got, err := defaultClaimOwner() + + switch { + case runtime.GOOS == "windows": + require.Error(t, err, "Windows keeps no record of who asked for elevation") + assert.Contains(t, err.Error(), "--owner") + case profilemanager.IsPlainRoot(): + require.Error(t, err, "plain root has no invoking user to act for") + assert.Contains(t, err.Error(), "--owner") + default: + require.NoError(t, err) + assert.NotEmpty(t, got) + } +} diff --git a/client/cmd/root.go b/client/cmd/root.go index be6479440..f64879b4c 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -91,6 +91,9 @@ var ( Short: "", Long: "", SilenceUsage: true, + // Execute prints the error instead, so a refusal the daemon already + // explained is not reprinted inside a gRPC envelope. + SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { SetFlagsFromEnvVars(cmd.Root()) @@ -111,7 +114,11 @@ func Execute() error { if isUpdateBinary() { return updateCmd.Execute() } - return rootCmd.Execute() + err := rootCmd.Execute() + if err != nil { + printCommandError(rootCmd, err) + } + return err } // init initialises package-level defaults and configures the root @@ -203,6 +210,7 @@ func init() { profileCmd.AddCommand(profileRenameCmd) profileCmd.AddCommand(profileRemoveCmd) profileCmd.AddCommand(profileSelectCmd) + profileCmd.AddCommand(profileClaimCmd) upCmd.PersistentFlags().StringSliceVar(&natExternalIPs, externalIPMapFlag, nil, `Sets external IPs maps between local addresses and interfaces.`+ @@ -280,7 +288,13 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e defer cancel() target, opts := daddr.DialTarget(addr) - opts = append(opts, grpc.WithBlock()) + // Refusals are re-presented here, at the one place every command dials, so + // no command has to remember to render them. + opts = append(opts, + grpc.WithBlock(), + grpc.WithChainUnaryInterceptor(denialInterceptor), + grpc.WithChainStreamInterceptor(denialStreamInterceptor), + ) return grpc.DialContext(ctx, target, opts...) } diff --git a/client/cmd/up.go b/client/cmd/up.go index f5fac9749..798e10fb3 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -136,7 +136,7 @@ func upFunc(cmd *cobra.Command, args []string) error { if profileName != "" { activeProf, err = switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username) if err != nil { - return fmt.Errorf("switch profile: %v", err) + return fmt.Errorf("switch profile: %w", err) } profileSwitched = true } else { @@ -344,8 +344,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager } if _, err := client.Down(ctx, &proto.DownRequest{}); err != nil { - log.Errorf("call service down method: %v", err) - return err + return daemonCallError("call service down method", err) } } diff --git a/client/cmd/update_supported.go b/client/cmd/update_supported.go index 0b197f4c5..7ced069e7 100644 --- a/client/cmd/update_supported.go +++ b/client/cmd/update_supported.go @@ -4,6 +4,7 @@ package cmd import ( "context" + "fmt" "os" "path/filepath" "strings" @@ -56,8 +57,7 @@ func updateFunc(cmd *cobra.Command, args []string) error { log.Infof("updater started: %s", serviceDirFlag) updater := installer.NewWithDir(tempDirFlag) if err := updater.Setup(context.Background(), dryRunFlag, installerFile, serviceDirFlag); err != nil { - log.Errorf("failed to update application: %v", err) - return err + return fmt.Errorf("update application: %w", err) } return nil } diff --git a/client/internal/ipcauth/authz_gate.go b/client/internal/ipcauth/authz_gate.go index 2cb200059..40f8325e8 100644 --- a/client/internal/ipcauth/authz_gate.go +++ b/client/internal/ipcauth/authz_gate.go @@ -20,8 +20,9 @@ type DaemonState interface { // OwnsProfile reports whether id owns the profile a request names. An empty // handle is the active profile, which is what a method that acts on the - // live session resolves against. - OwnsProfile(id Identity, handle string) bool + // live session resolves against. The error says what was wrong with the + // handle itself. + OwnsProfile(id Identity, handle string) (bool, error) } // AuthzGate authorizes every RPC call before its handler run. @@ -82,6 +83,36 @@ func denyLevel(r Request, want AuthzLevel) error { "%s requires %s, caller %s is %s", r.Method, want, r.Identity, r.Level) } +// denyPolicyLevel refuses a caller at the gate, where the policy is in hand. +// +// Requiring privilege is the one denial a caller can act on, so it carries the +// elevated command rather than a bare refusal. A privileged method that declares +// no action keeps the plain message. Rules deny through denyLevel instead: they +// cannot reach the policy table without an initialization cycle, and no rule +// requires privilege. +func denyPolicyLevel(r Request, p MethodPolicy) error { + switch p.Level { + case AuthzLevelPrivileged: + if p.Action != "" { + actor, command := RequiredActor(p.Command) + return PrivilegeError(PrivilegeSummary(p.Action, actor), command) + } + + case AuthzLevelSessionHolder: + // resolveLevel stops at profile owner only when a session is running and + // somebody else holds it. + if r.Level == AuthzLevelProfileOwner { + return SessionHeldError(p.Action) + } + return NotOwnerError(p.Action) + + case AuthzLevelProfileOwner: + return NotOwnerError(p.Action) + } + + return denyLevel(r, p.Level) +} + // StreamPolicyInterceptor authorizes each streaming RPC before the handler runs. // The request payload is not yet available, so no streaming method may be // target-scoped. @@ -130,9 +161,11 @@ func (g *AuthzGate) authorize(ctx context.Context, method string, msg any) error target = named } + level, resolveErr := resolveLevel(id, target, st) + req := Request{ Identity: id, - Level: resolveLevel(id, target, st), + Level: level, Target: target, Method: method, State: st, @@ -140,7 +173,10 @@ func (g *AuthzGate) authorize(ctx context.Context, method string, msg any) error } if req.Level < policy.Level { log.Warnf("ipc authz: DENY %s for %s (%s), requires %s", method, id, req.Level, policy.Level) - return denyLevel(req, policy.Level) + if resolveErr != nil { + return resolveErr + } + return denyPolicyLevel(req, policy) } for _, rule := range policy.Rules { if err := rule(req); err != nil { diff --git a/client/internal/ipcauth/authz_gate_test.go b/client/internal/ipcauth/authz_gate_test.go new file mode 100644 index 000000000..600a15c86 --- /dev/null +++ b/client/internal/ipcauth/authz_gate_test.go @@ -0,0 +1,139 @@ +package ipcauth + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/proto" +) + +// gateFor builds a gate over a stub daemon, with this process pinned to root so +// the unprivileged fixture caller is not mistaken for the daemon's own identity. +func gateFor(t *testing.T, st DaemonState) *AuthzGate { + t.Helper() + asDaemon(t, root) + + g := NewAuthzGate() + g.SetState(st) + return g +} + +func switchTo(handle string) *proto.SwitchProfileRequest { + if handle == "" { + return &proto.SwitchProfileRequest{} + } + return &proto.SwitchProfileRequest{ProfileName: &handle} +} + +// A handle that names no profile the caller can address is answered with what +// is wrong with the handle. The refusal about ownership would claim the profile +// exists and belongs to somebody, which a mistyped handle does not. +func TestAuthorizeSurfacesWhatIsWrongWithTheHandle(t *testing.T) { + notFound := gstatus.Errorf(codes.NotFound, "profile %q not found", "asdfasdfasdf") + g := gateFor(t, stubState{ownsErr: notFound}) + + err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("asdfasdfasdf")) + require.Error(t, err) + + st := gstatus.Convert(err) + assert.Equal(t, codes.NotFound, st.Code(), "a handle that resolves to nothing is not a permission problem") + assert.Contains(t, st.Message(), `profile "asdfasdfasdf" not found`) + + _, isDenial := DenialFrom(err) + assert.False(t, isDenial, "the ownership refusal took over an error about the handle") +} + +// The candidate list an ambiguous handle produces is the whole value of that +// error, and the CLI reformats it into a hint. It has to reach the CLI. +func TestAuthorizeSurfacesAnAmbiguousHandle(t *testing.T) { + ambiguous := gstatus.Errorf(codes.InvalidArgument, "handle %q matches 2 profiles", "ab") + g := gateFor(t, stubState{ownsErr: ambiguous}) + + err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("ab")) + require.Error(t, err) + assert.Equal(t, codes.InvalidArgument, gstatus.Convert(err).Code()) +} + +// A method that names no profile acts on the active one, which the caller never +// typed. Reporting it as not found would quote back an ID they never gave, so +// the refusal stays about who the profile belongs to. +func TestAuthorizeBlamesOwnershipForTheActiveProfile(t *testing.T) { + notFound := gstatus.Errorf(codes.NotFound, "profile %q not found", "active-profile-id") + g := gateFor(t, stubState{ownsErr: notFound}) + + err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("")) + require.Error(t, err) + + denial, ok := DenialFrom(err) + require.True(t, ok, "an unnamed profile is refused on ownership, not on the handle") + assert.Equal(t, ErrorReasonNotProfileOwner, denial.Reason) + assert.NotContains(t, denial.Summary, "active-profile-id", "the caller never named a profile") +} + +// A daemon-side failure is not something the caller can correct, and putting it +// on the wire would describe the daemon rather than the request. +func TestAuthorizeKeepsADaemonFailureOffTheWire(t *testing.T) { + g := gateFor(t, stubState{ownsErr: errors.New("read profile directory: permission denied")}) + + err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("some-profile")) + require.Error(t, err) + + denial, ok := DenialFrom(err) + require.True(t, ok, "a daemon-side failure must still refuse in the gate's own words") + assert.Equal(t, ErrorReasonNotProfileOwner, denial.Reason) + assert.NotContains(t, denial.Summary, "permission denied") +} + +// Resolving the active profile happens on every call, including the ones any +// identified caller may make. A failure there must not take those down. +func TestAuthorizeAllowsIdentifiedMethodsDespiteAResolveFailure(t *testing.T) { + notFound := gstatus.Errorf(codes.NotFound, "profile %q not found", "active-profile-id") + g := gateFor(t, stubState{ownsErr: notFound}) + + for _, method := range []string{"ListProfiles", "AddProfile", "GetActiveProfile", "GetFeatures"} { + t.Run(method, func(t *testing.T) { + require.Equal(t, AuthzLevelIdentified, methodPolicies[servicePath+method].Level, + "fixture is wrong: %s is no longer open to any identified caller", method) + + assert.NoError(t, g.authorize(transportCtx(unprivUser, nil), servicePath+method, nil)) + }) + } +} + +// Ownership is the gate's answer, never the error's: a resolution that failed is +// a no whatever it returned alongside. +func TestAuthorizeRefusesWhenResolutionFails(t *testing.T) { + g := gateFor(t, stubState{owns: false, ownsErr: gstatus.Error(codes.NotFound, "profile not found")}) + + err := g.authorize(transportCtx(unprivUser, nil), servicePath+"SwitchProfile", switchTo("some-profile")) + assert.Error(t, err, "an error from the resolution cannot be read as ownership") +} + +// A resolution that failed established nothing about the profile, so no level +// returned alongside the error may be acted on. This is the invariant the gate +// clamps, pinned at the function that has to hold it. +func TestResolveLevelNeverRaisesTheLevelOnAFailure(t *testing.T) { + asDaemon(t, root) + + notFound := gstatus.Error(codes.NotFound, "profile not found") + + for _, tc := range []struct { + name string + st stubState + }{ + {"a live session it reports as owned", stubState{owns: true, running: true, ownsErr: notFound}}, + {"an idle daemon it reports as owned", stubState{owns: true, ownsErr: notFound}}, + {"a daemon-side failure it reports as owned", stubState{owns: true, ownsErr: errors.New("read profile directory")}}, + } { + t.Run(tc.name, func(t *testing.T) { + level, _ := resolveLevel(unprivUser, "some-profile", tc.st) + assert.Equal(t, AuthzLevelIdentified, level, + "a failed resolution cannot confer %s", level) + }) + } +} diff --git a/client/internal/ipcauth/authz_level.go b/client/internal/ipcauth/authz_level.go index 0c9cf364f..9f6744984 100644 --- a/client/internal/ipcauth/authz_level.go +++ b/client/internal/ipcauth/authz_level.go @@ -1,5 +1,9 @@ package ipcauth +import ( + gstatus "google.golang.org/grpc/status" +) + // AuthzLevel is the authority a caller holds over the daemon's current state. // The values are ordered, and each level can do everything the levles below // it can. A MethodPolicy is satisfied when the caller's level is at least @@ -42,18 +46,44 @@ func (l AuthzLevel) String() string { } } -func resolveLevel(id Identity, target string, st DaemonState) AuthzLevel { +// resolveLevel is the authority the caller holds over the profile the request +// names. The second return is what was wrong with the handle, when that is +// worth showing the caller instead of a refusal. It never raises the level: a +// resolution that failed still denies. +func resolveLevel(id Identity, target string, st DaemonState) (AuthzLevel, error) { if !id.Known() { - return AuthzLevelNone + return AuthzLevelNone, nil } if IsPrivilegedCaller(id) { - return AuthzLevelPrivileged + return AuthzLevelPrivileged, nil } - if !st.OwnsProfile(id, target) { - return AuthzLevelIdentified + ownsProfile, err := st.OwnsProfile(id, target) + if err != nil { + return AuthzLevelIdentified, presentableHandleError(target, err) + } + if !ownsProfile { + return AuthzLevelIdentified, nil } if holder, running := st.SessionHolder(); !running || holder.Matches(id) { - return AuthzLevelSessionHolder + return AuthzLevelSessionHolder, nil } - return AuthzLevelProfileOwner + return AuthzLevelProfileOwner, nil +} + +// presentableHandleError keeps a resolution failure only when the gate can put +// it in front of the caller in place of its own refusal. Everything else is +// dropped, and the caller gets the refusal their level earned. +func presentableHandleError(target string, err error) error { + // An empty target is the active profile rather than something the caller + // typed, so a failure to resolve it is not theirs to correct. + if target == "" { + return nil + } + + // Only a gRPC status reaches the caller as a sentence the CLI and the UI + // render. + if _, ok := gstatus.FromError(err); !ok { + return nil + } + return err } diff --git a/client/internal/ipcauth/identity.go b/client/internal/ipcauth/identity.go index 485d8726f..aa4971802 100644 --- a/client/internal/ipcauth/identity.go +++ b/client/internal/ipcauth/identity.go @@ -12,6 +12,7 @@ package ipcauth import ( "context" "fmt" + "runtime" "slices" "strconv" "strings" @@ -211,6 +212,68 @@ func OwnerPrincipalForIdentity(id Identity) string { return UIDPrincipal(id.UID) } +// ValidatePrincipal checks an owner principal typed by a user, as opposed to one +// read back off disk. +func ValidatePrincipal(s string) (Principal, error) { + p, ok := ParsePrincipal(s) + if !ok { + return Principal{}, fmt.Errorf("owner %q is not a %s: or %s: principal", s, KindUID, KindSID) + } + if err := p.Validate(); err != nil { + return Principal{}, err + } + return p, nil +} + +// Validate reports whether a principal is one a caller on this platform could +// ever hold. +func (p Principal) Validate() error { + switch p.Kind { + case KindUID: + if runtime.GOOS == "windows" { + return fmt.Errorf("owner %q names a Unix user ID, which no caller on this platform can hold", p.String()) + } + if _, err := strconv.ParseUint(p.Value, 10, 32); err != nil { + return fmt.Errorf("owner %q does not carry a user ID", p.String()) + } + case KindSID: + if runtime.GOOS != "windows" { + return fmt.Errorf("owner %q names a Windows SID, which no caller on this platform can hold", p.String()) + } + if !looksLikeSID(p.Value) { + return fmt.Errorf("owner %q does not carry a SID", p.String()) + } + default: + return fmt.Errorf("owner %q is not a %s: or %s: principal", p.String(), KindUID, KindSID) + } + return nil +} + +// looksLikeSID reports whether a value has the shape of a security identifier, +// "S-1-" followed by one to fifteen sub-authorities. A shape check +// only, since the account it names need not exist yet. +func looksLikeSID(v string) bool { + parts := strings.Split(v, "-") + if len(parts) < 4 || parts[0] != "S" || parts[1] != "1" { + return false + } + // The identifier authority is a 48-bit field, unlike the 32-bit + // sub-authorities that follow it, of which a SID carries at most 15. + if _, err := strconv.ParseUint(parts[2], 10, 48); err != nil { + return false + } + subAuthorities := parts[3:] + if len(subAuthorities) > 15 { + return false + } + for _, part := range subAuthorities { + if _, err := strconv.ParseUint(part, 10, 32); err != nil { + return false + } + } + return true +} + // Matches reports whether a kernel-attested caller satisfies this stored owner // principal. // diff --git a/client/internal/ipcauth/methods.go b/client/internal/ipcauth/methods.go index 502eaa7d3..52fb44024 100644 --- a/client/internal/ipcauth/methods.go +++ b/client/internal/ipcauth/methods.go @@ -40,72 +40,90 @@ type MethodPolicy struct { Rules []Rule Audit bool TargetsProfile bool + + // Action and Command turn a privilege denial into guidance the caller can + // act on. Action reads as the subject of a sentence ("claiming a profile"), + // Command is the same operation run with the privileges it needs. Only read + // when Level is AuthzLevelPrivileged, the one denial a caller can fix by + // running as somebody else. + Action string + Command string } // methodPolicies is the complete authorization surface. Every RPC on // DaemonService appears here exactly once. var methodPolicies = map[string]MethodPolicy{ // Any identified caller. - servicePath + "Status": {Level: AuthzLevelIdentified, Rules: []Rule{RequireHolderForFullStatus}}, - servicePath + "AddProfile": {Level: AuthzLevelIdentified, Audit: true}, - servicePath + "ListProfiles": {Level: AuthzLevelIdentified}, - servicePath + "GetActiveProfile": {Level: AuthzLevelIdentified}, - servicePath + "GetFeatures": {Level: AuthzLevelIdentified}, - servicePath + "WailsUIReady": {Level: AuthzLevelIdentified}, + servicePath + "Status": {Level: AuthzLevelIdentified, Rules: []Rule{RequireHolderForFullStatus}, Action: "reading status"}, + servicePath + "AddProfile": {Level: AuthzLevelIdentified, Audit: true, Action: "adding a profile"}, + servicePath + "ListProfiles": {Level: AuthzLevelIdentified, Action: "listing profiles"}, + servicePath + "GetActiveProfile": {Level: AuthzLevelIdentified, Action: "reading the active profile"}, + servicePath + "GetFeatures": {Level: AuthzLevelIdentified, Action: "reading feature flags"}, + servicePath + "WailsUIReady": {Level: AuthzLevelIdentified, Action: "starting the UI"}, + // If a higher level is used, the deny logs message is spammed on frequent UI polls. + servicePath + "RegisterUILog": {Level: AuthzLevelIdentified, Action: "registering the UI log"}, // Owner of the profile the request names. - servicePath + "GetConfig": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true}, - servicePath + "SetConfig": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true}, - servicePath + "Login": {Level: AuthzLevelSessionHolder, TargetsProfile: true, Audit: true}, - servicePath + "Logout": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true}, - servicePath + "RenameProfile": {Level: AuthzLevelProfileOwner, TargetsProfile: true}, - servicePath + "RemoveProfile": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true}, - servicePath + "SwitchProfile": {Level: AuthzLevelSessionHolder, TargetsProfile: true, Audit: true}, + servicePath + "GetConfig": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true, Action: "reading the profile configuration"}, + servicePath + "SetConfig": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true, Action: "changing the profile configuration"}, + servicePath + "Login": {Level: AuthzLevelSessionHolder, TargetsProfile: true, Audit: true, Action: "logging in"}, + servicePath + "Logout": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true, Action: "logging out"}, + servicePath + "RenameProfile": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Action: "renaming a profile"}, + servicePath + "RemoveProfile": {Level: AuthzLevelProfileOwner, TargetsProfile: true, Audit: true, Action: "removing a profile"}, + servicePath + "SwitchProfile": {Level: AuthzLevelSessionHolder, TargetsProfile: true, Audit: true, Action: "switching profile"}, // Owner of the active profile, which is what an empty target resolves to. - servicePath + "GetLogLevel": {Level: AuthzLevelProfileOwner}, - servicePath + "ListStates": {Level: AuthzLevelProfileOwner}, - servicePath + "GetInstallerResult": {Level: AuthzLevelProfileOwner}, + servicePath + "WaitSSOLogin": {Level: AuthzLevelProfileOwner, Audit: true, Action: "waiting for the login to finish"}, + servicePath + "WaitJWTToken": {Level: AuthzLevelProfileOwner, Audit: true, Action: "waiting for the token"}, + servicePath + "WaitExtendAuthSession": {Level: AuthzLevelProfileOwner, Action: "extending the session"}, - // Session holder: the live engine and everything daemon-wide. A pending - // authentication flow belongs to the profile it was started for, so each - // Wait sits at the level of the RPC that starts it. - servicePath + "Up": {Level: AuthzLevelSessionHolder, TargetsProfile: true, Audit: true}, - servicePath + "Down": {Level: AuthzLevelSessionHolder, Audit: true}, - servicePath + "SubscribeStatus": {Level: AuthzLevelSessionHolder}, - servicePath + "SubscribeEvents": {Level: AuthzLevelSessionHolder}, - servicePath + "GetEvents": {Level: AuthzLevelSessionHolder}, - servicePath + "ListNetworks": {Level: AuthzLevelSessionHolder}, - servicePath + "SelectNetworks": {Level: AuthzLevelSessionHolder, Audit: true}, - servicePath + "DeselectNetworks": {Level: AuthzLevelSessionHolder, Audit: true}, - servicePath + "ForwardingRules": {Level: AuthzLevelSessionHolder}, - servicePath + "ExposeService": {Level: AuthzLevelSessionHolder, Audit: true}, - servicePath + "GetPeerSSHHostKey": {Level: AuthzLevelSessionHolder}, - servicePath + "RequestJWTAuth": {Level: AuthzLevelSessionHolder, Audit: true}, - servicePath + "WaitJWTToken": {Level: AuthzLevelSessionHolder, Audit: true}, - servicePath + "RequestExtendAuthSession": {Level: AuthzLevelSessionHolder}, - servicePath + "WaitExtendAuthSession": {Level: AuthzLevelSessionHolder}, - servicePath + "WaitSSOLogin": {Level: AuthzLevelSessionHolder, Audit: true}, - servicePath + "DismissSessionWarning": {Level: AuthzLevelSessionHolder}, - servicePath + "DebugBundle": {Level: AuthzLevelSessionHolder, Audit: true}, - servicePath + "SetLogLevel": {Level: AuthzLevelSessionHolder}, - servicePath + "SetSyncResponsePersistence": {Level: AuthzLevelSessionHolder}, - servicePath + "StartCapture": {Level: AuthzLevelSessionHolder, Audit: true}, - servicePath + "StartBundleCapture": {Level: AuthzLevelSessionHolder, Audit: true}, - servicePath + "StopBundleCapture": {Level: AuthzLevelSessionHolder}, - servicePath + "StartCPUProfile": {Level: AuthzLevelSessionHolder}, - servicePath + "StopCPUProfile": {Level: AuthzLevelSessionHolder}, - servicePath + "CleanState": {Level: AuthzLevelSessionHolder, Audit: true}, - servicePath + "DeleteState": {Level: AuthzLevelSessionHolder, Audit: true}, - servicePath + "TracePacket": {Level: AuthzLevelSessionHolder}, - servicePath + "RegisterUILog": {Level: AuthzLevelSessionHolder}, - servicePath + "TriggerUpdate": {Level: AuthzLevelSessionHolder, Audit: true}, + // Owner of some profile + servicePath + "GetLogLevel": {Level: AuthzLevelProfileOwner, Action: "reading the log level"}, + servicePath + "ListStates": {Level: AuthzLevelProfileOwner, Action: "listing stored state"}, + servicePath + "GetInstallerResult": {Level: AuthzLevelProfileOwner, Action: "reading the installer result"}, + + // Session holder: the live engine and everything daemon-wide. + servicePath + "Up": {Level: AuthzLevelSessionHolder, TargetsProfile: true, Audit: true, Action: "connecting"}, + servicePath + "Down": {Level: AuthzLevelSessionHolder, Audit: true, Action: "disconnecting"}, + servicePath + "SubscribeStatus": {Level: AuthzLevelSessionHolder, Action: "following status"}, + servicePath + "SubscribeEvents": {Level: AuthzLevelSessionHolder, Action: "following events"}, + servicePath + "GetEvents": {Level: AuthzLevelSessionHolder, Action: "reading events"}, + servicePath + "ListNetworks": {Level: AuthzLevelSessionHolder, Action: "listing networks"}, + servicePath + "SelectNetworks": {Level: AuthzLevelSessionHolder, Audit: true, Action: "selecting networks"}, + servicePath + "DeselectNetworks": {Level: AuthzLevelSessionHolder, Audit: true, Action: "deselecting networks"}, + servicePath + "ForwardingRules": {Level: AuthzLevelSessionHolder, Action: "listing forwarding rules"}, + servicePath + "ExposeService": {Level: AuthzLevelSessionHolder, Audit: true, Action: "exposing a service"}, + servicePath + "GetPeerSSHHostKey": {Level: AuthzLevelSessionHolder, Action: "reading a peer SSH host key"}, + servicePath + "RequestJWTAuth": {Level: AuthzLevelSessionHolder, Audit: true, Action: "starting authentication"}, + servicePath + "RequestExtendAuthSession": {Level: AuthzLevelSessionHolder, Action: "extending the session"}, + servicePath + "DismissSessionWarning": {Level: AuthzLevelSessionHolder, Action: "dismissing the session warning"}, + servicePath + "DebugBundle": {Level: AuthzLevelSessionHolder, Audit: true, Action: "creating a debug bundle"}, + servicePath + "SetLogLevel": {Level: AuthzLevelSessionHolder, Action: "changing the log level"}, + servicePath + "SetSyncResponsePersistence": {Level: AuthzLevelSessionHolder, Action: "changing sync persistence"}, + servicePath + "StartCapture": {Level: AuthzLevelSessionHolder, Audit: true, Action: "starting a packet capture"}, + servicePath + "StartBundleCapture": {Level: AuthzLevelSessionHolder, Audit: true, Action: "starting a bundle capture"}, + servicePath + "StopBundleCapture": {Level: AuthzLevelSessionHolder, Action: "stopping a bundle capture"}, + servicePath + "StartCPUProfile": {Level: AuthzLevelSessionHolder, Action: "starting a CPU profile"}, + servicePath + "StopCPUProfile": {Level: AuthzLevelSessionHolder, Action: "stopping a CPU profile"}, + servicePath + "CleanState": {Level: AuthzLevelSessionHolder, Audit: true, Action: "clearing stored state"}, + servicePath + "DeleteState": {Level: AuthzLevelSessionHolder, Audit: true, Action: "deleting stored state"}, + servicePath + "TracePacket": {Level: AuthzLevelSessionHolder, Action: "tracing a packet"}, + servicePath + "TriggerUpdate": {Level: AuthzLevelSessionHolder, Audit: true, Action: "starting an update"}, + + // Root or administrator only. Claiming names an arbitrary principal, so the + // caller asserts who a profile belongs to. Ownership does not enter it. + servicePath + "ClaimProfile": { + Level: AuthzLevelPrivileged, + TargetsProfile: true, + Audit: true, + Action: "claiming a profile", + Command: ElevatedCommand("netbird profile claim "), + }, } func methodPolicyFor(method string) MethodPolicy { if p, ok := methodPolicies[method]; ok { return p } - // TODO: reconsider falling back to Privileged rather than direct DENY. return MethodPolicy{Level: AuthzLevelPrivileged, Audit: true} } diff --git a/client/internal/ipcauth/principal_validate_test.go b/client/internal/ipcauth/principal_validate_test.go new file mode 100644 index 000000000..610f6d70c --- /dev/null +++ b/client/internal/ipcauth/principal_validate_test.go @@ -0,0 +1,79 @@ +package ipcauth + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ValidatePrincipal gates what a user may write. ParsePrincipal gates what is +// read back, and stays lenient so an existing config is never reinterpreted. +func TestValidatePrincipal(t *testing.T) { + unix := runtime.GOOS != "windows" + + for _, tc := range []struct { + in string + valid bool + }{ + {"uid:0", unix}, + {"uid:1000", unix}, + {"uid:4294967295", unix}, + {"uid:4294967296", false}, + {"uid:abc", false}, + {"uid:-1", false}, + {"uid:1000:extra", false}, + {"sid:S-1-5-21-1-2-3-1001", !unix}, + {"sid:S-1-5-18", !unix}, + {"sid:S-1", false}, + {"sid:S-1-5-", false}, + {"sid:hello", false}, + {"sid:X-1-5-18", false}, + {"bogus:1000", false}, + {"uid:", false}, + {"1000", false}, + {"", false}, + } { + t.Run(tc.in, func(t *testing.T) { + got, err := ValidatePrincipal(tc.in) + if !tc.valid { + require.Error(t, err, "%q must not be accepted as an owner", tc.in) + assert.Equal(t, Principal{}, got) + return + } + require.NoError(t, err) + assert.Equal(t, tc.in, got.String()) + }) + } +} + +// The read path must keep accepting what it always did, whatever the write path +// now refuses. +func TestParsePrincipalStaysLenient(t *testing.T) { + for _, in := range []string{"uid:abc", "uid:-1", "sid:hello", "uid:1000:extra"} { + t.Run(in, func(t *testing.T) { + _, ok := ParsePrincipal(in) + assert.True(t, ok, "ParsePrincipal must still read %q, a stored config may carry it", in) + + _, err := ValidatePrincipal(in) + assert.Error(t, err, "but it must not be accepted as new input") + }) + } +} + +// ValidatePrincipal cannot reach the unknown kinds, since ParsePrincipal refuses +// them first. A Principal built in code can carry one, and a privileged writer +// validates the value it was handed rather than a string it parsed. +func TestPrincipalValidateRejectsKindsParsingNeverProduces(t *testing.T) { + for _, p := range []Principal{ + {}, + {Kind: "bogus", Value: "1000"}, + {Kind: KindUID}, + {Kind: KindSID}, + } { + t.Run(p.String(), func(t *testing.T) { + assert.Error(t, p.Validate(), "%v must not be accepted as an owner", p) + }) + } +} diff --git a/client/internal/ipcauth/privilege_denial_test.go b/client/internal/ipcauth/privilege_denial_test.go new file mode 100644 index 000000000..ee2fc716f --- /dev/null +++ b/client/internal/ipcauth/privilege_denial_test.go @@ -0,0 +1,248 @@ +package ipcauth + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" +) + +// Every method names what it does, so a refusal can say what was refused rather +// than quoting a level at the user. +func TestPoliciesDeclareAnAction(t *testing.T) { + for method, policy := range methodPolicies { + assert.NotEmpty(t, policy.Action, "%s declares no Action, its refusals cannot name the operation", method) + } +} + +// A privileged method must also say how to satisfy it, since that is the one +// refusal the caller can act on. +func TestPrivilegedPoliciesDeclareGuidance(t *testing.T) { + for method, policy := range methodPolicies { + if policy.Level != AuthzLevelPrivileged { + continue + } + assert.NotEmpty(t, policy.Command, "%s requires privilege but declares no Command", method) + } +} + +// Only privilege is something the caller can run their way out of. The other +// refusals explain and stop there. +func TestOnlyPrivilegedPoliciesDeclareACommand(t *testing.T) { + for method, policy := range methodPolicies { + if policy.Level == AuthzLevelPrivileged { + continue + } + assert.Empty(t, policy.Command, "%s is not privileged but offers a command", method) + } +} + +func TestDenyPolicyLevelCarriesPrivilegeGuidance(t *testing.T) { + req := Request{ + Identity: KnownForTest(Identity{UID: 1000}), + Level: AuthzLevelIdentified, + Method: servicePath + "ClaimProfile", + } + + err := denyPolicyLevel(req, methodPolicies[servicePath+"ClaimProfile"]) + require.Error(t, err) + + st := gstatus.Convert(err) + assert.Equal(t, codes.PermissionDenied, st.Code()) + + var info *errdetails.ErrorInfo + for _, d := range st.Details() { + if got, ok := d.(*errdetails.ErrorInfo); ok { + info = got + } + } + require.NotNil(t, info, "a privilege refusal must be machine readable") + assert.Equal(t, ErrorReasonPrivilegeRequired, info.GetReason()) + assert.Equal(t, ErrorDomain, info.GetDomain()) + assert.NotEmpty(t, info.GetMetadata()[ErrorMetaSummary]) + assert.NotEmpty(t, info.GetMetadata()[ErrorMetaCommand]) +} + +// A profile that belongs to somebody else is explained, not answered with sudo. +func TestDenyPolicyLevelExplainsAProfileOwnedByAnother(t *testing.T) { + req := Request{ + Identity: KnownForTest(Identity{UID: 1000}), + Level: AuthzLevelIdentified, + Method: servicePath + "SetConfig", + State: stubState{}, + } + + info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"SetConfig"])) + assert.Equal(t, ErrorReasonNotProfileOwner, info.GetReason()) + assert.Contains(t, info.GetMetadata()[ErrorMetaSummary], "belongs to another user") + + _, hasCommand := info.GetMetadata()[ErrorMetaCommand] + assert.False(t, hasCommand, "privilege is not what the method asked for") +} + +// A privileged method that declares nothing still refuses, it just cannot say +// how to satisfy it. This is the methodPolicyFor fallback for an unknown RPC. +func TestDenyPolicyLevelWithoutGuidanceStaysBare(t *testing.T) { + req := Request{ + Identity: KnownForTest(Identity{UID: 1000}), + Level: AuthzLevelIdentified, + Method: servicePath + "NotARealMethod", + } + + err := denyPolicyLevel(req, methodPolicyFor(req.Method)) + require.Error(t, err) + assert.Equal(t, codes.PermissionDenied, gstatus.Convert(err).Code()) + assert.Empty(t, gstatus.Convert(err).Details()) +} + +// stubState stands in for the daemon so a denial can be built without a server. +type stubState struct { + holder Principal + running bool + owns bool + ownsErr error +} + +func (s stubState) SessionHolder() (Principal, bool) { return s.holder, s.running } +func (s stubState) OwnsProfile(Identity, string) (bool, error) { return s.owns, s.ownsErr } + +// A refusal caused by somebody else's connection explains itself and offers no +// command, since the caller cannot end a session that is not theirs. +func TestDenyPolicyLevelExplainsAHeldSession(t *testing.T) { + req := Request{ + Identity: KnownForTest(Identity{UID: 1000}), + Level: AuthzLevelProfileOwner, + Method: servicePath + "Up", + State: stubState{holder: Principal{Kind: KindUID, Value: "4242"}, running: true}, + } + + info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"Up"])) + assert.Equal(t, ErrorReasonSessionHeld, info.GetReason()) + + summary := info.GetMetadata()[ErrorMetaSummary] + assert.Contains(t, summary, "Connecting", "the summary names what was refused") + assert.Contains(t, summary, "another user") + assert.NotContains(t, summary, "4242", "who holds it is not the caller's business") + + // An administrator outranks the session holder, so taking the connection + // down is a remedy the caller can actually be pointed at. + assert.Contains(t, info.GetMetadata()[ErrorMetaCommand], "netbird down") +} + +// With no session running, a caller short of session holder fell short on +// ownership instead, and the refusal says so rather than blaming a session. +func TestDenyPolicyLevelWithNoSessionBlamesOwnership(t *testing.T) { + req := Request{ + Identity: KnownForTest(Identity{UID: 1000}), + Level: AuthzLevelIdentified, + Method: servicePath + "Up", + State: stubState{}, + } + + info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"Up"])) + assert.Equal(t, ErrorReasonNotProfileOwner, info.GetReason()) + assert.NotContains(t, info.GetMetadata()[ErrorMetaSummary], "connected") +} + +// Somebody else's session is not what stops a caller who never owned the +// profile: they are refused for the profile, and netbird down is neither theirs +// to run nor any help. +func TestDenyPolicyLevelBlamesOwnershipWhileASessionRuns(t *testing.T) { + req := Request{ + Identity: KnownForTest(Identity{UID: 1000}), + Level: AuthzLevelIdentified, + Method: servicePath + "Up", + State: stubState{holder: Principal{Kind: KindUID, Value: "4242"}, running: true}, + } + + info := denialDetail(t, denyPolicyLevel(req, methodPolicies[servicePath+"Up"])) + assert.Equal(t, ErrorReasonNotProfileOwner, info.GetReason()) + assert.Contains(t, info.GetMetadata()[ErrorMetaSummary], "belongs to another user") + + _, hasCommand := info.GetMetadata()[ErrorMetaCommand] + assert.False(t, hasCommand, "ending a session does not make the profile theirs") +} + +// A method with no Action still refuses, it just cannot name the operation. +func TestSessionHeldSummaryWithoutAnAction(t *testing.T) { + assert.Contains(t, sessionHeldSummary(""), "This command is refused") + assert.Contains(t, sessionHeldSummary("connecting"), "Connecting is refused") +} + +// denialDetail pulls the machine readable half out of a refusal. +func denialDetail(t *testing.T, err error) *errdetails.ErrorInfo { + t.Helper() + require.Error(t, err) + + st := gstatus.Convert(err) + require.Equal(t, codes.PermissionDenied, st.Code()) + + for _, d := range st.Details() { + if info, ok := d.(*errdetails.ErrorInfo); ok { + require.Equal(t, ErrorDomain, info.GetDomain()) + return info + } + } + t.Fatal("refusal carries no ErrorInfo detail") + return nil +} + +// DenialFrom is the one reader of the detail the builders attach, so the CLI and +// the UI cannot drift on what counts as a refusal. +func TestDenialFromReadsEveryReason(t *testing.T) { + for _, tc := range []struct { + name string + err error + reason string + command bool + }{ + {"privilege", PrivilegeError("Claiming a profile requires root.", "sudo netbird profile claim"), ErrorReasonPrivilegeRequired, true}, + {"session held", SessionHeldError("connecting"), ErrorReasonSessionHeld, true}, + {"not owner", NotOwnerError("switching profile"), ErrorReasonNotProfileOwner, false}, + } { + t.Run(tc.name, func(t *testing.T) { + denial, ok := DenialFrom(tc.err) + require.True(t, ok) + assert.Equal(t, tc.reason, denial.Reason) + assert.NotEmpty(t, denial.Summary) + assert.Equal(t, tc.command, denial.Command != "") + }) + } +} + +func TestDenialFromIgnoresWhatIsNotOurs(t *testing.T) { + _, ok := DenialFrom(nil) + assert.False(t, ok) + + _, ok = DenialFrom(errors.New("connection refused")) + assert.False(t, ok, "a plain error explains no refusal") + + _, ok = DenialFrom(gstatus.Error(codes.PermissionDenied, "denied")) + assert.False(t, ok, "a status with no detail of ours is not ours to reword") +} + +// A wrap must not hide the refusal, since commands add context before printing. +func TestDenialFromSeesThroughWrapping(t *testing.T) { + denial, ok := DenialFrom(fmt.Errorf("up failed: %w", SessionHeldError("connecting"))) + require.True(t, ok) + assert.Equal(t, ErrorReasonSessionHeld, denial.Reason) +} + +// A detail with no summary still refused something, so the status message stands +// in rather than leaving a consumer with nothing to show. +func TestDenialFromFallsBackToTheStatusMessage(t *testing.T) { + st, err := gstatus.New(codes.PermissionDenied, "refused for reasons").WithDetails(&errdetails.ErrorInfo{ + Reason: ErrorReasonSessionHeld, + Domain: ErrorDomain, + }) + require.NoError(t, err) + + denial, ok := DenialFrom(st.Err()) + require.True(t, ok) + assert.Equal(t, "refused for reasons", denial.Summary) +} diff --git a/client/internal/ipcauth/privileged.go b/client/internal/ipcauth/privileged.go index f5e4b1d59..a9ef0f0fa 100644 --- a/client/internal/ipcauth/privileged.go +++ b/client/internal/ipcauth/privileged.go @@ -1,8 +1,15 @@ package ipcauth import ( + "fmt" "os" "runtime" + "strings" + + log "github.com/sirupsen/logrus" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // Fields of the ErrorInfo detail the daemon attaches to a PermissionDenied it @@ -19,6 +26,17 @@ const ( // ErrorMetaCommand is the command that performs the same operation with the // privileges it needs, ready to copy and run. ErrorMetaCommand = "command" + + // ErrorReasonSessionHeld identifies a refusal caused by another user's live + // connection. Nothing the caller can run satisfies it, since the session is + // not theirs to end, so the detail carries no command. + ErrorReasonSessionHeld = "SESSION_HELD" + + // ErrorReasonNotProfileOwner identifies a refusal caused by the profile + // belonging to another account. It carries no command either: privilege is + // not what the method asked for, so telling the caller to elevate would send + // them the wrong way. + ErrorReasonNotProfileOwner = "NOT_PROFILE_OWNER" ) // The identity of the process evaluating callers, captured once because it cannot @@ -144,3 +162,131 @@ func ElevatedCommand(command string) string { func UpCommand(flags string) string { return ElevatedCommand("netbird down") + "; " + ElevatedCommand("netbird up "+flags) } + +// Denial is a refusal the daemon explained, read back off the error it raised. +// The reason identifies which refusal it was, so a consumer can present each one +// in its own way without matching on message text. +type Denial struct { + Reason string + Summary string + Command string +} + +// DenialFrom returns the refusal a daemon error explains, if it explains one. +func DenialFrom(err error) (Denial, bool) { + if err == nil { + return Denial{}, false + } + + st := status.Convert(err) + for _, detail := range st.Details() { + info, ok := detail.(*errdetails.ErrorInfo) + if !ok || info.GetDomain() != ErrorDomain { + continue + } + + summary := info.GetMetadata()[ErrorMetaSummary] + if summary == "" { + // A detail with no summary still refused something. The status + // message carries the same sentence, and showing it beats showing + // a consumer nothing. + summary = strings.TrimSpace(st.Message()) + } + + return Denial{ + Reason: info.GetReason(), + Summary: summary, + Command: info.GetMetadata()[ErrorMetaCommand], + }, true + } + + return Denial{}, false +} + +// PrivilegeError builds the PermissionDenied carrying summary and command. +func PrivilegeError(summary, command string) error { + return denialError(ErrorReasonPrivilegeRequired, summary, command) +} + +// SessionHeldError refuses an operation because another user has the machine +// connected. +func SessionHeldError(action string) error { + return denialError(ErrorReasonSessionHeld, sessionHeldSummary(action), ElevatedCommand("netbird down")) +} + +// NotOwnerError refuses an operation because the profile it addresses belongs to +// somebody else. +func NotOwnerError(action string) error { + return denialError(ErrorReasonNotProfileOwner, notOwnerSummary(action), "") +} + +// sessionHeldSummary says whose the connection is and why that settles it. +func sessionHeldSummary(action string) string { + return refusedSubject(action) + " refused while another user has this machine connected. " + + "The active profile and the connection on it belong to the user who brought it up, " + + "so the connection has to come down before anyone else can use the machine." +} + +// notOwnerSummary says who the profile belongs to and why that settles it. +func notOwnerSummary(action string) string { + return refusedSubject(action) + " refused because the profile it addresses belongs to another user. " + + "A profile and the configuration on it stay with the account that created or claimed it, " + + "so use one of your own or ask an administrator to hand this one over." +} + +// refusedSubject opens a refusal with what was refused, falling back to the +// command itself for a method that names no action. +func refusedSubject(action string) string { + if action == "" { + return "This command is" + } + return capitalize(action) + " is" +} + +// denialError builds a PermissionDenied carrying a summary a client can render, +// and a command when there is one to give. +func denialError(reason, summary, command string) error { + message := summary + metadata := map[string]string{ErrorMetaSummary: summary} + if command != "" { + message = fmt.Sprintf("%s\n\n%s", summary, command) + metadata[ErrorMetaCommand] = command + } + + st := status.New(codes.PermissionDenied, message) + detailed, err := st.WithDetails(&errdetails.ErrorInfo{ + Reason: reason, + Domain: ErrorDomain, + Metadata: metadata, + }) + if err != nil { + log.Debugf("attach %s error detail: %v", reason, err) + return st.Err() + } + return detailed.Err() +} + +// RequiredActor names who may perform the operation and adjusts the command to +// match. A daemon that is not itself privileged delegates to its own identity, so +// telling that host's user to become root is wrong twice over: root is not what the +// daemon checks for, and a rootless container has neither root nor sudo. +func RequiredActor(command string) (string, string) { + self, delegates := SelfDelegatesTo() + if !delegates { + return PrivilegedActor(), command + } + return fmt.Sprintf("the user the daemon runs as (%s)", self), strings.ReplaceAll(command, "sudo ", "") +} + +// PrivilegeSummary states what is refused and what it needs, in one sentence +// that reads the same in a dialog and in a terminal. +func PrivilegeSummary(action, actor string) string { + return fmt.Sprintf("%s requires %s.", capitalize(action), actor) +} + +func capitalize(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/client/internal/profilemanager/migration.go b/client/internal/profilemanager/migration.go index ec9f8a954..ebe1b94a4 100644 --- a/client/internal/profilemanager/migration.go +++ b/client/internal/profilemanager/migration.go @@ -193,7 +193,7 @@ func (s *ServiceManager) stampActiveUserDir(profiles []Profile, active *ActivePr return fmt.Errorf("resolve %q: %w", active.Username, err) } - principal, ok := principalForUser(u) + principal, ok := PrincipalForUser(u) if !ok { return fmt.Errorf("account %q has no usable id %q", active.Username, u.Uid) } @@ -231,10 +231,10 @@ func takesActiveAccountOwner(p *Profile, dir string) bool { return p.ID == defaultProfileName && !defaultProfileClaimDisabled() } -// principalForUser turns a resolved account into an owner principal. os/user +// PrincipalForUser turns a resolved account into an owner principal. os/user // reports a numeric id on Unix and a SID on Windows, which is what tells the // two kinds apart without a build tag. -func principalForUser(u *user.User) (string, bool) { +func PrincipalForUser(u *user.User) (string, bool) { if uid, err := strconv.ParseUint(u.Uid, 10, 32); err == nil { return ipcauth.UIDPrincipal(uint32(uid)), true } diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go index dccbf0693..0fb1d6c6d 100644 --- a/client/internal/profilemanager/service.go +++ b/client/internal/profilemanager/service.go @@ -959,6 +959,28 @@ func readProfileOwners(path string) ([]ipcauth.Principal, error) { return []ipcauth.Principal{principal}, nil } +// ClaimProfile records a principal as a profile's sole owner, replacing whoever +// is recorded now. +// +// The principal comes from an administrator rather than from the kernel, so it +// is never turned into an Identity on the way and it is validated here. +func (s *ServiceManager) ClaimProfile(p *Profile, principal ipcauth.Principal) error { + if err := principal.Validate(); err != nil { + return fmt.Errorf("claim %s: %w", p.ID, err) + } + + path, err := p.FilePath() + if err != nil { + return fmt.Errorf("profile path: %w", err) + } + if err := stampPrincipal(path, principal.String()); err != nil { + return fmt.Errorf("claim %s for %s: %w", p.ID, principal, err) + } + p.Owners = []ipcauth.Principal{principal} + log.Infof("claimed profile %s for %s", path, principal) + return nil +} + // StampOwner records a caller as a profile's owner, replacing whoever is // recorded now. func StampOwner(path string, owner ipcauth.Identity) error { diff --git a/client/internal/profilemanager/service_test.go b/client/internal/profilemanager/service_test.go index e4c669f53..09a829f69 100644 --- a/client/internal/profilemanager/service_test.go +++ b/client/internal/profilemanager/service_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "os" "os/user" "path/filepath" @@ -681,6 +682,115 @@ func TestActiveProfilePath_RefusesToGuessBetweenNamesakes(t *testing.T) { }) } +// claimIdentity names a caller the platform could actually hold: a uid names +// nobody on Windows, where a caller is a SID. The account itself need not +// exist, since a claim never looks one up. +func claimIdentity(n uint32) ipcauth.Identity { + if runtime.GOOS == "windows" { + return ipcauth.KnownForTest(ipcauth.Identity{SID: fmt.Sprintf("S-1-5-21-1-2-3-%d", n)}) + } + return ipcauth.KnownForTest(ipcauth.Identity{UID: n}) +} + +// claimPrincipal is the owner principal that claimIdentity's caller matches. +func claimPrincipal(t *testing.T, n uint32) ipcauth.Principal { + t.Helper() + p, err := ipcauth.ValidatePrincipal(ipcauth.OwnerPrincipalForIdentity(claimIdentity(n))) + require.NoError(t, err) + return p +} + +func TestClaimProfile_RecordsAnArbitraryPrincipal(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) { + p, err := sm.AddProfile("work", nil) + require.NoError(t, err) + require.Empty(t, readOwners(t, p.Path)) + + owner := claimPrincipal(t, 4242) + require.NoError(t, sm.ClaimProfile(p, owner)) + assert.Equal(t, []string{owner.String()}, readOwners(t, p.Path)) + + alice := claimIdentity(4242) + bob := claimIdentity(5252) + assert.True(t, p.AccessibleBy(alice), "the claim is reflected in memory, not only on disk") + assert.False(t, p.AccessibleBy(bob)) + + got, err := sm.ListProfiles(alice) + require.NoError(t, err) + assert.Contains(t, profileIDs(got), p.ID.String()) + + got, err = sm.ListProfiles(bob) + require.NoError(t, err) + assert.NotContains(t, profileIDs(got), p.ID.String()) + }) +} + +func TestClaimProfile_ReplacesTheRecordedOwner(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) { + p, err := sm.AddProfile("work", nil) + require.NoError(t, err) + + require.NoError(t, sm.ClaimProfile(p, claimPrincipal(t, 4242))) + require.NoError(t, sm.ClaimProfile(p, claimPrincipal(t, 5252))) + + assert.Equal(t, []string{claimPrincipal(t, 5252).String()}, readOwners(t, p.Path), + "handing a profile over replaces the owner rather than adding one") + + old := claimIdentity(4242) + assert.False(t, p.AccessibleBy(old), "the previous owner loses access") + }) +} + +func TestClaimProfile_ClaimsTheDefaultProfile(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) { + all, err := sm.loadAllProfiles() + require.NoError(t, err) + var def *Profile + for i := range all { + if all[i].ID == defaultProfileName { + def = &all[i] + } + } + require.NotNil(t, def) + + owner := claimPrincipal(t, 4242) + require.NoError(t, sm.ClaimProfile(def, owner)) + assert.Equal(t, []string{owner.String()}, readOwners(t, DefaultConfigPath), + "the headless case this exists for: no console user, owner recorded by hand") + + alice := claimIdentity(4242) + got, err := sm.ListProfiles(alice) + require.NoError(t, err) + assert.Contains(t, profileIDs(got), defaultProfileName) + }) +} + +// ClaimProfile writes the value the ownership check reads back, so an owner no +// caller could ever match is refused here rather than in whichever caller +// happens to reach it. +func TestClaimProfile_RefusesAnOwnerNobodyCanMatch(t *testing.T) { + for _, tc := range []struct { + name string + principal ipcauth.Principal + }{ + {"no kind", ipcauth.Principal{}}, + {"unknown kind", ipcauth.Principal{Kind: "bogus", Value: "1000"}}, + {"uid that is not a number", ipcauth.Principal{Kind: ipcauth.KindUID, Value: "abc"}}, + {"sid that is not a sid", ipcauth.Principal{Kind: ipcauth.KindSID, Value: "any"}}, + } { + t.Run(tc.name, func(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) { + p, err := sm.AddProfile("work", nil) + require.NoError(t, err) + + require.Error(t, sm.ClaimProfile(p, tc.principal)) + assert.Empty(t, readOwners(t, p.Path), "a refused claim records nothing") + assert.Empty(t, p.Owners, "and leaves the loaded profile as it was") + }) + }) + } +} + func TestListProfiles_ClaimKeepsFieldsThisVersionDoesNotModel(t *testing.T) { withLegacyLayout(t, func(sm *ServiceManager, configDir string) { // What a client newer than this one leaves behind: a key Config has no diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index f59a4c59b..39113a215 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -4915,6 +4915,116 @@ func (x *RemoveProfileResponse) GetId() string { return "" } +type ClaimProfileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // handle is an exact ID, a unique ID prefix, or a unique display name. + // Resolution happens server-side. + Handle string `protobuf:"bytes,1,opt,name=handle,proto3" json:"handle,omitempty"` + // owner is the principal to record, "uid:1000" or "sid:S-1-5-21-...". + // The daemon validates its shape and does not require the account to exist. + Owner string `protobuf:"bytes,2,opt,name=owner,proto3" json:"owner,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClaimProfileRequest) Reset() { + *x = ClaimProfileRequest{} + mi := &file_daemon_proto_msgTypes[66] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClaimProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClaimProfileRequest) ProtoMessage() {} + +func (x *ClaimProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[66] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClaimProfileRequest.ProtoReflect.Descriptor instead. +func (*ClaimProfileRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{66} +} + +func (x *ClaimProfileRequest) GetHandle() string { + if x != nil { + return x.Handle + } + return "" +} + +func (x *ClaimProfileRequest) GetOwner() string { + if x != nil { + return x.Owner + } + return "" +} + +type ClaimProfileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // id is the full resolved ID of the claimed profile. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // owner is the principal recorded, echoed back for confirmation. + Owner string `protobuf:"bytes,2,opt,name=owner,proto3" json:"owner,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClaimProfileResponse) Reset() { + *x = ClaimProfileResponse{} + mi := &file_daemon_proto_msgTypes[67] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClaimProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClaimProfileResponse) ProtoMessage() {} + +func (x *ClaimProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[67] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClaimProfileResponse.ProtoReflect.Descriptor instead. +func (*ClaimProfileResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{67} +} + +func (x *ClaimProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ClaimProfileResponse) GetOwner() string { + if x != nil { + return x.Owner + } + return "" +} + type ListProfilesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` @@ -4924,7 +5034,7 @@ type ListProfilesRequest struct { func (x *ListProfilesRequest) Reset() { *x = ListProfilesRequest{} - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4936,7 +5046,7 @@ func (x *ListProfilesRequest) String() string { func (*ListProfilesRequest) ProtoMessage() {} func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4949,7 +5059,7 @@ func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesRequest.ProtoReflect.Descriptor instead. func (*ListProfilesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{66} + return file_daemon_proto_rawDescGZIP(), []int{68} } func (x *ListProfilesRequest) GetUsername() string { @@ -4968,7 +5078,7 @@ type ListProfilesResponse struct { func (x *ListProfilesResponse) Reset() { *x = ListProfilesResponse{} - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4980,7 +5090,7 @@ func (x *ListProfilesResponse) String() string { func (*ListProfilesResponse) ProtoMessage() {} func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4993,7 +5103,7 @@ func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesResponse.ProtoReflect.Descriptor instead. func (*ListProfilesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{67} + return file_daemon_proto_rawDescGZIP(), []int{69} } func (x *ListProfilesResponse) GetProfiles() []*Profile { @@ -5015,7 +5125,7 @@ type Profile struct { func (x *Profile) Reset() { *x = Profile{} - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5027,7 +5137,7 @@ func (x *Profile) String() string { func (*Profile) ProtoMessage() {} func (x *Profile) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5040,7 +5150,7 @@ func (x *Profile) ProtoReflect() protoreflect.Message { // Deprecated: Use Profile.ProtoReflect.Descriptor instead. func (*Profile) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{68} + return file_daemon_proto_rawDescGZIP(), []int{70} } func (x *Profile) GetName() string { @@ -5079,7 +5189,7 @@ type GetActiveProfileRequest struct { func (x *GetActiveProfileRequest) Reset() { *x = GetActiveProfileRequest{} - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5091,7 +5201,7 @@ func (x *GetActiveProfileRequest) String() string { func (*GetActiveProfileRequest) ProtoMessage() {} func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[69] + mi := &file_daemon_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5104,7 +5214,7 @@ func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileRequest.ProtoReflect.Descriptor instead. func (*GetActiveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{69} + return file_daemon_proto_rawDescGZIP(), []int{71} } type GetActiveProfileResponse struct { @@ -5118,7 +5228,7 @@ type GetActiveProfileResponse struct { func (x *GetActiveProfileResponse) Reset() { *x = GetActiveProfileResponse{} - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5130,7 +5240,7 @@ func (x *GetActiveProfileResponse) String() string { func (*GetActiveProfileResponse) ProtoMessage() {} func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5143,7 +5253,7 @@ func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileResponse.ProtoReflect.Descriptor instead. func (*GetActiveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{70} + return file_daemon_proto_rawDescGZIP(), []int{72} } func (x *GetActiveProfileResponse) GetProfileName() string { @@ -5177,7 +5287,7 @@ type LogoutRequest struct { func (x *LogoutRequest) Reset() { *x = LogoutRequest{} - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5189,7 +5299,7 @@ func (x *LogoutRequest) String() string { func (*LogoutRequest) ProtoMessage() {} func (x *LogoutRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5202,7 +5312,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. func (*LogoutRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{71} + return file_daemon_proto_rawDescGZIP(), []int{73} } func (x *LogoutRequest) GetProfileName() string { @@ -5227,7 +5337,7 @@ type LogoutResponse struct { func (x *LogoutResponse) Reset() { *x = LogoutResponse{} - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5239,7 +5349,7 @@ func (x *LogoutResponse) String() string { func (*LogoutResponse) ProtoMessage() {} func (x *LogoutResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5252,7 +5362,7 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. func (*LogoutResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{72} + return file_daemon_proto_rawDescGZIP(), []int{74} } type WailsUIReadyRequest struct { @@ -5263,7 +5373,7 @@ type WailsUIReadyRequest struct { func (x *WailsUIReadyRequest) Reset() { *x = WailsUIReadyRequest{} - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5275,7 +5385,7 @@ func (x *WailsUIReadyRequest) String() string { func (*WailsUIReadyRequest) ProtoMessage() {} func (x *WailsUIReadyRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5288,7 +5398,7 @@ func (x *WailsUIReadyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WailsUIReadyRequest.ProtoReflect.Descriptor instead. func (*WailsUIReadyRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{73} + return file_daemon_proto_rawDescGZIP(), []int{75} } type WailsUIReadyResponse struct { @@ -5299,7 +5409,7 @@ type WailsUIReadyResponse struct { func (x *WailsUIReadyResponse) Reset() { *x = WailsUIReadyResponse{} - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5311,7 +5421,7 @@ func (x *WailsUIReadyResponse) String() string { func (*WailsUIReadyResponse) ProtoMessage() {} func (x *WailsUIReadyResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5324,7 +5434,7 @@ func (x *WailsUIReadyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WailsUIReadyResponse.ProtoReflect.Descriptor instead. func (*WailsUIReadyResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{74} + return file_daemon_proto_rawDescGZIP(), []int{76} } type GetFeaturesRequest struct { @@ -5335,7 +5445,7 @@ type GetFeaturesRequest struct { func (x *GetFeaturesRequest) Reset() { *x = GetFeaturesRequest{} - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5347,7 +5457,7 @@ func (x *GetFeaturesRequest) String() string { func (*GetFeaturesRequest) ProtoMessage() {} func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5360,7 +5470,7 @@ func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesRequest.ProtoReflect.Descriptor instead. func (*GetFeaturesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{75} + return file_daemon_proto_rawDescGZIP(), []int{77} } type GetFeaturesResponse struct { @@ -5380,7 +5490,7 @@ type GetFeaturesResponse struct { func (x *GetFeaturesResponse) Reset() { *x = GetFeaturesResponse{} - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5392,7 +5502,7 @@ func (x *GetFeaturesResponse) String() string { func (*GetFeaturesResponse) ProtoMessage() {} func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5405,7 +5515,7 @@ func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesResponse.ProtoReflect.Descriptor instead. func (*GetFeaturesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{76} + return file_daemon_proto_rawDescGZIP(), []int{78} } func (x *GetFeaturesResponse) GetDisableProfiles() bool { @@ -5450,7 +5560,7 @@ type MDMManagedFieldsViolation struct { func (x *MDMManagedFieldsViolation) Reset() { *x = MDMManagedFieldsViolation{} - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5462,7 +5572,7 @@ func (x *MDMManagedFieldsViolation) String() string { func (*MDMManagedFieldsViolation) ProtoMessage() {} func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5475,7 +5585,7 @@ func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message { // Deprecated: Use MDMManagedFieldsViolation.ProtoReflect.Descriptor instead. func (*MDMManagedFieldsViolation) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{77} + return file_daemon_proto_rawDescGZIP(), []int{79} } func (x *MDMManagedFieldsViolation) GetFields() []string { @@ -5493,7 +5603,7 @@ type TriggerUpdateRequest struct { func (x *TriggerUpdateRequest) Reset() { *x = TriggerUpdateRequest{} - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5505,7 +5615,7 @@ func (x *TriggerUpdateRequest) String() string { func (*TriggerUpdateRequest) ProtoMessage() {} func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5518,7 +5628,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead. func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{78} + return file_daemon_proto_rawDescGZIP(), []int{80} } type TriggerUpdateResponse struct { @@ -5531,7 +5641,7 @@ type TriggerUpdateResponse struct { func (x *TriggerUpdateResponse) Reset() { *x = TriggerUpdateResponse{} - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5543,7 +5653,7 @@ func (x *TriggerUpdateResponse) String() string { func (*TriggerUpdateResponse) ProtoMessage() {} func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5556,7 +5666,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead. func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{79} + return file_daemon_proto_rawDescGZIP(), []int{81} } func (x *TriggerUpdateResponse) GetSuccess() bool { @@ -5584,7 +5694,7 @@ type GetPeerSSHHostKeyRequest struct { func (x *GetPeerSSHHostKeyRequest) Reset() { *x = GetPeerSSHHostKeyRequest{} - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5596,7 +5706,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string { func (*GetPeerSSHHostKeyRequest) ProtoMessage() {} func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5609,7 +5719,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{80} + return file_daemon_proto_rawDescGZIP(), []int{82} } func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string { @@ -5636,7 +5746,7 @@ type GetPeerSSHHostKeyResponse struct { func (x *GetPeerSSHHostKeyResponse) Reset() { *x = GetPeerSSHHostKeyResponse{} - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5648,7 +5758,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string { func (*GetPeerSSHHostKeyResponse) ProtoMessage() {} func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5661,7 +5771,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{81} + return file_daemon_proto_rawDescGZIP(), []int{83} } func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte { @@ -5707,7 +5817,7 @@ type RequestJWTAuthRequest struct { func (x *RequestJWTAuthRequest) Reset() { *x = RequestJWTAuthRequest{} - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5719,7 +5829,7 @@ func (x *RequestJWTAuthRequest) String() string { func (*RequestJWTAuthRequest) ProtoMessage() {} func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5732,7 +5842,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead. func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{82} + return file_daemon_proto_rawDescGZIP(), []int{84} } func (x *RequestJWTAuthRequest) GetHint() string { @@ -5772,7 +5882,7 @@ type RequestJWTAuthResponse struct { func (x *RequestJWTAuthResponse) Reset() { *x = RequestJWTAuthResponse{} - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5784,7 +5894,7 @@ func (x *RequestJWTAuthResponse) String() string { func (*RequestJWTAuthResponse) ProtoMessage() {} func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5797,7 +5907,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead. func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{83} + return file_daemon_proto_rawDescGZIP(), []int{85} } func (x *RequestJWTAuthResponse) GetVerificationURI() string { @@ -5862,7 +5972,7 @@ type WaitJWTTokenRequest struct { func (x *WaitJWTTokenRequest) Reset() { *x = WaitJWTTokenRequest{} - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5874,7 +5984,7 @@ func (x *WaitJWTTokenRequest) String() string { func (*WaitJWTTokenRequest) ProtoMessage() {} func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5887,7 +5997,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead. func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{84} + return file_daemon_proto_rawDescGZIP(), []int{86} } func (x *WaitJWTTokenRequest) GetDeviceCode() string { @@ -5919,7 +6029,7 @@ type WaitJWTTokenResponse struct { func (x *WaitJWTTokenResponse) Reset() { *x = WaitJWTTokenResponse{} - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5931,7 +6041,7 @@ func (x *WaitJWTTokenResponse) String() string { func (*WaitJWTTokenResponse) ProtoMessage() {} func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5944,7 +6054,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead. func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{85} + return file_daemon_proto_rawDescGZIP(), []int{87} } func (x *WaitJWTTokenResponse) GetToken() string { @@ -5984,7 +6094,7 @@ type RequestExtendAuthSessionRequest struct { func (x *RequestExtendAuthSessionRequest) Reset() { *x = RequestExtendAuthSessionRequest{} - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5996,7 +6106,7 @@ func (x *RequestExtendAuthSessionRequest) String() string { func (*RequestExtendAuthSessionRequest) ProtoMessage() {} func (x *RequestExtendAuthSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6009,7 +6119,7 @@ func (x *RequestExtendAuthSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestExtendAuthSessionRequest.ProtoReflect.Descriptor instead. func (*RequestExtendAuthSessionRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{86} + return file_daemon_proto_rawDescGZIP(), []int{88} } func (x *RequestExtendAuthSessionRequest) GetHint() string { @@ -6047,7 +6157,7 @@ type RequestExtendAuthSessionResponse struct { func (x *RequestExtendAuthSessionResponse) Reset() { *x = RequestExtendAuthSessionResponse{} - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6059,7 +6169,7 @@ func (x *RequestExtendAuthSessionResponse) String() string { func (*RequestExtendAuthSessionResponse) ProtoMessage() {} func (x *RequestExtendAuthSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6072,7 +6182,7 @@ func (x *RequestExtendAuthSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestExtendAuthSessionResponse.ProtoReflect.Descriptor instead. func (*RequestExtendAuthSessionResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{87} + return file_daemon_proto_rawDescGZIP(), []int{89} } func (x *RequestExtendAuthSessionResponse) GetVerificationURI() string { @@ -6125,7 +6235,7 @@ type WaitExtendAuthSessionRequest struct { func (x *WaitExtendAuthSessionRequest) Reset() { *x = WaitExtendAuthSessionRequest{} - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6137,7 +6247,7 @@ func (x *WaitExtendAuthSessionRequest) String() string { func (*WaitExtendAuthSessionRequest) ProtoMessage() {} func (x *WaitExtendAuthSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6150,7 +6260,7 @@ func (x *WaitExtendAuthSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitExtendAuthSessionRequest.ProtoReflect.Descriptor instead. func (*WaitExtendAuthSessionRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{88} + return file_daemon_proto_rawDescGZIP(), []int{90} } func (x *WaitExtendAuthSessionRequest) GetDeviceCode() string { @@ -6179,7 +6289,7 @@ type WaitExtendAuthSessionResponse struct { func (x *WaitExtendAuthSessionResponse) Reset() { *x = WaitExtendAuthSessionResponse{} - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6191,7 +6301,7 @@ func (x *WaitExtendAuthSessionResponse) String() string { func (*WaitExtendAuthSessionResponse) ProtoMessage() {} func (x *WaitExtendAuthSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6204,7 +6314,7 @@ func (x *WaitExtendAuthSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitExtendAuthSessionResponse.ProtoReflect.Descriptor instead. func (*WaitExtendAuthSessionResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{89} + return file_daemon_proto_rawDescGZIP(), []int{91} } func (x *WaitExtendAuthSessionResponse) GetSessionExpiresAt() *timestamppb.Timestamp { @@ -6224,7 +6334,7 @@ type DismissSessionWarningRequest struct { func (x *DismissSessionWarningRequest) Reset() { *x = DismissSessionWarningRequest{} - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6236,7 +6346,7 @@ func (x *DismissSessionWarningRequest) String() string { func (*DismissSessionWarningRequest) ProtoMessage() {} func (x *DismissSessionWarningRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6249,7 +6359,7 @@ func (x *DismissSessionWarningRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DismissSessionWarningRequest.ProtoReflect.Descriptor instead. func (*DismissSessionWarningRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{90} + return file_daemon_proto_rawDescGZIP(), []int{92} } // DismissSessionWarningResponse acknowledges the dismissal. Carries no @@ -6263,7 +6373,7 @@ type DismissSessionWarningResponse struct { func (x *DismissSessionWarningResponse) Reset() { *x = DismissSessionWarningResponse{} - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6275,7 +6385,7 @@ func (x *DismissSessionWarningResponse) String() string { func (*DismissSessionWarningResponse) ProtoMessage() {} func (x *DismissSessionWarningResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6288,7 +6398,7 @@ func (x *DismissSessionWarningResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DismissSessionWarningResponse.ProtoReflect.Descriptor instead. func (*DismissSessionWarningResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{91} + return file_daemon_proto_rawDescGZIP(), []int{93} } // StartCPUProfileRequest for starting CPU profiling @@ -6300,7 +6410,7 @@ type StartCPUProfileRequest struct { func (x *StartCPUProfileRequest) Reset() { *x = StartCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6312,7 +6422,7 @@ func (x *StartCPUProfileRequest) String() string { func (*StartCPUProfileRequest) ProtoMessage() {} func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6325,7 +6435,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{92} + return file_daemon_proto_rawDescGZIP(), []int{94} } // StartCPUProfileResponse confirms CPU profiling has started @@ -6337,7 +6447,7 @@ type StartCPUProfileResponse struct { func (x *StartCPUProfileResponse) Reset() { *x = StartCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6349,7 +6459,7 @@ func (x *StartCPUProfileResponse) String() string { func (*StartCPUProfileResponse) ProtoMessage() {} func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6362,7 +6472,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{93} + return file_daemon_proto_rawDescGZIP(), []int{95} } // StopCPUProfileRequest for stopping CPU profiling @@ -6374,7 +6484,7 @@ type StopCPUProfileRequest struct { func (x *StopCPUProfileRequest) Reset() { *x = StopCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[94] + mi := &file_daemon_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6386,7 +6496,7 @@ func (x *StopCPUProfileRequest) String() string { func (*StopCPUProfileRequest) ProtoMessage() {} func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[94] + mi := &file_daemon_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6399,7 +6509,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{94} + return file_daemon_proto_rawDescGZIP(), []int{96} } // StopCPUProfileResponse confirms CPU profiling has stopped @@ -6411,7 +6521,7 @@ type StopCPUProfileResponse struct { func (x *StopCPUProfileResponse) Reset() { *x = StopCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[95] + mi := &file_daemon_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6423,7 +6533,7 @@ func (x *StopCPUProfileResponse) String() string { func (*StopCPUProfileResponse) ProtoMessage() {} func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[95] + mi := &file_daemon_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6436,7 +6546,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{95} + return file_daemon_proto_rawDescGZIP(), []int{97} } type InstallerResultRequest struct { @@ -6447,7 +6557,7 @@ type InstallerResultRequest struct { func (x *InstallerResultRequest) Reset() { *x = InstallerResultRequest{} - mi := &file_daemon_proto_msgTypes[96] + mi := &file_daemon_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6459,7 +6569,7 @@ func (x *InstallerResultRequest) String() string { func (*InstallerResultRequest) ProtoMessage() {} func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[96] + mi := &file_daemon_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6472,7 +6582,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead. func (*InstallerResultRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{96} + return file_daemon_proto_rawDescGZIP(), []int{98} } type InstallerResultResponse struct { @@ -6485,7 +6595,7 @@ type InstallerResultResponse struct { func (x *InstallerResultResponse) Reset() { *x = InstallerResultResponse{} - mi := &file_daemon_proto_msgTypes[97] + mi := &file_daemon_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6497,7 +6607,7 @@ func (x *InstallerResultResponse) String() string { func (*InstallerResultResponse) ProtoMessage() {} func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[97] + mi := &file_daemon_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6510,7 +6620,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead. func (*InstallerResultResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{97} + return file_daemon_proto_rawDescGZIP(), []int{99} } func (x *InstallerResultResponse) GetSuccess() bool { @@ -6543,7 +6653,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} - mi := &file_daemon_proto_msgTypes[98] + mi := &file_daemon_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6555,7 +6665,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[98] + mi := &file_daemon_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6568,7 +6678,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{98} + return file_daemon_proto_rawDescGZIP(), []int{100} } func (x *ExposeServiceRequest) GetPort() uint32 { @@ -6639,7 +6749,7 @@ type ExposeServiceEvent struct { func (x *ExposeServiceEvent) Reset() { *x = ExposeServiceEvent{} - mi := &file_daemon_proto_msgTypes[99] + mi := &file_daemon_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6651,7 +6761,7 @@ func (x *ExposeServiceEvent) String() string { func (*ExposeServiceEvent) ProtoMessage() {} func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[99] + mi := &file_daemon_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6664,7 +6774,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead. func (*ExposeServiceEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{99} + return file_daemon_proto_rawDescGZIP(), []int{101} } func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event { @@ -6705,7 +6815,7 @@ type ExposeServiceReady struct { func (x *ExposeServiceReady) Reset() { *x = ExposeServiceReady{} - mi := &file_daemon_proto_msgTypes[100] + mi := &file_daemon_proto_msgTypes[102] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6717,7 +6827,7 @@ func (x *ExposeServiceReady) String() string { func (*ExposeServiceReady) ProtoMessage() {} func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[100] + mi := &file_daemon_proto_msgTypes[102] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6730,7 +6840,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead. func (*ExposeServiceReady) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{100} + return file_daemon_proto_rawDescGZIP(), []int{102} } func (x *ExposeServiceReady) GetServiceName() string { @@ -6775,7 +6885,7 @@ type StartCaptureRequest struct { func (x *StartCaptureRequest) Reset() { *x = StartCaptureRequest{} - mi := &file_daemon_proto_msgTypes[101] + mi := &file_daemon_proto_msgTypes[103] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6787,7 +6897,7 @@ func (x *StartCaptureRequest) String() string { func (*StartCaptureRequest) ProtoMessage() {} func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[101] + mi := &file_daemon_proto_msgTypes[103] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6800,7 +6910,7 @@ func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead. func (*StartCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{101} + return file_daemon_proto_rawDescGZIP(), []int{103} } func (x *StartCaptureRequest) GetTextOutput() bool { @@ -6854,7 +6964,7 @@ type CapturePacket struct { func (x *CapturePacket) Reset() { *x = CapturePacket{} - mi := &file_daemon_proto_msgTypes[102] + mi := &file_daemon_proto_msgTypes[104] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6866,7 +6976,7 @@ func (x *CapturePacket) String() string { func (*CapturePacket) ProtoMessage() {} func (x *CapturePacket) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[102] + mi := &file_daemon_proto_msgTypes[104] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6879,7 +6989,7 @@ func (x *CapturePacket) ProtoReflect() protoreflect.Message { // Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead. func (*CapturePacket) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{102} + return file_daemon_proto_rawDescGZIP(), []int{104} } func (x *CapturePacket) GetData() []byte { @@ -6900,7 +7010,7 @@ type StartBundleCaptureRequest struct { func (x *StartBundleCaptureRequest) Reset() { *x = StartBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[103] + mi := &file_daemon_proto_msgTypes[105] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6912,7 +7022,7 @@ func (x *StartBundleCaptureRequest) String() string { func (*StartBundleCaptureRequest) ProtoMessage() {} func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[103] + mi := &file_daemon_proto_msgTypes[105] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6925,7 +7035,7 @@ func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{103} + return file_daemon_proto_rawDescGZIP(), []int{105} } func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration { @@ -6943,7 +7053,7 @@ type StartBundleCaptureResponse struct { func (x *StartBundleCaptureResponse) Reset() { *x = StartBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[104] + mi := &file_daemon_proto_msgTypes[106] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6955,7 +7065,7 @@ func (x *StartBundleCaptureResponse) String() string { func (*StartBundleCaptureResponse) ProtoMessage() {} func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[104] + mi := &file_daemon_proto_msgTypes[106] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6968,7 +7078,7 @@ func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{104} + return file_daemon_proto_rawDescGZIP(), []int{106} } type StopBundleCaptureRequest struct { @@ -6979,7 +7089,7 @@ type StopBundleCaptureRequest struct { func (x *StopBundleCaptureRequest) Reset() { *x = StopBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[105] + mi := &file_daemon_proto_msgTypes[107] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6991,7 +7101,7 @@ func (x *StopBundleCaptureRequest) String() string { func (*StopBundleCaptureRequest) ProtoMessage() {} func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[105] + mi := &file_daemon_proto_msgTypes[107] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7004,7 +7114,7 @@ func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{105} + return file_daemon_proto_rawDescGZIP(), []int{107} } type StopBundleCaptureResponse struct { @@ -7015,7 +7125,7 @@ type StopBundleCaptureResponse struct { func (x *StopBundleCaptureResponse) Reset() { *x = StopBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[106] + mi := &file_daemon_proto_msgTypes[108] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7027,7 +7137,7 @@ func (x *StopBundleCaptureResponse) String() string { func (*StopBundleCaptureResponse) ProtoMessage() {} func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[106] + mi := &file_daemon_proto_msgTypes[108] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7040,7 +7150,7 @@ func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{106} + return file_daemon_proto_rawDescGZIP(), []int{108} } type PortInfo_Range struct { @@ -7053,7 +7163,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} - mi := &file_daemon_proto_msgTypes[108] + mi := &file_daemon_proto_msgTypes[110] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7065,7 +7175,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[108] + mi := &file_daemon_proto_msgTypes[110] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7563,7 +7673,13 @@ const file_daemon_proto_rawDesc = "" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\"'\n" + "\x15RemoveProfileResponse\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"1\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"C\n" + + "\x13ClaimProfileRequest\x12\x16\n" + + "\x06handle\x18\x01 \x01(\tR\x06handle\x12\x14\n" + + "\x05owner\x18\x02 \x01(\tR\x05owner\"<\n" + + "\x14ClaimProfileResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + + "\x05owner\x18\x02 \x01(\tR\x05owner\"1\n" + "\x13ListProfilesRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\"C\n" + "\x14ListProfilesResponse\x12+\n" + @@ -7714,7 +7830,7 @@ const file_daemon_proto_rawDesc = "" + "\n" + "EXPOSE_UDP\x10\x03\x12\x0e\n" + "\n" + - "EXPOSE_TLS\x10\x042\xa3\x1c\n" + + "EXPOSE_TLS\x10\x042\xf0\x1c\n" + "\rDaemonService\x126\n" + "\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" + "\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" + @@ -7749,6 +7865,7 @@ const file_daemon_proto_rawDesc = "" + "AddProfile\x12\x19.daemon.AddProfileRequest\x1a\x1a.daemon.AddProfileResponse\"\x00\x12N\n" + "\rRenameProfile\x12\x1c.daemon.RenameProfileRequest\x1a\x1d.daemon.RenameProfileResponse\"\x00\x12N\n" + "\rRemoveProfile\x12\x1c.daemon.RemoveProfileRequest\x1a\x1d.daemon.RemoveProfileResponse\"\x00\x12K\n" + + "\fClaimProfile\x12\x1b.daemon.ClaimProfileRequest\x1a\x1c.daemon.ClaimProfileResponse\"\x00\x12K\n" + "\fListProfiles\x12\x1b.daemon.ListProfilesRequest\x1a\x1c.daemon.ListProfilesResponse\"\x00\x12W\n" + "\x10GetActiveProfile\x12\x1f.daemon.GetActiveProfileRequest\x1a .daemon.GetActiveProfileResponse\"\x00\x129\n" + "\x06Logout\x12\x15.daemon.LogoutRequest\x1a\x16.daemon.LogoutResponse\"\x00\x12H\n" + @@ -7779,7 +7896,7 @@ func file_daemon_proto_rawDescGZIP() []byte { } var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 110) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 112) var file_daemon_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel (ExposeProtocol)(0), // 1: daemon.ExposeProtocol @@ -7851,60 +7968,62 @@ var file_daemon_proto_goTypes = []any{ (*RenameProfileResponse)(nil), // 67: daemon.RenameProfileResponse (*RemoveProfileRequest)(nil), // 68: daemon.RemoveProfileRequest (*RemoveProfileResponse)(nil), // 69: daemon.RemoveProfileResponse - (*ListProfilesRequest)(nil), // 70: daemon.ListProfilesRequest - (*ListProfilesResponse)(nil), // 71: daemon.ListProfilesResponse - (*Profile)(nil), // 72: daemon.Profile - (*GetActiveProfileRequest)(nil), // 73: daemon.GetActiveProfileRequest - (*GetActiveProfileResponse)(nil), // 74: daemon.GetActiveProfileResponse - (*LogoutRequest)(nil), // 75: daemon.LogoutRequest - (*LogoutResponse)(nil), // 76: daemon.LogoutResponse - (*WailsUIReadyRequest)(nil), // 77: daemon.WailsUIReadyRequest - (*WailsUIReadyResponse)(nil), // 78: daemon.WailsUIReadyResponse - (*GetFeaturesRequest)(nil), // 79: daemon.GetFeaturesRequest - (*GetFeaturesResponse)(nil), // 80: daemon.GetFeaturesResponse - (*MDMManagedFieldsViolation)(nil), // 81: daemon.MDMManagedFieldsViolation - (*TriggerUpdateRequest)(nil), // 82: daemon.TriggerUpdateRequest - (*TriggerUpdateResponse)(nil), // 83: daemon.TriggerUpdateResponse - (*GetPeerSSHHostKeyRequest)(nil), // 84: daemon.GetPeerSSHHostKeyRequest - (*GetPeerSSHHostKeyResponse)(nil), // 85: daemon.GetPeerSSHHostKeyResponse - (*RequestJWTAuthRequest)(nil), // 86: daemon.RequestJWTAuthRequest - (*RequestJWTAuthResponse)(nil), // 87: daemon.RequestJWTAuthResponse - (*WaitJWTTokenRequest)(nil), // 88: daemon.WaitJWTTokenRequest - (*WaitJWTTokenResponse)(nil), // 89: daemon.WaitJWTTokenResponse - (*RequestExtendAuthSessionRequest)(nil), // 90: daemon.RequestExtendAuthSessionRequest - (*RequestExtendAuthSessionResponse)(nil), // 91: daemon.RequestExtendAuthSessionResponse - (*WaitExtendAuthSessionRequest)(nil), // 92: daemon.WaitExtendAuthSessionRequest - (*WaitExtendAuthSessionResponse)(nil), // 93: daemon.WaitExtendAuthSessionResponse - (*DismissSessionWarningRequest)(nil), // 94: daemon.DismissSessionWarningRequest - (*DismissSessionWarningResponse)(nil), // 95: daemon.DismissSessionWarningResponse - (*StartCPUProfileRequest)(nil), // 96: daemon.StartCPUProfileRequest - (*StartCPUProfileResponse)(nil), // 97: daemon.StartCPUProfileResponse - (*StopCPUProfileRequest)(nil), // 98: daemon.StopCPUProfileRequest - (*StopCPUProfileResponse)(nil), // 99: daemon.StopCPUProfileResponse - (*InstallerResultRequest)(nil), // 100: daemon.InstallerResultRequest - (*InstallerResultResponse)(nil), // 101: daemon.InstallerResultResponse - (*ExposeServiceRequest)(nil), // 102: daemon.ExposeServiceRequest - (*ExposeServiceEvent)(nil), // 103: daemon.ExposeServiceEvent - (*ExposeServiceReady)(nil), // 104: daemon.ExposeServiceReady - (*StartCaptureRequest)(nil), // 105: daemon.StartCaptureRequest - (*CapturePacket)(nil), // 106: daemon.CapturePacket - (*StartBundleCaptureRequest)(nil), // 107: daemon.StartBundleCaptureRequest - (*StartBundleCaptureResponse)(nil), // 108: daemon.StartBundleCaptureResponse - (*StopBundleCaptureRequest)(nil), // 109: daemon.StopBundleCaptureRequest - (*StopBundleCaptureResponse)(nil), // 110: daemon.StopBundleCaptureResponse - nil, // 111: daemon.Network.ResolvedIPsEntry - (*PortInfo_Range)(nil), // 112: daemon.PortInfo.Range - nil, // 113: daemon.SystemEvent.MetadataEntry - (*durationpb.Duration)(nil), // 114: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 115: google.protobuf.Timestamp + (*ClaimProfileRequest)(nil), // 70: daemon.ClaimProfileRequest + (*ClaimProfileResponse)(nil), // 71: daemon.ClaimProfileResponse + (*ListProfilesRequest)(nil), // 72: daemon.ListProfilesRequest + (*ListProfilesResponse)(nil), // 73: daemon.ListProfilesResponse + (*Profile)(nil), // 74: daemon.Profile + (*GetActiveProfileRequest)(nil), // 75: daemon.GetActiveProfileRequest + (*GetActiveProfileResponse)(nil), // 76: daemon.GetActiveProfileResponse + (*LogoutRequest)(nil), // 77: daemon.LogoutRequest + (*LogoutResponse)(nil), // 78: daemon.LogoutResponse + (*WailsUIReadyRequest)(nil), // 79: daemon.WailsUIReadyRequest + (*WailsUIReadyResponse)(nil), // 80: daemon.WailsUIReadyResponse + (*GetFeaturesRequest)(nil), // 81: daemon.GetFeaturesRequest + (*GetFeaturesResponse)(nil), // 82: daemon.GetFeaturesResponse + (*MDMManagedFieldsViolation)(nil), // 83: daemon.MDMManagedFieldsViolation + (*TriggerUpdateRequest)(nil), // 84: daemon.TriggerUpdateRequest + (*TriggerUpdateResponse)(nil), // 85: daemon.TriggerUpdateResponse + (*GetPeerSSHHostKeyRequest)(nil), // 86: daemon.GetPeerSSHHostKeyRequest + (*GetPeerSSHHostKeyResponse)(nil), // 87: daemon.GetPeerSSHHostKeyResponse + (*RequestJWTAuthRequest)(nil), // 88: daemon.RequestJWTAuthRequest + (*RequestJWTAuthResponse)(nil), // 89: daemon.RequestJWTAuthResponse + (*WaitJWTTokenRequest)(nil), // 90: daemon.WaitJWTTokenRequest + (*WaitJWTTokenResponse)(nil), // 91: daemon.WaitJWTTokenResponse + (*RequestExtendAuthSessionRequest)(nil), // 92: daemon.RequestExtendAuthSessionRequest + (*RequestExtendAuthSessionResponse)(nil), // 93: daemon.RequestExtendAuthSessionResponse + (*WaitExtendAuthSessionRequest)(nil), // 94: daemon.WaitExtendAuthSessionRequest + (*WaitExtendAuthSessionResponse)(nil), // 95: daemon.WaitExtendAuthSessionResponse + (*DismissSessionWarningRequest)(nil), // 96: daemon.DismissSessionWarningRequest + (*DismissSessionWarningResponse)(nil), // 97: daemon.DismissSessionWarningResponse + (*StartCPUProfileRequest)(nil), // 98: daemon.StartCPUProfileRequest + (*StartCPUProfileResponse)(nil), // 99: daemon.StartCPUProfileResponse + (*StopCPUProfileRequest)(nil), // 100: daemon.StopCPUProfileRequest + (*StopCPUProfileResponse)(nil), // 101: daemon.StopCPUProfileResponse + (*InstallerResultRequest)(nil), // 102: daemon.InstallerResultRequest + (*InstallerResultResponse)(nil), // 103: daemon.InstallerResultResponse + (*ExposeServiceRequest)(nil), // 104: daemon.ExposeServiceRequest + (*ExposeServiceEvent)(nil), // 105: daemon.ExposeServiceEvent + (*ExposeServiceReady)(nil), // 106: daemon.ExposeServiceReady + (*StartCaptureRequest)(nil), // 107: daemon.StartCaptureRequest + (*CapturePacket)(nil), // 108: daemon.CapturePacket + (*StartBundleCaptureRequest)(nil), // 109: daemon.StartBundleCaptureRequest + (*StartBundleCaptureResponse)(nil), // 110: daemon.StartBundleCaptureResponse + (*StopBundleCaptureRequest)(nil), // 111: daemon.StopBundleCaptureRequest + (*StopBundleCaptureResponse)(nil), // 112: daemon.StopBundleCaptureResponse + nil, // 113: daemon.Network.ResolvedIPsEntry + (*PortInfo_Range)(nil), // 114: daemon.PortInfo.Range + nil, // 115: daemon.SystemEvent.MetadataEntry + (*durationpb.Duration)(nil), // 116: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 117: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 114, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 116, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration 25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 115, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp - 115, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp - 115, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp - 114, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 117, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 117, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 117, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 116, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration 23, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo 20, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState 19, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState @@ -7915,8 +8034,8 @@ var file_daemon_proto_depIdxs = []int32{ 57, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent 24, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState 31, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network - 111, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry - 112, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range + 113, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry + 114, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range 32, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo 32, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo 33, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule @@ -7927,16 +8046,16 @@ var file_daemon_proto_depIdxs = []int32{ 54, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage 2, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity 3, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category - 115, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp - 113, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry + 117, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp + 115, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry 57, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent - 114, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 72, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile - 115, // 33: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 116, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 74, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile + 117, // 33: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp 1, // 34: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol - 104, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady - 114, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration - 114, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration + 106, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady + 116, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration + 116, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration 30, // 38: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList 5, // 39: daemon.DaemonService.Login:input_type -> daemon.LoginRequest 7, // 40: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest @@ -7957,9 +8076,9 @@ var file_daemon_proto_depIdxs = []int32{ 48, // 55: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest 50, // 56: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest 53, // 57: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest - 105, // 58: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest - 107, // 59: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest - 109, // 60: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest + 107, // 58: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest + 109, // 59: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest + 111, // 60: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest 56, // 61: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest 58, // 62: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest 41, // 63: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest @@ -7968,70 +8087,72 @@ var file_daemon_proto_depIdxs = []int32{ 64, // 66: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest 66, // 67: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest 68, // 68: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest - 70, // 69: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest - 73, // 70: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest - 75, // 71: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest - 79, // 72: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest - 82, // 73: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest - 84, // 74: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest - 86, // 75: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest - 88, // 76: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest - 90, // 77: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest - 92, // 78: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest - 94, // 79: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest - 96, // 80: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest - 98, // 81: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest - 100, // 82: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest - 102, // 83: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest - 77, // 84: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest - 6, // 85: daemon.DaemonService.Login:output_type -> daemon.LoginResponse - 8, // 86: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse - 10, // 87: daemon.DaemonService.Up:output_type -> daemon.UpResponse - 12, // 88: daemon.DaemonService.Status:output_type -> daemon.StatusResponse - 12, // 89: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse - 14, // 90: daemon.DaemonService.Down:output_type -> daemon.DownResponse - 16, // 91: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse - 27, // 92: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse - 29, // 93: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse - 29, // 94: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse - 34, // 95: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse - 36, // 96: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse - 38, // 97: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse - 40, // 98: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse - 45, // 99: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse - 47, // 100: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse - 49, // 101: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse - 51, // 102: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse - 55, // 103: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse - 106, // 104: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket - 108, // 105: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse - 110, // 106: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse - 57, // 107: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent - 59, // 108: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse - 42, // 109: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse - 61, // 110: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse - 63, // 111: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse - 65, // 112: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse - 67, // 113: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse - 69, // 114: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse - 71, // 115: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse - 74, // 116: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse - 76, // 117: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse - 80, // 118: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse - 83, // 119: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse - 85, // 120: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse - 87, // 121: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse - 89, // 122: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse - 91, // 123: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse - 93, // 124: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse - 95, // 125: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse - 97, // 126: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse - 99, // 127: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse - 101, // 128: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse - 103, // 129: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent - 78, // 130: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse - 85, // [85:131] is the sub-list for method output_type - 39, // [39:85] is the sub-list for method input_type + 70, // 69: daemon.DaemonService.ClaimProfile:input_type -> daemon.ClaimProfileRequest + 72, // 70: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest + 75, // 71: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest + 77, // 72: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest + 81, // 73: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest + 84, // 74: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest + 86, // 75: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest + 88, // 76: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest + 90, // 77: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest + 92, // 78: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest + 94, // 79: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest + 96, // 80: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest + 98, // 81: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest + 100, // 82: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest + 102, // 83: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest + 104, // 84: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest + 79, // 85: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest + 6, // 86: daemon.DaemonService.Login:output_type -> daemon.LoginResponse + 8, // 87: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse + 10, // 88: daemon.DaemonService.Up:output_type -> daemon.UpResponse + 12, // 89: daemon.DaemonService.Status:output_type -> daemon.StatusResponse + 12, // 90: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse + 14, // 91: daemon.DaemonService.Down:output_type -> daemon.DownResponse + 16, // 92: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse + 27, // 93: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse + 29, // 94: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse + 29, // 95: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse + 34, // 96: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse + 36, // 97: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse + 38, // 98: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse + 40, // 99: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse + 45, // 100: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse + 47, // 101: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse + 49, // 102: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse + 51, // 103: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse + 55, // 104: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse + 108, // 105: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket + 110, // 106: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse + 112, // 107: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse + 57, // 108: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent + 59, // 109: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse + 42, // 110: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse + 61, // 111: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse + 63, // 112: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse + 65, // 113: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse + 67, // 114: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse + 69, // 115: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse + 71, // 116: daemon.DaemonService.ClaimProfile:output_type -> daemon.ClaimProfileResponse + 73, // 117: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse + 76, // 118: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse + 78, // 119: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse + 82, // 120: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse + 85, // 121: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse + 87, // 122: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse + 89, // 123: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse + 91, // 124: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse + 93, // 125: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse + 95, // 126: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse + 97, // 127: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse + 99, // 128: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse + 101, // 129: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse + 103, // 130: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse + 105, // 131: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent + 80, // 132: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse + 86, // [86:133] is the sub-list for method output_type + 39, // [39:86] is the sub-list for method input_type 39, // [39:39] is the sub-list for extension type_name 39, // [39:39] is the sub-list for extension extendee 0, // [0:39] is the sub-list for field type_name @@ -8053,11 +8174,11 @@ func file_daemon_proto_init() { file_daemon_proto_msgTypes[50].OneofWrappers = []any{} file_daemon_proto_msgTypes[56].OneofWrappers = []any{} file_daemon_proto_msgTypes[58].OneofWrappers = []any{} - file_daemon_proto_msgTypes[71].OneofWrappers = []any{} - file_daemon_proto_msgTypes[76].OneofWrappers = []any{} - file_daemon_proto_msgTypes[82].OneofWrappers = []any{} - file_daemon_proto_msgTypes[86].OneofWrappers = []any{} - file_daemon_proto_msgTypes[99].OneofWrappers = []any{ + file_daemon_proto_msgTypes[73].OneofWrappers = []any{} + file_daemon_proto_msgTypes[78].OneofWrappers = []any{} + file_daemon_proto_msgTypes[84].OneofWrappers = []any{} + file_daemon_proto_msgTypes[88].OneofWrappers = []any{} + file_daemon_proto_msgTypes[101].OneofWrappers = []any{ (*ExposeServiceEvent_Ready)(nil), } type x struct{} @@ -8066,7 +8187,7 @@ func file_daemon_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), NumEnums: 4, - NumMessages: 110, + NumMessages: 112, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.pb.gw.go b/client/proto/daemon.pb.gw.go index b64dfeea1..3129005a6 100644 --- a/client/proto/daemon.pb.gw.go +++ b/client/proto/daemon.pb.gw.go @@ -743,6 +743,30 @@ func local_request_DaemonService_RemoveProfile_0(ctx context.Context, marshaler return msg, metadata, err } +func request_DaemonService_ClaimProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ClaimProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ClaimProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_DaemonService_ClaimProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ClaimProfileRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ClaimProfile(ctx, &protoReq) + return msg, metadata, err +} + func request_DaemonService_ListProfiles_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var ( protoReq ListProfilesRequest @@ -1690,6 +1714,26 @@ func RegisterDaemonServiceHandlerServer(ctx context.Context, mux *runtime.ServeM } forward_DaemonService_RemoveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_DaemonService_ClaimProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ClaimProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/ClaimProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_DaemonService_ClaimProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ClaimProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) mux.Handle(http.MethodPost, pattern_DaemonService_ListProfiles_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -2547,6 +2591,23 @@ func RegisterDaemonServiceHandlerClient(ctx context.Context, mux *runtime.ServeM } forward_DaemonService_RemoveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_DaemonService_ClaimProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ClaimProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/ClaimProfile")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_DaemonService_ClaimProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_DaemonService_ClaimProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) mux.Handle(http.MethodPost, pattern_DaemonService_ListProfiles_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -2853,6 +2914,7 @@ var ( pattern_DaemonService_AddProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "AddProfile"}, "")) pattern_DaemonService_RenameProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RenameProfile"}, "")) pattern_DaemonService_RemoveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RemoveProfile"}, "")) + pattern_DaemonService_ClaimProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ClaimProfile"}, "")) pattern_DaemonService_ListProfiles_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListProfiles"}, "")) pattern_DaemonService_GetActiveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetActiveProfile"}, "")) pattern_DaemonService_Logout_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Logout"}, "")) @@ -2902,6 +2964,7 @@ var ( forward_DaemonService_AddProfile_0 = runtime.ForwardResponseMessage forward_DaemonService_RenameProfile_0 = runtime.ForwardResponseMessage forward_DaemonService_RemoveProfile_0 = runtime.ForwardResponseMessage + forward_DaemonService_ClaimProfile_0 = runtime.ForwardResponseMessage forward_DaemonService_ListProfiles_0 = runtime.ForwardResponseMessage forward_DaemonService_GetActiveProfile_0 = runtime.ForwardResponseMessage forward_DaemonService_Logout_0 = runtime.ForwardResponseMessage diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 9a9b58147..d507874b0 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -100,6 +100,8 @@ service DaemonService { rpc RemoveProfile(RemoveProfileRequest) returns (RemoveProfileResponse) {} + rpc ClaimProfile(ClaimProfileRequest) returns (ClaimProfileResponse) {} + rpc ListProfiles(ListProfilesRequest) returns (ListProfilesResponse) {} rpc GetActiveProfile(GetActiveProfileRequest) returns (GetActiveProfileResponse) {} @@ -823,6 +825,22 @@ message RemoveProfileResponse { string id = 1; } +message ClaimProfileRequest { + // handle is an exact ID, a unique ID prefix, or a unique display name. + // Resolution happens server-side. + string handle = 1; + // owner is the principal to record, "uid:1000" or "sid:S-1-5-21-...". + // The daemon validates its shape and does not require the account to exist. + string owner = 2; +} + +message ClaimProfileResponse { + // id is the full resolved ID of the claimed profile. + string id = 1; + // owner is the principal recorded, echoed back for confirmation. + string owner = 2; +} + message ListProfilesRequest { string username = 1; } diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index 2d01d474d..dd7b61b77 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -49,6 +49,7 @@ const ( DaemonService_AddProfile_FullMethodName = "/daemon.DaemonService/AddProfile" DaemonService_RenameProfile_FullMethodName = "/daemon.DaemonService/RenameProfile" DaemonService_RemoveProfile_FullMethodName = "/daemon.DaemonService/RemoveProfile" + DaemonService_ClaimProfile_FullMethodName = "/daemon.DaemonService/ClaimProfile" DaemonService_ListProfiles_FullMethodName = "/daemon.DaemonService/ListProfiles" DaemonService_GetActiveProfile_FullMethodName = "/daemon.DaemonService/GetActiveProfile" DaemonService_Logout_FullMethodName = "/daemon.DaemonService/Logout" @@ -130,6 +131,7 @@ type DaemonServiceClient interface { AddProfile(ctx context.Context, in *AddProfileRequest, opts ...grpc.CallOption) (*AddProfileResponse, error) RenameProfile(ctx context.Context, in *RenameProfileRequest, opts ...grpc.CallOption) (*RenameProfileResponse, error) RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error) + ClaimProfile(ctx context.Context, in *ClaimProfileRequest, opts ...grpc.CallOption) (*ClaimProfileResponse, error) ListProfiles(ctx context.Context, in *ListProfilesRequest, opts ...grpc.CallOption) (*ListProfilesResponse, error) GetActiveProfile(ctx context.Context, in *GetActiveProfileRequest, opts ...grpc.CallOption) (*GetActiveProfileResponse, error) // Logout disconnects from the network and deletes the peer from the management server @@ -508,6 +510,16 @@ func (c *daemonServiceClient) RemoveProfile(ctx context.Context, in *RemoveProfi return out, nil } +func (c *daemonServiceClient) ClaimProfile(ctx context.Context, in *ClaimProfileRequest, opts ...grpc.CallOption) (*ClaimProfileResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ClaimProfileResponse) + err := c.cc.Invoke(ctx, DaemonService_ClaimProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *daemonServiceClient) ListProfiles(ctx context.Context, in *ListProfilesRequest, opts ...grpc.CallOption) (*ListProfilesResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListProfilesResponse) @@ -740,6 +752,7 @@ type DaemonServiceServer interface { AddProfile(context.Context, *AddProfileRequest) (*AddProfileResponse, error) RenameProfile(context.Context, *RenameProfileRequest) (*RenameProfileResponse, error) RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error) + ClaimProfile(context.Context, *ClaimProfileRequest) (*ClaimProfileResponse, error) ListProfiles(context.Context, *ListProfilesRequest) (*ListProfilesResponse, error) GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error) // Logout disconnects from the network and deletes the peer from the management server @@ -881,6 +894,9 @@ func (UnimplementedDaemonServiceServer) RenameProfile(context.Context, *RenamePr func (UnimplementedDaemonServiceServer) RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error) { return nil, status.Error(codes.Unimplemented, "method RemoveProfile not implemented") } +func (UnimplementedDaemonServiceServer) ClaimProfile(context.Context, *ClaimProfileRequest) (*ClaimProfileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ClaimProfile not implemented") +} func (UnimplementedDaemonServiceServer) ListProfiles(context.Context, *ListProfilesRequest) (*ListProfilesResponse, error) { return nil, status.Error(codes.Unimplemented, "method ListProfiles not implemented") } @@ -1469,6 +1485,24 @@ func _DaemonService_RemoveProfile_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _DaemonService_ClaimProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ClaimProfileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).ClaimProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_ClaimProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).ClaimProfile(ctx, req.(*ClaimProfileRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _DaemonService_ListProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ListProfilesRequest) if err := dec(in); err != nil { @@ -1865,6 +1899,10 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "RemoveProfile", Handler: _DaemonService_RemoveProfile_Handler, }, + { + MethodName: "ClaimProfile", + Handler: _DaemonService_ClaimProfile_Handler, + }, { MethodName: "ListProfiles", Handler: _DaemonService_ListProfiles_Handler, diff --git a/client/server/claim_profile_test.go b/client/server/claim_profile_test.go new file mode 100644 index 000000000..0a3dfd095 --- /dev/null +++ b/client/server/claim_profile_test.go @@ -0,0 +1,185 @@ +package server + +import ( + "context" + "fmt" + "os/user" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/ipcauth" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +// claimOwner names an owner the platform could actually hold, the way +// privilegedIdentity does for callers: a uid names nobody on Windows, where an +// owner is a SID, so a hardcoded one is refused before a test reaches what it +// is checking. The account itself need not exist, since a claim never looks one +// up. +func claimOwner(n uint32) string { + if runtime.GOOS == "windows" { + return ipcauth.OwnerPrincipalForIdentity(ipcauth.Identity{SID: fmt.Sprintf("S-1-5-21-1-2-3-%d", n)}) + } + return ipcauth.OwnerPrincipalForIdentity(ipcauth.Identity{UID: n}) +} + +// claimTestServer points the profile manager at a temp dir holding one default +// profile, which is the profile a claim exists to settle. +func claimTestServer(t *testing.T) *Server { + t.Helper() + + origDir, origPath, origActive := profilemanager.DefaultConfigPathDir, profilemanager.DefaultConfigPath, profilemanager.ActiveProfileStatePath + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDir + profilemanager.DefaultConfigPath = origPath + profilemanager.ActiveProfileStatePath = origActive + }) + + dir := t.TempDir() + profilemanager.DefaultConfigPathDir = dir + profilemanager.DefaultConfigPath = filepath.Join(dir, "default.json") + profilemanager.ActiveProfileStatePath = filepath.Join(dir, "active_profile.json") + + sm := profilemanager.NewServiceManager("") + require.NoError(t, sm.CreateDefaultProfile()) + + srv := newTestServer() + srv.profileManager = sm + return srv +} + +func TestClaimProfile_RecordsTheOwner(t *testing.T) { + srv := claimTestServer(t) + + owner := claimOwner(4242) + resp, err := srv.ClaimProfile(rootCtx(), &proto.ClaimProfileRequest{ + Handle: "default", + Owner: owner, + }) + require.NoError(t, err) + assert.Equal(t, "default", resp.GetId()) + assert.Equal(t, owner, resp.GetOwner()) + + list, err := srv.ListProfiles(rootCtx(), &proto.ListProfilesRequest{}) + require.NoError(t, err) + require.Len(t, list.GetProfiles(), 1) + assert.Equal(t, []string{owner}, list.GetProfiles()[0].GetOwners(), + "the listing has to show the owner, it is the only way to confirm a claim") +} + +// The owner is not looked up, so a claim lands on a machine whose accounts do +// not exist yet. Only its shape is checked. +func TestClaimProfile_RejectsWhatWouldMatchNobody(t *testing.T) { + for _, tc := range []struct { + name string + owner string + }{ + {"unparseable uid", "uid:abc"}, + {"unknown kind", "bogus:1000"}, + {"malformed sid", "sid:hello"}, + {"bare number", "1000"}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := claimTestServer(t) + + _, err := srv.ClaimProfile(rootCtx(), &proto.ClaimProfileRequest{ + Handle: "default", + Owner: tc.owner, + }) + require.Error(t, err) + assert.Equal(t, codes.InvalidArgument, gstatus.Convert(err).Code()) + + list, err := srv.ListProfiles(rootCtx(), &proto.ListProfilesRequest{}) + require.NoError(t, err) + assert.Empty(t, list.GetProfiles()[0].GetOwners(), + "a refused claim must leave the profile as it was") + }) + } +} + +func TestClaimProfile_RequiresBothArguments(t *testing.T) { + srv := claimTestServer(t) + + _, err := srv.ClaimProfile(rootCtx(), &proto.ClaimProfileRequest{Owner: "uid:4242"}) + require.Error(t, err) + assert.Equal(t, codes.InvalidArgument, gstatus.Convert(err).Code()) + + _, err = srv.ClaimProfile(rootCtx(), &proto.ClaimProfileRequest{Handle: "default"}) + require.Error(t, err) + assert.Equal(t, codes.InvalidArgument, gstatus.Convert(err).Code()) +} + +func TestClaimProfile_RefusesAnUnknownProfile(t *testing.T) { + srv := claimTestServer(t) + + _, err := srv.ClaimProfile(rootCtx(), &proto.ClaimProfileRequest{ + Handle: "no-such-profile", + Owner: claimOwner(4242), + }) + require.Error(t, err) + assert.Equal(t, codes.NotFound, gstatus.Convert(err).Code()) +} + +func TestClaimProfile_NeedsAnIdentifiedCaller(t *testing.T) { + srv := claimTestServer(t) + + _, err := srv.ClaimProfile(context.Background(), &proto.ClaimProfileRequest{ + Handle: "default", + Owner: claimOwner(4242), + }) + require.Error(t, err) + assert.Equal(t, codes.Unauthenticated, gstatus.Convert(err).Code()) +} + +// A principal is taken as given. The account deliberately does not exist, which +// is the provisioning case: a machine-wide profile is configured before the +// account that will own it. +func TestClaimProfile_TakesAPrincipalWithoutResolvingIt(t *testing.T) { + for _, owner := range []string{claimOwner(4242), claimOwner(999999)} { + t.Run(owner, func(t *testing.T) { + srv := claimTestServer(t) + + resp, err := srv.ClaimProfile(rootCtx(), &proto.ClaimProfileRequest{ + Handle: "default", + Owner: owner, + }) + require.NoError(t, err, "a principal must never need an account lookup") + assert.Equal(t, owner, resp.GetOwner()) + }) + } +} + +func TestClaimProfile_ResolvesAnAccountName(t *testing.T) { + srv := claimTestServer(t) + + u, err := user.Current() + require.NoError(t, err) + want, ok := profilemanager.PrincipalForUser(u) + require.True(t, ok) + + resp, err := srv.ClaimProfile(rootCtx(), &proto.ClaimProfileRequest{ + Handle: "default", + Owner: u.Username, + }) + require.NoError(t, err) + assert.Equal(t, want, resp.GetOwner(), + "a name has no shortcut, only a lookup turns it into a principal") +} + +func TestClaimProfile_RefusesAnUnknownAccountName(t *testing.T) { + srv := claimTestServer(t) + + _, err := srv.ClaimProfile(rootCtx(), &proto.ClaimProfileRequest{ + Handle: "default", + Owner: "no-such-account-here", + }) + require.Error(t, err) + assert.Equal(t, codes.InvalidArgument, gstatus.Convert(err).Code()) +} diff --git a/client/server/server.go b/client/server/server.go index 1099ab4cf..85c661adc 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -24,6 +24,7 @@ import ( "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/expose" + "github.com/netbirdio/netbird/client/internal/getent" "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/prometheus/client_golang/prometheus" @@ -2480,6 +2481,80 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ return &proto.RemoveProfileResponse{Id: resolved.ID.String()}, nil } +// ClaimProfile records an owner on a profile. +// +// Root or administrator only, enforced by the gate. The owner is whoever the +// caller names rather than the caller's own identity, so it is turned into a +// principal by ownerPrincipal and validated before anything is written. +func (s *Server) ClaimProfile(ctx context.Context, msg *proto.ClaimProfileRequest) (*proto.ClaimProfileResponse, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.checkProfilesDisabled() { + return nil, gstatus.Errorf(codes.Unavailable, errProfilesDisabled) + } + + if msg.Handle == "" { + return nil, gstatus.Errorf(codes.InvalidArgument, "profile must be provided") + } + if msg.Owner == "" { + return nil, gstatus.Errorf(codes.InvalidArgument, "owner must be provided") + } + + principal, err := ownerPrincipal(msg.Owner) + if err != nil { + return nil, gstatus.Errorf(codes.InvalidArgument, "%v", err) + } + + callerID, err := callerIdentity(ctx) + if err != nil { + return nil, err + } + + resolved, err := s.resolveProfileHandle(msg.Handle, callerID) + if err != nil { + return nil, err + } + + if err := s.profileManager.ClaimProfile(resolved, principal); err != nil { + return nil, fmt.Errorf("failed to claim profile: %w", err) + } + + s.publishProfileListChanged(resolved.Name) + + return &proto.ClaimProfileResponse{ + Id: resolved.ID.String(), + Owner: principal.String(), + }, nil +} + +// ownerPrincipal turns what the caller supplied into an owner principal. +// +// A principal is taken as given and never looked up. A machine-wide profile is +// routinely configured before the account that will own it exists, and a +// directory service that is briefly unreachable cannot be told apart from an +// account that is not there, so requiring a lookup would refuse both. Only its +// shape is checked. Anything else is an account name, which nothing but a lookup +// turns into a principal. +// +// Names resolve here rather than on the client so the daemon's own account +// database is the one consulted. +func ownerPrincipal(owner string) (ipcauth.Principal, error) { + candidate := owner + if _, ok := ipcauth.ParsePrincipal(owner); !ok { + u, err := getent.LookupUser(owner) + if err != nil { + return ipcauth.Principal{}, fmt.Errorf("resolve account %q: %w", owner, err) + } + resolved, ok := profilemanager.PrincipalForUser(u) + if !ok { + return ipcauth.Principal{}, fmt.Errorf("account %q has no usable id %q", owner, u.Uid) + } + candidate = resolved + } + return ipcauth.ValidatePrincipal(candidate) +} + // publishProfileListChanged nudges the desktop UI to refresh its profile list // after a CLI-driven add/remove. The daemon exposes no dedicated // profile-changed RPC event, and a profile add/remove doesn't move the @@ -2540,10 +2615,15 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques Profiles: make([]*proto.Profile, len(profiles)), } for i, profile := range profiles { + owners := make([]string, 0, len(profile.Owners)) + for _, owner := range profile.Owners { + owners = append(owners, owner.String()) + } response.Profiles[i] = &proto.Profile{ Id: profile.ID.String(), Name: profile.Name, IsActive: profile.IsActive, + Owners: owners, } } @@ -2568,15 +2648,21 @@ func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfi return nil, gstatus.Error(codes.Unauthenticated, "caller identity could not be resolved") } - // Fallback to legacy name == ID - displayName := activeProfile.ID.String() - if activeProfile.ID != profilemanager.DefaultProfileName { - if profiles, lerr := s.profileManager.ListProfiles(userID); lerr == nil { - for _, p := range profiles { - if p.ID == activeProfile.ID { - displayName = p.Name - break - } + // The name is resolved through the caller's own listing, so a profile + // belonging to somebody else is not in it. Leave the name empty rather than + // falling back to the ID: a 32 character hex string tells the user nothing, + // and the owner's chosen name is not the caller's to read. Clients render + // their own wording for an active profile that is not theirs. + // + // A legacy profile is its own name, so the ID stands in for it. + displayName := "" + if activeProfile.ID == profilemanager.DefaultProfileName { + displayName = activeProfile.ID.String() + } else if profiles, lerr := s.profileManager.ListProfiles(userID); lerr == nil { + for _, p := range profiles { + if p.ID == activeProfile.ID { + displayName = p.Name + break } } } @@ -2863,21 +2949,22 @@ func (s *Server) SessionHolder() (ipcauth.Principal, bool) { } // OwnsProfile reports whether the profile the handle resolves to answers to -// this identity. +// this identity, and what was wrong with the handle when resolution failed. // // This triggers stamping of legacy profiles, and reloads the active profile's // config so the stamp is visible to SessionHolder. -func (s *Server) OwnsProfile(id ipcauth.Identity, handle string) bool { +func (s *Server) OwnsProfile(id ipcauth.Identity, handle string) (bool, error) { // Without the active profile there is nothing to fall back to and nothing - // to refresh, so the gate gets a no rather than a guess. + // to refresh, so the gate gets a no rather than a guess. The handle is not + // what went wrong here, so the gate is left to refuse in its own words. activeProfile, err := s.profileManager.GetActiveProfileState() if err != nil { log.Warnf("failed to get active profile: %v", err) - return false + return false, nil } if activeProfile == nil { log.Warn("no active profile to authorize against") - return false + return false, nil } if handle == "" { handle = activeProfile.ID.String() @@ -2897,10 +2984,10 @@ func (s *Server) OwnsProfile(id ipcauth.Identity, handle string) bool { s.reloadActiveConfig() if resolveErr != nil { - log.Errorf("failed to resolve profile %q: %v", handle, resolveErr) - return false + log.Debugf("failed to resolve profile %q: %v", handle, resolveErr) + return false, resolveErr } - return resolved.AccessibleBy(id) + return resolved.AccessibleBy(id), nil } // afterProfileResolve is a seam for tests to run a concurrent profile switch diff --git a/client/server/server_ownsprofile_test.go b/client/server/server_ownsprofile_test.go index e3f416a7e..b7a24debd 100644 --- a/client/server/server_ownsprofile_test.go +++ b/client/server/server_ownsprofile_test.go @@ -6,6 +6,8 @@ import ( "testing" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal/ipcauth" "github.com/netbirdio/netbird/client/internal/profilemanager" @@ -45,7 +47,9 @@ func TestOwnsProfile_RefreshesActiveConfigForAnyHandle(t *testing.T) { _, running := s.SessionHolder() require.False(t, running, "fixture is wrong: the stale copy already names an owner") - require.True(t, s.OwnsProfile(owner, tc.handle), "the caller owns every profile in this fixture") + owns, err := s.OwnsProfile(owner, tc.handle) + require.NoError(t, err) + require.True(t, owns, "the caller owns every profile in this fixture") holder, running := s.SessionHolder() require.True(t, running, "the claimed owner never reached the daemon's config, so the live session is unowned") @@ -61,7 +65,9 @@ func TestOwnsProfile_UnreadableActiveProfileStateDenies(t *testing.T) { require.NoError(t, os.WriteFile(profilemanager.ActiveProfileStatePath, []byte("{"), 0600)) - require.False(t, s.OwnsProfile(unprivilegedIdentity(), "")) + owns, err := s.OwnsProfile(unprivilegedIdentity(), "") + require.NoError(t, err, "an unreadable active profile is not the caller's handle to fix") + require.False(t, owns) } // A config the daemon cannot re-read leaves the one it already has in place. @@ -78,7 +84,9 @@ func TestOwnsProfile_UnreadableConfigKeepsTheOneInPlace(t *testing.T) { s.config = kept s.clientRunning = true - require.False(t, s.OwnsProfile(unprivilegedIdentity(), "")) + owns, err := s.OwnsProfile(unprivilegedIdentity(), "") + require.False(t, owns, "a profile that did not resolve is nobody's") + require.Equal(t, codes.NotFound, gstatus.Code(err), "resolution failed") require.Same(t, kept, s.config, "a failed reload replaced the daemon's config") holder, running := s.SessionHolder() @@ -115,7 +123,9 @@ func TestOwnsProfile_ReloadFollowsASwitchThatLandsMidCheck(t *testing.T) { } t.Cleanup(func() { afterProfileResolve = nil }) - require.True(t, s.OwnsProfile(owner, activeProfile)) + owns, err := s.OwnsProfile(owner, activeProfile) + require.NoError(t, err) + require.True(t, owns) require.NotNil(t, s.config.ManagementURL) require.Equal(t, switchedToURL, s.config.ManagementURL.String(), @@ -130,6 +140,8 @@ func TestOwnsProfile_IdleDaemonKeepsItsConfig(t *testing.T) { s.config = untouched s.clientRunning = false - require.True(t, s.OwnsProfile(unprivilegedIdentity(), activeProfile)) + owns, err := s.OwnsProfile(unprivilegedIdentity(), activeProfile) + require.NoError(t, err) + require.True(t, owns) require.Same(t, untouched, s.config) } diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index d3689c1d0..e2e7240b5 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -103,8 +103,10 @@ func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profN return s, ctx, profName, currUser.Username, cfgPath } -// testProfileOwner is the identity userCtx carries, which is who a fixture -// profile belongs to. +// testProfileOwner is the identity the unprivileged test contexts carry, so a +// fixture profile can be owned by the very caller that drives the handler. +// Without an owner the profile is unowned, which the loader hides from every +// unprivileged caller. func testProfileOwner() *ipcauth.Identity { id := unprivilegedIdentity() return &id diff --git a/client/server/ssh_gate.go b/client/server/ssh_gate.go index 01d24687e..f4ef3ce7f 100644 --- a/client/server/ssh_gate.go +++ b/client/server/ssh_gate.go @@ -8,9 +8,6 @@ import ( "strings" log "github.com/sirupsen/logrus" - "google.golang.org/genproto/googleapis/rpc/errdetails" - "google.golang.org/grpc/codes" - gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/ipcauth" @@ -154,7 +151,7 @@ func denyPrivileged(ctx context.Context, action, command string) error { id, ok := ipcauth.CallerIdentity(ctx) if !ok { log.Warnf("denying %s: the caller's identity cannot be verified on this control channel", action) - return privilegeError(unidentifiedSummary(action), reinstallCommand()) + return ipcauth.PrivilegeError(unidentifiedSummary(action), reinstallCommand()) } if ipcauth.IsPrivilegedCaller(id) { @@ -163,45 +160,8 @@ func denyPrivileged(ctx context.Context, action, command string) error { } log.Warnf("denying %s for unprivileged caller %s", action, id) - actor, command := requiredActor(command) - return privilegeError(privilegeSummary(action, actor), command) -} - -// requiredActor names who may perform the operation and adjusts the command to -// match. A daemon that is not itself privileged delegates to its own identity, so -// telling that host's user to become root is wrong twice over: root is not what the -// daemon checks for, and a rootless container has neither root nor sudo. -func requiredActor(command string) (string, string) { - self, delegates := ipcauth.SelfDelegatesTo() - if !delegates { - return ipcauth.PrivilegedActor(), command - } - return fmt.Sprintf("the user the daemon runs as (%s)", self), strings.ReplaceAll(command, "sudo ", "") -} - -// privilegeError builds the PermissionDenied carrying summary and command. -func privilegeError(summary, command string) error { - st := gstatus.New(codes.PermissionDenied, fmt.Sprintf("%s\n\n%s", summary, command)) - - detailed, err := st.WithDetails(&errdetails.ErrorInfo{ - Reason: ipcauth.ErrorReasonPrivilegeRequired, - Domain: ipcauth.ErrorDomain, - Metadata: map[string]string{ - ipcauth.ErrorMetaSummary: summary, - ipcauth.ErrorMetaCommand: command, - }, - }) - if err != nil { - log.Debugf("attach privilege error detail: %v", err) - return st.Err() - } - return detailed.Err() -} - -// privilegeSummary states what is refused and what it needs, in one sentence -// that reads the same in a dialog and in a terminal. -func privilegeSummary(action, actor string) string { - return fmt.Sprintf("%s requires %s.", capitalize(action), actor) + actor, command := ipcauth.RequiredActor(command) + return ipcauth.PrivilegeError(ipcauth.PrivilegeSummary(action, actor), command) } // unidentifiedSummary covers a control channel that carries no caller identity. diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx index 35ef7d5b5..dd725ec33 100644 --- a/client/ui/frontend/src/components/LanguagePicker.tsx +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -11,7 +11,7 @@ import { Label } from "@/components/typography/Label"; import { useFocusVisible } from "@/hooks/useFocusVisible"; import { loadLanguages } from "@/lib/i18n"; import { cn } from "@/lib/cn"; -import { errorDialog, formatErrorMessage } from "@/lib/errors"; +import { errorDialogFor } from "@/lib/errors"; // No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/ @@ -66,10 +66,7 @@ export function LanguagePicker() { try { await Preferences.SetLanguage(code as LanguageCode); } catch (e) { - await errorDialog({ - Title: t("settings.error.saveTitle"), - Message: formatErrorMessage(e), - }); + await errorDialogFor(t("settings.error.saveTitle"), e); } finally { setBusy(false); } diff --git a/client/ui/frontend/src/contexts/ClientVersionContext.tsx b/client/ui/frontend/src/contexts/ClientVersionContext.tsx index c0699a9ee..e2381deca 100644 --- a/client/ui/frontend/src/contexts/ClientVersionContext.tsx +++ b/client/ui/frontend/src/contexts/ClientVersionContext.tsx @@ -13,12 +13,7 @@ import { Events } from "@wailsio/runtime"; import { Update as UpdateSvc, WindowManager } from "@bindings/services"; import type { State as UpdateState } from "@bindings/updater/models.js"; import i18next from "@/lib/i18n"; -import { errorDialog, formatErrorMessage } from "@/lib/errors"; - -const isDaemonUnavailable = (e: unknown): boolean => { - const msg = e instanceof Error ? e.message : String(e); - return msg.includes("code = Unavailable"); -}; +import { errorDialogFor, isDaemonUnavailable } from "@/lib/errors"; type ClientVersionContextValue = { updateAvailable: boolean; @@ -61,10 +56,7 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) => }) .catch((e) => { if (cancelled || isDaemonUnavailable(e)) return; - void errorDialog({ - Title: i18next.t("update.error.loadStateTitle"), - Message: formatErrorMessage(e), - }); + void errorDialogFor(i18next.t("update.error.loadStateTitle"), e); }); const off = Events.On(EVENT_UPDATE_STATE, (ev: { data: UpdateState }) => { if (ev?.data) setState(ev.data); @@ -90,10 +82,7 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) => .catch(async (e) => { if (isDaemonUnavailable(e)) return; WindowManager.CloseInstallProgress().catch(console.error); - await errorDialog({ - Title: i18next.t("update.error.triggerTitle"), - Message: formatErrorMessage(e), - }); + await errorDialogFor(i18next.t("update.error.triggerTitle"), e); }) .finally(() => setUpdating(false)); }, [state.version]); diff --git a/client/ui/frontend/src/contexts/DebugBundleContext.tsx b/client/ui/frontend/src/contexts/DebugBundleContext.tsx index 5f2ed9041..3df3475a7 100644 --- a/client/ui/frontend/src/contexts/DebugBundleContext.tsx +++ b/client/ui/frontend/src/contexts/DebugBundleContext.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useRef, useState, type ReactNode import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/services"; import type { DebugBundleResult } from "@bindings/services/models.js"; import i18next from "@/lib/i18n"; -import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; +import { errorDialogFor } from "@/lib/errors.ts"; import { startConnection } from "@/lib/connection.ts"; const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url"; @@ -260,10 +260,7 @@ const useDebugBundle = () => { } await cleanupBestEffort(pcap, level, false); setStage({ kind: "idle" }); - await errorDialog({ - Title: i18next.t("settings.error.debugBundleTitle"), - Message: formatErrorMessage(e), - }); + await errorDialogFor(i18next.t("settings.error.debugBundleTitle"), e); } finally { if (abortRef.current === ctrl) abortRef.current = null; } diff --git a/client/ui/frontend/src/contexts/ProfileContext.tsx b/client/ui/frontend/src/contexts/ProfileContext.tsx index 62377f1bc..9f86bc6ef 100644 --- a/client/ui/frontend/src/contexts/ProfileContext.tsx +++ b/client/ui/frontend/src/contexts/ProfileContext.tsx @@ -12,7 +12,7 @@ import { Events } from "@wailsio/runtime"; import { Connection, ProfileSwitcher, Profiles as ProfilesSvc } from "@bindings/services"; import type { Profile } from "@bindings/services/models.js"; import i18next from "@/lib/i18n"; -import { errorDialog, formatErrorMessage } from "@/lib/errors"; +import { errorDialogFor, isDaemonUnavailable } from "@/lib/errors"; const EVENT_PROFILE_CHANGED = "netbird:profile:changed"; @@ -65,23 +65,27 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { ProfilesSvc.List(u), ]); setUsername(u); - setActiveProfile(active.profileName || "default"); + // An empty name means the daemon would not disclose it: the active + // profile belongs to another user. Falling back to "default" would + // name the wrong profile, so say what it is instead. + const activeName = active.profileName + ? active.profileName + : active.id + ? i18next.t("profile.ownedByAnother") + : "default"; + setActiveProfile(activeName); setActiveProfileId(active.id || "default"); setProfiles(list); setLoaded(true); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - if (msg.includes("code = Unavailable")) { + if (isDaemonUnavailable(e)) { retryRef.current = setTimeout(() => { void refresh(); }, 1000); return; } setLoaded(true); - await errorDialog({ - Title: i18next.t("profile.error.loadTitle"), - Message: formatErrorMessage(e), - }); + await errorDialogFor(i18next.t("profile.error.loadTitle"), e); } }, []); diff --git a/client/ui/frontend/src/contexts/SettingsContext.tsx b/client/ui/frontend/src/contexts/SettingsContext.tsx index a7574c7e5..859576ad0 100644 --- a/client/ui/frontend/src/contexts/SettingsContext.tsx +++ b/client/ui/frontend/src/contexts/SettingsContext.tsx @@ -9,12 +9,12 @@ import { type ReactNode, } from "react"; import { Events } from "@wailsio/runtime"; -import { Autostart, Settings as SettingsSvc, Version } from "@bindings/services"; +import { Autostart, Settings as SettingsSvc } from "@bindings/services"; import type { Config } from "@bindings/services/models.js"; import i18next from "@/lib/i18n"; import { useProfile } from "@/contexts/ProfileContext.tsx"; import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx"; -import { errorCommand, errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts"; +import { errorDialogFor } from "@/lib/errors.ts"; const SAVE_DEBOUNCE_MS = 400; @@ -29,7 +29,6 @@ export type GuardedField = "serverSshAllowed" | "enableSshRoot" | "disableSshAut type SettingsContextValue = { config: Config; - guiVersion: string; setField: (k: K, v: Config[K]) => void; saveField: (k: K, v: Config[K]) => Promise; saveFields: (partial: Partial, opts?: { preSharedKey?: string }) => Promise; @@ -66,7 +65,6 @@ type LoadedConfig = { profileName: string; data: Config }; const useSettingsState = () => { const { username, activeProfileId, loaded: profileLoaded } = useProfile(); const [loaded, setLoaded] = useState(null); - const [guiVersion, setGuiVersion] = useState("—"); const saveTimer = useRef | null>(null); const loadedRef = useRef(null); // Set when the daemon's config changed while a save was pending, so the read @@ -116,10 +114,7 @@ const useSettingsState = () => { setLoaded({ profileName: activeProfileId, data }); } catch (e) { if (cancelled || !showError) return; - await errorDialog({ - Title: i18next.t("settings.error.loadTitle"), - Message: errorMessage(e), - }); + await errorDialogFor(i18next.t("settings.error.loadTitle"), e); } }; @@ -138,16 +133,6 @@ const useSettingsState = () => { }; }, [profileLoaded, activeProfileId, username]); - useEffect(() => { - let cancelled = false; - Version.GUI().then((v) => { - if (!cancelled) setGuiVersion(v); - }); - return () => { - cancelled = true; - }; - }, []); - useEffect( () => () => { if (saveTimer.current) clearTimeout(saveTimer.current); @@ -177,11 +162,7 @@ const useSettingsState = () => { // holds before reporting, so the UI never shows a value the // daemon does not have. await reload(profileName); - await errorDialog({ - Title: i18next.t("settings.error.saveTitle"), - Message: errorMessage(e), - Command: errorCommand(e), - }); + await errorDialogFor(i18next.t("settings.error.saveTitle"), e); } }, [username, reload], @@ -268,11 +249,7 @@ const useSettingsState = () => { // through here at all; this is a prompt that could not be raised, // which carries the command that would have done it. await reload(cur.profileName); - await errorDialog({ - Title: i18next.t("settings.error.saveTitle"), - Message: errorMessage(e), - Command: errorCommand(e), - }); + await errorDialogFor(i18next.t("settings.error.saveTitle"), e); return; } // Either the change went through or the user declined it. The daemon @@ -303,7 +280,6 @@ const useSettingsState = () => { return { config: loaded?.data ?? null, - guiVersion, setField, saveField, saveFields, @@ -313,15 +289,13 @@ const useSettingsState = () => { }; export const SettingsProvider = ({ children }: { children: ReactNode }) => { - const { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow } = + const { config, setField, saveField, saveFields, saveGuardedField, saveNow } = useSettingsState(); const value = useMemo( () => - config - ? { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow } - : null, - [config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow], + config ? { config, setField, saveField, saveFields, saveGuardedField, saveNow } : null, + [config, setField, saveField, saveFields, saveGuardedField, saveNow], ); if (!value) { @@ -361,10 +335,7 @@ export const AutostartSettingsProvider = ({ children }: { children: ReactNode }) await Autostart.SetEnabled(enabled); } catch (e) { setAutostart((s) => (s ? { ...s, enabled: !enabled } : s)); - await errorDialog({ - Title: i18next.t("settings.general.autostart.errorTitle"), - Message: errorMessage(e), - }); + await errorDialogFor(i18next.t("settings.general.autostart.errorTitle"), e); } }, []); diff --git a/client/ui/frontend/src/hooks/useGuiVersion.ts b/client/ui/frontend/src/hooks/useGuiVersion.ts new file mode 100644 index 000000000..f5565fbf9 --- /dev/null +++ b/client/ui/frontend/src/hooks/useGuiVersion.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react"; +import { Version } from "@bindings/services"; + +const UNKNOWN_VERSION = "—"; + +// useGuiVersion reports the UI binary's own version, which is stamped into it at +// build time and answered in-process. The daemon version comes from the status +// feed instead, see StatusContext. +export const useGuiVersion = (): string => { + const [guiVersion, setGuiVersion] = useState(UNKNOWN_VERSION); + + useEffect(() => { + let cancelled = false; + Version.GUI() + .then((v) => { + if (!cancelled) setGuiVersion(v); + }) + .catch((e: unknown) => { + console.warn("[useGuiVersion] read failed", e); + }); + return () => { + cancelled = true; + }; + }, []); + + return guiVersion; +}; diff --git a/client/ui/frontend/src/lib/connection.ts b/client/ui/frontend/src/lib/connection.ts index cc0e67cb3..9e08151e7 100644 --- a/client/ui/frontend/src/lib/connection.ts +++ b/client/ui/frontend/src/lib/connection.ts @@ -1,7 +1,7 @@ import { Events } from "@wailsio/runtime"; import { Connection, WindowManager } from "@bindings/services"; import i18next from "@/lib/i18n"; -import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; +import { errorDialogFor } from "@/lib/errors.ts"; export const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel"; export const EVENT_TRIGGER_LOGIN = "trigger-login"; @@ -120,10 +120,7 @@ export async function startConnection(onSettled?: () => void, signal?: AbortSign } if (connectError !== undefined) { - await errorDialog({ - Title: i18next.t("connect.error.loginTitle"), - Message: formatErrorMessage(connectError), - }); + await errorDialogFor(i18next.t("connect.error.loginTitle"), connectError); return; } diff --git a/client/ui/frontend/src/lib/errors.ts b/client/ui/frontend/src/lib/errors.ts index b4dee2717..5c9c80a91 100644 --- a/client/ui/frontend/src/lib/errors.ts +++ b/client/ui/frontend/src/lib/errors.ts @@ -1,6 +1,6 @@ import { WindowManager } from "@bindings/services"; -type ClassifiedError = { short: string; long: string; command: string }; +type ClassifiedError = { code: string; short: string; long: string; command: string }; const asObject = (v: unknown): Record | null => v && typeof v === "object" ? (v as Record) : null; @@ -22,14 +22,15 @@ const toWailsEnvelope = (e: unknown): Record | null => { return asObject(obj.cause) ?? parseJsonObject(obj.message); }; -// Read { short, long, command } from wherever the classified error sits in the envelope +// Read { code, short, long, command } from wherever the classified error sits in the envelope const toClassifiedError = (v: unknown): ClassifiedError | null => { const o = asObject(v); if (!o) return null; + const code = typeof o.code === "string" ? o.code : ""; const short = typeof o.short === "string" ? o.short : ""; const long = typeof o.long === "string" ? o.long : ""; const command = typeof o.command === "string" ? o.command : ""; - return short || long ? { short, long, command } : null; + return short || long ? { code, short, long, command } : null; }; const classify = (e: unknown): ClassifiedError | null => { @@ -60,14 +61,38 @@ export const formatErrorMessage = (e: unknown): string => { // privileges). Empty for every other error. export const errorCommand = (e: unknown): string => classify(e)?.command ?? ""; +// isDaemonUnavailable reports whether an error means the daemon could not be +// reached, so a caller can retry quietly instead of putting a dialog up while +// the service is still starting. Matches the classified code first and the raw +// gRPC status text second, since not every service classifies its errors. +export const isDaemonUnavailable = (e: unknown): boolean => { + if (classify(e)?.code === "daemon_unreachable") return true; + const msg = e instanceof Error ? e.message : String(e); + return msg.includes("code = Unavailable"); +}; + export type ErrorDialogOptions = { Title: string; Message: string; - // Command is shown for copying below the message. Defaults to the one the - // error carries, so callers only pass it to override. + // Command is shown for copying below the message. Prefer errorDialogFor, + // which takes it from the error, over setting this by hand. Command?: string; }; export function errorDialog(options: ErrorDialogOptions): Promise { return WindowManager.OpenError(options.Title, options.Message, options.Command ?? ""); } + +// errorDialogFor opens a dialog for a thrown error, taking both the message and +// any command the daemon attached from the error itself. +// +// Use it wherever the message is just the error. Passing Command by hand is what +// kept the daemon's suggested command off the screen everywhere except Settings, +// since every other caller had to remember to ask for it. +export function errorDialogFor(title: string, e: unknown): Promise { + return errorDialog({ + Title: title, + Message: formatErrorMessage(e), + Command: errorCommand(e), + }); +} diff --git a/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx index efbd1ee84..ff7c457d3 100644 --- a/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx +++ b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx @@ -11,7 +11,7 @@ import { DialogDescription } from "@/components/dialog/DialogDescription"; import { DialogHeading } from "@/components/dialog/DialogHeading"; import { SquareIcon } from "@/components/SquareIcon"; import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; -import { errorDialog, formatErrorMessage } from "@/lib/errors"; +import { errorDialogFor } from "@/lib/errors"; const EVENT_CANCEL = "browser-login:cancel"; const WINDOW_WIDTH = 360; @@ -25,10 +25,7 @@ export default function LoginWaitingForBrowserDialog() { const reportOpenFailure = useCallback( (e: unknown) => { - void errorDialog({ - Title: t("browserLogin.openFailedTitle"), - Message: formatErrorMessage(e), - }); + void errorDialogFor(t("browserLogin.openFailedTitle"), e); }, [t], ); diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx index 2f4014741..d48d9541c 100644 --- a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -6,7 +6,7 @@ import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx"; import { useStatus } from "@/contexts/StatusContext.tsx"; import { useProfile } from "@/contexts/ProfileContext.tsx"; import { cn } from "@/lib/cn.ts"; -import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; +import { errorDialogFor } from "@/lib/errors.ts"; import { startConnection, EVENT_BROWSER_LOGIN_CANCEL, @@ -40,8 +40,6 @@ const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed const FORCE_TOGGLE_DELAY_MS = 7000; -const errorMessage = formatErrorMessage; - export const MainConnectionStatusSwitch = () => { const { t } = useTranslation(); const { status, refresh } = useStatus(); @@ -100,10 +98,7 @@ export const MainConnectionStatusSwitch = () => { } catch (e) { setAction(null); await refresh(); - await errorDialog({ - Title: t("connect.error.connectTitle"), - Message: errorMessage(e), - }); + await errorDialogFor(t("connect.error.connectTitle"), e); } }; @@ -115,10 +110,7 @@ export const MainConnectionStatusSwitch = () => { } catch (e) { setAction(null); await refresh(); - await errorDialog({ - Title: t("connect.error.disconnectTitle"), - Message: errorMessage(e), - }); + await errorDialogFor(t("connect.error.disconnectTitle"), e); } }; @@ -209,10 +201,7 @@ export const MainConnectionStatusSwitch = () => { } catch (e) { setAction(null); await refresh(); - await errorDialog({ - Title: t("connect.error.disconnectTitle"), - Message: errorMessage(e), - }); + await errorDialogFor(t("connect.error.disconnectTitle"), e); } }; const show = connState === ConnectionState.Connected; diff --git a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx index 81da3aa29..90087d20f 100644 --- a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx @@ -10,7 +10,7 @@ import { Tooltip } from "@/components/Tooltip"; import { useProfile } from "@/contexts/ProfileContext"; import { useFocusVisible } from "@/hooks/useFocusVisible"; import { cn } from "@/lib/cn"; -import { errorDialog, formatErrorMessage } from "@/lib/errors"; +import { errorDialogFor } from "@/lib/errors"; type ProfileDropdownProps = { onManageProfiles?: () => void; @@ -45,10 +45,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { try { await fn(); } catch (e) { - await errorDialog({ - Title: title, - Message: formatErrorMessage(e), - }); + await errorDialogFor(title, e); } finally { setBusy(false); } diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx index ce3994d0c..48910da65 100644 --- a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx @@ -34,7 +34,7 @@ import { isNetbirdCloud } from "@/hooks/useManagementUrl.ts"; import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx"; import { cn } from "@/lib/cn"; import { reconcileOrder } from "@/lib/sorting"; -import { errorDialog, formatErrorMessage } from "@/lib/errors"; +import { errorDialogFor } from "@/lib/errors"; const DEFAULT_PROFILE_ID = "default"; @@ -84,10 +84,7 @@ export function ProfilesTab() { try { await fn(); } catch (e) { - await errorDialog({ - Title: title, - Message: formatErrorMessage(e), - }); + await errorDialogFor(title, e); } finally { setBusy(false); } diff --git a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx index e57040a7a..f1c67118c 100644 --- a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx +++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx @@ -12,7 +12,7 @@ import { SquareIcon } from "@/components/SquareIcon"; import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services"; import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; import { EVENT_BROWSER_LOGIN_CANCEL, EVENT_TRIGGER_LOGIN } from "@/lib/connection"; -import { errorDialog, formatErrorMessage } from "@/lib/errors.ts"; +import { errorDialogFor } from "@/lib/errors.ts"; import { formatRemaining } from "@/lib/formatters"; const DEFAULT_SECONDS = 360; @@ -159,10 +159,7 @@ export default function SessionExpirationDialog() { WindowManager.CloseRenewFlow().catch(console.error); } catch (e) { resetDialog(); - await errorDialog({ - Title: t("sessionExpiration.extendFailedTitle"), - Message: formatErrorMessage(e), - }); + await errorDialogFor(t("sessionExpiration.extendFailedTitle"), e); } }, [busy, t]); @@ -174,10 +171,7 @@ export default function SessionExpirationDialog() { await WindowManager.CloseSessionExpiration(); } catch (e) { setBusy(false); - await errorDialog({ - Title: t("connect.error.loginTitle"), - Message: formatErrorMessage(e), - }); + await errorDialogFor(t("connect.error.loginTitle"), e); } }, [busy, t]); @@ -194,10 +188,7 @@ export default function SessionExpirationDialog() { WindowManager.CloseSessionExpiration().catch(console.error); } catch (e) { setBusy(false); - await errorDialog({ - Title: t("sessionExpiration.logoutFailedTitle"), - Message: formatErrorMessage(e), - }); + await errorDialogFor(t("sessionExpiration.logoutFailedTitle"), e); } }, [busy, t]); diff --git a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx index c103d1d74..1db019e3e 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx @@ -24,8 +24,8 @@ const SlackIcon = (props: SVGProps) => ( /> ); -import { useSettings } from "@/contexts/SettingsContext.tsx"; import { useStatus } from "@/contexts/StatusContext.tsx"; +import { useGuiVersion } from "@/hooks/useGuiVersion"; import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard"; import { useAccentTrigger } from "@/modules/settings/SettingsAccent"; @@ -38,7 +38,7 @@ function openUrl(url: string) { export function SettingsAbout() { const { t } = useTranslation(); const { status } = useStatus(); - const { guiVersion } = useSettings(); + const guiVersion = useGuiVersion(); const daemonVersion = status?.daemonVersion ?? "—"; const handleVersionClick = useAccentTrigger(); diff --git a/client/ui/frontend/src/modules/settings/SettingsPage.tsx b/client/ui/frontend/src/modules/settings/SettingsPage.tsx index bf0db3bec..f4cb6615e 100644 --- a/client/ui/frontend/src/modules/settings/SettingsPage.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; -import { useLocation } from "react-router-dom"; +import { useLocation, useSearchParams } from "react-router-dom"; import { Events } from "@wailsio/runtime"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { cn } from "@/lib/cn"; @@ -31,6 +31,17 @@ const enum Tab { About = "about", } +// Tabs that render the daemon's profile configuration. Only these mount +// SettingsProvider, so a profile whose configuration this user may not read +// still leaves the rest of the page reachable. +const CONFIG_TABS: ReadonlySet = new Set([ + Tab.General, + Tab.Network, + Tab.Security, + Tab.SSH, + Tab.Advanced, +]); + const TAB_CONTENT: Record = { [Tab.General]: , [Tab.Network]: , @@ -42,9 +53,16 @@ const TAB_CONTENT: Record = { [Tab.About]: , }; +// WithSettings reads the daemon's profile configuration for the tabs that need +// it. Radix keeps only the active tab's content mounted, so gating on the active +// tab is what keeps the read off the tabs that do not use it. +const WithSettings = ({ enabled, children }: { enabled: boolean; children: ReactNode }) => + enabled ? {children} : <>{children}; + export const SettingsPage = () => { const location = useLocation(); const navState = location.state as { tab?: string } | null; + const [searchParams] = useSearchParams(); const { mdm, features } = useRestrictions(); const visibleTabs = useMemo(() => { @@ -63,7 +81,11 @@ export const SettingsPage = () => { }, [features.disableUpdateSettings, features.disableProfiles, mdm.allowServerSSH]); const defaultTab = visibleTabs[0]; - const [active, setActive] = useState(() => navState?.tab ?? defaultTab); + // The window carries its tab in the URL, so the first render opens on the + // requested one rather than on the default and then correcting itself. + const [active, setActive] = useState( + () => navState?.tab ?? searchParams.get("tab") ?? defaultTab, + ); useEffect(() => { if (navState?.tab) setActive(navState.tab); @@ -92,7 +114,7 @@ export const SettingsPage = () => { - + { /> - + diff --git a/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx b/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx index 7be59f102..cf3e7cffe 100644 --- a/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx +++ b/client/ui/frontend/src/modules/welcome/WelcomeDialog.tsx @@ -8,7 +8,7 @@ import { import { Restrictions, SetConfigParams } from "@bindings/services/models.js"; import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow"; -import { errorDialog, formatErrorMessage } from "@/lib/errors"; +import { errorDialogFor } from "@/lib/errors"; import i18next from "@/lib/i18n"; import { isNetbirdCloud } from "@/hooks/useManagementUrl"; import { WelcomeStepTray } from "./WelcomeStepTray"; @@ -130,10 +130,7 @@ export default function WelcomeDialog() { }), ); } catch (e) { - await errorDialog({ - Title: i18next.t("settings.error.saveTitle"), - Message: formatErrorMessage(e), - }); + await errorDialogFor(i18next.t("settings.error.saveTitle"), e); throw e; } setInitial((s) => (s ? { ...s, managementUrl: url } : s)); diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index c39584992..99d354b78 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -1389,5 +1389,17 @@ }, "settings.ssh.privilege.authorizePending": { "message": "Warten auf Autorisierung…" + }, + "profile.ownedByAnother": { + "message": "Profil eines anderen Benutzers" + }, + "error.privilege_required": { + "message": "Diese Aktion erfordert erhöhte Rechte." + }, + "error.session_held": { + "message": "Ein anderer Benutzer hat diesen Rechner verbunden." + }, + "error.not_profile_owner": { + "message": "Dieses Profil gehört einem anderen Benutzer." } } diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index e9ee26de4..682cfe4f8 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1850,5 +1850,21 @@ "settings.ssh.privilege.authorizePending": { "message": "Waiting for authorization…", "description": "Replaces the help text under a guarded SSH setting while the authorization prompt is open, which can take a few seconds to appear. Keep the trailing ellipsis." + }, + "profile.ownedByAnother": { + "message": "Another user's profile", + "description": "Shown in place of the active profile's name when it belongs to a different user account." + }, + "error.privilege_required": { + "message": "This action requires elevated privileges.", + "description": "Short headline when the daemon refuses an action that needs elevated privileges. The daemon's own sentence, naming the action and what it needs, is shown as the detail." + }, + "error.session_held": { + "message": "Another user has this machine connected.", + "description": "Short headline when the daemon refuses an action because a different user account holds the active connection. The daemon's own sentence is shown as the detail." + }, + "error.not_profile_owner": { + "message": "This profile belongs to another user.", + "description": "Short headline when the daemon refuses an action because the profile it addresses is owned by a different user account." } } diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 245b5aa5f..aa07e20e9 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -1389,5 +1389,17 @@ }, "settings.ssh.privilege.authorizePending": { "message": "Esperando la autorización…" + }, + "profile.ownedByAnother": { + "message": "Perfil de otro usuario" + }, + "error.privilege_required": { + "message": "Esta acción requiere privilegios elevados." + }, + "error.session_held": { + "message": "Otro usuario tiene esta máquina conectada." + }, + "error.not_profile_owner": { + "message": "Este perfil pertenece a otro usuario." } } diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index 6da66a643..c1afa9225 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -1389,5 +1389,17 @@ }, "settings.ssh.privilege.authorizePending": { "message": "En attente de l’autorisation…" + }, + "profile.ownedByAnother": { + "message": "Profil d’un autre utilisateur" + }, + "error.privilege_required": { + "message": "Cette action nécessite des privilèges élevés." + }, + "error.session_held": { + "message": "Un autre utilisateur a connecté cette machine." + }, + "error.not_profile_owner": { + "message": "Ce profil appartient à un autre utilisateur." } } diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 1b4d2fb9d..b7e6dc016 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -1389,5 +1389,17 @@ }, "settings.ssh.privilege.authorizePending": { "message": "Várakozás az engedélyezésre…" + }, + "profile.ownedByAnother": { + "message": "Másik felhasználó profilja" + }, + "error.privilege_required": { + "message": "Ehhez a művelethez emelt szintű jogosultság szükséges." + }, + "error.session_held": { + "message": "Egy másik felhasználó csatlakoztatta ezt a gépet." + }, + "error.not_profile_owner": { + "message": "Ez a profil egy másik felhasználóé." } } diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 4cee0f842..1b5dced3d 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -1389,5 +1389,17 @@ }, "settings.ssh.privilege.authorizePending": { "message": "In attesa dell'autorizzazione…" + }, + "profile.ownedByAnother": { + "message": "Profilo di un altro utente" + }, + "error.privilege_required": { + "message": "Questa azione richiede privilegi elevati." + }, + "error.session_held": { + "message": "Un altro utente è già connesso su questa macchina." + }, + "error.not_profile_owner": { + "message": "Questo profilo appartiene a un altro utente." } } diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index 4fc81d283..7bb719b07 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -1389,5 +1389,17 @@ }, "settings.ssh.privilege.authorizePending": { "message": "承認を待っています…" + }, + "profile.ownedByAnother": { + "message": "別のユーザーのプロファイル" + }, + "error.privilege_required": { + "message": "この操作には昇格した権限が必要です。" + }, + "error.session_held": { + "message": "別のユーザーがこのマシンを接続しています。" + }, + "error.not_profile_owner": { + "message": "このプロファイルは別のユーザーのものです。" } } diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index cb4a542d0..d08154fdb 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -1389,5 +1389,17 @@ }, "settings.ssh.privilege.authorizePending": { "message": "Aguardando a autorização…" + }, + "profile.ownedByAnother": { + "message": "Perfil de outro usuário" + }, + "error.privilege_required": { + "message": "Esta ação requer privilégios elevados." + }, + "error.session_held": { + "message": "Outro usuário está com esta máquina conectada." + }, + "error.not_profile_owner": { + "message": "Este perfil pertence a outro usuário." } } diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index 61ece03b8..2b6709350 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -1389,5 +1389,17 @@ }, "settings.ssh.privilege.authorizePending": { "message": "Ожидание авторизации…" + }, + "profile.ownedByAnother": { + "message": "Профиль другого пользователя" + }, + "error.privilege_required": { + "message": "Для этого действия нужны повышенные права." + }, + "error.session_held": { + "message": "Другой пользователь подключил эту машину." + }, + "error.not_profile_owner": { + "message": "Этот профиль принадлежит другому пользователю." } } diff --git a/client/ui/i18n/locales/uk/common.json b/client/ui/i18n/locales/uk/common.json index f8fe71562..cf910ba56 100644 --- a/client/ui/i18n/locales/uk/common.json +++ b/client/ui/i18n/locales/uk/common.json @@ -1387,5 +1387,17 @@ }, "settings.ssh.privilege.authorizePending": { "message": "Очікування авторизації…" + }, + "profile.ownedByAnother": { + "message": "Профіль іншого користувача" + }, + "error.privilege_required": { + "message": "Ця дія потребує підвищених привілеїв." + }, + "error.session_held": { + "message": "Інший користувач підключив цю машину." + }, + "error.not_profile_owner": { + "message": "Цей профіль належить іншому користувачеві." } } diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 126b11851..10e4ed453 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -1389,5 +1389,17 @@ }, "settings.ssh.privilege.authorizePending": { "message": "正在等待授权…" + }, + "profile.ownedByAnother": { + "message": "其他用户的配置文件" + }, + "error.privilege_required": { + "message": "此操作需要提升的权限。" + }, + "error.session_held": { + "message": "其他用户已连接此机器。" + }, + "error.not_profile_owner": { + "message": "此配置文件属于其他用户。" } } diff --git a/client/ui/main.go b/client/ui/main.go index 74a87b4df..328351415 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -105,11 +105,9 @@ func main() { } }) - profiles := services.NewProfiles(conn) // updater.Holder owns the typed update State; DaemonFeed feeds it and the // Update service is a thin Wails-bound facade over it plus the install RPCs. updaterHolder := updater.NewHolder(app.Event) - update := services.NewUpdate(conn, updaterHolder) daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog) notifier := newNotifier() compat := services.NewCompat(conn) @@ -128,6 +126,8 @@ func main() { app.RegisterService(application.NewService(services.NewTheme(app, prefStore))) // After bundle + prefStore: both are used to localise daemon errors. + profiles := services.NewProfiles(conn, bundle, prefStore) + update := services.NewUpdate(conn, updaterHolder, bundle, prefStore) settings := services.NewSettings(conn, bundle, prefStore, daemonAddr) connection := services.NewConnection(conn, bundle, prefStore) profileSwitcher := services.NewProfileSwitcher(profiles, connection, daemonFeed) @@ -338,7 +338,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) { app.RegisterService(application.NewService(s.networks)) app.RegisterService(application.NewService(services.NewForwarding(conn))) app.RegisterService(application.NewService(s.profiles)) - app.RegisterService(application.NewService(services.NewDebug(conn))) + app.RegisterService(application.NewService(services.NewDebug(conn, s.bundle, s.prefStore))) app.RegisterService(application.NewService(s.update)) app.RegisterService(application.NewService(s.daemonFeed)) app.RegisterService(application.NewService(s.notifier)) diff --git a/client/ui/services/debug.go b/client/ui/services/debug.go index d1f6555a8..73852c5d5 100644 --- a/client/ui/services/debug.go +++ b/client/ui/services/debug.go @@ -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 } diff --git a/client/ui/services/errors.go b/client/ui/services/errors.go index 0c6f2f20f..0d88ff53e 100644 --- a/client/ui/services/errors.go +++ b/client/ui/services/errors.go @@ -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." key when no translation is available so the gap stays visible. func (c errorClassifier) translateShort(code string) string { diff --git a/client/ui/services/errors_test.go b/client/ui/services/errors_test.go index 2f8f3d039..ab86e08df 100644 --- a/client/ui/services/errors_test.go +++ b/client/ui/services/errors_test.go @@ -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." 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." 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"))) +} diff --git a/client/ui/services/profile.go b/client/ui/services/profile.go index e76ab3db6..8b368ea64 100644 --- a/client/ui/services/profile.go +++ b/client/ui/services/profile.go @@ -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 } diff --git a/client/ui/services/profile_error_test.go b/client/ui/services/profile_error_test.go new file mode 100644 index 000000000..25b8bc987 --- /dev/null +++ b/client/ui/services/profile_error_test.go @@ -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." 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") +} diff --git a/client/ui/services/profileswitcher.go b/client/ui/services/profileswitcher.go index 727b2473f..54d9b29f8 100644 --- a/client/ui/services/profileswitcher.go +++ b/client/ui/services/profileswitcher.go @@ -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 } } diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index 7c20184bd..f4491b632 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -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) diff --git a/client/ui/services/settings_error_test.go b/client/ui/services/settings_error_test.go new file mode 100644 index 000000000..5b8d7a5a1 --- /dev/null +++ b/client/ui/services/settings_error_test.go @@ -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." 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") +} diff --git a/client/ui/services/update.go b/client/ui/services/update.go index b743b9858..f21b8df6d 100644 --- a/client/ui/services/update.go +++ b/client/ui/services/update.go @@ -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(), diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 24319dae0..bea6353b3 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -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 { diff --git a/client/ui/services/windowmanager_test.go b/client/ui/services/windowmanager_test.go index 13c8548ab..545b224a9 100644 --- a/client/ui/services/windowmanager_test.go +++ b/client/ui/services/windowmanager_test.go @@ -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") +}