Merge remote-tracking branch 'origin/main' into fix_debug_upload_url_from_mgmt

# Conflicts:
#	client/proto/daemon.pb.go
#	management/internals/controllers/network_map/controller/controller.go
#	management/internals/controllers/network_map/nmaptest/runner.go
#	management/internals/shared/grpc/server.go
#	management/server/account.go
#	shared/management/proto/management.pb.go
#	upload-server/server/server.go
This commit is contained in:
riccardom
2026-10-09 11:13:57 +02:00
518 changed files with 30626 additions and 12765 deletions
+58 -26
View File
@@ -23,7 +23,10 @@ import (
"github.com/netbirdio/netbird/version"
)
const errCloseConnection = "Failed to close connection: %v"
const (
errCloseConnection = "Failed to close connection: %v"
noUpDownFlag = "no-updown"
)
// uploadBundleURLUsage documents that an empty flag is not "no upload" but
// "wherever this deployment says": the daemon takes the destination from the
@@ -267,13 +270,14 @@ func runForDuration(cmd *cobra.Command, args []string) error {
}
stateWasDown := stat.Status != string(internal.StatusConnected) && stat.Status != string(internal.StatusConnecting)
noUpDown, _ := cmd.Flags().GetBool(noUpDownFlag)
initialLogLevel, err := client.GetLogLevel(cmd.Context(), &proto.GetLogLevelRequest{})
if err != nil {
return fmt.Errorf("failed to get log level: %v", status.Convert(err).Message())
}
if stateWasDown {
if stateWasDown && !noUpDown {
if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil {
cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message())
} else {
@@ -294,34 +298,20 @@ func runForDuration(cmd *cobra.Command, args []string) error {
}
needsRestoreUp := false
if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil {
cmd.PrintErrf("Failed to bring service down: %v\n", status.Convert(err).Message())
if noUpDown {
enableSyncResponsePersistence(cmd, client)
} else {
needsRestoreUp = !stateWasDown
cmd.Println("netbird down")
needsRestoreUp = restartDaemon(cmd, client, stateWasDown)
}
time.Sleep(1 * time.Second)
// Enable sync response persistence before bringing the service up
if _, err := client.SetSyncResponsePersistence(cmd.Context(), &proto.SetSyncResponsePersistenceRequest{
Enabled: true,
}); err != nil {
cmd.PrintErrf("Failed to enable sync response persistence: %v\n", status.Convert(err).Message())
}
if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil {
cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message())
} else {
needsRestoreUp = false
cmd.Println("netbird up")
}
time.Sleep(3 * time.Second)
cpuProfilingStarted := false
if _, err := client.StartCPUProfile(cmd.Context(), &proto.StartCPUProfileRequest{}); err != nil {
cmd.PrintErrf("Failed to start CPU profiling: %v\n", err)
if msg := status.Convert(err).Message(); strings.Contains(msg, "already in progress") {
cmd.PrintErrln("CPU profiling is already running (started with `netbird debug cpu start`). " +
"It is left running and is included in a bundle created after `netbird debug cpu stop`.")
} else {
cmd.PrintErrf("Failed to start CPU profiling: %v\n", msg)
}
} else {
cpuProfilingStarted = true
defer func() {
@@ -412,7 +402,7 @@ func runForDuration(cmd *cobra.Command, args []string) error {
}
}
if stateWasDown {
if stateWasDown && !noUpDown {
if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil {
cmd.PrintErrf("Failed to restore service down state: %v\n", status.Convert(err).Message())
} else {
@@ -469,6 +459,47 @@ func setSyncResponsePersistence(cmd *cobra.Command, args []string) error {
return nil
}
// enableSyncResponsePersistence asks the daemon to keep the latest sync
// response so the bundle carries the network map. With a running daemon only
// syncs received after the call are kept.
func enableSyncResponsePersistence(cmd *cobra.Command, client proto.DaemonServiceClient) {
if _, err := client.SetSyncResponsePersistence(cmd.Context(), &proto.SetSyncResponsePersistenceRequest{
Enabled: true,
}); err != nil {
cmd.PrintErrf("Failed to enable sync response persistence: %v\n", status.Convert(err).Message())
}
}
// restartDaemon cycles the daemon down and up with sync response persistence
// enabled so the bundle carries the network map. It reports whether the
// daemon was left down although it was running before, so the caller can
// bring it back up.
func restartDaemon(cmd *cobra.Command, client proto.DaemonServiceClient, stateWasDown bool) bool {
needsRestoreUp := false
if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil {
cmd.PrintErrf("Failed to bring service down: %v\n", status.Convert(err).Message())
} else {
needsRestoreUp = !stateWasDown
cmd.Println("netbird down")
}
time.Sleep(1 * time.Second)
// Enable sync response persistence before bringing the service up
enableSyncResponsePersistence(cmd, client)
if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil {
cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message())
} else {
needsRestoreUp = false
cmd.Println("netbird up")
}
time.Sleep(3 * time.Second)
return needsRestoreUp
}
func waitForDurationOrCancel(ctx context.Context, duration time.Duration, cmd *cobra.Command) error {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
@@ -572,4 +603,5 @@ func init() {
forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", "", uploadBundleURLUsage)
forCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root")
forCmd.Flags().Bool("capture", false, "Capture packets during the debug duration and include in bundle")
forCmd.Flags().Bool(noUpDownFlag, false, "Keep the daemon running instead of bringing it down and up before collecting. The bundle only includes the network map if a sync arrives during the run")
}
+83
View File
@@ -0,0 +1,83 @@
package cmd
import (
"fmt"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/proto"
)
var debugCPUCmd = &cobra.Command{
Use: "cpu",
Short: "Profile the daemon's CPU usage",
Long: `Starts and stops CPU profiling in the running daemon without restarting it.
The profile is included in the next debug bundle as cpu.prof.
Profiling is not time limited: it keeps running, and keeps costing CPU, until
"netbird debug cpu stop" is run.`,
}
var debugCPUStartCmd = &cobra.Command{
Use: "start",
Short: "Start CPU profiling in the daemon",
Example: " netbird debug cpu start",
Args: cobra.NoArgs,
RunE: debugCPUStart,
}
var debugCPUStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop CPU profiling in the daemon",
Long: `Stops CPU profiling. The captured profile stays in the daemon until the next
debug bundle is created, which includes it as cpu.prof.`,
Example: " netbird debug cpu stop && netbird debug bundle",
Args: cobra.NoArgs,
RunE: debugCPUStop,
}
func debugCPUStart(cmd *cobra.Command, _ []string) error {
conn, err := getClient(cmd)
if err != nil {
return err
}
defer func() {
if err := conn.Close(); err != nil {
log.Errorf(errCloseConnection, err)
}
}()
if _, err := proto.NewDaemonServiceClient(conn).StartCPUProfile(cmd.Context(), &proto.StartCPUProfileRequest{}); err != nil {
return fmt.Errorf("start CPU profiling: %v", status.Convert(err).Message())
}
cmd.Println("CPU profiling started and runs until stopped. Run `netbird debug cpu stop` and then `netbird debug bundle` to collect it.")
return nil
}
func debugCPUStop(cmd *cobra.Command, _ []string) error {
conn, err := getClient(cmd)
if err != nil {
return err
}
defer func() {
if err := conn.Close(); err != nil {
log.Errorf(errCloseConnection, err)
}
}()
if _, err := proto.NewDaemonServiceClient(conn).StopCPUProfile(cmd.Context(), &proto.StopCPUProfileRequest{}); err != nil {
return fmt.Errorf("stop CPU profiling: %v", status.Convert(err).Message())
}
cmd.Println("CPU profiling stopped. Run `netbird debug bundle` to include cpu.prof.")
return nil
}
func init() {
debugCPUCmd.AddCommand(debugCPUStartCmd)
debugCPUCmd.AddCommand(debugCPUStopCmd)
debugCmd.AddCommand(debugCPUCmd)
}
+164
View File
@@ -0,0 +1,164 @@
package cmd
import (
"bytes"
"context"
"os/user"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
// startDebugTestDaemon starts an in-process daemon with an isolated profile
// directory and returns the address the CLI should dial.
func startDebugTestDaemon(t *testing.T) string {
t.Helper()
tempDir := t.TempDir()
origDefaultProfileDir := profilemanager.DefaultConfigPathDir
origActiveProfileStatePath := profilemanager.ActiveProfileStatePath
origConfigDirOverride := profilemanager.ConfigDirOverride
origDaemonAddr := daemonAddr
t.Cleanup(func() {
profilemanager.DefaultConfigPathDir = origDefaultProfileDir
profilemanager.ActiveProfileStatePath = origActiveProfileStatePath
profilemanager.ConfigDirOverride = origConfigDirOverride
daemonAddr = origDaemonAddr
})
profilemanager.DefaultConfigPathDir = tempDir
profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json"
profilemanager.ConfigDirOverride = tempDir
currUser, err := user.Current()
require.NoError(t, err)
sm := profilemanager.ServiceManager{}
created, err := sm.AddProfile("test1", currUser.Username)
require.NoError(t, err)
require.NoError(t, sm.SetActiveProfileState(&profilemanager.ActiveProfileState{
ID: created.ID,
Username: currUser.Username,
}))
ctx, cancel := context.WithCancel(internal.CtxInitState(context.Background()))
srv, lis := startClientDaemon(t, ctx, "", tempDir+"/config.json")
t.Cleanup(func() {
cancel()
srv.Stop()
})
return "tcp://" + lis.Addr().String()
}
// runDebugCmd runs `netbird debug <args>` against the daemon at addr and
// returns everything the command printed.
func runDebugCmd(addr string, args ...string) (string, error) {
daemonAddr = addr
var out bytes.Buffer
rootCmd.SetOut(&out)
rootCmd.SetErr(&out)
rootCmd.SetArgs(append(append([]string{"debug"}, args...), "--daemon-addr", addr, "--log-file", ""))
err := rootCmd.Execute()
rootCmd.SetOut(nil)
rootCmd.SetErr(nil)
rootCmd.SetArgs(nil)
resetFlags(rootCmd)
return out.String(), err
}
// resetFlags puts every flag of the command and its subcommands back to its
// default so a value parsed in one run does not leak into the next in-process
// execution.
func resetFlags(cmd *cobra.Command) {
reset := func(f *pflag.Flag) {
// Set appends to a slice flag and would parse the "[a,b]" default
// text as elements, so slices are replaced instead.
if sv, ok := f.Value.(pflag.SliceValue); ok {
var def []string
if trimmed := strings.Trim(f.DefValue, "[]"); trimmed != "" {
def = strings.Split(trimmed, ",")
}
_ = sv.Replace(def)
} else {
_ = f.Value.Set(f.DefValue)
}
f.Changed = false
}
cmd.Flags().VisitAll(reset)
cmd.PersistentFlags().VisitAll(reset)
// Commands pin their writers to the buffer of the run that first used
// them, so a later run would print into the old buffer.
cmd.SetOut(nil)
cmd.SetErr(nil)
for _, sub := range cmd.Commands() {
resetFlags(sub)
}
}
// TestResetFlagsSliceDefault guards against Set("[]") on slice flags, which
// stores a literal "[]" element instead of the empty default.
func TestResetFlagsSliceDefault(t *testing.T) {
cmd := &cobra.Command{Use: "x"}
var env, withDefault []string
cmd.Flags().StringSliceVar(&env, "env", nil, "")
cmd.Flags().StringSliceVar(&withDefault, "names", []string{"a", "b"}, "")
require.NoError(t, cmd.Flags().Parse([]string{"--env", "K=V", "--names", "c"}))
resetFlags(cmd)
assert.Empty(t, env, "slice flag with no default must reset to empty")
assert.Equal(t, []string{"a", "b"}, withDefault, "slice flag must reset to its default")
}
func TestDebugCPUStartStop(t *testing.T) {
addr := startDebugTestDaemon(t)
run := func(args ...string) error {
_, err := runDebugCmd(addr, append([]string{"cpu"}, args...)...)
return err
}
require.Error(t, run("stop"), "stop without a running profile must fail")
require.NoError(t, run("start"))
assert.Error(t, run("start"), "second start must be rejected while profiling")
require.NoError(t, run("stop"))
assert.Error(t, run("stop"), "second stop must be rejected")
assert.NoError(t, run("start"), "profiling can be started again after a stop")
assert.NoError(t, run("stop"))
}
// TestDebugForKeepsRunningCPUProfile covers `debug for` started while a
// profile from `debug cpu start` is running: it must say so, leave the
// profile alone, and still create the bundle.
func TestDebugForKeepsRunningCPUProfile(t *testing.T) {
addr := startDebugTestDaemon(t)
_, err := runDebugCmd(addr, "cpu", "start")
require.NoError(t, err)
out, err := runDebugCmd(addr, "for", "1s", "-S=false", "--no-updown")
require.NoError(t, err, "output: %s", out)
assert.Contains(t, out, "CPU profiling is already running", "the conflict must be explained")
assert.NotContains(t, out, "rpc error", "the raw RPC error must not reach the user")
assert.Contains(t, out, "Local file:", "the bundle must still be created")
_, err = runDebugCmd(addr, "cpu", "stop")
assert.NoError(t, err, "the profile started by the user must still be running")
}
func TestDebugForNoUpDown(t *testing.T) {
addr := startDebugTestDaemon(t)
out, err := runDebugCmd(addr, "for", "1s", "-S=false", "--no-updown")
require.NoError(t, err, "output: %s", out)
assert.NotContains(t, out, "netbird down", "--no-updown must not bring the daemon down")
assert.NotContains(t, out, "netbird up", "--no-updown must not bring the daemon up")
assert.Contains(t, out, "Local file:", "the bundle must still be created")
}
-98
View File
@@ -1,98 +0,0 @@
package cmd
import (
"fmt"
"sort"
"github.com/spf13/cobra"
"google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/proto"
)
var forwardingRulesCmd = &cobra.Command{
Use: "forwarding",
Short: "List forwarding rules",
Long: `Commands to list forwarding rules.`,
}
var forwardingRulesListCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List forwarding rules",
Example: " netbird forwarding list",
Long: "Commands to list forwarding rules.",
RunE: listForwardingRules,
}
func listForwardingRules(cmd *cobra.Command, _ []string) error {
conn, err := getClient(cmd)
if err != nil {
return err
}
defer conn.Close()
client := proto.NewDaemonServiceClient(conn)
resp, err := client.ForwardingRules(cmd.Context(), &proto.EmptyRequest{})
if err != nil {
return fmt.Errorf("failed to list network: %v", status.Convert(err).Message())
}
if len(resp.GetRules()) == 0 {
cmd.Println("No forwarding rules available.")
return nil
}
printForwardingRules(cmd, resp.GetRules())
return nil
}
func printForwardingRules(cmd *cobra.Command, rules []*proto.ForwardingRule) {
cmd.Println("Available forwarding rules:")
// Sort rules by translated address
sort.Slice(rules, func(i, j int) bool {
if rules[i].GetTranslatedAddress() != rules[j].GetTranslatedAddress() {
return rules[i].GetTranslatedAddress() < rules[j].GetTranslatedAddress()
}
if rules[i].GetProtocol() != rules[j].GetProtocol() {
return rules[i].GetProtocol() < rules[j].GetProtocol()
}
return getFirstPort(rules[i].GetDestinationPort()) < getFirstPort(rules[j].GetDestinationPort())
})
var lastIP string
for _, rule := range rules {
dPort := portToString(rule.GetDestinationPort())
tPort := portToString(rule.GetTranslatedPort())
if lastIP != rule.GetTranslatedAddress() {
lastIP = rule.GetTranslatedAddress()
cmd.Printf("\nTranslated peer: %s\n", rule.GetTranslatedHostname())
}
cmd.Printf(" Local %s/%s to %s:%s\n", rule.GetProtocol(), dPort, rule.GetTranslatedAddress(), tPort)
}
}
func getFirstPort(portInfo *proto.PortInfo) int {
switch v := portInfo.PortSelection.(type) {
case *proto.PortInfo_Port:
return int(v.Port)
case *proto.PortInfo_Range_:
return int(v.Range.GetStart())
default:
return 0
}
}
func portToString(translatedPort *proto.PortInfo) string {
switch v := translatedPort.PortSelection.(type) {
case *proto.PortInfo_Port:
return fmt.Sprintf("%d", v.Port)
case *proto.PortInfo_Range_:
return fmt.Sprintf("%d-%d", v.Range.GetStart(), v.Range.GetEnd())
default:
return "No port specified"
}
}
+54 -8
View File
@@ -9,8 +9,6 @@ import (
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"golang.org/x/term"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
@@ -145,10 +143,7 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
err = WithBackOff(func() error {
var backOffErr error
loginResp, backOffErr = client.Login(ctx, &loginRequest)
if s, ok := gstatus.FromError(backOffErr); ok && (s.Code() == codes.InvalidArgument ||
s.Code() == codes.PermissionDenied ||
s.Code() == codes.NotFound ||
s.Code() == codes.Unimplemented) {
if terminalLoginError(backOffErr) {
loginErr = backOffErr
return nil
}
@@ -327,10 +322,28 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string,
}
config, err := profilemanager.ReadConfig(configFilePath)
config, err := profilemanager.ReadConfigOrDefault(configFilePath)
if err != nil {
return fmt.Errorf("read config file %s: %v", configFilePath, err)
}
// Reading a config does not provision one: this login is about to dial
// management with the profile's identity, so mint the keys if the profile
// has none yet and put them on disk — a key that stayed in memory would
// come back different on the next run and register a second peer.
//
// Before the MDM overlay below, on purpose: the file must keep the
// profile's own values. The overlay is runtime-only and re-derived on
// every load, so persisting it would turn an enforced management URL or
// pre-shared key into one the user appears to own once the policy is
// withdrawn.
if generated, err := config.EnsureIdentity(); err != nil {
return fmt.Errorf("ensure profile identity: %v", err)
} else if generated {
if err := profilemanager.WriteOutConfig(configFilePath, config); err != nil {
return fmt.Errorf("write out config file %s: %v", configFilePath, err)
}
}
// CLI standalone login: profilemanager no longer auto-applies MDM,
// so layer in the OS-native policy here. Desktop builds construct
// a Loader with no fetcher — the build-tagged loadPlatform reads
@@ -412,11 +425,44 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro
hint = profileState.Email
}
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint)
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint, false)
if err != nil {
return nil, err
}
tokenInfo, err := runInteractiveFlow(cmd, oAuthFlow)
if err != nil {
return nil, err
}
if tokenInfo.MatchesAccount(hint) {
return tokenInfo, nil
}
// The IdP answered from a session belonging to another account. Retrying is
// what makes this recoverable: on a peer already registered the server would
// reject the token, and on a fresh one it would silently register the peer
// under the wrong account and bind the profile to it.
cmd.Println("The login returned a different account than this profile uses. Asking to sign in again.")
retryFlow := auth.RetryFlowForAccount(oAuthFlow)
if retryFlow == nil {
return tokenInfo, nil
}
retryToken, err := runInteractiveFlow(cmd, retryFlow)
if err != nil {
return nil, err
}
if !retryToken.MatchesAccount(hint) {
log.Warnf("login still returned a different account after the prompt, continuing with it")
}
return retryToken, nil
}
// runInteractiveFlow requests the authorization info, shows the URL to the user
// and blocks until the token comes back.
func runInteractiveFlow(cmd *cobra.Command, oAuthFlow auth.OAuthFlow) (*auth.TokenInfo, error) {
flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO())
if err != nil {
return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err)
+39 -3
View File
@@ -20,6 +20,8 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/anonymize"
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
@@ -175,7 +177,6 @@ func init() {
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(sshCmd)
rootCmd.AddCommand(networksCMD)
rootCmd.AddCommand(forwardingRulesCmd)
rootCmd.AddCommand(debugCmd)
rootCmd.AddCommand(profileCmd)
rootCmd.AddCommand(exposeCmd)
@@ -183,8 +184,6 @@ func init() {
networksCMD.AddCommand(routesListCmd)
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
forwardingRulesCmd.AddCommand(forwardingRulesListCmd)
debugCmd.AddCommand(debugBundleCmd)
debugCmd.AddCommand(logCmd)
logCmd.AddCommand(logLevelCmd)
@@ -285,6 +284,43 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e
return grpc.DialContext(ctx, target, opts...)
}
// terminalLoginError reports whether a Login failure is final, so the backoff
// cycle stops and the caller is told what the daemon said instead of "login
// backoff cycle failed" thirty seconds later. Retrying cannot change any of
// these answers: the request is malformed, the caller is not allowed, the
// target does not exist, a precondition on the daemon refuses it (the
// update-settings kill switch, an MDM-managed field), or the method is not
// implemented.
//
// Both `netbird up` and `netbird login` run Login through the backoff, and
// they each carried their own copy of this list — which is how one of them
// ended up retrying a refusal the other treated as final.
func terminalLoginError(err error) bool {
// A successful Login reaches here with a nil error, and that is not a
// terminal failure. Handled explicitly rather than left to
// gstatus.FromError, which answers (nil, true) for a nil error and leans on
// Status.Code tolerating a nil receiver to come back as codes.OK.
if err == nil {
return false
}
s, ok := gstatus.FromError(err)
if !ok {
return false
}
switch s.Code() {
case codes.InvalidArgument,
codes.PermissionDenied,
codes.NotFound,
codes.FailedPrecondition,
codes.Unimplemented:
return true
default:
return false
}
}
// WithBackOff execute function in backoff cycle.
func WithBackOff(bf func() error) error {
return backoff.RetryNotify(bf, CLIBackOffSettings, func(err error, duration time.Duration) {
+3 -4
View File
@@ -6,9 +6,9 @@ import (
"testing"
"time"
"go.uber.org/mock/gomock"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
"go.uber.org/mock/gomock"
"google.golang.org/grpc"
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
@@ -28,7 +28,6 @@ import (
mgmt "github.com/netbirdio/netbird/management/server"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/groups"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/settings"
"github.com/netbirdio/netbird/management/server/store"
@@ -124,9 +123,9 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
updateManager := update_channel.NewPeersUpdateManager(metrics)
requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store)
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config, nil)
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", manager.NewEphemeralManager(store, peersmanager), config, nil)
accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, settingsMockManager, permissionsManagerMock, false, cacheStore)
if err != nil {
t.Fatal(err)
}
+28 -7
View File
@@ -357,9 +357,17 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
// set the new config
req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username)
if _, err := client.SetConfig(ctx, req); err != nil {
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable {
log.Warnf("setConfig method is not available in the daemon: %s", st.Message())
} else {
switch reason, refused := refusedSettingsUpdate(err); {
case refused:
// Failing here is the point: carrying on would connect while
// silently dropping the settings the caller asked for, since
// nothing further down the line applies them.
return fmt.Errorf("the daemon refused the settings update: %s", reason)
case gstatus.Code(err) == codes.Unavailable:
// The daemon cannot serve the method at all, which is what this
// code means; an older daemon without it lands here.
log.Warnf("the daemon did not apply the settings update: %s", gstatus.Convert(err).Message())
default:
return daemonCallError("call service setConfig method", err)
}
}
@@ -400,10 +408,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
err = WithBackOff(func() error {
var backOffErr error
loginResp, backOffErr = client.Login(ctx, loginRequest)
if s, ok := gstatus.FromError(backOffErr); ok && (s.Code() == codes.InvalidArgument ||
s.Code() == codes.PermissionDenied ||
s.Code() == codes.NotFound ||
s.Code() == codes.Unimplemented) {
if terminalLoginError(backOffErr) {
loginErr = backOffErr
return nil
}
@@ -472,6 +477,22 @@ func setSSHSetConfigFields(req *proto.SetConfigRequest, cmd *cobra.Command) {
}
}
// refusedSettingsUpdate reports whether err is the daemon refusing the settings
// a request carried — the update-settings kill switch, or a field an MDM policy
// manages — and returns the reason it gave.
//
// The distinction that matters is against codes.Unavailable, which means the
// daemon cannot serve the call: that one is worth a warning, because an older
// daemon without the method lands there and the rest of `netbird up` still
// works. A refusal is not, because the settings would be silently dropped.
func refusedSettingsUpdate(err error) (string, bool) {
st, ok := gstatus.FromError(err)
if !ok || st.Code() != codes.FailedPrecondition {
return "", false
}
return st.Message(), true
}
func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, profileName, username string) *proto.SetConfigRequest {
var req proto.SetConfigRequest
req.ProfileName = profileName
+85
View File
@@ -0,0 +1,85 @@
package cmd
import (
"errors"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
)
// A refused settings update has to fail `netbird up`, or a caller that asked
// for a setting the daemon will not apply connects as if it had been applied.
// The daemon being unable to serve the call is the case that stays a warning.
func TestRefusedSettingsUpdate(t *testing.T) {
tests := []struct {
name string
err error
wantRefused bool
}{
{
name: "the kill switch refused the change",
err: gstatus.Errorf(codes.FailedPrecondition, "update settings are disabled, you cannot use this feature without update settings enabled"),
wantRefused: true,
},
{
name: "an MDM policy manages the field",
err: gstatus.Errorf(codes.FailedPrecondition, "fields managed by MDM policy: managementURL"),
wantRefused: true,
},
{
name: "the daemon cannot serve the call",
err: gstatus.Errorf(codes.Unavailable, "connection refused"),
wantRefused: false,
},
{
name: "any other RPC failure",
err: gstatus.Errorf(codes.Internal, "boom"),
wantRefused: false,
},
{
name: "not a status error at all",
err: errors.New("boom"),
wantRefused: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reason, refused := refusedSettingsUpdate(tt.err)
require.Equal(t, tt.wantRefused, refused)
if tt.wantRefused {
require.Equal(t, gstatus.Convert(tt.err).Message(), reason, "the daemon's reason must reach the caller")
}
})
}
}
// Both `netbird up` and `netbird login` drive Login through the backoff cycle,
// and a final answer has to stop it: retrying a refusal only replaces the
// daemon's reason with "login backoff cycle failed" thirty seconds later.
func TestTerminalLoginError(t *testing.T) {
tests := []struct {
name string
err error
wantTerminal bool
}{
{name: "settings refused by the kill switch", err: gstatus.Errorf(codes.FailedPrecondition, "update settings are disabled"), wantTerminal: true},
{name: "field managed by MDM", err: gstatus.Errorf(codes.FailedPrecondition, "fields managed by MDM policy: managementURL"), wantTerminal: true},
{name: "caller not allowed", err: gstatus.Errorf(codes.PermissionDenied, "nope"), wantTerminal: true},
{name: "malformed request", err: gstatus.Errorf(codes.InvalidArgument, "nope"), wantTerminal: true},
{name: "profile not found", err: gstatus.Errorf(codes.NotFound, "nope"), wantTerminal: true},
{name: "method missing on an older daemon", err: gstatus.Errorf(codes.Unimplemented, "nope"), wantTerminal: true},
{name: "daemon unreachable, worth retrying", err: gstatus.Errorf(codes.Unavailable, "connection refused"), wantTerminal: false},
{name: "transient internal failure", err: gstatus.Errorf(codes.Internal, "boom"), wantTerminal: false},
{name: "not a status error", err: errors.New("boom"), wantTerminal: false},
{name: "no error at all, the login succeeded", err: nil, wantTerminal: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.wantTerminal, terminalLoginError(tt.err))
})
}
}