mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-02 03:29:07 +02:00
[Client] Surface readable Authz errors and add profile claim command (#7540)
* [client] Surface error messages for IPC authz in UI (#7553)
This commit is contained in:
+90
-41
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user