[Client] Surface readable Authz errors and add profile claim command (#7540)

* [client] Surface error messages for IPC authz in UI (#7553)
This commit is contained in:
Theodor Midtlien
2026-09-17 14:36:56 +02:00
committed by GitHub
parent 801557e21b
commit cec9ee6699
69 changed files with 2912 additions and 681 deletions
+90 -41
View File
@@ -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())
}
+240
View File
@@ -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")
}
+5 -9
View File
@@ -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")
+1 -2
View File
@@ -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
+2 -2
View File
@@ -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)
+98 -11
View File
@@ -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 <profile>",
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 <id-prefix>"))
}
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 <id-prefix> <new_profile_name>")
}
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 <id-prefix>")
}
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 <id-prefix>")
}
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 <id-prefix>")
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)
+29
View File
@@ -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)
}
}
+16 -2
View File
@@ -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...)
}
+2 -3
View File
@@ -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)
}
}
+2 -2
View File
@@ -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
}
+40 -4
View File
@@ -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 {
+139
View File
@@ -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)
})
}
}
+37 -7
View File
@@ -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
}
+63
View File
@@ -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-<authority>" 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.
//
+68 -50
View File
@@ -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 <profile>"),
},
}
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}
}
@@ -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)
})
}
}
@@ -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)
}
+146
View File
@@ -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:]
}
+3 -3
View File
@@ -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
}
+22
View File
@@ -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 {
@@ -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
+383 -262
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -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
+18
View File
@@ -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;
}
+38
View File
@@ -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,
+185
View File
@@ -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())
}
+104 -17
View File
@@ -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
+17 -5
View File
@@ -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)
}
+4 -2
View File
@@ -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
+3 -43
View File
@@ -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.
@@ -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);
}
@@ -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]);
@@ -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;
}
@@ -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);
}
}, []);
@@ -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 extends keyof Config>(k: K, v: Config[K]) => void;
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
saveFields: (partial: Partial<Config>, opts?: { preSharedKey?: string }) => Promise<void>;
@@ -66,7 +65,6 @@ type LoadedConfig = { profileName: string; data: Config };
const useSettingsState = () => {
const { username, activeProfileId, loaded: profileLoaded } = useProfile();
const [loaded, setLoaded] = useState<LoadedConfig | null>(null);
const [guiVersion, setGuiVersion] = useState<string>("—");
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadedRef = useRef<LoadedConfig | null>(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<SettingsContextValue | null>(
() =>
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);
}
}, []);
@@ -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<string>(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;
};
+2 -5
View File
@@ -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;
}
+30 -5
View File
@@ -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<string, unknown> | null =>
v && typeof v === "object" ? (v as Record<string, unknown>) : null;
@@ -22,14 +22,15 @@ const toWailsEnvelope = (e: unknown): Record<string, unknown> | 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<void> {
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<void> {
return errorDialog({
Title: title,
Message: formatErrorMessage(e),
Command: errorCommand(e),
});
}
@@ -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],
);
@@ -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;
@@ -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);
}
@@ -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);
}
@@ -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]);
@@ -24,8 +24,8 @@ const SlackIcon = (props: SVGProps<SVGSVGElement>) => (
/>
</svg>
);
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();
@@ -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<Tab> = new Set([
Tab.General,
Tab.Network,
Tab.Security,
Tab.SSH,
Tab.Advanced,
]);
const TAB_CONTENT: Record<Tab, ReactNode> = {
[Tab.General]: <SettingsGeneral />,
[Tab.Network]: <SettingsNetwork />,
@@ -42,9 +53,16 @@ const TAB_CONTENT: Record<Tab, ReactNode> = {
[Tab.About]: <SettingsAbout />,
};
// 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 ? <SettingsProvider>{children}</SettingsProvider> : <>{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<Tab[]>(() => {
@@ -63,7 +81,11 @@ export const SettingsPage = () => {
}, [features.disableUpdateSettings, features.disableProfiles, mdm.allowServerSSH]);
const defaultTab = visibleTabs[0];
const [active, setActive] = useState<string>(() => 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<string>(
() => navState?.tab ?? searchParams.get("tab") ?? defaultTab,
);
useEffect(() => {
if (navState?.tab) setActive(navState.tab);
@@ -92,7 +114,7 @@ export const SettingsPage = () => {
<SettingsNavigation />
<AppRightPanel>
<AutostartSettingsProvider>
<SettingsProvider>
<WithSettings enabled={CONFIG_TABS.has(active as Tab)}>
<ScrollArea.Root
key={active}
type={"auto"}
@@ -121,7 +143,7 @@ export const SettingsPage = () => {
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</SettingsProvider>
</WithSettings>
</AutostartSettingsProvider>
</AppRightPanel>
</VerticalTabs>
@@ -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));
+12
View File
@@ -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."
}
}
+16
View File
@@ -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."
}
}
+12
View File
@@ -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."
}
}
+12
View File
@@ -1389,5 +1389,17 @@
},
"settings.ssh.privilege.authorizePending": {
"message": "En attente de lautorisation…"
},
"profile.ownedByAnother": {
"message": "Profil dun 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."
}
}
+12
View File
@@ -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óé."
}
}
+12
View File
@@ -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."
}
}
+12
View File
@@ -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": "このプロファイルは別のユーザーのものです。"
}
}
+12
View File
@@ -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."
}
}
+12
View File
@@ -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": "Этот профиль принадлежит другому пользователю."
}
}
+12
View File
@@ -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": "Цей профіль належить іншому користувачеві."
}
}
+12
View File
@@ -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": "此配置文件属于其他用户。"
}
}
+3 -3
View File
@@ -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))
+25 -16
View File
@@ -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
}
+44 -31
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"strings"
"google.golang.org/genproto/googleapis/rpc/errdetails"
gcodes "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
@@ -15,19 +14,27 @@ import (
"github.com/netbirdio/netbird/client/ui/preferences"
)
// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error
// carries one.
func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) {
for _, detail := range gstatus.Convert(err).Details() {
info, ok := detail.(*errdetails.ErrorInfo)
if !ok {
continue
}
if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain {
return info, true
}
// denialCode maps a refusal to the code the frontend presents it by, reporting
// false for a reason this build does not know. An unknown reason keeps the
// summary the daemon wrote and loses only the tailored presentation, which is
// what makes adding a reason daemon-side safe.
func denialCode(reason string) (string, bool) {
switch reason {
case ipcauth.ErrorReasonPrivilegeRequired:
return "privilege_required", true
case ipcauth.ErrorReasonSessionHeld:
return "session_held", true
case ipcauth.ErrorReasonNotProfileOwner:
return "not_profile_owner", true
default:
return "permission_denied", false
}
return nil, false
}
// privilegeRefused reports whether the daemon refused for want of privileges.
func privilegeRefused(err error) bool {
denial, ok := ipcauth.DenialFrom(err)
return ok && denial.Reason == ipcauth.ErrorReasonPrivilegeRequired
}
// ErrorTranslator localises daemon errors; runtime impl is *i18n.Bundle.
@@ -40,9 +47,10 @@ type LanguagePreference interface {
Get() preferences.UIPreferences
}
// ClientError is a structured error returned to the frontend. The frontend
// translates Code via i18n; Short is an English fallback; Long carries the
// unwrapped daemon message.
// ClientError is a structured error returned to the frontend. Short is the
// localised headline, Long the unwrapped daemon message shown under it, and Code
// the stable identifier Short was resolved from. The frontend reads Short, Long
// and Command; it does not translate Code itself.
type ClientError struct {
Code string `json:"code"`
Short string `json:"short"`
@@ -94,21 +102,8 @@ func (c errorClassifier) classify(err error) *ClientError {
grpcCode = st.Code()
}
// A refusal for want of privileges carries its own summary and the command
// that performs the operation, both written for the user. Surface them
// verbatim: no substring guessing, and no localisation of a message the
// daemon composed.
if info, ok := privilegeErrorInfo(err); ok {
summary := info.GetMetadata()[ipcauth.ErrorMetaSummary]
if summary == "" {
summary = msg
}
return &ClientError{
Code: "privilege_required",
Short: summary,
Long: summary,
Command: info.GetMetadata()[ipcauth.ErrorMetaCommand],
}
if denial, ok := ipcauth.DenialFrom(err); ok {
return c.classifyDenial(denial)
}
lower := strings.ToLower(msg)
@@ -155,6 +150,24 @@ func (c errorClassifier) classify(err error) *ClientError {
}
}
// classifyDenial presents a refusal the daemon explained: a localised headline
// for the reasons this build knows, with the daemon's own sentence as the
// detail the frontend shows under it. An unrecognised reason keeps that sentence
// as the headline too, so a reason added daemon-side still reaches the user.
func (c errorClassifier) classifyDenial(denial ipcauth.Denial) *ClientError {
code, known := denialCode(denial.Reason)
short := denial.Summary
if known {
short = c.translateShort(code)
}
return &ClientError{
Code: code,
Short: short,
Long: denial.Summary,
Command: denial.Command,
}
}
// translateShort resolves the localised short message for code, returning the
// bare "error.<code>" key when no translation is available so the gap stays visible.
func (c errorClassifier) translateShort(code string) string {
+109
View File
@@ -4,11 +4,17 @@ package services
import (
"errors"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genproto/googleapis/rpc/errdetails"
gcodes "google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/ui/i18n"
)
func TestErrorClassifier_Classify(t *testing.T) {
@@ -48,3 +54,106 @@ func TestErrorClassifier_Classify(t *testing.T) {
require.Nil(t, c.classify(nil))
})
}
// Every reason the daemon explains gets its own code, and the headline is looked
// up from that code rather than repeating the daemon's sentence, so a held
// session reads differently from a privilege refusal.
func TestClassifyMapsEveryDaemonReason(t *testing.T) {
c := errorClassifier{} // nil translator → Short is the bare "error.<code>" key
for _, tc := range []struct {
name string
err error
code string
command bool
}{
{"privilege", ipcauth.PrivilegeError("Claiming a profile requires root.", "sudo netbird profile claim"), "privilege_required", true},
{"session held", ipcauth.SessionHeldError("switching profile"), "session_held", true},
{"not owner", ipcauth.NotOwnerError("reading the profile configuration"), "not_profile_owner", false},
} {
t.Run(tc.name, func(t *testing.T) {
got := c.classify(tc.err)
require.NotNil(t, got)
assert.Equal(t, tc.code, got.Code)
assert.Equal(t, "error."+tc.code, got.Short, "Short comes from the locale bundle, not the daemon")
assert.NotEmpty(t, got.Long, "the daemon's sentence has to reach the user")
assert.NotContains(t, got.Long, "rpc error")
assert.Equal(t, tc.command, got.Command != "")
})
}
}
// Every denial code needs an entry in the shipped bundle, or the dialog shows a
// bare "error.<code>" key where the headline should be. Resolved against the real
// locale tree so a reason added without a translation fails here, not on screen.
func TestDenialHeadlinesResolveInTheShippedBundle(t *testing.T) {
bundle, err := i18n.NewBundle(os.DirFS("../i18n/locales"))
require.NoError(t, err, "the shipped locale tree must load")
c := errorClassifier{translator: bundle}
for _, tc := range []struct {
name string
err error
short string
long string
}{
{
"privilege",
ipcauth.PrivilegeError("Claiming a profile requires root.", "sudo netbird profile claim"),
"This action requires elevated privileges.",
"Claiming a profile requires root.",
},
{
"session held",
ipcauth.SessionHeldError("switching profile"),
"Another user has this machine connected.",
"Switching profile is refused while another user has this machine connected.",
},
{
"not owner",
ipcauth.NotOwnerError("reading the profile configuration"),
"This profile belongs to another user.",
"Reading the profile configuration is refused because the profile it addresses belongs to another user.",
},
} {
t.Run(tc.name, func(t *testing.T) {
got := c.classify(tc.err)
require.NotNil(t, got)
assert.Equal(t, tc.short, got.Short, "Short should be the localised headline for the code")
assert.Contains(t, got.Long, tc.long, "Long should carry the daemon's own sentence")
assert.NotEqual(t, got.Short, got.Long, "a repeated sentence costs the frontend its detail line")
})
}
}
// A reason added daemon-side must still reach the user, losing only the tailored
// presentation. It must not borrow another code's headline: "permission_denied"
// is the sign-in rejection, which has nothing to do with an IPC refusal.
func TestClassifyKeepsTheSentenceForAnUnknownReason(t *testing.T) {
code, known := denialCode("SOMETHING_NEW")
assert.False(t, known, "an unrecognised reason must not claim a tailored headline")
assert.Equal(t, "permission_denied", code)
const summary = "Doing something new is refused for a reason this build predates."
st, err := gstatus.New(gcodes.PermissionDenied, summary).WithDetails(&errdetails.ErrorInfo{
Reason: "SOMETHING_NEW",
Domain: ipcauth.ErrorDomain,
Metadata: map[string]string{ipcauth.ErrorMetaSummary: summary},
})
require.NoError(t, err)
got := errorClassifier{}.classify(st.Err())
require.NotNil(t, got)
assert.Equal(t, summary, got.Short, "the daemon's sentence stands in for the headline")
assert.Equal(t, summary, got.Long)
}
// Only a privilege refusal is answered by offering to elevate. Offering it for
// a session another user holds would be nonsense.
func TestPrivilegeRefusedIsNarrow(t *testing.T) {
assert.True(t, privilegeRefused(ipcauth.PrivilegeError("x", "y")))
assert.False(t, privilegeRefused(ipcauth.SessionHeldError("connecting")))
assert.False(t, privilegeRefused(ipcauth.NotOwnerError("connecting")))
assert.False(t, privilegeRefused(errors.New("connection refused")))
}
+18 -15
View File
@@ -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
}
+85
View File
@@ -0,0 +1,85 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
)
// stubProfileDaemon refuses SwitchProfile the way the daemon refuses a caller
// who does not hold the session. The embedded interface is nil, so any other
// call panics rather than passing quietly.
type stubProfileDaemon struct {
proto.DaemonServiceClient
err error
}
func (d *stubProfileDaemon) SwitchProfile(_ context.Context, _ *proto.SwitchProfileRequest, _ ...grpc.CallOption) (*proto.SwitchProfileResponse, error) {
return nil, d.err
}
// sessionHeldRefusal is the error the daemon raises when another user holds the
// connection, detail and all: see ipcauth.SessionHeldError.
func sessionHeldRefusal(t *testing.T) error {
t.Helper()
st, err := gstatus.New(codes.PermissionDenied, sessionHeldSummaryText+"\n\nsudo netbird down").
WithDetails(&errdetails.ErrorInfo{
Reason: ipcauth.ErrorReasonSessionHeld,
Domain: ipcauth.ErrorDomain,
Metadata: map[string]string{
ipcauth.ErrorMetaSummary: sessionHeldSummaryText,
ipcauth.ErrorMetaCommand: "sudo netbird down",
},
})
require.NoError(t, err, "build the refusal detail")
return st.Err()
}
const sessionHeldSummaryText = "Switching profiles is refused while another user has this machine connected."
func profilesRefusingSwitch(t *testing.T) *Profiles {
t.Helper()
// nil translator → Short is the bare "error.<code>" key, which is enough to
// tell a resolved headline from the daemon's own sentence.
return NewProfiles(stubConn{client: &stubProfileDaemon{err: sessionHeldRefusal(t)}}, nil, nil)
}
// A refused switch has to reach the caller as the classified value, since that is
// the only thing carrying the headline and the command the frontend renders.
func TestProfilesSwitchClassifiesRefusal(t *testing.T) {
_, err := profilesRefusingSwitch(t).Switch(context.Background(), ProfileRef{ProfileName: "work"})
clientErr, ok := err.(*ClientError)
require.True(t, ok, "Switch must return the classified error, got %T", err)
assert.Equal(t, "session_held", clientErr.Code, "the refusal reason decides the code")
assert.Equal(t, sessionHeldSummaryText, clientErr.Long, "the daemon's sentence is the detail")
assert.Equal(t, "sudo netbird down", clientErr.Command, "the suggested command survives")
}
// The switcher used to wrap this in fmt.Errorf, which left the Wails binding
// nothing to marshal and put the raw "switch profile %q: rpc error: ..." string
// in front of the user instead of the headline and the copyable command.
func TestProfileSwitcherReturnsClassifiedRefusal(t *testing.T) {
switcher := NewProfileSwitcher(profilesRefusingSwitch(t), nil, nil)
err := switcher.SwitchActive(context.Background(), ProfileRef{ProfileName: "01HZY0000000000000000000"})
clientErr, ok := err.(*ClientError)
require.True(t, ok, "the switcher must pass the classified error through, got %T", err)
assert.Equal(t, "session_held", clientErr.Code, "the refusal reason decides the code")
assert.Equal(t, "sudo netbird down", clientErr.Command, "the suggested command survives")
assert.Equal(t, "error.session_held", err.Error(),
"no wrapping prefix and no gRPC dump in front of the headline")
}
+6 -3
View File
@@ -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
}
}
+7 -5
View File
@@ -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)
+63
View File
@@ -0,0 +1,63 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
)
const notOwnerSummaryText = "Reading the profile configuration is refused because the profile it addresses belongs to another user."
// stubConfigDaemon refuses GetConfig the way the daemon refuses a caller who
// does not own the profile the request names. The embedded interface is nil, so
// any other call panics rather than passing quietly.
type stubConfigDaemon struct {
proto.DaemonServiceClient
err error
}
func (d *stubConfigDaemon) GetConfig(_ context.Context, _ *proto.GetConfigRequest, _ ...grpc.CallOption) (*proto.GetConfigResponse, error) {
return nil, d.err
}
// notOwnerRefusal is the error the gate raises for a profile owned by somebody
// else: see ipcauth.NotOwnerError. It carries no command on purpose — privilege
// is not what the method asked for.
func notOwnerRefusal(t *testing.T) error {
t.Helper()
st, err := gstatus.New(codes.PermissionDenied, notOwnerSummaryText).
WithDetails(&errdetails.ErrorInfo{
Reason: ipcauth.ErrorReasonNotProfileOwner,
Domain: ipcauth.ErrorDomain,
Metadata: map[string]string{ipcauth.ErrorMetaSummary: notOwnerSummaryText},
})
require.NoError(t, err, "build the refusal detail")
return st.Err()
}
// The settings screen reads the config on mount and shows whatever comes back,
// so an unclassified refusal there is a raw gRPC dump in front of the user.
func TestSettingsGetConfigClassifiesRefusal(t *testing.T) {
// nil translator → Short is the bare "error.<code>" key.
settings := NewSettings(stubConn{client: &stubConfigDaemon{err: notOwnerRefusal(t)}}, nil, nil, testDaemonAddr)
_, err := settings.GetConfig(context.Background(), ConfigParams{ProfileName: "01HZY0000000000000000000"})
clientErr, ok := err.(*ClientError)
require.True(t, ok, "GetConfig must return the classified error, got %T", err)
assert.Equal(t, "not_profile_owner", clientErr.Code, "the refusal reason decides the code")
assert.Equal(t, notOwnerSummaryText, clientErr.Long, "the daemon's sentence is the detail")
assert.Empty(t, clientErr.Command, "this refusal has no command to offer")
}
+9 -6
View File
@@ -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(),
+12 -3
View File
@@ -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 {
+9
View File
@@ -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")
}