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