From 27991aab984e5aa8fc19a307b13ea4ed0f1dc6e4 Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Wed, 9 Sep 2026 15:36:37 +0200 Subject: [PATCH 001/209] [management] Let embedding binaries extend the command tree (#7483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The management binary is embedded by downstream builds that override server construction via SetNewServer, but the cobra command tree itself was closed: rootCmd is unexported and fully assembled in init, with no way to attach additional subcommands. Customize hands the built root command to a caller-supplied function before Execute, so an embedding binary can add its own commands next to — or under — the built-in ones, such as extra administrative helpers beneath the existing admin group. --- management/cmd/root.go | 9 ++++++++ management/cmd/root_test.go | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 management/cmd/root_test.go diff --git a/management/cmd/root.go b/management/cmd/root.go index 969dd60dd..ae03a09e8 100644 --- a/management/cmd/root.go +++ b/management/cmd/root.go @@ -54,6 +54,15 @@ func Execute() error { return rootCmd.Execute() } +// Customize hands the fully built root command to fn so an embedding binary +// can extend or adjust the command tree — most commonly attaching its own +// subcommands next to (or under) the built-in ones — before calling Execute. +// The root command is constructed in this package's init, so Customize may be +// called from the embedding binary's main at any point before Execute. +func Customize(fn func(root *cobra.Command)) { + fn(rootCmd) +} + func init() { mgmtCmd.Flags().IntVar(&mgmtPort, "port", 80, "server port to listen on (defaults to 443 if TLS is enabled, 80 otherwise") mgmtCmd.Flags().BoolVar(&disableLegacyManagementPort, "disable-legacy-port", false, "disabling the old legacy port (33073)") diff --git a/management/cmd/root_test.go b/management/cmd/root_test.go new file mode 100644 index 000000000..826fd2d50 --- /dev/null +++ b/management/cmd/root_test.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +// TestCustomize verifies an embedding binary can extend the command tree: a +// top-level command attached through the hook, and a subcommand attached under +// the built-in admin group, are both resolvable exactly as Execute would +// resolve them. +func TestCustomize(t *testing.T) { + topLevel := &cobra.Command{Use: "some-extra", RunE: func(*cobra.Command, []string) error { return nil }} + nested := &cobra.Command{Use: "cluster", RunE: func(*cobra.Command, []string) error { return nil }} + + Customize(func(root *cobra.Command) { + root.AddCommand(topLevel) + for _, c := range root.Commands() { + if c.Name() == "admin" { + c.AddCommand(nested) + return + } + } + t.Fatal("admin command not found in the root tree") + }) + t.Cleanup(func() { + rootCmd.RemoveCommand(topLevel) + for _, c := range rootCmd.Commands() { + if c.Name() == "admin" { + c.RemoveCommand(nested) + } + } + }) + + if found, _, err := rootCmd.Find([]string{"some-extra"}); err != nil || found != topLevel { + t.Fatalf("top-level command not resolvable: found=%v err=%v", found, err) + } + if found, _, err := rootCmd.Find([]string{"admin", "cluster"}); err != nil || found != nested { + t.Fatalf("nested admin subcommand not resolvable: found=%v err=%v", found, err) + } +} From 21b4a83cea05cb2a3a54d2357c875f85ec2dd10f Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 10 Sep 2026 11:57:14 +0200 Subject: [PATCH 002/209] [management] Refuse services on unvalidated custom domains (#7341) Require validated custom domains when creating or updating reverse proxy services. Propagate validation errors during updates and return HTTP 409 for duplicate domain claims. Add regression tests for domain validation, ownership, and service creation and updates. --- .../domain/manager/domain_test.go | 50 ++- .../reverseproxy/domain/manager/manager.go | 64 +++- .../domain/manager/manager_realstore_test.go | 326 ++++++++++++++++++ .../domain/manager/manager_test.go | 4 + .../service/manager/domain_validation_test.go | 127 +++++++ .../reverseproxy/service/manager/manager.go | 19 +- management/server/store/sql_store.go | 29 ++ management/server/store/store.go | 1 + management/server/store/store_mock.go | 15 + 9 files changed, 612 insertions(+), 23 deletions(-) create mode 100644 management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go create mode 100644 management/internals/modules/reverseproxy/service/manager/domain_validation_test.go diff --git a/management/internals/modules/reverseproxy/domain/manager/domain_test.go b/management/internals/modules/reverseproxy/domain/manager/domain_test.go index 523920a99..38d5a923b 100644 --- a/management/internals/modules/reverseproxy/domain/manager/domain_test.go +++ b/management/internals/modules/reverseproxy/domain/manager/domain_test.go @@ -66,8 +66,8 @@ func TestExtractClusterFromFreeDomain(t *testing.T) { func TestExtractClusterFromCustomDomains(t *testing.T) { customDomains := []*domain.Domain{ - {Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io"}, - {Domain: "proxy.corp.io", TargetCluster: "us1.proxy.netbird.io"}, + {Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io", Validated: true}, + {Domain: "proxy.corp.io", TargetCluster: "us1.proxy.netbird.io", Validated: true}, } tests := []struct { @@ -120,19 +120,49 @@ func TestExtractClusterFromCustomDomains(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains) - assert.Equal(t, tc.wantOK, ok) - if ok { - assert.Equal(t, tc.wantVal, cluster) + cluster, match := extractClusterFromCustomDomains(tc.domain, customDomains) + if !tc.wantOK { + assert.Equal(t, customDomainNoMatch, match, "unrelated domain should not match any custom domain") + return } + assert.Equal(t, customDomainValidated, match, "validated custom domain should resolve a cluster") + assert.Equal(t, tc.wantVal, cluster) }) } } +// An unvalidated row must never yield a cluster: the account has not shown it +// controls the name, so no service may be bound to it. +func TestExtractClusterFromCustomDomains_UnvalidatedDomainRefused(t *testing.T) { + customDomains := []*domain.Domain{ + {Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io", Validated: false}, + } + + for _, serviceDomain := range []string{"example.com", "app.example.com"} { + t.Run(serviceDomain, func(t *testing.T) { + cluster, match := extractClusterFromCustomDomains(serviceDomain, customDomains) + assert.Equal(t, customDomainUnvalidated, match, "unvalidated row must be reported as such") + assert.Empty(t, cluster, "unvalidated row must not resolve a cluster") + }) + } +} + +// A more specific unvalidated row must not shadow a validated parent domain. +func TestExtractClusterFromCustomDomains_ValidatedParentWinsOverUnvalidatedChild(t *testing.T) { + customDomains := []*domain.Domain{ + {Domain: "example.com", TargetCluster: "cluster-generic", Validated: true}, + {Domain: "app.example.com", TargetCluster: "cluster-app", Validated: false}, + } + + cluster, match := extractClusterFromCustomDomains("app.example.com", customDomains) + assert.Equal(t, customDomainValidated, match) + assert.Equal(t, "cluster-generic", cluster, "validated parent domain should provide the cluster") +} + func TestExtractClusterFromCustomDomains_OverlappingDomains(t *testing.T) { customDomains := []*domain.Domain{ - {Domain: "example.com", TargetCluster: "cluster-generic"}, - {Domain: "app.example.com", TargetCluster: "cluster-app"}, + {Domain: "example.com", TargetCluster: "cluster-generic", Validated: true}, + {Domain: "app.example.com", TargetCluster: "cluster-app", Validated: true}, } tests := []struct { @@ -164,8 +194,8 @@ func TestExtractClusterFromCustomDomains_OverlappingDomains(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains) - assert.True(t, ok) + cluster, match := extractClusterFromCustomDomains(tc.domain, customDomains) + assert.Equal(t, customDomainValidated, match) assert.Equal(t, tc.wantVal, cluster) }) } diff --git a/management/internals/modules/reverseproxy/domain/manager/manager.go b/management/internals/modules/reverseproxy/domain/manager/manager.go index a9774d0e9..46e4ced83 100644 --- a/management/internals/modules/reverseproxy/domain/manager/manager.go +++ b/management/internals/modules/reverseproxy/domain/manager/manager.go @@ -26,6 +26,7 @@ type store interface { GetAgentNetworkSettings(ctx context.Context, lockStrength nbstore.LockingStrength, accountID string) (*agentnetworkTypes.Settings, error) GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) + GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error) ListFreeDomains(ctx context.Context, accountID string) ([]string, error) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) @@ -150,6 +151,10 @@ func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName return nil, fmt.Errorf("target cluster %s is not available", targetCluster) } + if err := m.checkDomainAvailable(ctx, domainName); err != nil { + return nil, err + } + // Attempt an initial validation against the specified cluster only var validated bool if m.validator.IsValid(ctx, domainName, []string{targetCluster}) { @@ -166,6 +171,23 @@ func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName return d, nil } +// checkDomainAvailable reports whether the domain is free to claim. The unique +// index on the column is the real guard; this turns the violation into a +// conflict the caller can act on instead of a database error, and says nothing +// about which account holds the domain. +func (m Manager) checkDomainAvailable(ctx context.Context, domainName string) error { + _, err := m.store.GetCustomDomainByName(ctx, domainName) + if err == nil { + return status.Errorf(status.AlreadyExists, "domain %s is already registered", domainName) + } + + if sErr, ok := status.FromError(err); ok && sErr.Type() == status.NotFound { + return nil + } + + return fmt.Errorf("look up domain: %w", err) +} + func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID string) error { ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete) if err != nil { @@ -203,7 +225,9 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID log.WithFields(log.Fields{ "accountID": accountID, "domainID": domainID, - }).WithError(err).Error("validate domain") + "userID": userID, + }).Error("validate domain: permission denied") + return } log.WithFields(log.Fields{ @@ -298,9 +322,12 @@ func (m Manager) DeriveClusterFromDomain(ctx context.Context, accountID, domain return "", fmt.Errorf("list custom domains: %w", err) } - targetCluster, valid := extractClusterFromCustomDomains(domain, customDomains) - if valid { + targetCluster, match := extractClusterFromCustomDomains(domain, customDomains) + switch match { + case customDomainValidated: return targetCluster, nil + case customDomainUnvalidated: + return "", status.Errorf(status.PreconditionFailed, "domain %s is not validated", domain) } return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain) @@ -363,19 +390,46 @@ func (m Manager) reservedGatewayAddress(ctx context.Context, accountID string) ( return settings.ProxyAddress, nil } -func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, bool) { +// customDomainMatch describes how a service domain relates to the account's +// custom domain rows. +type customDomainMatch int + +const ( + customDomainNoMatch customDomainMatch = iota + customDomainUnvalidated + customDomainValidated +) + +// extractClusterFromCustomDomains finds the longest custom domain covering the +// service domain and reports its target cluster. Only a validated row yields a +// cluster: until the CNAME check has passed the account has not shown it +// controls the name, so no traffic may be routed for it. +func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, customDomainMatch) { bestCluster := "" bestLen := -1 + matched := false for _, cd := range customDomains { if serviceDomain != cd.Domain && !strings.HasSuffix(serviceDomain, "."+cd.Domain) { continue } + matched = true + if !cd.Validated { + continue + } if l := len(cd.Domain); l > bestLen { bestLen = l bestCluster = cd.TargetCluster } } - return bestCluster, bestLen >= 0 + + switch { + case bestLen >= 0: + return bestCluster, customDomainValidated + case matched: + return "", customDomainUnvalidated + default: + return "", customDomainNoMatch + } } // ExtractClusterFromFreeDomain extracts the cluster address from a free domain. diff --git a/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go b/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go new file mode 100644 index 000000000..8a0b56171 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go @@ -0,0 +1,326 @@ +package manager + +import ( + "context" + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/metric/noop" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/mock_server" + "github.com/netbirdio/netbird/management/server/permissions" + nbstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + testCluster = "eu.proxy.test" + accountA = "account-a" + accountAUser = "account-a-admin" + accountB = "account-b" + accountBUser = "account-b-admin" + accountAMember = "account-a-member" +) + +// stubResolver answers CNAME lookups from a table the test controls, so a +// domain can point at the cluster or nowhere without touching a real resolver. +type stubResolver struct { + mu sync.Mutex + cnames map[string]string +} + +func (r *stubResolver) LookupCNAME(_ context.Context, host string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + cname, ok := r.cnames[host] + if !ok { + return "", fmt.Errorf("lookup %s: no such host", host) + } + return cname + ".", nil +} + +func (r *stubResolver) set(host, cname string) { + r.mu.Lock() + defer r.mu.Unlock() + r.cnames[host] = cname +} + +type domainTestEnv struct { + manager Manager + store nbstore.Store + resolver *stubResolver +} + +// setupDomainTest builds the domain manager on a real SQLite store with two +// accounts and one active public proxy cluster. +func setupDomainTest(t *testing.T) *domainTestEnv { + t.Helper() + + ctx := context.Background() + testStore, cleanup, err := nbstore.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + for accountID, userID := range map[string]string{accountA: accountAUser, accountB: accountBUser} { + users := map[string]*types.User{ + userID: { + Id: userID, + AccountID: accountID, + Role: types.UserRoleAdmin, + }, + } + if accountID == accountA { + // A real member of the account whose role denies Services:Create, so + // permission denial is exercised as ok=false rather than as a lookup + // error for a user who is not in the account at all. + users[accountAMember] = &types.User{ + Id: accountAMember, + AccountID: accountID, + Role: types.UserRoleUser, + } + } + + require.NoError(t, testStore.SaveAccount(ctx, &types.Account{ + Id: accountID, + CreatedBy: userID, + Settings: &types.Settings{}, + Users: users, + })) + } + + proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter("")) + require.NoError(t, err) + + _, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", testCluster, "127.0.0.1", nil, nil) + require.NoError(t, err) + + resolver := &stubResolver{cnames: make(map[string]string)} + + mgr := Manager{ + store: testStore, + proxyManager: proxyMgr, + validator: domain.Validator{Resolver: resolver}, + permissionsManager: permissions.NewManager(testStore), + accountManager: &mock_server.MockAccountManager{ + StoreEventFunc: func(context.Context, string, string, string, activity.ActivityDescriber, map[string]any) {}, + }, + } + + return &domainTestEnv{manager: mgr, store: testStore, resolver: resolver} +} + +// storedDomain reads a domain row back through the store so assertions are made +// on what was persisted rather than on the value the manager returned. +func storedDomain(t *testing.T, s nbstore.Store, accountID, domainName string) *domain.Domain { + t.Helper() + + domains, err := s.ListCustomDomains(context.Background(), accountID) + require.NoError(t, err) + for _, d := range domains { + if d.Domain == domainName { + return d + } + } + return nil +} + +// A domain whose CNAME check fails is stored unvalidated and must not resolve a +// cluster, which is what service creation gates on. +func TestCreateDomain_FailedLookupIsNotServable(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + + created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "apps.example.com", testCluster) + require.NoError(t, err) + assert.False(t, created.Validated, "a domain whose CNAME lookup fails must not be created validated") + + stored := storedDomain(t, env.store, accountA, "apps.example.com") + require.NotNil(t, stored, "domain row should exist") + assert.False(t, stored.Validated, "persisted row must be unvalidated") + + cluster, err := env.manager.DeriveClusterFromDomain(ctx, accountA, "apps.example.com") + require.Error(t, err, "an unvalidated domain must not resolve a cluster") + assert.Empty(t, cluster) + assert.Contains(t, err.Error(), "not validated", "error should tell the caller what to fix") + + sErr, ok := status.FromError(err) + require.True(t, ok, "error should be a typed status error") + assert.Equal(t, status.PreconditionFailed, sErr.Type()) + + _, err = env.manager.DeriveClusterFromDomain(ctx, accountA, "sub.apps.example.com") + assert.Error(t, err, "subdomains of an unvalidated custom domain are not servable either") +} + +// A second account claiming a registered domain gets a clean conflict, not a +// database error surfaced as a 500. +func TestCreateDomain_DuplicateIsAConflict(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + + _, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "shared.example.com", testCluster) + require.NoError(t, err) + + _, err = env.manager.CreateDomain(ctx, accountB, accountBUser, "shared.example.com", testCluster) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok, "conflict must be a typed status error, not a raw database error") + assert.Equal(t, status.AlreadyExists, sErr.Type(), "conflict should map to 409, not 500") + assert.NotContains(t, sErr.Message, accountA, "the response must not reveal the holding account") + + assert.Nil(t, storedDomain(t, env.store, accountB, "shared.example.com"), "no row should be written on conflict") +} + +// The same account re-adding one of its own domains is a conflict too. +func TestCreateDomain_SameAccountDuplicateIsAConflict(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + + _, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "dup.example.com", testCluster) + require.NoError(t, err) + + _, err = env.manager.CreateDomain(ctx, accountA, accountAUser, "dup.example.com", testCluster) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.AlreadyExists, sErr.Type()) +} + +// The negative control: a validated domain still derives its cluster, for the +// bare name and for subdomains, exactly as before. +func TestCreateDomain_ValidatedDomainDerivesCluster(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + env.resolver.set("validation.valid.example.com", testCluster) + + created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "valid.example.com", testCluster) + require.NoError(t, err) + require.True(t, created.Validated, "a matching CNAME should validate on create") + + cluster, err := env.manager.DeriveClusterFromDomain(ctx, accountA, "valid.example.com") + require.NoError(t, err) + assert.Equal(t, testCluster, cluster) + + cluster, err = env.manager.DeriveClusterFromDomain(ctx, accountA, "app.valid.example.com") + require.NoError(t, err) + assert.Equal(t, testCluster, cluster, "subdomains of a validated custom domain resolve too") +} + +// Validating a domain flips the gate: the same lookup that failed before now +// resolves a cluster. +func TestValidateDomain_UnlocksClusterDerivation(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + + created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "later.example.com", testCluster) + require.NoError(t, err) + require.False(t, created.Validated) + + _, err = env.manager.DeriveClusterFromDomain(ctx, accountA, "later.example.com") + require.Error(t, err) + + env.resolver.set("validation.later.example.com", testCluster) + env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID) + + require.True(t, storedDomain(t, env.store, accountA, "later.example.com").Validated) + + cluster, err := env.manager.DeriveClusterFromDomain(ctx, accountA, "later.example.com") + require.NoError(t, err) + assert.Equal(t, testCluster, cluster) +} + +// Free cluster domains are unaffected by the custom domain gate. +func TestDeriveClusterFromDomain_FreeDomainUnaffected(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + + cluster, err := env.manager.DeriveClusterFromDomain(ctx, accountA, "myapp.abc123."+testCluster) + require.NoError(t, err) + assert.Equal(t, testCluster, cluster) +} + +// The manager pre-check exists to turn a conflict into a 409, but the unique +// index on the column is what actually guarantees the domain is claimed once. +// +// Two requests can clear the pre-check concurrently and race to the insert. +// Inserting twice through the store reaches the same code path the loser of +// that race takes, without the nondeterminism of driving it from goroutines, +// and the loser must still see a conflict rather than an internal error. +func TestStore_DuplicateDomainRejectedByIndexAsConflict(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + + _, err := env.store.CreateCustomDomain(ctx, accountA, "indexed.example.com", testCluster, false) + require.NoError(t, err) + + _, err = env.store.CreateCustomDomain(ctx, accountB, "indexed.example.com", testCluster, false) + require.Error(t, err, "the unique index must reject the same domain in a second account") + + sErr, ok := status.FromError(err) + require.True(t, ok, "the losing insert must return a typed status error") + assert.Equal(t, status.AlreadyExists, sErr.Type(), "a lost race is a 409, not a 500") +} + +// Validation is what decides whether a domain routes traffic, so a caller +// without permission to it must not be able to flip the flag. The check logged +// the denial and then carried on, which was inert while nothing read Validated +// and is not once cluster derivation gates on it. +func TestValidateDomain_PermissionDeniedDoesNotValidate(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + + created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "guarded.example.com", testCluster) + require.NoError(t, err) + require.False(t, created.Validated) + + // The CNAME is in place, so the only thing standing between this caller and + // a validated domain is the permission check. + env.resolver.set("validation.guarded.example.com", testCluster) + + env.manager.ValidateDomain(ctx, accountA, accountAMember, created.ID) + + stored := storedDomain(t, env.store, accountA, "guarded.example.com") + require.NotNil(t, stored) + assert.False(t, stored.Validated, "a caller without permission must not validate the domain") + + _, err = env.manager.DeriveClusterFromDomain(ctx, accountA, "guarded.example.com") + assert.Error(t, err, "the domain must still be unservable") +} + +// Validation runs asynchronously, so it can finish after the domain was +// deleted and then write a stale row back. gorm's Save falls back to an insert +// when an update affects no rows, which would resurrect the domain as +// validated; UpdateCustomDomain avoids that by selecting explicit columns. +// This pins that behaviour, since dropping the Select would reintroduce it. +func TestUpdateCustomDomain_DoesNotResurrectDeletedDomain(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + + created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "racy.example.com", testCluster) + require.NoError(t, err) + + stale := storedDomain(t, env.store, accountA, "racy.example.com") + require.NotNil(t, stale) + + require.NoError(t, env.manager.DeleteDomain(ctx, accountA, accountAUser, created.ID)) + require.Nil(t, storedDomain(t, env.store, accountA, "racy.example.com"), "the domain should be gone") + + // What an in-flight validation would write once its CNAME check succeeded. + // The write has to succeed for the assertion below to mean anything: a + // rejected write would leave the domain absent for the wrong reason. + stale.Validated = true + _, err = env.store.UpdateCustomDomain(ctx, accountA, stale) + require.NoError(t, err, "the update itself must succeed, so absence is not just a failed write") + + assert.Nil(t, storedDomain(t, env.store, accountA, "racy.example.com"), + "a late validation write must not recreate a deleted domain") +} diff --git a/management/internals/modules/reverseproxy/domain/manager/manager_test.go b/management/internals/modules/reverseproxy/domain/manager/manager_test.go index 12281b447..519f5efeb 100644 --- a/management/internals/modules/reverseproxy/domain/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/domain/manager/manager_test.go @@ -184,6 +184,10 @@ func (s *stubStore) GetCustomDomain(context.Context, string, string) (*domain.Do panic("not used in allow-list tests") } +func (s *stubStore) GetCustomDomainByName(context.Context, string) (*domain.Domain, error) { + panic("not used in allow-list tests") +} + func (s *stubStore) ListFreeDomains(context.Context, string) ([]string, error) { panic("not used in allow-list tests") } diff --git a/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go b/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go new file mode 100644 index 000000000..ccb955cd8 --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go @@ -0,0 +1,127 @@ +package manager + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/metric/noop" + + domainmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/mock_server" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" +) + +const validationTestCluster = "eu.proxy.test" + +// withRealDomainManager swaps the stub cluster deriver for the real domain +// manager backed by the same store, so service creation is gated by the actual +// domain rows rather than by a test double that always agrees. +func withRealDomainManager(t *testing.T, mgr *Manager, testStore store.Store) { + t.Helper() + + ctx := context.Background() + proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter("")) + require.NoError(t, err) + + _, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", validationTestCluster, "127.0.0.1", nil, nil) + require.NoError(t, err) + + accountMgr := &mock_server.MockAccountManager{ + StoreEventFunc: func(context.Context, string, string, string, activity.ActivityDescriber, map[string]any) {}, + } + mgr.clusterDeriver = domainmanager.NewManager(testStore, proxyMgr, permissions.NewManager(testStore), accountMgr) +} + +func newTestService(domain string) *rpservice.Service { + return &rpservice.Service{ + Name: "test-service", + Domain: domain, + Enabled: true, + Mode: rpservice.ModeHTTP, + Targets: []*rpservice.Target{{ + Host: "10.0.0.1", + Port: 8080, + Protocol: "http", + TargetId: testPeerID, + TargetType: "peer", + Enabled: true, + }}, + } +} + +// A service must not bind to a domain the account has not validated, and +// nothing may be persisted for the attempt. +func TestCreateService_RefusesUnvalidatedDomain(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupIntegrationTest(t) + withRealDomainManager(t, mgr, testStore) + + _, err := testStore.CreateCustomDomain(ctx, testAccountID, "unproven.example.com", validationTestCluster, false) + require.NoError(t, err) + + _, err = mgr.CreateService(ctx, testAccountID, testUserID, newTestService("unproven.example.com")) + require.Error(t, err, "an unvalidated domain must not bind a service") + assert.Contains(t, err.Error(), "not validated", "the API error should name the actual problem") + + sErr, ok := status.FromError(err) + require.True(t, ok, "error should be a typed status error") + assert.Equal(t, status.PreconditionFailed, sErr.Type()) + + services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + assert.Empty(t, services, "no service row should be written for a refused domain") +} + +// The negative control: a validated domain still binds a service and derives +// its cluster exactly as before. +func TestCreateService_ValidatedDomainBindsService(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupIntegrationTest(t) + withRealDomainManager(t, mgr, testStore) + + _, err := testStore.CreateCustomDomain(ctx, testAccountID, "proven.example.com", validationTestCluster, true) + require.NoError(t, err) + + created, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.proven.example.com")) + require.NoError(t, err) + assert.Equal(t, validationTestCluster, created.ProxyCluster, "service should bind to the domain's target cluster") + + services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1, "the service should be persisted") + assert.Equal(t, "app.proven.example.com", services[0].Domain) +} + +// An update must not be a way around the creation gate: moving a live service +// onto an unvalidated domain has to fail rather than silently keep the old +// cluster and start serving the new hostname. +func TestUpdateService_RefusesMoveToUnvalidatedDomain(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupIntegrationTest(t) + withRealDomainManager(t, mgr, testStore) + + _, err := testStore.CreateCustomDomain(ctx, testAccountID, "proven.example.com", validationTestCluster, true) + require.NoError(t, err) + _, err = testStore.CreateCustomDomain(ctx, testAccountID, "unproven.example.com", validationTestCluster, false) + require.NoError(t, err) + + created, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.proven.example.com")) + require.NoError(t, err) + + moved := *created + moved.Domain = "app.unproven.example.com" + _, err = mgr.UpdateService(ctx, testAccountID, testUserID, &moved) + require.Error(t, err, "moving to an unvalidated domain must fail") + assert.Contains(t, err.Error(), "not validated") + + stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, created.ID) + require.NoError(t, err) + assert.Equal(t, "app.proven.example.com", stored.Domain, "the service must keep its original domain") +} diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go index 365fbab40..9c7f95eb4 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager.go +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -606,16 +606,19 @@ func (m *Manager) resolveEffectiveCluster(ctx context.Context, accountID string, return existing.ProxyCluster, nil } - if m.clusterDeriver != nil { - derived, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, svc.Domain) - if err != nil { - log.WithError(err).Warnf("could not derive cluster from domain %s", svc.Domain) - } else { - return derived, nil - } + if m.clusterDeriver == nil { + return existing.ProxyCluster, nil } - return existing.ProxyCluster, nil + // Falling back to the old cluster here would let an update move a service + // onto a domain the account has not validated, bypassing the check that + // creation makes. + derived, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, svc.Domain) + if err != nil { + return "", status.Errorf(status.PreconditionFailed, "could not derive cluster from domain %s: %v", svc.Domain, err) + } + + return derived, nil } func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.Store, accountID string, service *service.Service, updateInfo *serviceUpdateInfo, customPorts *bool, effectiveCluster string) error { diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 6337ebf1a..ef353ea83 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -5686,6 +5686,23 @@ func (s *SqlStore) ListCustomDomains(ctx context.Context, accountID string) ([]* return domains, nil } +// GetCustomDomainByName returns the custom domain row holding the given name, +// regardless of which account owns it. +func (s *SqlStore) GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error) { + customDomain := &domain.Domain{} + result := s.db.Take(customDomain, "domain = ?", domainName) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "custom domain %s not found", domainName) + } + + log.WithContext(ctx).Errorf("failed to get custom domain by name from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get custom domain from store") + } + + return customDomain, nil +} + func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) { newDomain := &domain.Domain{ ID: xid.New().String(), // Generate our own ID because gorm doesn't always configure the database to handle this for us. @@ -5697,6 +5714,18 @@ func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, dom } result := s.db.Create(newDomain) if result.Error != nil { + // The unique index is the last guard when two requests clear the + // manager's availability check at the same time. The one that loses the + // insert is a conflict, not an internal failure. + var count int64 + if err := s.db.Model(&domain.Domain{}).Where("domain = ?", domainName).Count(&count).Error; err == nil && count > 0 { + // The insert error is logged even on this path: the name being taken + // is what the caller has to act on, but if the insert also failed for + // an unrelated reason the operator still needs to see it. + log.WithContext(ctx).Warnf("create reverse proxy custom domain %s rejected, name already registered: %v", domainName, result.Error) + return nil, status.Errorf(status.AlreadyExists, "domain %s is already registered", domainName) + } + log.WithContext(ctx).Errorf("failed to create reverse proxy custom domain to store: %v", result.Error) return nil, status.Errorf(status.Internal, "failed to create reverse proxy custom domain to store") } diff --git a/management/server/store/store.go b/management/server/store/store.go index 7daeb28a9..da2b3c6e0 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -302,6 +302,7 @@ type Store interface { GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) ListFreeDomains(ctx context.Context, accountID string) ([]string, error) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) + GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 70acb9f58..9bf49f076 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -1941,6 +1941,21 @@ func (mr *MockStoreMockRecorder) GetCustomDomain(ctx, accountID, domainID any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCustomDomain", reflect.TypeOf((*MockStore)(nil).GetCustomDomain), ctx, accountID, domainID) } +// GetCustomDomainByName mocks base method. +func (m *MockStore) GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCustomDomainByName", ctx, domainName) + ret0, _ := ret[0].(*domain.Domain) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCustomDomainByName indicates an expected call of GetCustomDomainByName. +func (mr *MockStoreMockRecorder) GetCustomDomainByName(ctx, domainName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCustomDomainByName", reflect.TypeOf((*MockStore)(nil).GetCustomDomainByName), ctx, domainName) +} + // GetCustomDomainsCounts mocks base method. func (m *MockStore) GetCustomDomainsCounts(ctx context.Context) (int64, int64, error) { m.ctrl.T.Helper() From 15a684248c7e33556fe6535662ec8ded719b7db9 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Thu, 10 Sep 2026 12:02:19 +0200 Subject: [PATCH 003/209] [client] Support arbitrary UIDs in rootless image (#7440) * [client] Support arbitrary UIDs in rootless image * [client] Keep rootless executables root-owned * [client] Harden arbitrary UID image validation * [client] Preserve executable access in rootless image Keep the binary and entrypoint executable when deployments override the runtime group. Retain root ownership so non-root users cannot modify either file. * [client] Verify rootless state reuse with a stable UID Persisted profiles remain scoped to the creating UID. Verify same-UID container recreation without broadening application permissions, and document the Kubernetes volume permission behavior observed on OpenShift. Remove unused synthetic-user home metadata. * [client] Separate image changes from invoking user fix Keep this PR limited to resolving unmapped non-root invoking users. Move container permissions and their smoke test to a dependent image branch so they can be reviewed separately. * [client] Restore invoking process user test Retain coverage for successful current-user lookup without sudo. Numeric-identity fallback tests do not cover this existing behavior. --- .../internal/profilemanager/invoking_user.go | 35 ++++++++-- .../profilemanager/invoking_user_test.go | 70 ++++++++++++++++++- 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/client/internal/profilemanager/invoking_user.go b/client/internal/profilemanager/invoking_user.go index c86a6ce43..7ba612ffb 100644 --- a/client/internal/profilemanager/invoking_user.go +++ b/client/internal/profilemanager/invoking_user.go @@ -6,6 +6,7 @@ import ( "os/user" "path/filepath" "runtime" + "strconv" log "github.com/sirupsen/logrus" ) @@ -13,17 +14,21 @@ import ( const envSudoUser = "SUDO_USER" var ( - geteuid = os.Geteuid - lookupUser = user.Lookup + currentUser = user.Current + getegid = os.Getegid + geteuid = os.Geteuid + lookupUser = user.Lookup ) // InvokingUser returns the user a CLI invocation acts for. Under sudo that is // the user who ran sudo, not root: privileged flags force commands through // sudo, and resolving profiles as root would silently switch the daemon to -// root's (default) profile instead of the invoking user's. Privilege decisions -// are not made here — those stay on the kernel credentials of the daemon -// connection, which SUDO_USER (a plain environment variable) can never -// influence; a forged value only selects a profile root could select anyway. +// root's (default) profile instead of the invoking user's. An unmapped positive +// process UID uses its numeric kernel identity; root, sudo lookup failures, and +// unavailable platform identities still fail closed. Privilege decisions stay +// on the kernel credentials of the daemon connection, which SUDO_USER (a plain +// environment variable) can never influence; a forged value only selects a +// profile root could select anyway. func InvokingUser() (*user.User, error) { if u, ok := sudoInvokingUser(); ok { return u, nil @@ -35,7 +40,23 @@ func InvokingUser() (*user.User, error) { if sudoActive() { return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser)) } - return user.Current() + u, err := currentUser() + if err == nil { + return u, nil + } + + uid := geteuid() + if uid <= 0 { + return nil, err + } + + log.Debugf("current user lookup for UID %d: %v; using numeric UID", uid, err) + uidString := strconv.Itoa(uid) + return &user.User{ + Username: uidString, + Uid: uidString, + Gid: strconv.Itoa(getegid()), + }, nil } // IsPlainRoot reports that the process runs as root with no usable sudo diff --git a/client/internal/profilemanager/invoking_user_test.go b/client/internal/profilemanager/invoking_user_test.go index 54c8ad8fd..159d2616b 100644 --- a/client/internal/profilemanager/invoking_user_test.go +++ b/client/internal/profilemanager/invoking_user_test.go @@ -2,6 +2,7 @@ package profilemanager import ( "errors" + "fmt" "io/fs" "os" "os/user" @@ -21,7 +22,51 @@ func TestInvokingUserFallsBackToProcessUser(t *testing.T) { current, err := user.Current() require.NoError(t, err) - assert.Equal(t, current.Username, got.Username) + assert.Equal(t, current.Username, got.Username, "invoking user should match the process user without sudo") +} + +func TestInvokingUserFailsClosedWithoutPositiveUID(t *testing.T) { + for _, uid := range []int{0, -1} { + t.Run(fmt.Sprintf("UID%d", uid), func(t *testing.T) { + t.Setenv(envSudoUser, "") + lookupErr := errors.New("current user unavailable") + fakeUnmappedUser(t, uid, 0, lookupErr) + + got, err := InvokingUser() + require.ErrorIs(t, err, lookupErr) + assert.Nil(t, got, "root or unavailable UID must not become a synthetic identity") + }) + } +} + +func TestProfileFilePathUsesNumericIdentityForUnmappedNonRoot(t *testing.T) { + t.Setenv(envSudoUser, "") + fakeUnmappedUser(t, 1001230000, 0, errors.New("user: unknown userid 1001230000")) + + profilesRoot := t.TempDir() + origDir := DefaultConfigPathDir + origOverride := ConfigDirOverride + DefaultConfigPathDir = profilesRoot + ConfigDirOverride = "" + t.Cleanup(func() { + DefaultConfigPathDir = origDir + ConfigDirOverride = origOverride + }) + + profileID := ID("0123456789abcdef0123456789abcdef") + got, err := (&Profile{ID: profileID}).FilePath() + require.NoError(t, err) + assert.Equal(t, + filepath.Join(profilesRoot, "1001230000", profileID.String()+".json"), + got, + "profile path should use the numeric UID namespace", + ) + + entries, err := os.ReadDir(profilesRoot) + require.NoError(t, err) + require.Len(t, entries, 1, "only the numeric UID directory should be created") + assert.Equal(t, "1001230000", entries[0].Name(), "profile namespace should be numeric") + assert.True(t, entries[0].IsDir(), "profile namespace should be a directory") } func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) { @@ -60,6 +105,13 @@ func TestInvokingUserFailsClosedWhenSudoLookupFails(t *testing.T) { fakeSudo(t, filepath.Join("/home", "misha")) lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") } + origCurrentUser := currentUser + currentUser = func() (*user.User, error) { + t.Fatal("currentUser must not be called after a sudo lookup failure") + return nil, errors.New("currentUser called unexpectedly") + } + t.Cleanup(func() { currentUser = origCurrentUser }) + got, err := InvokingUser() require.Error(t, err) assert.Nil(t, got, "must not resolve to the root process user") @@ -215,6 +267,22 @@ func fakeSudo(t *testing.T, home string) { }) } +func fakeUnmappedUser(t *testing.T, uid, gid int, lookupErr error) { + t.Helper() + + origCurrentUser := currentUser + origEuid := geteuid + origEgid := getegid + currentUser = func() (*user.User, error) { return nil, lookupErr } + geteuid = func() int { return uid } + getegid = func() int { return gid } + t.Cleanup(func() { + currentUser = origCurrentUser + geteuid = origEuid + getegid = origEgid + }) +} + func assertNoEntries(t *testing.T, root string) { t.Helper() err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error { From 9615d2ab162e7badee5c8b4e84048ce49e1db6e7 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 10 Sep 2026 12:06:30 +0200 Subject: [PATCH 004/209] [client] Report the remote jobs key in the MDM UI snapshot (#7485) * [client] Report the remote jobs key in the MDM UI snapshot Co-Authored-By: Claude Opus 5 (1M context) * [client] Align the remote jobs snapshot key with the policy key The snapshot field carried the JSON tag remoteJobsAllowed while the policy key is allowRemoteJobs. GetConfigResponse.mDMManagedFields reports the raw policy keys, and applyMDMRestrictions matches them against the struct's JSON tags, so the field never turned true for a policy that set the key. Every other field in Fields already uses its policy key as the JSON tag; this was the only divergence. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- client/mdm/restrictions.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/mdm/restrictions.go b/client/mdm/restrictions.go index c8e443395..200756b78 100644 --- a/client/mdm/restrictions.go +++ b/client/mdm/restrictions.go @@ -20,6 +20,7 @@ type Fields struct { DisableMetricsCollection bool `json:"disableMetricsCollection"` SplitTunnelMode bool `json:"splitTunnelMode"` SplitTunnelApps bool `json:"splitTunnelApps"` + RemoteJobsAllowed bool `json:"allowRemoteJobs"` DisableAdvancedView *bool `json:"disableAdvancedView"` } @@ -60,6 +61,7 @@ func BuildRestrictions(policy *Policy) Restrictions { r.MDM.DisableMetricsCollection = policy.HasKey(KeyDisableMetricsCollection) r.MDM.SplitTunnelMode = policy.HasKey(KeySplitTunnelMode) r.MDM.SplitTunnelApps = policy.HasKey(KeySplitTunnelApps) + r.MDM.RemoteJobsAllowed = policy.HasKey(KeyRemoteJobsAllowed) if v, ok := policy.GetBool(KeyAllowServerSSH); ok { r.MDM.AllowServerSSH = &v } From 0fac1ee638d89a5b2a5cfef38b9c638d57bfad24 Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Thu, 10 Sep 2026 16:37:43 +0200 Subject: [PATCH 005/209] [management] cleanup resources when ws-grpc proxy connection goes away (#7484) * ws to grpc connection adapter Signed-off-by: Dmitri Dolguikh * support for timeouts on reading h2 stream headers Signed-off-by: Dmitri Dolguikh * cleanups Signed-off-by: Dmitri Dolguikh * we can't always expect a DATA frame, as not all http methods send it Signed-off-by: Dmitri Dolguikh * set default headers read timeout to 10s Signed-off-by: Dmitri Dolguikh * fix a race in tests Signed-off-by: Dmitri Dolguikh * remove frame interceptor Signed-off-by: Dmitri Dolguikh * cleanup test cleanup Signed-off-by: Dmitri Dolguikh * make linter happy Signed-off-by: Dmitri Dolguikh * removed unused consts Signed-off-by: Dmitri Dolguikh * set 5s ReadTimeout Signed-off-by: Dmitri Dolguikh * making linter happy Signed-off-by: Dmitri Dolguikh * making linter happy Signed-off-by: Dmitri Dolguikh * updated comments Signed-off-by: Dmitri Dolguikh * fix spelling Signed-off-by: Dmitri Dolguikh * disabled all http server read timeouts Signed-off-by: Dmitri Dolguikh * Revert "disabled all http server read timeouts" This reverts commit adf5005ba44d42f620d3a0a0851df9854cfd180d. Signed-off-by: Dmitri Dolguikh * clarify comment re: ReadTimeout/WriteTimeout issues Signed-off-by: Dmitri Dolguikh --------- Signed-off-by: Dmitri Dolguikh --- util/wsproxy/server/proxy.go | 163 +++++----------- util/wsproxy/server/ws_conn_adapter.go | 126 ++++++++++++ util/wsproxy/server/ws_conn_adapter_test.go | 204 ++++++++++++++++++++ 3 files changed, 373 insertions(+), 120 deletions(-) create mode 100644 util/wsproxy/server/ws_conn_adapter.go create mode 100644 util/wsproxy/server/ws_conn_adapter_test.go diff --git a/util/wsproxy/server/proxy.go b/util/wsproxy/server/proxy.go index ffb622200..0618beb91 100644 --- a/util/wsproxy/server/proxy.go +++ b/util/wsproxy/server/proxy.go @@ -1,12 +1,8 @@ package server import ( - "context" - "io" - "net" "net/http" - "sync" - "time" + "sync/atomic" "github.com/coder/websocket" log "github.com/sirupsen/logrus" @@ -15,11 +11,6 @@ import ( "github.com/netbirdio/netbird/util/wsproxy" ) -const ( - bufferSize = 32 * 1024 - ioTimeout = 5 * time.Second -) - // Config contains the configuration for the WebSocket proxy. type Config struct { Handler http.Handler @@ -53,14 +44,23 @@ func New(handler http.Handler, opts ...Option) *Proxy { // Handler returns an http.Handler that proxies WebSocket connections to the local gRPC server. func (p *Proxy) Handler() http.Handler { - return http.HandlerFunc(p.handleWebSocket) + return &proxyHandler{ + metrics: p.config.MetricsRecorder, + handler: p.config.Handler, + } } -func (p *Proxy) handleWebSocket(w http.ResponseWriter, r *http.Request) { +type proxyHandler struct { + metrics MetricsRecorder + handler http.Handler + conn atomic.Pointer[wsConnAdapter] +} + +func (ph *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - p.metrics.RecordConnection(ctx) - defer p.metrics.RecordDisconnection(ctx) + ph.metrics.RecordConnection(ctx) + defer ph.metrics.RecordDisconnection(ctx) log.Debugf("WebSocket proxy handling connection from %s, forwarding to internal gRPC handler", r.RemoteAddr) acceptOptions := &websocket.AcceptOptions{ @@ -69,121 +69,44 @@ func (p *Proxy) handleWebSocket(w http.ResponseWriter, r *http.Request) { wsConn, err := websocket.Accept(w, r, acceptOptions) if err != nil { - p.metrics.RecordError(ctx, "websocket_accept_failed") + ph.metrics.RecordError(ctx, "websocket_accept_failed") log.Errorf("WebSocket upgrade failed from %s: %v", r.RemoteAddr, err) return } - defer func() { - _ = wsConn.Close(websocket.StatusNormalClosure, "") - }() + serverConn := (&wsConnAdapter{ + ctx: ctx, + conn: wsConn, + metrics: ph.metrics, + clientAddr: r.RemoteAddr, + }) - clientConn, serverConn := net.Pipe() defer func() { - _ = clientConn.Close() _ = serverConn.Close() }() + ph.conn.Store(serverConn) // used in tests only + log.Debugf("WebSocket proxy established: %s -> gRPC handler", r.RemoteAddr) - go func() { - (&http2.Server{}).ServeConn(serverConn, &http2.ServeConnOpts{ - Context: ctx, - Handler: p.config.Handler, - }) - }() + (&http2.Server{ + // TODO (dmitri) we should limit the number of concurrent streams per connection (peer) + // and idle timeouts + // MaxConcurrentStreams: 20, + // IdleTimeout: 10 * time.Second, + }).ServeConn(serverConn, &http2.ServeConnOpts{ + Context: ctx, + Handler: ph.handler, + BaseConfig: &http.Server{ + // b/c we are wrapping a ws connection, read and write connection deadlines normally set + // via ReadTimeout and WriteTimeout http.Server fields aren't available to us. The ws + // library doesn't expose connection deadline timer config, and we ignore these calls in "wsConnAdapter". + // + // Another issue is that Server.ServeConn() call bypasses setting of connection deadlines altogether, + // ReadTimeout and Writetimeout set here would only apply to h2 streams, i.e. after a HEADERS frame + // arrival and processing, turning ReadTimeout into a request body read deadline, and WriteTimeout into + // a response deadline (the latter not useful for streaming requests). + }, + }) - p.proxyData(ctx, wsConn, clientConn, r.RemoteAddr) -} - -func (p *Proxy) proxyData(ctx context.Context, wsConn *websocket.Conn, pipeConn net.Conn, clientAddr string) { - proxyCtx, cancel := context.WithCancel(ctx) - defer cancel() - - var wg sync.WaitGroup - wg.Add(2) - - go p.wsToPipe(proxyCtx, cancel, &wg, wsConn, pipeConn, clientAddr) - go p.pipeToWS(proxyCtx, cancel, &wg, wsConn, pipeConn, clientAddr) - - wg.Wait() -} - -func (p *Proxy) wsToPipe(ctx context.Context, cancel context.CancelFunc, wg *sync.WaitGroup, wsConn *websocket.Conn, pipeConn net.Conn, clientAddr string) { - defer wg.Done() - defer cancel() - - for { - msgType, data, err := wsConn.Read(ctx) - if err != nil { - switch { - case ctx.Err() != nil: - log.Debugf("WebSocket from %s terminating due to context cancellation", clientAddr) - case websocket.CloseStatus(err) != -1: - log.Debugf("WebSocket from %s disconnected", clientAddr) - default: - p.metrics.RecordError(ctx, "websocket_read_error") - log.Debugf("WebSocket read error from %s: %v", clientAddr, err) - } - return - } - - if msgType != websocket.MessageBinary { - log.Warnf("Unexpected WebSocket message type from %s: %v", clientAddr, msgType) - continue - } - - if ctx.Err() != nil { - log.Tracef("wsToPipe goroutine terminating due to context cancellation before pipe write") - return - } - - if err := pipeConn.SetWriteDeadline(time.Now().Add(ioTimeout)); err != nil { - log.Debugf("Failed to set pipe write deadline: %v", err) - } - - n, err := pipeConn.Write(data) - if err != nil { - p.metrics.RecordError(ctx, "pipe_write_error") - log.Warnf("Pipe write error for %s: %v", clientAddr, err) - return - } - - p.metrics.RecordBytesTransferred(ctx, "ws_to_grpc", int64(n)) - } -} - -func (p *Proxy) pipeToWS(ctx context.Context, cancel context.CancelFunc, wg *sync.WaitGroup, wsConn *websocket.Conn, pipeConn net.Conn, clientAddr string) { - defer wg.Done() - defer cancel() - - buf := make([]byte, bufferSize) - for { - n, err := pipeConn.Read(buf) - if err != nil { - if ctx.Err() != nil { - log.Tracef("pipeToWS goroutine terminating due to context cancellation") - return - } - - if err != io.EOF { - log.Debugf("Pipe read error for %s: %v", clientAddr, err) - } - return - } - - if ctx.Err() != nil { - log.Tracef("pipeToWS goroutine terminating due to context cancellation before WebSocket write") - return - } - - if n > 0 { - if err := wsConn.Write(ctx, websocket.MessageBinary, buf[:n]); err != nil { - p.metrics.RecordError(ctx, "websocket_write_error") - log.Warnf("WebSocket write error for %s: %v", clientAddr, err) - return - } - - p.metrics.RecordBytesTransferred(ctx, "grpc_to_ws", int64(n)) - } - } + log.Debugf("WebSocket proxy closing: %s -> gRPC handler", r.RemoteAddr) } diff --git a/util/wsproxy/server/ws_conn_adapter.go b/util/wsproxy/server/ws_conn_adapter.go new file mode 100644 index 000000000..eb29ab0cb --- /dev/null +++ b/util/wsproxy/server/ws_conn_adapter.go @@ -0,0 +1,126 @@ +package server + +import ( + "context" + "net" + "sync/atomic" + "time" + + "github.com/coder/websocket" + log "github.com/sirupsen/logrus" +) + +type wsConnAdapter struct { + prefix string + ctx context.Context + conn *websocket.Conn + metrics MetricsRecorder + clientAddr string + closed atomic.Bool + bufferedRead []byte +} + +var _ net.Conn = &wsConnAdapter{} + +type wsAddr struct{ prefix string } + +func (wa wsAddr) Network() string { return wa.prefix + "ws-proxy" } +func (wa wsAddr) String() string { return wa.prefix + "ws-proxy" } + +func (ws *wsConnAdapter) Read(b []byte) (int, error) { + if len(ws.bufferedRead) > 0 { + return ws.readFromBuffer(b) + } + + msgType, data, err := ws.conn.Read(ws.ctx) + if err != nil { + switch { + case ws.ctx.Err() != nil: + log.Debugf("WebSocket from %s terminating due to context cancellation", ws.clientAddr) + case websocket.CloseStatus(err) != -1: + log.Debugf("WebSocket from %s disconnected", ws.clientAddr) + default: + ws.recordError(ws.ctx, "websocket_read_error") + log.Debugf("WebSocket read error from %s: %v", ws.clientAddr, err) + } + return copy(b, data), err + } + if msgType != websocket.MessageBinary { + log.Warnf("Unexpected WebSocket message type from %s: %v", ws.clientAddr, msgType) + return 0, nil + } + + ws.bufferedRead = data + return ws.readFromBuffer(b) +} + +func (ws *wsConnAdapter) readFromBuffer(b []byte) (int, error) { + n := copy(b, ws.bufferedRead) + + ws.recordBytesTransferred(ws.ctx, "ws_to_grpc", n) + if n == len(ws.bufferedRead) { + ws.bufferedRead = nil + return n, nil + } else { + ws.bufferedRead = ws.bufferedRead[n:] + } + return n, nil +} + +func (ws *wsConnAdapter) Write(b []byte) (int, error) { + maybeErr := ws.ctx.Err() + + n := len(b) + if n == 0 { + return n, maybeErr + } + if maybeErr != nil { + return 0, maybeErr + } + if err := ws.conn.Write(ws.ctx, websocket.MessageBinary, b[:n]); err != nil { + ws.recordError(ws.ctx, "websocket_write_error") + log.Warnf("WebSocket write error for %s: %v", ws.clientAddr, err) + return 0, err // we don't know how many bytes have been written + } + + ws.recordBytesTransferred(ws.ctx, "grpc_to_ws", n) + return n, nil +} + +func (ws *wsConnAdapter) Close() error { + ws.closed.Store(true) + return ws.conn.Close(websocket.StatusNormalClosure, "") +} + +func (ws *wsConnAdapter) LocalAddr() net.Addr { return wsAddr{ws.prefix} } +func (ws *wsConnAdapter) RemoteAddr() net.Addr { return wsAddr{ws.prefix} } + +func (ws *wsConnAdapter) SetDeadline(t time.Time) error { + return nil +} + +func (ws *wsConnAdapter) SetReadDeadline(t time.Time) error { + return nil +} + +func (ws *wsConnAdapter) SetWriteDeadline(t time.Time) error { + return nil +} + +func (ws *wsConnAdapter) recordError(ctx context.Context, errorType string) { + if ws.metrics == nil { + return + } + ws.metrics.RecordError(ctx, errorType) +} + +func (ws *wsConnAdapter) recordBytesTransferred(ctx context.Context, direction string, bytes int) { + if ws.metrics == nil { + return + } + ws.metrics.RecordBytesTransferred(ctx, direction, int64(bytes)) +} + +func (ws *wsConnAdapter) IsClosed() bool { + return ws.closed.Load() +} diff --git a/util/wsproxy/server/ws_conn_adapter_test.go b/util/wsproxy/server/ws_conn_adapter_test.go new file mode 100644 index 000000000..5369b2362 --- /dev/null +++ b/util/wsproxy/server/ws_conn_adapter_test.go @@ -0,0 +1,204 @@ +package server + +import ( + "bytes" + "context" + "crypto/tls" + "io" + "math/rand/v2" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/stretchr/testify/assert" + "golang.org/x/net/http2" + "golang.org/x/net/http2/hpack" +) + +func TestAdapterHandlingConnectionClosures(t *testing.T) { + var cases = []struct { + description string + casenum int + }{ + {"client-side ws connection is closed", 0}, + {"server-side ws connection is closed", 1}, + {"client-side context is cancelled", 2}, + {"server-side context is cancelled", 3}, + } + + for _, c := range cases { + t.Run(c.description, func(t *testing.T) { + serversock := filepath.Join("/tmp", "http-server-"+strconv.FormatInt(rand.Int64(), 10)+".sock") + t.Cleanup(func() { os.Remove(serversock) }) + + l, err := net.Listen("unix", serversock) + assert.NoError(t, err) + + proxy := New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf, _ := io.ReadAll(r.Body) + defer r.Body.Close() + w.Write([]byte("echo: " + string(buf))) //nolint:errcheck + })) + + handler, ok := proxy.Handler().(*proxyHandler) + assert.True(t, ok) + + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetUnencryptedHTTP2(true) + httpServer := http.Server{ + Handler: handler, + } + go httpServer.Serve(l) //nolint:errcheck + t.Cleanup(func() { httpServer.Close() }) + + clientconn, _, err := websocket.Dial(context.Background(), "http://whatever", //nolint:bodyclose + &websocket.DialOptions{HTTPClient: &http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", serversock) + }, + }}}) + assert.NoError(t, err) + + clientCtx, cancel := context.WithCancel(context.Background()) //nolint:govet + h2client := &http.Client{ + Transport: &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(_ context.Context, _, _ string, _ *tls.Config) (net.Conn, error) { + return &wsConnAdapter{ + prefix: "test-client", + ctx: clientCtx, + conn: clientconn, + }, nil + }, + }} + + resp, err := h2client.Post("http://whatever", "text/html", strings.NewReader("g'day")) + assert.NoError(t, err) + + body, err := io.ReadAll(resp.Body) + defer resp.Body.Close() + + assert.NoError(t, err) + assert.Equal(t, "echo: g'day", string(body)) + + switch c.casenum { + case 0: + clientconn.Close(websocket.StatusNormalClosure, "") + case 1: + handler.conn.Load().Close() + case 2: + cancel() + case 3: + resp.Body.Close() + h2client.CloseIdleConnections() + } + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + assert.True(c, handler.conn.Load().IsClosed()) + }, 3*time.Second, 100*time.Millisecond) + }) //nolint:govet + } +} + +func TestAdapterHandlingHttpConnection_NoHeadersSent(t *testing.T) { + t.Skip("currently disabled as it requires idle timeout to be set") + + serversock := filepath.Join("/tmp", "http-server-"+strconv.FormatInt(rand.Int64(), 10)+".sock") + defer os.Remove(serversock) + + l, err := net.Listen("unix", serversock) + assert.NoError(t, err) + + proxy := New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf, _ := io.ReadAll(r.Body) + defer r.Body.Close() //nolint:errcheck + w.Write([]byte("echo: " + string(buf))) //nolint:errcheck + })) + + handler, ok := proxy.Handler().(*proxyHandler) + assert.True(t, ok) + + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetUnencryptedHTTP2(true) + httpServer := http.Server{ + Handler: handler, + } + go httpServer.Serve(l) //nolint:errcheck + + clientconn, _, err := websocket.Dial(context.Background(), "http://whatever", //nolint:bodyclose + &websocket.DialOptions{HTTPClient: &http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", serversock) + }, + }}}) + assert.NoError(t, err) + + h2client := &http.Client{ + Transport: &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(_ context.Context, _, _ string, _ *tls.Config) (net.Conn, error) { + return &h2ConnectionSnooper{wrappedConn: &wsConnAdapter{ + prefix: "test-client", + ctx: context.Background(), + conn: clientconn, + }, shouldDropFrame: func(f http2.FrameType) bool { return f == http2.FrameHeaders || f == http2.FrameData }}, nil + }, + }} + + _, err = h2client.Post("http://whatever", "text/html", strings.NewReader("g'day")) + assert.Error(t, err) + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + assert.True(c, handler.conn.Load().IsClosed()) + }, 3*time.Second, 100*time.Millisecond) +} + +type h2ConnectionSnooper struct { + wrappedConn net.Conn + shouldDropFrame func(f http2.FrameType) bool +} + +func (hs *h2ConnectionSnooper) Read(b []byte) (n int, err error) { + return hs.wrappedConn.Read(b) +} + +func (hs *h2ConnectionSnooper) Write(b []byte) (n int, err error) { + fr := http2.NewFramer(nil, bytes.NewReader(b)) + fr.ReadMetaHeaders = hpack.NewDecoder(0, nil) + f, err := fr.ReadFrame() + if err != nil { + return hs.wrappedConn.Write(b) + } + + if hs.shouldDropFrame != nil && hs.shouldDropFrame(f.Header().Type) { + return len(b), nil + } + + return hs.wrappedConn.Write(b) +} + +func (hs *h2ConnectionSnooper) Close() error { return hs.wrappedConn.Close() } + +func (hs *h2ConnectionSnooper) LocalAddr() net.Addr { return hs.wrappedConn.LocalAddr() } + +func (hs *h2ConnectionSnooper) RemoteAddr() net.Addr { return hs.wrappedConn.RemoteAddr() } + +func (hs *h2ConnectionSnooper) SetDeadline(t time.Time) error { return hs.wrappedConn.SetDeadline(t) } + +func (hs *h2ConnectionSnooper) SetReadDeadline(t time.Time) error { + return hs.wrappedConn.SetReadDeadline(t) +} + +func (hs *h2ConnectionSnooper) SetWriteDeadline(t time.Time) error { + return hs.wrappedConn.SetWriteDeadline(t) +} From e704203927fcd40ee2b4687a134d513c3909f3bf Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Thu, 10 Sep 2026 20:05:42 +0200 Subject: [PATCH 006/209] [management] do not hard-code tmp dir path in ws_conn_adapter_test (#7503) * do not hard-code tmp dir path Signed-off-by: Dmitri Dolguikh * use os.TempDir to get tmp dir Signed-off-by: Dmitri Dolguikh --------- Signed-off-by: Dmitri Dolguikh --- util/wsproxy/server/ws_conn_adapter_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/util/wsproxy/server/ws_conn_adapter_test.go b/util/wsproxy/server/ws_conn_adapter_test.go index 5369b2362..d46e4830b 100644 --- a/util/wsproxy/server/ws_conn_adapter_test.go +++ b/util/wsproxy/server/ws_conn_adapter_test.go @@ -34,7 +34,7 @@ func TestAdapterHandlingConnectionClosures(t *testing.T) { for _, c := range cases { t.Run(c.description, func(t *testing.T) { - serversock := filepath.Join("/tmp", "http-server-"+strconv.FormatInt(rand.Int64(), 10)+".sock") + serversock := filepath.Join(os.TempDir(), "http-server-"+strconv.FormatInt(rand.Int64(), 10)+".sock") t.Cleanup(func() { os.Remove(serversock) }) l, err := net.Listen("unix", serversock) @@ -111,7 +111,7 @@ func TestAdapterHandlingConnectionClosures(t *testing.T) { func TestAdapterHandlingHttpConnection_NoHeadersSent(t *testing.T) { t.Skip("currently disabled as it requires idle timeout to be set") - serversock := filepath.Join("/tmp", "http-server-"+strconv.FormatInt(rand.Int64(), 10)+".sock") + serversock := filepath.Join(os.TempDir(), "http-server-"+strconv.FormatInt(rand.Int64(), 10)+".sock") defer os.Remove(serversock) l, err := net.Listen("unix", serversock) From 2f48dbea6ae4d07411e37283008d63534803e37a Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Thu, 10 Sep 2026 21:41:59 +0200 Subject: [PATCH 007/209] [client] Add a release-wired rootless UBI image variant (#7469) * [client] Add a release-wired rootless UBI image variant * [client] Add ARM64 to the rootless UBI image * [client] Express license output validation as a guard --- .goreleaser.yaml | 37 ++++++++++++++++ client/Dockerfile-rootless.ubi | 45 ++++++++++++++++++++ client/collect-licenses.sh | 77 ++++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 client/Dockerfile-rootless.ubi create mode 100644 client/collect-licenses.sh diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c5d260376..778ccb892 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -289,6 +289,43 @@ dockers_v2: "org.opencontainers.image.revision": "{{.FullCommit}}" "org.opencontainers.image.source": "{{.GitURL}}" "maintainer": "dev@netbird.io" + - id: netbird-rootless-ubi + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird + images: + - netbirdio/netbird + - ghcr.io/netbirdio/netbird + tags: + - "{{ .Version }}-rootless-ubi" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-ubi-latest{{ end }}" + dockerfile: client/Dockerfile-rootless.ubi + extra_files: + - client/netbird-entrypoint.sh + platforms: + - linux/amd64 + - linux/arm64 + build_args: + VERSION: "{{ .Version }}" + RELEASE: "{{ .Timestamp }}" + hooks: + pre: + - cmd: 'sh client/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + env: + - GOOS=linux + - CGO_ENABLED=0 + labels: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" - id: relay disable: "{{ .Env.SKIP_DOCKER_PUSH }}" ids: diff --git a/client/Dockerfile-rootless.ubi b/client/Dockerfile-rootless.ubi new file mode 100644 index 000000000..4701728c1 --- /dev/null +++ b/client/Dockerfile-rootless.ubi @@ -0,0 +1,45 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 + +ARG TARGETPLATFORM +ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird +ARG VERSION=dev +ARG RELEASE=1 + +LABEL name="netbird-rootless" \ + maintainer="NetBird " \ + vendor="NetBird GmbH" \ + version="${VERSION}" \ + release="${RELEASE}" \ + summary="NetBird Rootless Client" \ + description="NetBird connects devices through an encrypted overlay using userspace networking without a TUN device or network administration capabilities." + +RUN microdnf install -y bash ca-certificates && microdnf clean all + +COPY --chmod=0555 client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh +COPY --chmod=0555 ${NETBIRD_BINARY} /usr/local/bin/netbird +COPY licenses/ /licenses/ +# Only application storage is group-writable for arbitrary non-root UIDs. +# Runtime-created credentials keep the client's restrictive file modes. +RUN mkdir -p /var/lib/netbird && \ + chown 1000:0 /var/lib/netbird && \ + chmod 0770 /var/lib/netbird && \ + chmod -R a+rX /licenses + +WORKDIR /var/lib/netbird +USER 1000:0 + +ENV \ + HOME="/var/lib/netbird" \ + NETBIRD_BIN="/usr/local/bin/netbird" \ + NB_USE_NETSTACK_MODE="true" \ + NB_ENABLE_NETSTACK_LOCAL_FORWARDING="true" \ + NB_CONFIG="/var/lib/netbird/config.json" \ + NB_STATE_DIR="/var/lib/netbird" \ + NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \ + NB_LOG_FILE="console,/var/lib/netbird/client.log" \ + NB_DISABLE_DNS="true" \ + NB_ENABLE_CAPTURE="false" \ + NB_ENTRYPOINT_SERVICE_TIMEOUT="30" + +STOPSIGNAL SIGTERM +ENTRYPOINT ["/usr/local/bin/netbird-entrypoint.sh"] diff --git a/client/collect-licenses.sh b/client/collect-licenses.sh new file mode 100644 index 000000000..7dfabada9 --- /dev/null +++ b/client/collect-licenses.sh @@ -0,0 +1,77 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 2 ]; then + printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 + exit 2 +fi + +repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +output_name=$(basename "$1") +if [ -z "$output_name" ] || [ "$output_name" = "." ] || + [ "$output_name" = ".." ] || [ "$output_name" = "/" ]; then + printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2 + exit 2 +fi +output_parent=$(CDPATH= cd -- "$(dirname "$1")" && pwd) +output="$output_parent/$output_name" +shift +modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.modules.XXXXXX") +sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.sorted.XXXXXX") +trap 'rm -f "$modules" "$sorted_modules"' EXIT HUP INT TERM + +if [ -e "$output" ] || [ -L "$output" ]; then + printf 'output directory already exists: %s\n' "$output" >&2 + exit 1 +fi +mkdir "$output" +mkdir "$output/third_party" + +cp "$repo_root/LICENSE" "$output/BSD-3-Clause.txt" + +cd "$repo_root" +for arch in "$@"; do + GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \ + go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' -tags load_wgnt_from_rsrc ./client >>"$modules" +done +LC_ALL=C sort -u "$modules" >"$sorted_modules" + +goroot=$(go env GOROOT) +for term in LICENSE PATENTS; do + if [ ! -f "$goroot/$term" ]; then + printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2 + exit 1 + fi + cp "$goroot/$term" "$output/Go-$term" +done + +while IFS=' ' read -r module version module_dir; do + [ -n "$module" ] || continue + [ "$module" = "github.com/netbirdio/netbird" ] && continue + + if [ -z "$version" ] || [ ! -d "$module_dir" ]; then + printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2 + exit 1 + fi + + destination="$output/third_party/$module/$version" + mkdir -p "$destination" + printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE" + + found=false + for term in \ + "$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \ + "$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \ + "$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \ + "$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \ + "$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do + [ -f "$term" ] || continue + cp "$term" "$destination/" + found=true + done + + if [ "$found" = false ]; then + printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2 + exit 1 + fi +done <"$sorted_modules" From a419e770d9750caf4ea7c525056c9d2a60bd78b6 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:38:22 +0200 Subject: [PATCH 008/209] [client, proxy] Make the buffer-pool retune reachable while a device is stalled (#7452) * [client] Track the WireGuard device on the engine as a lock-free handle Add an atomic handle on the wg device next to wgInterface, stored once the interface is up and cleared when it is closed. Nothing reads it yet, so this is a pure addition with no behavior change; it exists so the next commit can reach the device without taking syncMsgMux. * [client] Retune the WireGuard buffer pool without the engine lock SetPerformance took syncMsgMux before reaching the device. That lock is held by handleSync while it adds and removes peers, and peer removal is exactly what blocks when a device's buffer pool is exhausted: Peer.Stop waits on a keepalive timer callback that is itself parked in WaitPool.Get. Raising the cap is the way out of that state, so the call must not queue behind the lock the stall is holding. Read the device through the atomic handle instead. Device.SetPreallocatedBuffersPerPool takes the pool's own lock and broadcasts, so the waiters wake up. * [proxy] Extract the buffer-cap apply loop out of the perf handler Pure move: the loop over the registered clients becomes applyBufferCap, with the same sequential behavior and the same return values. Split out so the next commit can change how it iterates without the diff also carrying the move. * [proxy] Bound the perf endpoint so one wedged client cannot hold it The apply loop was sequential and unbounded. embed.Client.SetPerformance goes through the client lock, which Start holds for the whole of a startup, so a single account that is busy or wedged delayed the new buffer cap for every other account on the node -- on the endpoint whose whole purpose is to un-wedge a node. Apply to all clients concurrently and give the whole call a 5s budget. Accounts that do not answer in time are reported in "failed" instead of blocking the response. * [client] Drop the device handle before closing the interface close() cleared the atomic handle only after wgInterface.Close() returned, so a concurrent SetPerformance could still load it, retune a device that is being torn down, and report the change as applied for an engine that has stopped. Clear it first, so the window closes before the teardown begins. Reported by cubic on PR #7452. * [proxy] Put the per-client retune behind a field Pure refactor: applyBufferCap calls h.setPerformance instead of the client method directly, and NewHandler wires it to setClientPerformance. Same call, same behavior; the seam is what lets the next two commits be tested without a live embedded client. * [proxy] Do not report a finished retune as timed out When the deadline fires, select chooses at random among the ready cases, so a result already sitting in the buffered channel could be skipped and its account reported as timed out even though the cap had been applied. Drain what is buffered before declaring the rest pending. Reported by cubic on PR #7452. * [proxy] Keep one retune per account in flight The 5s budget bounds how long the endpoint waits, not the work: SetPerformance goes through the embedded client's lock, and on a wedged account Stop holds that lock forever, so every retry left one more goroutine parked there. Route each account through a single worker. A request that finds one already running takes its result if it has landed, and otherwise reports the account under "in_flight" instead of starting a second attempt. One stuck account now costs one goroutine, no matter how often the endpoint is called. Reported by CodeRabbit and cubic on PR #7452. * [proxy] Make the retune budget a var Pure refactor: perfApplyTimeout becomes a var so a test can shorten it instead of waiting five seconds. Same value, same behavior in production. * [proxy] Extract the buffered-result drain Pure refactor: the loop that empties the results channel when the deadline fires becomes collectBuffered. Same behavior; split out so it can be tested on its own, which the inline version could not be without racing the deadline. * [proxy] Cover the retune single-flight and the deadline drain TestApplyBufferCapSingleFlightPerAccount fails without the worker registry: five calls against a client stuck in its own lock start five blocked workers instead of one. TestCollectBufferedCountsResultsReadyAtTheDeadline pins the drain helper's contract - buffered results counted, errors recorded, only unanswered accounts left pending. It drives collectBuffered directly: through applyBufferCap the two select cases race by construction, so an end-to-end version of it would pass on the unfixed code about half the time. * [proxy] Keep the worker alongside each pending account Pure refactor: the pending set becomes a map to the account's worker instead of an empty struct. Same membership and same behavior; the next commit needs the worker to resolve an account whose result has not reached the channel yet. * [proxy] Publish a retune result before releasing its slot The worker sent its result last, after taking perfMu to remove itself from the registry. That lock is taken once per account by every caller walking the fleet, so a worker that finished on time could queue behind an apply over thousands of accounts and land after the deadline. Send first, deregister after. Reported by cubic on PR #7452. * [proxy] Read the worker, not the clock, for a finished retune Publishing earlier only narrows the window: a client that answers just before the deadline can still be reported as timed out. At the deadline the workers themselves are authoritative - a closed done channel means the retune finished and w.err carries its outcome, ordered by the close. Consult them instead of declaring every pending account timed out, and keep the timeout label for the ones actually still running. Reported by cubic on PR #7452. * [proxy] Cover the finished-worker resolution at the deadline Fails on the previous behavior with "applied = 0, want 1": every pending account was labelled a timeout, including the one whose retune had already completed. --- client/internal/engine.go | 28 +++-- proxy/internal/debug/handler.go | 189 ++++++++++++++++++++++++++++-- proxy/internal/debug/perf_test.go | 158 +++++++++++++++++++++++++ 3 files changed, 355 insertions(+), 20 deletions(-) create mode 100644 proxy/internal/debug/perf_test.go diff --git a/client/internal/engine.go b/client/internal/engine.go index f8b65f7d8..d517d1d68 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -14,12 +14,14 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "github.com/hashicorp/go-multierror" "github.com/pion/ice/v4" "github.com/pion/stun/v3" log "github.com/sirupsen/logrus" + wgdevice "golang.zx2c4.com/wireguard/device" "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" @@ -236,6 +238,12 @@ type Engine struct { wgInterface WGIface + // wgDevice is a lock-free handle on the WireGuard device behind + // wgInterface. Reaching the device through wgInterface requires + // syncMsgMux, which handleSync holds while it adds and removes peers; + // SetPerformance must stay reachable exactly when that work is stuck. + wgDevice atomic.Pointer[wgdevice.Device] + udpMux *udpmux.UniversalUDPMuxDefault // networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service @@ -651,6 +659,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error()) return fmt.Errorf("up wg interface: %w", err) } + e.wgDevice.Store(e.wgInterface.GetWGDevice()) // Set up notrack rules immediately after proxy is listening to prevent // conntrack entries from being created before the rules are in place @@ -2144,6 +2153,10 @@ func (e *Engine) close() { log.Debugf("removing Netbird interface %s", e.config.WgIfaceName) if e.wgInterface != nil { + // Drop the handle before the close starts: a retune that loads it + // afterwards would touch a device on its way out and report success + // for an engine that is already gone. + e.wgDevice.Store(nil) if err := e.wgInterface.Close(); err != nil { log.Errorf("failed closing Netbird interface %s %v", e.config.WgIfaceName, err) } @@ -2303,15 +2316,16 @@ type Performance struct { } // SetPerformance applies the given tuning to this engine's live Device. +// +// It deliberately does not take syncMsgMux. Raising the buffer pool cap is the +// recovery path for a device whose pool is exhausted, and an exhausted pool +// blocks peer removal inside handleSync, which holds syncMsgMux for as long as +// it stays blocked. Taking the lock here would make the retune unreachable in +// the one situation that needs it. func (e *Engine) SetPerformance(t Performance) error { - e.syncMsgMux.Lock() - defer e.syncMsgMux.Unlock() - if e.wgInterface == nil { - return fmt.Errorf("wg interface not initialized") - } - dev := e.wgInterface.GetWGDevice() + dev := e.wgDevice.Load() if dev == nil { - return fmt.Errorf("wg device not initialized") + return errors.New("wg device not initialized") } if t.PreallocatedBuffersPerPool != nil { dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool) diff --git a/proxy/internal/debug/handler.go b/proxy/internal/debug/handler.go index 6300228d7..960c3e089 100644 --- a/proxy/internal/debug/handler.go +++ b/proxy/internal/debug/handler.go @@ -105,6 +105,20 @@ type Handler struct { startTime time.Time templates *template.Template templateMu sync.RWMutex + + // setPerformance applies a buffer cap to one client. Held as a field so + // tests can drive applyBufferCap without a live embedded client. + setPerformance func(*nbembed.Client, uint32) error + + perfMu sync.Mutex + perfInflight map[types.AccountID]*perfWorker +} + +// perfWorker is the single in-flight retune for one account. err is valid once +// done is closed. +type perfWorker struct { + done chan struct{} + err error } // NewHandler creates a new debug handler. @@ -113,10 +127,11 @@ func NewHandler(provider clientProvider, healthChecker healthChecker, logger *lo logger = log.StandardLogger() } h := &Handler{ - provider: provider, - health: healthChecker, - logger: logger, - startTime: time.Now(), + provider: provider, + health: healthChecker, + logger: logger, + startTime: time.Now(), + setPerformance: setClientPerformance, } if err := h.loadTemplates(); err != nil { logger.Errorf("failed to load embedded templates: %v", err) @@ -716,15 +731,7 @@ func (h *Handler) handlePerf(w http.ResponseWriter, r *http.Request) { } capN := uint32(n) - applied := 0 - failed := map[string]string{} - for accountID, client := range h.provider.ListClientsForStartup() { - if err := client.SetPerformance(nbembed.Performance{PreallocatedBuffersPerPool: &capN}); err != nil { - failed[string(accountID)] = err.Error() - continue - } - applied++ - } + applied, failed, inFlight := h.applyBufferCap(capN) resp := map[string]any{ "success": true, @@ -734,9 +741,165 @@ func (h *Handler) handlePerf(w http.ResponseWriter, r *http.Request) { if len(failed) > 0 { resp["failed"] = failed } + if len(inFlight) > 0 { + resp["in_flight"] = inFlight + } h.writeJSON(w, resp) } +// perfApplyTimeout bounds the whole apply, however many clients are registered. +// A var, not a const, so tests can shorten the wait. +var perfApplyTimeout = 5 * time.Second + +type perfResult struct { + accountID types.AccountID + err error +} + +// setClientPerformance is the production implementation behind Handler.setPerformance. +func setClientPerformance(client *nbembed.Client, capN uint32) error { + return client.SetPerformance(nbembed.Performance{PreallocatedBuffersPerPool: &capN}) +} + +// collectBuffered takes every result already sitting in the channel, removing +// those accounts from pending, and returns how many of them succeeded. It is +// called when the deadline fires: select picks at random among ready cases, so +// a result that landed in time would otherwise be reported as a timeout. +func collectBuffered(results <-chan perfResult, pending map[types.AccountID]*perfWorker, failed map[string]string) int { + applied := 0 + for { + select { + case res := <-results: + delete(pending, res.accountID) + if res.err != nil { + failed[string(res.accountID)] = res.err.Error() + continue + } + applied++ + default: + return applied + } + } +} + +// resolvePending closes out the accounts still pending when the deadline fires. +// A worker whose done channel is closed has finished, whatever the results +// channel has managed to deliver, so its own error is the truth; the rest are +// genuinely still running and are reported as timed out. Returns how many of +// them had in fact succeeded. +func resolvePending(pending map[types.AccountID]*perfWorker, failed map[string]string) int { + applied := 0 + for accountID, w := range pending { + select { + case <-w.done: + if w.err != nil { + failed[string(accountID)] = w.err.Error() + continue + } + applied++ + default: + failed[string(accountID)] = fmt.Sprintf("timed out after %s waiting for the client", perfApplyTimeout) + } + } + return applied +} + +// startPerfWorker returns the in-flight retune for the account, starting one if +// there is none. The bool reports whether this call started it. +// +// At most one retune runs per account at a time. A client wedged inside its own +// lock never returns, so without this a caller could add one permanently blocked +// goroutine per request just by retrying the endpoint. +func (h *Handler) startPerfWorker(accountID types.AccountID, client *nbembed.Client, capN uint32, results chan<- perfResult) (*perfWorker, bool) { + h.perfMu.Lock() + defer h.perfMu.Unlock() + + if w, ok := h.perfInflight[accountID]; ok { + return w, false + } + + w := &perfWorker{done: make(chan struct{})} + if h.perfInflight == nil { + h.perfInflight = make(map[types.AccountID]*perfWorker) + } + h.perfInflight[accountID] = w + + go func() { + err := h.setPerformance(client, capN) + w.err = err + close(w.done) + + // Publish before touching the registry: perfMu is taken once per + // account by every caller walking the fleet, so a finishing worker + // can queue behind a long apply and miss its own deadline. + results <- perfResult{accountID: accountID, err: err} + + h.perfMu.Lock() + delete(h.perfInflight, accountID) + h.perfMu.Unlock() + }() + + return w, true +} + +// applyBufferCap sets the WireGuard buffer pool cap on every registered client +// and reports how many took it, a per-account error for those that did not, and +// the accounts whose earlier retune has not come back yet. +// +// Clients are handled concurrently and the wait is bounded: SetPerformance goes +// through the embedded client's lock, which Start and Stop hold for as long as +// they take - and on a wedged client Stop never returns. This endpoint is the +// recovery path for exactly that fleet, so one stuck account must neither delay +// the others nor accumulate goroutines across retries. +func (h *Handler) applyBufferCap(capN uint32) (int, map[string]string, []string) { + clients := h.provider.ListClientsForStartup() + results := make(chan perfResult, len(clients)) + + applied := 0 + failed := map[string]string{} + var inFlight []string + pending := make(map[types.AccountID]*perfWorker, len(clients)) + + for accountID, client := range clients { + w, started := h.startPerfWorker(accountID, client, capN, results) + if started { + pending[accountID] = w + continue + } + // Another request owns this account's retune. Take its result if it + // has already landed, otherwise report it as still running instead of + // waiting on it again. + select { + case <-w.done: + if w.err != nil { + failed[string(accountID)] = w.err.Error() + continue + } + applied++ + default: + inFlight = append(inFlight, string(accountID)) + } + } + + deadline := time.After(perfApplyTimeout) + for range len(pending) { + select { + case res := <-results: + delete(pending, res.accountID) + if res.err != nil { + failed[string(res.accountID)] = res.err.Error() + continue + } + applied++ + case <-deadline: + applied += collectBuffered(results, pending, failed) + applied += resolvePending(pending, failed) + return applied, failed, inFlight + } + } + return applied, failed, inFlight +} + // handleRuntime returns cheap runtime and process stats. Safe to hit on a // running proxy; does not read pprof profiles. func (h *Handler) handleRuntime(w http.ResponseWriter, _ *http.Request) { diff --git a/proxy/internal/debug/perf_test.go b/proxy/internal/debug/perf_test.go new file mode 100644 index 000000000..abcfccb50 --- /dev/null +++ b/proxy/internal/debug/perf_test.go @@ -0,0 +1,158 @@ +package debug + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + nbembed "github.com/netbirdio/netbird/client/embed" + "github.com/netbirdio/netbird/proxy/internal/health" + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +// perfProvider serves a fixed set of accounts. The clients are nil: the tests +// drive Handler.setPerformance, which never dereferences them. +type perfProvider struct { + accounts []types.AccountID +} + +func (p *perfProvider) GetClient(types.AccountID) (*nbembed.Client, bool) { return nil, false } + +func (p *perfProvider) ListClientsForDebug() map[types.AccountID]roundtrip.ClientDebugInfo { + return nil +} + +func (p *perfProvider) ListClientsForStartup() map[types.AccountID]*nbembed.Client { + out := make(map[types.AccountID]*nbembed.Client, len(p.accounts)) + for _, id := range p.accounts { + out[id] = nil + } + return out +} + +type stubHealth struct{} + +func (stubHealth) ReadinessProbe() bool { return true } +func (stubHealth) StartupProbe(context.Context) bool { return true } +func (stubHealth) CheckClientsConnected(context.Context) (bool, map[types.AccountID]health.ClientHealth) { + return true, nil +} + +func shortenPerfTimeout(t *testing.T, d time.Duration) { + t.Helper() + prev := perfApplyTimeout + perfApplyTimeout = d + t.Cleanup(func() { perfApplyTimeout = prev }) +} + +// TestCollectBufferedCountsResultsReadyAtTheDeadline covers the select-ordering +// trap: when the deadline fires, results already buffered must be counted, not +// reported as timeouts. Driving collectBuffered directly keeps it deterministic +// - through applyBufferCap the two select cases race by construction. +func TestCollectBufferedCountsResultsReadyAtTheDeadline(t *testing.T) { + results := make(chan perfResult, 3) + results <- perfResult{accountID: "ok"} + results <- perfResult{accountID: "broken", err: errors.New("boom")} + + pending := map[types.AccountID]*perfWorker{ + "ok": {done: make(chan struct{})}, + "broken": {done: make(chan struct{})}, + "wedged": {done: make(chan struct{})}, + } + failed := map[string]string{} + + applied := collectBuffered(results, pending, failed) + + if applied != 1 { + t.Fatalf("applied = %d, want 1", applied) + } + if failed["broken"] != "boom" { + t.Fatalf("failed = %v, want the error recorded for \"broken\"", failed) + } + if _, ok := pending["wedged"]; !ok || len(pending) != 1 { + t.Fatalf("pending = %v, want only the account that never answered", pending) + } +} + +// TestApplyBufferCapSingleFlightPerAccount covers the goroutine accumulation +// reported on PR #7452: repeated calls against a client stuck in its own lock +// must not start a second attempt for the same account. +func TestApplyBufferCapSingleFlightPerAccount(t *testing.T) { + shortenPerfTimeout(t, 50*time.Millisecond) + + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + + var calls atomic.Int32 + h := &Handler{ + provider: &perfProvider{accounts: []types.AccountID{"wedged"}}, + health: stubHealth{}, + setPerformance: func(_ *nbembed.Client, _ uint32) error { + calls.Add(1) + <-release + return nil + }, + } + + for i := range 5 { + applied, failed, inFlight := h.applyBufferCap(4096) + if applied != 0 { + t.Fatalf("call %d: applied = %d, want 0", i, applied) + } + if i == 0 { + if len(failed) != 1 { + t.Fatalf("first call: failed = %v, want the account reported as timed out", failed) + } + continue + } + if len(inFlight) != 1 { + t.Fatalf("call %d: inFlight = %v, want the account reported as still running", i, inFlight) + } + if len(failed) != 0 { + t.Fatalf("call %d: failed = %v, want empty while the retune is in flight", i, failed) + } + } + + if got := calls.Load(); got != 1 { + t.Fatalf("setPerformance called %d times, want 1: each retry started another blocked worker", got) + } +} + +// TestResolvePendingTrustsFinishedWorkers covers the reporting race cubic +// flagged on PR #7452: a retune that finished just before the deadline must be +// reported by its outcome, not as a timeout, whatever the results channel has +// delivered so far. +func TestResolvePendingTrustsFinishedWorkers(t *testing.T) { + ok := &perfWorker{done: make(chan struct{})} + close(ok.done) + + broken := &perfWorker{done: make(chan struct{}), err: errors.New("boom")} + close(broken.done) + + stillRunning := &perfWorker{done: make(chan struct{})} + + pending := map[types.AccountID]*perfWorker{ + "ok": ok, + "broken": broken, + "running": stillRunning, + } + failed := map[string]string{} + + applied := resolvePending(pending, failed) + + if applied != 1 { + t.Fatalf("applied = %d, want 1", applied) + } + if failed["broken"] != "boom" { + t.Fatalf("failed[broken] = %q, want the worker's own error", failed["broken"]) + } + if _, ok := failed["ok"]; ok { + t.Fatalf("failed = %v, want no entry for the account that succeeded", failed) + } + if got := failed["running"]; got == "" || got == "boom" { + t.Fatalf("failed[running] = %q, want the timeout message", got) + } +} From add8a75981b84375c3cfca5cb23f33f41d77e1b5 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:49:20 +0200 Subject: [PATCH 009/209] [management] validate peer existence when adding to group (#7486) --- management/server/group.go | 30 +++++++-- management/server/group_test.go | 81 ++++++++++++++++++++++- management/server/store/sql_store.go | 4 +- management/server/store/sql_store_test.go | 14 ++++ 4 files changed, 121 insertions(+), 8 deletions(-) diff --git a/management/server/group.go b/management/server/group.go index 33870f25e..ca20a6b08 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -101,10 +101,8 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use return status.Errorf(status.Internal, "failed to create group: %v", err) } - for _, peerID := range newGroup.Peers { - if err := transaction.AddPeerToGroup(ctx, accountID, peerID, newGroup.ID); err != nil { - return status.Errorf(status.Internal, "failed to add peer %s to group %s: %v", peerID, newGroup.ID, err) - } + if err = syncGroupMembership(ctx, transaction, accountID, newGroup.ID, newGroup.Peers, nil); err != nil { + return err } snap, err = affectedpeers.Load(ctx, transaction, accountID, change) @@ -200,6 +198,9 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use // syncGroupMembership applies the peer membership delta for a group within a transaction. func syncGroupMembership(ctx context.Context, transaction store.Store, accountID, groupID string, peersToAdd, peersToRemove []string) error { + if err := validateGroupPeers(ctx, transaction, accountID, peersToAdd); err != nil { + return err + } for _, peerID := range peersToAdd { if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil { return status.Errorf(status.Internal, "failed to add peer %s to group %s: %v", peerID, groupID, err) @@ -213,6 +214,25 @@ func syncGroupMembership(ctx context.Context, transaction store.Store, accountID return nil } +func validateGroupPeers(ctx context.Context, transaction store.Store, accountID string, peerIDs []string) error { + if len(peerIDs) == 0 { + return nil + } + + peers, err := transaction.GetPeersByIDs(ctx, store.LockingStrengthNone, accountID, peerIDs) + if err != nil { + return err + } + + for _, peerID := range peerIDs { + if _, ok := peers[peerID]; !ok { + return status.Errorf(status.InvalidArgument, "peer with ID %s not found", peerID) + } + } + + return nil +} + // CreateGroups adds new groups to the account. // Note: This function does not acquire the global lock. // It is the caller's responsibility to ensure proper locking is in place before invoking this method. @@ -540,7 +560,7 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr change := affectedpeers.Change{OutputPeerIDs: []string{peerID}, LinkGroups: []string{groupID}} err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil { + if err := syncGroupMembership(ctx, transaction, accountID, groupID, []string{peerID}, nil); err != nil { return err } diff --git a/management/server/group_test.go b/management/server/group_test.go index f5aeceea8..da056c8a9 100644 --- a/management/server/group_test.go +++ b/management/server/group_test.go @@ -11,10 +11,10 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "golang.org/x/exp/maps" nbdns "github.com/netbirdio/netbird/dns" @@ -1236,3 +1236,82 @@ func Test_IncrementNetworkSerial(t *testing.T) { assert.Equal(t, totalPeers, int(account.Network.Serial), "Expected %d serial increases in account %s, got %d", totalPeers, accountID, account.Network.Serial) } + +func TestDefaultAccountManager_GroupPeersMustBelongToAccount(t *testing.T) { + manager, _, account, peer1, _, _ := setupNetworkMapTest(t) + + otherAccount, err := createAccount(manager, "other_account", "other_user", "") + require.NoError(t, err) + + foreignPeer := &peer2.Peer{ + ID: "foreign-peer", + AccountID: otherAccount.Id, + Key: "foreign-key", + DNSLabel: "foreign-peer", + IP: uint32ToIP(1), + } + require.NoError(t, manager.Store.AddPeerToAccount(context.Background(), foreignPeer)) + + assertRejected := func(t *testing.T, err error) { + t.Helper() + require.Error(t, err) + s, ok := status.FromError(err) + require.True(t, ok, "expected status error, got %v", err) + assert.Equal(t, status.InvalidArgument, s.Type(), "peer outside the account should be rejected as invalid argument") + } + + t.Run("create rejects foreign peer", func(t *testing.T) { + err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ + Name: "foreign", + Issued: types.GroupIssuedAPI, + Peers: []string{peer1.ID, foreignPeer.ID}, + }) + assertRejected(t, err) + + _, err = manager.Store.GetGroupByName(context.Background(), store.LockingStrengthNone, account.Id, "foreign") + assert.Error(t, err, "rejected create must not persist the group") + }) + + t.Run("update rejects foreign and unknown peers", func(t *testing.T) { + group := &types.Group{ID: "own", Name: "own", Issued: types.GroupIssuedAPI, Peers: []string{peer1.ID}} + require.NoError(t, manager.CreateGroup(context.Background(), account.Id, userID, group)) + + group.Peers = []string{peer1.ID, foreignPeer.ID} + assertRejected(t, manager.UpdateGroup(context.Background(), account.Id, userID, group)) + + group.Peers = []string{peer1.ID, "does-not-exist"} + assertRejected(t, manager.UpdateGroup(context.Background(), account.Id, userID, group)) + + stored, err := manager.Store.GetGroupByID(context.Background(), store.LockingStrengthNone, account.Id, group.ID) + require.NoError(t, err) + assert.Equal(t, []string{peer1.ID}, stored.Peers, "rejected updates must not change membership") + }) + + t.Run("update tolerates and drops pre-existing dangling members", func(t *testing.T) { + group := &types.Group{ID: "polluted", Name: "polluted", Issued: types.GroupIssuedAPI, Peers: []string{peer1.ID}} + require.NoError(t, manager.CreateGroup(context.Background(), account.Id, userID, group)) + require.NoError(t, manager.Store.AddPeerToGroup(context.Background(), account.Id, foreignPeer.ID, group.ID)) + + group.Peers = []string{peer1.ID, foreignPeer.ID} + assert.NoError(t, manager.UpdateGroup(context.Background(), account.Id, userID, group), "keeping an existing member must not be rejected") + + group.Peers = []string{peer1.ID} + require.NoError(t, manager.UpdateGroup(context.Background(), account.Id, userID, group)) + + stored, err := manager.Store.GetGroupByID(context.Background(), store.LockingStrengthNone, account.Id, group.ID) + require.NoError(t, err) + assert.Equal(t, []string{peer1.ID}, stored.Peers, "dangling member should be removed once omitted") + }) + + t.Run("direct add rejects foreign and unknown peers", func(t *testing.T) { + group := &types.Group{ID: "direct", Name: "direct", Issued: types.GroupIssuedAPI, Peers: []string{peer1.ID}} + require.NoError(t, manager.CreateGroup(context.Background(), account.Id, userID, group)) + + assertRejected(t, manager.GroupAddPeer(context.Background(), account.Id, group.ID, foreignPeer.ID)) + assertRejected(t, manager.GroupAddPeer(context.Background(), account.Id, group.ID, "does-not-exist")) + + stored, err := manager.Store.GetGroupByID(context.Background(), store.LockingStrengthNone, account.Id, group.ID) + require.NoError(t, err) + assert.Equal(t, []string{peer1.ID}, stored.Peers, "rejected direct adds must not change membership") + }) +} diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index ef353ea83..33c723a8a 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -3473,7 +3473,7 @@ func (s *SqlStore) GetPeerGroups(ctx context.Context, lockStrength LockingStreng var groups []*types.Group query := tx. Joins("JOIN group_peers ON group_peers.group_id = groups.id"). - Where("group_peers.peer_id = ?", peerId). + Where("groups.account_id = ? AND group_peers.peer_id = ?", accountId, peerId). Preload(clause.Associations). Find(&groups) @@ -5053,7 +5053,7 @@ func (s *SqlStore) GetPeersByGroupIDs(ctx context.Context, accountID string, gro Select("DISTINCT peer_id"). Where("account_id = ? AND group_id IN ?", accountID, groupIDs) - result := s.db.Where("id IN (?)", peerIDsSubquery).Find(&peers) + result := s.db.Where("account_id = ? AND id IN (?)", accountID, peerIDsSubquery).Find(&peers) if result.Error != nil { log.WithContext(ctx).Errorf("failed to get peers by group IDs: %s", result.Error) return nil, status.Errorf(status.Internal, "failed to get peers by group IDs") diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 4b7bcf068..fbcff5257 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -2844,6 +2844,14 @@ func TestSqlStore_GetPeerGroups(t *testing.T) { groups, err = store.GetPeerGroups(context.Background(), LockingStrengthNone, accountID, peerID) require.NoError(t, err) assert.Len(t, groups, 2) + + foreignPeerID := "foreign-peer" + err = store.AddPeerToGroup(context.Background(), accountID, foreignPeerID, "cfefqs706sqkneg59g4h") + require.NoError(t, err) + + groups, err = store.GetPeerGroups(context.Background(), LockingStrengthNone, "other-account", foreignPeerID) + require.NoError(t, err) + assert.Empty(t, groups, "groups of another account must not be returned") } func TestSqlStore_GetAccountPeers(t *testing.T) { @@ -4039,9 +4047,15 @@ func TestSqlStore_GetPeersByGroupIDs(t *testing.T) { } require.NoError(t, store.CreateGroups(ctx, accountID, groups)) + otherAccount := newAccountWithId(ctx, "other-account", "other-user", "") + require.NoError(t, store.SaveAccount(ctx, otherAccount)) + foreignPeer := &nbpeer.Peer{ID: "foreign-peer", AccountID: otherAccount.Id} + require.NoError(t, store.AddPeerToAccount(ctx, foreignPeer)) + require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer1, group1ID)) require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer2, group1ID)) require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer1, group2ID)) + require.NoError(t, store.AddPeerToGroup(ctx, accountID, foreignPeer.ID, group1ID)) peers, err := store.GetPeersByGroupIDs(ctx, accountID, tt.groupIDs) require.NoError(t, err) From 1047df5fa26dd690ef812bd35a454fd6cd0d68c2 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:50:11 +0200 Subject: [PATCH 010/209] [management] pass tls config for combined server (#7499) --- combined/cmd/root.go | 11 +++--- management/internals/server/server.go | 50 ++++++++++++++++++--------- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/combined/cmd/root.go b/combined/cmd/root.go index 3e583ef20..917312e57 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -205,7 +205,7 @@ func createAllServers(ctx context.Context, cfg *CombinedConfig) (*serverInstance metricsServer: metricsServer, } - _, tlsSupport, err := handleTLSConfig(cfg) + tlsConfig, tlsSupport, err := handleTLSConfig(cfg) if err != nil { return nil, fmt.Errorf("failed to setup TLS config: %w", err) } @@ -214,7 +214,7 @@ func createAllServers(ctx context.Context, cfg *CombinedConfig) (*serverInstance return nil, err } - if err := servers.createManagementServer(ctx, cfg); err != nil { + if err := servers.createManagementServer(ctx, cfg, tlsConfig); err != nil { return nil, err } @@ -264,7 +264,7 @@ func (s *serverInstances) createRelayServer(cfg *CombinedConfig, tlsSupport bool return nil } -func (s *serverInstances) createManagementServer(ctx context.Context, cfg *CombinedConfig) error { +func (s *serverInstances) createManagementServer(ctx context.Context, cfg *CombinedConfig, tlsConfig *tls.Config) error { if !cfg.Management.Enabled { return nil } @@ -297,7 +297,7 @@ func (s *serverInstances) createManagementServer(ctx context.Context, cfg *Combi LogConfigInfo(mgmtConfig) - s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig) + s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig, tlsConfig) if err != nil { cleanupSTUNListeners(s.stunListeners) return fmt.Errorf("failed to create management server: %w", err) @@ -513,7 +513,7 @@ func handleTLSConfig(cfg *CombinedConfig) (*tls.Config, bool, error) { return nil, false, nil } -func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (mgmtServer.Server, error) { +func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config, tlsConfig *tls.Config) (mgmtServer.Server, error) { mgmt := cfg.Management // Extract port from listen address @@ -542,6 +542,7 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (m AutoResolveDomains: true, MgmtPort: mgmtPort, MgmtMetricsPort: cfg.Server.MetricsPort, + TLSConfig: tlsConfig, DisableMetrics: mgmt.DisableAnonymousMetrics, DisableGeoliteUpdate: mgmt.DisableGeoliteUpdate, // Always enable user deletion from IDP in combined server (embedded IdP is always enabled) diff --git a/management/internals/server/server.go b/management/internals/server/server.go index 22a61bada..9709d1099 100644 --- a/management/internals/server/server.go +++ b/management/internals/server/server.go @@ -74,6 +74,7 @@ type BaseServer struct { grpcExtensions []GRPCExtension listener net.Listener + tlsConfig *tls.Config certManager *autocert.Manager update *version.Update @@ -94,6 +95,7 @@ type Config struct { DisableGeoliteUpdate bool UserDeleteFromIDPEnabled bool AutoResolveDomains bool + TLSConfig *tls.Config } // NewServer initializes and configures a new Server instance @@ -110,6 +112,7 @@ func NewServer(cfg *Config) *BaseServer { disableLegacyManagementPort: cfg.DisableLegacyManagementPort, mgmtMetricsPort: cfg.MgmtMetricsPort, autoResolveDomains: cfg.AutoResolveDomains, + tlsConfig: cfg.TLSConfig, } s.container[ContainerKeyBaseServer] = s @@ -139,21 +142,9 @@ func (s *BaseServer) Start(ctx context.Context) error { } s.EphemeralManager().LoadInitialPeers(srvCtx) - var tlsConfig *tls.Config - tlsEnabled := false - if s.Config.HttpConfig.LetsEncryptDomain != "" { - s.certManager, err = encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain) - if err != nil { - return fmt.Errorf("failed creating LetsEncrypt cert manager: %v", err) - } - tlsEnabled = true - } else if s.Config.HttpConfig.CertFile != "" && s.Config.HttpConfig.CertKey != "" { - tlsConfig, err = loadTLSConfig(s.Config.HttpConfig.CertFile, s.Config.HttpConfig.CertKey) - if err != nil { - log.WithContext(srvCtx).Errorf("cannot load TLS credentials: %v", err) - return err - } - tlsEnabled = true + tlsEnabled, err := s.setupTLS(srvCtx) + if err != nil { + return err } installationID, err := getInstallationID(srvCtx, s.Store()) @@ -215,8 +206,8 @@ func (s *BaseServer) Start(ctx context.Context) error { log.WithContext(ctx).Infof("running HTTP server (LetsEncrypt challenge handler): %s", cml.Addr().String()) s.serveHTTP(ctx, cml, s.certManager.HTTPHandler(nil)) } - case tlsConfig != nil: - s.listener, err = tls.Listen("tcp", fmt.Sprintf(":%d", s.mgmtPort), tlsConfig) + case s.tlsConfig != nil: + s.listener, err = tls.Listen("tcp", fmt.Sprintf(":%d", s.mgmtPort), s.tlsConfig) if err != nil { return fmt.Errorf("failed creating TLS listener on port %d: %v", s.mgmtPort, err) } @@ -240,6 +231,31 @@ func (s *BaseServer) Start(ctx context.Context) error { return nil } +// setupTLS resolves the listener's TLS source: an injected config wins over the HttpConfig certificate settings +func (s *BaseServer) setupTLS(ctx context.Context) (bool, error) { + switch { + case s.tlsConfig != nil: + return true, nil + case s.Config.HttpConfig.LetsEncryptDomain != "": + certManager, err := encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain) + if err != nil { + return false, fmt.Errorf("failed creating LetsEncrypt cert manager: %v", err) + } + s.certManager = certManager + return true, nil + case s.Config.HttpConfig.CertFile != "" && s.Config.HttpConfig.CertKey != "": + tlsConfig, err := loadTLSConfig(s.Config.HttpConfig.CertFile, s.Config.HttpConfig.CertKey) + if err != nil { + log.WithContext(ctx).Errorf("cannot load TLS credentials: %v", err) + return false, err + } + s.tlsConfig = tlsConfig + return true, nil + default: + return false, nil + } +} + // Stop attempts a graceful shutdown, waiting up to 5 seconds for active connections to finish func (s *BaseServer) Stop() error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) From ad3f570e324c1098d4f035466e77730e7df1b214 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:51:03 +0200 Subject: [PATCH 011/209] [management] validate the domain for the flock in proxy (#7501) --- proxy/internal/acme/locker.go | 16 ++++++++++++---- proxy/internal/acme/locker_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/proxy/internal/acme/locker.go b/proxy/internal/acme/locker.go index 2f0f18885..f42324736 100644 --- a/proxy/internal/acme/locker.go +++ b/proxy/internal/acme/locker.go @@ -2,12 +2,14 @@ package acme import ( "context" + "fmt" "path/filepath" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/proxy/internal/flock" "github.com/netbirdio/netbird/proxy/internal/k8s" + "github.com/netbirdio/netbird/shared/management/domain" ) // certLocker provides distributed mutual exclusion for certificate operations. @@ -74,9 +76,15 @@ func newFlockLocker(certDir string, logger *log.Logger) *flockLocker { return &flockLocker{certDir: certDir, logger: logger} } -// Lock acquires an advisory file lock for the given domain. -func (l *flockLocker) Lock(ctx context.Context, domain string) (func(), error) { - lockPath := filepath.Join(l.certDir, domain+".lock") +// Lock acquires an advisory file lock for the given domain. The domain must +// be a valid hostname so the lock file always resolves to a direct child of +// certDir; anything else is rejected before touching the filesystem. +func (l *flockLocker) Lock(ctx context.Context, name string) (func(), error) { + if !domain.IsValidDomainNoWildcard(name) { + return nil, fmt.Errorf("invalid domain %q for lock file", name) + } + + lockPath := filepath.Join(l.certDir, name+".lock") lockFile, err := flock.Lock(ctx, lockPath) if err != nil { return nil, err @@ -89,7 +97,7 @@ func (l *flockLocker) Lock(ctx context.Context, domain string) (func(), error) { return func() { if err := flock.Unlock(lockFile); err != nil { - l.logger.Debugf("release cert lock for domain %q: %v", domain, err) + l.logger.Debugf("release cert lock for domain %q: %v", name, err) } }, nil } diff --git a/proxy/internal/acme/locker_test.go b/proxy/internal/acme/locker_test.go index 39245df0c..f131f64f3 100644 --- a/proxy/internal/acme/locker_test.go +++ b/proxy/internal/acme/locker_test.go @@ -63,3 +63,33 @@ func TestNewCertLockerK8sFallsBackToFlock(t *testing.T) { _, ok := locker.(*flockLocker) assert.True(t, ok, "k8s-lease without SA should fall back to flockLocker") } + +func TestFlockLockerRejectsUnsafeDomain(t *testing.T) { + root := t.TempDir() + certDir := filepath.Join(root, "certs") + require.NoError(t, os.Mkdir(certDir, 0o700)) + locker := newFlockLocker(certDir, nil) + + for _, d := range []string{ + "", + ".", + "..", + "../escape", + "../../etc/cron.d/attacker", + "sub/dir.example.com", + `back\slash.example.com`, + "*.example.com", + } { + unlock, err := locker.Lock(context.Background(), d) + assert.Error(t, err, "domain %q", d) + assert.Nil(t, unlock, "domain %q", d) + } + + assert.NoFileExists(t, filepath.Join(root, "escape.lock")) + certEntries, err := os.ReadDir(certDir) + require.NoError(t, err) + assert.Empty(t, certEntries) + rootEntries, err := os.ReadDir(root) + require.NoError(t, err) + assert.Len(t, rootEntries, 1) +} From b57f0e56085bb57d05611b2c378913a2b9098c43 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Fri, 11 Sep 2026 14:20:58 +0200 Subject: [PATCH 012/209] [infrastructure] Preserve snapshot image variant tags (#7511) --- .github/workflows/release.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1bbe9c44..9d3fe3641 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -287,10 +287,15 @@ jobs: image_refs=() tag_and_push() { - local src="$1" img_name tag dst + local src="$1" img_name tag dst variant="" img_name="${src%%:*}" + # Client variants share a repository, so keep their tag suffixes. + case "$src" in + *-rootless-ubi-amd64) variant="-rootless-ubi" ;; + *-rootless-amd64) variant="-rootless" ;; + esac for tag in $(resolve_tags); do - dst="${img_name}:${tag}" + dst="${img_name}:${tag}${variant}" echo "Tagging ${src} -> ${dst}" docker tag "$src" "$dst" docker push "$dst" From 58114f98fb253ea3e4abf6eefe07d3d57b43e5d5 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Fri, 11 Sep 2026 15:41:52 +0300 Subject: [PATCH 013/209] [management] Only trust forwarded-IP headers from configured trusted peers (#7454) --- .../getting-started-enterprise.sh | 5 +- infrastructure_files/getting-started.sh | 29 +++ infrastructure_files/management.json.tmpl | 4 +- management/internals/server/boot.go | 56 ++++-- management/internals/server/realip_test.go | 171 ++++++++++++++++++ 5 files changed, 240 insertions(+), 25 deletions(-) create mode 100644 management/internals/server/realip_test.go diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index 3f7cf6357..701598a60 100755 --- a/infrastructure_files/getting-started-enterprise.sh +++ b/infrastructure_files/getting-started-enterprise.sh @@ -808,8 +808,9 @@ server: # Trust X-Forwarded-* only from the Traefik container's static address. Both # keys must stay in step with the ipv4_address pinned in docker-compose.yml: - # trustedPeers decides whether forwarded headers are read at all, and leaving - # it unset falls back to 0.0.0.0/0. + # trustedPeers decides whether forwarded headers are read at all. Leaving it + # unset trusts nothing and records Traefik's own address as every peer's + # connection IP. reverseProxy: trustedPeers: - "${TRAEFIK_IP}/32" diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index 5efc0181e..afbc5c282 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -153,6 +153,7 @@ check_domain_resolves() { # NETBIRD_TRAEFIK_CERTRESOLVER external-Traefik cert resolver (type 1) # NETBIRD_BIND_LOCALHOST_ONLY true/false (default true, types 2-5) # NETBIRD_EXTERNAL_PROXY_NETWORK docker network to join (types 2-4) +# NETBIRD_TRUSTED_PEERS reverse proxy address management sees (default: built-in Traefik's IP, empty for types 1-5) # NETBIRD_NON_INTERACTIVE true forces unattended mode even with a TTY # tty_available succeeds only when we may prompt: never when the operator has @@ -459,6 +460,8 @@ initialize_default_values() { MANAGEMENT_HOST_PORT="8081" # Combined server port (management + signal + relay) BIND_LOCALHOST_ONLY="true" EXTERNAL_PROXY_NETWORK="" + TRUSTED_PEERS="" # Address the reverse proxy connects to management from + # Traefik static IP within the internal bridge network TRAEFIK_IP="172.30.0.10" @@ -519,6 +522,7 @@ apply_agent_network_preset() { REVERSE_PROXY_TYPE="0" ENABLE_PROXY="true" ENABLE_CROWDSEC="false" + TRUSTED_PEERS="${NETBIRD_TRUSTED_PEERS:-$TRAEFIK_IP/32}" TRAEFIK_ACME_EMAIL=$(resolve NETBIRD_LETSENCRYPT_EMAIL required read_traefik_acme_email) @@ -573,6 +577,21 @@ configure_reverse_proxy() { 4) EXTERNAL_PROXY_NETWORK=$(resolve NETBIRD_EXTERNAL_PROXY_NETWORK "" read_proxy_docker_network "Caddy") ;; *) ;; # No network prompt for other options esac + + # Only the bundled Traefik has an address we know at render time. External proxies + # must supply the address their proxy reaches management from. + if [[ "$REVERSE_PROXY_TYPE" == "0" ]]; then + TRUSTED_PEERS="${NETBIRD_TRUSTED_PEERS:-$TRAEFIK_IP/32}" + else + TRUSTED_PEERS="${NETBIRD_TRUSTED_PEERS:-}" + if [[ -z "$TRUSTED_PEERS" ]]; then + echo "" > /dev/stderr + echo "Note: reverseProxy.trustedPeers is unset, so NetBird will use the address your" > /dev/stderr + echo "proxy connects from as each peer's connection IP. To record real client IPs," > /dev/stderr + echo "set NETBIRD_TRUSTED_PEERS to your proxy's address (e.g. 172.20.0.5/32) and re-run." > /dev/stderr + echo "" > /dev/stderr + fi + fi return 0 } @@ -1033,6 +1052,7 @@ server: reverseProxy: trustedHTTPProxies: - "$TRAEFIK_IP/32" +$(render_trusted_peers) store: engine: "sqlite" @@ -1041,6 +1061,12 @@ EOF return 0 } +render_trusted_peers() { + if [[ -n "$TRUSTED_PEERS" ]]; then + printf ' trustedPeers:\n - "%s"' "$TRUSTED_PEERS" + fi +} + render_dashboard_env() { cat < 0 && trustedProxiesCount > 0 { - log.WithContext(context.Background()).Warn("TrustedHTTPProxies and TrustedHTTPProxiesCount both are configured. " + - "This is not recommended way to extract X-Forwarded-For. Consider using one of these options.") - } - realipOpts := []realip.Option{ - realip.WithTrustedPeers(trustedPeers), - realip.WithTrustedProxies(trustedHTTPProxies), - realip.WithTrustedProxiesCount(trustedProxiesCount), - realip.WithHeaders([]string{realip.XForwardedFor, realip.XRealIp}), - } + realipOpts := realIPOptions(s.Config.ReverseProxy) proxyUnary, proxyStream, proxyAuthClose := nbgrpc.NewProxyAuthInterceptors(s.Store()) s.proxyAuthClose = proxyAuthClose gRPCOpts := []grpc.ServerOption{ @@ -333,7 +318,7 @@ func (s *BaseServer) AccessLogsManager() accesslogs.Manager { }) } -func loadTLSConfig(certFile string, certKey string) (*tls.Config, error) { +func loadTLSConfig(certFile, certKey string) (*tls.Config, error) { // Load server's certificate and private key serverCert, err := tls.LoadX509KeyPair(certFile, certKey) if err != nil { @@ -380,3 +365,34 @@ func streamInterceptor( wrapped.WrappedContext = context.WithValue(ctx, nbContext.RequestIDKey, reqID) return handler(srv, wrapped) } + +// realIPOptions builds the real-IP middleware options from the reverse proxy config. +// +// TrustedPeers controls which transport peers are allowed to supply forwarded-IP +// headers. If empty, forwarded headers are ignored and the transport peer address +// is used directly. Operators terminating connections at a reverse proxy should +// configure TrustedPeers with that proxy's address or network. +// +// Only X-Forwarded-For is trusted. X-Real-IP contains a single client-supplied +// address with no proxy chain to validate, and none of the reverse proxies we ship +// use it on the gRPC path. +func realIPOptions(cfg nbconfig.ReverseProxy) []realip.Option { + if idx := slices.IndexFunc(cfg.TrustedPeers, func(p netip.Prefix) bool { return p.Bits() == 0 }); idx >= 0 { + log.WithContext(context.Background()).Warnf("TrustedPeers contains the default route %s, which trusts "+ + "X-Forwarded-For from every client and allows connection IP spoofing. Set TrustedPeers to the address "+ + "of your reverse proxy, or leave it empty to use the connection's source address.", cfg.TrustedPeers[idx]) + } + if cfg.TrustedHTTPProxiesCount > 0 { + log.WithContext(context.Background()).Warn( + "TrustedHTTPProxiesCount skips X-Forwarded-For entries by position before TrustedHTTPProxies filters by address. " + + "An incorrect count may skip the real client IP and produce an incorrect source address.", + ) + } + + return []realip.Option{ + realip.WithTrustedPeers(cfg.TrustedPeers), + realip.WithTrustedProxies(cfg.TrustedHTTPProxies), + realip.WithTrustedProxiesCount(cfg.TrustedHTTPProxiesCount), + realip.WithHeaders([]string{realip.XForwardedFor}), + } +} diff --git a/management/internals/server/realip_test.go b/management/internals/server/realip_test.go new file mode 100644 index 000000000..89ac02730 --- /dev/null +++ b/management/internals/server/realip_test.go @@ -0,0 +1,171 @@ +package server + +import ( + "context" + "io" + "net" + "net/netip" + "testing" + "time" + + "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/types/known/emptypb" + + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" +) + +const ( + realIPProbeMethod = "/netbird.test.RealIPProbe/Probe" + realIPProbeStreamMethod = "/netbird.test.RealIPProbe/ProbeStream" +) + +// realIPProbe records the real IP the middleware derived for each call. +type realIPProbe struct { + got chan string +} + +func (p *realIPProbe) record(ctx context.Context) { + addr, _ := realip.FromContext(ctx) + p.got <- addr.String() +} + +func (p *realIPProbe) wait(t *testing.T) string { + t.Helper() + + select { + case got := <-p.got: + return got + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for probe") + return "" + } +} + +func startProbeServer(t *testing.T, cfg nbconfig.ReverseProxy) (*grpc.ClientConn, *realIPProbe) { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + probe := &realIPProbe{got: make(chan string, 1)} + opts := realIPOptions(cfg) + srv := grpc.NewServer( + grpc.ChainUnaryInterceptor(realip.UnaryServerInterceptorOpts(opts...)), + grpc.ChainStreamInterceptor(realip.StreamServerInterceptorOpts(opts...)), + ) + srv.RegisterService(&grpc.ServiceDesc{ + ServiceName: "netbird.test.RealIPProbe", + HandlerType: (*any)(nil), + Methods: []grpc.MethodDesc{{ + MethodName: "Probe", + Handler: func(_ any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + req := new(emptypb.Empty) + if err := dec(req); err != nil { + return nil, err + } + handler := func(ctx context.Context, _ any) (any, error) { + probe.record(ctx) + return &emptypb.Empty{}, nil + } + if interceptor == nil { + return handler(ctx, req) + } + return interceptor(ctx, req, &grpc.UnaryServerInfo{FullMethod: realIPProbeMethod}, handler) + }, + }}, + Streams: []grpc.StreamDesc{{ + StreamName: "ProbeStream", + ServerStreams: true, + Handler: func(_ any, stream grpc.ServerStream) error { + probe.record(stream.Context()) + return nil + }, + }}, + }, probe) + + go func() { _ = srv.Serve(listener) }() + t.Cleanup(srv.Stop) + + conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + return conn, probe +} + +func callUnary(t *testing.T, conn *grpc.ClientConn, probe *realIPProbe, kv ...string) string { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ctx = metadata.AppendToOutgoingContext(ctx, kv...) + require.NoError(t, conn.Invoke(ctx, realIPProbeMethod, &emptypb.Empty{}, &emptypb.Empty{})) + + return probe.wait(t) +} + +func callStream(t *testing.T, conn *grpc.ClientConn, probe *realIPProbe, kv ...string) string { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ctx = metadata.AppendToOutgoingContext(ctx, kv...) + desc := &grpc.StreamDesc{StreamName: "ProbeStream", ServerStreams: true} + stream, err := conn.NewStream(ctx, desc, realIPProbeStreamMethod) + require.NoError(t, err) + require.NoError(t, stream.CloseSend()) + require.ErrorIs(t, stream.RecvMsg(&emptypb.Empty{}), io.EOF) + + return probe.wait(t) +} + +func assertRealIP(t *testing.T, cfg nbconfig.ReverseProxy, want string, kv ...string) { + t.Helper() + + conn, probe := startProbeServer(t, cfg) + t.Run("unary", func(t *testing.T) { + assert.Equal(t, want, callUnary(t, conn, probe, kv...)) + }) + t.Run("stream", func(t *testing.T) { + assert.Equal(t, want, callStream(t, conn, probe, kv...)) + }) +} + +func TestRealIPDefaultIgnoresClientForwardedHeaders(t *testing.T) { + assertRealIP(t, nbconfig.ReverseProxy{}, "127.0.0.1", + realip.XForwardedFor, "203.0.113.44", + realip.XRealIp, "203.0.113.44", + ) +} + +func TestRealIPUntrustedPeerIgnoresForwardedHeaders(t *testing.T) { + cfg := nbconfig.ReverseProxy{TrustedPeers: []netip.Prefix{netip.MustParsePrefix("10.9.8.7/32")}} + + assertRealIP(t, cfg, "127.0.0.1", + realip.XForwardedFor, "203.0.113.44", + realip.XRealIp, "203.0.113.44", + ) +} + +func TestRealIPTrustedPeerHonoursForwardedHeaders(t *testing.T) { + cfg := nbconfig.ReverseProxy{TrustedPeers: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}} + + assertRealIP(t, cfg, "203.0.113.44", + realip.XForwardedFor, "203.0.113.44", + realip.XRealIp, "203.0.113.44", + ) +} + +func TestRealIPIgnoresXRealIPWhenProxyCountIsSet(t *testing.T) { + cfg := nbconfig.ReverseProxy{ + TrustedPeers: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}, + TrustedHTTPProxiesCount: 1, + } + + assertRealIP(t, cfg, "127.0.0.1", realip.XRealIp, "203.0.113.44") +} From 794956a7a313c8919fd92123fb06ff30b7b0032a Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 11 Sep 2026 16:21:10 +0200 Subject: [PATCH 014/209] [client] Fix relay instance address race (#7498) Read the relay instance URL and IP atomically to prevent reconnects from mixing values from different connections. Extend existing connection and offer/answer logs with relay URLs and IPs to help trace mismatched advertisements. --- client/internal/peer/handshaker.go | 8 +- shared/relay/client/client.go | 41 ++++---- shared/relay/client/client_serverip_test.go | 45 ++++----- shared/relay/client/manager.go | 6 +- shared/relay/client/manager_address_test.go | 103 ++++++++++++++++++++ shared/relay/client/picker.go | 7 +- 6 files changed, 157 insertions(+), 53 deletions(-) create mode 100644 shared/relay/client/manager_address_test.go diff --git a/client/internal/peer/handshaker.go b/client/internal/peer/handshaker.go index 6ecb2a947..654e32158 100644 --- a/client/internal/peer/handshaker.go +++ b/client/internal/peer/handshaker.go @@ -116,7 +116,7 @@ func (h *Handshaker) Listen(ctx context.Context) { for { select { case remoteOfferAnswer := <-h.remoteOffersCh: - h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials()) + h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP) // Record signaling received for reconnection attempts if h.metricsStages != nil { @@ -138,7 +138,7 @@ func (h *Handshaker) Listen(ctx context.Context) { continue } case remoteOfferAnswer := <-h.remoteAnswerCh: - h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials()) + h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP) // Record signaling received for reconnection attempts if h.metricsStages != nil { @@ -209,14 +209,14 @@ func (h *Handshaker) sendOffer() error { } offer := h.buildOfferAnswer() - h.log.Debugf("sending offer with serial: %s", offer.SessionIDString()) + h.log.Debugf("sending offer with serial: %s, relay server: %s, relay IP: %s", offer.SessionIDString(), offer.RelaySrvAddress, offer.RelaySrvIP) return h.signaler.SignalOffer(offer, h.config.Key) } func (h *Handshaker) sendAnswer() error { answer := h.buildOfferAnswer() - h.log.Debugf("sending answer with serial: %s", answer.SessionIDString()) + h.log.Debugf("sending answer with serial: %s, relay server: %s, relay IP: %s", answer.SessionIDString(), answer.RelaySrvAddress, answer.RelaySrvIP) return h.signaler.SignalAnswer(answer, h.config.Key) } diff --git a/shared/relay/client/client.go b/shared/relay/client/client.go index 38c9c7375..7171b40ad 100644 --- a/shared/relay/client/client.go +++ b/shared/relay/client/client.go @@ -279,7 +279,7 @@ func (c *Client) Connect(ctx context.Context) error { c.stateSubscription = NewPeersStateSubscription(c.log, c.relayConn, c.closeConnsByPeerID) c.log = c.log.WithField("relay", instanceURL.String()) - c.log.Infof("relay connection established") + c.log.Infof("relay connection established, server IP: %s", connectedIP(c.relayConn)) c.serviceIsRunning = true @@ -364,23 +364,6 @@ func (c *Client) ServerInstanceURL() (string, error) { return c.instanceURL.String(), nil } -// ConnectedIP returns the IP address of the live relay-server connection, -// extracted from the underlying socket's RemoteAddr. Zero value if not -// connected or if the address is not an IP literal. -func (c *Client) ConnectedIP() netip.Addr { - c.mu.Lock() - conn := c.relayConn - c.mu.Unlock() - if conn == nil { - return netip.Addr{} - } - addr := conn.RemoteAddr() - if addr == nil { - return netip.Addr{} - } - return extractIPLiteral(addr.String()) -} - // SetOnDisconnectListener sets a function that will be called when the connection to the relay server is closed. func (c *Client) SetOnDisconnectListener(fn func(string)) { c.listenerMutex.Lock() @@ -777,6 +760,17 @@ func (c *Client) listenForStopEvents(ctx context.Context, hc *healthcheck.Receiv } } +func (c *Client) serverInstanceAddress() (string, netip.Addr, error) { + c.mu.Lock() + defer c.mu.Unlock() + + addr, err := c.ServerInstanceURL() + if err != nil { + return "", netip.Addr{}, err + } + return addr, connectedIP(c.relayConn), nil +} + func (c *Client) closeAllConns() { for _, container := range c.conns { container.close() @@ -923,6 +917,17 @@ func (c *Client) handlePeersWentOfflineMsg(buf []byte) { c.stateSubscription.OnPeersWentOffline(peersID) } +func connectedIP(conn net.Conn) netip.Addr { + if conn == nil { + return netip.Addr{} + } + addr := conn.RemoteAddr() + if addr == nil { + return netip.Addr{} + } + return extractIPLiteral(addr.String()) +} + // extractIPLiteral returns the IP from address forms produced by the relay // dialers (URL or host:port). Zero value if the host is not an IP. func extractIPLiteral(s string) netip.Addr { diff --git a/shared/relay/client/client_serverip_test.go b/shared/relay/client/client_serverip_test.go index 7e699e37d..a52d434f7 100644 --- a/shared/relay/client/client_serverip_test.go +++ b/shared/relay/client/client_serverip_test.go @@ -8,6 +8,8 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" "github.com/netbirdio/netbird/client/iface" @@ -68,18 +70,17 @@ func TestClient_ServerIPRecoversFromUnresolvableFQDN(t *testing.T) { if !c.Ready() { t.Fatalf("client not ready after connect") } - if got := c.ConnectedIP(); got.String() != "127.0.0.1" { - t.Fatalf("ConnectedIP = %q, want 127.0.0.1", got) - } + url, ip, err := c.serverInstanceAddress() + require.NoError(t, err) + assert.Equal(t, srvCfg.ExposedAddress, url, "relay URL must come from the handshake") + assert.Equal(t, netip.MustParseAddr("127.0.0.1"), ip, "relay IP must come from the connection") }) } -// TestClient_ConnectedIPAfterFQDNDial verifies ConnectedIP returns the -// resolved IP after a successful FQDN-based dial. The underlying socket's -// RemoteAddr must be exposed through the dialer wrappers; if it returns -// the dial-time URL instead, ConnectedIP returns empty and the dial -// IP we advertise to peers is empty too. -func TestClient_ConnectedIPAfterFQDNDial(t *testing.T) { +// TestClient_ServerInstanceAddressAfterFQDNDial verifies the relay address +// includes the resolved IP after an FQDN dial. The dialer wrappers must expose +// the socket's RemoteAddr; returning the dial-time URL would lose the IP. +func TestClient_ServerInstanceAddressAfterFQDNDial(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -111,10 +112,10 @@ func TestClient_ConnectedIPAfterFQDNDial(t *testing.T) { } t.Cleanup(func() { _ = c.Close() }) - got := c.ConnectedIP().String() - if got != "127.0.0.1" && got != "::1" { - t.Fatalf("ConnectedIP after FQDN dial = %q, want 127.0.0.1 or ::1", got) - } + url, ip, err := c.serverInstanceAddress() + require.NoError(t, err) + assert.Equal(t, srvCfg.ExposedAddress, url, "relay URL must come from the handshake") + assert.Contains(t, []string{"127.0.0.1", "::1"}, ip.String(), "relay IP must resolve to localhost") } func TestSubstituteHost(t *testing.T) { @@ -214,15 +215,12 @@ func TestSubstituteHost(t *testing.T) { } } -func TestClient_ConnectedIPEmptyWhenNotConnected(t *testing.T) { - c := NewClient("rel://example.invalid:80", hmacTokenStore, "x", iface.DefaultMTU) - if got := c.ConnectedIP(); got.IsValid() { - t.Fatalf("ConnectedIP on disconnected client = %q, want zero", got) - } +func TestConnectedIPNilConnection(t *testing.T) { + assert.False(t, connectedIP(nil).IsValid(), "missing connection must not provide an IP") } // staticAddr is a net.Addr that returns a fixed string. Used to verify -// ConnectedIP parses RemoteAddr correctly. +// connectedIP parses RemoteAddr correctly. type staticAddr struct{ s string } func (a staticAddr) Network() string { return "tcp" } @@ -235,7 +233,7 @@ type stubConn struct { func (s stubConn) RemoteAddr() net.Addr { return s.remote } -func TestClient_ConnectedIPParsesRemoteAddr(t *testing.T) { +func TestConnectedIPParsesRemoteAddr(t *testing.T) { tests := []struct { name string s string @@ -252,15 +250,12 @@ func TestClient_ConnectedIPParsesRemoteAddr(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c := &Client{relayConn: stubConn{remote: staticAddr{s: tt.s}}} - got := c.ConnectedIP() + got := connectedIP(stubConn{remote: staticAddr{s: tt.s}}) var gotStr string if got.IsValid() { gotStr = got.String() } - if gotStr != tt.want { - t.Errorf("ConnectedIP(%q) = %q, want %q", tt.s, gotStr, tt.want) - } + assert.Equal(t, tt.want, gotStr, "IP extracted from RemoteAddr %q", tt.s) }) } } diff --git a/shared/relay/client/manager.go b/shared/relay/client/manager.go index 50fcc0b8f..367c6dfc5 100644 --- a/shared/relay/client/manager.go +++ b/shared/relay/client/manager.go @@ -256,11 +256,7 @@ func (m *Manager) RelayInstanceAddress() (string, netip.Addr, error) { if m.relayClient == nil { return "", netip.Addr{}, ErrRelayClientNotConnected } - addr, err := m.relayClient.ServerInstanceURL() - if err != nil { - return "", netip.Addr{}, err - } - return addr, m.relayClient.ConnectedIP(), nil + return m.relayClient.serverInstanceAddress() } // ServerURLs returns the addresses of the relay servers. diff --git a/shared/relay/client/manager_address_test.go b/shared/relay/client/manager_address_test.go new file mode 100644 index 000000000..4f669e60d --- /dev/null +++ b/shared/relay/client/manager_address_test.go @@ -0,0 +1,103 @@ +package client + +import ( + "net/netip" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManager_RelayInstanceAddressAcrossReconnect(t *testing.T) { + relays := []struct { + url *RelayAddr + conn stubConn + ip netip.Addr + }{ + { + url: &RelayAddr{addr: "rels://relay-a.example:443"}, + conn: stubConn{remote: staticAddr{s: "192.0.2.1:443"}}, + ip: netip.MustParseAddr("192.0.2.1"), + }, + { + url: &RelayAddr{addr: "rels://relay-b.example:443"}, + conn: stubConn{remote: staticAddr{s: "192.0.2.2:443"}}, + ip: netip.MustParseAddr("192.0.2.2"), + }, + } + c := &Client{ + instanceURL: relays[0].url, + relayConn: relays[0].conn, + serviceIsRunning: true, + } + m := &Manager{relayClient: c} + started := make(chan struct{}) + stop := make(chan struct{}) + done := make(chan struct{}) + t.Cleanup(func() { + close(stop) + <-done + }) + go func() { + defer close(done) + for i := 0; ; i++ { + select { + case <-stop: + return + default: + } + // Publish successive connection states using the lifecycle locks. + // Yield before publication so a getter using only muInstanceURL + // can read the old URL while waiting for the new connection's IP. + c.mu.Lock() + runtime.Gosched() + relay := relays[i%len(relays)] + c.muInstanceURL.Lock() + c.instanceURL = relay.url + c.muInstanceURL.Unlock() + c.relayConn = relay.conn + c.mu.Unlock() + if i == 0 { + close(started) + } + } + }() + <-started + + for range 1000 { + url, ip, err := m.RelayInstanceAddress() + require.NoError(t, err) + wantIP := relays[0].ip + if url == relays[1].url.String() { + wantIP = relays[1].ip + } + if !assert.Equal(t, wantIP, ip, "advertised IP must belong to relay %s", url) { + return + } + } +} + +func TestManager_RelayInstanceAddressDisconnected(t *testing.T) { + for _, tt := range []struct { + name string + client *Client + }{ + {name: "no client"}, + {name: "not connected", client: &Client{}}, + { + name: "closed connection", + client: &Client{ + relayConn: stubConn{remote: staticAddr{s: "192.0.2.1:443"}}, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + m := &Manager{relayClient: tt.client} + url, ip, err := m.RelayInstanceAddress() + assert.Error(t, err) + assert.Empty(t, url, "disconnected relay must not advertise a URL") + assert.False(t, ip.IsValid(), "disconnected relay must not advertise a stale IP") + }) + } +} diff --git a/shared/relay/client/picker.go b/shared/relay/client/picker.go index 17b1390b1..fc1d8c1cb 100644 --- a/shared/relay/client/picker.go +++ b/shared/relay/client/picker.go @@ -63,7 +63,12 @@ func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) { if !ok { return nil, <-errChan } - log.Infof("chosen home Relay server: %s", cr.Url) + instanceURL, serverIP, err := cr.RelayClient.serverInstanceAddress() + if err != nil { + log.Infof("chosen home Relay server: %s, instance address unavailable: %v", cr.Url, err) + return cr.RelayClient, nil + } + log.Infof("chosen home Relay server: %s, instance URL: %s, server IP: %s", cr.Url, instanceURL, serverIP) return cr.RelayClient, nil case <-ctx.Done(): return nil, fmt.Errorf("connect to relay server: %w", ctx.Err()) From f422c4165437679a1470893e7917a8ef597a2277 Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Fri, 11 Sep 2026 17:07:26 +0200 Subject: [PATCH 015/209] [management] extract peer update logic and wrap it in tests (#7338) * extract peer update loop into a dedicated struct and wrap it in tests Signed-off-by: Dmitri Dolguikh * make linter happy Signed-off-by: Dmitri Dolguikh --------- Signed-off-by: Dmitri Dolguikh --- encryption/message.go | 10 ++ .../shared/grpc/peer_update_handler.go | 135 +++++++++++++++ .../shared/grpc/peer_update_handler_test.go | 155 ++++++++++++++++++ management/internals/shared/grpc/server.go | 88 +--------- .../internals/shared/grpc/sync_sender_mock.go | 70 ++++++++ management/internals/shared/grpc/token_mgr.go | 2 + .../internals/shared/grpc/token_mgr_mock.go | 111 +++++++++++++ .../internals/shared/grpc/update_debouncer.go | 8 + .../shared/grpc/update_debouncer_mock.go | 96 +++++++++++ 9 files changed, 589 insertions(+), 86 deletions(-) create mode 100644 management/internals/shared/grpc/peer_update_handler.go create mode 100644 management/internals/shared/grpc/peer_update_handler_test.go create mode 100644 management/internals/shared/grpc/sync_sender_mock.go create mode 100644 management/internals/shared/grpc/token_mgr_mock.go create mode 100644 management/internals/shared/grpc/update_debouncer_mock.go diff --git a/encryption/message.go b/encryption/message.go index 6e4cd7391..2bf2c59dc 100644 --- a/encryption/message.go +++ b/encryption/message.go @@ -6,6 +6,16 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) +type Encrypter interface { + EncryptMessage(remotePubKey wgtypes.Key, ourPrivateKey wgtypes.Key, message pb.Message) ([]byte, error) +} + +type DefaultEncrypter struct{} + +func (e DefaultEncrypter) EncryptMessage(remotePubKey wgtypes.Key, ourPrivateKey wgtypes.Key, message pb.Message) ([]byte, error) { + return EncryptMessage(remotePubKey, ourPrivateKey, message) +} + // EncryptMessage encrypts a body of the given protobuf Message func EncryptMessage(remotePubKey wgtypes.Key, ourPrivateKey wgtypes.Key, message pb.Message) ([]byte, error) { byteResp, err := pb.Marshal(message) diff --git a/management/internals/shared/grpc/peer_update_handler.go b/management/internals/shared/grpc/peer_update_handler.go new file mode 100644 index 000000000..d2f403841 --- /dev/null +++ b/management/internals/shared/grpc/peer_update_handler.go @@ -0,0 +1,135 @@ +package grpc + +import ( + "context" + "time" + + "github.com/netbirdio/netbird/encryption" + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/shared/management/proto" + log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func PeerUpdateHandlerFactory( + peerKey wgtypes.Key, + updates chan *network_map.UpdateMessage, + secretsManager SecretsManager, + srv proto.ManagementService_SyncServer, + cleanupfunc func()) *PeerUpdateHandler { + return &PeerUpdateHandler{ + peerKey: peerKey, + updates: updates, + secretsManager: secretsManager, + srv: srv, + encrypter: encryption.DefaultEncrypter{}, + debouncer: NewUpdateDebouncer(1000 * time.Millisecond), + cleanupFunc: cleanupfunc, + } +} + +// PeerUpdateHandler sends updates to the connected peer until the updates channel is closed. +// It implements a backpressure mechanism that sends the first update immediately, +// then debounces subsequent rapid updates, ensuring only the latest update is sent +// after a quiet period. +type PeerUpdateHandler struct { + peerKey wgtypes.Key + updates chan *network_map.UpdateMessage + appMetrics telemetry.AppMetrics + secretsManager SecretsManager + srv syncSender + encrypter encryption.Encrypter + debouncer Debouncer + cleanupFunc func() +} + +func (pu *PeerUpdateHandler) WithMetrics(appMetrics telemetry.AppMetrics) *PeerUpdateHandler { + pu.appMetrics = appMetrics + return pu +} + +//go:generate go tool mockgen -source=./peer_update_handler.go -destination=./sync_sender_mock.go -package=grpc +type syncSender interface { + Send(*proto.EncryptedMessage) error + Context() context.Context +} + +func (pu *PeerUpdateHandler) HandleUpdates(ctx context.Context) error { + log.WithContext(ctx).Tracef("starting to handle updates for peer %s", pu.peerKey.String()) + + defer pu.debouncer.Stop() + + for { + select { + // condition when there are some updates + // todo set the updates channel size to 1 + case update, open := <-pu.updates: + if pu.appMetrics != nil { + pu.appMetrics.GRPCMetrics().UpdateChannelQueueLength(len(pu.updates) + 1) + } + + if !open { + log.WithContext(ctx).Debugf("updates channel for peer %s was closed", pu.peerKey.String()) + pu.cleanupFunc() + return nil + } + + log.WithContext(ctx).Tracef("received an update for peer %s", pu.peerKey.String()) + if pu.debouncer.ProcessUpdate(update) { + // Send immediately (first update or after quiet period) + if err := pu.SendUpdate(ctx, update); err != nil { + log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", pu.peerKey.String(), err) + return err + } + } + + // Timer expired - quiet period reached, send pending updates if any + case <-pu.debouncer.TimerChannel(): + pendingUpdates := pu.debouncer.GetPendingUpdates() + if len(pendingUpdates) == 0 { + continue + } + log.WithContext(ctx).Debugf("sending %d debounced update(s) for peer %s", len(pendingUpdates), pu.peerKey.String()) + for _, pendingUpdate := range pendingUpdates { + if err := pu.SendUpdate(ctx, pendingUpdate); err != nil { + log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", pu.peerKey.String(), err) + return err + } + } + + // condition when client <-> server connection has been terminated + case <-pu.srv.Context().Done(): + // happens when connection drops, e.g. client disconnects + log.WithContext(ctx).Debugf("stream of peer %s has been closed", pu.peerKey.String()) + pu.cleanupFunc() + return pu.srv.Context().Err() + } + } +} + +func (pu *PeerUpdateHandler) SendUpdate(ctx context.Context, update *network_map.UpdateMessage) error { + key, err := pu.secretsManager.GetWGKey() + if err != nil { + pu.cleanupFunc() + return status.Errorf(codes.Internal, "failed processing update message") + } + + encryptedResp, err := pu.encrypter.EncryptMessage(pu.peerKey, key, update.Update) + if err != nil { + pu.cleanupFunc() + return status.Errorf(codes.Internal, "failed processing update message") + } + err = pu.srv.Send(&proto.EncryptedMessage{ + WgPubKey: key.PublicKey().String(), + Body: encryptedResp, + }) + if err != nil { + pu.cleanupFunc() + return status.Errorf(codes.Internal, "failed sending update message") + } + log.WithContext(ctx).Tracef("sent an update to peer %s", pu.peerKey.String()) + return nil +} diff --git a/management/internals/shared/grpc/peer_update_handler_test.go b/management/internals/shared/grpc/peer_update_handler_test.go new file mode 100644 index 000000000..02de49c47 --- /dev/null +++ b/management/internals/shared/grpc/peer_update_handler_test.go @@ -0,0 +1,155 @@ +package grpc + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + pb "github.com/golang/protobuf/proto" //nolint + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +func TestSendPeerUpdates_FirstUpdate(t *testing.T) { + ctrl := gomock.NewController(t) + secretsManager := NewMockSecretsManager(ctrl) + updateDebouncer := NewMockDebouncer(ctrl) + syncSender := NewMocksyncSender(ctrl) + + pu := PeerUpdateHandler{ + peerKey: mustGenerateKey(t), + updates: make(chan *network_map.UpdateMessage), + secretsManager: secretsManager, + encrypter: testEncrypter{}, + debouncer: updateDebouncer, + srv: syncSender, + cleanupFunc: func() {}, + } + + msg := network_map.UpdateMessage{ + Update: &proto.SyncResponse{Version: 1}, + } + + timeCh := make(chan time.Time) + srvCtx := context.TODO() + srvKey := mustGenerateKey(t) + // mock a first update, should send it right away + updateDebouncer.EXPECT().ProcessUpdate(gomock.Eq(&msg)).Return(true) + updateDebouncer.EXPECT().TimerChannel().AnyTimes().Return(timeCh) + syncSender.EXPECT().Context().AnyTimes().Return(srvCtx) + secretsManager.EXPECT().GetWGKey().Return(srvKey, nil) + syncSender.EXPECT().Send(pbMatcher{x: &proto.EncryptedMessage{WgPubKey: srvKey.PublicKey().String(), Body: mustMarshal(t, &msg)}}) + updateDebouncer.EXPECT().Stop() + + var wg sync.WaitGroup + wg.Go(func() { pu.HandleUpdates(context.TODO()) }) //nolint:errcheck + pu.updates <- &msg + close(pu.updates) + wg.Wait() +} + +func TestSendPeerUpdates_TimerUpdate(t *testing.T) { + ctrl := gomock.NewController(t) + secretsManager := NewMockSecretsManager(ctrl) + updateDebouncer := NewMockDebouncer(ctrl) + syncSender := NewMocksyncSender(ctrl) + + pu := PeerUpdateHandler{ + peerKey: mustGenerateKey(t), + updates: make(chan *network_map.UpdateMessage), + secretsManager: secretsManager, + encrypter: testEncrypter{}, + debouncer: updateDebouncer, + srv: syncSender, + cleanupFunc: func() {}, + } + + msg := network_map.UpdateMessage{ + Update: &proto.SyncResponse{Version: 1}, + } + + timeCh := make(chan time.Time) + srvCtx := context.TODO() + srvKey := mustGenerateKey(t) + updateDebouncer.EXPECT().GetPendingUpdates().Return([]*network_map.UpdateMessage{&msg}) + updateDebouncer.EXPECT().TimerChannel().AnyTimes().Return(timeCh) + syncSender.EXPECT().Context().AnyTimes().Return(srvCtx) + secretsManager.EXPECT().GetWGKey().Return(srvKey, nil) + syncSender.EXPECT().Send(pbMatcher{x: &proto.EncryptedMessage{WgPubKey: srvKey.PublicKey().String(), Body: mustMarshal(t, &msg)}}) + updateDebouncer.EXPECT().Stop() + + var wg sync.WaitGroup + wg.Go(func() { pu.HandleUpdates(context.TODO()) }) //nolint:errcheck + timeCh <- time.Now() + close(pu.updates) + wg.Wait() +} + +func TestSendPeerUpdates_ServerContextDone(t *testing.T) { + ctrl := gomock.NewController(t) + secretsManager := NewMockSecretsManager(ctrl) + updateDebouncer := NewMockDebouncer(ctrl) + syncSender := NewMocksyncSender(ctrl) + + pu := PeerUpdateHandler{ + peerKey: mustGenerateKey(t), + updates: make(chan *network_map.UpdateMessage), + secretsManager: secretsManager, + encrypter: testEncrypter{}, + debouncer: updateDebouncer, + srv: syncSender, + cleanupFunc: func() {}, + } + + timeCh := make(chan time.Time) + srvCtx, cancel := context.WithCancel(context.TODO()) + updateDebouncer.EXPECT().TimerChannel().AnyTimes().Return(timeCh) + syncSender.EXPECT().Context().AnyTimes().Return(srvCtx) + updateDebouncer.EXPECT().Stop() + + var wg sync.WaitGroup + wg.Go(func() { pu.HandleUpdates(context.TODO()) }) //nolint:errcheck + cancel() + wg.Wait() +} + +func mustGenerateKey(t *testing.T) wgtypes.Key { + t.Helper() + k, err := wgtypes.GenerateKey() + assert.NoError(t, err) + return k +} + +func mustMarshal(t *testing.T, msg *network_map.UpdateMessage) []byte { + t.Helper() + r, err := pb.Marshal(msg.Update) + assert.NoError(t, err) + return r +} + +type testEncrypter struct{} + +func (testEncrypter) EncryptMessage(remotePubKey wgtypes.Key, ourPrivateKey wgtypes.Key, message pb.Message) ([]byte, error) { + return pb.Marshal(message) +} + +type pbMatcher struct { + x pb.Message +} + +func (pbm pbMatcher) Matches(x any) bool { + msg, ok := x.(pb.Message) + if !ok { + return false + } + return pb.Equal(pbm.x, msg) +} + +func (pbm pbMatcher) String() string { + return fmt.Sprintf("is equal to %s (%T)", pbm.x, pbm.x) +} diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index a9cc0ad36..c178b6fa1 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -337,7 +337,8 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S s.syncSem.Add(-1) - return s.handleUpdates(ctx, accountID, peerKey, peer, updates, srv, syncStart) + return PeerUpdateHandlerFactory(peerKey, updates, s.secretsManager, srv, func() { s.cancelPeerRoutines(ctx, accountID, peer, syncStart) }). + WithMetrics(s.appMetrics).HandleUpdates(ctx) } func (s *Server) handleHandshake(ctx context.Context, srv proto.ManagementService_JobServer) (wgtypes.Key, error) { @@ -404,91 +405,6 @@ func (s *Server) sendJobsLoop(ctx context.Context, accountID string, peerKey wgt } } -// handleUpdates sends updates to the connected peer until the updates channel is closed. -// It implements a backpressure mechanism that sends the first update immediately, -// then debounces subsequent rapid updates, ensuring only the latest update is sent -// after a quiet period. -func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, updates chan *network_map.UpdateMessage, srv proto.ManagementService_SyncServer, streamStartTime time.Time) error { - log.WithContext(ctx).Tracef("starting to handle updates for peer %s", peerKey.String()) - - // Create a debouncer for this peer connection - debouncer := NewUpdateDebouncer(1000 * time.Millisecond) - defer debouncer.Stop() - - for { - select { - // condition when there are some updates - // todo set the updates channel size to 1 - case update, open := <-updates: - if s.appMetrics != nil { - s.appMetrics.GRPCMetrics().UpdateChannelQueueLength(len(updates) + 1) - } - - if !open { - log.WithContext(ctx).Debugf("updates channel for peer %s was closed", peerKey.String()) - s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) - return nil - } - - log.WithContext(ctx).Tracef("received an update for peer %s", peerKey.String()) - if debouncer.ProcessUpdate(update) { - // Send immediately (first update or after quiet period) - if err := s.sendUpdate(ctx, accountID, peerKey, peer, update, srv, streamStartTime); err != nil { - log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", peerKey.String(), err) - return err - } - } - - // Timer expired - quiet period reached, send pending updates if any - case <-debouncer.TimerChannel(): - pendingUpdates := debouncer.GetPendingUpdates() - if len(pendingUpdates) == 0 { - continue - } - log.WithContext(ctx).Debugf("sending %d debounced update(s) for peer %s", len(pendingUpdates), peerKey.String()) - for _, pendingUpdate := range pendingUpdates { - if err := s.sendUpdate(ctx, accountID, peerKey, peer, pendingUpdate, srv, streamStartTime); err != nil { - log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", peerKey.String(), err) - return err - } - } - - // condition when client <-> server connection has been terminated - case <-srv.Context().Done(): - // happens when connection drops, e.g. client disconnects - log.WithContext(ctx).Debugf("stream of peer %s has been closed", peerKey.String()) - s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) - return srv.Context().Err() - } - } -} - -// sendUpdate encrypts the update message using the peer key and the server's wireguard key, -// then sends the encrypted message to the connected peer via the sync server. -func (s *Server) sendUpdate(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, update *network_map.UpdateMessage, srv proto.ManagementService_SyncServer, streamStartTime time.Time) error { - key, err := s.secretsManager.GetWGKey() - if err != nil { - s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) - return status.Errorf(codes.Internal, "failed processing update message") - } - - encryptedResp, err := encryption.EncryptMessage(peerKey, key, update.Update) - if err != nil { - s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) - return status.Errorf(codes.Internal, "failed processing update message") - } - err = srv.Send(&proto.EncryptedMessage{ - WgPubKey: key.PublicKey().String(), - Body: encryptedResp, - }) - if err != nil { - s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) - return status.Errorf(codes.Internal, "failed sending update message") - } - log.WithContext(ctx).Tracef("sent an update to peer %s", peerKey.String()) - return nil -} - // sendJob encrypts the update message using the peer key and the server's wireguard key, // then sends the encrypted message to the connected peer via the sync server. func (s *Server) sendJob(ctx context.Context, peerKey wgtypes.Key, job *job.Event, srv proto.ManagementService_JobServer) error { diff --git a/management/internals/shared/grpc/sync_sender_mock.go b/management/internals/shared/grpc/sync_sender_mock.go new file mode 100644 index 000000000..3d1696f59 --- /dev/null +++ b/management/internals/shared/grpc/sync_sender_mock.go @@ -0,0 +1,70 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./peer_update_handler.go +// +// Generated by this command: +// +// mockgen -source=./peer_update_handler.go -destination=./sync_sender_mock.go -package=grpc +// + +// Package grpc is a generated GoMock package. +package grpc + +import ( + context "context" + reflect "reflect" + + proto "github.com/netbirdio/netbird/shared/management/proto" + gomock "go.uber.org/mock/gomock" +) + +// MocksyncSender is a mock of syncSender interface. +type MocksyncSender struct { + ctrl *gomock.Controller + recorder *MocksyncSenderMockRecorder + isgomock struct{} +} + +// MocksyncSenderMockRecorder is the mock recorder for MocksyncSender. +type MocksyncSenderMockRecorder struct { + mock *MocksyncSender +} + +// NewMocksyncSender creates a new mock instance. +func NewMocksyncSender(ctrl *gomock.Controller) *MocksyncSender { + mock := &MocksyncSender{ctrl: ctrl} + mock.recorder = &MocksyncSenderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MocksyncSender) EXPECT() *MocksyncSenderMockRecorder { + return m.recorder +} + +// Context mocks base method. +func (m *MocksyncSender) Context() context.Context { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Context") + ret0, _ := ret[0].(context.Context) + return ret0 +} + +// Context indicates an expected call of Context. +func (mr *MocksyncSenderMockRecorder) Context() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Context", reflect.TypeOf((*MocksyncSender)(nil).Context)) +} + +// Send mocks base method. +func (m *MocksyncSender) Send(arg0 *proto.EncryptedMessage) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Send", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Send indicates an expected call of Send. +func (mr *MocksyncSenderMockRecorder) Send(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Send", reflect.TypeOf((*MocksyncSender)(nil).Send), arg0) +} diff --git a/management/internals/shared/grpc/token_mgr.go b/management/internals/shared/grpc/token_mgr.go index fb2d83a9a..4dab5007f 100644 --- a/management/internals/shared/grpc/token_mgr.go +++ b/management/internals/shared/grpc/token_mgr.go @@ -25,6 +25,8 @@ import ( const defaultDuration = 12 * time.Hour // SecretsManager used to manage TURN and relay secrets +// +//go:generate go tool mockgen -source=./token_mgr.go -destination=./token_mgr_mock.go -package=grpc type SecretsManager interface { GenerateTurnToken() (*Token, error) GenerateRelayToken() (*Token, error) diff --git a/management/internals/shared/grpc/token_mgr_mock.go b/management/internals/shared/grpc/token_mgr_mock.go new file mode 100644 index 000000000..e7dde4e3a --- /dev/null +++ b/management/internals/shared/grpc/token_mgr_mock.go @@ -0,0 +1,111 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./token_mgr.go +// +// Generated by this command: +// +// mockgen -source=./token_mgr.go -destination=./token_mgr_mock.go -package=grpc +// + +// Package grpc is a generated GoMock package. +package grpc + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" + wgtypes "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// MockSecretsManager is a mock of SecretsManager interface. +type MockSecretsManager struct { + ctrl *gomock.Controller + recorder *MockSecretsManagerMockRecorder + isgomock struct{} +} + +// MockSecretsManagerMockRecorder is the mock recorder for MockSecretsManager. +type MockSecretsManagerMockRecorder struct { + mock *MockSecretsManager +} + +// NewMockSecretsManager creates a new mock instance. +func NewMockSecretsManager(ctrl *gomock.Controller) *MockSecretsManager { + mock := &MockSecretsManager{ctrl: ctrl} + mock.recorder = &MockSecretsManagerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSecretsManager) EXPECT() *MockSecretsManagerMockRecorder { + return m.recorder +} + +// CancelRefresh mocks base method. +func (m *MockSecretsManager) CancelRefresh(peerKey string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "CancelRefresh", peerKey) +} + +// CancelRefresh indicates an expected call of CancelRefresh. +func (mr *MockSecretsManagerMockRecorder) CancelRefresh(peerKey any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CancelRefresh", reflect.TypeOf((*MockSecretsManager)(nil).CancelRefresh), peerKey) +} + +// GenerateRelayToken mocks base method. +func (m *MockSecretsManager) GenerateRelayToken() (*Token, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GenerateRelayToken") + ret0, _ := ret[0].(*Token) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GenerateRelayToken indicates an expected call of GenerateRelayToken. +func (mr *MockSecretsManagerMockRecorder) GenerateRelayToken() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateRelayToken", reflect.TypeOf((*MockSecretsManager)(nil).GenerateRelayToken)) +} + +// GenerateTurnToken mocks base method. +func (m *MockSecretsManager) GenerateTurnToken() (*Token, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GenerateTurnToken") + ret0, _ := ret[0].(*Token) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GenerateTurnToken indicates an expected call of GenerateTurnToken. +func (mr *MockSecretsManagerMockRecorder) GenerateTurnToken() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateTurnToken", reflect.TypeOf((*MockSecretsManager)(nil).GenerateTurnToken)) +} + +// GetWGKey mocks base method. +func (m *MockSecretsManager) GetWGKey() (wgtypes.Key, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWGKey") + ret0, _ := ret[0].(wgtypes.Key) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWGKey indicates an expected call of GetWGKey. +func (mr *MockSecretsManagerMockRecorder) GetWGKey() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWGKey", reflect.TypeOf((*MockSecretsManager)(nil).GetWGKey)) +} + +// SetupRefresh mocks base method. +func (m *MockSecretsManager) SetupRefresh(ctx context.Context, accountID, peerKey string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetupRefresh", ctx, accountID, peerKey) +} + +// SetupRefresh indicates an expected call of SetupRefresh. +func (mr *MockSecretsManagerMockRecorder) SetupRefresh(ctx, accountID, peerKey any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetupRefresh", reflect.TypeOf((*MockSecretsManager)(nil).SetupRefresh), ctx, accountID, peerKey) +} diff --git a/management/internals/shared/grpc/update_debouncer.go b/management/internals/shared/grpc/update_debouncer.go index 8af9c2656..9483007c6 100644 --- a/management/internals/shared/grpc/update_debouncer.go +++ b/management/internals/shared/grpc/update_debouncer.go @@ -6,6 +6,14 @@ import ( "github.com/netbirdio/netbird/management/internals/controllers/network_map" ) +//go:generate go tool mockgen -source=./update_debouncer.go -destination=./update_debouncer_mock.go -package=grpc +type Debouncer interface { + Stop() + TimerChannel() <-chan time.Time + ProcessUpdate(update *network_map.UpdateMessage) bool + GetPendingUpdates() []*network_map.UpdateMessage +} + // UpdateDebouncer implements a backpressure mechanism that: // - Sends the first update immediately // - Coalesces rapid subsequent network map updates (only latest matters) diff --git a/management/internals/shared/grpc/update_debouncer_mock.go b/management/internals/shared/grpc/update_debouncer_mock.go new file mode 100644 index 000000000..4dff632b7 --- /dev/null +++ b/management/internals/shared/grpc/update_debouncer_mock.go @@ -0,0 +1,96 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./update_debouncer.go +// +// Generated by this command: +// +// mockgen -source=./update_debouncer.go -destination=./update_debouncer_mock.go -package=grpc +// + +// Package grpc is a generated GoMock package. +package grpc + +import ( + reflect "reflect" + time "time" + + network_map "github.com/netbirdio/netbird/management/internals/controllers/network_map" + gomock "go.uber.org/mock/gomock" +) + +// MockDebouncer is a mock of Debouncer interface. +type MockDebouncer struct { + ctrl *gomock.Controller + recorder *MockDebouncerMockRecorder + isgomock struct{} +} + +// MockDebouncerMockRecorder is the mock recorder for MockDebouncer. +type MockDebouncerMockRecorder struct { + mock *MockDebouncer +} + +// NewMockDebouncer creates a new mock instance. +func NewMockDebouncer(ctrl *gomock.Controller) *MockDebouncer { + mock := &MockDebouncer{ctrl: ctrl} + mock.recorder = &MockDebouncerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDebouncer) EXPECT() *MockDebouncerMockRecorder { + return m.recorder +} + +// GetPendingUpdates mocks base method. +func (m *MockDebouncer) GetPendingUpdates() []*network_map.UpdateMessage { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPendingUpdates") + ret0, _ := ret[0].([]*network_map.UpdateMessage) + return ret0 +} + +// GetPendingUpdates indicates an expected call of GetPendingUpdates. +func (mr *MockDebouncerMockRecorder) GetPendingUpdates() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPendingUpdates", reflect.TypeOf((*MockDebouncer)(nil).GetPendingUpdates)) +} + +// ProcessUpdate mocks base method. +func (m *MockDebouncer) ProcessUpdate(update *network_map.UpdateMessage) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ProcessUpdate", update) + ret0, _ := ret[0].(bool) + return ret0 +} + +// ProcessUpdate indicates an expected call of ProcessUpdate. +func (mr *MockDebouncerMockRecorder) ProcessUpdate(update any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProcessUpdate", reflect.TypeOf((*MockDebouncer)(nil).ProcessUpdate), update) +} + +// Stop mocks base method. +func (m *MockDebouncer) Stop() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Stop") +} + +// Stop indicates an expected call of Stop. +func (mr *MockDebouncerMockRecorder) Stop() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockDebouncer)(nil).Stop)) +} + +// TimerChannel mocks base method. +func (m *MockDebouncer) TimerChannel() <-chan time.Time { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TimerChannel") + ret0, _ := ret[0].(<-chan time.Time) + return ret0 +} + +// TimerChannel indicates an expected call of TimerChannel. +func (mr *MockDebouncerMockRecorder) TimerChannel() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TimerChannel", reflect.TypeOf((*MockDebouncer)(nil).TimerChannel)) +} From ec0c36b0e7b34fd17948ff3fcd7c77d50104750e Mon Sep 17 00:00:00 2001 From: Brandon Hopkins <76761586+TechHutTV@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:25:10 -0700 Subject: [PATCH 016/209] [client] Add light mode with system, light, and dark theme options (#7344) * desktop UI light mode * Theme review fixes plus macOS window outline fix * Windows runtime chrome re-theming plus apply serialization * Windows chrome threading and theme event ordering fixes * Darken toggle and setting sidebar text * resolve theme appearance, apply on UI thread * read theme once per window * Re-assert Windows dark opt-in after SetTheme * split app-wide GTK theming from per-window chrome * Update Wails dependency and checksums * KDE tray icon panel fix * Five review fixes: theme ordering, cgo dedup, KDE panel resolution * Path guard hardening, toggle contrast, windows comment * non-vacuous escape tests * Default view edits * Polish settings nav, controls, borders, and disc * Profiles settings boarder, modals, and buttons * Additional edits based on feedback * Switch colors away from slight blue hue * Update missing lang * Fix vertical tab active view --- client/ui/frontend/index.html | 18 + client/ui/frontend/src/app.tsx | 57 +-- .../src/assets/logos/netbird-full-light.svg | 19 + client/ui/frontend/src/components/Badge.tsx | 12 +- .../src/components/CopyToClipboard.tsx | 2 +- .../frontend/src/components/DropdownMenu.tsx | 2 +- .../src/components/LanguagePicker.tsx | 6 +- .../ui/frontend/src/components/SquareIcon.tsx | 4 +- .../frontend/src/components/ThemePicker.tsx | 109 ++++++ client/ui/frontend/src/components/Tooltip.tsx | 4 +- .../frontend/src/components/VerticalTabs.tsx | 14 +- .../src/components/buttons/Button.tsx | 49 +-- .../src/components/buttons/IconButton.tsx | 2 +- .../frontend/src/components/dialog/Dialog.tsx | 4 +- .../frontend/src/components/inputs/Input.tsx | 18 +- .../src/components/inputs/SearchInput.tsx | 2 +- .../components/switches/FancyToggleSwitch.tsx | 6 +- .../src/components/switches/SwitchItem.tsx | 6 +- .../components/switches/SwitchItemGroup.tsx | 2 +- .../src/components/switches/ToggleSwitch.tsx | 6 +- .../src/components/typography/HelpText.tsx | 2 +- .../ui/frontend/src/contexts/ThemeContext.tsx | 129 +++++++ client/ui/frontend/src/globals.css | 65 +++- .../ui/frontend/src/layouts/AppRightPanel.tsx | 2 +- client/ui/frontend/src/lib/formatters.ts | 4 +- .../main/MainConnectionStatusSwitch.tsx | 13 +- .../src/modules/main/MainExitNodeSwitcher.tsx | 6 +- .../frontend/src/modules/main/MainHeader.tsx | 4 +- .../src/modules/main/advanced/Navigation.tsx | 2 +- .../main/advanced/networks/NetworkFilters.tsx | 2 +- .../main/advanced/networks/Networks.tsx | 9 +- .../main/advanced/peers/PeerDetailPanel.tsx | 12 +- .../main/advanced/peers/PeerFilters.tsx | 2 +- .../src/modules/main/advanced/peers/Peers.tsx | 6 +- .../src/modules/profiles/ProfileDropdown.tsx | 10 +- .../src/modules/profiles/ProfilesTab.tsx | 8 +- .../src/modules/settings/SettingsAbout.tsx | 16 +- .../src/modules/settings/SettingsGeneral.tsx | 2 + .../src/modules/settings/SettingsSection.tsx | 4 +- .../settings/SettingsTroubleshooting.tsx | 13 +- client/ui/frontend/tailwind.config.ts | 47 +-- client/ui/i18n/locales/de/common.json | 15 + client/ui/i18n/locales/en/common.json | 20 + client/ui/i18n/locales/es/common.json | 15 + client/ui/i18n/locales/fr/common.json | 15 + client/ui/i18n/locales/hu/common.json | 15 + client/ui/i18n/locales/it/common.json | 15 + client/ui/i18n/locales/ja/common.json | 15 + client/ui/i18n/locales/pt/common.json | 15 + client/ui/i18n/locales/ru/common.json | 15 + client/ui/i18n/locales/uk/common.json | 19 +- client/ui/i18n/locales/zh-CN/common.json | 15 + client/ui/main.go | 11 +- client/ui/preferences/store.go | 53 ++- client/ui/services/appappearance_linux.go | 99 +++++ client/ui/services/appappearance_linux_gtk.go | 63 ++++ client/ui/services/appappearance_other.go | 7 + client/ui/services/preferences.go | 4 + client/ui/services/theme.go | 194 ++++++++++ client/ui/services/windowappearance_darwin.go | 53 +++ client/ui/services/windowappearance_other.go | 16 + .../ui/services/windowappearance_windows.go | 54 +++ client/ui/services/windowmanager.go | 123 +++++- client/ui/tray_theme_linux.go | 147 ++++++-- client/ui/tray_theme_linux_test.go | 357 ++++++++++++++++-- client/ui/tray_theme_watcher_linux.go | 51 +-- go.mod | 2 +- go.sum | 4 +- 68 files changed, 1853 insertions(+), 259 deletions(-) create mode 100644 client/ui/frontend/src/assets/logos/netbird-full-light.svg create mode 100644 client/ui/frontend/src/components/ThemePicker.tsx create mode 100644 client/ui/frontend/src/contexts/ThemeContext.tsx create mode 100644 client/ui/services/appappearance_linux.go create mode 100644 client/ui/services/appappearance_linux_gtk.go create mode 100644 client/ui/services/appappearance_other.go create mode 100644 client/ui/services/theme.go create mode 100644 client/ui/services/windowappearance_darwin.go create mode 100644 client/ui/services/windowappearance_other.go create mode 100644 client/ui/services/windowappearance_windows.go diff --git a/client/ui/frontend/index.html b/client/ui/frontend/index.html index e62139956..f4c8b3d68 100644 --- a/client/ui/frontend/index.html +++ b/client/ui/frontend/index.html @@ -6,7 +6,25 @@ NetBird +
diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index 7f1359510..6accda36f 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -13,6 +13,7 @@ import { SkeletonTheme } from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; import { welcome } from "@/lib/welcome"; import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx"; +import { ThemeProvider } from "@/contexts/ThemeContext.tsx"; import { initI18n } from "@/lib/i18n"; import { initPlatform } from "@/lib/platform"; import { initLogForwarding } from "@/lib/logs"; @@ -35,30 +36,38 @@ Promise.all([ ]).finally(() => { ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - } - /> - } /> - } - /> - } /> - } /> - - }> - } /> - } /> - } /> - - - - + + + + + + } + /> + } + /> + } + /> + } /> + } /> + + }> + } /> + } /> + } /> + + + + + , ); }); diff --git a/client/ui/frontend/src/assets/logos/netbird-full-light.svg b/client/ui/frontend/src/assets/logos/netbird-full-light.svg new file mode 100644 index 000000000..3457b50c6 --- /dev/null +++ b/client/ui/frontend/src/assets/logos/netbird-full-light.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/client/ui/frontend/src/components/Badge.tsx b/client/ui/frontend/src/components/Badge.tsx index c5e2b5f22..a6f2e886a 100644 --- a/client/ui/frontend/src/components/Badge.tsx +++ b/client/ui/frontend/src/components/Badge.tsx @@ -11,12 +11,14 @@ type Props = HTMLAttributes & { }; const VARIANT_CLASSES: Record = { - info: "bg-sky-900 border border-sky-700 text-sky-200", + info: "bg-sky-100 border border-sky-300 text-sky-800 dark:bg-sky-900 dark:border-sky-700 dark:text-sky-200", neutral: "bg-nb-gray-900 border border-nb-gray-850 text-nb-gray-200", - brand: "bg-netbird/15 border border-netbird/30 text-netbird", - success: "bg-green-900 border border-green-700 text-green-200", - warning: "bg-yellow-900 border border-yellow-700 text-yellow-200", - danger: "bg-red-900 border border-red-700 text-red-200", + brand: "bg-netbird/15 border border-netbird/30 text-netbird-700 dark:text-netbird", + success: + "bg-green-100 border border-green-300 text-green-800 dark:bg-green-900 dark:border-green-700 dark:text-green-200", + warning: + "bg-yellow-100 border border-yellow-300 text-yellow-800 dark:bg-yellow-900 dark:border-yellow-700 dark:text-yellow-200", + danger: "bg-red-100 border border-red-300 text-red-800 dark:bg-red-900 dark:border-red-700 dark:text-red-200", }; export const Badge = forwardRef(function Badge( diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx index 3cf681a1c..4af4ecc8f 100644 --- a/client/ui/frontend/src/components/CopyToClipboard.tsx +++ b/client/ui/frontend/src/components/CopyToClipboard.tsx @@ -81,7 +81,7 @@ export const CopyToClipboard = ({ aria-live={"polite"} className={cn( "group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-left outline-none", - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", className, )} > diff --git a/client/ui/frontend/src/components/DropdownMenu.tsx b/client/ui/frontend/src/components/DropdownMenu.tsx index d43c37e1b..8cedcea03 100644 --- a/client/ui/frontend/src/components/DropdownMenu.tsx +++ b/client/ui/frontend/src/components/DropdownMenu.tsx @@ -16,7 +16,7 @@ const menuItemVariants = cva("", { variant: { default: "text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50", - danger: "text-red-500 hover:bg-red-900/20 hover:text-red-500 focus-visible:bg-red-900/20 focus-visible:text-red-500", + danger: "text-red-500 hover:bg-red-500/10 hover:text-red-500 focus-visible:bg-red-500/10 focus-visible:text-red-500 dark:hover:bg-red-900/20 dark:focus-visible:bg-red-900/20", }, }, defaultVariants: { variant: "default" }, diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx index 7a30f8b33..35ef7d5b5 100644 --- a/client/ui/frontend/src/components/LanguagePicker.tsx +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -97,9 +97,9 @@ export function LanguagePicker() { "rounded-md border bg-white dark:bg-nb-gray-900", "border-neutral-200 dark:border-nb-gray-700", "cursor-default text-xs font-semibold text-nb-gray-100 outline-none", - "hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600", + "hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600", isFocusVisible && - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", "disabled:opacity-50", )} > @@ -157,7 +157,7 @@ export function LanguagePicker() { placeholder={t("settings.general.language.search")} aria-label={t("settings.general.language.search")} className={cn( - "w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300", + "w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-600 dark:placeholder:text-nb-gray-300", "border-none outline-none", )} /> diff --git a/client/ui/frontend/src/components/SquareIcon.tsx b/client/ui/frontend/src/components/SquareIcon.tsx index e904d2de5..aaf3b1100 100644 --- a/client/ui/frontend/src/components/SquareIcon.tsx +++ b/client/ui/frontend/src/components/SquareIcon.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/cn"; export type SquareIconVariant = "default" | "info" | "warning" | "danger"; const variantClass: Record = { - default: "text-white", + default: "text-nb-gray-50", info: "text-sky-400", warning: "text-netbird", danger: "text-red-500", @@ -27,7 +27,7 @@ export const SquareIcon = ({
o.value === theme) ?? OPTIONS[0]; + const CurrentIcon = current.icon; + + const select = async (value: string) => { + if (busy || value === theme) return; + setBusy(true); + try { + await setTheme(value as ThemePreference); + } catch (e) { + await errorDialog({ + Title: t("settings.error.saveTitle"), + Message: formatErrorMessage(e), + }); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ + {t("settings.general.theme.help")} +
+
+ + + + + + void select(v)}> + {OPTIONS.map(({ value, icon: Icon, labelKey }) => ( + + + {t(labelKey)} + + ))} + + + +
+
+ ); +} diff --git a/client/ui/frontend/src/components/Tooltip.tsx b/client/ui/frontend/src/components/Tooltip.tsx index 2c77ba139..d7a85277a 100644 --- a/client/ui/frontend/src/components/Tooltip.tsx +++ b/client/ui/frontend/src/components/Tooltip.tsx @@ -81,12 +81,12 @@ export const Tooltip = ({ onPointerLeave={interactive ? scheduleClose : undefined} onPointerDownOutside={interactive ? undefined : (e) => e.preventDefault()} className={cn( - "z-50 select-none text-xs text-nb-gray-100 shadow-lg", + "z-50 select-none text-xs text-nb-gray-100 shadow-sm dark:shadow-lg", "data-[state=delayed-open]:animate-in data-[state=closed]:animate-out", "data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0", !interactive && "pointer-events-none", contentClassName ?? - "rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1", + "rounded-md border border-nb-gray-800 bg-white px-2 py-1 dark:border-nb-gray-850 dark:bg-nb-gray-900", )} > {content} diff --git a/client/ui/frontend/src/components/VerticalTabs.tsx b/client/ui/frontend/src/components/VerticalTabs.tsx index 1aedf82a6..306850ee2 100644 --- a/client/ui/frontend/src/components/VerticalTabs.tsx +++ b/client/ui/frontend/src/components/VerticalTabs.tsx @@ -46,12 +46,12 @@ const Trigger = forwardRef(function VerticalTab (function VerticalTab aria-hidden={"true"} className={cn( "ml-2 shrink-0 transition-colors duration-150", - "text-nb-gray-400 group-data-[state=active]:text-nb-gray-100", + "text-nb-gray-350 dark:text-nb-gray-400", + "group-data-[state=active]:text-nb-gray-100", )} /> {title} diff --git a/client/ui/frontend/src/components/buttons/Button.tsx b/client/ui/frontend/src/components/buttons/Button.tsx index 6b151c17b..931988ead 100644 --- a/client/ui/frontend/src/components/buttons/Button.tsx +++ b/client/ui/frontend/src/components/buttons/Button.tsx @@ -24,71 +24,74 @@ const buttonVariants = cva( variants: { variant: { default: [ - "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", - "dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-white dark:focus:ring-zinc-800/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", + "dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-nb-gray-50 dark:focus:ring-zinc-800/50", ], primary: [ - "dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-white disabled:dark:bg-nb-gray-900", - "enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50", + "dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-nb-gray-50 disabled:dark:bg-nb-gray-900", + "enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50 disabled:bg-nb-gray-700", ], secondary: [ - "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", - "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", - "dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:bg-nb-gray-910 dark:hover:text-white", + "border-neutral-200 bg-white text-neutral-900 hover:border-nb-gray-700 hover:bg-nb-gray-950 hover:text-black focus:ring-nb-gray-500/50 focus:ring-offset-0", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 dark:focus:ring-offset-1", + "dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:border-gray-700/40 dark:hover:bg-nb-gray-910 dark:hover:text-nb-gray-50", ], secondaryLighter: [ - "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", - "dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-white", + "dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-nb-gray-50", ], subtle: [ - "border-nb-gray-200 bg-nb-gray-50 text-nb-gray-900 hover:bg-nb-gray-100 focus:ring-nb-gray-200/60", + "border-neutral-200 bg-neutral-50 text-neutral-900 hover:bg-neutral-100 focus:ring-neutral-200/60", "dark:ring-offset-neutral-950/50 dark:focus:ring-nb-gray-200/40", "dark:border-nb-gray-200 dark:bg-nb-gray-50 dark:text-nb-gray-900 dark:hover:bg-nb-gray-100 dark:hover:text-nb-gray-950", ], input: [ - "border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", "dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:text-gray-400 dark:hover:bg-nb-gray-900/80", ], dropdown: [ - "border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", "dark:border-nb-gray-900 dark:bg-nb-gray-900/40 dark:text-gray-400 dark:hover:bg-nb-gray-900/50", ], dotted: [ - "border-dashed border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-dashed border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", - "dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-white", + "dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-nb-gray-50", ], tertiary: [ - "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:border-gray-700/40 dark:bg-white dark:text-gray-800 dark:hover:bg-neutral-200 dark:focus:ring-zinc-800/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", ], white: [ - "border-white bg-white text-gray-800 outline-none hover:bg-neutral-200 focus:ring-white/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", + "border-white bg-white text-neutral-800 outline-none hover:bg-neutral-200 focus:ring-white/50 dark:text-gray-800 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", "disabled:dark:border-nb-gray-900 disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300", ], outline: [ - "border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50", + "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", "dark:border-netbird dark:bg-transparent dark:text-netbird dark:hover:bg-nb-gray-900/30 dark:focus:ring-zinc-800/50", ], "danger-outline": [ + "bg-transparent text-red-600 enabled:hover:bg-red-50 enabled:focus:ring-red-200/50", "dark:bg-transparent dark:text-red-500 enabled:dark:hover:border-red-800/50 enabled:hover:dark:bg-red-950/50 enabled:dark:focus:bg-red-950/40 enabled:dark:focus:ring-red-800/20", ], "danger-text": [ - "rounded-sm !px-0 !py-0 !shadow-none focus:ring-red-500/30 dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600", + "rounded-sm border-transparent bg-transparent !px-0 !py-0 text-red-600 !shadow-none hover:text-red-700 focus:ring-red-500/30", + "dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600", ], "default-outline": [ - "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", - "dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:border-nb-gray-800/50 dark:hover:bg-nb-gray-900/30 dark:hover:text-white", - "data-[state=open]:dark:border-nb-gray-800/50 data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:text-white", + "ring-offset-nb-gray-950/50 focus:ring-nb-gray-500/20", + "border-transparent bg-transparent text-nb-gray-400 hover:border-nb-gray-800/50 hover:bg-nb-gray-900/30 hover:text-nb-gray-50", + "data-[state=open]:border-nb-gray-800/50 data-[state=open]:bg-nb-gray-900/30 data-[state=open]:text-nb-gray-50", ], ghost: [ - "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", - "dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/30 dark:hover:text-white", + "ring-offset-nb-gray-950/50 focus:ring-nb-gray-500/20", + "border-transparent bg-transparent text-nb-gray-400 hover:bg-nb-gray-900/30 hover:text-nb-gray-50", ], danger: [ + "bg-red-600 text-red-50 hover:bg-red-700 focus:bg-red-700 focus:ring-red-700/20", "dark:bg-red-600 dark:text-red-100 dark:hover:border-red-800/50 hover:dark:bg-red-700 dark:focus:bg-red-700 dark:focus:ring-red-700/20", ], }, diff --git a/client/ui/frontend/src/components/buttons/IconButton.tsx b/client/ui/frontend/src/components/buttons/IconButton.tsx index 3d36bc111..8d688cb0b 100644 --- a/client/ui/frontend/src/components/buttons/IconButton.tsx +++ b/client/ui/frontend/src/components/buttons/IconButton.tsx @@ -24,7 +24,7 @@ export const IconButton = forwardRef(function IconButt "flex h-10 w-10 cursor-default items-center justify-center rounded-lg outline-none", "text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-300", isFocusVisible && - "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", "wails-no-draggable transition-colors duration-150", className, )} diff --git a/client/ui/frontend/src/components/dialog/Dialog.tsx b/client/ui/frontend/src/components/dialog/Dialog.tsx index fa8007d9f..c43c04b0b 100644 --- a/client/ui/frontend/src/components/dialog/Dialog.tsx +++ b/client/ui/frontend/src/components/dialog/Dialog.tsx @@ -23,7 +23,7 @@ const Overlay = forwardRef, OverlayPr ref={ref} className={cn( "fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16", - "bg-black/60", + "bg-black/25 dark:bg-black/60", "data-[state=open]:animate-in data-[state=open]:fade-in-0", exitAnimation && "data-[state=closed]:animate-out data-[state=closed]:fade-out-0", @@ -67,7 +67,7 @@ export const Content = forwardRef, Co className={cn( "relative z-[52] mx-auto w-full outline-none ring-0", "focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0", - "rounded-lg border border-nb-gray-900 bg-nb-gray py-7 shadow-2xl", + "rounded-lg border border-nb-gray-800 bg-nb-gray-940 py-7 shadow-2xl dark:border-nb-gray-900 dark:bg-nb-gray", "data-[state=open]:animate-in data-[state=open]:fade-in-0", "data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-left-1", exitAnimation && diff --git a/client/ui/frontend/src/components/inputs/Input.tsx b/client/ui/frontend/src/components/inputs/Input.tsx index 2dad80d7a..eada79a1f 100644 --- a/client/ui/frontend/src/components/inputs/Input.tsx +++ b/client/ui/frontend/src/components/inputs/Input.tsx @@ -32,19 +32,19 @@ const inputVariants = cva("", { variants: { variant: { default: [ - "border-neutral-200 placeholder:text-neutral-500 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "border-neutral-200 placeholder:text-nb-gray-600 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", "ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20", ], darker: [ - "border-neutral-300 placeholder:text-neutral-500 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70", + "border-neutral-300 placeholder:text-nb-gray-600 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70", "ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20", ], error: [ - "border-neutral-200 text-red-500 placeholder:text-neutral-500 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "border-neutral-200 text-red-500 placeholder:text-nb-gray-600 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", "ring-offset-red-500/10 focus-visible:ring-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10", ], warning: [ - "border-neutral-200 text-orange-400 placeholder:text-neutral-500 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", + "border-neutral-200 text-orange-400 placeholder:text-nb-gray-600 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70", "ring-offset-orange-400/10 focus-visible:ring-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10", ], }, @@ -158,7 +158,7 @@ function NumberStepper({ className={cn( "flex h-[40px] shrink-0 flex-col overflow-hidden", "rounded-r-md border border-l-0", - "border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900", + "border-neutral-200 bg-white dark:border-nb-gray-700 dark:bg-nb-gray-900", error && "dark:border-red-500", disabled && "pointer-events-none opacity-40", )} @@ -274,7 +274,9 @@ export const Input = forwardRef(function Input( - - - void select(v)}> - {OPTIONS.map(({ value, icon: Icon, labelKey }) => ( - - - {t(labelKey)} - - ))} - - - + ({ + value, + icon, + label: t(`settings.troubleshooting.anonymize.${value}`), + }))} + onChange={setAnonymizeLevel} + ariaLabel={t("settings.troubleshooting.anonymize.label")} + />
Date: Tue, 29 Sep 2026 18:29:55 +0300 Subject: [PATCH 075/209] [management,proxy] Use single-use codes for OIDC session handoff (#7635) * Generalize PKCE verifier store into SingleUseStore * Generalize PKCE verifier store into SingleUseStore * Extend single-use store to generate one-time retrieval codes * Hand off proxy OIDC session via one-time code instead of URL token * Use the single-use store in integration tests * Read active proxy versions by cluster * Detect proxy clusters that support session codes * Bind OIDC session handoff mode to signed state * Deprecate legacy OIDC session token handoff * Remove unrelated session code test stub * fix tests * fix merge * Fix session code compatibility detection * Isolate proxy session codes in shared cache * bump min session version --- .../modules/reverseproxy/proxy/manager.go | 1 + .../reverseproxy/proxy/manager/manager.go | 20 + .../proxy/manager/manager_test.go | 35 ++ .../reverseproxy/proxy/manager_mock.go | 14 + management/internals/shared/grpc/proxy.go | 71 ++- .../internals/shared/grpc/proxy_test.go | 28 +- .../internals/shared/grpc/single_use.go | 20 + .../internals/shared/grpc/single_use_test.go | 37 ++ .../shared/grpc/validate_session_test.go | 48 ++ management/server/http/handlers/proxy/auth.go | 15 +- .../proxy/auth_callback_integration_test.go | 87 +++- management/server/store/sql_store_proxy.go | 15 + management/server/store/store.go | 1 + management/server/store/store_mock.go | 15 + proxy/internal/auth/middleware.go | 49 +- proxy/internal/auth/middleware_test.go | 26 ++ proxy/internal/auth/oidc.go | 11 +- proxy/internal/proxy/reverseproxy.go | 7 +- proxy/management_integration_test.go | 10 +- proxy/web/web.go | 1 + shared/management/proto/proxy_service.pb.go | 439 +++++++++--------- shared/management/proto/proxy_service.proto | 7 +- 22 files changed, 676 insertions(+), 281 deletions(-) diff --git a/management/internals/modules/reverseproxy/proxy/manager.go b/management/internals/modules/reverseproxy/proxy/manager.go index a591b86ca..c0b8435ec 100644 --- a/management/internals/modules/reverseproxy/proxy/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager.go @@ -20,6 +20,7 @@ type Manager interface { ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + ClusterSupportsSessionCode(ctx context.Context, clusterAddr string) bool CleanupStale(ctx context.Context, inactivityDuration time.Duration) error GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) CountAccountProxies(ctx context.Context, accountID string) (int64, error) diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager.go b/management/internals/modules/reverseproxy/proxy/manager/manager.go index edfa32aa9..7ddb66eec 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager.go @@ -8,6 +8,7 @@ import ( "go.opentelemetry.io/otel/metric" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + nbversion "github.com/netbirdio/netbird/version" ) // store defines the interface for proxy persistence operations @@ -22,6 +23,7 @@ type store interface { GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error) CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error) @@ -29,6 +31,8 @@ type store interface { DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error } +const minSessionCodeVersion = "0.81.0" + // Manager handles all proxy operations type Manager struct { store store @@ -145,6 +149,22 @@ func (m Manager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) return m.store.GetClusterSupportsPrivate(ctx, clusterAddr) } +// ClusterSupportsSessionCode reports whether all active proxies support session codes. +func (m Manager) ClusterSupportsSessionCode(ctx context.Context, clusterAddr string) bool { + versions, err := m.store.GetActiveProxyVersions(ctx, clusterAddr) + if err != nil || len(versions) == 0 { + return false + } + + for _, version := range versions { + if supported, err := nbversion.MeetsMinVersion(minSessionCodeVersion, version); err != nil || !supported { + return false + } + } + + return true +} + // CleanupStale removes proxies that haven't sent heartbeat in the specified duration func (m *Manager) CleanupStale(ctx context.Context, inactivityDuration time.Duration) error { if err := m.store.CleanupStaleProxies(ctx, inactivityDuration); err != nil { diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go index d5a3ce777..66ddb95bd 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go @@ -22,6 +22,7 @@ type mockStore struct { updateProxyHeartbeatFunc func(ctx context.Context, p *proxy.Proxy) error getActiveProxyClusterAddressesFunc func(ctx context.Context) ([]string, error) getActiveProxyClusterAddressesForAccFunc func(ctx context.Context, accountID string) ([]string, error) + getActiveProxyVersionsFunc func(ctx context.Context, clusterAddress string) ([]string, error) cleanupStaleProxiesFunc func(ctx context.Context, d time.Duration) error getProxyByAccountIDFunc func(ctx context.Context, accountID string) (*proxy.Proxy, error) countProxiesByAccountIDFunc func(ctx context.Context, accountID string) (int64, error) @@ -104,6 +105,12 @@ func (m *mockStore) GetClusterSupportsCrowdSec(_ context.Context, _ string) *boo func (m *mockStore) GetClusterSupportsPrivate(_ context.Context, _ string) *bool { return nil } +func (m *mockStore) GetActiveProxyVersions(ctx context.Context, clusterAddress string) ([]string, error) { + if m.getActiveProxyVersionsFunc != nil { + return m.getActiveProxyVersionsFunc(ctx, clusterAddress) + } + return nil, nil +} func newTestManager(s store) *Manager { meter := noop.NewMeterProvider().Meter("test") @@ -114,6 +121,34 @@ func newTestManager(s store) *Manager { return m } +func TestClusterSupportsSessionCode(t *testing.T) { + tests := []struct { + name string + versions []string + storeErr error + want bool + }{ + {name: "all supported", versions: []string{"0.81.0", "0.81.2"}, want: true}, + {name: "one old proxy", versions: []string{"0.81.0", "0.80.0"}}, + {name: "missing version", versions: []string{"0.81.0", ""}}, + {name: "no active proxies"}, + {name: "store error", storeErr: errors.New("db error")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &mockStore{ + getActiveProxyVersionsFunc: func(_ context.Context, _ string) ([]string, error) { + return tt.versions, tt.storeErr + }, + } + + got := newTestManager(s).ClusterSupportsSessionCode(context.Background(), "cluster.example.com") + assert.Equal(t, tt.want, got) + }) + } +} + func TestConnect_WithAccountID(t *testing.T) { accountID := "acc-123" diff --git a/management/internals/modules/reverseproxy/proxy/manager_mock.go b/management/internals/modules/reverseproxy/proxy/manager_mock.go index ec6df8a4a..d6f7197d7 100644 --- a/management/internals/modules/reverseproxy/proxy/manager_mock.go +++ b/management/internals/modules/reverseproxy/proxy/manager_mock.go @@ -112,6 +112,20 @@ func (mr *MockManagerMockRecorder) ClusterSupportsPrivate(ctx, clusterAddr any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsPrivate", reflect.TypeOf((*MockManager)(nil).ClusterSupportsPrivate), ctx, clusterAddr) } +// ClusterSupportsSessionCode mocks base method. +func (m *MockManager) ClusterSupportsSessionCode(ctx context.Context, clusterAddr string) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClusterSupportsSessionCode", ctx, clusterAddr) + ret0, _ := ret[0].(bool) + return ret0 +} + +// ClusterSupportsSessionCode indicates an expected call of ClusterSupportsSessionCode. +func (mr *MockManagerMockRecorder) ClusterSupportsSessionCode(ctx, clusterAddr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsSessionCode", reflect.TypeOf((*MockManager)(nil).ClusterSupportsSessionCode), ctx, clusterAddr) +} + // Connect mocks base method. func (m *MockManager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress, version string, accountID *string, capabilities *Capabilities) (*Proxy, error) { m.ctrl.T.Helper() diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index e5d89ab3d..00355087d 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -27,8 +27,6 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "github.com/netbirdio/netbird/shared/management/domain" - "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/internals/modules/peers" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" @@ -42,6 +40,7 @@ import ( "github.com/netbirdio/netbird/management/server/users" proxyauth "github.com/netbirdio/netbird/proxy/auth" "github.com/netbirdio/netbird/shared/hash/argon2id" + "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/proto" nbstatus "github.com/netbirdio/netbird/shared/management/status" ) @@ -142,7 +141,7 @@ type ProxyServiceServer struct { // OIDC configuration for proxy authentication oidcConfig ProxyOIDCConfig - // Store for PKCE verifiers + // singleUseStore backs both PKCE verifiers and OIDC session exchange codes. singleUseStore *SingleUseStore // tokenTTL is the lifetime of one-time tokens generated for proxy @@ -158,6 +157,13 @@ type ProxyServiceServer struct { const pkceVerifierTTL = 10 * time.Minute +const sessionCodeTTL = 60 * time.Second + +const sessionCodeCacheNamespace = "proxy:session" + +// The signed nonce binds the handoff mode without changing the state format. +const sessionCodeNoncePrefix = "code." + const defaultProxyTokenTTL = 5 * time.Minute const defaultSnapshotBatchSize = 500 @@ -306,6 +312,16 @@ func (s *ProxyServiceServer) proxyConnectAuthorizer() ProxyConnectAuthorizer { return s.connectAuthorizer } +// GenerateSessionCode creates a single-use code for the given session token. +func (s *ProxyServiceServer) GenerateSessionCode(sessionToken string) (code string, ok bool) { + code, err := s.singleUseStore.Generate(sessionCodeCacheNamespace, sessionToken, sessionCodeTTL) + if err != nil { + log.WithError(err).Error("failed to generate proxy session code") + return "", false + } + return code, true +} + // CheckLLMPolicyLimits is the pre-flight policy gate the proxy calls before // forwarding an LLM request upstream. Delegates to the agent-network selector, // which scores applicable policies by remaining headroom and returns the @@ -1536,18 +1552,20 @@ func (s *ProxyServiceServer) GetOIDCURL(ctx context.Context, req *proto.GetOIDCU log.WithContext(ctx).Errorf("failed to get account services: %v", err) return nil, status.Errorf(codes.FailedPrecondition, "get account services: %v", err) } - var found bool + var matchedService *rpservice.Service for _, service := range services { if service.Domain == redirectURL.Hostname() { - found = true + matchedService = service break } } - if !found { + if matchedService == nil { log.WithContext(ctx).Debugf("OIDC redirect URL %q does not match any service domain", redirectURL.Hostname()) return nil, status.Errorf(codes.FailedPrecondition, "service not found in store") } + useSessionCode := s.proxyManager.ClusterSupportsSessionCode(ctx, matchedService.ProxyCluster) + provider, err := oidc.NewProvider(ctx, s.oidcConfig.Issuer) if err != nil { log.WithContext(ctx).Errorf("failed to create OIDC provider: %v", err) @@ -1567,9 +1585,12 @@ func (s *ProxyServiceServer) GetOIDCURL(ctx context.Context, req *proto.GetOIDCU return nil, status.Errorf(codes.Internal, "generate nonce: %v", err) } nonceB64 := base64.URLEncoding.EncodeToString(nonce) + if useSessionCode { + nonceB64 = sessionCodeNoncePrefix + nonceB64 + } // Using an HMAC here to avoid redirection state being modified. - // State format: base64(redirectURL)|nonce|hmac(redirectURL|nonce) + // State format: base64(redirectURL)|[code.]nonce|hmac(redirectURL|nonce) payload := redirectURL.String() + "|" + nonceB64 hmacSum := s.generateHMAC(payload) state := fmt.Sprintf("%s|%s|%s", base64.URLEncoding.EncodeToString([]byte(redirectURL.String())), nonceB64, hmacSum) @@ -1612,15 +1633,12 @@ func (s *ProxyServiceServer) generateHMAC(input string) string { return hex.EncodeToString(mac.Sum(nil)) } -// ValidateState validates the state parameter from an OAuth callback. -// Returns the original redirect URL if valid, or an error if invalid. -// The HMAC is verified before consuming the PKCE verifier to prevent -// an attacker from invalidating a legitimate user's auth flow. -func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL string, err error) { - // State format: base64(redirectURL)|nonce|hmac(redirectURL|nonce) +// ValidateState validates and consumes an OIDC state. +func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL string, useSessionCode bool, err error) { + // State format: base64(redirectURL)|[code.]nonce|hmac(redirectURL|nonce) parts := strings.Split(state, "|") if len(parts) != 3 { - return "", "", errors.New("invalid state format") + return "", "", false, errors.New("invalid state format") } encodedURL := parts[0] @@ -1629,7 +1647,7 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL redirectURLBytes, err := base64.URLEncoding.DecodeString(encodedURL) if err != nil { - return "", "", fmt.Errorf("invalid state encoding: %w", err) + return "", "", false, fmt.Errorf("invalid state encoding: %w", err) } redirectURL = string(redirectURLBytes) @@ -1637,16 +1655,17 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL expectedHMAC := s.generateHMAC(payload) if !hmac.Equal([]byte(providedHMAC), []byte(expectedHMAC)) { - return "", "", errors.New("invalid state signature") + return "", "", false, errors.New("invalid state signature") } + useSessionCode = strings.HasPrefix(nonce, sessionCodeNoncePrefix) // Consume the PKCE verifier only after HMAC validation passes. verifier, ok := s.singleUseStore.LoadAndDelete(state) if !ok { - return "", "", errors.New("no verifier for state") + return "", "", false, errors.New("no verifier for state") } - return verifier, redirectURL, nil + return verifier, redirectURL, useSessionCode, nil } // Denied reasons reported to the proxy when access is refused because of the @@ -1851,7 +1870,20 @@ func (s *ProxyServiceServer) getAccountServiceByDomain(ctx context.Context, acco // ValidateSession validates a session token and checks if the user has access to the domain. func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.ValidateSessionRequest) (*proto.ValidateSessionResponse, error) { domain := req.GetDomain() - sessionToken := req.GetSessionToken() + sessionToken := req.GetSessionToken() //nolint:staticcheck + + // A one-time code from the OIDC callback is redeemed here for the durable + // token, so the token never travels in a redirect URL. The redeemed token + // is returned to the proxy (mintedToken) to install as the session cookie. + mintedToken := "" + if code := req.GetSessionCode(); code != "" { + redeemed, found := s.singleUseStore.LoadAndDelete(singleUseCacheKey(sessionCodeCacheNamespace, code)) + if !found { + return deniedSessionResponse("invalid or expired session code"), nil + } + sessionToken = redeemed + mintedToken = redeemed + } if domain == "" || sessionToken == "" { return deniedSessionResponse("missing domain or session_token"), nil @@ -1924,6 +1956,7 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val UserEmail: user.Email, PeerGroupIds: groupIDs, PeerGroupNames: groupNames, + SessionToken: mintedToken, }, nil } diff --git a/management/internals/shared/grpc/proxy_test.go b/management/internals/shared/grpc/proxy_test.go index 060689da1..1e718a2c4 100644 --- a/management/internals/shared/grpc/proxy_test.go +++ b/management/internals/shared/grpc/proxy_test.go @@ -313,7 +313,7 @@ func TestValidateState_RejectsOldTwoPartFormat(t *testing.T) { err := s.singleUseStore.Store("base64url|hmac", "test", 10*time.Minute) require.NoError(t, err) - _, _, err = s.ValidateState("base64url|hmac") + _, _, _, err = s.ValidateState("base64url|hmac") require.Error(t, err) assert.Contains(t, err.Error(), "invalid state format") } @@ -385,11 +385,35 @@ func TestValidateState_RejectsInvalidHMAC(t *testing.T) { err := s.singleUseStore.Store("dGVzdA==|nonce|wrong-hmac", "test", 10*time.Minute) require.NoError(t, err) - _, _, err = s.ValidateState("dGVzdA==|nonce|wrong-hmac") + _, _, _, err = s.ValidateState("dGVzdA==|nonce|wrong-hmac") require.Error(t, err) assert.Contains(t, err.Error(), "invalid state signature") } +func TestSessionCodeCannotConsumeOIDCState(t *testing.T) { + const verifier = "pkce-verifier" + + store := NewSingleUseStore(context.Background(), testCacheStore(t)) + server := &ProxyServiceServer{ + oidcConfig: ProxyOIDCConfig{ + HMACKey: []byte("test-hmac-key"), + }, + singleUseStore: store, + } + state := generateState(server, "https://service.example.com/callback") + require.NoError(t, store.Store(state, verifier, time.Minute)) + + response, err := server.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + SessionCode: state, + }) + require.NoError(t, err) + assert.False(t, response.GetValid()) + + gotVerifier, _, _, err := server.ValidateState(state) + require.NoError(t, err) + assert.Equal(t, verifier, gotVerifier) +} + func TestSendServiceUpdateToCluster_FiltersOnCapability(t *testing.T) { tokenStore := NewOneTimeTokenStore(context.Background(), testCacheStore(t)) diff --git a/management/internals/shared/grpc/single_use.go b/management/internals/shared/grpc/single_use.go index 1698707d7..467ee6020 100644 --- a/management/internals/shared/grpc/single_use.go +++ b/management/internals/shared/grpc/single_use.go @@ -2,6 +2,8 @@ package grpc import ( "context" + "crypto/rand" + "encoding/base64" "fmt" "time" @@ -33,6 +35,24 @@ func (s *SingleUseStore) Store(key, value string, ttl time.Duration) error { return nil } +// Generate stores a value under a namespaced random key and returns the random key. +func (s *SingleUseStore) Generate(namespace, value string, ttl time.Duration) (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate single-use key: %w", err) + } + + key := base64.RawURLEncoding.EncodeToString(buf) + if err := s.Store(singleUseCacheKey(namespace, key), value, ttl); err != nil { + return "", err + } + return key, nil +} + +func singleUseCacheKey(namespace, key string) string { + return namespace + ":" + key +} + // LoadAndDelete retrieves and removes the value for a key. func (s *SingleUseStore) LoadAndDelete(key string) (string, bool) { value, found, err := s.cache.GetDel(s.ctx, key) diff --git a/management/internals/shared/grpc/single_use_test.go b/management/internals/shared/grpc/single_use_test.go index 357046720..8243ca43b 100644 --- a/management/internals/shared/grpc/single_use_test.go +++ b/management/internals/shared/grpc/single_use_test.go @@ -83,3 +83,40 @@ func TestSingleUseStoreLoadAndDelete(t *testing.T) { } }) } + +func TestSingleUseStore_GenerateAndConsumeOnce(t *testing.T) { + const namespace = "test" + s := NewSingleUseStore(context.Background(), testCacheStore(t)) + + key, err := s.Generate(namespace, "the-value", time.Minute) + if err != nil { + t.Fatalf("generate: %v", err) + } + if key == "" || key == "the-value" { + t.Fatalf("unexpected key %q", key) + } + + value, found := s.LoadAndDelete(singleUseCacheKey(namespace, key)) + if !found || value != "the-value" { + t.Fatalf("expected to load the stored value, got %q found=%v", value, found) + } + + if _, found := s.LoadAndDelete(singleUseCacheKey(namespace, key)); found { + t.Fatal("value must be consumed on first LoadAndDelete") + } +} + +func TestSingleUseStore_GenerateUniqueKeys(t *testing.T) { + s := NewSingleUseStore(context.Background(), testCacheStore(t)) + a, err := s.Generate("test", "v", time.Minute) + if err != nil { + t.Fatalf("generate a: %v", err) + } + b, err := s.Generate("test", "v", time.Minute) + if err != nil { + t.Fatalf("generate b: %v", err) + } + if a == b { + t.Fatal("generated keys must be distinct") + } +} diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go index 2af250a85..4b36e74cf 100644 --- a/management/internals/shared/grpc/validate_session_test.go +++ b/management/internals/shared/grpc/validate_session_test.go @@ -634,6 +634,10 @@ func (m *testValidateSessionProxyManager) ClusterSupportsPrivate(_ context.Conte return nil } +func (m *testValidateSessionProxyManager) ClusterSupportsSessionCode(_ context.Context, _ string) bool { + return false +} + type testValidateSessionUsersManager struct { store store.Store } @@ -662,3 +666,47 @@ func (m *testValidateSessionUsersManager) GetUserWithGroups(ctx context.Context, } return user, groups, nil } + +func TestValidateSession_RedeemsSessionCode(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "testProxyId") + require.NoError(t, err) + + token := createSessionToken(t, proxy.SessionPrivateKey, "allowedUserId", "test-proxy.example.com") + code, ok := setup.proxyService.GenerateSessionCode(token) + require.True(t, ok) + require.NotEqual(t, token, code, "code must not be the token itself") + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionCode: code, + }) + require.NoError(t, err) + assert.True(t, resp.Valid, "redeemed code should authorize the user") + assert.Equal(t, "allowedUserId", resp.UserId) + assert.Equal(t, token, resp.GetSessionToken(), "response must carry the durable token for the cookie") + + // Single-use: the same code must not redeem again. + resp2, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionCode: code, + }) + require.NoError(t, err) + assert.False(t, resp2.Valid, "a consumed code must be rejected") + assert.Empty(t, resp2.GetSessionToken()) +} + +func TestValidateSession_InvalidSessionCode(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionCode: "does-not-exist", + }) + require.NoError(t, err) + assert.False(t, resp.Valid) + assert.Empty(t, resp.GetSessionToken()) +} diff --git a/management/server/http/handlers/proxy/auth.go b/management/server/http/handlers/proxy/auth.go index 15327fd30..298fb503e 100644 --- a/management/server/http/handlers/proxy/auth.go +++ b/management/server/http/handlers/proxy/auth.go @@ -59,7 +59,7 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ state := r.URL.Query().Get("state") - codeVerifier, originalURL, err := h.proxyService.ValidateState(state) + codeVerifier, originalURL, useSessionCode, err := h.proxyService.ValidateState(state) if err != nil { log.WithError(err).Error("OAuth callback state validation failed") http.Error(w, "Invalid state parameter", http.StatusBadRequest) @@ -119,10 +119,19 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ redirectURL.Scheme = "https" query := redirectURL.Query() - query.Set("session_token", sessionToken) + if useSessionCode { + code, ok := h.proxyService.GenerateSessionCode(sessionToken) + if !ok { + http.Error(w, "Failed to create session", http.StatusInternalServerError) + return + } + query.Set("session_code", code) + } else { + query.Set("session_token", sessionToken) + } redirectURL.RawQuery = query.Encode() - log.WithField("redirect", redirectURL.Host).Debug("OAuth callback: redirecting user with session token") + log.WithField("redirect", redirectURL.Host).Debug("OAuth callback: redirecting user to proxy") http.Redirect(w, r, redirectURL.String(), http.StatusFound) } diff --git a/management/server/http/handlers/proxy/auth_callback_integration_test.go b/management/server/http/handlers/proxy/auth_callback_integration_test.go index 41ecb5f23..964841a63 100644 --- a/management/server/http/handlers/proxy/auth_callback_integration_test.go +++ b/management/server/http/handlers/proxy/auth_callback_integration_test.go @@ -181,6 +181,10 @@ func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string, } func setupAuthCallbackTest(t *testing.T) *testSetup { + return setupAuthCallbackTestWithProxyManager(t, testSessionCodeManager{}) +} + +func setupAuthCallbackTestWithProxyManager(t *testing.T, proxyManager nbproxy.Manager) *testSetup { t.Helper() ctx := context.Background() @@ -217,7 +221,7 @@ func setupAuthCallbackTest(t *testing.T) *testSetup { nil, usersManager, nil, - nil, + proxyManager, nil, ) @@ -242,6 +246,15 @@ func setupAuthCallbackTest(t *testing.T) *testSetup { } } +type testSessionCodeManager struct { + nbproxy.Manager + supported bool +} + +func (m testSessionCodeManager) ClusterSupportsSessionCode(_ context.Context, _ string) bool { + return m.supported +} + func createTestReverseProxies(t *testing.T, ctx context.Context, testStore store.Store) { t.Helper() @@ -252,10 +265,11 @@ func createTestReverseProxies(t *testing.T, ctx context.Context, testStore store privKey := base64.StdEncoding.EncodeToString(priv) testProxy := &service.Service{ - ID: "testProxyId", - AccountID: "testAccountId", - Name: "Test Proxy", - Domain: "test-proxy.example.com", + ID: "testProxyId", + AccountID: "testAccountId", + Name: "Test Proxy", + Domain: "test-proxy.example.com", + ProxyCluster: "cluster.example.com", Targets: []*service.Target{{ Path: strPtr("/"), Host: "localhost", @@ -512,29 +526,56 @@ func createTestState(t *testing.T, ps *nbgrpc.ProxyServiceServer, redirectURL st } func TestAuthCallback_UserAllowedToLogin(t *testing.T) { - setup := setupAuthCallbackTest(t) - defer setup.cleanup() + tests := []struct { + name string + manager nbproxy.Manager + wantParam string + absentParam string + }{ + {name: "legacy proxy", manager: testSessionCodeManager{}, wantParam: "session_token", absentParam: "session_code"}, + {name: "compatible proxy", manager: testSessionCodeManager{supported: true}, wantParam: "session_code", absentParam: "session_token"}, + } - setup.oidcServer.tokenSubject = "allowedUserId" + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setup := setupAuthCallbackTestWithProxyManager(t, tt.manager) + defer setup.cleanup() - state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard") + setup.oidcServer.tokenSubject = "allowedUserId" + state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard") + req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil) + rec := httptest.NewRecorder() + setup.router.ServeHTTP(rec, req) + require.Equal(t, http.StatusFound, rec.Code) - req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil) - rec := httptest.NewRecorder() + location, err := url.Parse(rec.Header().Get("Location")) + require.NoError(t, err) + require.Equal(t, "test-proxy.example.com", location.Host) + require.NotEmpty(t, location.Query().Get(tt.wantParam)) + require.Empty(t, location.Query().Get(tt.absentParam)) + require.Empty(t, location.Query().Get("error")) - setup.router.ServeHTTP(rec, req) + if tt.wantParam == "session_code" { + code := location.Query().Get("session_code") + response, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: location.Hostname(), + SessionCode: code, + }) + require.NoError(t, err) + require.True(t, response.GetValid()) + require.NotEmpty(t, response.GetSessionToken()) + require.NotEqual(t, code, response.GetSessionToken()) - require.Equal(t, http.StatusFound, rec.Code) - - location := rec.Header().Get("Location") - require.NotEmpty(t, location) - - parsedLocation, err := url.Parse(location) - require.NoError(t, err) - - require.Equal(t, "test-proxy.example.com", parsedLocation.Host) - require.NotEmpty(t, parsedLocation.Query().Get("session_token"), "Should include session token") - require.Empty(t, parsedLocation.Query().Get("error"), "Should not have error parameter") + replayed, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: location.Hostname(), + SessionCode: code, + }) + require.NoError(t, err) + require.False(t, replayed.GetValid()) + require.Empty(t, replayed.GetSessionToken()) + } + }) + } } // TestAuthCallback_UserDeniedByAccountStatus asserts that a user whose account diff --git a/management/server/store/sql_store_proxy.go b/management/server/store/sql_store_proxy.go index 5d1a9afba..58fa86468 100644 --- a/management/server/store/sql_store_proxy.go +++ b/management/server/store/sql_store_proxy.go @@ -367,6 +367,21 @@ func (s *SqlStore) GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr s return s.getClusterUnanimousCapability(ctx, clusterAddr, "supports_crowdsec") } +// GetActiveProxyVersions returns every active proxy version in a cluster. +func (s *SqlStore) GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error) { + var versions []string + err := s.db.WithContext(ctx). + Model(&proxy.Proxy{}). + Where("LOWER(cluster_address) = LOWER(?) AND status = ? AND last_seen > ?", + clusterAddr, proxy.StatusConnected, time.Now().Add(-proxyActiveThreshold)). + Pluck("version", &versions).Error + if err != nil { + log.WithContext(ctx).Errorf("failed to get active proxy versions for %s: %v", clusterAddr, err) + return nil, status.Errorf(status.Internal, "get active proxy versions") + } + return versions, nil +} + // getClusterUnanimousCapability returns an aggregated boolean capability // requiring all active proxies in the cluster to report true. func (s *SqlStore) getClusterUnanimousCapability(ctx context.Context, clusterAddr, column string) *bool { diff --git a/management/server/store/store.go b/management/server/store/store.go index e935d2d88..bd55e3176 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -337,6 +337,7 @@ type Store interface { GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) DisconnectAllProxies(ctx context.Context) (int64, error) diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 068ac6ff2..4dd7f9286 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -1539,6 +1539,21 @@ func (mr *MockStoreMockRecorder) GetActiveProxyClusterAddressesForAccount(ctx, a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveProxyClusterAddressesForAccount", reflect.TypeOf((*MockStore)(nil).GetActiveProxyClusterAddressesForAccount), ctx, accountID) } +// GetActiveProxyVersions mocks base method. +func (m *MockStore) GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActiveProxyVersions", ctx, clusterAddr) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActiveProxyVersions indicates an expected call of GetActiveProxyVersions. +func (mr *MockStoreMockRecorder) GetActiveProxyVersions(ctx, clusterAddr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveProxyVersions", reflect.TypeOf((*MockStore)(nil).GetActiveProxyVersions), ctx, clusterAddr) +} + // GetAgentNetworkAccessLogSessions mocks base method. func (m *MockStore) GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) { m.ctrl.T.Helper() diff --git a/proxy/internal/auth/middleware.go b/proxy/internal/auth/middleware.go index 311dd2cbb..672286748 100644 --- a/proxy/internal/auth/middleware.go +++ b/proxy/internal/auth/middleware.go @@ -46,7 +46,7 @@ type Scheme interface { // an authenticated user. An empty token indicates an unauthenticated // request; optionally, promptData may be returned for the login UI. // An error indicates an infrastructure failure (e.g. gRPC unavailable). - Authenticate(*http.Request) (token string, promptData string, err error) + Authenticate(*http.Request) (token, promptData string, err error) } // DomainConfig holds the authentication and restriction settings for a protected domain. @@ -77,6 +77,8 @@ type validationResult struct { // Groups for tokens minted before names were embedded; the consumer // falls back to ids for missing positions. GroupNames []string + // MintedToken is the session token issued when a one-time code is redeemed. + MintedToken string } // Middleware applies per-domain authentication and IP restriction checks. @@ -581,7 +583,8 @@ func (mw *Middleware) authenticateWithSchemes(w http.ResponseWriter, r *http.Req // handleAuthenticatedToken validates the token, handles denied access, and on // success sets a session cookie and redirects to the original URL. func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Request, host, token string, config DomainConfig, scheme Scheme) { - result, err := mw.validateSessionToken(r.Context(), host, token, config.SessionPublicKey, scheme.Type()) + isCode := scheme.Type() == auth.MethodOIDC && r.URL.Query().Get("session_code") != "" + result, err := mw.validateSessionToken(r.Context(), host, token, isCode, config.SessionPublicKey, scheme.Type()) if err != nil { if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { cd.SetOrigin(proxy.OriginAuth) @@ -612,7 +615,13 @@ func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Re return } - setSessionCookie(w, token, config.SessionExpiration) + // When a code was redeemed, the cookie must hold the durable token the + // server returned, not the single-use code. + cookieValue := token + if result.MintedToken != "" { + cookieValue = result.MintedToken + } + setSessionCookie(w, cookieValue, config.SessionExpiration) // Redirect instead of forwarding the auth POST to the backend. // The browser will follow with a GET carrying the new session cookie. @@ -652,7 +661,7 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool { case auth.MethodPassword: return credentialFormValue(r, passwordFormId) != "" case auth.MethodOIDC: - return r.URL.Query().Get("session_token") != "" + return r.URL.Query().Get("session_token") != "" || r.URL.Query().Get("session_code") != "" } return false } @@ -706,12 +715,15 @@ func (mw *Middleware) RemoveDomain(domain string) { // validateSessionToken validates a session token. OIDC tokens with a configured // validator go through gRPC for group access checks; other methods validate locally. -func (mw *Middleware) validateSessionToken(ctx context.Context, host, token string, publicKey ed25519.PublicKey, method auth.Method) (*validationResult, error) { +func (mw *Middleware) validateSessionToken(ctx context.Context, host, token string, isCode bool, publicKey ed25519.PublicKey, method auth.Method) (*validationResult, error) { if method == auth.MethodOIDC && mw.sessionValidator != nil { - resp, err := mw.sessionValidator.ValidateSession(ctx, &proto.ValidateSessionRequest{ - Domain: host, - SessionToken: token, - }) + req := &proto.ValidateSessionRequest{Domain: host} + if isCode { + req.SessionCode = token + } else { + req.SessionToken = token //nolint:staticcheck + } + resp, err := mw.sessionValidator.ValidateSession(ctx, req) if err != nil { return nil, fmt.Errorf("%w: %w", errValidationUnavailable, err) } @@ -729,11 +741,12 @@ func (mw *Middleware) validateSessionToken(ctx context.Context, host, token stri }, nil } return &validationResult{ - UserID: resp.UserId, - UserEmail: resp.GetUserEmail(), - Valid: true, - Groups: resp.GetPeerGroupIds(), - GroupNames: resp.GetPeerGroupNames(), + UserID: resp.UserId, + UserEmail: resp.GetUserEmail(), + Valid: true, + Groups: resp.GetPeerGroupIds(), + GroupNames: resp.GetPeerGroupNames(), + MintedToken: resp.GetSessionToken(), }, nil } @@ -788,14 +801,16 @@ func sessionGroupsAllowed(allowed map[string]struct{}, method auth.Method, group } } -// stripSessionTokenParam returns the request URI with the session_token query -// parameter removed so it doesn't linger in the browser's address bar or history. +// stripSessionTokenParam returns the request URI with the session hand-off +// query parameters removed so they don't linger in the browser's address bar +// or history. func stripSessionTokenParam(u *url.URL) string { q := u.Query() - if !q.Has("session_token") { + if !q.Has("session_token") && !q.Has("session_code") { return u.RequestURI() } q.Del("session_token") + q.Del("session_code") clean := *u clean.RawQuery = q.Encode() return clean.RequestURI() diff --git a/proxy/internal/auth/middleware_test.go b/proxy/internal/auth/middleware_test.go index f1242f95e..88c900f97 100644 --- a/proxy/internal/auth/middleware_test.go +++ b/proxy/internal/auth/middleware_test.go @@ -783,6 +783,12 @@ func TestWasCredentialSubmitted(t *testing.T) { query: url.Values{"session_token": {"abc123"}}, expected: true, }, + { + name: "OIDC code in query", + method: auth.MethodOIDC, + query: url.Values{"session_code": {"abc123"}}, + expected: true, + }, { name: "OIDC token not in query", method: auth.MethodOIDC, @@ -1571,3 +1577,23 @@ func TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker(t *testing.T) { assert.Equal(t, http.StatusOK, rec.Code, "a successful tunnel-peer validation must forward to the next handler") } + +func TestStripSessionTokenParam(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"strips session_token", "https://ex.com/p?a=1&session_token=tok", "/p?a=1"}, + {"strips session_code", "https://ex.com/p?a=1&session_code=code", "/p?a=1"}, + {"strips both", "https://ex.com/p?session_token=tok&session_code=code&a=1", "/p?a=1"}, + {"no-op when absent", "https://ex.com/p?a=1", "/p?a=1"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + u, err := url.Parse(tc.in) + require.NoError(t, err) + assert.Equal(t, tc.want, stripSessionTokenParam(u)) + }) + } +} diff --git a/proxy/internal/auth/oidc.go b/proxy/internal/auth/oidc.go index a60e6437a..739777924 100644 --- a/proxy/internal/auth/oidc.go +++ b/proxy/internal/auth/oidc.go @@ -40,9 +40,14 @@ func (OIDC) Type() auth.Method { // Authenticate checks for an OIDC session token or obtains the OIDC redirect URL. func (o OIDC) Authenticate(r *http.Request) (string, string, error) { - // Check for the session_token query param (from OIDC redirects). - // The management server passes the token in the URL because it cannot set - // cookies for the proxy's domain (cookies are domain-scoped per RFC 6265). + // Check for the session credential returned by the OIDC callback. The management + // server passes it in the URL because it cannot set a cookie for the proxy's + // domain (cookies are domain-scoped per RFC 6265). The current flow uses a + // single-use session_code to keep the durable token out of the URL. + // session_token remains supported for backward compatibility. + if code := r.URL.Query().Get("session_code"); code != "" { + return code, "", nil + } if token := r.URL.Query().Get("session_token"); token != "" { return token, "", nil } diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index 7c9e21261..7b0acd1b3 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -721,12 +721,13 @@ func stripSessionCookie(r *httputil.ProxyRequest) { } } -// stripSessionTokenQuery removes the OIDC session_token query parameter from -// the outgoing URL to prevent credential leakage to backends. +// stripSessionTokenQuery removes the OIDC session hand-off query parameters +// from the outgoing URL to prevent credential leakage to backends. func stripSessionTokenQuery(r *httputil.ProxyRequest) { q := r.Out.URL.Query() - if q.Has("session_token") { + if q.Has("session_token") || q.Has("session_code") { q.Del("session_token") + q.Del("session_code") r.Out.URL.RawQuery = q.Encode() } } diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index befd1f1ee..000d8ce72 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -131,7 +131,7 @@ func setupIntegrationTest(t *testing.T) *integrationTestSetup { HMACKey: []byte("test-hmac-key"), } - proxyManager := &testProxyManager{} + proxyManager := &testProxyManager{supportsSessionCode: true} proxyService := nbgrpc.NewProxyServiceServer( &testAccessLogManager{}, @@ -202,7 +202,9 @@ func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string, } // testProxyManager is a mock implementation of proxy.Manager for testing. -type testProxyManager struct{} +type testProxyManager struct { + supportsSessionCode bool +} func (m *testProxyManager) Connect(_ context.Context, proxyID, sessionID, _, _, _ string, _ *string, _ *nbproxy.Capabilities) (*nbproxy.Proxy, error) { return &nbproxy.Proxy{ID: proxyID, SessionID: sessionID, Status: nbproxy.StatusConnected}, nil @@ -244,6 +246,10 @@ func (m *testProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) * return nil } +func (m *testProxyManager) ClusterSupportsSessionCode(_ context.Context, _ string) bool { + return m.supportsSessionCode +} + func (m *testProxyManager) CleanupStale(_ context.Context, _ time.Duration) error { return nil } diff --git a/proxy/web/web.go b/proxy/web/web.go index 6773a9c1a..a45fc8730 100644 --- a/proxy/web/web.go +++ b/proxy/web/web.go @@ -181,6 +181,7 @@ func ServeAccessDeniedPage(w http.ResponseWriter, r *http.Request, code int, tit func stripAuthParams(u *url.URL) string { q := u.Query() q.Del("session_token") + q.Del("session_code") q.Del("error") q.Del("error_description") clean := *u diff --git a/shared/management/proto/proxy_service.pb.go b/shared/management/proto/proxy_service.pb.go index 496774a4b..09dff0d36 100644 --- a/shared/management/proto/proxy_service.pb.go +++ b/shared/management/proto/proxy_service.pb.go @@ -2265,8 +2265,11 @@ type ValidateSessionRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Domain string `protobuf:"bytes,1,opt,name=domain,proto3" json:"domain,omitempty"` + Domain string `protobuf:"bytes,1,opt,name=domain,proto3" json:"domain,omitempty"` + // Deprecated: Do not use. SessionToken string `protobuf:"bytes,2,opt,name=session_token,json=sessionToken,proto3" json:"session_token,omitempty"` + // session_code is a short-lived, single-use code exchanged for a session token. + SessionCode string `protobuf:"bytes,3,opt,name=session_code,json=sessionCode,proto3" json:"session_code,omitempty"` } func (x *ValidateSessionRequest) Reset() { @@ -2308,6 +2311,7 @@ func (x *ValidateSessionRequest) GetDomain() string { return "" } +// Deprecated: Do not use. func (x *ValidateSessionRequest) GetSessionToken() string { if x != nil { return x.SessionToken @@ -2315,6 +2319,13 @@ func (x *ValidateSessionRequest) GetSessionToken() string { return "" } +func (x *ValidateSessionRequest) GetSessionCode() string { + if x != nil { + return x.SessionCode + } + return "" +} + type ValidateSessionResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2333,6 +2344,8 @@ type ValidateSessionResponse struct { // Stamped onto upstream requests as X-NetBird-Groups so downstream // services can read names rather than opaque ids. PeerGroupNames []string `protobuf:"bytes,6,rep,name=peer_group_names,json=peerGroupNames,proto3" json:"peer_group_names,omitempty"` + // session_token contains the durable token issued when session_code is redeemed. + SessionToken string `protobuf:"bytes,7,opt,name=session_token,json=sessionToken,proto3" json:"session_token,omitempty"` } func (x *ValidateSessionResponse) Reset() { @@ -2409,6 +2422,13 @@ func (x *ValidateSessionResponse) GetPeerGroupNames() []string { return nil } +func (x *ValidateSessionResponse) GetSessionToken() string { + if x != nil { + return x.SessionToken + } + return "" +} + // ValidateTunnelPeerRequest carries the inbound peer's tunnel IP and the // service domain whose group requirements should gate access. The calling // account is inferred from the proxy's gRPC metadata (ProxyToken). @@ -3519,223 +3539,228 @@ var file_proxy_service_proto_rawDesc = []byte{ 0x09, 0x52, 0x0b, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22, 0x26, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x55, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, + 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x7c, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xdc, 0x01, - 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x27, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, + 0x02, 0x18, 0x01, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x64, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x43, 0x6f, 0x64, 0x65, 0x22, 0x81, 0x02, 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, + 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, + 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, + 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, + 0x73, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, + 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, + 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, + 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x06, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, + 0x6d, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x50, 0x0a, 0x19, 0x56, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, + 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, + 0x49, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x84, 0x02, 0x0a, 0x1a, 0x56, + 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, + 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, - 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, - 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x05, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, - 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, - 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, - 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x50, 0x0a, 0x19, - 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, - 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, - 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x84, - 0x02, 0x0a, 0x1a, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, - 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, - 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, - 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, - 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, - 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, - 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, - 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, - 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, - 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, - 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, - 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, - 0x04, 0x69, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, - 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, 0x04, 0x69, 0x6e, 0x69, - 0x74, 0x12, 0x2f, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, - 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, - 0x63, 0x6b, 0x42, 0x05, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, 0x0a, 0x10, 0x53, 0x79, - 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x12, 0x19, - 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, - 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, - 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, - 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, - 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, - 0x79, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63, - 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x11, 0x0a, 0x0f, 0x53, - 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x22, 0x7e, - 0x0a, 0x14, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x52, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69, 0x6e, - 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, - 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74, 0x69, - 0x61, 0x6c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x22, 0xa9, - 0x01, 0x0a, 0x1b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, - 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, - 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, - 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, - 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, - 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, - 0x49, 0x64, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, - 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, - 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0xff, 0x01, 0x0a, 0x1c, 0x43, + 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, + 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, + 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, + 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, + 0x73, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, + 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x04, 0x69, 0x6e, 0x69, + 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, + 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, 0x04, 0x69, 0x6e, 0x69, 0x74, 0x12, 0x2f, 0x0a, + 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, + 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x05, + 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, + 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x70, 0x72, + 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, + 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, + 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, + 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, + 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, + 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x11, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, + 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x22, 0x7e, 0x0a, 0x14, 0x53, 0x79, + 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x6d, + 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, + 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79, + 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x22, 0xa9, 0x01, 0x0a, 0x1b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, - 0x69, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x64, - 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, - 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x65, 0x6c, 0x65, 0x63, - 0x74, 0x65, 0x64, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x6c, - 0x69, 0x63, 0x79, 0x49, 0x64, 0x12, 0x30, 0x0a, 0x14, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, - 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x12, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, - 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, 0x64, 0x6f, - 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x1b, - 0x0a, 0x09, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x08, 0x64, 0x65, 0x6e, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x64, - 0x65, 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0a, 0x64, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x91, 0x02, 0x0a, - 0x15, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, - 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x19, - 0x0a, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, - 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, - 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x49, 0x6e, - 0x70, 0x75, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x6f, 0x75, - 0x74, 0x70, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x73, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x63, 0x6f, 0x73, 0x74, - 0x5f, 0x75, 0x73, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x07, 0x63, 0x6f, 0x73, 0x74, - 0x55, 0x73, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, - 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, - 0x22, 0x18, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, - 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72, - 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, - 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, - 0x14, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, - 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, - 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02, - 0x2a, 0x46, 0x0a, 0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, - 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, - 0x49, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x19, 0x0a, - 0x15, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x50, 0x52, - 0x45, 0x53, 0x45, 0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0x90, 0x01, 0x0a, 0x0e, 0x4d, 0x69, 0x64, - 0x64, 0x6c, 0x65, 0x77, 0x61, 0x72, 0x65, 0x53, 0x6c, 0x6f, 0x74, 0x12, 0x1f, 0x0a, 0x1b, 0x4d, - 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x55, - 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1e, 0x0a, 0x1a, - 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, - 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x01, 0x12, 0x1f, 0x0a, 0x1b, - 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, - 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10, 0x02, 0x12, 0x1c, 0x0a, - 0x18, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, - 0x5f, 0x54, 0x45, 0x52, 0x4d, 0x49, 0x4e, 0x41, 0x4c, 0x10, 0x03, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, - 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, - 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, - 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, - 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, - 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, - 0x55, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, - 0x44, 0x10, 0x02, 0x12, 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, - 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, - 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, - 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, - 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, - 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, - 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x32, 0xfc, 0x07, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, - 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, - 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, - 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, - 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, - 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, - 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, - 0x54, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, - 0x12, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, - 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, - 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, 0x65, 0x6e, 0x64, - 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, - 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, - 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, + 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, + 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, + 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, + 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, + 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x49, 0x64, + 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0xff, 0x01, 0x0a, 0x1c, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x64, 0x65, 0x63, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x63, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, + 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, + 0x64, 0x12, 0x30, 0x0a, 0x14, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, + 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x12, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x47, 0x72, 0x6f, 0x75, + 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x65, + 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, 0x69, 0x6e, + 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, + 0x6e, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, + 0x65, 0x6e, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x65, 0x6e, 0x79, 0x5f, + 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x65, + 0x6e, 0x79, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x91, 0x02, 0x0a, 0x15, 0x52, 0x65, 0x63, + 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, + 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x5f, + 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, + 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, + 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x4f, 0x75, + 0x74, 0x70, 0x75, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x63, 0x6f, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x64, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x07, 0x63, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x64, 0x12, + 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x22, 0x18, 0x0a, 0x16, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, + 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, + 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, + 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x55, 0x50, 0x44, + 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, + 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, + 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02, 0x2a, 0x46, 0x0a, 0x0f, + 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x12, + 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, + 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x41, 0x54, + 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x50, 0x52, 0x45, 0x53, 0x45, 0x52, + 0x56, 0x45, 0x10, 0x01, 0x2a, 0x90, 0x01, 0x0a, 0x0e, 0x4d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x77, + 0x61, 0x72, 0x65, 0x53, 0x6c, 0x6f, 0x74, 0x12, 0x1f, 0x0a, 0x1b, 0x4d, 0x49, 0x44, 0x44, 0x4c, + 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, + 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1e, 0x0a, 0x1a, 0x4d, 0x49, 0x44, 0x44, + 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x4f, 0x4e, 0x5f, 0x52, + 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x01, 0x12, 0x1f, 0x0a, 0x1b, 0x4d, 0x49, 0x44, 0x44, + 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x4f, 0x4e, 0x5f, 0x52, + 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10, 0x02, 0x12, 0x1c, 0x0a, 0x18, 0x4d, 0x49, 0x44, + 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x54, 0x45, 0x52, + 0x4d, 0x49, 0x4e, 0x41, 0x4c, 0x10, 0x03, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x78, + 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x58, 0x59, + 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, + 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, + 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, + 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, 0x4e, 0x45, + 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, + 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, + 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, + 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, + 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, + 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, + 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, + 0x10, 0x05, 0x32, 0xfc, 0x07, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, + 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, + 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, + 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x0d, 0x53, + 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, + 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, + 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, + 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, + 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, + 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, + 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, - 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, - 0x4c, 0x12, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, - 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, - 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x5a, 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x12, 0x1d, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, + 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, + 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, + 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, + 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x12, - 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, - 0x65, 0x72, 0x12, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, - 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, - 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x69, 0x0a, 0x14, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, - 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x12, 0x27, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, - 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, - 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0e, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, - 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x12, 0x56, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x12, 0x25, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, + 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a, + 0x14, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, + 0x69, 0x6d, 0x69, 0x74, 0x73, 0x12, 0x27, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, + 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, + 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0e, 0x52, 0x65, 0x63, 0x6f, + 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, + 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, } var ( diff --git a/shared/management/proto/proxy_service.proto b/shared/management/proto/proxy_service.proto index facadc4d5..7c5fb08eb 100644 --- a/shared/management/proto/proxy_service.proto +++ b/shared/management/proto/proxy_service.proto @@ -363,7 +363,9 @@ message GetOIDCURLResponse { message ValidateSessionRequest { string domain = 1; - string session_token = 2; + string session_token = 2 [deprecated = true]; + // session_code is a short-lived, single-use code exchanged for a session token. + string session_code = 3; } message ValidateSessionResponse { @@ -380,6 +382,8 @@ message ValidateSessionResponse { // Stamped onto upstream requests as X-NetBird-Groups so downstream // services can read names rather than opaque ids. repeated string peer_group_names = 6; + // session_token contains the durable token issued when session_code is redeemed. + string session_token = 7; } // ValidateTunnelPeerRequest carries the inbound peer's tunnel IP and the @@ -506,4 +510,3 @@ message RecordLLMUsageRequest { message RecordLLMUsageResponse { } - From 8edc120370e57bea07482412675ca9a2ac9bf808 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:41:49 +0900 Subject: [PATCH 076/209] [client] Replace the eBPF WireGuard proxy with loopback endpoint addressing (#7316) --- client/firewall/iptables/family_linux.go | 2 + client/firewall/iptables/manager_linux.go | 141 +-------- client/firewall/manager/firewall.go | 4 - client/firewall/nftables/manager_linux.go | 182 ----------- client/firewall/nftables/routing_linux.go | 2 +- client/firewall/uspfilter/filter.go | 6 - client/iface/iface.go | 7 - client/iface/iface_close_test.go | 1 - client/iface/wgproxy/ebpf/portlookup.go | 32 -- client/iface/wgproxy/ebpf/portlookup_test.go | 45 --- client/iface/wgproxy/ebpf/proxy.go | 243 --------------- client/iface/wgproxy/ebpf/proxy_test.go | 56 ---- client/iface/wgproxy/factory_kernel.go | 51 +-- client/iface/wgproxy/factory_usp.go | 5 - client/iface/wgproxy/loopback/addr.go | 70 +++++ client/iface/wgproxy/loopback/addr_test.go | 114 +++++++ client/iface/wgproxy/loopback/proxy.go | 291 ++++++++++++++++++ .../wgproxy/loopback/proxy_privileged_test.go | 196 ++++++++++++ .../wgproxy/{ebpf => loopback}/wrapper.go | 46 ++- client/iface/wgproxy/proxy_linux_test.go | 34 +- client/iface/wgproxy/redirect_test.go | 46 +-- client/internal/ebpf/ebpf/bpf_bpfeb.go | 148 --------- client/internal/ebpf/ebpf/bpf_bpfeb.o | Bin 8712 -> 0 bytes client/internal/ebpf/ebpf/bpf_bpfel.go | 148 --------- client/internal/ebpf/ebpf/bpf_bpfel.o | Bin 8712 -> 0 bytes client/internal/ebpf/ebpf/manager_linux.go | 115 ------- .../internal/ebpf/ebpf/manager_linux_test.go | 31 -- client/internal/ebpf/ebpf/src/bpf_map_def.h | 16 - client/internal/ebpf/ebpf/src/prog.c | 54 ---- client/internal/ebpf/ebpf/src/readme.md | 27 -- client/internal/ebpf/ebpf/src/wg_proxy.c | 60 ---- client/internal/ebpf/ebpf/wg_proxy_linux.go | 41 --- client/internal/ebpf/instantiater_linux.go | 15 - client/internal/ebpf/instantiater_nonlinux.go | 10 - client/internal/ebpf/manager/manager.go | 7 - client/internal/engine.go | 21 -- client/internal/engine_test.go | 7 - client/internal/iface_common.go | 1 - docs/testing-privileged.md | 3 +- go.mod | 2 +- go.sum | 2 - 41 files changed, 775 insertions(+), 1507 deletions(-) delete mode 100644 client/iface/wgproxy/ebpf/portlookup.go delete mode 100644 client/iface/wgproxy/ebpf/portlookup_test.go delete mode 100644 client/iface/wgproxy/ebpf/proxy.go delete mode 100644 client/iface/wgproxy/ebpf/proxy_test.go create mode 100644 client/iface/wgproxy/loopback/addr.go create mode 100644 client/iface/wgproxy/loopback/addr_test.go create mode 100644 client/iface/wgproxy/loopback/proxy.go create mode 100644 client/iface/wgproxy/loopback/proxy_privileged_test.go rename client/iface/wgproxy/{ebpf => loopback}/wrapper.go (84%) delete mode 100644 client/internal/ebpf/ebpf/bpf_bpfeb.go delete mode 100644 client/internal/ebpf/ebpf/bpf_bpfeb.o delete mode 100644 client/internal/ebpf/ebpf/bpf_bpfel.go delete mode 100644 client/internal/ebpf/ebpf/bpf_bpfel.o delete mode 100644 client/internal/ebpf/ebpf/manager_linux.go delete mode 100644 client/internal/ebpf/ebpf/manager_linux_test.go delete mode 100644 client/internal/ebpf/ebpf/src/bpf_map_def.h delete mode 100644 client/internal/ebpf/ebpf/src/prog.c delete mode 100644 client/internal/ebpf/ebpf/src/readme.md delete mode 100644 client/internal/ebpf/ebpf/src/wg_proxy.c delete mode 100644 client/internal/ebpf/ebpf/wg_proxy_linux.go delete mode 100644 client/internal/ebpf/instantiater_linux.go delete mode 100644 client/internal/ebpf/instantiater_nonlinux.go delete mode 100644 client/internal/ebpf/manager/manager.go diff --git a/client/firewall/iptables/family_linux.go b/client/firewall/iptables/family_linux.go index c5ed8cc20..0e1ce5440 100644 --- a/client/firewall/iptables/family_linux.go +++ b/client/firewall/iptables/family_linux.go @@ -24,6 +24,7 @@ const ( tableFilter = "filter" tableNat = "nat" tableMangle = "mangle" + tableRaw = "raw" // chainACLInput is the peer ACL chain that holds installed // peer-filtering rules. @@ -34,6 +35,7 @@ const ( mangleForwardKey chainKey = "MANGLE-FORWARD" chainInput = "INPUT" + chainOutput = "OUTPUT" chainPostrouting = "POSTROUTING" chainPrerouting = "PREROUTING" chainForward = "FORWARD" diff --git a/client/firewall/iptables/manager_linux.go b/client/firewall/iptables/manager_linux.go index 49b88f1ea..0f0b0110e 100644 --- a/client/firewall/iptables/manager_linux.go +++ b/client/firewall/iptables/manager_linux.go @@ -25,9 +25,8 @@ type Manager struct { wgIface iFaceMapper - ipv4Client *iptables.IPTables - family4 *family - rawSupported bool + ipv4Client *iptables.IPTables + family4 *family // IPv6 counterparts, nil when no v6 overlay ipv6Client *iptables.IPTables @@ -108,10 +107,6 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error { return err } - if err := m.initNoTrackChain(); err != nil { - log.Warnf("raw table not available, notrack rules will be disabled: %v", err) - } - // Trust after all fatal init steps so a later failure doesn't leave the // interface in firewalld's trusted zone without a corresponding Close. if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil { @@ -285,10 +280,6 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error { var merr *multierror.Error - if err := m.cleanupNoTrackChain(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("cleanup notrack chain: %w", err)) - } - if m.hasIPv6() { if err := m.family6.Reset(); err != nil { merr = multierror.Append(merr, fmt.Errorf("reset v6 family: %w", err)) @@ -440,134 +431,6 @@ func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Proto return m.family4.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) } -const ( - chainNameRaw = "NETBIRD-RAW" - chainOutput = "OUTPUT" - tableRaw = "raw" -) - -// SetupEBPFProxyNoTrack creates notrack rules for eBPF proxy loopback traffic. -// This prevents conntrack from tracking WireGuard proxy traffic on loopback, which -// can interfere with MASQUERADE rules (e.g., from container runtimes like Podman/netavark). -// -// Traffic flows that need NOTRACK: -// -// 1. Egress: WireGuard -> fake endpoint (before eBPF rewrite) -// src=127.0.0.1:wgPort -> dst=127.0.0.1:fakePort -// Matched by: sport=wgPort -// -// 2. Egress: Proxy -> WireGuard (via raw socket) -// src=127.0.0.1:fakePort -> dst=127.0.0.1:wgPort -// Matched by: dport=wgPort -// -// 3. Ingress: Packets to WireGuard -// dst=127.0.0.1:wgPort -// Matched by: dport=wgPort -// -// 4. Ingress: Packets to proxy (after eBPF rewrite) -// dst=127.0.0.1:proxyPort -// Matched by: dport=proxyPort -// -// Rules are cleaned up when the firewall manager is closed. -func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - if !m.rawSupported { - return fmt.Errorf("raw table not available") - } - - wgPortStr := fmt.Sprintf("%d", wgPort) - proxyPortStr := fmt.Sprintf("%d", proxyPort) - - // Egress rules: match outgoing loopback UDP packets - outputRuleSport := []string{"-o", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--sport", wgPortStr, "-j", "NOTRACK"} - if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, outputRuleSport...); err != nil { - return fmt.Errorf("add output sport notrack rule: %w", err) - } - - outputRuleDport := []string{"-o", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--dport", wgPortStr, "-j", "NOTRACK"} - if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, outputRuleDport...); err != nil { - return fmt.Errorf("add output dport notrack rule: %w", err) - } - - // Ingress rules: match incoming loopback UDP packets - preroutingRuleWg := []string{"-i", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--dport", wgPortStr, "-j", "NOTRACK"} - if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, preroutingRuleWg...); err != nil { - return fmt.Errorf("add prerouting wg notrack rule: %w", err) - } - - preroutingRuleProxy := []string{"-i", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--dport", proxyPortStr, "-j", "NOTRACK"} - if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, preroutingRuleProxy...); err != nil { - return fmt.Errorf("add prerouting proxy notrack rule: %w", err) - } - - log.Debugf("set up ebpf proxy notrack rules for ports %d,%d", proxyPort, wgPort) - return nil -} - -func (m *Manager) initNoTrackChain() error { - if err := m.cleanupNoTrackChain(); err != nil { - log.Debugf("cleanup notrack chain: %v", err) - } - - if err := m.ipv4Client.NewChain(tableRaw, chainNameRaw); err != nil { - return fmt.Errorf("create chain: %w", err) - } - - jumpRule := []string{"-j", chainNameRaw} - - if err := m.ipv4Client.InsertUnique(tableRaw, chainOutput, 1, jumpRule...); err != nil { - if delErr := m.ipv4Client.DeleteChain(tableRaw, chainNameRaw); delErr != nil { - log.Debugf("delete orphan chain: %v", delErr) - } - return fmt.Errorf("add output jump rule: %w", err) - } - - if err := m.ipv4Client.InsertUnique(tableRaw, chainPrerouting, 1, jumpRule...); err != nil { - if delErr := m.ipv4Client.DeleteIfExists(tableRaw, chainOutput, jumpRule...); delErr != nil { - log.Debugf("delete output jump rule: %v", delErr) - } - if delErr := m.ipv4Client.DeleteChain(tableRaw, chainNameRaw); delErr != nil { - log.Debugf("delete orphan chain: %v", delErr) - } - return fmt.Errorf("add prerouting jump rule: %w", err) - } - - m.rawSupported = true - return nil -} - -func (m *Manager) cleanupNoTrackChain() error { - exists, err := m.ipv4Client.ChainExists(tableRaw, chainNameRaw) - if err != nil { - if !m.rawSupported { - return nil - } - return fmt.Errorf("check chain exists: %w", err) - } - if !exists { - return nil - } - - jumpRule := []string{"-j", chainNameRaw} - - if err := m.ipv4Client.DeleteIfExists(tableRaw, chainOutput, jumpRule...); err != nil { - return fmt.Errorf("remove output jump rule: %w", err) - } - - if err := m.ipv4Client.DeleteIfExists(tableRaw, chainPrerouting, jumpRule...); err != nil { - return fmt.Errorf("remove prerouting jump rule: %w", err) - } - - if err := m.ipv4Client.ClearAndDeleteChain(tableRaw, chainNameRaw); err != nil { - return fmt.Errorf("clear and delete chain: %w", err) - } - - m.rawSupported = false - return nil -} - func getConntrackEstablished() []string { return []string{"-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"} } diff --git a/client/firewall/manager/firewall.go b/client/firewall/manager/firewall.go index 97a94d0f5..0eb376875 100644 --- a/client/firewall/manager/firewall.go +++ b/client/firewall/manager/firewall.go @@ -192,10 +192,6 @@ type Manager interface { // RemoveOutputDNAT removes an OUTPUT chain DNAT rule. RemoveOutputDNAT(localAddr netip.Addr, protocol Protocol, originalPort, translatedPort uint16) error - - // SetupEBPFProxyNoTrack creates static notrack rules for eBPF proxy loopback traffic. - // This prevents conntrack from interfering with WireGuard proxy communication. - SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error } // GenKey builds the rule id for this pair from the given format. diff --git a/client/firewall/nftables/manager_linux.go b/client/firewall/nftables/manager_linux.go index dbd5e4fa2..87651761f 100644 --- a/client/firewall/nftables/manager_linux.go +++ b/client/firewall/nftables/manager_linux.go @@ -12,7 +12,6 @@ import ( "github.com/google/nftables/expr" "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" - "golang.org/x/sys/unix" nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" @@ -55,9 +54,6 @@ type Manager struct { // IPv6 counterpart, nil when no v6 overlay. family6 *family - notrackOutputChain *nftables.Chain - notrackPreroutingChain *nftables.Chain - extMonitor *externalChainMonitor } @@ -170,10 +166,6 @@ func (m *Manager) initFirewall() (err error) { } } - if err := m.initNoTrackChains(workTable); err != nil { - log.Warnf("raw priority chains not available, notrack rules will be disabled: %v", err) - } - return nil } @@ -455,10 +447,6 @@ func (m *Manager) Flush() error { } } - if err := m.refreshNoTrackChains(); err != nil { - log.Errorf("failed to refresh notrack chains: %v", err) - } - return nil } @@ -571,176 +559,6 @@ func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Proto return m.family4.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) } -const ( - chainNameRawOutput = "netbird-raw-out" - chainNameRawPrerouting = "netbird-raw-pre" -) - -// SetupEBPFProxyNoTrack creates notrack rules for eBPF proxy loopback traffic. -// This prevents conntrack from tracking WireGuard proxy traffic on loopback, which -// can interfere with MASQUERADE rules (e.g., from container runtimes like Podman/netavark). -// -// Traffic flows that need NOTRACK: -// -// 1. Egress: WireGuard -> fake endpoint (before eBPF rewrite) -// src=127.0.0.1:wgPort -> dst=127.0.0.1:fakePort -// Matched by: sport=wgPort -// -// 2. Egress: Proxy -> WireGuard (via raw socket) -// src=127.0.0.1:fakePort -> dst=127.0.0.1:wgPort -// Matched by: dport=wgPort -// -// 3. Ingress: Packets to WireGuard -// dst=127.0.0.1:wgPort -// Matched by: dport=wgPort -// -// 4. Ingress: Packets to proxy (after eBPF rewrite) -// dst=127.0.0.1:proxyPort -// Matched by: dport=proxyPort -// -// Rules are cleaned up when the firewall manager is closed. -func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - if m.notrackOutputChain == nil || m.notrackPreroutingChain == nil { - return fmt.Errorf("notrack chains not initialized") - } - - proxyPortBytes := binaryutil.BigEndian.PutUint16(proxyPort) - wgPortBytes := binaryutil.BigEndian.PutUint16(wgPort) - loopback := []byte{127, 0, 0, 1} - - // Egress rules: match outgoing loopback UDP packets - m.rConn.AddRule(&nftables.Rule{ - Table: m.notrackOutputChain.Table, - Chain: m.notrackOutputChain, - Exprs: []expr.Any{ - &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 0, Len: 2}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: wgPortBytes}, // sport=wgPort - &expr.Counter{}, - &expr.Notrack{}, - }, - }) - m.rConn.AddRule(&nftables.Rule{ - Table: m.notrackOutputChain.Table, - Chain: m.notrackOutputChain, - Exprs: []expr.Any{ - &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: wgPortBytes}, // dport=wgPort - &expr.Counter{}, - &expr.Notrack{}, - }, - }) - - // Ingress rules: match incoming loopback UDP packets - m.rConn.AddRule(&nftables.Rule{ - Table: m.notrackPreroutingChain.Table, - Chain: m.notrackPreroutingChain, - Exprs: []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: wgPortBytes}, // dport=wgPort - &expr.Counter{}, - &expr.Notrack{}, - }, - }) - m.rConn.AddRule(&nftables.Rule{ - Table: m.notrackPreroutingChain.Table, - Chain: m.notrackPreroutingChain, - Exprs: []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}}, - &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, - &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: proxyPortBytes}, // dport=proxyPort - &expr.Counter{}, - &expr.Notrack{}, - }, - }) - - if err := m.rConn.Flush(); err != nil { - return fmt.Errorf("flush notrack rules: %w", err) - } - - log.Debugf("set up ebpf proxy notrack rules for ports %d,%d", proxyPort, wgPort) - return nil -} - -func (m *Manager) initNoTrackChains(table *nftables.Table) error { - m.notrackOutputChain = m.rConn.AddChain(&nftables.Chain{ - Name: chainNameRawOutput, - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookOutput, - Priority: nftables.ChainPriorityRaw, - }) - - m.notrackPreroutingChain = m.rConn.AddChain(&nftables.Chain{ - Name: chainNameRawPrerouting, - Table: table, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityRaw, - }) - - if err := m.rConn.Flush(); err != nil { - return fmt.Errorf("flush chain creation: %w", err) - } - - return nil -} - -func (m *Manager) refreshNoTrackChains() error { - chains, err := m.rConn.ListChainsOfTableFamily(nftables.TableFamilyIPv4) - if err != nil { - return fmt.Errorf("list chains: %w", err) - } - - tableName := getTableName() - for _, c := range chains { - if c.Table.Name != tableName { - continue - } - switch c.Name { - case chainNameRawOutput: - m.notrackOutputChain = c - case chainNameRawPrerouting: - m.notrackPreroutingChain = c - } - } - - return nil -} - func (m *Manager) createWorkTable() (*nftables.Table, error) { return m.createWorkTableFamily(nftables.TableFamilyIPv4) } diff --git a/client/firewall/nftables/routing_linux.go b/client/firewall/nftables/routing_linux.go index 4115c94bd..d619c5543 100644 --- a/client/firewall/nftables/routing_linux.go +++ b/client/firewall/nftables/routing_linux.go @@ -192,7 +192,7 @@ func (r *family) addPostroutingRules() { Data: binaryutil.NativeEndian.PutUint32(nbnet.PreroutingFwmarkMasquerade), }, - // We need to exclude the loopback interface as this changes the ebpf proxy port + // We need to exclude the loopback interface as this changes the wg proxy port &expr.Meta{ Key: expr.MetaKeyOIFNAME, Register: 1, diff --git a/client/firewall/uspfilter/filter.go b/client/firewall/uspfilter/filter.go index 5e1366c1f..0c73400f1 100644 --- a/client/firewall/uspfilter/filter.go +++ b/client/firewall/uspfilter/filter.go @@ -879,12 +879,6 @@ func (m *Manager) resetState() { } } -// SetupEBPFProxyNoTrack is not supported by the userspace firewall: eBPF isn't -// used in userspace mode, so this should never be called. -func (m *Manager) SetupEBPFProxyNoTrack(uint16, uint16) error { - return errNotSupported -} - // UpdateSet updates the rule destinations associated with the given set // by merging the existing prefixes with the new ones, then deduplicating. func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { diff --git a/client/iface/iface.go b/client/iface/iface.go index 247f421a2..f6006fa87 100644 --- a/client/iface/iface.go +++ b/client/iface/iface.go @@ -51,7 +51,6 @@ func ValidateMTU(mtu uint16) error { type wgProxyFactory interface { GetProxy() wgproxy.Proxy - GetProxyPort() uint16 Free() error } @@ -81,12 +80,6 @@ func (w *WGIface) GetProxy() wgproxy.Proxy { return w.wgProxyFactory.GetProxy() } -// GetProxyPort returns the proxy port used by the WireGuard proxy. -// Returns 0 if no proxy port is used (e.g., for userspace WireGuard). -func (w *WGIface) GetProxyPort() uint16 { - return w.wgProxyFactory.GetProxyPort() -} - // GetBind returns the EndpointManager userspace bind mode. func (w *WGIface) GetBind() device.EndpointManager { w.mu.Lock() diff --git a/client/iface/iface_close_test.go b/client/iface/iface_close_test.go index 171e15d0a..ea3115ec0 100644 --- a/client/iface/iface_close_test.go +++ b/client/iface/iface_close_test.go @@ -52,7 +52,6 @@ func (f *fakeTunDevice) Close() error { type fakeProxyFactory struct{} func (fakeProxyFactory) GetProxy() wgproxy.Proxy { return nil } -func (fakeProxyFactory) GetProxyPort() uint16 { return 0 } func (fakeProxyFactory) Free() error { return nil } // TestWGIface_CloseReleasesMutexBeforeTunClose guards against a deadlock diff --git a/client/iface/wgproxy/ebpf/portlookup.go b/client/iface/wgproxy/ebpf/portlookup.go deleted file mode 100644 index fce8f1507..000000000 --- a/client/iface/wgproxy/ebpf/portlookup.go +++ /dev/null @@ -1,32 +0,0 @@ -package ebpf - -import ( - "fmt" - "net" -) - -var ( - portRangeStart = 3128 - portRangeEnd = portRangeStart + 100 -) - -type portLookup struct { -} - -func (pl portLookup) searchFreePort() (int, error) { - for i := portRangeStart; i <= portRangeEnd; i++ { - if pl.tryToBind(i) == nil { - return i, nil - } - } - return 0, fmt.Errorf("failed to bind free port for eBPF proxy") -} - -func (pl portLookup) tryToBind(port int) error { - l, err := net.ListenPacket("udp", fmt.Sprintf(":%d", port)) - if err != nil { - return err - } - _ = l.Close() - return nil -} diff --git a/client/iface/wgproxy/ebpf/portlookup_test.go b/client/iface/wgproxy/ebpf/portlookup_test.go deleted file mode 100644 index a2e92fc79..000000000 --- a/client/iface/wgproxy/ebpf/portlookup_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package ebpf - -import ( - "fmt" - "net" - "testing" -) - -func Test_portLookup_searchFreePort(t *testing.T) { - pl := portLookup{} - _, err := pl.searchFreePort() - if err != nil { - t.Fatal(err) - } -} - -func Test_portLookup_on_allocated(t *testing.T) { - pl := portLookup{} - - portRangeStart = 4128 - portRangeEnd = portRangeStart + 100 - - allocatedPort, err := allocatePort(portRangeStart) - if err != nil { - t.Fatal(err) - } - defer allocatedPort.Close() - - fp, err := pl.searchFreePort() - if err != nil { - t.Fatal(err) - } - - if fp != (portRangeStart + 1) { - t.Errorf("invalid free port, expected: %d, got: %d", portRangeStart+1, fp) - } -} - -func allocatePort(port int) (net.PacketConn, error) { - c, err := net.ListenPacket("udp", fmt.Sprintf(":%d", port)) - if err != nil { - return nil, err - } - return c, err -} diff --git a/client/iface/wgproxy/ebpf/proxy.go b/client/iface/wgproxy/ebpf/proxy.go deleted file mode 100644 index 91c741c0d..000000000 --- a/client/iface/wgproxy/ebpf/proxy.go +++ /dev/null @@ -1,243 +0,0 @@ -//go:build linux && !android - -package ebpf - -import ( - "context" - "fmt" - "net" - "sync" - - "github.com/hashicorp/go-multierror" - "github.com/pion/transport/v3" - log "github.com/sirupsen/logrus" - - nberrors "github.com/netbirdio/netbird/client/errors" - "github.com/netbirdio/netbird/client/iface/bufsize" - "github.com/netbirdio/netbird/client/iface/wgproxy/rawsocket" - "github.com/netbirdio/netbird/client/internal/ebpf" - ebpfMgr "github.com/netbirdio/netbird/client/internal/ebpf/manager" - nbnet "github.com/netbirdio/netbird/client/net" -) - -const ( - loopbackAddr = "127.0.0.1" -) - -// WGEBPFProxy definition for proxy with EBPF support -type WGEBPFProxy struct { - localWGListenPort int - proxyPort int - mtu uint16 - - ebpfManager ebpfMgr.Manager - relayedConnStore map[uint16]net.Conn - relayedConnMutex sync.Mutex - - lastUsedPort uint16 - rawConnIPv4 net.PacketConn - rawConnIPv6 net.PacketConn - conn transport.UDPConn - - ctx context.Context - ctxCancel context.CancelFunc -} - -// NewWGEBPFProxy create new WGEBPFProxy instance -func NewWGEBPFProxy(wgPort int, mtu uint16) *WGEBPFProxy { - log.Debugf("instantiate ebpf proxy") - wgProxy := &WGEBPFProxy{ - localWGListenPort: wgPort, - mtu: mtu, - ebpfManager: ebpf.GetEbpfManagerInstance(), - relayedConnStore: make(map[uint16]net.Conn), - } - return wgProxy -} - -// Listen load ebpf program and listen the proxy -func (p *WGEBPFProxy) Listen() error { - pl := portLookup{} - proxyPort, err := pl.searchFreePort() - if err != nil { - return err - } - p.proxyPort = proxyPort - - // Prepare IPv4 raw socket (required) - p.rawConnIPv4, err = rawsocket.PrepareSenderRawSocketIPv4() - if err != nil { - return err - } - - // Prepare IPv6 raw socket (optional) - p.rawConnIPv6, err = rawsocket.PrepareSenderRawSocketIPv6() - if err != nil { - log.Warnf("failed to prepare IPv6 raw socket, continuing with IPv4 only: %v", err) - } - - err = p.ebpfManager.LoadWgProxy(proxyPort, p.localWGListenPort) - if err != nil { - if closeErr := p.rawConnIPv4.Close(); closeErr != nil { - log.Warnf("failed to close IPv4 raw socket: %v", closeErr) - } - if p.rawConnIPv6 != nil { - if closeErr := p.rawConnIPv6.Close(); closeErr != nil { - log.Warnf("failed to close IPv6 raw socket: %v", closeErr) - } - } - return err - } - - addr := net.UDPAddr{ - Port: proxyPort, - IP: net.ParseIP(loopbackAddr), - } - - p.ctx, p.ctxCancel = context.WithCancel(context.Background()) - - conn, err := nbnet.ListenUDP("udp", &addr) - if err != nil { - if cErr := p.Free(); cErr != nil { - log.Errorf("Failed to close the wgproxy: %s", cErr) - } - return err - } - p.conn = conn - - go p.proxyToRemote() - log.Infof("local wg proxy listening on: %d", proxyPort) - return nil -} - -// AddRelayedConn add new relayed connection for the proxy -func (p *WGEBPFProxy) AddRelayedConn(relayedConn net.Conn) (*net.UDPAddr, error) { - wgEndpointPort, err := p.storeRelayedConn(relayedConn) - if err != nil { - return nil, err - } - - log.Infof("relayed conn added to wg proxy store: %s, endpoint port: :%d", relayedConn.RemoteAddr(), wgEndpointPort) - - wgEndpoint := &net.UDPAddr{ - IP: net.ParseIP(loopbackAddr), - Port: int(wgEndpointPort), - } - return wgEndpoint, nil -} - -// Free resources except the remoteConns will be keep open. -func (p *WGEBPFProxy) Free() error { - log.Debugf("free up ebpf wg proxy") - if p.ctx != nil && p.ctx.Err() != nil { - //nolint - return nil - } - - p.ctxCancel() - - var result *multierror.Error - if p.conn != nil { - if err := p.conn.Close(); err != nil { - result = multierror.Append(result, err) - } - } - - if err := p.ebpfManager.FreeWGProxy(); err != nil { - result = multierror.Append(result, err) - } - - if p.rawConnIPv4 != nil { - if err := p.rawConnIPv4.Close(); err != nil { - result = multierror.Append(result, err) - } - } - - if p.rawConnIPv6 != nil { - if err := p.rawConnIPv6.Close(); err != nil { - result = multierror.Append(result, err) - } - } - return nberrors.FormatErrorOrNil(result) -} - -// GetProxyPort returns the proxy listening port. -func (p *WGEBPFProxy) GetProxyPort() uint16 { - return uint16(p.proxyPort) -} - -// proxyToRemote read messages from local WireGuard interface and forward it to remote conn -// From this go routine has only one instance. -func (p *WGEBPFProxy) proxyToRemote() { - buf := make([]byte, p.mtu+bufsize.WGBufferOverhead) - for p.ctx.Err() == nil { - if err := p.readAndForwardPacket(buf); err != nil { - if p.ctx.Err() != nil { - return - } - log.Errorf("failed to proxy packet to remote conn: %s", err) - } - } -} - -func (p *WGEBPFProxy) readAndForwardPacket(buf []byte) error { - n, addr, err := p.conn.ReadFromUDP(buf) - if err != nil { - return fmt.Errorf("failed to read UDP packet from WG: %w", err) - } - - p.relayedConnMutex.Lock() - conn, ok := p.relayedConnStore[uint16(addr.Port)] - p.relayedConnMutex.Unlock() - if !ok { - if p.ctx.Err() == nil { - log.Debugf("relayed conn not found by port because conn already has been closed: %d", addr.Port) - } - return nil - } - - if _, err := conn.Write(buf[:n]); err != nil { - return fmt.Errorf("forward local WG packet (%d) to remote relayed conn: %w", addr.Port, err) - } - return nil -} - -func (p *WGEBPFProxy) storeRelayedConn(relayedConn net.Conn) (uint16, error) { - p.relayedConnMutex.Lock() - defer p.relayedConnMutex.Unlock() - - np, err := p.nextFreePort() - if err != nil { - return np, err - } - p.relayedConnStore[np] = relayedConn - return np, nil -} - -func (p *WGEBPFProxy) removeRelayedConn(relayedConnID uint16) { - p.relayedConnMutex.Lock() - defer p.relayedConnMutex.Unlock() - - _, ok := p.relayedConnStore[relayedConnID] - if ok { - log.Debugf("remove relayed conn from store by port: %d", relayedConnID) - } - delete(p.relayedConnStore, relayedConnID) -} - -func (p *WGEBPFProxy) nextFreePort() (uint16, error) { - if len(p.relayedConnStore) == 65535 { - return 0, fmt.Errorf("reached maximum relayed connection numbers") - } -generatePort: - if p.lastUsedPort == 65535 { - p.lastUsedPort = 1 - } else { - p.lastUsedPort++ - } - - if _, ok := p.relayedConnStore[p.lastUsedPort]; ok { - goto generatePort - } - return p.lastUsedPort, nil -} diff --git a/client/iface/wgproxy/ebpf/proxy_test.go b/client/iface/wgproxy/ebpf/proxy_test.go deleted file mode 100644 index 228c06c9b..000000000 --- a/client/iface/wgproxy/ebpf/proxy_test.go +++ /dev/null @@ -1,56 +0,0 @@ -//go:build linux && !android - -package ebpf - -import ( - "testing" -) - -func TestWGEBPFProxy_connStore(t *testing.T) { - wgProxy := NewWGEBPFProxy(1, 1280) - - p, _ := wgProxy.storeRelayedConn(nil) - if p != 1 { - t.Errorf("invalid initial port: %d", wgProxy.lastUsedPort) - } - - numOfConns := 10 - for i := 0; i < numOfConns; i++ { - p, _ = wgProxy.storeRelayedConn(nil) - } - if p != uint16(numOfConns)+1 { - t.Errorf("invalid last used port: %d, expected: %d", p, numOfConns+1) - } - if len(wgProxy.relayedConnStore) != numOfConns+1 { - t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), numOfConns+1) - } -} - -func TestWGEBPFProxy_portCalculation_overflow(t *testing.T) { - wgProxy := NewWGEBPFProxy(1, 1280) - - _, _ = wgProxy.storeRelayedConn(nil) - wgProxy.lastUsedPort = 65535 - p, _ := wgProxy.storeRelayedConn(nil) - - if len(wgProxy.relayedConnStore) != 2 { - t.Errorf("invalid store size: %d, expected: %d", len(wgProxy.relayedConnStore), 2) - } - - if p != 2 { - t.Errorf("invalid last used port: %d, expected: %d", p, 2) - } -} - -func TestWGEBPFProxy_portCalculation_maxConn(t *testing.T) { - wgProxy := NewWGEBPFProxy(1, 1280) - - for i := 0; i < 65535; i++ { - _, _ = wgProxy.storeRelayedConn(nil) - } - - _, err := wgProxy.storeRelayedConn(nil) - if err == nil { - t.Errorf("invalid relayed conn store calculation") - } -} diff --git a/client/iface/wgproxy/factory_kernel.go b/client/iface/wgproxy/factory_kernel.go index 7821df3de..0b2329b96 100644 --- a/client/iface/wgproxy/factory_kernel.go +++ b/client/iface/wgproxy/factory_kernel.go @@ -8,11 +8,13 @@ import ( log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/client/iface/wgproxy/ebpf" + "github.com/netbirdio/netbird/client/iface/wgproxy/loopback" udpProxy "github.com/netbirdio/netbird/client/iface/wgproxy/udp" ) const ( + envDisableKernelWGProxy = "NB_DISABLE_KERNEL_WG_PROXY" + // envDisableEBPFWGProxy is a deprecated alias for envDisableKernelWGProxy. envDisableEBPFWGProxy = "NB_DISABLE_EBPF_WG_PROXY" ) @@ -20,7 +22,7 @@ type KernelFactory struct { wgPort int mtu uint16 - ebpfProxy *ebpf.WGEBPFProxy + loopbackProxy *loopback.Proxy } func NewKernelFactory(wgPort int, mtu uint16) *KernelFactory { @@ -29,55 +31,56 @@ func NewKernelFactory(wgPort int, mtu uint16) *KernelFactory { mtu: mtu, } - if isEBPFDisabled() { + if isKernelProxyDisabled() { log.Infof("WireGuard Proxy Factory will produce UDP proxy") - log.Infof("eBPF WireGuard proxy is disabled via %s environment variable", envDisableEBPFWGProxy) return f } - ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, mtu) - if err := ebpfProxy.Listen(); err != nil { + loopbackProxy := loopback.NewProxy(wgPort, mtu) + if err := loopbackProxy.Listen(); err != nil { log.Infof("WireGuard Proxy Factory will produce UDP proxy") - log.Warnf("failed to initialize ebpf proxy, fallback to user space proxy: %s", err) + log.Warnf("failed to initialize loopback proxy, fallback to user space proxy: %s", err) return f } - log.Infof("WireGuard Proxy Factory will produce eBPF proxy") - f.ebpfProxy = ebpfProxy + log.Infof("WireGuard Proxy Factory will produce loopback proxy") + f.loopbackProxy = loopbackProxy return f } func (w *KernelFactory) GetProxy() Proxy { - if w.ebpfProxy == nil { + if w.loopbackProxy == nil { return udpProxy.NewWGUDPProxy(w.wgPort, w.mtu) } - return ebpf.NewProxyWrapper(w.ebpfProxy) -} - -// GetProxyPort returns the eBPF proxy port, or 0 if eBPF is not active. -func (w *KernelFactory) GetProxyPort() uint16 { - if w.ebpfProxy == nil { - return 0 - } - return w.ebpfProxy.GetProxyPort() + return loopback.NewProxyWrapper(w.loopbackProxy) } func (w *KernelFactory) Free() error { - if w.ebpfProxy == nil { + if w.loopbackProxy == nil { return nil } - return w.ebpfProxy.Free() + return w.loopbackProxy.Free() } -func isEBPFDisabled() bool { - val := os.Getenv(envDisableEBPFWGProxy) +func isKernelProxyDisabled() bool { + env := envDisableKernelWGProxy + val := os.Getenv(env) + if val == "" { + env = envDisableEBPFWGProxy + val = os.Getenv(env) + } if val == "" { return false } + disabled, err := strconv.ParseBool(val) if err != nil { - log.Warnf("failed to parse %s: %v", envDisableEBPFWGProxy, err) + log.Warnf("failed to parse %s: %v", env, err) return false } + + if disabled { + log.Infof("kernel WireGuard proxy is disabled via %s", env) + } return disabled } diff --git a/client/iface/wgproxy/factory_usp.go b/client/iface/wgproxy/factory_usp.go index bbd67e076..a1b1c34d7 100644 --- a/client/iface/wgproxy/factory_usp.go +++ b/client/iface/wgproxy/factory_usp.go @@ -24,11 +24,6 @@ func (w *USPFactory) GetProxy() Proxy { return proxyBind.NewProxyBind(w.bind, w.mtu) } -// GetProxyPort returns 0 as userspace WireGuard doesn't use a separate proxy port. -func (w *USPFactory) GetProxyPort() uint16 { - return 0 -} - func (w *USPFactory) Free() error { return nil } diff --git a/client/iface/wgproxy/loopback/addr.go b/client/iface/wgproxy/loopback/addr.go new file mode 100644 index 000000000..52feee295 --- /dev/null +++ b/client/iface/wgproxy/loopback/addr.go @@ -0,0 +1,70 @@ +//go:build linux && !android + +package loopback + +import ( + "fmt" + "net/netip" +) + +// Peer endpoints live in the upper half of 127.0.0.0/8. Everything in that +// range is delivered to the loopback device without any address or route being +// configured, and staying out of 127.0.0.0/9 keeps well-known squatters such as +// 127.0.0.53 (systemd-resolved) and 127.0.1.1 out of the way. +const ( + addrRangeBase uint32 = 0x7f800000 // 127.128.0.0 + addrRangeSize uint32 = 1 << 23 // /9 + addrRangePrefix = "127.128.0.0/9" +) + +// allocator hands out one loopback address per relayed connection. The address +// is the peer's identity: WireGuard sends to it, and the proxy recovers which +// peer a packet belongs to from the destination address. +type allocator struct { + cursor uint32 +} + +// next returns the first free address at or after the cursor, wrapping once. +// inUse reports whether an address is already handed out. +func (a *allocator) next(inUse func(netip.Addr) bool) (netip.Addr, error) { + for i := uint32(0); i < addrRangeSize; i++ { + a.cursor = (a.cursor + 1) % addrRangeSize + addr := addrFromOffset(a.cursor) + if !addr.IsValid() { + continue + } + if inUse(addr) { + continue + } + return addr, nil + } + return netip.Addr{}, fmt.Errorf("no free endpoint address in %s", addrRangePrefix) +} + +// addrFromOffset maps an offset in the range to an address, skipping the .0 and +// .255 hosts. They are unremarkable on loopback, but tools and firewall rules +// tend to treat them as network and broadcast addresses. +func addrFromOffset(offset uint32) netip.Addr { + last := offset & 0xff + if last == 0 || last == 0xff { + return netip.Addr{} + } + + v := addrRangeBase + offset + return netip.AddrFrom4([4]byte{ + byte(v >> 24), + byte(v >> 16), + byte(v >> 8), + byte(v), + }) +} + +// inRange reports whether addr is one this proxy could have handed out. +func inRange(addr netip.Addr) bool { + if !addr.Is4() { + return false + } + b := addr.As4() + v := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) + return v >= addrRangeBase && v < addrRangeBase+addrRangeSize && b[3] != 0 && b[3] != 0xff +} diff --git a/client/iface/wgproxy/loopback/addr_test.go b/client/iface/wgproxy/loopback/addr_test.go new file mode 100644 index 000000000..3755b7269 --- /dev/null +++ b/client/iface/wgproxy/loopback/addr_test.go @@ -0,0 +1,114 @@ +//go:build linux && !android + +package loopback + +import ( + "net/netip" + "testing" +) + +func TestAllocatorHandsOutDistinctAddresses(t *testing.T) { + var a allocator + taken := make(map[netip.Addr]bool) + + for i := 0; i < 1000; i++ { + addr, err := a.next(func(candidate netip.Addr) bool { return taken[candidate] }) + if err != nil { + t.Fatalf("allocate %d: %v", i, err) + } + if taken[addr] { + t.Fatalf("address %s handed out twice", addr) + } + if !inRange(addr) { + t.Fatalf("address %s outside %s", addr, addrRangePrefix) + } + taken[addr] = true + } +} + +func TestAllocatorSkipsNetworkAndBroadcastHosts(t *testing.T) { + var a allocator + taken := make(map[netip.Addr]bool) + + // enough allocations to walk past a .255/.0 boundary + for i := 0; i < 600; i++ { + addr, err := a.next(func(candidate netip.Addr) bool { return taken[candidate] }) + if err != nil { + t.Fatalf("allocate %d: %v", i, err) + } + last := addr.As4()[3] + if last == 0 || last == 255 { + t.Fatalf("address %s ends in .%d", addr, last) + } + taken[addr] = true + } +} + +func TestAllocatorReusesReleasedAddresses(t *testing.T) { + var a allocator + taken := make(map[netip.Addr]bool) + inUse := func(candidate netip.Addr) bool { return taken[candidate] } + alloc := func() netip.Addr { + t.Helper() + addr, err := a.next(inUse) + if err != nil { + t.Fatalf("allocate: %v", err) + } + taken[addr] = true + return addr + } + + first := alloc() + second := alloc() + delete(taken, first) + + // The cursor only moves forward, so a released address comes back after a + // wrap. Park the cursor near the end of the range instead of allocating + // 2^23 addresses: the next call takes the last usable address, and the one + // after that wraps past the skipped .255 and .0 hosts to the released one. + a.cursor = addrRangeSize - 3 + last := alloc() + if want := netip.MustParseAddr("127.255.255.254"); last != want { + t.Fatalf("expected the last usable address %s before the wrap, got %s", want, last) + } + + if reused := alloc(); reused != first { + t.Fatalf("expected the released address %s after the wrap, got %s", first, reused) + } + + // second is still held, so the allocator must step over it. + if next := alloc(); next == second { + t.Fatalf("allocator handed out %s while it was still in use", second) + } +} + +func TestInRange(t *testing.T) { + tests := []struct { + addr string + want bool + }{ + {"127.128.0.1", true}, + {"127.255.255.254", true}, + {"127.128.0.0", false}, // network host, never handed out + {"127.128.5.255", false}, // broadcast host, never handed out + {"127.127.255.255", false}, // below the range, where 127.0.0.53 and friends live + {"127.0.0.1", false}, + {"127.0.0.53", false}, + {"127.0.1.1", false}, + {"128.0.0.1", false}, + {"10.0.0.1", false}, + } + + for _, tc := range tests { + addr := netip.MustParseAddr(tc.addr) + if got := inRange(addr); got != tc.want { + t.Errorf("inRange(%s) = %v, want %v", tc.addr, got, tc.want) + } + } +} + +func TestInRangeIgnoresIPv6(t *testing.T) { + if inRange(netip.MustParseAddr("::1")) { + t.Error("inRange(::1) = true, want false") + } +} diff --git a/client/iface/wgproxy/loopback/proxy.go b/client/iface/wgproxy/loopback/proxy.go new file mode 100644 index 000000000..f9364c766 --- /dev/null +++ b/client/iface/wgproxy/loopback/proxy.go @@ -0,0 +1,291 @@ +//go:build linux && !android + +package loopback + +import ( + "context" + "fmt" + "net" + "net/netip" + "sync" + "syscall" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "golang.org/x/net/ipv4" + "golang.org/x/sys/unix" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/iface/bufsize" + "github.com/netbirdio/netbird/client/iface/wgproxy/rawsocket" +) + +const ( + loopbackDevice = "lo" + + portRangeStart = 3128 + portRangeEnd = portRangeStart + 100 +) + +// Proxy forwards packets between relayed connections and a local kernel +// WireGuard instance. Every relayed peer gets its own loopback address as its +// WireGuard endpoint, so a single socket serves all of them: the destination +// address of an incoming packet identifies the peer. +type Proxy struct { + localWGListenPort int + mtu uint16 + proxyPort int + + conn *net.UDPConn + packetConn *ipv4.PacketConn + loIndex int + rawConnIPv4 net.PacketConn + rawConnIPv6 net.PacketConn + + relayedConnMutex sync.Mutex + relayedConnStore map[netip.Addr]net.Conn + addrs allocator + + ctx context.Context + ctxCancel context.CancelFunc +} + +// NewProxy creates a proxy for the WireGuard instance listening on wgPort. +func NewProxy(wgPort int, mtu uint16) *Proxy { + log.Debugf("instantiate loopback wg proxy") + return &Proxy{ + localWGListenPort: wgPort, + mtu: mtu, + relayedConnStore: make(map[netip.Addr]net.Conn), + } +} + +// Listen opens the shared socket and starts forwarding WireGuard packets to the +// relayed connections. +func (p *Proxy) Listen() error { + rawConnIPv4, err := rawsocket.PrepareSenderRawSocketIPv4() + if err != nil { + return fmt.Errorf("prepare IPv4 raw socket: %w", err) + } + p.rawConnIPv4 = rawConnIPv4 + + p.rawConnIPv6, err = rawsocket.PrepareSenderRawSocketIPv6() + if err != nil { + log.Warnf("failed to prepare IPv6 raw socket, continuing with IPv4 only: %v", err) + } + + loopback, err := net.InterfaceByName(loopbackDevice) + if err != nil { + if freeErr := p.Free(); freeErr != nil { + log.Errorf("failed to free the wgproxy: %s", freeErr) + } + return fmt.Errorf("look up %s: %w", loopbackDevice, err) + } + p.loIndex = loopback.Index + + if err := p.listen(); err != nil { + if freeErr := p.Free(); freeErr != nil { + log.Errorf("failed to free the wgproxy: %s", freeErr) + } + return err + } + + p.ctx, p.ctxCancel = context.WithCancel(context.Background()) + + go p.proxyToRemote() + log.Infof("local wg proxy listening on %s:%d", addrRangePrefix, p.proxyPort) + return nil +} + +// listen binds the shared socket on the first free port of the range. The bind +// has to be a wildcard one to receive every peer address in the range, so it is +// restricted to the loopback device: without that the port would be reachable +// on every interface. +func (p *Proxy) listen() error { + var lastErr error + for port := portRangeStart; port <= portRangeEnd; port++ { + err := p.listenOn(port) + if err == nil { + p.proxyPort = port + return nil + } + lastErr = err + } + return fmt.Errorf("bind proxy port in range %d-%d: %w", portRangeStart, portRangeEnd, lastErr) +} + +func (p *Proxy) listenOn(proxyPort int) error { + lc := net.ListenConfig{ + Control: func(_, _ string, c syscall.RawConn) error { + var sockErr error + if err := c.Control(func(fd uintptr) { + if err := unix.SetsockoptString(int(fd), unix.SOL_SOCKET, unix.SO_BINDTODEVICE, loopbackDevice); err != nil { + sockErr = fmt.Errorf("bind to %s: %w", loopbackDevice, err) + return + } + }); err != nil { + return fmt.Errorf("control socket: %w", err) + } + return sockErr + }, + } + + conn, err := lc.ListenPacket(context.Background(), "udp4", fmt.Sprintf(":%d", proxyPort)) + if err != nil { + return fmt.Errorf("listen on :%d: %w", proxyPort, err) + } + + udpConn, ok := conn.(*net.UDPConn) + if !ok { + if closeErr := conn.Close(); closeErr != nil { + log.Errorf("failed to close proxy conn: %s", closeErr) + } + return fmt.Errorf("unexpected conn type %T", conn) + } + + packetConn := ipv4.NewPacketConn(udpConn) + // the destination address carries the peer identity, the interface index is + // checked on receive as a second line of defense behind SO_BINDTODEVICE + if err := packetConn.SetControlMessage(ipv4.FlagDst|ipv4.FlagInterface, true); err != nil { + if closeErr := udpConn.Close(); closeErr != nil { + log.Errorf("failed to close proxy conn: %s", closeErr) + } + return fmt.Errorf("request destination address: %w", err) + } + + p.conn = udpConn + p.packetConn = packetConn + return nil +} + +// AddRelayedConn assigns an endpoint address to the relayed connection and +// returns the address WireGuard should send to, along with the key the +// connection is stored under. +func (p *Proxy) AddRelayedConn(relayedConn net.Conn) (*net.UDPAddr, netip.Addr, error) { + addr, err := p.storeRelayedConn(relayedConn) + if err != nil { + return nil, netip.Addr{}, err + } + + log.Infof("relayed conn added to wg proxy store: %s, endpoint address: %s", relayedConn.RemoteAddr(), addr) + + return &net.UDPAddr{ + IP: addr.AsSlice(), + Port: p.proxyPort, + }, addr, nil +} + +// Free releases the proxy resources. The relayed connections are left open. +func (p *Proxy) Free() error { + log.Debugf("free up loopback wg proxy") + if p.ctx != nil && p.ctx.Err() != nil { + //nolint + return nil + } + + if p.ctxCancel != nil { + p.ctxCancel() + } + + var result *multierror.Error + if p.conn != nil { + if err := p.conn.Close(); err != nil { + result = multierror.Append(result, err) + } + } + + if p.rawConnIPv4 != nil { + if err := p.rawConnIPv4.Close(); err != nil { + result = multierror.Append(result, err) + } + } + + if p.rawConnIPv6 != nil { + if err := p.rawConnIPv6.Close(); err != nil { + result = multierror.Append(result, err) + } + } + return nberrors.FormatErrorOrNil(result) +} + +// proxyToRemote reads packets from the local WireGuard instance and forwards +// them to the relayed connection the destination address belongs to. +func (p *Proxy) proxyToRemote() { + buf := make([]byte, p.mtu+bufsize.WGBufferOverhead) + for p.ctx.Err() == nil { + if err := p.readAndForwardPacket(buf); err != nil { + if p.ctx.Err() != nil { + return + } + log.Errorf("failed to proxy packet to remote conn: %s", err) + } + } +} + +func (p *Proxy) readAndForwardPacket(buf []byte) error { + n, cm, _, err := p.packetConn.ReadFrom(buf) + if err != nil { + return fmt.Errorf("read UDP packet from WG: %w", err) + } + + if cm == nil { + return fmt.Errorf("no control message on packet") + } + + if cm.IfIndex != p.loIndex { + log.Tracef("dropping packet received on interface %d instead of %s", cm.IfIndex, loopbackDevice) + return nil + } + + dst, ok := netip.AddrFromSlice(cm.Dst.To4()) + if !ok || !inRange(dst) { + log.Tracef("dropping packet for unexpected destination %s", cm.Dst) + return nil + } + + p.relayedConnMutex.Lock() + conn, ok := p.relayedConnStore[dst] + p.relayedConnMutex.Unlock() + if !ok { + if p.ctx.Err() == nil { + log.Debugf("relayed conn not found by address because conn already has been closed: %s", dst) + } + return nil + } + + if _, err := conn.Write(buf[:n]); err != nil { + return fmt.Errorf("forward local WG packet (%s) to remote relayed conn: %w", dst, err) + } + return nil +} + +func (p *Proxy) storeRelayedConn(relayedConn net.Conn) (netip.Addr, error) { + p.relayedConnMutex.Lock() + defer p.relayedConnMutex.Unlock() + + addr, err := p.addrs.next(func(a netip.Addr) bool { + _, ok := p.relayedConnStore[a] + return ok + }) + if err != nil { + return netip.Addr{}, err + } + + p.relayedConnStore[addr] = relayedConn + return addr, nil +} + +// removeRelayedConn releases an endpoint address. It only removes the entry +// while it still belongs to relayedConn, so a late release cannot take an +// address away from the peer it was handed to next. +func (p *Proxy) removeRelayedConn(addr netip.Addr, relayedConn net.Conn) { + p.relayedConnMutex.Lock() + defer p.relayedConnMutex.Unlock() + + if stored, ok := p.relayedConnStore[addr]; !ok || stored != relayedConn { + return + } + + log.Debugf("remove relayed conn from store by address: %s", addr) + delete(p.relayedConnStore, addr) +} diff --git a/client/iface/wgproxy/loopback/proxy_privileged_test.go b/client/iface/wgproxy/loopback/proxy_privileged_test.go new file mode 100644 index 000000000..6314fe2de --- /dev/null +++ b/client/iface/wgproxy/loopback/proxy_privileged_test.go @@ -0,0 +1,196 @@ +//go:build linux && !android && privileged + +package loopback + +import ( + "context" + "net" + "strconv" + "testing" + "time" +) + +const testWGPort = 51862 + +// relayEnd stands in for a relayed connection: the proxy writes what it read +// from WireGuard into it, and the test reads it back out here. +func relayEnd(t *testing.T) (proxySide net.Conn, testSide *net.UDPConn) { + t.Helper() + + testSide, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatalf("relay listener: %v", err) + } + t.Cleanup(func() { + if err := testSide.Close(); err != nil { + t.Logf("close relay listener: %v", err) + } + }) + + proxySide, err = net.Dial("udp", testSide.LocalAddr().String()) + if err != nil { + t.Fatalf("relay conn: %v", err) + } + t.Cleanup(func() { + if err := proxySide.Close(); err != nil { + t.Logf("close relay conn: %v", err) + } + }) + + return proxySide, testSide +} + +// TestProxyDemuxesByDestinationAddress is the core of the design: one socket +// serves every peer, and the destination address decides which relayed +// connection a WireGuard packet belongs to. +func TestProxyDemuxesByDestinationAddress(t *testing.T) { + proxy := NewProxy(testWGPort, 1280) + if err := proxy.Listen(); err != nil { + t.Fatalf("listen: %v", err) + } + defer func() { + if err := proxy.Free(); err != nil { + t.Errorf("free proxy: %v", err) + } + }() + + const peers = 3 + endpoints := make([]*net.UDPAddr, 0, peers) + readers := make([]*net.UDPConn, 0, peers) + for i := 0; i < peers; i++ { + proxySide, testSide := relayEnd(t) + endpoint, _, err := proxy.AddRelayedConn(proxySide) + if err != nil { + t.Fatalf("add relayed conn %d: %v", i, err) + } + if endpoint.Port != proxy.proxyPort { + t.Errorf("peer %d endpoint port = %d, want the shared proxy port %d", i, endpoint.Port, proxy.proxyPort) + } + endpoints = append(endpoints, endpoint) + readers = append(readers, testSide) + } + + // every peer must have its own address, otherwise they are indistinguishable + seen := make(map[string]bool, peers) + for i, endpoint := range endpoints { + if seen[endpoint.IP.String()] { + t.Fatalf("peer %d reuses endpoint address %s", i, endpoint.IP) + } + seen[endpoint.IP.String()] = true + } + + wgSock, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatalf("wg socket: %v", err) + } + defer func() { + if err := wgSock.Close(); err != nil { + t.Logf("close wg socket: %v", err) + } + }() + + for i, endpoint := range endpoints { + payload := []byte{byte(i), 'p', 'k', 't'} + if _, err := wgSock.WriteTo(payload, endpoint); err != nil { + t.Fatalf("write to peer %d endpoint %s: %v", i, endpoint, err) + } + + buf := make([]byte, 1500) + if err := readers[i].SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + n, _, err := readers[i].ReadFrom(buf) + if err != nil { + t.Fatalf("peer %d did not receive its packet: %v", i, err) + } + if string(buf[:n]) != string(payload) { + t.Errorf("peer %d got %q, want %q", i, buf[:n], payload) + } + + // no other peer may see it + for j, other := range readers { + if j == i { + continue + } + if err := other.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + if _, _, err := other.ReadFrom(buf); err == nil { + t.Errorf("packet for peer %d also delivered to peer %d", i, j) + } + } + } +} + +// TestProxyDropsPacketsOutsideTheRange guards the wildcard bind: anything that +// is not addressed to a handed-out endpoint must not reach a relayed peer. +func TestProxyDropsPacketsOutsideTheRange(t *testing.T) { + proxy := NewProxy(testWGPort+1, 1280) + if err := proxy.Listen(); err != nil { + t.Fatalf("listen: %v", err) + } + defer func() { + if err := proxy.Free(); err != nil { + t.Errorf("free proxy: %v", err) + } + }() + + proxySide, testSide := relayEnd(t) + if _, _, err := proxy.AddRelayedConn(proxySide); err != nil { + t.Fatalf("add relayed conn: %v", err) + } + + sender, err := net.Dial("udp", net.JoinHostPort("127.0.0.1", strconv.Itoa(proxy.proxyPort))) + if err != nil { + t.Fatalf("sender: %v", err) + } + defer func() { + if err := sender.Close(); err != nil { + t.Logf("close sender: %v", err) + } + }() + + if _, err := sender.Write([]byte("stray")); err != nil { + t.Fatalf("write stray packet: %v", err) + } + + buf := make([]byte, 1500) + if err := testSide.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + if _, _, err := testSide.ReadFrom(buf); err == nil { + t.Error("packet addressed to 127.0.0.1 was forwarded to a relayed peer") + } +} + +// A wrapper that is closed before it starts forwarding still has to give its +// endpoint address back, otherwise the range leaks an address per attempt. +func TestClosingBeforeWorkReleasesTheAddress(t *testing.T) { + proxy := NewProxy(testWGPort+2, 1280) + if err := proxy.Listen(); err != nil { + t.Fatalf("listen: %v", err) + } + defer func() { + if err := proxy.Free(); err != nil { + t.Errorf("free proxy: %v", err) + } + }() + + proxySide, _ := relayEnd(t) + wrapper := NewProxyWrapper(proxy) + if err := wrapper.AddRelayedConn(context.Background(), nil, proxySide); err != nil { + t.Fatalf("add relayed conn: %v", err) + } + + if got := len(proxy.relayedConnStore); got != 1 { + t.Fatalf("store holds %d entries after adding one conn, want 1", got) + } + + if err := wrapper.CloseConn(); err != nil { + t.Fatalf("close conn: %v", err) + } + + if got := len(proxy.relayedConnStore); got != 0 { + t.Errorf("store holds %d entries after close, want 0", got) + } +} diff --git a/client/iface/wgproxy/ebpf/wrapper.go b/client/iface/wgproxy/loopback/wrapper.go similarity index 84% rename from client/iface/wgproxy/ebpf/wrapper.go rename to client/iface/wgproxy/loopback/wrapper.go index f75e21aa6..a9cb1ab59 100644 --- a/client/iface/wgproxy/ebpf/wrapper.go +++ b/client/iface/wgproxy/loopback/wrapper.go @@ -1,6 +1,6 @@ //go:build linux && !android -package ebpf +package loopback import ( "context" @@ -8,6 +8,7 @@ import ( "fmt" "io" "net" + "net/netip" "sync" "github.com/google/gopacket" @@ -95,13 +96,14 @@ func NewPacketHeaders(localWGListenPort int, endpoint *net.UDPAddr) (*PacketHead // ProxyWrapper help to keep the remoteConn instance for net.Conn.Close function call type ProxyWrapper struct { - wgeBPFProxy *WGEBPFProxy + proxy *Proxy remoteConn net.Conn ctx context.Context cancel context.CancelFunc wgRelayedEndpointAddr *net.UDPAddr + peerAddr netip.Addr headers *PacketHeaders headerCurrentUsed *PacketHeaders rawConn net.PacketConn @@ -113,36 +115,44 @@ type ProxyWrapper struct { closeListener *listener.CloseListener } -func NewProxyWrapper(proxy *WGEBPFProxy) *ProxyWrapper { +func NewProxyWrapper(proxy *Proxy) *ProxyWrapper { return &ProxyWrapper{ - wgeBPFProxy: proxy, + proxy: proxy, pausedCond: sync.NewCond(&sync.Mutex{}), closeListener: listener.NewCloseListener(), } } func (p *ProxyWrapper) AddRelayedConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { - addr, err := p.wgeBPFProxy.AddRelayedConn(remoteConn) + addr, peerAddr, err := p.proxy.AddRelayedConn(remoteConn) if err != nil { return fmt.Errorf("add relayed conn: %w", err) } - headers, err := NewPacketHeaders(p.wgeBPFProxy.localWGListenPort, addr) + // the endpoint address is otherwise only released by the forwarding + // goroutine, which never starts when the setup below fails + release := func() { p.proxy.removeRelayedConn(peerAddr, remoteConn) } + + headers, err := NewPacketHeaders(p.proxy.localWGListenPort, addr) if err != nil { + release() return fmt.Errorf("create packet sender: %w", err) } // Check if required raw connection is available - if !headers.isIPv4 && p.wgeBPFProxy.rawConnIPv6 == nil { + if !headers.isIPv4 && p.proxy.rawConnIPv6 == nil { + release() return errIPv6ConnNotAvailable } - if headers.isIPv4 && p.wgeBPFProxy.rawConnIPv4 == nil { + if headers.isIPv4 && p.proxy.rawConnIPv4 == nil { + release() return errIPv4ConnNotAvailable } p.remoteConn = remoteConn p.ctx, p.cancel = context.WithCancel(ctx) p.wgRelayedEndpointAddr = addr + p.peerAddr = peerAddr p.headers = headers p.rawConn = p.selectRawConn(headers) return nil @@ -193,18 +203,18 @@ func (p *ProxyWrapper) RedirectAs(endpoint *net.UDPAddr) { return } - header, err := NewPacketHeaders(p.wgeBPFProxy.localWGListenPort, endpoint) + header, err := NewPacketHeaders(p.proxy.localWGListenPort, endpoint) if err != nil { log.Errorf("failed to create packet headers: %s", err) return } // Check if required raw connection is available - if !header.isIPv4 && p.wgeBPFProxy.rawConnIPv6 == nil { + if !header.isIPv4 && p.proxy.rawConnIPv6 == nil { log.Error(errIPv6ConnNotAvailable) return } - if header.isIPv4 && p.wgeBPFProxy.rawConnIPv4 == nil { + if header.isIPv4 && p.proxy.rawConnIPv4 == nil { log.Error(errIPv4ConnNotAvailable) return } @@ -240,6 +250,10 @@ func (p *ProxyWrapper) CloseConn() error { p.closeListener.SetCloseListener(nil) + // releases the endpoint address for a wrapper that was never started, and + // is a no-op once the forwarding goroutine has released it + p.proxy.removeRelayedConn(p.peerAddr, p.remoteConn) + p.pausedCond.L.Lock() p.paused = false p.pausedCond.Signal() @@ -252,9 +266,9 @@ func (p *ProxyWrapper) CloseConn() error { } func (p *ProxyWrapper) proxyToLocal(ctx context.Context) { - defer p.wgeBPFProxy.removeRelayedConn(uint16(p.wgRelayedEndpointAddr.Port)) + defer p.proxy.removeRelayedConn(p.peerAddr, p.remoteConn) - buf := make([]byte, p.wgeBPFProxy.mtu+bufsize.WGBufferOverhead) + buf := make([]byte, p.proxy.mtu+bufsize.WGBufferOverhead) for { n, err := p.readFromRemote(ctx, buf) if err != nil { @@ -286,7 +300,7 @@ func (p *ProxyWrapper) readFromRemote(ctx context.Context, buf []byte) (int, err } p.closeListener.Notify() if !errors.Is(err, io.EOF) { - log.Errorf("failed to read from relayed conn (endpoint: :%d): %s", p.wgRelayedEndpointAddr.Port, err) + log.Errorf("failed to read from relayed conn (endpoint: %s): %s", p.wgRelayedEndpointAddr, err) } return 0, err } @@ -314,7 +328,7 @@ func (p *ProxyWrapper) sendPkg(data []byte, header *PacketHeaders) error { func (p *ProxyWrapper) selectRawConn(header *PacketHeaders) net.PacketConn { if header.isIPv4 { - return p.wgeBPFProxy.rawConnIPv4 + return p.proxy.rawConnIPv4 } - return p.wgeBPFProxy.rawConnIPv6 + return p.proxy.rawConnIPv6 } diff --git a/client/iface/wgproxy/proxy_linux_test.go b/client/iface/wgproxy/proxy_linux_test.go index e34dd3b6b..88d4588a5 100644 --- a/client/iface/wgproxy/proxy_linux_test.go +++ b/client/iface/wgproxy/proxy_linux_test.go @@ -9,25 +9,25 @@ import ( "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/wgaddr" bindproxy "github.com/netbirdio/netbird/client/iface/wgproxy/bind" - "github.com/netbirdio/netbird/client/iface/wgproxy/ebpf" + "github.com/netbirdio/netbird/client/iface/wgproxy/loopback" "github.com/netbirdio/netbird/client/iface/wgproxy/udp" ) func seedProxies() ([]proxyInstance, error) { pl := make([]proxyInstance, 0) - ebpfProxy := ebpf.NewWGEBPFProxy(51831, 1280) - if err := ebpfProxy.Listen(); err != nil { - return nil, fmt.Errorf("failed to initialize ebpf proxy: %s", err) + loopbackProxy := loopback.NewProxy(51831, 1280) + if err := loopbackProxy.Listen(); err != nil { + return nil, fmt.Errorf("failed to initialize loopback proxy: %s", err) } - pEbpf := proxyInstance{ - name: "ebpf kernel proxy", - proxy: ebpf.NewProxyWrapper(ebpfProxy), + pLoopback := proxyInstance{ + name: "loopback kernel proxy", + proxy: loopback.NewProxyWrapper(loopbackProxy), wgPort: 51831, - closeFn: ebpfProxy.Free, + closeFn: loopbackProxy.Free, } - pl = append(pl, pEbpf) + pl = append(pl, pLoopback) pUDP := proxyInstance{ name: "udp kernel proxy", @@ -42,18 +42,18 @@ func seedProxies() ([]proxyInstance, error) { func seedProxyForProxyCloseByRemoteConn() ([]proxyInstance, error) { pl := make([]proxyInstance, 0) - ebpfProxy := ebpf.NewWGEBPFProxy(51831, 1280) - if err := ebpfProxy.Listen(); err != nil { - return nil, fmt.Errorf("failed to initialize ebpf proxy: %s", err) + loopbackProxy := loopback.NewProxy(51831, 1280) + if err := loopbackProxy.Listen(); err != nil { + return nil, fmt.Errorf("failed to initialize loopback proxy: %s", err) } - pEbpf := proxyInstance{ - name: "ebpf kernel proxy", - proxy: ebpf.NewProxyWrapper(ebpfProxy), + pLoopback := proxyInstance{ + name: "loopback kernel proxy", + proxy: loopback.NewProxyWrapper(loopbackProxy), wgPort: 51831, - closeFn: ebpfProxy.Free, + closeFn: loopbackProxy.Free, } - pl = append(pl, pEbpf) + pl = append(pl, pLoopback) pUDP := proxyInstance{ name: "udp kernel proxy", diff --git a/client/iface/wgproxy/redirect_test.go b/client/iface/wgproxy/redirect_test.go index f0d59cc64..47f571f2b 100644 --- a/client/iface/wgproxy/redirect_test.go +++ b/client/iface/wgproxy/redirect_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/netbirdio/netbird/client/iface/wgproxy/ebpf" + "github.com/netbirdio/netbird/client/iface/wgproxy/loopback" "github.com/netbirdio/netbird/client/iface/wgproxy/udp" ) @@ -198,20 +198,20 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint * } } -// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses -func TestRedirectAs_eBPF_IPv4(t *testing.T) { +// TestRedirectAs_Loopback_IPv4 tests RedirectAs with the loopback proxy using IPv4 addresses +func TestRedirectAs_Loopback_IPv4(t *testing.T) { wgPort := 51850 - ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) - if err := ebpfProxy.Listen(); err != nil { - t.Fatalf("failed to initialize ebpf proxy: %v", err) + loopbackProxy := loopback.NewProxy(wgPort, 1280) + if err := loopbackProxy.Listen(); err != nil { + t.Fatalf("failed to initialize loopback proxy: %v", err) } defer func() { - if err := ebpfProxy.Free(); err != nil { - t.Errorf("failed to free ebpf proxy: %v", err) + if err := loopbackProxy.Free(); err != nil { + t.Errorf("failed to free loopback proxy: %v", err) } }() - proxy := ebpf.NewProxyWrapper(ebpfProxy) + proxy := loopback.NewProxyWrapper(loopbackProxy) // NetBird UDP address of the remote peer nbAddr := &net.UDPAddr{ @@ -227,20 +227,20 @@ func TestRedirectAs_eBPF_IPv4(t *testing.T) { testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint) } -// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses -func TestRedirectAs_eBPF_IPv6(t *testing.T) { +// TestRedirectAs_Loopback_IPv6 tests RedirectAs with the loopback proxy using IPv6 addresses +func TestRedirectAs_Loopback_IPv6(t *testing.T) { wgPort := 51851 - ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) - if err := ebpfProxy.Listen(); err != nil { - t.Fatalf("failed to initialize ebpf proxy: %v", err) + loopbackProxy := loopback.NewProxy(wgPort, 1280) + if err := loopbackProxy.Listen(); err != nil { + t.Fatalf("failed to initialize loopback proxy: %v", err) } defer func() { - if err := ebpfProxy.Free(); err != nil { - t.Errorf("failed to free ebpf proxy: %v", err) + if err := loopbackProxy.Free(); err != nil { + t.Errorf("failed to free loopback proxy: %v", err) } }() - proxy := ebpf.NewProxyWrapper(ebpfProxy) + proxy := loopback.NewProxyWrapper(loopbackProxy) // NetBird UDP address of the remote peer nbAddr := &net.UDPAddr{ @@ -259,17 +259,17 @@ func TestRedirectAs_eBPF_IPv6(t *testing.T) { // TestRedirectAs_Multiple_Switches tests switching between multiple endpoints func TestRedirectAs_Multiple_Switches(t *testing.T) { wgPort := 51856 - ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280) - if err := ebpfProxy.Listen(); err != nil { - t.Fatalf("failed to initialize ebpf proxy: %v", err) + loopbackProxy := loopback.NewProxy(wgPort, 1280) + if err := loopbackProxy.Listen(); err != nil { + t.Fatalf("failed to initialize loopback proxy: %v", err) } defer func() { - if err := ebpfProxy.Free(); err != nil { - t.Errorf("failed to free ebpf proxy: %v", err) + if err := loopbackProxy.Free(); err != nil { + t.Errorf("failed to free loopback proxy: %v", err) } }() - proxy := ebpf.NewProxyWrapper(ebpfProxy) + proxy := loopback.NewProxyWrapper(loopbackProxy) ctx := context.Background() diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.go b/client/internal/ebpf/ebpf/bpf_bpfeb.go deleted file mode 100644 index 4b6230217..000000000 --- a/client/internal/ebpf/ebpf/bpf_bpfeb.go +++ /dev/null @@ -1,148 +0,0 @@ -// Code generated by bpf2go; DO NOT EDIT. -//go:build mips || mips64 || ppc64 || s390x - -package ebpf - -import ( - "bytes" - _ "embed" - "fmt" - "io" - - "github.com/cilium/ebpf" -) - -// loadBpf returns the embedded CollectionSpec for bpf. -func loadBpf() (*ebpf.CollectionSpec, error) { - reader := bytes.NewReader(_BpfBytes) - spec, err := ebpf.LoadCollectionSpecFromReader(reader) - if err != nil { - return nil, fmt.Errorf("can't load bpf: %w", err) - } - - return spec, err -} - -// loadBpfObjects loads bpf and converts it into a struct. -// -// The following types are suitable as obj argument: -// -// *bpfObjects -// *bpfPrograms -// *bpfMaps -// -// See ebpf.CollectionSpec.LoadAndAssign documentation for details. -func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error { - spec, err := loadBpf() - if err != nil { - return err - } - - return spec.LoadAndAssign(obj, opts) -} - -// bpfSpecs contains maps and programs before they are loaded into the kernel. -// -// It can be passed ebpf.CollectionSpec.Assign. -type bpfSpecs struct { - bpfProgramSpecs - bpfMapSpecs - bpfVariableSpecs -} - -// bpfProgramSpecs contains programs before they are loaded into the kernel. -// -// It can be passed ebpf.CollectionSpec.Assign. -type bpfProgramSpecs struct { - NbXdpProg *ebpf.ProgramSpec `ebpf:"nb_xdp_prog"` -} - -// bpfMapSpecs contains maps before they are loaded into the kernel. -// -// It can be passed ebpf.CollectionSpec.Assign. -type bpfMapSpecs struct { - NbFeatures *ebpf.MapSpec `ebpf:"nb_features"` - NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"` -} - -// bpfVariableSpecs contains global variables before they are loaded into the kernel. -// -// It can be passed ebpf.CollectionSpec.Assign. -type bpfVariableSpecs struct { - FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"` - MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"` - MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"` - MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"` - ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"` - WgPort *ebpf.VariableSpec `ebpf:"wg_port"` -} - -// bpfObjects contains all objects after they have been loaded into the kernel. -// -// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. -type bpfObjects struct { - bpfPrograms - bpfMaps - bpfVariables -} - -func (o *bpfObjects) Close() error { - return _BpfClose( - &o.bpfPrograms, - &o.bpfMaps, - ) -} - -// bpfMaps contains all maps after they have been loaded into the kernel. -// -// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. -type bpfMaps struct { - NbFeatures *ebpf.Map `ebpf:"nb_features"` - NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"` -} - -func (m *bpfMaps) Close() error { - return _BpfClose( - m.NbFeatures, - m.NbWgProxySettingsMap, - ) -} - -// bpfVariables contains all global variables after they have been loaded into the kernel. -// -// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. -type bpfVariables struct { - FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"` - MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"` - MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"` - MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"` - ProxyPort *ebpf.Variable `ebpf:"proxy_port"` - WgPort *ebpf.Variable `ebpf:"wg_port"` -} - -// bpfPrograms contains all programs after they have been loaded into the kernel. -// -// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. -type bpfPrograms struct { - NbXdpProg *ebpf.Program `ebpf:"nb_xdp_prog"` -} - -func (p *bpfPrograms) Close() error { - return _BpfClose( - p.NbXdpProg, - ) -} - -func _BpfClose(closers ...io.Closer) error { - for _, closer := range closers { - if err := closer.Close(); err != nil { - return err - } - } - return nil -} - -// Do not access this directly. -// -//go:embed bpf_bpfeb.o -var _BpfBytes []byte diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.o b/client/internal/ebpf/ebpf/bpf_bpfeb.o deleted file mode 100644 index b435d49647544d14fc150e72031fe0251e485a42..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8712 zcmds6Z;Vw(6`%X|57^drt3cP5wzCztZz(LhfRuzl9taf-DNh8UhI;qyzPr26ef!?y z-M6qvwHs3nsSV*nO;%&NhGEpi17nPP57XZ#2EbjX3pH5 zH?R@yHz%1p=Xd7JIcLtCnLGEr*Z1syDVK{>RwDHe&>kb}0rAXog9`HOQBiM?p|eM? z&PYRi-NNYNh$U7kWkytFTqr-)XXPGLFZ6YDgwuE3>g`!dulNyNHlvjKmNlm?k6PmL zaodLWE00_L${y84YN`Iq;{Rc-;M%=%pisrA2w~jdW7wS=2I;{u()2s#E^S}9fy0= z1~iYm^|0|!5!Qbl`$Iqf@AIQyJ;Qn5CqJGe|G&tOJmryBYuF-R%y=(93_r{d%A!Qx z$;ABP+OvHKmE|7Zr;Gd5pWi`7W6AiykP%zE%S0wA?6}BC!3LD_oLh!OANHj-4XDQa3IPu(8rwLz+gvTl0NL$=(x~`Pjph`I2HQxjYT;#G4h4w!V;YYPfk7) zY>|oH2LDl1l;}Hnkk+6VM8RXAe;_n%y(_jED#$&EF1siY#W@!J1Oqu1Dx;s;7Ecvj z6`D4FEwl#h;x+^xCqut+#74hC3q8?wv>!ox!}*0)1HUh8juUAzwDV5_!z1fP>q}8g_CQ>!nN@iPXa6{T0+i-FsZZzw&rv}HAMmbTc&Ln!f z9&L=r)1^jSt=Fe!8gWuhrt|%^(b$P+$|$u?_fvK%Ic0Sja|7Fq!pgYL_Zh7N`n2H~ zb4CPq)|xIW`ax~eH_THE3xbWwR=t+yttE7RMO7M;<)*4kR@I55nO5pG)vBkct+<-h zRHdxOo27}kK0dBmt*U~*t@;=uIX0P$O{qAZ*{Wu0X=S38l=awTsR`C-GQ5R7cxGMc zB%A>=otXx^R4zADm9kM~^W>;a8#xNkY*eMzQZr@aU|OGPjwPy`q%CC}Yp_RAVz%7K zmVylJGxBMxIWyMMW|~^BtzdzKWi{)yXx(yff&3-eu7~t%ujwp&z`kguv3`f~U-#L_ zR`VN&Hx0cwv}Nj5=RjGvIyMPx$aF0^8JE+R%Lcnri{8jm8cinpSj{1u zES>_Su~T3q889p4yRh9DbFyAP#i7@CWt$1`bQvqvDw#)|)GUgp6R@z2nn{|*mGMfg zoXo1`Y}I~vR;evF0kWCsWk)B zaCoUL>O{O<=~(`r%y_abPt+?g#R+q+(4V)Qfqb=In;3u(TzK1SqqjJMC&$twtTgkC z+tQPXY~--DDY~y%Jb2*Hf%x$5q6;=Pu61$J{d&BLV?l7X{V06z>6fu2?Uh@t za)MLkR3lM5Eg1-9jy+3Gmu3j86LU`8-B!yxXB}G9 z&dsPJ!sEO3NmTcaMGnfcvo=mHp=j>z}CIbH0WVmOL;ggk1lu^ z{+qk0M%Z6g>ifjkY`?+qH*)^ZD)k=krZ=UZ7+0L=mWL^STky9L3f!r3J%WFs)Gg4t zUctXt>Sr1lmGR2mRq7h<@VOz2JFvFD;?-rV#fEIYySza> zOuyuPmS-++8q)9bW>&Dv8_ds)CuW{3Se)ez{5SqGkKn({vmEr8ytyR(#LSy39;UpT zpX5!JSCThbUP<0WcVxWFo8=Z~dDCO#$-KE9p>N63fgs=88S$)eeEKdW>H{JCNDC89 z#P46n=UiO*7YN~-Ty}f~yPX-xh=^+|e6x3?hoAe7Z`R)N^NW15Cc;;fn1J)&@#xnA zHW|Lh+sX={ZQyseajm^ifzR0bg?eB1o;eBH@*XBtUTH~nmOa}X3(6k!}~$wo#yhdvkaR4I{gjMjF({Zn<``FF!lL( zH!$~*>*sv^e&&mhX9LW5`2F(%J|Eyq0lpgG8v(u*;5+R&S{~ru0Otd|HNblVd^Et5 z0iF%;nE=lR_9-0}eT1~?z!tpP3qd+VPY2{7}?@1G0sxd1N& z_(FiM1o&EjZwB~wJ2u_)37U1^8xw zZ?|K!O}+V>^TfwGkRJ+gA;84|j|8~k@KdXLS3bS^+4kRO?*C0T*i5SOLma7Hhh7@w zuW}FYDQP~(%;(pAe%o{7v(G#KQ;#T_c4f{l3Jxv zHJF~7Hm!UYn)zTBYX7cX!|_%&8{-!(`Xrm7c#~xgn|3(fD3Hy|k zFox>J2kX#z$w=GZ)P*#SKmLp0IDf+NH)W77PnPfJb>OA!-=r`~INr&#?+&Vu$&=Fg zM@u}!9P^8>?{DZ>D*jw|mUF0#zx%(?&6n}th`NOR$F86crE?AszxehC(XoX6-Z?v{ zvxt`VdADNm?f<-fcfX1Ioayo(tFH-i_jcFm`^y;l>)~Y3t|(YwzI!+(oPY1X0mtFu A(f|Me diff --git a/client/internal/ebpf/ebpf/bpf_bpfel.go b/client/internal/ebpf/ebpf/bpf_bpfel.go deleted file mode 100644 index f56efc901..000000000 --- a/client/internal/ebpf/ebpf/bpf_bpfel.go +++ /dev/null @@ -1,148 +0,0 @@ -// Code generated by bpf2go; DO NOT EDIT. -//go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm - -package ebpf - -import ( - "bytes" - _ "embed" - "fmt" - "io" - - "github.com/cilium/ebpf" -) - -// loadBpf returns the embedded CollectionSpec for bpf. -func loadBpf() (*ebpf.CollectionSpec, error) { - reader := bytes.NewReader(_BpfBytes) - spec, err := ebpf.LoadCollectionSpecFromReader(reader) - if err != nil { - return nil, fmt.Errorf("can't load bpf: %w", err) - } - - return spec, err -} - -// loadBpfObjects loads bpf and converts it into a struct. -// -// The following types are suitable as obj argument: -// -// *bpfObjects -// *bpfPrograms -// *bpfMaps -// -// See ebpf.CollectionSpec.LoadAndAssign documentation for details. -func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error { - spec, err := loadBpf() - if err != nil { - return err - } - - return spec.LoadAndAssign(obj, opts) -} - -// bpfSpecs contains maps and programs before they are loaded into the kernel. -// -// It can be passed ebpf.CollectionSpec.Assign. -type bpfSpecs struct { - bpfProgramSpecs - bpfMapSpecs - bpfVariableSpecs -} - -// bpfProgramSpecs contains programs before they are loaded into the kernel. -// -// It can be passed ebpf.CollectionSpec.Assign. -type bpfProgramSpecs struct { - NbXdpProg *ebpf.ProgramSpec `ebpf:"nb_xdp_prog"` -} - -// bpfMapSpecs contains maps before they are loaded into the kernel. -// -// It can be passed ebpf.CollectionSpec.Assign. -type bpfMapSpecs struct { - NbFeatures *ebpf.MapSpec `ebpf:"nb_features"` - NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"` -} - -// bpfVariableSpecs contains global variables before they are loaded into the kernel. -// -// It can be passed ebpf.CollectionSpec.Assign. -type bpfVariableSpecs struct { - FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"` - MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"` - MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"` - MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"` - ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"` - WgPort *ebpf.VariableSpec `ebpf:"wg_port"` -} - -// bpfObjects contains all objects after they have been loaded into the kernel. -// -// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. -type bpfObjects struct { - bpfPrograms - bpfMaps - bpfVariables -} - -func (o *bpfObjects) Close() error { - return _BpfClose( - &o.bpfPrograms, - &o.bpfMaps, - ) -} - -// bpfMaps contains all maps after they have been loaded into the kernel. -// -// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. -type bpfMaps struct { - NbFeatures *ebpf.Map `ebpf:"nb_features"` - NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"` -} - -func (m *bpfMaps) Close() error { - return _BpfClose( - m.NbFeatures, - m.NbWgProxySettingsMap, - ) -} - -// bpfVariables contains all global variables after they have been loaded into the kernel. -// -// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. -type bpfVariables struct { - FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"` - MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"` - MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"` - MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"` - ProxyPort *ebpf.Variable `ebpf:"proxy_port"` - WgPort *ebpf.Variable `ebpf:"wg_port"` -} - -// bpfPrograms contains all programs after they have been loaded into the kernel. -// -// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign. -type bpfPrograms struct { - NbXdpProg *ebpf.Program `ebpf:"nb_xdp_prog"` -} - -func (p *bpfPrograms) Close() error { - return _BpfClose( - p.NbXdpProg, - ) -} - -func _BpfClose(closers ...io.Closer) error { - for _, closer := range closers { - if err := closer.Close(); err != nil { - return err - } - } - return nil -} - -// Do not access this directly. -// -//go:embed bpf_bpfel.o -var _BpfBytes []byte diff --git a/client/internal/ebpf/ebpf/bpf_bpfel.o b/client/internal/ebpf/ebpf/bpf_bpfel.o deleted file mode 100644 index a388b6d6d747827318287b94a4d069e8b15748e4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8712 zcmds6VQf`N6`q#vZVMDjcgw=U%4Na&+J*88$dVAqTZFY3vOE!7*0A2b_T9G6zP|T( z_ZEt*ZnIfUqlWMU6C}p%4--kuCM23r{ZmXY93jsQjMRQ=QEnks1|6h^OV=s z&+C!<78+Gt2yl$H^3)}VXU|m{RYF+w4)6>1i&i75HrrQlO z{k-Y@eHGRid0X96nptXmTKqp*-?pfp@0X2Fn|e{}+kNd zahQ+SK0P6qYcTzQnLpiU`e(3sC)n++d$iz8Zl&q_nsq;j@kVgRYWeEc_)N|ez{$ED ziYd4GU_4kiZFSr9eBVzShyUC9=rb3nVNM@s7#`nKwRxZTX3uMZ>l>x0US zKN|_@Q_MX3=`X)qOG(2NuE3G}idY=3j zAb`F1o&z6%mt`8Z3Xi25wh7PrN`q(5)3sOl0Q^4T)8G$@eg=Gx@IBza2z~|nEE6Fq1U1sf(}uTxtFOrzPwN@s8J}~a$Gy^*Z##N`jNMc8lVnbaQWv2k*PfO`r-3?` zAz!5>N?jE`0G^aJSA+kF@U-=T;p>>M0FCsxwTzak=PmY2%x7F_`&ZztpEJVK#_xp> zz}vW`!INOCnYZyrW*v14J&*lu^dRe39cvK(Rx-Cj^{kuy%XFR_$R7gF{gI9cAAmnD zd!Uy2537-M~GvRx|PYXW|{TkFhr!~9RG)my#$`R zE6l;D`MgoET{~s4ty@kZAp%OZqovSTFWW`W*3PT4&Sx;jNnZd)esxL$U=py}tU1FU zgAV3vosA;rdgd3`*xBOfZ*_Fuz+BkTb2}V)=-AyPa>oDD4zI$4<#JI~qg+0$l&eV? zM@dpB4aTY~iN+GuwIiILYR;uN(^IiQa0G-VhN26eW&{r7G(l zjilO3eW6vaEk>DScs2Uf_C6$z8v?MG>B~{3)fog6rEDsE*Bq=KR zo0R(z$^N0Je^`a#=w3BiiVK6KC?E6><*E?%McsQig1@z)cnHow7>|xXoXh8{v5MKL zy#C8syLNIG{?=v{N{JfHYX{@-H66kFP zL1zI85|LRs=tOVR!34P}*&n2Xx8DwG;RAM63zh8$wEw|o6WL;FXZNo3E9pJEckSwM zHDP1>0ka3{+U%GjFd+*{(YY`mC$<_)rslnoRO*#<^pVZ9DHdk|l(AhvYcgO)$#r4B zHfD95ajIio*Oh%bz$1BND#__TVN^0G9El*pF{(y!92N!&rF=A|s$=1q(P%UZ4U86! z_9tTsXNNh$-Uhk2oIXOFJZ8{QunFXZk$ljJ`8KcvQ7##UX*j&p5w$ALsk9>ZOx9#_ zEYFq;FvSgXuUJ22Bps<@xir`TADDPEYN9tkgEz<0GmJLtjPrw+gShbZs61eI86`tF zjvbH4b@@IjMozW*fxvpxaY5fJG=R0X9~fekld>()ICc0$PnZplWEYet3t8*oXjAn_ zHhcWoiDTi(Ls=VauGneg6to8eMO+JlYsZhukKTSYk(9l%*~&+_RnAu;#oLmBQ2N?4 z>_~15!B47%DAo(mV9e6tD!io^T)VuPAmW{;teHh!tT|)%!X<0GTw$*sKmO{Q;g^nl z<#2f7&FtYYRAI5uAC=-rb@j#g(WsUgaAk$qbF80j1NqJ2n+e-){J8NMm&9kK7tr{d zNa7Fd3>sgzuA1i!a7KsE4b-*2X94(bwB`(+W6=j_@1ivw!n3U4%jjEAD3x(=3-Bqy zAE0kOt<<<+J-=6}3BmtDzq+W@6$dv1PYLFGR^6EKIe_dhz+CJ$x&UnpHrDi>QgaSo z4;(;`0w|%AbyLu zg7+EG|1R+U@0q&bIB^p&meA|n2VOO4>V)IOO~4Z}{yd&9n-Pag4rW|fH`G6X@um;) z4&vZvfv-832SvQk{ZQWdRFze>JU|Tm+4rbllbTH#HBiPo>J;AnaY+c#9c_8DqZW_J1Vcjv_ zx^8YE2>*qa)oz`6rB!NS< zZ2x7^NBA|_p6<~f>&LkG`wK0%vz5LNI1xuq`)U2N{RJ(5T7P4po}J0}eEM%awRZoY z<$UuqWc4kk8~k?BA_C%-Xc-6dJHsglQ-8?8w0*_FdryR`M;j;#b z`rPkP!R+|WCGTMRTX8VswGE9#F(`!`D1K<>46*-}dl5 z4?plQe?Q#)+VA<+|CBe*=Mq;x>tN=;*TWSLk9+u%hbKLJ-NQFMJnP}R9-i~CJ_zuW z`=h@t&iNt^Je>A$#=}_;_jJx2zt1fGPS#b8it-_j+`$vCb@8j*6TC|5*D?M2waB+U zJHPgN=f3LECO+E~@B)81z|o>2Q}3cI&=7+$k`vQ}l`c+`()qg$3*UQ9iiZ-!VHV*Mx=odoN|o zp-UM7J#x|{#PNUPyYP7qUh`U ze%`{|#q2LNpSmvbchi?~{9`eE$NnduO!EEtJLHSSe@^0mN#-&BT*{CCPRJIsf0GkH z -#include // ETH_P_IP -#include -#include -#include -#include -#include -#include "wg_proxy.c" - -const __u16 flag_feature_wg_proxy = 0b01; - -const __u32 map_key_features = 0; -struct bpf_map_def SEC("maps") nb_features = { - .type = BPF_MAP_TYPE_ARRAY, - .key_size = sizeof(__u32), - .value_size = sizeof(__u16), - .max_entries = 10, -}; - -SEC("xdp") -int nb_xdp_prog(struct xdp_md *ctx) { - __u16 *features; - features = bpf_map_lookup_elem(&nb_features, &map_key_features); - if (!features) { - return XDP_PASS; - } - - void *data = (void *)(long)ctx->data; - void *data_end = (void *)(long)ctx->data_end; - struct ethhdr *eth = data; - struct iphdr *ip = (data + sizeof(struct ethhdr)); - struct udphdr *udp = (data + sizeof(struct ethhdr) + sizeof(struct iphdr)); - - // return early if not enough data - if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct udphdr) > data_end){ - return XDP_PASS; - } - - // skip non IPv4 packages - if (eth->h_proto != htons(ETH_P_IP)) { - return XDP_PASS; - } - - // skip non UPD packages - if (ip->protocol != IPPROTO_UDP) { - return XDP_PASS; - } - - if (*features & flag_feature_wg_proxy) { - xdp_wg_proxy(ip, udp); - } - return XDP_PASS; -} -char _license[] SEC("license") = "GPL"; diff --git a/client/internal/ebpf/ebpf/src/readme.md b/client/internal/ebpf/ebpf/src/readme.md deleted file mode 100644 index aa47847da..000000000 --- a/client/internal/ebpf/ebpf/src/readme.md +++ /dev/null @@ -1,27 +0,0 @@ -# XDP programs - -`prog.c` is attached to the `lo` device and dispatches to the features enabled in the -`nb_features` map. The only feature is the WireGuard proxy (`wg_proxy.c`): it rewrites -loopback UDP sent from the WireGuard listen port so it reaches the userspace relay proxy -port instead, and swaps the peer endpoint port into the source so the proxy can tell -peers apart. - -Maps use the legacy `struct bpf_map_def` form, defined in `bpf_map_def.h` because libbpf -1.0 removed it. They load on kernels built without BTF, which BTF-style (`SEC(".maps")`) -definitions do not. - -Regenerate the objects with `go generate ./client/internal/ebpf/ebpf/`; it needs -`clang-14`. Loading a regenerated object needs root, attaching it needs `bpf_link` -(kernel >= 5.7), and only one XDP program can own `lo` at a time. - -# Debug - -The CONFIG_BPF_EVENTS kernel module is required for bpf_printk. -Apply this code to use bpf_printk -``` -#define bpf_printk(fmt, ...) \ - ({ \ - char ____fmt[] = fmt; \ - bpf_trace_printk(____fmt, sizeof(____fmt), ##__VA_ARGS__); \ - }) -``` diff --git a/client/internal/ebpf/ebpf/src/wg_proxy.c b/client/internal/ebpf/ebpf/src/wg_proxy.c deleted file mode 100644 index 5e7474928..000000000 --- a/client/internal/ebpf/ebpf/src/wg_proxy.c +++ /dev/null @@ -1,60 +0,0 @@ -const __u32 map_key_proxy_port = 0; -const __u32 map_key_wg_port = 1; - -struct bpf_map_def SEC("maps") nb_wg_proxy_settings_map = { - .type = BPF_MAP_TYPE_ARRAY, - .key_size = sizeof(__u32), - .value_size = sizeof(__u16), - .max_entries = 10, -}; - -__u16 proxy_port = 0; -__u16 wg_port = 0; - -bool read_port_settings() { - __u16 *value; - value = bpf_map_lookup_elem(&nb_wg_proxy_settings_map, &map_key_proxy_port); - if (!value) { - return false; - } - - proxy_port = *value; - - value = bpf_map_lookup_elem(&nb_wg_proxy_settings_map, &map_key_wg_port); - if (!value) { - return false; - } - wg_port = htons(*value); - - return true; -} - -int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) { - if (proxy_port == 0 || wg_port == 0) { - if (!read_port_settings()){ - return XDP_PASS; - } - // bpf_printk("proxy port: %d, wg port: %d", proxy_port, wg_port); - } - - // 2130706433 = 127.0.0.1 - if (ip->daddr != htonl(2130706433)) { - return XDP_PASS; - } - - if (udp->source != wg_port){ - return XDP_PASS; - } - - __be16 new_src_port = udp->dest; - __be16 new_dst_port = htons(proxy_port); - udp->dest = new_dst_port; - udp->source = new_src_port; - - // The ports are covered by the UDP checksum. This is an IPv4 loopback hop - // and the payload is already integrity-protected, so clear the checksum (a - // zero UDP checksum means "not computed" for IPv4) rather than leave a - // stale value the kernel would drop as UDP_CSUM. - udp->check = 0; - return XDP_PASS; -} diff --git a/client/internal/ebpf/ebpf/wg_proxy_linux.go b/client/internal/ebpf/ebpf/wg_proxy_linux.go deleted file mode 100644 index 4e0df7329..000000000 --- a/client/internal/ebpf/ebpf/wg_proxy_linux.go +++ /dev/null @@ -1,41 +0,0 @@ -package ebpf - -import log "github.com/sirupsen/logrus" - -const ( - mapKeyProxyPort uint32 = 0 - mapKeyWgPort uint32 = 1 -) - -func (tf *GeneralManager) LoadWgProxy(proxyPort, wgPort int) error { - log.Debugf("load ebpf WG proxy") - tf.lock.Lock() - defer tf.lock.Unlock() - - err := tf.loadXdp() - if err != nil { - return err - } - - err = tf.bpfObjs.NbWgProxySettingsMap.Put(mapKeyProxyPort, uint16(proxyPort)) - if err != nil { - return err - } - - err = tf.bpfObjs.NbWgProxySettingsMap.Put(mapKeyWgPort, uint16(wgPort)) - if err != nil { - return err - } - - tf.setFeatureFlag(featureFlagWGProxy) - err = tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags) - if err != nil { - return err - } - return nil -} - -func (tf *GeneralManager) FreeWGProxy() error { - log.Debugf("free ebpf WG proxy") - return tf.unsetFeatureFlag(featureFlagWGProxy) -} diff --git a/client/internal/ebpf/instantiater_linux.go b/client/internal/ebpf/instantiater_linux.go deleted file mode 100644 index 20d8145b4..000000000 --- a/client/internal/ebpf/instantiater_linux.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build !android - -package ebpf - -import ( - "github.com/netbirdio/netbird/client/internal/ebpf/ebpf" - "github.com/netbirdio/netbird/client/internal/ebpf/manager" -) - -// GetEbpfManagerInstance is a wrapper function. This encapsulation is required because if the code import the internal -// ebpf package the Go compiler will include the object files. But it is not supported on Android. It can cause instant -// panic on older Android version. -func GetEbpfManagerInstance() manager.Manager { - return ebpf.GetEbpfManagerInstance() -} diff --git a/client/internal/ebpf/instantiater_nonlinux.go b/client/internal/ebpf/instantiater_nonlinux.go deleted file mode 100644 index b7c38733a..000000000 --- a/client/internal/ebpf/instantiater_nonlinux.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build !linux || android - -package ebpf - -import "github.com/netbirdio/netbird/client/internal/ebpf/manager" - -// GetEbpfManagerInstance return error because ebpf is not supported on all os -func GetEbpfManagerInstance() manager.Manager { - panic("unsupported os") -} diff --git a/client/internal/ebpf/manager/manager.go b/client/internal/ebpf/manager/manager.go deleted file mode 100644 index fdc5d8d82..000000000 --- a/client/internal/ebpf/manager/manager.go +++ /dev/null @@ -1,7 +0,0 @@ -package manager - -// Manager is used to load multiple eBPF programs. E.g., the WireGuard proxy -type Manager interface { - LoadWgProxy(proxyPort, wgPort int) error - FreeWGProxy() error -} diff --git a/client/internal/engine.go b/client/internal/engine.go index 1613bbbc9..fc7dce869 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -661,10 +661,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) } e.wgDevice.Store(e.wgInterface.GetWGDevice()) - // Set up notrack rules immediately after proxy is listening to prevent - // conntrack entries from being created before the rules are in place - e.setupWGProxyNoTrack() - // Start after interface is up since port may have been resolved from 0 or changed if occupied e.shutdownWg.Add(1) go func() { @@ -802,23 +798,6 @@ func (e *Engine) initFirewall() error { return nil } -// setupWGProxyNoTrack configures connection tracking exclusion for WireGuard proxy traffic. -// This prevents conntrack/MASQUERADE from affecting loopback traffic between WireGuard and the eBPF proxy. -func (e *Engine) setupWGProxyNoTrack() { - if e.firewall == nil { - return - } - - proxyPort := e.wgInterface.GetProxyPort() - if proxyPort == 0 { - return - } - - if err := e.firewall.SetupEBPFProxyNoTrack(proxyPort, uint16(e.config.WgPort)); err != nil { - log.Warnf("failed to setup ebpf proxy notrack: %v", err) - } -} - func (e *Engine) blockLanAccess() { if e.config.BlockInbound { // no need to set up extra deny rules if inbound is already blocked in general diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index ec388ac94..2a7ecd652 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -65,7 +65,6 @@ type MockWGIface struct { GetStatsFunc func() (map[string]configurer.WGStats, error) GetInterfaceGUIDStringFunc func() (string, error) GetProxyFunc func() wgproxy.Proxy - GetProxyPortFunc func() uint16 GetNetFunc func() *netstack.Net LastActivitiesFunc func() map[string]monotime.Time } @@ -162,12 +161,6 @@ func (m *MockWGIface) GetProxy() wgproxy.Proxy { return m.GetProxyFunc() } -func (m *MockWGIface) GetProxyPort() uint16 { - if m.GetProxyPortFunc != nil { - return m.GetProxyPortFunc() - } - return 0 -} func (m *MockWGIface) GetNet() *netstack.Net { return m.GetNetFunc() diff --git a/client/internal/iface_common.go b/client/internal/iface_common.go index 8ffa0b102..d772a3a03 100644 --- a/client/internal/iface_common.go +++ b/client/internal/iface_common.go @@ -28,7 +28,6 @@ type wgIfaceBase interface { Up() (*udpmux.UniversalUDPMuxDefault, error) UpdateAddr(newAddr wgaddr.Address) error GetProxy() wgproxy.Proxy - GetProxyPort() uint16 UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error RemoveEndpointAddress(key string) error RemovePeer(peerKey string) error diff --git a/docs/testing-privileged.md b/docs/testing-privileged.md index 72e8a0f8f..939770de9 100644 --- a/docs/testing-privileged.md +++ b/docs/testing-privileged.md @@ -1,7 +1,7 @@ # Privileged tests Some tests in this repo need `root` or mutate host network state: they create -TUN/WireGuard interfaces, open netlink/raw sockets, run eBPF programs, or shell +TUN/WireGuard interfaces, open netlink/raw sockets, or shell out to `ip`/`iptables`/`nft`/`ifconfig`/`route`. Running them on a developer machine would require `sudo` and could leave stray interfaces or routes behind. @@ -44,7 +44,6 @@ A test is privileged if it does any of: - creates a real interface via `iface.NewWGIFace(...).Create()`, - opens a netlink or raw socket that hard-fails without `CAP_NET_ADMIN`, -- runs an eBPF program (`ebpf.*.Listen()`), - shells out to `ip`, `iptables`, `nft`, `ifconfig`, or `route` to change state. Add the tag to the **top** of the file, combined with any existing platform diff --git a/go.mod b/go.mod index 2d0653ec0..35e254f8e 100644 --- a/go.mod +++ b/go.mod @@ -42,7 +42,6 @@ require ( github.com/c-robinson/iplib v1.0.3 github.com/caarlos0/env/v11 v11.4.1 github.com/caddyserver/certmagic v0.21.3 - github.com/cilium/ebpf v0.19.0 github.com/coder/websocket v1.8.14 github.com/coreos/go-iptables v0.7.0 github.com/coreos/go-oidc/v3 v3.18.0 @@ -190,6 +189,7 @@ require ( github.com/caddyserver/zerossl v0.1.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cilium/ebpf v0.19.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect diff --git a/go.sum b/go.sum index e65c32f26..c193a388f 100644 --- a/go.sum +++ b/go.sum @@ -407,8 +407,6 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM= -github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= From 8ab34fcf8ba693de2f504154a2fa24e003fa8e17 Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Wed, 30 Sep 2026 11:05:45 +0200 Subject: [PATCH 077/209] [proxy] Apply the upstream HTTP version before cloning transports (#7806) createClientEntry and NewMultiTransport clone the secure transport into its insecure variant before newUpstreamTransport applies the configured HTTP version. http.Transport.Clone runs the source's one-time protocol setup, and at that point ForceAttemptHTTP2 is still false while a custom DialContext is set, so net/http disables HTTP/2 on the source for good. Setting ForceAttemptHTTP2 afterwards has no effect. As a result every TLS-verified upstream has been served over HTTP/1.1 since the upstream HTTP version became configurable, whatever NB_PROXY_UPSTREAM_HTTP_VERSION says, while skip-TLS-verify upstreams kept HTTP/2. gRPC upstreams break outright: unary calls get a 502 and streaming calls hang until the client gives up. Apply the version to the base transport before cloning it, and add a test that the direct and insecure transports both offer h2. --- proxy/internal/roundtrip/clone_http2_test.go | 40 ++++++++++++++++++++ proxy/internal/roundtrip/multi.go | 3 ++ proxy/internal/roundtrip/netbird.go | 3 ++ 3 files changed, 46 insertions(+) create mode 100644 proxy/internal/roundtrip/clone_http2_test.go diff --git a/proxy/internal/roundtrip/clone_http2_test.go b/proxy/internal/roundtrip/clone_http2_test.go new file mode 100644 index 000000000..f2707ff19 --- /dev/null +++ b/proxy/internal/roundtrip/clone_http2_test.go @@ -0,0 +1,40 @@ +package roundtrip + +import ( + "net/http" + "testing" + + log "github.com/sirupsen/logrus" +) + +// offersHTTP2 reports whether t will negotiate h2 with a TLS upstream. +// Clone forces t's one-time protocol setup, which registers an "h2" +// handler in t.TLSNextProto only when HTTP/2 ended up enabled. +func offersHTTP2(t *http.Transport) bool { + _ = t.Clone() + _, ok := t.TLSNextProto["h2"] + return ok +} + +func TestNewMultiTransportKeepsHTTP2AcrossClone(t *testing.T) { + for _, tc := range []struct { + version string + want bool + }{ + {string(upstreamHTTPAuto), true}, + {string(upstreamHTTP2), true}, + {string(upstreamHTTP11), false}, + } { + t.Run(tc.version, func(t *testing.T) { + t.Setenv(EnvUpstreamHTTPVersion, tc.version) + m := NewMultiTransport(noEmbeddedRoundTripper{}, log.New()) + + if got := offersHTTP2(m.direct.primary); got != tc.want { + t.Errorf("direct transport offers h2 = %v, want %v", got, tc.want) + } + if got := offersHTTP2(m.insecure.primary); got != tc.want { + t.Errorf("insecure transport offers h2 = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/proxy/internal/roundtrip/multi.go b/proxy/internal/roundtrip/multi.go index 1abf54a8d..d50ad1fc9 100644 --- a/proxy/internal/roundtrip/multi.go +++ b/proxy/internal/roundtrip/multi.go @@ -64,6 +64,9 @@ func NewMultiTransport(embedded http.RoundTripper, logger *log.Logger) *MultiTra ReadBufferSize: cfg.readBufferSize, DisableCompression: cfg.disableCompression, } + // Clone runs the transport's one-time protocol setup, so the HTTP + // version must be applied first or the source loses HTTP/2 for good. + applyUpstreamHTTPVersion(direct, cfg.upstreamHTTPVersion) insecure := direct.Clone() insecure.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // matches the embedded NetBird transport's per-target opt-in diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go index d7b464182..07b497c46 100644 --- a/proxy/internal/roundtrip/netbird.go +++ b/proxy/internal/roundtrip/netbird.go @@ -425,6 +425,9 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account ReadBufferSize: n.transportCfg.readBufferSize, DisableCompression: n.transportCfg.disableCompression, } + // Clone runs the transport's one-time protocol setup, so the HTTP + // version must be applied first or the source loses HTTP/2 for good. + applyUpstreamHTTPVersion(transport, n.transportCfg.upstreamHTTPVersion) insecureTransport := transport.Clone() insecureTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec From 96bfcc360008b0c12e41b39cb3f7c575ab49ac42 Mon Sep 17 00:00:00 2001 From: Edward <43848523+thomashacker@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:10:14 +0200 Subject: [PATCH 078/209] [client] Classify Windows local accounts by NetBIOS name (#7628) --- client/ssh/server/executor_windows.go | 35 ++++++++++++---- client/ssh/server/executor_windows_test.go | 48 ++++++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 client/ssh/server/executor_windows_test.go diff --git a/client/ssh/server/executor_windows.go b/client/ssh/server/executor_windows.go index 51c995ec3..9c2969d5d 100644 --- a/client/ssh/server/executor_windows.go +++ b/client/ssh/server/executor_windows.go @@ -6,7 +6,6 @@ import ( "context" "errors" "fmt" - "os" "os/exec" "os/user" "strings" @@ -506,15 +505,37 @@ func userExists(fullUsername, username, domain string) error { return nil } -// isLocalUser determines if this is a local user vs domain user +// isLocalUser reports whether domain refers to this machine rather than to a +// Windows domain. func (pd *PrivilegeDropper) isLocalUser(domain string) bool { - hostname, err := os.Hostname() - if err != nil { - hostname = "localhost" + return isLocalDomain(domain, netbiosComputerName) +} + +// isLocalDomain compares against the NetBIOS name because Windows qualifies local +// accounts with it, and it is the DNS host name truncated to 15 characters. +// An unknown name falls back to the domain path: treating it as local could +// authenticate a same named local account instead. +// https://learn.microsoft.com/en-us/windows/win32/sysinfo/computer-names +func isLocalDomain(domain string, machineName func() (string, error)) bool { + if domain == "" || domain == "." { + return true } - return domain == "" || domain == "." || - strings.EqualFold(domain, hostname) + name, err := machineName() + if err != nil { + log.Debugf("read NetBIOS computer name: %v", err) + return false + } + return strings.EqualFold(domain, name) +} + +func netbiosComputerName() (string, error) { + buf := make([]uint16, windows.MAX_COMPUTERNAME_LENGTH+1) + size := uint32(len(buf)) + if err := windows.GetComputerNameEx(windows.ComputerNamePhysicalNetBIOS, &buf[0], &size); err != nil { + return "", fmt.Errorf("GetComputerNameEx: %w", err) + } + return windows.UTF16ToString(buf[:size]), nil } // authenticateLocalUser handles authentication for local users diff --git a/client/ssh/server/executor_windows_test.go b/client/ssh/server/executor_windows_test.go new file mode 100644 index 000000000..678ca22b7 --- /dev/null +++ b/client/ssh/server/executor_windows_test.go @@ -0,0 +1,48 @@ +//go:build windows + +package server + +import ( + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// Past 15 characters the DNS host name and the NetBIOS name differ, and Windows +// qualifies local accounts with the NetBIOS one. +func TestIsLocalDomain(t *testing.T) { + const dnsHostname = "WINTESTMACHINE01XYZ" // 19 characters + netbios := dnsHostname[:windows.MAX_COMPUTERNAME_LENGTH] + require.NotEqual(t, strings.ToLower(dnsHostname), strings.ToLower(netbios), + "a 19 character name must not equal its 15 character truncation") + + name := func() (string, error) { return netbios, nil } + unreadable := func() (string, error) { return "", errors.New("name unavailable") } + + tests := []struct { + name string + domain string + machineName func() (string, error) + want bool + }{ + {"empty_domain", "", unreadable, true}, + {"dot_domain", ".", unreadable, true}, + {"truncated_netbios_name", netbios, name, true}, + {"netbios_name_lowercase", strings.ToLower(netbios), name, true}, + {"untruncated_dns_host_name", dnsHostname, name, false}, + {"real_domain", "CORP", name, false}, + // Must not resolve to local: that could authenticate the wrong account. + {"unreadable_machine_name", netbios, unreadable, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isLocalDomain(tt.domain, tt.machineName), + "classification of domain %q", tt.domain) + }) + } +} From 39de33ceca93c2a0ea07756b5ca9ffd6bb4eb496 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:35:18 +0200 Subject: [PATCH 079/209] [management] handle db conn close on errpr (#7740) --- management/internals/shared/db/open.go | 22 +++++++++++++++++++--- management/server/store/store.go | 21 +++++---------------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/management/internals/shared/db/open.go b/management/internals/shared/db/open.go index 020aff422..48a7bf330 100644 --- a/management/internals/shared/db/open.go +++ b/management/internals/shared/db/open.go @@ -93,7 +93,12 @@ func OpenSqliteFile(ctx context.Context, dataDir, storeFile string) (*Conn, erro if err != nil { return nil, err } - return NewConn(ctx, gormDB, SqliteStoreEngine, nil) + conn, err := NewConn(ctx, gormDB, SqliteStoreEngine, nil) + if err != nil { + closeGorm(gormDB) + return nil, err + } + return conn, nil } // OpenPostgres opens a Postgres database through gorm and a pgx pool sized by pool. @@ -107,7 +112,13 @@ func OpenPostgres(ctx context.Context, dsn string, pool PoolConfig) (*Conn, erro closeGorm(gormDB) return nil, err } - return NewConn(ctx, gormDB, PostgresStoreEngine, pgxPool) + conn, err := NewConn(ctx, gormDB, PostgresStoreEngine, pgxPool) + if err != nil { + pgxPool.Close() + closeGorm(gormDB) + return nil, err + } + return conn, nil } // MysqlDSN adds the connection parameters every MySQL handle needs, keeping @@ -126,7 +137,12 @@ func OpenMysql(ctx context.Context, dsn string) (*Conn, error) { if err != nil { return nil, err } - return NewConn(ctx, gormDB, MysqlStoreEngine, nil) + conn, err := NewConn(ctx, gormDB, MysqlStoreEngine, nil) + if err != nil { + closeGorm(gormDB) + return nil, err + } + return conn, nil } func newPgxPool(ctx context.Context, dsn string, cfg PoolConfig) (*pgxpool.Pool, error) { diff --git a/management/server/store/store.go b/management/server/store/store.go index bd55e3176..6d810643c 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -13,7 +13,6 @@ import ( "path" "path/filepath" "regexp" - "runtime" "slices" "strings" "sync" @@ -23,7 +22,6 @@ import ( log "github.com/sirupsen/logrus" "gorm.io/driver/mysql" "gorm.io/driver/postgres" - "gorm.io/driver/sqlite" "gorm.io/gorm" "github.com/netbirdio/netbird/dns" @@ -716,29 +714,19 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) ( kind = types.SqliteStoreEngine } - storeStr := fmt.Sprintf("%s?cache=shared", db.SqliteFileName) - if runtime.GOOS == "windows" { - // Vo avoid `The process cannot access the file because it is being used by another process` on Windows - storeStr = db.SqliteFileName - } - - file := filepath.Join(dataDir, storeStr) - gormDB, err := gorm.Open(sqlite.Open(file), db.GormConfig()) + conn, err := db.OpenSqliteFile(ctx, dataDir, db.SqliteFileName) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("failed to create test store: %v", err) } if filename != "" { - err = LoadSQL(gormDB, filename) + err = LoadSQL(conn.DB(nil), filename) if err != nil { + _ = conn.Close() return nil, nil, fmt.Errorf("failed to load SQL file: %v", err) } } - conn, err := db.NewConn(ctx, gormDB, db.SqliteStoreEngine, nil) - if err != nil { - return nil, nil, fmt.Errorf("failed to create test store: %v", err) - } store, err := NewSqlStore(ctx, conn, nil, false) if err != nil { _ = conn.Close() @@ -747,6 +735,7 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) ( err = addAllGroupToAccount(ctx, store) if err != nil { + _ = store.Close(ctx) return nil, nil, fmt.Errorf("failed to add all group to account: %v", err) } From e72be6698f7ad0d00c1c5c08e5b601599488e87f Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 30 Sep 2026 16:00:14 +0200 Subject: [PATCH 080/209] [client] Keep the advertised ICE session ID when following a remote restart (#7814) A worker that saw a new remote session ID rebuilt its agent and also picked a new local ID. On the answer path nothing carries that ID back, so the next offer made the remote see a changed session, rebuild, and answer with yet another ID. Two peers kept tearing down working ICE connections on every offer and answer; nearly every answer in the affected logs carried a new remote session ID. Only a local restart changes the local ID now: a failed negotiation, as before, and an explicit Close, which previously kept the old ID and left the remote answering from a negotiation this side had abandoned. Following a remote restart keeps the ID the remote already knows, so the pair settles after one rebuild, also against peers that still pick a new ID when following a restart. --- client/internal/peer/worker_ice.go | 27 +- .../internal/peer/worker_ice_session_test.go | 375 ++++++++++++++++++ 2 files changed, 392 insertions(+), 10 deletions(-) create mode 100644 client/internal/peer/worker_ice_session_test.go diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go index d17f6e693..5979e9bdc 100644 --- a/client/internal/peer/worker_ice.go +++ b/client/internal/peer/worker_ice.go @@ -121,11 +121,8 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) { } } - sessionID, err := NewICESessionID() - if err != nil { - w.log.Errorf("failed to create new session ID: %s", err) - } - w.sessionID = sessionID + // Keep the ID already advertised to the remote. Answers do not get a + // reply, so changing it here makes the next offer restart both sides. w.abandonNegotiation() } @@ -205,6 +202,9 @@ func (w *WorkerICE) Close() { w.muxAgent.Lock() defer w.muxAgent.Unlock() + if w.agent != nil || w.agentConnecting { + w.renewSessionID() + } if w.agent != nil { w.agentDialerCancel() if err := w.agent.Close(); err != nil { @@ -366,16 +366,23 @@ func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.C // Only the owner of the current session may reset its state: a stale dial // goroutine waking after a newer attempt must not clobber it. if w.agent == agent { - sessionID, err := NewICESessionID() - if err != nil { - w.log.Errorf("failed to create new session ID: %s", err) - } - w.sessionID = sessionID + w.renewSessionID() w.abandonNegotiation() } return sessionChanged } +// renewSessionID starts a new local session, so the remote treats our next offer +// or answer as a restart. Caller holds muxAgent. +func (w *WorkerICE) renewSessionID() { + sessionID, err := NewICESessionID() + if err != nil { + w.log.Errorf("failed to create new session ID: %s", err) + return + } + w.sessionID = sessionID +} + // abandonNegotiation drops all recorded ICE session state so the worker treats the // next offer as a fresh start instead of a duplicate of a dead negotiation. The // agent and agentConnecting flags must change together: leaving one stale wedges diff --git a/client/internal/peer/worker_ice_session_test.go b/client/internal/peer/worker_ice_session_test.go new file mode 100644 index 000000000..4858e0bc3 --- /dev/null +++ b/client/internal/peer/worker_ice_session_test.go @@ -0,0 +1,375 @@ +package peer + +import ( + "context" + "fmt" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + icemaker "github.com/netbirdio/netbird/client/internal/peer/ice" +) + +func TestWorkerICE_RemoteRestartPreservesAdvertisedSession(t *testing.T) { + w := newTestWorkerICE(t) + t.Cleanup(w.Close) + w.dialFunc = parkDial + advertised := w.SessionID() + remoteSession := ICESessionID("remote-first") + offer := OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "remoteufrag", Pwd: "remote-password-long-enough"}, + SessionID: &remoteSession, + } + w.OnNewOffer(&offer) + require.True(t, w.InProgress(), "the first remote session must start ICE") + w.muxAgent.Lock() + firstAgent := w.agent + w.muxAgent.Unlock() + + // The same callback handles answers. A changed remote ID must not create + // an unannounced local ID that makes the remote restart on our next offer. + secondSession := ICESessionID("remote-restarted") + answer := offer + answer.SessionID = &secondSession + w.OnNewOffer(&answer) + assert.Equal(t, advertised, w.SessionID(), "following a remote restart must keep our advertised ID") + w.muxAgent.Lock() + secondAgent := w.agent + w.muxAgent.Unlock() + assert.NotSame(t, firstAgent, secondAgent, "the changed remote session must still rebuild ICE") + + w.OnNewOffer(&answer) + w.muxAgent.Lock() + defer w.muxAgent.Unlock() + assert.Same(t, secondAgent, w.agent, "a repeated answer must keep the replacement agent") +} + +func TestWorkerICE_LocalCloseChangesAdvertisedSession(t *testing.T) { + w := newTestWorkerICE(t) + dialStarted := make(chan struct{}) + dialDone := make(chan struct{}) + w.dialFunc = func(ctx context.Context, _ *icemaker.ThreadSafeAgent, _ *OfferAnswer) (net.Conn, error) { + close(dialStarted) + defer close(dialDone) + <-ctx.Done() + return nil, ctx.Err() + } + session := ICESessionID("remote-session") + w.OnNewOffer(&OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "remoteufrag", Pwd: "remote-password-long-enough"}, + SessionID: &session, + }) + <-dialStarted + advertised := w.SessionID() + w.Close() + assert.NotEqual(t, advertised, w.SessionID(), "a local teardown must tell the remote to restart") + closedSession := w.SessionID() + + // The abandoned dial goroutine cleans up after Close returned. + <-dialDone + assert.Never(t, func() bool { return w.SessionID() != closedSession }, 200*time.Millisecond, 10*time.Millisecond, + "the late cleanup of a closed negotiation must not restart again") + w.Close() + assert.Equal(t, closedSession, w.SessionID(), "closing an idle worker must not restart again") +} + +// parkDial stands in for the ICE dial. It never connects and returns once the +// negotiation is abandoned, so a test decides when a negotiation fails. +func parkDial(ctx context.Context, _ *icemaker.ThreadSafeAgent, _ *OfferAnswer) (net.Conn, error) { + <-ctx.Done() + return nil, ctx.Err() +} + +func newTestSessionID(t *testing.T) ICESessionID { + t.Helper() + sid, err := NewICESessionID() + require.NoError(t, err) + return sid +} + +// handshakeSide is one end of a simulated signaling exchange. +type handshakeSide interface { + // message builds the offer or answer the side would send now. + message() OfferAnswer + // receive hands a remote offer or answer to the side's ICE logic. + receive(msg OfferAnswer) + // teardowns counts negotiations the side tore down to follow a remote restart. + teardowns() int + // failAgent ends the side's current negotiation as an ICE failure does. + failAgent() +} + +// workerSide drives a real WorkerICE. +type workerSide struct { + t *testing.T + w *WorkerICE + replaced int +} + +func newWorkerSide(t *testing.T) *workerSide { + t.Helper() + w := newTestWorkerICE(t) + w.dialFunc = parkDial + t.Cleanup(w.Close) + return &workerSide{t: t, w: w} +} + +func (s *workerSide) message() OfferAnswer { + sid := s.w.SessionID() + ufrag, pwd := s.w.GetLocalUserCredentials() + return OfferAnswer{IceCredentials: IceCredentials{UFrag: ufrag, Pwd: pwd}, SessionID: &sid} +} + +func (s *workerSide) receive(msg OfferAnswer) { + before := s.agent() + s.w.OnNewOffer(&msg) + if after := s.agent(); before != nil && after != before { + s.replaced++ + } +} + +func (s *workerSide) teardowns() int { return s.replaced } + +func (s *workerSide) agent() *icemaker.ThreadSafeAgent { + s.w.muxAgent.Lock() + defer s.w.muxAgent.Unlock() + return s.w.agent +} + +// failAgent runs the cleanup the dial goroutine or the Failed state callback +// performs when the current negotiation dies. +func (s *workerSide) failAgent() { + s.t.Helper() + s.w.muxAgent.Lock() + agent, cancel := s.w.agent, s.w.agentDialerCancel + s.w.muxAgent.Unlock() + require.NotNil(s.t, agent, "failing requires a running negotiation") + s.w.closeAgent(agent, cancel) +} + +// legacySide models a remote peer running a release from before this change: +// when it follows a remote restart it also picks a new session ID of its own, +// which it announces only with its next offer or answer. +type legacySide struct { + t *testing.T + sessionID ICESessionID + remoteID ICESessionID + hasAgent bool + replaced int +} + +func newLegacySide(t *testing.T) *legacySide { + return &legacySide{t: t, sessionID: newTestSessionID(t)} +} + +func (s *legacySide) message() OfferAnswer { + sid := s.sessionID + return OfferAnswer{ + IceCredentials: IceCredentials{UFrag: "legacyufrag", Pwd: "legacy-password-long-enough"}, + SessionID: &sid, + } +} + +func (s *legacySide) receive(msg OfferAnswer) { + if msg.SessionID == nil { + s.hasAgent = true + return + } + if s.hasAgent { + if *msg.SessionID == s.remoteID { + return + } + s.replaced++ + s.sessionID = newTestSessionID(s.t) + } + s.hasAgent = true + s.remoteID = *msg.SessionID +} + +func (s *legacySide) teardowns() int { return s.replaced } + +func (s *legacySide) failAgent() { + s.hasAgent = false + s.remoteID = "" + s.sessionID = newTestSessionID(s.t) +} + +// exchange runs one guard-driven round in the order Handshaker.Listen uses: the +// answerer handles the offer and answers with the session ID it holds +// afterwards, and the offerer handles the answer without replying. +func exchange(offerer, answerer handshakeSide) { + answerer.receive(offerer.message()) + offerer.receive(answerer.message()) +} + +// offerPattern decides which side's guard sends the offer in a round. +type offerPattern struct { + name string + picker func(round int, local, remote handshakeSide) (offerer, answerer handshakeSide) +} + +var offerPatterns = []offerPattern{ + { + // A routing peer whose relay is down keeps offering on its own. + name: "local peer offers", + picker: func(_ int, local, remote handshakeSide) (handshakeSide, handshakeSide) { + return local, remote + }, + }, + { + name: "both peers offer", + picker: func(round int, local, remote handshakeSide) (handshakeSide, handshakeSide) { + if round%2 == 0 { + return local, remote + } + return remote, local + }, + }, +} + +// assertSettles runs guard rounds and requires the pair to stop restarting +// each other: at most maxTeardowns in total, and none once half the rounds ran. +func assertSettles(t *testing.T, pattern offerPattern, local, remote handshakeSide, maxTeardowns int) { + t.Helper() + const rounds = 10 + + total := func() int { return local.teardowns() + remote.teardowns() } + start := total() + var halfway int + for round := range rounds { + if round == rounds/2 { + halfway = total() + } + offerer, answerer := pattern.picker(round, local, remote) + exchange(offerer, answerer) + } + + assert.LessOrEqual(t, total()-start, maxTeardowns, "the peers must not keep restarting each other") + assert.Equal(t, halfway, total(), "the negotiation must be stable in the later rounds") +} + +// establish runs the first offer and answer, so both sides negotiate. +func establish(t *testing.T, local, remote handshakeSide) { + t.Helper() + exchange(local, remote) + require.Zero(t, local.teardowns()+remote.teardowns(), "the first exchange must not restart anything") +} + +func TestICESession_SettlesAfterAgentFailure(t *testing.T) { + sides := []struct { + name string + remote func(t *testing.T) handshakeSide + }{ + {name: "current remote", remote: func(t *testing.T) handshakeSide { return newWorkerSide(t) }}, + {name: "legacy remote", remote: func(t *testing.T) handshakeSide { return newLegacySide(t) }}, + } + failures := []struct { + name string + fail func(local, remote handshakeSide) + }{ + {name: "remote agent fails", fail: func(_, remote handshakeSide) { remote.failAgent() }}, + {name: "local agent fails", fail: func(local, _ handshakeSide) { local.failAgent() }}, + {name: "both agents fail", fail: func(local, remote handshakeSide) { + local.failAgent() + remote.failAgent() + }}, + } + + for _, side := range sides { + for _, failure := range failures { + for _, pattern := range offerPatterns { + t.Run(fmt.Sprintf("%s/%s/%s", side.name, failure.name, pattern.name), func(t *testing.T) { + local := newWorkerSide(t) + remote := side.remote(t) + establish(t, local, remote) + + failure.fail(local, remote) + assertSettles(t, pattern, local, remote, 2) + }) + } + } + } +} + +// TestICESession_LocalCloseRestartsRemote covers an explicit teardown, as on a +// WireGuard handshake timeout. The remote must start over as well, or it keeps +// answering from the negotiation this side just abandoned. +func TestICESession_LocalCloseRestartsRemote(t *testing.T) { + for _, pattern := range offerPatterns { + t.Run(pattern.name, func(t *testing.T) { + local := newWorkerSide(t) + remote := newWorkerSide(t) + establish(t, local, remote) + + local.w.Close() + assertSettles(t, pattern, local, remote, 1) + assert.Equal(t, 1, remote.teardowns(), "the remote must restart its negotiation exactly once") + }) + } +} + +func TestICESession_DuplicateMessagesKeepNegotiation(t *testing.T) { + local := newWorkerSide(t) + remote := newWorkerSide(t) + + offer := local.message() + remote.receive(offer) + answer := remote.message() + local.receive(answer) + + // Signaling may deliver the same message again, and a peer answers every + // offer, including repeats of one it already handled. + remote.receive(offer) + local.receive(answer) + local.receive(remote.message()) + + assert.Zero(t, local.teardowns(), "a repeated answer must not restart the negotiation") + assert.Zero(t, remote.teardowns(), "a repeated offer must not restart the negotiation") +} + +// TestICESession_RemoteWithoutSessionIDKeepsNegotiation covers remote peers +// too old to send session IDs: once negotiating, their messages cannot tell a +// restart from a repeat, so they must not tear anything down. +func TestICESession_RemoteWithoutSessionIDKeepsNegotiation(t *testing.T) { + local := newWorkerSide(t) + unversioned := OfferAnswer{IceCredentials: IceCredentials{UFrag: "oldufrag", Pwd: "old-password-long-enough"}} + + local.receive(unversioned) + require.NotNil(t, local.agent(), "a message without a session ID must still start ICE") + advertised := local.w.SessionID() + + for range 3 { + local.receive(unversioned) + } + assert.Zero(t, local.teardowns(), "messages without a session ID must not restart the negotiation") + assert.Equal(t, advertised, local.w.SessionID(), "the advertised session must not change") +} + +// TestWorkerICE_StaleCleanupKeepsAdvertisedSession covers the cleanup of a +// replaced negotiation finishing late, from its dial goroutine or its Closed +// state callback. It must neither pick a new session ID, an unannounced local +// restart, nor disturb the negotiation that replaced it. +func TestWorkerICE_StaleCleanupKeepsAdvertisedSession(t *testing.T) { + local := newWorkerSide(t) + remote := newWorkerSide(t) + establish(t, local, remote) + + local.w.muxAgent.Lock() + oldAgent, oldCancel := local.w.agent, local.w.agentDialerCancel + local.w.muxAgent.Unlock() + + remote.failAgent() + exchange(local, remote) + require.Equal(t, 1, local.teardowns(), "the local side must follow the remote restart") + advertised := local.w.SessionID() + current := local.agent() + + local.w.closeAgent(oldAgent, oldCancel) + + assert.Equal(t, advertised, local.w.SessionID(), "a stale cleanup must not change the advertised session") + assert.Same(t, current, local.agent(), "a stale cleanup must keep the current negotiation") + assertSettles(t, offerPatterns[1], local, remote, 0) +} From 0dc729c4ead3dce2839cdc34738a7a99bdef71ad Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:03:04 +0900 Subject: [PATCH 081/209] [client] Cache the box shared key per remote peer in the Signal client (#7807) --- encryption/sharedkey.go | 147 +++++++++++++++++++++ encryption/sharedkey_limit.go | 8 ++ encryption/sharedkey_limit_mobile.go | 8 ++ encryption/sharedkey_test.go | 184 +++++++++++++++++++++++++++ shared/signal/client/grpc.go | 7 +- 5 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 encryption/sharedkey.go create mode 100644 encryption/sharedkey_limit.go create mode 100644 encryption/sharedkey_limit_mobile.go create mode 100644 encryption/sharedkey_test.go diff --git a/encryption/sharedkey.go b/encryption/sharedkey.go new file mode 100644 index 000000000..1509632c4 --- /dev/null +++ b/encryption/sharedkey.go @@ -0,0 +1,147 @@ +package encryption + +import ( + "fmt" + "sync" + + pb "github.com/golang/protobuf/proto" //nolint + "golang.org/x/crypto/nacl/box" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// SharedKeyCache encrypts and decrypts messages for one local private key, deriving +// the box shared key once per remote public key instead of once per message. +// +// The shared key is a pure function of the two keys, so a cached entry never goes +// stale: a different remote key is a different entry, and a different local key +// needs a different cache. Entries are only dropped to stay under maxSharedKeys. +// Every message still uses its own random nonce. +// +// The cached values are secret key material, as sensitive as the private key. +type SharedKeyCache struct { + privateKey wgtypes.Key + limit int + + mu sync.RWMutex + keys map[wgtypes.Key]*[32]byte + closed bool +} + +// NewSharedKeyCache returns a cache for messages sent and received with privateKey. +func NewSharedKeyCache(privateKey wgtypes.Key) *SharedKeyCache { + return &SharedKeyCache{ + privateKey: privateKey, + limit: maxSharedKeys, + keys: make(map[wgtypes.Key]*[32]byte), + } +} + +// Encrypt encrypts msg for peerPublicKey. It is safe for concurrent use. +func (c *SharedKeyCache) Encrypt(msg []byte, peerPublicKey wgtypes.Key) ([]byte, error) { + nonce, err := genNonce() + if err != nil { + return nil, err + } + return box.SealAfterPrecomputation(nonce[:], msg, nonce, c.sharedKey(peerPublicKey)), nil +} + +// Decrypt decrypts a message that peerPublicKey encrypted for this cache's private +// key. It is safe for concurrent use. +func (c *SharedKeyCache) Decrypt(encryptedMsg []byte, peerPublicKey wgtypes.Key) ([]byte, error) { + if len(encryptedMsg) < nonceSize { + return nil, fmt.Errorf("invalid encrypted message length") + } + + var nonce [nonceSize]byte + copy(nonce[:], encryptedMsg[:nonceSize]) + + shared, cached := c.cached(peerPublicKey) + if !cached { + shared = c.derive(peerPublicKey) + } + + opened, ok := box.OpenAfterPrecomputation(nil, encryptedMsg[nonceSize:], &nonce, shared) + if !ok { + return nil, fmt.Errorf("failed to decrypt message from peer %s", peerPublicKey.String()) + } + + // The sender key of an incoming message is not authenticated until it opens, so + // only a key that produced a valid message is cached. Forged senders cannot fill + // the cache or evict real peers. + if !cached { + c.store(peerPublicKey, shared) + } + return opened, nil +} + +// EncryptMessage marshals message and encrypts it for peerPublicKey. +func (c *SharedKeyCache) EncryptMessage(peerPublicKey wgtypes.Key, message pb.Message) ([]byte, error) { + body, err := pb.Marshal(message) + if err != nil { + return nil, fmt.Errorf("marshal message: %w", err) + } + return c.Encrypt(body, peerPublicKey) +} + +// DecryptMessage decrypts a message from peerPublicKey and unmarshals it into message. +func (c *SharedKeyCache) DecryptMessage(peerPublicKey wgtypes.Key, encryptedMessage []byte, message pb.Message) error { + body, err := c.Decrypt(encryptedMessage, peerPublicKey) + if err != nil { + return err + } + if err := pb.Unmarshal(body, message); err != nil { + return fmt.Errorf("unmarshal message from peer %s: %w", peerPublicKey.String(), err) + } + return nil +} + +// Close drops every cached shared key and stops caching new ones. Encrypt and +// Decrypt keep working afterwards by deriving the key for each message. +func (c *SharedKeyCache) Close() { + c.mu.Lock() + defer c.mu.Unlock() + c.closed = true + clear(c.keys) +} + +func (c *SharedKeyCache) sharedKey(peerPublicKey wgtypes.Key) *[32]byte { + if shared, ok := c.cached(peerPublicKey); ok { + return shared + } + + shared := c.derive(peerPublicKey) + c.store(peerPublicKey, shared) + return shared +} + +func (c *SharedKeyCache) cached(peerPublicKey wgtypes.Key) (*[32]byte, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + shared, ok := c.keys[peerPublicKey] + return shared, ok +} + +// derive computes the shared key outside the lock: two goroutines racing on a new +// peer compute the same value, and holding the lock would serialise the x25519 work +// this cache avoids. +func (c *SharedKeyCache) derive(peerPublicKey wgtypes.Key) *[32]byte { + shared := new([32]byte) + box.Precompute(shared, toByte32(peerPublicKey), toByte32(c.privateKey)) + return shared +} + +func (c *SharedKeyCache) store(peerPublicKey wgtypes.Key, shared *[32]byte) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return + } + if len(c.keys) >= c.limit { + // Map iteration order is random, so this evicts an arbitrary entry. + for k := range c.keys { + delete(c.keys, k) + break + } + } + c.keys[peerPublicKey] = shared +} diff --git a/encryption/sharedkey_limit.go b/encryption/sharedkey_limit.go new file mode 100644 index 000000000..b141492f6 --- /dev/null +++ b/encryption/sharedkey_limit.go @@ -0,0 +1,8 @@ +//go:build !ios && !android + +package encryption + +// maxSharedKeys bounds the cache so peers that come and go (ephemeral peers get a +// new key on every registration) cannot grow it for the lifetime of the process. +// An entry costs about 130 bytes, so a full cache is around 8 MB. +const maxSharedKeys = 1 << 16 diff --git a/encryption/sharedkey_limit_mobile.go b/encryption/sharedkey_limit_mobile.go new file mode 100644 index 000000000..f36181f94 --- /dev/null +++ b/encryption/sharedkey_limit_mobile.go @@ -0,0 +1,8 @@ +//go:build ios || android + +package encryption + +// maxSharedKeys is small on mobile, where the process runs under a tight memory +// limit. A miss only costs a fresh key derivation. An entry costs about 130 bytes, +// so a full cache is around 130 KB. +const maxSharedKeys = 1 << 10 diff --git a/encryption/sharedkey_test.go b/encryption/sharedkey_test.go new file mode 100644 index 000000000..003ccb627 --- /dev/null +++ b/encryption/sharedkey_test.go @@ -0,0 +1,184 @@ +package encryption + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +func newKeyPair(t testing.TB) (wgtypes.Key, wgtypes.Key) { + t.Helper() + priv, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + return priv, priv.PublicKey() +} + +// The cache must stay wire compatible with peers that use the uncached functions, +// in both directions. +func TestSharedKeyCache_InteropWithUncached(t *testing.T) { + alicePriv, alicePub := newKeyPair(t) + bobPriv, bobPub := newKeyPair(t) + alice := NewSharedKeyCache(alicePriv) + msg := []byte("offer") + + enc, err := alice.Encrypt(msg, bobPub) + require.NoError(t, err) + dec, err := Decrypt(enc, alicePub, bobPriv) + require.NoError(t, err) + assert.Equal(t, msg, dec, "uncached peer must read a cached sender's message") + + enc, err = Encrypt(msg, alicePub, bobPriv) + require.NoError(t, err) + dec, err = alice.Decrypt(enc, bobPub) + require.NoError(t, err) + assert.Equal(t, msg, dec, "cached peer must read an uncached sender's message") +} + +// Two messages to the same peer share the derived key but never the nonce, so the +// ciphertexts differ. +func TestSharedKeyCache_FreshNoncePerMessage(t *testing.T) { + priv, _ := newKeyPair(t) + _, peerPub := newKeyPair(t) + c := NewSharedKeyCache(priv) + + a, err := c.Encrypt([]byte("same"), peerPub) + require.NoError(t, err) + b, err := c.Encrypt([]byte("same"), peerPub) + require.NoError(t, err) + assert.NotEqual(t, a, b, "ciphertexts of identical plaintext must differ") + assert.Len(t, c.keys, 1, "the shared key must be derived once per peer") +} + +// A message from one peer must not decrypt under another peer's cached key. +func TestSharedKeyCache_DoesNotMixPeers(t *testing.T) { + alicePriv, alicePub := newKeyPair(t) + bobPriv, _ := newKeyPair(t) + _, carolPub := newKeyPair(t) + alice := NewSharedKeyCache(alicePriv) + + enc, err := Encrypt([]byte("hi"), alicePub, bobPriv) + require.NoError(t, err) + _, err = alice.Decrypt(enc, carolPub) + assert.Error(t, err, "a message from Bob must not open with Carol's key") +} + +func TestSharedKeyCache_RejectsShortMessage(t *testing.T) { + priv, _ := newKeyPair(t) + _, peerPub := newKeyPair(t) + _, err := NewSharedKeyCache(priv).Decrypt(make([]byte, nonceSize-1), peerPub) + assert.Error(t, err) +} + +func TestSharedKeyCache_StaysBounded(t *testing.T) { + priv, _ := newKeyPair(t) + c := NewSharedKeyCache(priv) + c.limit = 4 + + for i := 0; i < 20; i++ { + _, peerPub := newKeyPair(t) + _, err := c.Encrypt([]byte("x"), peerPub) + require.NoError(t, err) + assert.LessOrEqual(t, len(c.keys), c.limit, "cache must not grow past its cap") + } + assert.Len(t, c.keys, c.limit, "a full cache keeps evicting one entry per new peer") + c.Close() + assert.Empty(t, c.keys, "Close must drop every entry") +} + +func TestSharedKeyCache_Concurrent(t *testing.T) { + alicePriv, alicePub := newKeyPair(t) + bobPriv, bobPub := newKeyPair(t) + alice := NewSharedKeyCache(alicePriv) + bob := NewSharedKeyCache(bobPriv) + + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 50; j++ { + enc, err := alice.Encrypt([]byte("m"), bobPub) + if !assert.NoError(t, err) { + return + } + dec, err := bob.Decrypt(enc, alicePub) + if !assert.NoError(t, err) || !assert.Equal(t, []byte("m"), dec) { + return + } + } + }() + } + wg.Wait() +} + +func BenchmarkEncryptDecryptUncached(b *testing.B) { + alicePriv, alicePub := newKeyPair(b) + bobPriv, bobPub := newKeyPair(b) + msg := make([]byte, 512) + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + enc, err := Encrypt(msg, bobPub, alicePriv) + require.NoError(b, err) + _, err = Decrypt(enc, alicePub, bobPriv) + require.NoError(b, err) + } +} + +func BenchmarkEncryptDecryptCached(b *testing.B) { + alicePriv, alicePub := newKeyPair(b) + bobPriv, bobPub := newKeyPair(b) + alice := NewSharedKeyCache(alicePriv) + bob := NewSharedKeyCache(bobPriv) + msg := make([]byte, 512) + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + enc, err := alice.Encrypt(msg, bobPub) + require.NoError(b, err) + _, err = bob.Decrypt(enc, alicePub) + require.NoError(b, err) + } +} + +// A forged sender key must not populate the cache: the key of an incoming message +// is only trusted once the message opens. +func TestSharedKeyCache_FailedDecryptDoesNotCache(t *testing.T) { + alicePriv, alicePub := newKeyPair(t) + bobPriv, bobPub := newKeyPair(t) + _, forgedPub := newKeyPair(t) + alice := NewSharedKeyCache(alicePriv) + + enc, err := Encrypt([]byte("hi"), alicePub, bobPriv) + require.NoError(t, err) + + _, err = alice.Decrypt(enc, forgedPub) + require.Error(t, err) + assert.Empty(t, alice.keys, "a message that fails to open must not add a cache entry") + + _, err = alice.Decrypt(enc, bobPub) + require.NoError(t, err) + assert.Len(t, alice.keys, 1, "a message that opens caches its sender's key") +} + +// After Close the cache still works but no longer keeps key material. +func TestSharedKeyCache_ClosedDoesNotRepopulate(t *testing.T) { + alicePriv, alicePub := newKeyPair(t) + bobPriv, bobPub := newKeyPair(t) + alice := NewSharedKeyCache(alicePriv) + + _, err := alice.Encrypt([]byte("x"), bobPub) + require.NoError(t, err) + alice.Close() + assert.Empty(t, alice.keys) + + enc, err := alice.Encrypt([]byte("y"), bobPub) + require.NoError(t, err) + dec, err := Decrypt(enc, alicePub, bobPriv) + require.NoError(t, err) + assert.Equal(t, []byte("y"), dec, "a closed cache must still encrypt correctly") + assert.Empty(t, alice.keys, "a closed cache must not cache new keys") +} diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go index a0bb2f080..92be57b30 100644 --- a/shared/signal/client/grpc.go +++ b/shared/signal/client/grpc.go @@ -53,6 +53,7 @@ type ConnStateNotifier interface { // GrpcClient Wraps the Signal Exchange Service gRpc client type GrpcClient struct { key wgtypes.Key + sharedKeys *encryption.SharedKeyCache realClient proto.SignalExchangeClient signalConn *grpc.ClientConn ctx context.Context @@ -107,6 +108,7 @@ func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled boo c := &GrpcClient{ ctx: ctx, key: key, + sharedKeys: encryption.NewSharedKeyCache(key), mux: sync.Mutex{}, status: StreamDisconnected, connStateCallbackLock: sync.RWMutex{}, @@ -158,6 +160,7 @@ func (c *GrpcClient) Close() error { } c.decryptionWg.Wait() c.decryptionWorker = nil + c.sharedKeys.Close() return c.signalConn.Close() } @@ -418,7 +421,7 @@ func (c *GrpcClient) decryptMessage(msg *proto.EncryptedMessage) (*proto.Message } body := &proto.Body{} - err = encryption.DecryptMessage(remoteKey, c.key, msg.GetBody(), body) + err = c.sharedKeys.DecryptMessage(remoteKey, msg.GetBody(), body) if err != nil { return nil, err } @@ -438,7 +441,7 @@ func (c *GrpcClient) encryptMessage(msg *proto.Message) (*proto.EncryptedMessage return nil, err } - encryptedBody, err := encryption.EncryptMessage(remoteKey, c.key, msg.Body) + encryptedBody, err := c.sharedKeys.EncryptMessage(remoteKey, msg.Body) if err != nil { return nil, err } From 6c453a0f979806cd93a230073ae0e127b6ce01f0 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:37:49 +0900 Subject: [PATCH 082/209] [client] Add debug cpu start and stop commands (#7749) * Add debug cpu start and stop commands to profile the daemon without a restart * Restore test globals on every exit and stop the daemon in the cpu profile test * Add a no-updown flag to debug for * Enable sync response persistence with --no-updown and reset flags between debug test runs * Reset flags of every command between debug test runs * Reset slice flags with Replace in the debug test helper * Explain a running CPU profile in debug for and document cpu start and no-updown limits --- client/cmd/debug.go | 84 ++++++++++++------ client/cmd/debug_cpu.go | 83 ++++++++++++++++++ client/cmd/debug_cpu_test.go | 164 +++++++++++++++++++++++++++++++++++ 3 files changed, 305 insertions(+), 26 deletions(-) create mode 100644 client/cmd/debug_cpu.go create mode 100644 client/cmd/debug_cpu_test.go diff --git a/client/cmd/debug.go b/client/cmd/debug.go index 98fe53626..c4d5ad6d7 100644 --- a/client/cmd/debug.go +++ b/client/cmd/debug.go @@ -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" +) var ( logFileCount uint32 @@ -257,13 +260,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 { @@ -284,34 +288,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() { @@ -401,7 +391,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 { @@ -458,6 +448,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() @@ -546,4 +577,5 @@ func init() { forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle") 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") } diff --git a/client/cmd/debug_cpu.go b/client/cmd/debug_cpu.go new file mode 100644 index 000000000..a01b845cf --- /dev/null +++ b/client/cmd/debug_cpu.go @@ -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) +} diff --git a/client/cmd/debug_cpu_test.go b/client/cmd/debug_cpu_test.go new file mode 100644 index 000000000..85fffd462 --- /dev/null +++ b/client/cmd/debug_cpu_test.go @@ -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 ` 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") +} From fd1a0203c7d9dcf0df81bb16d344edb7bd85a416 Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Wed, 30 Sep 2026 18:25:38 +0200 Subject: [PATCH 083/209] [management] Clean up after account deletion (#7812) Deleting an account left state behind that DeleteAccount's store associations don't reach. The Agent Network tables outlived the account, keeping its gateway domain claimed and its provider API keys stored. The proxies kept serving its gateway until they next resynced. Cloud-side state, such as managed proxy deployments, had no way to be cleaned up at all. Account deletion now runs registered hooks after the permission check and before any users or data are removed. A failing hook aborts the deletion. Agent Network registers one that tells the proxies to drop the account's gateway mappings. The account's settings, providers, policies, guardrails and budget rules are deleted in the account's transaction. Consumption counters, and the access logs of deleted accounts, are left to the background cleanup; usage records are kept. --- e2e/agentnetwork/account_delete_test.go | 292 ++++++++++++++++++ e2e/harness/agentnetwork.go | 5 + e2e/harness/combined.go | 27 ++ e2e/harness/proxy.go | 39 +++ .../accesslog_cleanup_realstore_test.go | 76 +++++ .../internals/modules/agentnetwork/manager.go | 73 +++-- .../modules/agentnetwork/reconcile.go | 68 +++- .../modules/agentnetwork/reconcile_test.go | 154 +++++++++ management/internals/server/modules.go | 1 + management/server/account.go | 33 ++ management/server/account/deletion_hook.go | 14 + management/server/account_test.go | 95 ++++++ management/server/store/sql_store_account.go | 28 ++ .../server/store/sql_store_account_test.go | 46 +++ .../sql_store_agent_network_access_log.go | 17 + .../server/store/sql_store_agentnetwork.go | 15 + .../sql_store_agentnetwork_accesslog_test.go | 35 +++ management/server/store/store.go | 2 + management/server/store/store_mock.go | 30 ++ 19 files changed, 1023 insertions(+), 27 deletions(-) create mode 100644 e2e/agentnetwork/account_delete_test.go create mode 100644 management/internals/modules/agentnetwork/accesslog_cleanup_realstore_test.go create mode 100644 management/server/account/deletion_hook.go diff --git a/e2e/agentnetwork/account_delete_test.go b/e2e/agentnetwork/account_delete_test.go new file mode 100644 index 000000000..a8b52766f --- /dev/null +++ b/e2e/agentnetwork/account_delete_test.go @@ -0,0 +1,292 @@ +//go:build e2e + +package agentnetwork + +import ( + "context" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// accountDeleteModel is a made-up model id the provider enumerates and prices, +// so the chat routes to the mock upstream and is metered deterministically. +const accountDeleteModel = "e2e-account-delete-model" + +// agentNetworkConfigTables are deleted with the account, in its transaction. +var agentNetworkConfigTables = []string{ + "agent_network_settings", + "agent_network_providers", + "agent_network_policies", + "agent_network_guardrails", + "agent_network_budget_rules", +} + +// TestAccountDelete_RemovesAgentNetworkState deletes an account that has a full +// Agent Network setup and has served traffic, and checks what that leaves +// behind, end to end: +// +// - the proxy stops running the account's gateway, instead of keeping its +// mappings and provider API keys in memory until it next resyncs; +// - the configuration rows go with the account, while access logs and usage +// records stay for retention; +// - the account's consumption counters are swept once the cleanup runs; +// - the gateway domain is free for another account to claim. +// +// It runs on a dedicated server, since deleting the shared account would take +// every other test down with it. +func TestAccountDelete_RemovesAgentNetworkState(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + fresh, err := harnessStartFresh(ctx, t) + require.NoError(t, err, "start dedicated combined server") + + accounts, err := fresh.API().Accounts.List(ctx) + require.NoError(t, err, "list accounts") + require.Len(t, accounts, 1, "a fresh server has exactly the bootstrapped account") + accountID := accounts[0].Id + + cluster := harness.AgentNetworkCluster + settings, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{ProxyAddress: &cluster}) + require.NoError(t, err, "bootstrap agent-network endpoint") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned at bootstrap") + + env := provisionAccountDeleteEnv(t, ctx, fresh, settings.Endpoint) + chatThrough(t, ctx, env) + + // Preconditions: the proxy runs the account's gateway, and the request left + // the traffic-driven rows the rest of the test expects to outlive the delete. + requireEventually(t, ctx, 60*time.Second, "proxy should run a client for the account", func() bool { + return proxyRunsAccount(t, ctx, env.proxy, accountID) + }) + requireEventually(t, ctx, accessLogIngestWindow, "the request should leave consumption, usage and access-log rows", func() bool { + counts := accountRowCounts(t, fresh, accountID, + "agent_network_consumption", "agent_network_request_usage", "agent_network_access_log") + return counts["agent_network_consumption"] > 0 && + counts["agent_network_request_usage"] > 0 && + counts["agent_network_access_log"] > 0 + }) + + require.NoError(t, fresh.API().Accounts.Delete(ctx, accountID), "delete account") + + // The proxy is told to drop the gateway. A proxy that only learns on its + // next resync keeps serving the deleted account with its provider API keys. + if !eventually(ctx, 60*time.Second, func() bool { return proxyDroppedAccount(t, ctx, env.proxy, accountID) }) { + t.Errorf("proxy still runs a client for deleted account %s\n=== proxy logs ===\n%s", + accountID, env.proxy.Logs(context.Background())) + } + + counts := accountRowCounts(t, fresh, accountID, append(slices.Clone(agentNetworkConfigTables), + "agent_network_request_usage", "agent_network_access_log")...) + for _, table := range agentNetworkConfigTables { + assert.Zero(t, counts[table], "%s rows should be deleted with the account", table) + } + assert.NotZero(t, counts["agent_network_request_usage"], "usage records should be kept") + assert.NotZero(t, counts["agent_network_access_log"], "access logs should be left for retention") + + // The cleanup's first pass runs at startup, and whether instance setup is + // open again is only re-evaluated then. + require.NoError(t, fresh.Restart(ctx), "restart combined server") + requireEventually(t, ctx, 60*time.Second, "the cleanup should sweep the deleted account's consumption counters", func() bool { + return accountRowCounts(t, fresh, accountID, "agent_network_consumption")["agent_network_consumption"] == 0 + }) + + // A new account can claim the deleted account's gateway domain: its + // settings row no longer holds the global unique index. + _, err = fresh.Bootstrap(ctx) + require.NoError(t, err, "bootstrap a second account once the first is gone") + claimed, err := fresh.CreateSettings(ctx, api.AgentNetworkSettingsCreateRequest{Endpoint: &settings.Endpoint}) + require.NoError(t, err, "a new account should be able to claim the deleted account's gateway domain") + assert.Equal(t, settings.Endpoint, claimed.Endpoint, "the new account should hold the released domain") +} + +// accountDeleteEnv is a connected gateway for one account: a proxy running the +// debug endpoint, a client peer, and the resolved endpoint. +type accountDeleteEnv struct { + endpoint string + proxyIP string + client *harness.Client + proxy *harness.Proxy +} + +// provisionAccountDeleteEnv gives the server's account one of every Agent +// Network configuration row (provider, guardrail, policy, budget rule; the +// settings row is the caller's) and brings up a proxy and a client. The policy +// and budget rule switch on usage metering, so a request records consumption. +func provisionAccountDeleteEnv(t *testing.T, ctx context.Context, srv *harness.Combined, endpoint string) accountDeleteEnv { + t.Helper() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock vLLM upstream") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-account-delete"}) + require.NoError(t, err, "create group") + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: "e2e-account-delete-client", + Type: "reusable", + ExpiresIn: 86400, + AutoGroups: []string{grp.Id}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key") + + apiKey := "sk-account-delete-e2e" + models := []api.AgentNetworkProviderModel{{Id: accountDeleteModel, InputPer1k: 0.01, OutputPer1k: 0.02}} + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "account-delete", + ProviderId: "openai_api", + UpstreamUrl: vllm.URL, + ApiKey: &apiKey, + Enabled: ptr(true), + Models: &models, + }) + require.NoError(t, err, "create provider") + + var gr api.AgentNetworkGuardrailRequest + gr.Name = "e2e-account-delete" + gr.Checks.ModelAllowlist.Enabled = true + gr.Checks.ModelAllowlist.Models = []string{accountDeleteModel} + guard, err := srv.CreateGuardrail(ctx, gr) + require.NoError(t, err, "create guardrail") + + limits := api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + } + _, err = srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-account-delete", + Enabled: ptr(true), + SourceGroups: []string{grp.Id}, + DestinationProviderIds: []string{prov.Id}, + GuardrailIds: &[]string{guard.Id}, + Limits: &limits, + }) + require.NoError(t, err, "create policy") + + _, err = srv.CreateBudgetRule(ctx, api.AgentNetworkBudgetRuleRequest{ + Name: "e2e-account-delete", + Limits: limits, + TargetGroups: &[]string{grp.Id}, + }) + require.NoError(t, err, "create budget rule") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-account-delete-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken, map[string]string{"NB_PROXY_DEBUG_ENDPOINT": "true"}) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + cl, err := harness.StartClient(ctx, srv, sk.Key) + require.NoError(t, err, "start client") + t.Cleanup(func() { _ = cl.Terminate(context.Background()) }) + + require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management") + proxyIP, err := cl.ResolveProxyIP(ctx, endpoint) + require.NoError(t, err, "resolve endpoint to proxy IP") + if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil { + t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background())) + } + + return accountDeleteEnv{endpoint: endpoint, proxyIP: proxyIP, client: cl, proxy: px} +} + +// chatThrough drives one chat through the gateway, retrying to absorb +// first-call tunnel and DNS jitter. +func chatThrough(t *testing.T, ctx context.Context, env accountDeleteEnv) { + t.Helper() + var code int + var body string + ok := eventually(ctx, 90*time.Second, func() bool { + c, b, err := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, + accountDeleteModel, "Reply with exactly: pong", "e2e-session-account-delete") + code, body = c, b + return err == nil && c == 200 + }) + require.True(t, ok, "chat must return 200, last got %d: %s\n=== proxy logs ===\n%s", + code, body, env.proxy.Logs(context.Background())) +} + +// proxyRunsAccount reports whether a lookup succeeded and shows the proxy +// running a client for the account. +func proxyRunsAccount(t *testing.T, ctx context.Context, px *harness.Proxy, accountID string) bool { + t.Helper() + runs, ok := lookupProxyAccount(t, ctx, px, accountID) + return ok && runs +} + +// proxyDroppedAccount reports whether a lookup succeeded and shows the proxy +// no longer running a client for the account. A failed lookup confirms +// nothing, so it keeps the caller polling rather than passing the check. +func proxyDroppedAccount(t *testing.T, ctx context.Context, px *harness.Proxy, accountID string) bool { + t.Helper() + runs, ok := lookupProxyAccount(t, ctx, px, accountID) + return ok && !runs +} + +// lookupProxyAccount asks the proxy whether it runs a client for the account; +// ok is false when the lookup itself failed. +func lookupProxyAccount(t *testing.T, ctx context.Context, px *harness.Proxy, accountID string) (runs, ok bool) { + t.Helper() + clients, err := px.DebugClients(ctx) + if err != nil { + t.Logf("proxy debug clients: %v", err) + return false, false + } + return slices.ContainsFunc(clients, func(c harness.ProxyDebugClient) bool { return c.AccountID == accountID }), true +} + +// accountRowCounts counts the account's rows in each table, read from a +// snapshot of the management store. +func accountRowCounts(t *testing.T, srv *harness.Combined, accountID string, tables ...string) map[string]int64 { + t.Helper() + dbPath, err := srv.SnapshotStoreDB(t.TempDir()) + require.NoError(t, err, "snapshot management sqlite store") + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err, "open store snapshot") + sqlDB, err := db.DB() + require.NoError(t, err) + defer func() { _ = sqlDB.Close() }() + + counts := make(map[string]int64, len(tables)) + for _, table := range tables { + var n int64 + require.NoError(t, db.Table(table).Where("account_id = ?", accountID).Count(&n).Error, "count %s rows", table) + counts[table] = n + } + return counts +} + +// eventually polls cond every two seconds until it holds or timeout passes. +func eventually(ctx context.Context, timeout time.Duration, cond func() bool) bool { + deadline := time.Now().Add(timeout) + for { + if cond() { + return true + } + if time.Now().After(deadline) || !waitBeforeRetry(ctx, 2*time.Second) { + return false + } + } +} + +// requireEventually fails the test now if cond does not hold within timeout. +func requireEventually(t *testing.T, ctx context.Context, timeout time.Duration, msg string, cond func() bool) { + t.Helper() + require.True(t, eventually(ctx, timeout, cond), msg) +} diff --git a/e2e/harness/agentnetwork.go b/e2e/harness/agentnetwork.go index e51f2dd7a..8f688836d 100644 --- a/e2e/harness/agentnetwork.go +++ b/e2e/harness/agentnetwork.go @@ -135,6 +135,11 @@ func (c *Combined) DeleteGuardrail(ctx context.Context, id string) error { return anDelete(ctx, c, "/api/agent-network/guardrails/"+id) } +// CreateBudgetRule creates an account-level agent-network budget rule. +func (c *Combined) CreateBudgetRule(ctx context.Context, req api.AgentNetworkBudgetRuleRequest) (api.AgentNetworkBudgetRule, error) { + return anRequest[api.AgentNetworkBudgetRule](ctx, c, http.MethodPost, "/api/agent-network/budget-rules", req) +} + // CreateSettings bootstraps the account's agent-network settings row, // assigning the immutable endpoint. Exactly one of req.ProxyAddress (labeled // endpoint beneath that cluster) and req.Endpoint (self-addressed dedicated diff --git a/e2e/harness/combined.go b/e2e/harness/combined.go index e03f9f256..ea451e3ad 100644 --- a/e2e/harness/combined.go +++ b/e2e/harness/combined.go @@ -305,6 +305,33 @@ func (c *Combined) SnapshotStoreDB(dstDir string) (string, error) { return dst, nil } +// Restart stops and starts the combined container, keeping its bind-mounted +// data dir, and waits for the API again. The host port can change across a +// restart, so BaseURL and the authenticated client are refreshed. Work that +// management only does at startup (such as the agent-network cleanup's first +// pass, or re-evaluating whether instance setup is required) runs again. +func (c *Combined) Restart(ctx context.Context) error { + if err := c.container.Stop(ctx, nil); err != nil { + return fmt.Errorf("stop combined container: %w", err) + } + if err := c.container.Start(ctx); err != nil { + return fmt.Errorf("start combined container: %w", err) + } + host, err := c.container.Host(ctx) + if err != nil { + return fmt.Errorf("container host: %w", err) + } + mapped, err := c.container.MappedPort(ctx, nat.Port(combinedHTTPPort)) + if err != nil { + return fmt.Errorf("mapped port: %w", err) + } + c.BaseURL = fmt.Sprintf("http://%s:%s", host, mapped.Port()) + if c.PAT != "" { + c.api = rest.New(c.BaseURL, c.PAT) + } + return nil +} + // Logs returns the combined server container logs, for diagnostics. func (c *Combined) Logs(ctx context.Context) string { return containerLogs(ctx, c.container) diff --git a/e2e/harness/proxy.go b/e2e/harness/proxy.go index 3d709b439..ee458908b 100644 --- a/e2e/harness/proxy.go +++ b/e2e/harness/proxy.go @@ -3,13 +3,17 @@ package harness import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "os" "time" "github.com/docker/docker/api/types/container" "github.com/testcontainers/testcontainers-go" + tcexec "github.com/testcontainers/testcontainers-go/exec" "github.com/testcontainers/testcontainers-go/wait" ) @@ -114,6 +118,41 @@ func StartProxy(ctx context.Context, c *Combined, proxyToken string, envOverride return &Proxy{container: ctr, workDir: workDir}, nil } +// ProxyDebugClient is one per-account embedded client the proxy runs, as the +// proxy's debug endpoint reports it. +type ProxyDebugClient struct { + AccountID string `json:"account_id"` + ServiceCount int `json:"service_count"` + ServiceKeys []string `json:"service_keys"` +} + +// DebugClients lists the per-account clients the proxy is running, through +// the proxy's own debug CLI inside the container. The proxy must be started +// with NB_PROXY_DEBUG_ENDPOINT=true. +func (p *Proxy) DebugClients(ctx context.Context) ([]ProxyDebugClient, error) { + code, reader, err := p.container.Exec(ctx, + []string{"/usr/bin/netbird-proxy", "debug", "clients", "--json"}, tcexec.Multiplexed()) + if err != nil { + return nil, fmt.Errorf("exec debug clients: %w", err) + } + out, _ := io.ReadAll(reader) + if code != 0 { + return nil, fmt.Errorf("debug clients exited %d: %s", code, string(out)) + } + // stderr is multiplexed in; the JSON document starts at the first brace. + start := bytes.IndexByte(out, '{') + if start < 0 { + return nil, fmt.Errorf("no JSON in debug clients output: %s", string(out)) + } + var resp struct { + Clients []ProxyDebugClient `json:"clients"` + } + if err := json.NewDecoder(bytes.NewReader(out[start:])).Decode(&resp); err != nil { + return nil, fmt.Errorf("decode debug clients output: %w", err) + } + return resp.Clients, nil +} + // Logs returns the proxy container logs, for diagnostics on failure. func (p *Proxy) Logs(ctx context.Context) string { return containerLogs(ctx, p.container) diff --git a/management/internals/modules/agentnetwork/accesslog_cleanup_realstore_test.go b/management/internals/modules/agentnetwork/accesslog_cleanup_realstore_test.go new file mode 100644 index 000000000..0e294a560 --- /dev/null +++ b/management/internals/modules/agentnetwork/accesslog_cleanup_realstore_test.go @@ -0,0 +1,76 @@ +package agentnetwork + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/store" + nbtypes "github.com/netbirdio/netbird/management/server/types" +) + +// TestCleanupAccessLogs_RealStore_DeletedAccount covers a deleted account's access logs. +// The sweep is driven by settings rows, which go with the account, so without a fallback +// those logs would never expire. They get the default retention instead. A live account +// can delete its own settings row, so "no settings" must not be mistaken for "deleted": +// that account's logs are left alone, as are those of an account that keeps logs forever. +func TestCleanupAccessLogs_RealStore_DeletedAccount(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + const ( + deletedAccountID = "acc-deleted" + keepAccountID = "acc-keep-forever" + noSettingsAccountID = "acc-live-no-settings" + ) + old := time.Now().UTC().AddDate(0, 0, -(types.DefaultAccessLogRetentionDays + 10)) + recent := time.Now().UTC().AddDate(0, 0, -1) + + require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: keepAccountID})) + require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: noSettingsAccountID})) + + keepSettings := types.DefaultSettings(keepAccountID) + keepSettings.Domain = "keep.gw.example.com" + keepSettings.AccessLogRetentionDays = 0 + require.NoError(t, s.SaveAgentNetworkSettings(ctx, keepSettings)) + + mkLog := func(id, accountID string, ts time.Time) { + t.Helper() + entry := &types.AgentNetworkAccessLog{ + ID: id, AccountID: accountID, ServiceID: "svc", Timestamp: ts, StatusCode: 200, Model: "gpt-4o", + } + groups := []types.AgentNetworkAccessLogGroup{{LogID: id, GroupID: "grp-eng", AccountID: accountID}} + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, entry, groups)) + } + mkLog("deleted-old", deletedAccountID, old) + mkLog("deleted-recent", deletedAccountID, recent) + mkLog("keep-old", keepAccountID, old) + mkLog("no-settings-old", noSettingsAccountID, old) + + m := &managerImpl{store: s} + m.cleanupAccessLogsOnce(ctx) + + logIDs := func(accountID string) []string { + t.Helper() + logs, _, err := s.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, + types.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50}) + require.NoError(t, err) + ids := make([]string, 0, len(logs)) + for _, l := range logs { + ids = append(ids, l.ID) + } + return ids + } + assert.Equal(t, []string{"deleted-recent"}, logIDs(deletedAccountID), + "a deleted account should have logs past the default retention swept") + assert.Equal(t, []string{"keep-old"}, logIDs(keepAccountID), + "an account with retention disabled should keep its old logs") + assert.Equal(t, []string{"no-settings-old"}, logIDs(noSettingsAccountID), + "a live account without a settings row should keep its old logs") +} diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go index d1a5ebd7b..71ff53214 100644 --- a/management/internals/modules/agentnetwork/manager.go +++ b/management/internals/modules/agentnetwork/manager.go @@ -80,6 +80,9 @@ type Manager interface { ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) StartAccessLogCleanup(ctx context.Context, cleanupIntervalHours int) + // RemoveAccountGateway drops the account's gateway mappings from the + // proxies. It runs as an account deletion hook. + RemoveAccountGateway(ctx context.Context, accountID string) error RecordConsumption(ctx context.Context, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds, tokensIn, tokensOut int64, costUSD float64) error RecordAccountBudgetUsage(ctx context.Context, accountID, userID string, groupIDs []string, tokensIn, tokensOut int64, costUSD float64) error RecordUsage(ctx context.Context, in RecordUsageInput) error @@ -1350,8 +1353,8 @@ func (m *managerImpl) scopeFilterToCaller(ctx context.Context, accountID, userID // StartAccessLogCleanup launches a background sweep that periodically deletes // each account's agent-network access-log rows older than that account's -// AccessLogRetentionDays. Usage records are never swept. A non-positive -// interval defaults to 24h. +// AccessLogRetentionDays, and the consumption counters of deleted accounts. +// Usage records are never swept. A non-positive interval defaults to 24h. func (m *managerImpl) StartAccessLogCleanup(ctx context.Context, cleanupIntervalHours int) { if cleanupIntervalHours <= 0 { cleanupIntervalHours = 24 @@ -1362,21 +1365,40 @@ func (m *managerImpl) StartAccessLogCleanup(ctx context.Context, cleanupInterval ticker := time.NewTicker(interval) defer ticker.Stop() - m.cleanupAccessLogsOnce(ctx) // run once on startup + m.cleanupOnce(ctx) // run once on startup for { select { case <-ctx.Done(): return case <-ticker.C: - m.cleanupAccessLogsOnce(ctx) + m.cleanupOnce(ctx) } } }() } +func (m *managerImpl) cleanupOnce(ctx context.Context) { + m.cleanupAccessLogsOnce(ctx) + m.cleanupDeletedAccountConsumption(ctx) +} + +// cleanupDeletedAccountConsumption deletes the consumption counters of accounts +// that no longer exist. Best-effort: a failure is logged and retried next sweep. +func (m *managerImpl) cleanupDeletedAccountConsumption(ctx context.Context) { + deleted, err := m.store.DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx) + if err != nil { + log.WithContext(ctx).Warnf("agent-network consumption cleanup: %v", err) + return + } + if deleted > 0 { + log.WithContext(ctx).Infof("agent-network consumption cleanup: deleted %d counters of deleted accounts", deleted) + } +} + // cleanupAccessLogsOnce sweeps every account's expired access-log rows against -// its configured retention. Best-effort: a per-account failure is logged and -// the sweep continues. +// its configured retention. Deleted accounts, whose settings rows went with +// them, get the default retention. Best-effort: a per-account failure is +// logged and the sweep continues. func (m *managerImpl) cleanupAccessLogsOnce(ctx context.Context) { settings, err := m.store.GetAllAgentNetworkSettings(ctx, store.LockingStrengthNone) if err != nil { @@ -1384,18 +1406,31 @@ func (m *managerImpl) cleanupAccessLogsOnce(ctx context.Context) { return } for _, s := range settings { - if s.AccessLogRetentionDays <= 0 { - continue // keep indefinitely - } - cutoff := time.Now().UTC().AddDate(0, 0, -s.AccessLogRetentionDays) - deleted, err := m.store.DeleteOldAgentNetworkAccessLogs(ctx, s.AccountID, cutoff) - if err != nil { - log.WithContext(ctx).Warnf("agent-network access-log cleanup for account %s: %v", s.AccountID, err) - continue - } - if deleted > 0 { - log.WithContext(ctx).Infof("agent-network access-log cleanup: deleted %d rows for account %s (retention %d days)", deleted, s.AccountID, s.AccessLogRetentionDays) - } + m.cleanupAccountAccessLogs(ctx, s.AccountID, s.AccessLogRetentionDays) + } + + deleted, err := m.store.GetDeletedAccountIDsWithAgentNetworkAccessLogs(ctx) + if err != nil { + log.WithContext(ctx).Errorf("agent-network access-log cleanup: list deleted accounts: %v", err) + return + } + for _, accountID := range deleted { + m.cleanupAccountAccessLogs(ctx, accountID, types.DefaultAccessLogRetentionDays) + } +} + +func (m *managerImpl) cleanupAccountAccessLogs(ctx context.Context, accountID string, retentionDays int) { + if retentionDays <= 0 { + return // keep indefinitely + } + cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays) + deleted, err := m.store.DeleteOldAgentNetworkAccessLogs(ctx, accountID, cutoff) + if err != nil { + log.WithContext(ctx).Warnf("agent-network access-log cleanup for account %s: %v", accountID, err) + return + } + if deleted > 0 { + log.WithContext(ctx).Infof("agent-network access-log cleanup: deleted %d rows for account %s (retention %d days)", deleted, accountID, retentionDays) } } @@ -1545,6 +1580,8 @@ func (*mockManager) GetUsageOverview(_ context.Context, _, _ string, _ types.Age func (*mockManager) StartAccessLogCleanup(_ context.Context, _ int) {} +func (*mockManager) RemoveAccountGateway(_ context.Context, _ string) error { return nil } + func (*mockManager) RecordConsumption(_ context.Context, _ string, _ types.ConsumptionDimension, _ string, _, _, _ int64, _ float64) error { return nil } diff --git a/management/internals/modules/agentnetwork/reconcile.go b/management/internals/modules/agentnetwork/reconcile.go index 69e684014..20d0bb42a 100644 --- a/management/internals/modules/agentnetwork/reconcile.go +++ b/management/internals/modules/agentnetwork/reconcile.go @@ -2,8 +2,10 @@ package agentnetwork import ( "context" + "fmt" log "github.com/sirupsen/logrus" + goproto "google.golang.org/protobuf/proto" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/server/types" @@ -81,18 +83,66 @@ func (m *managerImpl) reconcile(ctx context.Context, accountID string) { } m.reconcileMu.Unlock() - for _, entry := range creates { - entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED - m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster) + m.sendMappings(ctx, accountID, creates, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED) + m.sendMappings(ctx, accountID, updates, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED) + m.sendMappings(ctx, accountID, deletes, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED) +} + +// sendMappings sends each entry as updateType. It sends a copy: the entries' +// mappings are shared with reconcileCache, which another reconcile or +// RemoveAccountGateway may be reading, so they are never written. +func (m *managerImpl) sendMappings(ctx context.Context, accountID string, entries []syntheticMapping, updateType proto.ProxyMappingUpdateType) { + for _, entry := range entries { + update := goproto.Clone(entry.mapping).(*proto.ProxyMapping) + update.Type = updateType + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, update, entry.cluster) } - for _, entry := range updates { - entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED - m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster) +} + +// RemoveAccountGateway tells the proxies to drop every mapping of the account's +// gateway, so a deleted account's proxy config, provider API keys included, does +// not linger in proxy memory until the next resync. It is an account deletion +// hook: it runs before the account's data is removed, the last point at which +// the mappings can be synthesised from the store. The cache alone would miss +// them, since it is per instance and empty after a restart. If the deletion +// then fails, the gateway stays down until the account's next change reconciles +// it back. +func (m *managerImpl) RemoveAccountGateway(ctx context.Context, accountID string) error { + if m.proxyController == nil { + return nil } - for _, entry := range deletes { - entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED - m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster) + + services, err := SynthesizeServices(ctx, m.store, accountID) + if err != nil { + return fmt.Errorf("synthesise agent network services: %w", err) } + oidcCfg := m.proxyController.GetOIDCValidationConfig() + removed := make(map[string]syntheticMapping, len(services)) + for _, svc := range services { + if svc == nil || svc.ID == "" { + continue + } + removed[svc.ID] = syntheticMapping{ + mapping: svc.ToProtoMapping(rpservice.Delete, "", oidcCfg), + cluster: svc.ProxyCluster, + } + } + + m.reconcileMu.Lock() + for id, entry := range m.reconcileCache[accountID] { + if _, ok := removed[id]; !ok { + removed[id] = entry + } + } + delete(m.reconcileCache, accountID) + m.reconcileMu.Unlock() + + entries := make([]syntheticMapping, 0, len(removed)) + for _, entry := range removed { + entries = append(entries, entry) + } + m.sendMappings(ctx, accountID, entries, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED) + return nil } // diffMappings classifies the previous→current transition for a single diff --git a/management/internals/modules/agentnetwork/reconcile_test.go b/management/internals/modules/agentnetwork/reconcile_test.go index ab3b08481..2cfea9828 100644 --- a/management/internals/modules/agentnetwork/reconcile_test.go +++ b/management/internals/modules/agentnetwork/reconcile_test.go @@ -2,6 +2,8 @@ package agentnetwork import ( "context" + "sync" + "sync/atomic" "testing" "go.uber.org/mock/gomock" @@ -12,6 +14,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/management/status" ) func newReconcileMgr(t *testing.T, ctrl *gomock.Controller) (*managerImpl, *store.MockStore, *proxy.MockController) { @@ -287,3 +290,154 @@ func TestDiffMappings_RemovedServiceIsDeletedOnItsOwnCluster(t *testing.T) { assert.Equal(t, "brave-otter.gateway.example.com", deletes[0].cluster) } } + +// TestRemoveAccountGateway_EmitsRemovedFromStore — account deletion runs on an +// instance that may never have reconciled the account, so its cache is empty. +// The mappings are synthesised from the store, still intact before the delete, +// and each is sent as REMOVED to the cluster that serves it. +func TestRemoveAccountGateway_EmitsRemovedFromStore(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl) + provider := newReconcileTestProvider() + policy := newReconcileTestPolicy(provider.ID, "grp-eng") + + expectReconcileSynthInputs(mockStore, ctx, []*types.Provider{provider}, []*types.Policy{policy}, []*types.Guardrail{}) + mockProxy.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}) + + var sent []*proto.ProxyMapping + mockProxy.EXPECT(). + SendServiceUpdateToCluster(ctx, "acct-1", gomock.Any(), "eu.proxy.netbird.io"). + Do(func(_ context.Context, _ string, m *proto.ProxyMapping, _ string) { + sent = append(sent, m) + }) + + require.NoError(t, mgr.RemoveAccountGateway(ctx, "acct-1")) + + require.Len(t, sent, 1, "the account's one gateway mapping must be removed") + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, sent[0].Type, "the update must be a removal") + assert.Equal(t, "agent-net-svc-acct-1", sent[0].Id, "the removal must name the account's gateway service") +} + +// TestRemoveAccountGateway_AlsoRemovesCachedMappings — a mapping this instance +// last sent but the store no longer synthesises (here, one on another cluster) +// is removed too, and the account's cache entry is cleared. +func TestRemoveAccountGateway_AlsoRemovesCachedMappings(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl) + mgr.reconcileCache["acct-1"] = map[string]syntheticMapping{ + "stale-svc": {mapping: &proto.ProxyMapping{Id: "stale-svc"}, cluster: "us.proxy.netbird.io"}, + } + + // Settings but no providers: the store synthesises nothing. + mockStore.EXPECT(). + GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1"). + Return(newReconcileTestSettings(), nil) + mockStore.EXPECT(). + GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1"). + Return([]*types.Provider{}, nil) + mockProxy.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}) + + var sent []*proto.ProxyMapping + mockProxy.EXPECT(). + SendServiceUpdateToCluster(ctx, "acct-1", gomock.Any(), "us.proxy.netbird.io"). + Do(func(_ context.Context, _ string, m *proto.ProxyMapping, _ string) { + sent = append(sent, m) + }) + + require.NoError(t, mgr.RemoveAccountGateway(ctx, "acct-1")) + + require.Len(t, sent, 1, "the cached mapping must be removed from its own cluster") + assert.Equal(t, "stale-svc", sent[0].Id) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, sent[0].Type) + mgr.reconcileMu.Lock() + _, present := mgr.reconcileCache["acct-1"] + mgr.reconcileMu.Unlock() + assert.False(t, present, "the deleted account's cache entry must be cleared") +} + +// TestRemoveAccountGateway_SynthFailureAbortsDeletion — if the mappings cannot +// be read, nothing is sent and the error is returned, which as an account +// deletion hook keeps the account rather than leaving its gateway running. +func TestRemoveAccountGateway_SynthFailureAbortsDeletion(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mgr, mockStore, _ := newReconcileMgr(t, ctrl) + mockStore.EXPECT(). + GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1"). + Return(nil, status.Errorf(status.Internal, "store unavailable")) + + assert.Error(t, mgr.RemoveAccountGateway(ctx, "acct-1"), "a failed synthesis must fail the hook") +} + +func TestRemoveAccountGateway_NilProxyController_NoOp(t *testing.T) { + mgr := &managerImpl{reconcileCache: make(map[string]map[string]syntheticMapping)} + // Must not panic and must not query the store. + assert.NoError(t, mgr.RemoveAccountGateway(context.Background(), "acct-1")) +} + +// TestReconcile_ConcurrentWithGatewayChanges — while an account's gateway +// flaps (its policy is removed and re-added between reads), concurrent +// reconciles and RemoveAccountGateway share the cached mappings: one caches a +// mapping and sends it, another finds it gone and sends its removal. Run under +// -race: neither path may write a cached mapping, only copies of it. +func TestReconcile_ConcurrentWithGatewayChanges(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl) + // gomock serialises every call on the controller's mutex, which would give + // the race detector the ordering the code under test lacks. The sends go + // through a fake that takes no lock. + mgr.proxyController = unsyncedSender{MockController: mockProxy} + provider := newReconcileTestProvider() + policy := newReconcileTestPolicy(provider.ID, "grp-eng") + + var reads atomic.Int64 + mockStore.EXPECT().GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1").Return(newReconcileTestSettings(), nil).AnyTimes() + mockStore.EXPECT().GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Provider{provider}, nil).AnyTimes() + mockStore.EXPECT().GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, "acct-1"). + DoAndReturn(func(context.Context, store.LockingStrength, string) ([]*types.Policy, error) { + if reads.Add(1)%2 == 0 { + return []*types.Policy{}, nil + } + return []*types.Policy{policy}, nil + }).AnyTimes() + mockStore.EXPECT().GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Guardrail{}, nil).AnyTimes() + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(remove bool) { + defer wg.Done() + for j := 0; j < 50; j++ { + if remove && j%10 == 0 { + _ = mgr.RemoveAccountGateway(ctx, "acct-1") + continue + } + mgr.reconcile(ctx, "acct-1") + } + }(i == 0) + } + wg.Wait() +} + +// unsyncedSender answers the calls reconcile makes on every pass without any +// locking, so concurrent callers are not ordered by the fake itself. +type unsyncedSender struct { + *proxy.MockController +} + +func (unsyncedSender) GetOIDCValidationConfig() proxy.OIDCValidationConfig { + return proxy.OIDCValidationConfig{} +} + +func (unsyncedSender) SendServiceUpdateToCluster(context.Context, string, *proto.ProxyMapping, string) {} diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index 6b1365f3b..4840e40ad 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -103,6 +103,7 @@ func (s *BaseServer) AccountManager() account.Manager { s.AfterInit(func(s *BaseServer) { accountManager.SetServiceManager(s.ServiceManager()) + accountManager.AddAccountDeletionHook(s.AgentNetworkManager().RemoveAccountGateway) }) return accountManager diff --git a/management/server/account.go b/management/server/account.go index 6ccf673f5..340bcc84b 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -112,6 +112,9 @@ type DefaultAccountManager struct { permissionsManager permissions.Manager disableDefaultPolicy bool + + deletionHooksMu sync.RWMutex + deletionHooks []account.DeletionHook } var _ account.Manager = (*DefaultAccountManager)(nil) @@ -120,6 +123,32 @@ func (am *DefaultAccountManager) SetServiceManager(serviceManager service.Manage am.serviceManager = serviceManager } +// AddAccountDeletionHook registers hook to run on every account deletion. Hooks run in +// registration order, and the first one to fail stops the rest and aborts the deletion. +// It panics on a nil hook: dropping one silently would skip that hook's cleanup on every +// deletion, so the wiring bug surfaces at startup instead. +func (am *DefaultAccountManager) AddAccountDeletionHook(hook account.DeletionHook) { + if hook == nil { + panic("nil account deletion hook") + } + am.deletionHooksMu.Lock() + defer am.deletionHooksMu.Unlock() + am.deletionHooks = append(am.deletionHooks, hook) +} + +func (am *DefaultAccountManager) runAccountDeletionHooks(ctx context.Context, accountID string) error { + am.deletionHooksMu.RLock() + hooks := slices.Clone(am.deletionHooks) + am.deletionHooksMu.RUnlock() + + for _, hook := range hooks { + if err := hook(ctx, accountID); err != nil { + return fmt.Errorf("account deletion hook: %w", err) + } + } + return nil +} + func isUniqueConstraintError(err error) bool { switch { case strings.Contains(err.Error(), "(SQLSTATE 23505)"), @@ -889,6 +918,10 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u return status.Errorf(status.Internal, "failed to build user infos for account %s: %v", accountID, err) } + if err = am.runAccountDeletionHooks(ctx, accountID); err != nil { + return err + } + if err = am.deleteAccountUsers(ctx, accountID, userID, account.Users, userInfosMap); err != nil { return err } diff --git a/management/server/account/deletion_hook.go b/management/server/account/deletion_hook.go new file mode 100644 index 000000000..17c817444 --- /dev/null +++ b/management/server/account/deletion_hook.go @@ -0,0 +1,14 @@ +package account + +import "context" + +// DeletionHook runs when an account is deleted, after the caller's permission to delete +// it has been checked and before any of its users or data are removed. It lets code that +// keeps per-account state outside the store tear that state down while the account still +// exists. +// +// A hook that returns an error aborts the deletion and the account is kept. The caller +// sees the error, so a hook that wants a specific response returns a status error. A +// retried deletion runs every hook again, and a later step can still fail after the hooks +// succeed, so a hook must be idempotent and must tolerate the account surviving it. +type DeletionHook func(ctx context.Context, accountID string) error diff --git a/management/server/account_test.go b/management/server/account_test.go index c63782ca8..8c735b28e 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -958,6 +958,101 @@ func TestAccountManager_DeleteAccount(t *testing.T) { assert.Len(t, pats, 0) } +func TestAccountManager_DeleteAccount_RunsDeletionHooks(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + ownerID := "account_creator" + account, err := createAccount(manager, "test_account", ownerID, "") + require.NoError(t, err) + + // Each hook records its call and checks the account is still in the store, which is + // the point of running before deletion: a hook must be able to read what it cleans up. + var calls []string + hook := func(name string) nbAccount.DeletionHook { + return func(ctx context.Context, accountID string) error { + calls = append(calls, name+":"+accountID) + _, err := manager.Store.GetAccount(ctx, accountID) + assert.NoError(t, err, "account should still exist while hook %s runs", name) + return nil + } + } + manager.AddAccountDeletionHook(hook("first")) + manager.AddAccountDeletionHook(hook("second")) + + require.NoError(t, manager.DeleteAccount(context.Background(), account.Id, ownerID)) + + assert.Equal(t, []string{"first:" + account.Id, "second:" + account.Id}, calls, + "hooks should run once each, in registration order, with the deleted account's ID") + _, err = manager.Store.GetAccount(context.Background(), account.Id) + assert.Error(t, err, "account should be deleted after the hooks succeed") +} + +func TestAccountManager_DeleteAccount_DeletionHookErrorAbortsDeletion(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + ownerID := "account_creator" + account, err := createAccount(manager, "test_account", ownerID, "") + require.NoError(t, err) + + manager.AddAccountDeletionHook(func(context.Context, string) error { + return status.Errorf(status.PreconditionFailed, "teardown refused") + }) + secondCalled := false + manager.AddAccountDeletionHook(func(context.Context, string) error { + secondCalled = true + return nil + }) + + err = manager.DeleteAccount(context.Background(), account.Id, ownerID) + require.Error(t, err) + + // The hook's status type has to survive the wrapping, since the HTTP layer maps it + // to the response code. + sErr, ok := status.FromError(err) + require.True(t, ok, "error should carry the hook's status error, got %v", err) + assert.Equal(t, status.PreconditionFailed, sErr.Type(), "status type should be the hook's") + assert.False(t, secondCalled, "hooks after a failing one should not run") + + _, err = manager.Store.GetAccount(context.Background(), account.Id) + assert.NoError(t, err, "account should survive a failing hook") + _, err = manager.Store.GetUserByUserID(context.Background(), store.LockingStrengthNone, ownerID) + assert.NoError(t, err, "account owner should survive a failing hook") +} + +func TestAccountManager_AddAccountDeletionHook_RejectsNil(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + assert.PanicsWithValue(t, "nil account deletion hook", func() { + manager.AddAccountDeletionHook(nil) + }, "registering a nil hook should panic instead of breaking a later deletion") +} + +func TestAccountManager_DeleteAccount_DeletionHooksSkippedWithoutPermission(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + ownerID := "account_creator" + account, err := createAccount(manager, "test_account", ownerID, "") + require.NoError(t, err) + + adminID := "regular_admin" + account.Users[adminID] = types.NewAdminUser(adminID) + require.NoError(t, manager.Store.SaveAccount(context.Background(), account)) + + called := false + manager.AddAccountDeletionHook(func(context.Context, string) error { + called = true + return nil + }) + + err = manager.DeleteAccount(context.Background(), account.Id, adminID) + require.Error(t, err, "only the owner may delete the account") + assert.False(t, called, "hooks should not run for a caller who may not delete the account") +} + func BenchmarkTest_GetAccountWithclaims(b *testing.B) { claims := auth.UserAuth{ Domain: "example.com", diff --git a/management/server/store/sql_store_account.go b/management/server/store/sql_store_account.go index 8729c1f5a..43fe09861 100644 --- a/management/server/store/sql_store_account.go +++ b/management/server/store/sql_store_account.go @@ -18,6 +18,7 @@ import ( "gorm.io/gorm/clause" nbdns "github.com/netbirdio/netbird/dns" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/route" @@ -162,6 +163,10 @@ func (s *SqlStore) DeleteAccount(ctx context.Context, account *types.Account) er return result.Error } + if err := deleteAgentNetworkAccountConfig(tx, account.Id); err != nil { + return err + } + result = tx.Select(clause.Associations).Delete(account) if result.Error != nil { return result.Error @@ -179,6 +184,29 @@ func (s *SqlStore) DeleteAccount(ctx context.Context, account *types.Account) er return err } +// deleteAgentNetworkAccountConfig removes the account's agent network configuration. These +// tables are not account associations, so deleting the account does not reach them. The +// settings row holds the account's globally unique gateway domain and the provider rows +// hold its upstream API keys. Tables that grow with traffic are left out: consumption +// counters and access logs are swept in the background, and usage records are kept. +func deleteAgentNetworkAccountConfig(tx *gorm.DB, accountID string) error { + // Dependents first: policies point at providers and guardrails, and settings + // go last, as DeleteSettings refuses while providers exist. + models := []any{ + &agentNetworkTypes.Policy{}, + &agentNetworkTypes.Provider{}, + &agentNetworkTypes.Guardrail{}, + &agentNetworkTypes.AccountBudgetRule{}, + &agentNetworkTypes.Settings{}, + } + for _, model := range models { + if err := tx.Delete(model, "account_id = ?", accountID).Error; err != nil { + return fmt.Errorf("delete %T rows: %w", model, err) + } + } + return nil +} + func (s *SqlStore) UpdateAccountDomainAttributes(ctx context.Context, accountID string, domain string, category string, isPrimaryDomain bool) error { accountCopy := types.Account{ Domain: domain, diff --git a/management/server/store/sql_store_account_test.go b/management/server/store/sql_store_account_test.go index 4c3b3f5fd..ec5976982 100644 --- a/management/server/store/sql_store_account_test.go +++ b/management/server/store/sql_store_account_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" nbdns "github.com/netbirdio/netbird/dns" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" @@ -401,6 +402,25 @@ func TestSqlite_DeleteAccount(t *testing.T) { require.NoError(t, err) require.Equal(t, o.AccountID, account.Id) + err = store.CreateAgentNetworkSettings(context.Background(), &agentNetworkTypes.Settings{ + AccountID: account.Id, + Domain: "gw.example.com", + ProxyAddress: "gw.example.com", + }) + require.NoError(t, err) + + agentNetworkConfig := []any{ + &agentNetworkTypes.Provider{ID: "an_provider", AccountID: account.Id, APIKey: "sk-test"}, + &agentNetworkTypes.Policy{ID: "an_policy", AccountID: account.Id}, + &agentNetworkTypes.Guardrail{ID: "an_guardrail", AccountID: account.Id}, + &agentNetworkTypes.AccountBudgetRule{ID: "an_budget_rule", AccountID: account.Id}, + } + for _, row := range agentNetworkConfig { + require.NoError(t, store.(*SqlStore).db.Create(row).Error, "creating %T", row) + } + otherProvider := &agentNetworkTypes.Provider{ID: "other_provider", AccountID: "other_account"} + require.NoError(t, store.(*SqlStore).db.Create(otherProvider).Error) + err = store.DeleteAccount(context.Background(), account) require.NoError(t, err) @@ -465,6 +485,32 @@ func TestSqlite_DeleteAccount(t *testing.T) { err = store.(*SqlStore).db.Model(&rpservice.Target{}).Find(&targets, "account_id = ?", account.Id).Error require.NoError(t, err, "expecting no error after DeleteAccount when searching for service targets") require.Len(t, targets, 0, "expecting no service targets to be found after DeleteAccount") + + _, err = store.GetAgentNetworkSettings(context.Background(), LockingStrengthNone, account.Id) + require.Error(t, err, "expecting agent network settings to be deleted with the account") + sErr, ok := status.FromError(err) + require.True(t, ok, "expecting a status error when getting agent network settings, got %v", err) + require.Equal(t, status.NotFound, sErr.Type(), "expecting agent network settings to be deleted with the account") + + // The domain is globally unique, so a leftover row would keep it from another account. + err = store.CreateAgentNetworkSettings(context.Background(), &agentNetworkTypes.Settings{ + AccountID: "other_account", + Domain: "gw.example.com", + ProxyAddress: "gw.example.com", + }) + require.NoError(t, err, "expecting the deleted account's gateway domain to be free for another account") + + for _, row := range agentNetworkConfig { + var count int64 + err = store.(*SqlStore).db.Model(row).Where("account_id = ?", account.Id).Count(&count).Error + require.NoError(t, err, "counting %T rows after DeleteAccount", row) + assert.Zero(t, count, "expecting no %T rows to be found after DeleteAccount", row) + } + + var otherProviders int64 + err = store.(*SqlStore).db.Model(&agentNetworkTypes.Provider{}).Where("account_id = ?", "other_account").Count(&otherProviders).Error + require.NoError(t, err) + assert.Equal(t, int64(1), otherProviders, "expecting another account's agent network provider to survive DeleteAccount") } func Test_GetAccount(t *testing.T) { diff --git a/management/server/store/sql_store_agent_network_access_log.go b/management/server/store/sql_store_agent_network_access_log.go index a1ae0150d..479dfa45e 100644 --- a/management/server/store/sql_store_agent_network_access_log.go +++ b/management/server/store/sql_store_agent_network_access_log.go @@ -69,6 +69,23 @@ func (s *SqlStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountI return deleted, nil } +// GetDeletedAccountIDsWithAgentNetworkAccessLogs returns the IDs of accounts that no +// longer exist but still have access-log rows. The retention sweep is driven by settings +// rows, which are deleted with the account, so it uses this to find logs it would +// otherwise never expire. +func (s *SqlStore) GetDeletedAccountIDsWithAgentNetworkAccessLogs(ctx context.Context) ([]string, error) { + var accountIDs []string + err := s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}). + Distinct("account_id"). + Where("NOT EXISTS (SELECT 1 FROM accounts WHERE accounts.id = agent_network_access_log.account_id)"). + Pluck("account_id", &accountIDs).Error + if err != nil { + log.WithContext(ctx).Errorf("failed to get deleted accounts with agent-network access logs: %v", err) + return nil, status.Errorf(status.Internal, "failed to get deleted accounts with agent-network access logs") + } + return accountIDs, nil +} + // GetAgentNetworkAccessLogs retrieves flattened agent-network access logs for // an account with server-side pagination, filtering and sorting. Authorising // group ids are hydrated from the group child table for the returned page. diff --git a/management/server/store/sql_store_agentnetwork.go b/management/server/store/sql_store_agentnetwork.go index 4fe77d994..bdb1f97d6 100644 --- a/management/server/store/sql_store_agentnetwork.go +++ b/management/server/store/sql_store_agentnetwork.go @@ -663,6 +663,21 @@ func (s *SqlStore) IncrementAgentNetworkConsumptionBatch( return nil } +// DeleteAgentNetworkConsumptionOfDeletedAccounts deletes every consumption counter whose +// account no longer exists and returns the number of rows deleted. Counters grow with +// traffic, so they are swept in the background instead of in the account-deletion +// transaction, and the sweep also catches counters a proxy writes after the deletion. +func (s *SqlStore) DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx context.Context) (int64, error) { + res := s.db. + Where("NOT EXISTS (SELECT 1 FROM accounts WHERE accounts.id = agent_network_consumption.account_id)"). + Delete(&agentNetworkTypes.Consumption{}) + if res.Error != nil { + log.WithContext(ctx).Errorf("failed to delete agent-network consumption of deleted accounts: %v", res.Error) + return 0, status.Errorf(status.Internal, "failed to delete agent-network consumption of deleted accounts") + } + return res.RowsAffected, nil +} + // ListAgentNetworkConsumption returns every consumption row recorded // for the account, ordered by window_start descending. Backs the // dashboard's basic counter view. diff --git a/management/server/store/sql_store_agentnetwork_accesslog_test.go b/management/server/store/sql_store_agentnetwork_accesslog_test.go index 8ba79a062..b8a3560e0 100644 --- a/management/server/store/sql_store_agentnetwork_accesslog_test.go +++ b/management/server/store/sql_store_agentnetwork_accesslog_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/types" ) // TestAgentNetworkUsage_RealStore_RoundTrip drives CreateAgentNetworkUsage and @@ -300,3 +301,37 @@ func TestDeleteOldAgentNetworkAccessLogs(t *testing.T) { require.NoError(t, err) require.Len(t, usage, 1, "usage record for the deleted log must survive") } + +// TestDeleteAgentNetworkConsumptionOfDeletedAccounts verifies that the sweep removes the +// consumption counters of accounts that no longer exist and leaves live accounts' counters, +// including those of a live account without a settings row. +func TestDeleteAgentNetworkConsumptionOfDeletedAccounts(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, s Store) { + ctx := context.Background() + const ( + liveAccountID = "acc-anet-consumption-live" + deletedAccountID = "acc-anet-consumption-deleted" + ) + require.NoError(t, s.SaveAccount(ctx, &types.Account{Id: liveAccountID})) + + windowStart := time.Now().UTC().Truncate(time.Hour) + for _, accountID := range []string{liveAccountID, deletedAccountID} { + for _, dimID := range []string{"user-1", "user-2"} { + require.NoError(t, s.IncrementAgentNetworkConsumption(ctx, accountID, + agentNetworkTypes.DimensionUser, dimID, 3600, windowStart, 10, 5, 0.01)) + } + } + + deleted, err := s.DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx) + require.NoError(t, err) + assert.Equal(t, int64(2), deleted, "both of the deleted account's counters should be removed") + + rows, err := s.ListAgentNetworkConsumption(ctx, LockingStrengthNone, deletedAccountID) + require.NoError(t, err) + assert.Empty(t, rows, "the deleted account should have no consumption counters left") + + rows, err = s.ListAgentNetworkConsumption(ctx, LockingStrengthNone, liveAccountID) + require.NoError(t, err) + assert.Len(t, rows, 2, "the live account's consumption counters should survive") + }) +} diff --git a/management/server/store/store.go b/management/server/store/store.go index 6d810643c..465f84413 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -320,6 +320,7 @@ type Store interface { GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLogSession, int64, error) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkUsage, error) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) + GetDeletedAccountIDsWithAgentNetworkAccessLogs(ctx context.Context) ([]string, error) GetServiceTargetByTargetID(ctx context.Context, lockStrength LockingStrength, accountID string, targetID string) (*rpservice.Target, error) GetTargetsByServiceID(ctx context.Context, lockStrength LockingStrength, accountID string, serviceID string) ([]*rpservice.Target, error) DeleteTarget(ctx context.Context, accountID string, serviceID string, targetID uint) error @@ -388,6 +389,7 @@ type Store interface { GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*agentNetworkTypes.Consumption, error) GetAgentNetworkConsumptionBatch(ctx context.Context, lockStrength LockingStrength, accountID string, keys []agentNetworkTypes.ConsumptionKey) (map[agentNetworkTypes.ConsumptionKey]*agentNetworkTypes.Consumption, error) ListAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Consumption, error) + DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx context.Context) (int64, error) GetAccountAgentNetworkBudgetRules(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.AccountBudgetRule, error) GetAgentNetworkBudgetRuleByID(ctx context.Context, lockStrength LockingStrength, accountID, ruleID string) (*agentNetworkTypes.AccountBudgetRule, error) SaveAgentNetworkBudgetRule(ctx context.Context, rule *agentNetworkTypes.AccountBudgetRule) error diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 4dd7f9286..cd9e7334d 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -457,6 +457,21 @@ func (mr *MockStoreMockRecorder) DeleteAgentNetworkBudgetRule(ctx, accountID, ru return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkBudgetRule), ctx, accountID, ruleID) } +// DeleteAgentNetworkConsumptionOfDeletedAccounts mocks base method. +func (m *MockStore) DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx context.Context) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkConsumptionOfDeletedAccounts", ctx) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteAgentNetworkConsumptionOfDeletedAccounts indicates an expected call of DeleteAgentNetworkConsumptionOfDeletedAccounts. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkConsumptionOfDeletedAccounts", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkConsumptionOfDeletedAccounts), ctx) +} + // DeleteAgentNetworkGuardrail mocks base method. func (m *MockStore) DeleteAgentNetworkGuardrail(ctx context.Context, accountID, guardrailID string) error { m.ctrl.T.Helper() @@ -1972,6 +1987,21 @@ func (mr *MockStoreMockRecorder) GetDNSRecordByID(ctx, lockStrength, accountID, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDNSRecordByID", reflect.TypeOf((*MockStore)(nil).GetDNSRecordByID), ctx, lockStrength, accountID, zoneID, recordID) } +// GetDeletedAccountIDsWithAgentNetworkAccessLogs mocks base method. +func (m *MockStore) GetDeletedAccountIDsWithAgentNetworkAccessLogs(ctx context.Context) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDeletedAccountIDsWithAgentNetworkAccessLogs", ctx) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDeletedAccountIDsWithAgentNetworkAccessLogs indicates an expected call of GetDeletedAccountIDsWithAgentNetworkAccessLogs. +func (mr *MockStoreMockRecorder) GetDeletedAccountIDsWithAgentNetworkAccessLogs(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeletedAccountIDsWithAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).GetDeletedAccountIDsWithAgentNetworkAccessLogs), ctx) +} + // GetEmbeddedProxyPeerIDsByCluster mocks base method. func (m *MockStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) { m.ctrl.T.Helper() From 82e5428c2f75469fdbfb133b52d8d5d284734a21 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Wed, 30 Sep 2026 23:41:30 +0200 Subject: [PATCH 084/209] [management] Let usage_viewer read Agent Network access logs (#7750) usage_viewer saw account-wide usage but only its own request logs, so the people reviewing cost could not drill into the requests behind it. The role now also holds Read on agent_network.logs, which makes the access-log and session endpoints return every caller's rows instead of self-scoping. Logs can contain captured prompts, so this widens what the role exposes; policies, guardrails, budgets and settings stay hidden. Co-authored-by: Misha Bragin --- agent-network/README.md | 12 ++++----- .../permissions/agent_network_roles_test.go | 14 +++++------ .../server/permissions/roles/usage_viewer.go | 25 +++++++++++-------- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/agent-network/README.md b/agent-network/README.md index 029ada299..35b9c6668 100644 --- a/agent-network/README.md +++ b/agent-network/README.md @@ -110,12 +110,12 @@ Two roles delegate Agent Network access without account-admin rights: read-only users, groups, peers, and account info (needed to build policies). Nothing else in the account. - **`usage_viewer`** — the regular User baseline plus read on - `agent_network.usage` (the aggregated usage and cost overview) and read-only - access to the resources the usage filters resolve against: users, groups, - peers, and the provider list (connection config redacted — no upstream URLs - or operator-supplied header values). No policies, and no account-wide - request-level access logs; like any caller, it still reads its own requests - through the self-scoped endpoints below. + `agent_network.usage` (the aggregated usage and cost overview) and + `agent_network.logs` (the account-wide request-level access logs, which can + contain captured prompts), and read-only access to the resources those + filters resolve against: users, groups, peers, and the provider list + (connection config redacted — no upstream URLs or operator-supplied header + values). No policies, guardrails, budgets, or settings. Every authenticated user, regardless of role, can read the caller-scoped self-service endpoint `GET /api/agent-network/agent-config` (the endpoint, providers, diff --git a/management/server/permissions/agent_network_roles_test.go b/management/server/permissions/agent_network_roles_test.go index 9ab708bd7..f5ad2000d 100644 --- a/management/server/permissions/agent_network_roles_test.go +++ b/management/server/permissions/agent_network_roles_test.go @@ -62,11 +62,11 @@ func TestAgentNetworkAdminRole(t *testing.T) { } } -// TestUsageViewerRole pins the least-privilege cost role: read on the -// aggregated usage overview plus read-only on the resources its filters -// and display columns resolve against (users, groups, peers, the provider -// list) — no policies, no request-level logs (which can contain captured -// prompts), nothing else in the account. +// TestUsageViewerRole pins the read-only usage role: read on the aggregated +// usage overview and the account-wide request-level logs, plus read-only on +// the resources their filters and display columns resolve against (users, +// groups, peers, the provider list) — no policies, guardrails, budgets, or +// settings, nothing else in the account. func TestUsageViewerRole(t *testing.T) { manager := NewManager(nil) ctx := context.Background() @@ -76,6 +76,7 @@ func TestUsageViewerRole(t *testing.T) { readOnly := []modules.Module{ modules.AgentNetworkUsage, + modules.AgentNetworkLogs, modules.AgentNetworkProviders, modules.Users, modules.Groups, @@ -83,7 +84,7 @@ func TestUsageViewerRole(t *testing.T) { } for _, m := range readOnly { assert.True(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, operations.Read), - "usage_viewer must read %s for the usage view and its filters", m) + "usage_viewer must read %s for the usage and log views and their filters", m) for _, op := range []operations.Operation{operations.Create, operations.Update, operations.Delete} { assert.False(t, manager.ValidateRoleModuleAccess(ctx, "account", role, m, op), "usage_viewer must not have %s on %s", op, m) @@ -95,7 +96,6 @@ func TestUsageViewerRole(t *testing.T) { modules.AgentNetworkPolicies, modules.AgentNetworkGuardrails, modules.AgentNetworkBudgets, - modules.AgentNetworkLogs, modules.AgentNetworkSettings, modules.Networks, modules.SetupKeys, diff --git a/management/server/permissions/roles/usage_viewer.go b/management/server/permissions/roles/usage_viewer.go index e480ae478..ab35a24db 100644 --- a/management/server/permissions/roles/usage_viewer.go +++ b/management/server/permissions/roles/usage_viewer.go @@ -7,16 +7,15 @@ import ( ) // UsageViewer is the regular User baseline plus read access to the -// aggregated Agent Network usage and cost overview, and read-only access -// to the resources the usage filters and display columns resolve against: -// users and groups (identity filters and name resolution), peers (agent -// principals in the caller column), and the provider list (provider and -// model filter options — the manager redacts connection config such as -// upstream URLs and operator-supplied header values for callers holding -// read without update). It sees no policies and no account-wide -// request-level access logs (which can contain captured prompts); its own -// requests remain readable through the self-scoped endpoints, like any -// caller's. +// aggregated Agent Network usage and cost overview and to the account-wide +// request-level access logs (which can contain captured prompts), and +// read-only access to the resources the usage and log filters and display +// columns resolve against: users and groups (identity filters and name +// resolution), peers (agent principals in the caller column), and the +// provider list (provider and model filter options — the manager redacts +// connection config such as upstream URLs and operator-supplied header +// values for callers holding read without update). It sees no policies, +// guardrails, budgets, or Agent Network settings. var UsageViewer = RolePermissions{ Role: types.UserRoleUsageViewer, AutoAllowNew: map[operations.Operation]bool{ @@ -32,6 +31,12 @@ var UsageViewer = RolePermissions{ operations.Update: false, operations.Delete: false, }, + modules.AgentNetworkLogs: { + operations.Read: true, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, modules.AgentNetworkProviders: { operations.Read: true, operations.Create: false, From 6425b042002f0891e8d3142fd9371b87512b75bb Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:01:10 +0900 Subject: [PATCH 085/209] [client] Only treat LocalSystem as a privileged identity by SID on Windows (#7889) --- client/internal/ipcauth/forward_test.go | 2 +- client/internal/ipcauth/identity.go | 12 ++-- ...tity_sameuser_test.go => identity_test.go} | 55 +++++++++++++++++++ client/internal/ipcauth/privileged.go | 10 +++- client/internal/ipcauth/privileged_test.go | 27 ++++++++- 5 files changed, 97 insertions(+), 9 deletions(-) rename client/internal/ipcauth/{identity_sameuser_test.go => identity_test.go} (56%) diff --git a/client/internal/ipcauth/forward_test.go b/client/internal/ipcauth/forward_test.go index d9adf05da..d80c293be 100644 --- a/client/internal/ipcauth/forward_test.go +++ b/client/internal/ipcauth/forward_test.go @@ -38,7 +38,7 @@ func asDaemon(t *testing.T, id Identity) { prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate }) selfIdentity, selfKnown = id, true - selfMayDelegate = !id.IsPrivileged() + selfMayDelegate = mayDelegate(id) } func TestCallerIdentity_DirectConnections(t *testing.T) { diff --git a/client/internal/ipcauth/identity.go b/client/internal/ipcauth/identity.go index d7d10f57d..255585821 100644 --- a/client/internal/ipcauth/identity.go +++ b/client/internal/ipcauth/identity.go @@ -18,7 +18,8 @@ import ( "google.golang.org/grpc/peer" ) -// Well-known Windows SIDs that identify a fully privileged principal. +// Well-known Windows SIDs. Only LocalSystem and BUILTIN\Administrators identify a +// privileged principal; the service accounts are shared by unrelated services. const ( sidLocalSystem = "S-1-5-18" // NT AUTHORITY\SYSTEM sidLocalService = "S-1-5-19" // NT AUTHORITY\LOCAL SERVICE @@ -67,9 +68,9 @@ func (i Identity) IsWindows() bool { // user-to-root boundary. // // On Windows the decision comes from the caller's token rather than from -// account names or group RIDs: an elevated token, one of the service accounts -// the daemon itself may run as, or a token with BUILTIN\Administrators -// enabled. A UAC-filtered administrator has that group marked deny-only, and +// account names or group RIDs: an elevated token, the LocalSystem SID, or a +// token with BUILTIN\Administrators enabled. LocalService and NetworkService +// are not privileged by SID. A UAC-filtered administrator has that group marked deny-only, and // deny-only groups are dropped when the identity is captured, so such a // caller is correctly reported as unprivileged. Domain group memberships // (Domain Admins and friends) are deliberately not consulted: they say @@ -83,8 +84,7 @@ func (i Identity) IsPrivileged() bool { return true } - switch i.SID { - case sidLocalSystem, sidLocalService, sidNetworkService: + if i.SID == sidLocalSystem { return true } diff --git a/client/internal/ipcauth/identity_sameuser_test.go b/client/internal/ipcauth/identity_test.go similarity index 56% rename from client/internal/ipcauth/identity_sameuser_test.go rename to client/internal/ipcauth/identity_test.go index c98f583db..57be1b94e 100644 --- a/client/internal/ipcauth/identity_sameuser_test.go +++ b/client/internal/ipcauth/identity_test.go @@ -64,3 +64,58 @@ func TestIdentitySameUser(t *testing.T) { }) } } + +func TestIdentityIsPrivileged(t *testing.T) { + tests := []struct { + name string + id Identity + want bool + }{ + { + name: "Root", + id: Identity{UID: 0, GID: 0}, + want: true, + }, + { + name: "Non-root", + id: Identity{UID: 1000, GID: 1000}, + want: false, + }, + { + name: "Local system windows", + id: Identity{SID: sidLocalSystem}, + want: true, + }, + { + name: "Windows elevated", + id: Identity{SID: "S-1-5-21-1927267129-3959769253-3036563910-1001", Elevated: true}, + want: true, + }, + { + name: "Admin group windows", + id: Identity{SID: "S-1-5-21-1927267129-3959769253-3036563910-1001", Groups: []string{sidAdministrators}}, + want: true, + }, + { + name: "Regular user windows", + id: Identity{SID: "S-1-5-21-1927267129-3959769253-3036563910-1001"}, + want: false, + }, + { + name: "Network service windows", + id: Identity{SID: sidNetworkService}, + want: false, + }, + { + name: "Local service windows", + id: Identity{SID: sidLocalService}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.id.IsPrivileged()) + }) + } +} diff --git a/client/internal/ipcauth/privileged.go b/client/internal/ipcauth/privileged.go index 3c2e68432..54c66a5d2 100644 --- a/client/internal/ipcauth/privileged.go +++ b/client/internal/ipcauth/privileged.go @@ -45,7 +45,15 @@ func init() { // matching there would let a non-elevated shell of an administrator account // act as an administrator, which is the boundary the token check exists to // keep. - selfMayDelegate = !id.IsPrivileged() + selfMayDelegate = mayDelegate(id) +} + +// mayDelegate reports whether a daemon running as id may extend its authority to +// callers sharing its identity. The shared service accounts are excluded: their +// SID is held by unrelated services, so matching on it would grant them the +// daemon's authority. +func mayDelegate(id Identity) bool { + return !id.IsPrivileged() && id.SID != sidLocalService && id.SID != sidNetworkService } // IsDaemonSelf reports whether an identity is this very process. The JSON gateway diff --git a/client/internal/ipcauth/privileged_test.go b/client/internal/ipcauth/privileged_test.go index c1c7c1543..a6bbcf44b 100644 --- a/client/internal/ipcauth/privileged_test.go +++ b/client/internal/ipcauth/privileged_test.go @@ -98,7 +98,7 @@ func TestIsPrivilegedCaller_SelfRule(t *testing.T) { t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate }) selfIdentity, selfKnown = tt.self, tt.selfKnown - selfMayDelegate = tt.selfKnown && !tt.self.IsPrivileged() + selfMayDelegate = tt.selfKnown && mayDelegate(tt.self) if got := IsPrivilegedCaller(tt.caller); got != tt.want { t.Fatalf("IsPrivilegedCaller(%v) with daemon %v = %t, want %t", @@ -132,3 +132,28 @@ func TestIsPrivilegedCaller_ThisProcess(t *testing.T) { t.Errorf("an unrelated identity %v was treated as privileged", other) } } + +// The shared service accounts are held by unrelated services, so a daemon running +// as one of them must not extend its authority to every process with that SID. +func TestMayDelegate(t *testing.T) { + tests := []struct { + name string + self Identity + want bool + }{ + {name: "unprivileged unix user", self: Identity{UID: 1000}, want: true}, + {name: "root", self: Identity{UID: 0}, want: false}, + {name: "unprivileged windows user", self: Identity{SID: "S-1-5-21-1-2-3-1001"}, want: true}, + {name: "elevated windows user", self: Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}, want: false}, + {name: "local system", self: Identity{SID: sidLocalSystem}, want: false}, + {name: "local service", self: Identity{SID: sidLocalService}, want: false}, + {name: "network service", self: Identity{SID: sidNetworkService}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mayDelegate(tt.self); got != tt.want { + t.Errorf("mayDelegate(%+v) = %v, want %v", tt.self, got, tt.want) + } + }) + } +} From 3906295446d33466c1579993dbf48ced23980c1d Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 1 Oct 2026 21:00:08 +0200 Subject: [PATCH 086/209] [client] Add Homebrew cask e2e test (#7618) * [client] Migrate macOS cask template to Homebrew install steps Homebrew deprecated the postflight and uninstall_preflight cask stanzas in favour of the declarative *_steps DSL, so every brew command that evaluates netbirdio/tap now prints deprecation warnings. Once the deprecation becomes a disable the generated cask stops loading and netbird-ui can no longer be installed or upgraded through Homebrew. The *_steps blocks take JSON-serialisable steps run in a sandbox rather than arbitrary Ruby, so system_command is re-expressed as run/remove. The two postflight blocks merge into one because a cask carries only a single instance, preserving the original order. set_permissions moves from a hardcoded /Applications to base: :appdir, matching what the installer invocation already did. The launchctl fallbacks keep their tolerant semantics through must_succeed: false, and remove is a no-op when the plist is absent. (cherry picked from commit df3756151f3af5c378aa74fc56dedc70e4adb052) * [client] Test the macOS Homebrew cask on a disposable runner The cask template only runs on real macOS with Homebrew, sudo and launchd, so changes to it have never been exercised before merge. This job installs the rendered cask on a GitHub macOS runner, walks the uninstall through a running, stopped and missing daemon, and reinstalls over the tap's published legacy cask, which is the path every existing user takes on their next upgrade. The fixture is the published cask itself rather than a pinned version and checksums, so the test follows each release instead of breaking at the next one. The installer scripts inside the signed archives are not under test, which is why their paths are left out of the trigger. * [client] Address SonarCloud findings in the Homebrew cask test Positional parameters move into local variables and the scenario switch gains an explicit default, so an unknown scenario fails instead of silently running the plain install and uninstall path. * [client] Make the Homebrew cask test deterministic with a stub bundle The released installer script opens the UI as root, which never returns on a headless runner, so a test that installs the published archive hangs until the job timeout. The cask itself never looks past two script paths and a version argument, so the test now builds a stub bundle on the runner, serves it from a local HTTP server and renders the template against it. The scripts ship without the executable bit, which turns the 0755 check into proof that set_permissions ran, and the stub records the version and uid it received. The published archives are still downloaded to assert the two script paths exist, and the published cask still supplies the legacy stanzas for the reinstall scenario. * [client] Drop the launchctl stderr check from the Homebrew cask test The test asserts what the cask template promises: install, uninstall and no deprecation warnings. Whether the uninstall steps print launchctl errors is a review remark on the template, not part of that contract. * [client] Retry the daemon start in the Homebrew cask test stub A reinstall runs the previous cask's bootout and the new postflight within a second of each other. launchd is still tearing the old daemon down at that point, so loading the same label again fails with EIO. The stub now retries the start for up to fifteen seconds, and the test still verifies afterwards that the daemon reached the running state. --------- Co-authored-by: Daniele Casciani --- .github/scripts/test-homebrew-cask.sh | 338 +++++++++++++++++++++++ .github/workflows/test-homebrew-cask.yml | 46 +++ client/ui/netbird-ui.rb.tmpl | 26 +- 3 files changed, 393 insertions(+), 17 deletions(-) create mode 100755 .github/scripts/test-homebrew-cask.sh create mode 100644 .github/workflows/test-homebrew-cask.yml diff --git a/.github/scripts/test-homebrew-cask.sh b/.github/scripts/test-homebrew-cask.sh new file mode 100755 index 000000000..9c1041bdd --- /dev/null +++ b/.github/scripts/test-homebrew-cask.sh @@ -0,0 +1,338 @@ +#!/usr/bin/env bash +set -euo pipefail + +fail() { + echo "::error::$*" >&2 + exit 1 +} + +if [[ ${RUNNER_ENVIRONMENT:-} != github-hosted || ${RUNNER_OS:-} != macOS || $(uname -s) != Darwin ]]; then + fail "This test installs a system daemon and must run on a disposable GitHub macOS runner." +fi +if [[ $EUID == 0 ]]; then + fail "Run this script as the Homebrew user, not root." +fi + +readonly test_dir="${RUNNER_TEMP:?}/homebrew-cask" +readonly results_dir="$test_dir/results" +readonly app='/Applications/Netbird UI.app' +readonly plist='/Library/LaunchDaemons/netbird.plist' +readonly cask='netbirdio/tap/netbird-ui' +readonly formula='netbirdio/tap/netbird' +readonly published_cask="$test_dir/published-netbird-ui.rb" +readonly legacy_cask="$test_dir/legacy-netbird-ui.rb" +readonly rendered_cask="$test_dir/rendered-netbird-ui.rb" +readonly fixture_dir="$test_dir/fixture" +readonly serve_dir="$test_dir/serve" +readonly fixture_zip="$serve_dir/netbird-ui.zip" +readonly fixture_port=18080 +readonly fixture_url="http://127.0.0.1:$fixture_port/netbird-ui.zip" +readonly marker="$test_dir/installer.marker" + +mkdir -p "$results_dir" "$fixture_dir/netbird_ui_darwin" "$serve_dir" "$test_dir/downloads" +exec > >(tee "$results_dir/test.log") 2>&1 + +sudo -n true +if command -v netbird || [[ -e "$app" || -e "$plist" ]] || pgrep -x netbird-ui; then + fail "The runner already has NetBird installed or running." +fi +if sudo launchctl print system/netbird > "$results_dir/initial-service.log" 2>&1; then + fail "The runner already has a NetBird service loaded." +fi + +install_attempted=false +server_pid='' +daemon_pid='' +version='' + +stop_ui() { + local status=0 + sudo pkill -x netbird-ui || status=$? + # pkill returns 1 when the UI is already closed. + [[ $status == 0 || $status == 1 ]] +} + +cleanup() { + local status=$? + trap - EXIT + set +e + + if [[ $install_attempted == true ]]; then + stop_ui || status=1 + if [[ -S /var/run/netbird.sock ]]; then + sudo netbird down || status=1 + fi + if brew list --cask "$cask" >/dev/null 2>&1 || [[ -e "$app" ]]; then + brew uninstall --cask --force "$cask" || status=1 + fi + # A failed cask install can leave a daemon even after Homebrew rolls back the app. + if sudo launchctl print system/netbird > "$results_dir/cleanup-service.log" 2>&1; then + sudo netbird service stop || status=1 + fi + if [[ -e "$plist" ]]; then + sudo netbird service uninstall || status=1 + fi + fi + if [[ -f /var/log/netbird/client.log ]]; then + sudo cat /var/log/netbird/client.log > "$results_dir/client.log" || status=1 + fi + if command -v netbird >/dev/null; then + brew uninstall --formula "$formula" || status=1 + fi + if [[ -n $server_pid ]]; then + kill "$server_pid" 2>/dev/null || true + fi + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +run_logged() { + local name=$1 + shift + "$@" 2>&1 | tee "$results_dir/$name.log" +} + +cask_field() { + local stanza=$1 file=$2 + sed -nE "s/^[[:space:]]*$stanza \"([^\"]+)\".*/\\1/p" "$file" +} + +release_fields() { + local file=$1 + grep -E '^[[:space:]]*(version|url|sha256|app) ' "$file" +} + +use_cask() { + local file=$1 + cp "$file" "$tap_dir/Casks/netbird-ui.rb" +} + +# The released installer opens the UI as root, which never returns on a headless +# runner. The cask only needs two script paths and a version argument, so the test +# ships a stub bundle that records what it received and starts the daemon. +build_fixture() { + local bundle="$fixture_dir/netbird_ui_darwin" + printf '#!/bin/sh\nexit 0\n' > "$bundle/netbird-ui" + chmod 755 "$bundle/netbird-ui" + # After a bootout launchd keeps tearing the previous daemon down for a couple of + # seconds, and loading the same label again fails until that finishes. + cat > "$bundle/installer.sh" < '$marker' +netbird service install +attempt=0 +until netbird service start; do + attempt=\$((attempt + 1)) + [ "\$attempt" -lt 15 ] || exit 1 + sleep 1 +done +EOF + printf '#!/bin/sh\nexit 0\n' > "$bundle/uninstaller.sh" + # Shipped without the executable bit so the 0755 seen after install can only come from the cask. + chmod 644 "$bundle/installer.sh" "$bundle/uninstaller.sh" + rm -f "$fixture_zip" + (cd "$fixture_dir" && zip -qr "$fixture_zip" netbird_ui_darwin) +} + +start_fixture_server() { + python3 -m http.server "$fixture_port" --bind 127.0.0.1 --directory "$serve_dir" \ + > "$results_dir/fixture-server.log" 2>&1 & + server_pid=$! + local attempt + for attempt in {1..20}; do + if curl --silent --fail --output /dev/null "$fixture_url"; then + return + fi + sleep 0.5 + done + fail "The fixture HTTP server did not come up on port $fixture_port." +} + +assert_published_layout() { + local url archive script + while read -r url; do + archive="$test_dir/downloads/${url##*/}" + curl --fail --location --silent --retry 3 --output "$archive" "$url" + for script in installer.sh uninstaller.sh; do + unzip -l "$archive" | grep -q " netbird_ui_darwin/$script\$" || + fail "The published archive ${url##*/} has no netbird_ui_darwin/$script." + done + done < <(cask_field url "$published_cask") +} + +assert_no_deprecations() { + if grep -Ei '(postflight|uninstall_preflight).*deprecated|deprecated.*(postflight|uninstall_preflight)' "$@"; then + fail "Homebrew reported a deprecated cask lifecycle hook." + fi +} + +wait_for_daemon() { + local attempt + for attempt in {1..30}; do + if sudo launchctl print system/netbird > "$results_dir/service.log" 2>&1 && + grep -Eq '^[[:space:]]*state = running$' "$results_dir/service.log"; then + return + fi + sleep 1 + done + cat "$results_dir/service.log" + fail "The installed daemon did not reach the running state." +} + +wait_for_exit() { + local pid=$1 attempt + for attempt in {1..30}; do + if ! sudo kill -0 "$pid" 2>/dev/null; then + return + fi + sleep 1 + done + fail "Daemon process $pid is still running after removal." +} + +assert_service_absent() { + if sudo launchctl print system/netbird > "$results_dir/removed-service.log" 2>&1; then + fail "The NetBird service is still loaded after removal." + fi +} + +assert_installed() { + local script + [[ -f $marker ]] || fail "The cask did not run installer.sh." + grep -qx "version=$version" "$marker" || fail "installer.sh did not receive the cask version: $(cat "$marker")" + grep -qx 'uid=0' "$marker" || fail "installer.sh did not run as root: $(cat "$marker")" + [[ -d "$app" && -x "$app/netbird-ui" ]] || fail "The UI was not installed." + for script in installer.sh uninstaller.sh; do + [[ $(stat -f '%Lp' "$app/$script") == 755 ]] || fail "Incorrect permissions on $script." + done + [[ -f "$plist" ]] || fail "The installer did not create the daemon plist." + wait_for_daemon + daemon_pid=$(awk '/^[[:space:]]*pid = / { print $3; exit }' "$results_dir/service.log") + [[ $daemon_pid =~ ^[0-9]+$ ]] || fail "The running daemon has no PID." + sudo kill -0 "$daemon_pid" +} + +assert_uninstalled() { + local log=$1 + assert_no_deprecations "$log" + [[ ! -e "$app" ]] || fail "The UI app remains after uninstall." + [[ ! -e "$plist" ]] || fail "The daemon plist remains after uninstall." + assert_service_absent + wait_for_exit "$daemon_pid" + [[ $(netbird version) == "$version" ]] || fail "Cask uninstall removed the CLI dependency." +} + +installed_caskfiles() { + local extension=$1 + find "$(brew --caskroom)/netbird-ui/.metadata" -name "netbird-ui.$extension" 2>/dev/null +} + +assert_legacy_metadata() { + installed_caskfiles rb | grep -q . || fail "The legacy cask did not leave a Ruby caskfile behind." +} + +assert_steps_metadata() { + if installed_caskfiles rb | grep -q .; then + fail "Homebrew still keeps the legacy Ruby caskfile after reinstall." + fi + installed_caskfiles json | grep -q . || fail "Homebrew did not save the reinstalled cask as JSON." +} + +brew --version +sw_vers +brew tap netbirdio/tap "${GITHUB_WORKSPACE:?}/.homebrew-cask-tap" +tap_dir=$(brew --repository netbirdio/tap) +readonly tap_dir + +[[ -f "$tap_dir/Casks/netbird-ui.rb" ]] || fail "The tap has no Casks/netbird-ui.rb." +cp "$tap_dir/Casks/netbird-ui.rb" "$published_cask" +cp "$published_cask" "$results_dir/published-netbird-ui.rb" + +version=$(brew info --json=v2 --formula "$formula" | jq -r '.formulae[0].versions.stable') +readonly version +[[ -n $version && $version != null ]] || fail "Could not read the formula version from the tap." + +assert_published_layout + +build_fixture +fixture_sha=$(shasum -a 256 "$fixture_zip" | cut -d' ' -f1) +readonly fixture_sha +start_fixture_server + +export PROJECT=netbird-ui VERSION="$version" +export AMD="$fixture_zip" ARM="$fixture_zip" AMD_URL="$fixture_url" ARM_URL="$fixture_url" +gomplate -f "$GITHUB_WORKSPACE/client/ui/netbird-ui.rb.tmpl" -o "$rendered_cask" +cp "$rendered_cask" "$results_dir/rendered-netbird-ui.rb" + +sed -E "s|^([[:space:]]*version) \"[^\"]+\"|\\1 \"$version\"|; s|^([[:space:]]*url) \"[^\"]+\"|\\1 \"$fixture_url\"|; s|^([[:space:]]*sha256) \"[^\"]+\"|\\1 \"$fixture_sha\"|" \ + "$published_cask" > "$legacy_cask" +cp "$legacy_cask" "$results_dir/legacy-netbird-ui.rb" +if ! diff <(release_fields "$legacy_cask") <(release_fields "$rendered_cask"); then + fail "The rendered cask changes release data, not only lifecycle stanzas." +fi + +use_cask "$rendered_cask" +brew info --json=v2 --cask "$cask" > "$results_dir/cask.json" 2> "$results_dir/load.log" +cat "$results_dir/load.log" +assert_no_deprecations "$results_dir/load.log" +run_logged style brew style --cask --only-cops=Cask/InstallSteps "$cask" + +run_logged install-cli brew install --formula "$formula" +[[ $(netbird version) == "$version" ]] || fail "The installed CLI does not report the formula version." + +for scenario in running stopped missing; do + echo "::group::Uninstall with $scenario service" + install_attempted=true + sudo rm -f "$marker" + run_logged "install-$scenario" brew install --cask "$cask" + assert_no_deprecations "$results_dir/install-$scenario.log" + assert_installed + stop_ui + + case "$scenario" in + running) ;; + stopped) + run_logged stop-daemon sudo netbird service stop + wait_for_exit "$daemon_pid" + [[ -f "$plist" ]] || fail "Stopping the daemon unexpectedly removed its plist." + ;; + missing) + run_logged stop-missing-daemon sudo netbird service stop + run_logged remove-daemon sudo netbird service uninstall + wait_for_exit "$daemon_pid" + [[ ! -e "$plist" ]] || fail "The missing-service scenario still has a plist." + assert_service_absent + ;; + *) fail "Unknown uninstall scenario: $scenario" ;; + esac + + run_logged "uninstall-$scenario" brew uninstall --cask "$cask" + assert_uninstalled "$results_dir/uninstall-$scenario.log" + echo "::endgroup::" +done + +# Every existing user first meets the new cask through an upgrade of the published +# one, whose legacy flight blocks Homebrew replays from the saved Ruby caskfile. +echo "::group::Reinstall over the published legacy cask" +install_attempted=true +use_cask "$legacy_cask" +sudo rm -f "$marker" +run_logged install-legacy brew install --cask "$cask" +assert_installed +assert_legacy_metadata +stop_ui + +use_cask "$rendered_cask" +sudo rm -f "$marker" +run_logged reinstall-legacy brew reinstall --cask "$cask" +assert_installed +assert_steps_metadata +stop_ui + +run_logged uninstall-legacy brew uninstall --cask "$cask" +assert_uninstalled "$results_dir/uninstall-legacy.log" +echo "::endgroup::" diff --git a/.github/workflows/test-homebrew-cask.yml b/.github/workflows/test-homebrew-cask.yml new file mode 100644 index 000000000..a75951425 --- /dev/null +++ b/.github/workflows/test-homebrew-cask.yml @@ -0,0 +1,46 @@ +name: Test Homebrew cask + +on: + pull_request: + paths: + - "client/ui/netbird-ui.rb.tmpl" + - ".github/scripts/test-homebrew-cask.sh" + - ".github/workflows/test-homebrew-cask.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} + cancel-in-progress: true + +jobs: + install-uninstall: + runs-on: macos-latest + timeout-minutes: 20 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Clone the Homebrew tap + run: git clone https://github.com/netbirdio/homebrew-tap.git .homebrew-cask-tap + + - name: Update Homebrew and install gomplate + # The runner image disables auto-update; the cask steps DSL needs Homebrew 6.0.20 or newer. + run: | + brew update + brew install gomplate + + - name: Install and uninstall the cask + run: .github/scripts/test-homebrew-cask.sh + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + with: + name: homebrew-cask-results + path: ${{ runner.temp }}/homebrew-cask/results + if-no-files-found: ignore diff --git a/client/ui/netbird-ui.rb.tmpl b/client/ui/netbird-ui.rb.tmpl index 1c77e6717..a6ef59d3f 100644 --- a/client/ui/netbird-ui.rb.tmpl +++ b/client/ui/netbird-ui.rb.tmpl @@ -17,25 +17,17 @@ cask "{{ $projectName }}" do depends_on formula: "netbird" - postflight do - set_permissions "/Applications/Netbird UI.app/installer.sh", '0755' - set_permissions "/Applications/Netbird UI.app/uninstaller.sh", '0755' + postflight_steps do + set_permissions ["Netbird UI.app/installer.sh", "Netbird UI.app/uninstaller.sh"], "0755", + base: :appdir, recursive: false + run "Netbird UI.app/installer.sh", args: ["#{version}"], base: :appdir, sudo: true end - postflight do - system_command "#{appdir}/Netbird UI.app/installer.sh", - args: ["#{version}"], - sudo: true - end - - uninstall_preflight do - system_command "/bin/sh", - args: ["-c", <<~CMD], - launchctl bootout system/netbird 2>/dev/null || \ - launchctl unload /Library/LaunchDaemons/netbird.plist 2>/dev/null || true - rm -f /Library/LaunchDaemons/netbird.plist - CMD - sudo: true + uninstall_preflight_steps do + run "/bin/launchctl", args: ["bootout", "system/netbird"], sudo: true, must_succeed: false + run "/bin/launchctl", args: ["unload", "/Library/LaunchDaemons/netbird.plist"], + sudo: true, must_succeed: false + remove "/Library/LaunchDaemons/netbird.plist", sudo: true end name "Netbird UI" From 5ceca6e500fb453ddb55a917f55fb84929eabcbd Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 1 Oct 2026 21:01:56 +0200 Subject: [PATCH 087/209] [client] Report both peers' state when the connect test times out (#7944) Test_ConnectPeers fails every few weeks on the Linux runner with a bare "waiting for peer handshake timeout after 30s". The failing logs show both kernel devices up and both peers configured within a second, then nothing for 30 s, which is six retries of the 5 s handshake retransmit and so a condition that lasted the whole window rather than a race. The failure cannot be reproduced locally and the log cannot tell whether initiations were sent, whether they arrived, or whether only one direction worked. On timeout the test now prints each device's view of its peer, the endpoint, the byte counters and the last handshake, so the next failure says which of those it is. The comment also states that the peers are kernel devices on the runner and that the first initiation of each side is always lost to the other side not knowing the peer yet. --- client/iface/iface_test.go | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go index 89c8cd16e..fff0d4e30 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -568,11 +568,14 @@ func Test_ConnectPeers(t *testing.T) { if err != nil { t.Fatal(err) } - // The peers use userspace WireGuard (stdnet transport). A tight busy-loop - // here starves the wireguard-go goroutines that process the handshake, so - // poll on a ticker instead and yield the CPU between checks. WireGuard also - // only retries a lost handshake initiation every REKEY_TIMEOUT (5s), which - // is why the overall wait can occasionally stretch to tens of seconds. + // On Linux with the kernel module both peers are kernel devices, elsewhere + // they run on wireguard-go. A tight busy-loop here would starve the + // wireguard-go goroutines that process the handshake, so poll on a ticker + // instead and yield the CPU between checks. WireGuard also only retries a + // lost handshake initiation every REKEY_TIMEOUT (5s), which is why the + // overall wait can occasionally stretch to tens of seconds. Each side sends + // its first initiation when its peer is configured, and the first one leaves + // before the other device knows the peer, so that one is always wasted. timeout := 30 * time.Second timeoutChannel := time.After(timeout) ticker := time.NewTicker(500 * time.Millisecond) @@ -590,13 +593,26 @@ func Test_ConnectPeers(t *testing.T) { select { case <-timeoutChannel: - t.Fatalf("waiting for peer handshake timeout after %s", timeout.String()) + // The counters tell whether initiations were sent at all, whether they + // arrived, and whether only one direction is working. + t.Fatalf("waiting for peer handshake timeout after %s\n%s\n%s", timeout.String(), + describePeer(peer1ifaceName, peer2Key.PublicKey().String()), + describePeer(peer2ifaceName, peer1Key.PublicKey().String())) case <-ticker.C: } } } +func describePeer(ifaceName, peerPubKey string) string { + peer, err := getPeer(ifaceName, peerPubKey) + if err != nil { + return fmt.Sprintf("%s: peer %s: %v", ifaceName, peerPubKey, err) + } + return fmt.Sprintf("%s: peer %s endpoint=%v tx=%d rx=%d last_handshake=%v", + ifaceName, peerPubKey, peer.Endpoint, peer.TransmitBytes, peer.ReceiveBytes, peer.LastHandshakeTime) +} + func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) { wg, err := wgctrl.New() if err != nil { From f400f4bee8d110c1bae499a42e52a505792877c5 Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Fri, 2 Oct 2026 12:02:06 +0200 Subject: [PATCH 088/209] [proxy] Optionally refuse private addresses on direct-upstream dials (#7913) Direct-upstream targets are dialled on the proxy host's network stack, outside the embedded client's LAN blocking. A proxy that serves untrusted accounts lets them reach the host's loopback, its LAN or cluster, and the cloud metadata service through such a target. NB_PROXY_DIRECT_UPSTREAM_BLOCK_PRIVATE adds a dialer control that refuses addresses that are not globally reachable. It checks each socket's resolved address just before connect, so hostnames and DNS rebinding are covered, and IPv4 embedded in IPv6 addresses is checked as IPv4. Refused dials are served as a 502. The setting defaults to off for private and self-hosted proxies; an unparsable value turns it on. --- proxy/internal/proxy/reverseproxy.go | 6 + proxy/internal/proxy/reverseproxy_test.go | 11 ++ proxy/internal/roundtrip/dialguard.go | 90 ++++++++++ proxy/internal/roundtrip/dialguard_test.go | 189 +++++++++++++++++++++ proxy/internal/roundtrip/multi.go | 7 +- proxy/internal/roundtrip/transport.go | 27 +++ 6 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 proxy/internal/roundtrip/dialguard.go create mode 100644 proxy/internal/roundtrip/dialguard_test.go diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index 7b0acd1b3..7583b2e01 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -809,6 +809,12 @@ func classifyProxyError(err error) (title, message string, code int, status web. http.StatusBadGateway, web.ErrorStatus{Proxy: false, Destination: false} + case errors.Is(err, roundtrip.ErrDirectUpstreamBlocked): + return "Destination Not Allowed", + "This proxy does not connect to private or internal addresses. Please contact your administrator.", + http.StatusBadGateway, + web.ErrorStatus{Proxy: false, Destination: false} + case errors.Is(err, roundtrip.ErrTooManyInflight): return "Service Overloaded", "The service is currently handling too many requests. Please try again shortly.", diff --git a/proxy/internal/proxy/reverseproxy_test.go b/proxy/internal/proxy/reverseproxy_test.go index 83afee387..c0724ce84 100644 --- a/proxy/internal/proxy/reverseproxy_test.go +++ b/proxy/internal/proxy/reverseproxy_test.go @@ -1053,6 +1053,17 @@ func TestClassifyProxyError(t *testing.T) { wantCode: http.StatusBadGateway, wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, }, + { + name: "direct upstream blocked by dial guard", + err: &net.OpError{ + Op: "dial", + Net: "tcp", + Err: roundtrip.ErrDirectUpstreamBlocked, + }, + wantTitle: "Destination Not Allowed", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: false, Destination: false}, + }, { name: "unknown error falls to default", err: errors.New("something unexpected"), diff --git a/proxy/internal/roundtrip/dialguard.go b/proxy/internal/roundtrip/dialguard.go new file mode 100644 index 000000000..ac01263b4 --- /dev/null +++ b/proxy/internal/roundtrip/dialguard.go @@ -0,0 +1,90 @@ +package roundtrip + +import ( + "context" + "errors" + "net/netip" + "syscall" +) + +// ErrDirectUpstreamBlocked is returned when a direct-upstream dial targets +// an address that is not globally reachable while +// NB_PROXY_DIRECT_UPSTREAM_BLOCK_PRIVATE is set. +var ErrDirectUpstreamBlocked = errors.New("direct upstream address is not allowed") + +// blockedUpstreamPrefixes are the ranges that reach the proxy host, its +// cluster or its cloud provider rather than the public internet. NAT64 +// and 6to4 addresses are matched by the IPv4 address they embed. +var blockedUpstreamPrefixes = []netip.Prefix{ + // IPv4 + netip.MustParsePrefix("0.0.0.0/8"), // "this network", including 0.0.0.0 + netip.MustParsePrefix("10.0.0.0/8"), // RFC1918 + netip.MustParsePrefix("100.64.0.0/10"), // CGNAT + netip.MustParsePrefix("127.0.0.0/8"), // loopback + netip.MustParsePrefix("169.254.0.0/16"), // link-local, cloud metadata services + netip.MustParsePrefix("172.16.0.0/12"), // RFC1918 + netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments + netip.MustParsePrefix("192.0.2.0/24"), // documentation + netip.MustParsePrefix("192.88.99.0/24"), // 6to4 relay anycast (deprecated) + netip.MustParsePrefix("192.168.0.0/16"), // RFC1918 + netip.MustParsePrefix("198.18.0.0/15"), // benchmarking + netip.MustParsePrefix("198.51.100.0/24"), // documentation + netip.MustParsePrefix("203.0.113.0/24"), // documentation + netip.MustParsePrefix("224.0.0.0/4"), // multicast + netip.MustParsePrefix("240.0.0.0/4"), // reserved, including broadcast + + // IPv6 + netip.MustParsePrefix("::/96"), // unspecified, loopback, IPv4-compatible + netip.MustParsePrefix("64:ff9b:1::/48"), // local-use NAT64 + netip.MustParsePrefix("100::/64"), // discard-only + netip.MustParsePrefix("2001::/32"), // Teredo + netip.MustParsePrefix("2001:2::/48"), // benchmarking + netip.MustParsePrefix("2001:db8::/32"), // documentation + netip.MustParsePrefix("3fff::/20"), // documentation + netip.MustParsePrefix("5f00::/16"), // SRv6 SIDs + netip.MustParsePrefix("fc00::/7"), // unique local, including AWS IMDS fd00:ec2::254 + netip.MustParsePrefix("fe80::/10"), // link-local + netip.MustParsePrefix("fec0::/10"), // site-local (deprecated) + netip.MustParsePrefix("ff00::/8"), // multicast +} + +var ( + nat64Prefix = netip.MustParsePrefix("64:ff9b::/96") + sixToFour = netip.MustParsePrefix("2002::/16") +) + +// isBlockedUpstreamAddr reports whether a guarded direct-upstream dial +// must refuse addr. +func isBlockedUpstreamAddr(addr netip.Addr) bool { + addr = addr.Unmap().WithZone("") + if !addr.IsValid() { + return true + } + + if nat64Prefix.Contains(addr) { + b := addr.As16() + return isBlockedUpstreamAddr(netip.AddrFrom4([4]byte(b[12:16]))) + } + if sixToFour.Contains(addr) { + b := addr.As16() + return isBlockedUpstreamAddr(netip.AddrFrom4([4]byte(b[2:6]))) + } + + for _, p := range blockedUpstreamPrefixes { + if p.Contains(addr) { + return true + } + } + return false +} + +// guardUpstreamDial is a net.Dialer ControlContext that refuses blocked +// addresses. It sees the resolved address of each socket just before +// connect, so DNS rebinding cannot swap the target after the check. +func guardUpstreamDial(_ context.Context, _, address string, _ syscall.RawConn) error { + ap, err := netip.ParseAddrPort(address) + if err != nil || isBlockedUpstreamAddr(ap.Addr()) { + return ErrDirectUpstreamBlocked + } + return nil +} diff --git a/proxy/internal/roundtrip/dialguard_test.go b/proxy/internal/roundtrip/dialguard_test.go new file mode 100644 index 000000000..79453d8c3 --- /dev/null +++ b/proxy/internal/roundtrip/dialguard_test.go @@ -0,0 +1,189 @@ +package roundtrip + +import ( + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsBlockedUpstreamAddr(t *testing.T) { + blocked := []string{ + "0.0.0.0", + "0.1.2.3", + "10.1.2.3", + "100.64.0.1", + "100.127.255.254", + "127.0.0.1", + "127.255.255.255", + "169.254.169.254", + "172.16.0.1", + "172.31.255.255", + "192.0.0.170", + "192.168.1.1", + "192.88.99.1", + "198.18.0.1", + "224.0.0.1", + "255.255.255.255", + "::", + "::1", + "::169.254.169.254", + "::ffff:127.0.0.1", + "::ffff:169.254.169.254", + "::ffff:10.0.0.1", + "64:ff9b::a9fe:a9fe", // NAT64 of 169.254.169.254 + "64:ff9b::a00:1", // NAT64 of 10.0.0.1 + "64:ff9b:1::1", + "2001::1", + "2001:0:4136:e378:8000:63bf:3fff:fdd2", + "2001:2::1", + "3fff::1", + "5f00::1", + "2002:a9fe:a9fe::1", // 6to4 of 169.254.169.254 + "2002:7f00:1::", // 6to4 of 127.0.0.1 + "fc00::1", + "fd00:ec2::254", + "fe80::1", + "fe80::1%eth0", + "fec0::1", + "ff02::1", + } + for _, s := range blocked { + t.Run("blocks "+s, func(t *testing.T) { + assert.True(t, isBlockedUpstreamAddr(netip.MustParseAddr(s))) + }) + } + + allowed := []string{ + "1.1.1.1", + "8.8.8.8", + "100.63.255.255", + "100.128.0.0", + "172.15.255.255", + "172.32.0.0", + "169.253.255.255", + "2606:4700:4700::1111", + "2001:4860:4860::8888", + "2001:1::1", + "4000::1", + "::ffff:8.8.8.8", + "64:ff9b::808:808", // NAT64 of 8.8.8.8 + "2002:808:808::1", // 6to4 of 8.8.8.8 + } + for _, s := range allowed { + t.Run("allows "+s, func(t *testing.T) { + assert.False(t, isBlockedUpstreamAddr(netip.MustParseAddr(s))) + }) + } + + assert.True(t, isBlockedUpstreamAddr(netip.Addr{}), "the zero Addr must be refused") +} + +func TestGuardUpstreamDial_RejectsUnparsableAddress(t *testing.T) { + err := guardUpstreamDial(context.Background(), "tcp", "not-an-address", nil) + assert.ErrorIs(t, err, ErrDirectUpstreamBlocked, "an address the guard cannot parse must fail closed") +} + +// TestMultiTransport_BlockPrivateUpstreams exercises the guard end to end +// against a loopback test server: by IP literal and by a hostname that +// resolves to loopback, on both direct branches, and confirms the +// embedded branch is not affected. +func TestMultiTransport_BlockPrivateUpstreams(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "reached") + })) + defer srv.Close() + + _, port, err := net.SplitHostPort(srv.Listener.Addr().String()) + require.NoError(t, err) + byName := (&url.URL{Scheme: "http", Host: net.JoinHostPort("localhost", port)}).String() + + directCtx := WithDirectUpstream(context.Background()) + insecureCtx := WithSkipTLSVerify(directCtx) + + // roundTrip returns the response body, so callers never hold one open. + roundTrip := func(t *testing.T, mt *MultiTransport, ctx context.Context, target string) (string, error) { + t.Helper() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + require.NoError(t, err) + resp, err := mt.RoundTrip(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return string(body), nil + } + + t.Run("enabled refuses loopback", func(t *testing.T) { + t.Setenv(EnvDirectUpstreamBlockPrivate, "true") + mt := NewMultiTransport(&stubRoundTripper{body: "embedded"}, nil) + + cases := []struct { + name string + ctx context.Context + target string + }{ + {"direct by IP", directCtx, srv.URL}, + {"direct by hostname", directCtx, byName}, + {"insecure by IP", insecureCtx, srv.URL}, + {"insecure by hostname", insecureCtx, byName}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := roundTrip(t, mt, tc.ctx, tc.target) + require.Error(t, err) + assert.ErrorIs(t, err, ErrDirectUpstreamBlocked) + }) + } + }) + + t.Run("invalid value enables the guard", func(t *testing.T) { + t.Setenv(EnvDirectUpstreamBlockPrivate, "yes please") + mt := NewMultiTransport(&stubRoundTripper{body: "embedded"}, nil) + + _, err := roundTrip(t, mt, directCtx, srv.URL) + assert.ErrorIs(t, err, ErrDirectUpstreamBlocked, "a value that does not parse must fail closed") + }) + + t.Run("explicit false disables the guard", func(t *testing.T) { + t.Setenv(EnvDirectUpstreamBlockPrivate, "false") + mt := NewMultiTransport(&stubRoundTripper{body: "embedded"}, nil) + + body, err := roundTrip(t, mt, directCtx, srv.URL) + require.NoError(t, err) + assert.Equal(t, "reached", body) + }) + + t.Run("enabled leaves embedded branch alone", func(t *testing.T) { + t.Setenv(EnvDirectUpstreamBlockPrivate, "true") + embedded := &stubRoundTripper{body: "embedded"} + mt := NewMultiTransport(embedded, nil) + + body, err := roundTrip(t, mt, context.Background(), srv.URL) + require.NoError(t, err) + assert.Equal(t, "embedded", body) + assert.True(t, embedded.called, "the guard must not change dispatch to the embedded transport") + }) + + t.Run("disabled by default", func(t *testing.T) { + // Register the restore first so an exported value comes back after + // the test, then exercise a genuinely absent variable. + t.Setenv(EnvDirectUpstreamBlockPrivate, "") + require.NoError(t, os.Unsetenv(EnvDirectUpstreamBlockPrivate)) + mt := NewMultiTransport(&stubRoundTripper{body: "embedded"}, nil) + + body, err := roundTrip(t, mt, directCtx, srv.URL) + require.NoError(t, err, "private and self-hosted proxies must keep reaching local upstreams") + assert.Equal(t, "reached", body) + }) +} diff --git a/proxy/internal/roundtrip/multi.go b/proxy/internal/roundtrip/multi.go index d50ad1fc9..a430d45bd 100644 --- a/proxy/internal/roundtrip/multi.go +++ b/proxy/internal/roundtrip/multi.go @@ -41,7 +41,9 @@ var errNoEmbeddedTransport = errors.New("multitransport: embedded roundtripper n // MultiTransport that only ever uses the direct branch. The direct // branches honour the same NB_PROXY_* tuning env vars as the embedded // transport (see loadTransportConfig) plus a dial-timeout wrapper that -// respects types.WithDialTimeout. +// respects types.WithDialTimeout. With NB_PROXY_DIRECT_UPSTREAM_BLOCK_PRIVATE +// set, the direct branches refuse addresses that are not globally reachable +// (see guardUpstreamDial). func NewMultiTransport(embedded http.RoundTripper, logger *log.Logger) *MultiTransport { if logger == nil { logger = log.StandardLogger() @@ -51,6 +53,9 @@ func NewMultiTransport(embedded http.RoundTripper, logger *log.Logger) *MultiTra Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, } + if cfg.blockPrivateUpstreams { + dialer.ControlContext = guardUpstreamDial + } direct := &http.Transport{ DialContext: dialWithTimeout(dialer.DialContext), MaxIdleConns: cfg.maxIdleConns, diff --git a/proxy/internal/roundtrip/transport.go b/proxy/internal/roundtrip/transport.go index 9e872e447..6383079c4 100644 --- a/proxy/internal/roundtrip/transport.go +++ b/proxy/internal/roundtrip/transport.go @@ -25,6 +25,12 @@ const ( EnvDisableCompression = "NB_PROXY_DISABLE_COMPRESSION" EnvMaxInflight = "NB_PROXY_MAX_INFLIGHT" EnvUpstreamHTTPVersion = "NB_PROXY_UPSTREAM_HTTP_VERSION" + // EnvDirectUpstreamBlockPrivate refuses direct-upstream dials to + // addresses that are not globally reachable (loopback, private, + // link-local, CGNAT, ...). Off by default: private and self-hosted + // proxies use direct_upstream to reach LAN and localhost services. + // Proxies that serve untrusted accounts must turn it on. + EnvDirectUpstreamBlockPrivate = "NB_PROXY_DIRECT_UPSTREAM_BLOCK_PRIVATE" ) // upstreamHTTPVersion selects the HTTP version the proxy uses towards an @@ -69,6 +75,9 @@ type transportConfig struct { // explicit values are for backends whose advertised h2 support is // unusable and whose failure mode the negotiation cannot see. upstreamHTTPVersion upstreamHTTPVersion + // blockPrivateUpstreams guards the direct branches' dialer with + // guardUpstreamDial. It has no effect on the embedded branch. + blockPrivateUpstreams bool } func defaultTransportConfig() transportConfig { @@ -122,6 +131,7 @@ func loadTransportConfig(logger *log.Logger) transportConfig { if v, ok := envUpstreamHTTPVersion(EnvUpstreamHTTPVersion, logger); ok { cfg.upstreamHTTPVersion = v } + cfg.blockPrivateUpstreams = envGuardBool(EnvDirectUpstreamBlockPrivate, logger) logger.WithFields(log.Fields{ "max_idle_conns": cfg.maxIdleConns, @@ -136,6 +146,7 @@ func loadTransportConfig(logger *log.Logger) transportConfig { "disable_compression": cfg.disableCompression, "max_inflight": cfg.maxInflight, "upstream_http_version": cfg.upstreamHTTPVersion, + "block_private_upstreams": cfg.blockPrivateUpstreams, }).Debug("backend transport configuration") return cfg @@ -246,6 +257,22 @@ func envDuration(key string, logger *log.Logger) (time.Duration, bool) { return v, true } +// envGuardBool reads a bool that turns a security guard on. Unset means +// off, but a value that does not parse turns the guard on: a typo must not +// leave a proxy that was meant to be guarded without the guard. +func envGuardBool(key string, logger *log.Logger) bool { + s := os.Getenv(key) + if s == "" { + return false + } + v, err := strconv.ParseBool(s) + if err != nil { + logger.Warnf("failed to parse %s=%q as bool, enabling it: %v", key, s, err) + return true + } + return v +} + func envBool(key string, logger *log.Logger) (bool, bool) { s := os.Getenv(key) if s == "" { From 0712a5a5b92e17392ddd809d1fe0c0188175f611 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Fri, 2 Oct 2026 15:55:51 +0300 Subject: [PATCH 089/209] [management,proxy] Rename the OIDC session code query parameter (#7981) --- management/server/http/handlers/proxy/auth.go | 4 ++-- .../proxy/auth_callback_integration_test.go | 8 ++++---- proxy/auth/auth.go | 8 ++++++++ proxy/internal/auth/middleware.go | 10 +++++----- proxy/internal/auth/middleware_test.go | 13 ++++++++++--- proxy/internal/auth/oidc.go | 6 +++--- proxy/internal/proxy/reverseproxy.go | 6 +++--- proxy/internal/proxy/reverseproxy_test.go | 11 +++++++++++ proxy/web/web.go | 6 ++++-- 9 files changed, 50 insertions(+), 22 deletions(-) diff --git a/management/server/http/handlers/proxy/auth.go b/management/server/http/handlers/proxy/auth.go index 298fb503e..133236401 100644 --- a/management/server/http/handlers/proxy/auth.go +++ b/management/server/http/handlers/proxy/auth.go @@ -125,9 +125,9 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ http.Error(w, "Failed to create session", http.StatusInternalServerError) return } - query.Set("session_code", code) + query.Set(auth.SessionCodeQueryParam, code) } else { - query.Set("session_token", sessionToken) + query.Set(auth.SessionTokenQueryParam, sessionToken) } redirectURL.RawQuery = query.Encode() diff --git a/management/server/http/handlers/proxy/auth_callback_integration_test.go b/management/server/http/handlers/proxy/auth_callback_integration_test.go index 964841a63..862d5d5f2 100644 --- a/management/server/http/handlers/proxy/auth_callback_integration_test.go +++ b/management/server/http/handlers/proxy/auth_callback_integration_test.go @@ -532,8 +532,8 @@ func TestAuthCallback_UserAllowedToLogin(t *testing.T) { wantParam string absentParam string }{ - {name: "legacy proxy", manager: testSessionCodeManager{}, wantParam: "session_token", absentParam: "session_code"}, - {name: "compatible proxy", manager: testSessionCodeManager{supported: true}, wantParam: "session_code", absentParam: "session_token"}, + {name: "legacy proxy", manager: testSessionCodeManager{}, wantParam: "session_token", absentParam: "nb_session_code"}, + {name: "compatible proxy", manager: testSessionCodeManager{supported: true}, wantParam: "nb_session_code", absentParam: "session_token"}, } for _, tt := range tests { @@ -555,8 +555,8 @@ func TestAuthCallback_UserAllowedToLogin(t *testing.T) { require.Empty(t, location.Query().Get(tt.absentParam)) require.Empty(t, location.Query().Get("error")) - if tt.wantParam == "session_code" { - code := location.Query().Get("session_code") + if tt.wantParam == "nb_session_code" { + code := location.Query().Get("nb_session_code") response, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ Domain: location.Hostname(), SessionCode: code, diff --git a/proxy/auth/auth.go b/proxy/auth/auth.go index 084046c49..605780959 100644 --- a/proxy/auth/auth.go +++ b/proxy/auth/auth.go @@ -30,6 +30,14 @@ const ( SessionJWTIssuer = "netbird-management" ) +// Query parameters management uses to hand the OIDC session to the proxy. The +// proxy strips them before forwarding, so they must not collide with names the +// proxied service uses itself. +const ( + SessionCodeQueryParam = "nb_session_code" + SessionTokenQueryParam = "session_token" +) + // HeaderUserID is the synthetic user id recorded for header-authenticated // requests. Header auth validates a per-service secret and resolves no user // record, so proxy access logs and management-minted session tokens both diff --git a/proxy/internal/auth/middleware.go b/proxy/internal/auth/middleware.go index 672286748..647741139 100644 --- a/proxy/internal/auth/middleware.go +++ b/proxy/internal/auth/middleware.go @@ -583,7 +583,7 @@ func (mw *Middleware) authenticateWithSchemes(w http.ResponseWriter, r *http.Req // handleAuthenticatedToken validates the token, handles denied access, and on // success sets a session cookie and redirects to the original URL. func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Request, host, token string, config DomainConfig, scheme Scheme) { - isCode := scheme.Type() == auth.MethodOIDC && r.URL.Query().Get("session_code") != "" + isCode := scheme.Type() == auth.MethodOIDC && r.URL.Query().Get(auth.SessionCodeQueryParam) != "" result, err := mw.validateSessionToken(r.Context(), host, token, isCode, config.SessionPublicKey, scheme.Type()) if err != nil { if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { @@ -661,7 +661,7 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool { case auth.MethodPassword: return credentialFormValue(r, passwordFormId) != "" case auth.MethodOIDC: - return r.URL.Query().Get("session_token") != "" || r.URL.Query().Get("session_code") != "" + return r.URL.Query().Get(auth.SessionTokenQueryParam) != "" || r.URL.Query().Get(auth.SessionCodeQueryParam) != "" } return false } @@ -806,11 +806,11 @@ func sessionGroupsAllowed(allowed map[string]struct{}, method auth.Method, group // or history. func stripSessionTokenParam(u *url.URL) string { q := u.Query() - if !q.Has("session_token") && !q.Has("session_code") { + if !q.Has(auth.SessionTokenQueryParam) && !q.Has(auth.SessionCodeQueryParam) { return u.RequestURI() } - q.Del("session_token") - q.Del("session_code") + q.Del(auth.SessionTokenQueryParam) + q.Del(auth.SessionCodeQueryParam) clean := *u clean.RawQuery = q.Encode() return clean.RequestURI() diff --git a/proxy/internal/auth/middleware_test.go b/proxy/internal/auth/middleware_test.go index 88c900f97..cce35ae35 100644 --- a/proxy/internal/auth/middleware_test.go +++ b/proxy/internal/auth/middleware_test.go @@ -786,9 +786,15 @@ func TestWasCredentialSubmitted(t *testing.T) { { name: "OIDC code in query", method: auth.MethodOIDC, - query: url.Values{"session_code": {"abc123"}}, + query: url.Values{"nb_session_code": {"abc123"}}, expected: true, }, + { + name: "OIDC backend session_code in query", + method: auth.MethodOIDC, + query: url.Values{"session_code": {"abc123"}}, + expected: false, + }, { name: "OIDC token not in query", method: auth.MethodOIDC, @@ -1585,8 +1591,9 @@ func TestStripSessionTokenParam(t *testing.T) { want string }{ {"strips session_token", "https://ex.com/p?a=1&session_token=tok", "/p?a=1"}, - {"strips session_code", "https://ex.com/p?a=1&session_code=code", "/p?a=1"}, - {"strips both", "https://ex.com/p?session_token=tok&session_code=code&a=1", "/p?a=1"}, + {"strips nb_session_code", "https://ex.com/p?a=1&nb_session_code=code", "/p?a=1"}, + {"strips both", "https://ex.com/p?session_token=tok&nb_session_code=code&a=1", "/p?a=1"}, + {"keeps backend session_code", "https://ex.com/p?a=1&session_code=backend", "/p?a=1&session_code=backend"}, {"no-op when absent", "https://ex.com/p?a=1", "/p?a=1"}, } for _, tc := range cases { diff --git a/proxy/internal/auth/oidc.go b/proxy/internal/auth/oidc.go index 739777924..0215fddc3 100644 --- a/proxy/internal/auth/oidc.go +++ b/proxy/internal/auth/oidc.go @@ -43,12 +43,12 @@ func (o OIDC) Authenticate(r *http.Request) (string, string, error) { // Check for the session credential returned by the OIDC callback. The management // server passes it in the URL because it cannot set a cookie for the proxy's // domain (cookies are domain-scoped per RFC 6265). The current flow uses a - // single-use session_code to keep the durable token out of the URL. + // single-use session code to keep the durable token out of the URL. // session_token remains supported for backward compatibility. - if code := r.URL.Query().Get("session_code"); code != "" { + if code := r.URL.Query().Get(auth.SessionCodeQueryParam); code != "" { return code, "", nil } - if token := r.URL.Query().Get("session_token"); token != "" { + if token := r.URL.Query().Get(auth.SessionTokenQueryParam); token != "" { return token, "", nil } diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index 7583b2e01..a3987fe5a 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -725,9 +725,9 @@ func stripSessionCookie(r *httputil.ProxyRequest) { // from the outgoing URL to prevent credential leakage to backends. func stripSessionTokenQuery(r *httputil.ProxyRequest) { q := r.Out.URL.Query() - if q.Has("session_token") || q.Has("session_code") { - q.Del("session_token") - q.Del("session_code") + if q.Has(auth.SessionTokenQueryParam) || q.Has(auth.SessionCodeQueryParam) { + q.Del(auth.SessionTokenQueryParam) + q.Del(auth.SessionCodeQueryParam) r.Out.URL.RawQuery = q.Encode() } } diff --git a/proxy/internal/proxy/reverseproxy_test.go b/proxy/internal/proxy/reverseproxy_test.go index c0724ce84..b26ca1f9f 100644 --- a/proxy/internal/proxy/reverseproxy_test.go +++ b/proxy/internal/proxy/reverseproxy_test.go @@ -236,6 +236,17 @@ func TestRewriteFunc_SessionTokenQueryStripping(t *testing.T) { "other query parameters must be preserved") }) + t.Run("strips nb_session_code query parameter", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/callback?nb_session_code=code123&other=keep", "1.2.3.4:5000") + + rewrite(pr) + + assert.Empty(t, pr.Out.URL.Query().Get("nb_session_code"), + "OIDC session code must be stripped from backend request") + assert.Equal(t, "keep", pr.Out.URL.Query().Get("other"), + "other query parameters must be preserved") + }) + t.Run("preserves query when no session_token present", func(t *testing.T) { pr := newProxyRequest(t, "http://example.com/api?foo=bar&baz=qux", "1.2.3.4:5000") diff --git a/proxy/web/web.go b/proxy/web/web.go index a45fc8730..de3e4771a 100644 --- a/proxy/web/web.go +++ b/proxy/web/web.go @@ -10,6 +10,8 @@ import ( "net/url" "path/filepath" "strings" + + "github.com/netbirdio/netbird/proxy/auth" ) // PathPrefix is the unique URL prefix for serving the proxy's own web assets. @@ -180,8 +182,8 @@ func ServeAccessDeniedPage(w http.ResponseWriter, r *http.Request, code int, tit // stripAuthParams returns the request URI with auth-related query parameters removed. func stripAuthParams(u *url.URL) string { q := u.Query() - q.Del("session_token") - q.Del("session_code") + q.Del(auth.SessionTokenQueryParam) + q.Del(auth.SessionCodeQueryParam) q.Del("error") q.Del("error_description") clean := *u From e2678d4e05e8eda40a74915a4d376b0c4ea49a67 Mon Sep 17 00:00:00 2001 From: Allan ELKAIM Date: Fri, 2 Oct 2026 15:00:26 +0200 Subject: [PATCH 090/209] [management] expose peer MAC addresses and make peers searchable by MAC (#6553) --- .../network_map/controller/repository.go | 2 +- management/internals/modules/peers/manager.go | 2 +- management/server/account.go | 10 +-- management/server/account/manager.go | 2 +- management/server/account/manager_mock.go | 8 +- management/server/account_test.go | 18 ++--- .../handlers/accounts/accounts_handler.go | 2 +- .../http/handlers/groups/groups_handler.go | 10 +-- .../handlers/groups/groups_handler_test.go | 2 +- .../http/handlers/peers/peers_handler.go | 16 +++- .../http/handlers/peers/peers_handler_test.go | 46 ++++++++++- management/server/integrated_validator.go | 2 +- management/server/mock_server/account_mock.go | 6 +- management/server/peer.go | 4 +- management/server/peer_test.go | 76 ++++++++++++++++++- management/server/store/sql_store_peer.go | 7 +- .../server/store/sql_store_peer_test.go | 46 ++++++++++- management/server/store/store.go | 2 +- management/server/store/store_mock.go | 8 +- shared/management/http/api/openapi.yml | 24 ++++++ shared/management/http/api/types.gen.go | 18 +++++ 21 files changed, 265 insertions(+), 46 deletions(-) diff --git a/management/internals/controllers/network_map/controller/repository.go b/management/internals/controllers/network_map/controller/repository.go index 5c3195f16..c11af0b69 100644 --- a/management/internals/controllers/network_map/controller/repository.go +++ b/management/internals/controllers/network_map/controller/repository.go @@ -44,7 +44,7 @@ func (r *repository) GetAccountNetwork(ctx context.Context, accountID string) (* } func (r *repository) GetAccountPeers(ctx context.Context, accountID string) ([]*peer.Peer, error) { - return r.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + return r.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") } func (r *repository) GetAccountByPeerID(ctx context.Context, peerID string) (*types.Account, error) { diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index 3274ec524..e944be291 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -97,7 +97,7 @@ func (m *managerImpl) GetAllPeers(ctx context.Context, accountID, userID string) return m.store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID) } - return m.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + return m.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") } func (m *managerImpl) GetPeerAccountID(ctx context.Context, peerID string) (string, error) { diff --git a/management/server/account.go b/management/server/account.go index 340bcc84b..1c09c8252 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -2391,7 +2391,7 @@ func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, t return err } - peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "") + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "", "") if err != nil { return err } @@ -2428,7 +2428,7 @@ func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, t // v6 address get one allocated. When disabled, all v6 addresses are cleared. // When the v6 range changes, all v6 addresses are reallocated. func (am *DefaultAccountManager) checkIPv6Collision(ctx context.Context, transaction store.Store, accountID, peerID string, newIPv6 netip.Addr) error { - peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "") + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "", "") if err != nil { return fmt.Errorf("get peers: %w", err) } @@ -2441,7 +2441,7 @@ func (am *DefaultAccountManager) checkIPv6Collision(ctx context.Context, transac } func (am *DefaultAccountManager) updatePeerIPv6Addresses(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings) error { - peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "") + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "", "") if err != nil { return fmt.Errorf("get peers: %w", err) } @@ -2602,7 +2602,7 @@ func (am *DefaultAccountManager) buildIPv6AllowedPeers(ctx context.Context, tran // Embedded proxy peers sit outside regular group membership but must // participate in any v6-enabled overlay to reach v6-only peers. - peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") if err != nil { return nil, fmt.Errorf("get peers: %w", err) } @@ -2673,7 +2673,7 @@ func (am *DefaultAccountManager) updatePeerIPInTransaction(ctx context.Context, return nil } - peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "") + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "", "") if err != nil { return fmt.Errorf("get account peers: %w", err) } diff --git a/management/server/account/manager.go b/management/server/account/manager.go index 154c9ab18..2ac8584f4 100644 --- a/management/server/account/manager.go +++ b/management/server/account/manager.go @@ -62,7 +62,7 @@ type Manager interface { GetUserByID(ctx context.Context, id string) (*types.User, error) GetUserFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) ListUsers(ctx context.Context, accountID string) ([]*types.User, error) - GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) + GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) MarkPeerConnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error MarkPeerDisconnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error DeletePeer(ctx context.Context, accountID, peerID, userID string) error diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go index f31f63d0e..60075b169 100644 --- a/management/server/account/manager_mock.go +++ b/management/server/account/manager_mock.go @@ -982,18 +982,18 @@ func (mr *MockManagerMockRecorder) GetPeerNetwork(ctx, peerID any) *gomock.Call } // GetPeers mocks base method. -func (m *MockManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*peer.Peer, error) { +func (m *MockManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*peer.Peer, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPeers", ctx, accountID, userID, nameFilter, ipFilter) + ret := m.ctrl.Call(m, "GetPeers", ctx, accountID, userID, nameFilter, ipFilter, macFilter) ret0, _ := ret[0].([]*peer.Peer) ret1, _ := ret[1].(error) return ret0, ret1 } // GetPeers indicates an expected call of GetPeers. -func (mr *MockManagerMockRecorder) GetPeers(ctx, accountID, userID, nameFilter, ipFilter any) *gomock.Call { +func (mr *MockManagerMockRecorder) GetPeers(ctx, accountID, userID, nameFilter, ipFilter, macFilter any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeers", reflect.TypeOf((*MockManager)(nil).GetPeers), ctx, accountID, userID, nameFilter, ipFilter) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeers", reflect.TypeOf((*MockManager)(nil).GetPeers), ctx, accountID, userID, nameFilter, ipFilter, macFilter) } // GetPolicy mocks base method. diff --git a/management/server/account_test.go b/management/server/account_test.go index 8c735b28e..881ad19d7 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -2557,7 +2557,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerApproval(t *testing.T) _, err = manager.UpdateAccountSettings(ctx, accountID, userID, newSettings) require.NoError(t, err) - accountPeers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + accountPeers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) for _, peer := range accountPeers { @@ -4557,7 +4557,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te }) require.NoError(t, err) - peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "", "") require.NoError(t, err) require.Len(t, peers, len(before)) for _, p := range peers { @@ -4575,7 +4575,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te }) require.NoError(t, err) - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "", "") require.NoError(t, err) for _, p := range peers { assert.Equal(t, before[p.ID], p.IP, "peer %s IP should not change for host-bit-set equivalent range", p.ID) @@ -4589,7 +4589,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te }) require.NoError(t, err) - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "", "") require.NoError(t, err) for _, p := range peers { assert.Equal(t, before[p.ID], p.IP, "peer %s IP should not change when NetworkRange omitted", p.ID) @@ -4605,7 +4605,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *te }) require.NoError(t, err) - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "", "") require.NoError(t, err) for _, p := range peers { assert.True(t, newRange.Contains(p.IP), "peer %s should be in new range %s, got %s", p.ID, newRange, p.IP) @@ -4623,7 +4623,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testin require.NoError(t, err) require.NotEmpty(t, settings.IPv6EnabledGroups, "new account should have IPv6 enabled for All group") - peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) for _, p := range peers { assert.True(t, p.IPv6.IsValid(), "peer %s should have IPv6 with All group enabled", p.ID) @@ -4651,7 +4651,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testin assert.Equal(t, []string{partialGroup.ID}, updatedSettings.IPv6EnabledGroups) // peer1 and peer2 should have IPv6; peer3 should not. - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) peerMap := make(map[string]*nbpeer.Peer, len(peers)) for _, p := range peers { @@ -4671,7 +4671,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testin require.NoError(t, err) assert.Empty(t, updatedSettings.IPv6EnabledGroups) - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) for _, p := range peers { assert.False(t, p.IPv6.IsValid(), "peer %s should have no IPv6 when groups cleared", p.ID) @@ -4686,7 +4686,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testin }) require.NoError(t, err) - peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) peerMap = make(map[string]*nbpeer.Peer, len(peers)) for _, p := range peers { diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index c4cba5962..795214c31 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -127,7 +127,7 @@ func (h *handler) validateNetworkRange(ctx context.Context, accountID, userID st } func (h *handler) validateCapacity(ctx context.Context, accountID, userID string, prefix netip.Prefix) error { - peers, err := h.accountManager.GetPeers(ctx, accountID, userID, "", "") + peers, err := h.accountManager.GetPeers(ctx, accountID, userID, "", "", "") if err != nil { return status.Errorf(status.Internal, "get peer count: %v", err) } diff --git a/management/server/http/handlers/groups/groups_handler.go b/management/server/http/handlers/groups/groups_handler.go index ed01e7c3d..1a7753a57 100644 --- a/management/server/http/handlers/groups/groups_handler.go +++ b/management/server/http/handlers/groups/groups_handler.go @@ -58,7 +58,7 @@ func (h *handler) getAllGroups(w http.ResponseWriter, r *http.Request) { return } - accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "") if err != nil { util.WriteError(r.Context(), err, w) return @@ -77,7 +77,7 @@ func (h *handler) getAllGroups(w http.ResponseWriter, r *http.Request) { return } - accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "") if err != nil { util.WriteError(r.Context(), err, w) return @@ -169,7 +169,7 @@ func (h *handler) updateGroup(w http.ResponseWriter, r *http.Request) { return } - accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "") if err != nil { util.WriteError(r.Context(), err, w) return @@ -226,7 +226,7 @@ func (h *handler) createGroup(w http.ResponseWriter, r *http.Request) { return } - accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "") if err != nil { util.WriteError(r.Context(), err, w) return @@ -287,7 +287,7 @@ func (h *handler) getGroup(w http.ResponseWriter, r *http.Request) { return } - accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "", "") if err != nil { util.WriteError(r.Context(), err, w) return diff --git a/management/server/http/handlers/groups/groups_handler_test.go b/management/server/http/handlers/groups/groups_handler_test.go index 78e4a2578..3e322db4e 100644 --- a/management/server/http/handlers/groups/groups_handler_test.go +++ b/management/server/http/handlers/groups/groups_handler_test.go @@ -78,7 +78,7 @@ func initGroupTestData(initGroups ...*types.Group) *handler { return nil, status.Errorf(status.NotFound, "unknown group name") }, - GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { + GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { return maps.Values(TestPeers), nil }, DeleteGroupFunc: func(_ context.Context, accountID, userId, groupID string) error { diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index 773b640e0..8a9bf1f70 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -317,10 +317,11 @@ func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) { nameFilter := r.URL.Query().Get("name") ipFilter := r.URL.Query().Get("ip") + macFilter := r.URL.Query().Get("mac") accountID, userID := userAuth.AccountId, userAuth.UserId - peers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, nameFilter, ipFilter) + peers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, nameFilter, ipFilter, macFilter) if err != nil { util.WriteError(r.Context(), err, w) return @@ -571,6 +572,17 @@ func peerToAccessiblePeer(peer *nbpeer.Peer, dnsDomain string) api.AccessiblePee } } +func toNetworkAddresses(addrs []nbpeer.NetworkAddress) *[]api.NetworkAddress { + if len(addrs) == 0 { + return nil + } + out := make([]api.NetworkAddress, 0, len(addrs)) + for _, a := range addrs { + out = append(out, api.NetworkAddress{NetIp: a.NetIP.String(), Mac: a.Mac}) + } + return &out +} + func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsDomain string, approved bool, reason string) *api.Peer { osVersion := peer.Meta.OSVersion if osVersion == "" { @@ -583,6 +595,7 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsD Name: peer.Name, Ip: peer.IP.String(), Ipv6: peerIPv6String(peer), + NetworkAddresses: toNetworkAddresses(peer.Meta.NetworkAddresses), ConnectionIp: peer.Location.ConnectionIP.String(), Connected: peer.Status.Connected, LastSeen: peer.Status.LastSeen, @@ -639,6 +652,7 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn Name: peer.Name, Ip: peer.IP.String(), Ipv6: peerIPv6String(peer), + NetworkAddresses: toNetworkAddresses(peer.Meta.NetworkAddresses), ConnectionIp: peer.Location.ConnectionIP.String(), Connected: peer.Status.Connected, LastSeen: peer.Status.LastSeen, diff --git a/management/server/http/handlers/peers/peers_handler_test.go b/management/server/http/handlers/peers/peers_handler_test.go index 592d64d1a..7054082cc 100644 --- a/management/server/http/handlers/peers/peers_handler_test.go +++ b/management/server/http/handlers/peers/peers_handler_test.go @@ -173,7 +173,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { return nil, fmt.Errorf("user not found") } }, - GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { + GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { return peers, nil }, GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) { @@ -364,6 +364,50 @@ func TestGetPeers(t *testing.T) { } } +func TestPeerResponseNetworkAddresses(t *testing.T) { + tests := []struct { + name string + addresses []nbpeer.NetworkAddress + wantJSON string + }{ + {name: "not reported"}, + {name: "empty", addresses: []nbpeer.NetworkAddress{}}, + { + name: "multiple interfaces", + addresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.0.11/24"), Mac: "00:93:37:bd:83:0f"}, + {NetIP: netip.MustParsePrefix("2001:db8::123/64"), Mac: "00:93:37:bd:83:10"}, + }, + wantJSON: `[{"net_ip":"192.168.0.11/24","mac":"00:93:37:bd:83:0f"},{"net_ip":"2001:db8::123/64","mac":"00:93:37:bd:83:10"}]`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + peer := &nbpeer.Peer{ + Status: &nbpeer.PeerStatus{}, + Meta: nbpeer.PeerSystemMeta{NetworkAddresses: tt.addresses}, + } + responses := map[string]any{ + "single peer": toSinglePeerResponse(peer, nil, "example.com", true, ""), + "peer list": toPeerListItemResponse(peer, nil, "example.com", 0), + } + for name, response := range responses { + t.Run(name, func(t *testing.T) { + body, err := json.Marshal(response) + require.NoError(t, err) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(body, &fields)) + if tt.wantJSON == "" { + assert.NotContains(t, fields, "network_addresses", "unreported interfaces should be omitted") + return + } + assert.JSONEq(t, tt.wantJSON, string(fields["network_addresses"]), "response should preserve interface addresses and MACs") + }) + } + }) + } +} + func TestGetAccessiblePeers(t *testing.T) { peer1 := &nbpeer.Peer{ ID: "peer1", diff --git a/management/server/integrated_validator.go b/management/server/integrated_validator.go index 9ec1f491e..5928a8ed2 100644 --- a/management/server/integrated_validator.go +++ b/management/server/integrated_validator.go @@ -100,7 +100,7 @@ func (am *DefaultAccountManager) GetValidatedPeers(ctx context.Context, accountI return nil, nil, err } - peers, err = am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + peers, err = am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") if err != nil { return nil, nil, err } diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index 2f871c3e2..3313bf99c 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -39,7 +39,7 @@ type MockAccountManager struct { GetAccountIDByUserIdFunc func(ctx context.Context, userAuth auth.UserAuth) (string, error) GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) ListUsersFunc func(ctx context.Context, accountID string) ([]*types.User, error) - GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) + GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) MarkPeerConnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error MarkPeerDisconnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) @@ -807,9 +807,9 @@ func (am *MockAccountManager) GetAccountIDFromUserAuth(ctx context.Context, user } // GetPeers mocks GetPeers of the AccountManager interface -func (am *MockAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { +func (am *MockAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { if am.GetPeersFunc != nil { - return am.GetPeersFunc(ctx, accountID, userID, nameFilter, ipFilter) + return am.GetPeersFunc(ctx, accountID, userID, nameFilter, ipFilter, macFilter) } return nil, status.Errorf(codes.Unimplemented, "method GetPeers is not implemented") } diff --git a/management/server/peer.go b/management/server/peer.go index 9f5572252..5d5863fa7 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -47,7 +47,7 @@ const ( // GetPeers returns peers visible to the user within an account. // Users with "peers:read" see all peers. Otherwise, users see only their own peers, or none if restricted by account settings. -func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { +func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) if err != nil { return nil, err @@ -59,7 +59,7 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID } if allowed { - return am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, nameFilter, ipFilter) + return am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, nameFilter, ipFilter, macFilter) } settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 22f2b9b6f..5c3e02af5 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -4,10 +4,14 @@ import ( "context" "crypto/sha256" b64 "encoding/base64" + "encoding/json" "fmt" "io" "net" + "net/http" + "net/http/httptest" "net/netip" + "net/url" "os" "runtime" "strconv" @@ -33,12 +37,15 @@ import ( "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/internals/shared/grpc" nbcache "github.com/netbirdio/netbird/management/server/cache" + nbcontext "github.com/netbirdio/netbird/management/server/context" + peershandler "github.com/netbirdio/netbird/management/server/http/handlers/peers" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/management/server/util" @@ -718,7 +725,7 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) { return } - peers, err := manager.GetPeers(context.Background(), accountID, someUser, "", "") + peers, err := manager.GetPeers(context.Background(), accountID, someUser, "", "", "") if err != nil { t.Fatal(err) return @@ -731,6 +738,71 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) { } } +func TestDefaultAccountManager_GetPeers_FilterByMac(t *testing.T) { + ctx := context.Background() + manager, _, err := createManager(t) + require.NoError(t, err) + account := newAccountWithId(ctx, "mac-account", "mac-admin", "", "", "", false) + account.Peers["matching"] = &nbpeer.Peer{ + ID: "matching", Key: "matching-key", Name: "laptop", DNSLabel: "laptop", + IP: netip.MustParseAddr("100.64.0.10"), Status: &nbpeer.PeerStatus{LastSeen: time.Now()}, + Meta: nbpeer.PeerSystemMeta{NetworkAddresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.0.11/24"), Mac: "00:93:37:bd:83:0f"}, + {NetIP: netip.MustParsePrefix("192.168.1.11/24"), Mac: "aa:bb:cc:dd:ee:ff"}, + }}, + } + account.Peers["other"] = &nbpeer.Peer{ + ID: "other", Key: "other-key", Name: "desktop", DNSLabel: "desktop", + IP: netip.MustParseAddr("100.64.0.20"), Status: &nbpeer.PeerStatus{LastSeen: time.Now()}, + } + require.NoError(t, manager.Store.SaveAccount(ctx, account)) + otherAccount := newAccountWithId(ctx, "other-account", "other-admin", "", "", "", false) + otherPeer := account.Peers["matching"].Copy() + otherPeer.ID, otherPeer.Key = "outside-account", "outside-key" + otherAccount.Peers[otherPeer.ID] = otherPeer + require.NoError(t, manager.Store.SaveAccount(ctx, otherAccount)) + handler := peershandler.NewHandler(manager, manager.networkMapController, manager.permissionsManager) + + tests := []struct { + name, nameFilter, ipFilter, macFilter string + wantIDs []string + }{ + {name: "no filter", wantIDs: []string{"matching", "other"}}, + {name: "full MAC", macFilter: "00:93:37:bd:83:0f", wantIDs: []string{"matching"}}, + {name: "partial MAC", macFilter: "93:37:bd", wantIDs: []string{"matching"}}, + {name: "second interface", macFilter: "aa:bb:cc:dd:ee:ff", wantIDs: []string{"matching"}}, + {name: "unknown MAC", macFilter: "11:22:33:44:55:66"}, + {name: "combined filters", nameFilter: "laptop", ipFilter: "100.64.0.10", macFilter: "00:93:37", wantIDs: []string{"matching"}}, + {name: "name mismatch", nameFilter: "desktop", macFilter: "00:93:37"}, + {name: "IP mismatch", ipFilter: "100.64.0.20", macFilter: "00:93:37"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + peers, err := manager.GetPeers(ctx, account.Id, "mac-admin", tt.nameFilter, tt.ipFilter, tt.macFilter) + require.NoError(t, err) + ids := make([]string, 0, len(peers)) + for _, peer := range peers { + ids = append(ids, peer.ID) + } + assert.ElementsMatch(t, tt.wantIDs, ids, "filters should return only matching peers in the account") + + query := url.Values{"name": {tt.nameFilter}, "ip": {tt.ipFilter}, "mac": {tt.macFilter}} + req := httptest.NewRequest(http.MethodGet, "/api/peers?"+query.Encode(), nil) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{AccountId: account.Id, UserId: "mac-admin"}) + recorder := httptest.NewRecorder() + handler.GetAllPeers(recorder, req) + require.Equal(t, http.StatusOK, recorder.Code, "peer listing should succeed: %s", recorder.Body.String()) + var response []api.PeerBatch + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + responseIDs := make([]string, 0, len(response)) + for _, peer := range response { + responseIDs = append(responseIDs, peer.Id) + } + assert.ElementsMatch(t, tt.wantIDs, responseIDs, "HTTP query filters should reach the store") + }) + } +} + func setupTestAccountManager(b testing.TB, peers int, groups int) (*DefaultAccountManager, *update_channel.PeersUpdateManager, string, string, error) { b.Helper() @@ -934,7 +1006,7 @@ func BenchmarkGetPeers(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - _, err := manager.GetPeers(context.Background(), accountID, userID, "", "") + _, err := manager.GetPeers(context.Background(), accountID, userID, "", "", "") if err != nil { b.Fatalf("GetPeers failed: %v", err) } diff --git a/management/server/store/sql_store_peer.go b/management/server/store/sql_store_peer.go index e5086b6db..1b0e23cec 100644 --- a/management/server/store/sql_store_peer.go +++ b/management/server/store/sql_store_peer.go @@ -492,7 +492,7 @@ func (s *SqlStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength Locking } // GetAccountPeers retrieves peers for an account. -func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { +func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { var peers []*nbpeer.Peer tx := s.db if lockStrength != LockingStrengthNone { @@ -506,6 +506,11 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre if ipFilter != "" { query = query.Where("ip LIKE ? OR ipv6 LIKE ?", "%"+ipFilter+"%", "%"+ipFilter+"%") } + // MAC addresses live in the JSON-serialized meta_network_addresses column, + // so we match the raw JSON text rather than a dedicated column. + if macFilter != "" { + query = query.Where("meta_network_addresses LIKE ?", "%"+macFilter+"%") + } if err := query.Find(&peers).Error; err != nil { log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err) diff --git a/management/server/store/sql_store_peer_test.go b/management/server/store/sql_store_peer_test.go index b49e04f2f..1432b5d96 100644 --- a/management/server/store/sql_store_peer_test.go +++ b/management/server/store/sql_store_peer_test.go @@ -512,7 +512,7 @@ func TestSqlStore_GetAccountPeers(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - peers, err := store.GetAccountPeers(context.Background(), LockingStrengthNone, tt.accountID, tt.nameFilter, tt.ipFilter) + peers, err := store.GetAccountPeers(context.Background(), LockingStrengthNone, tt.accountID, tt.nameFilter, tt.ipFilter, "") require.NoError(t, err) require.Len(t, peers, tt.expectedCount) }) @@ -520,6 +520,48 @@ func TestSqlStore_GetAccountPeers(t *testing.T) { } +func TestSqlStore_GetAccountPeers_FilterByMac(t *testing.T) { + ctx := context.Background() + store, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + accountID := "test-account-mac" + userID := "test-user-mac" + account := newAccountWithId(ctx, accountID, userID, "example.com") + account.Peers["peer-mac-1"] = &nbpeer.Peer{ + ID: "peer-mac-1", + AccountID: accountID, + Key: "peer-mac-key-1", + Name: "macpeer", + IP: netip.MustParseAddr("100.64.0.10"), + Meta: nbpeer.PeerSystemMeta{ + NetworkAddresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.0.11/24"), Mac: "00:93:37:bd:83:0f"}, + }, + }, + } + require.NoError(t, store.SaveAccount(ctx, account)) + + tests := []struct { + name string + macFilter string + expectedCount int + }{ + {name: "full mac matches", macFilter: "00:93:37:bd:83:0f", expectedCount: 1}, + {name: "mac prefix matches", macFilter: "00:93:37", expectedCount: 1}, + {name: "unknown mac does not match", macFilter: "11:22:33:44:55:66", expectedCount: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + peers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "", tt.macFilter) + require.NoError(t, err) + require.Len(t, peers, tt.expectedCount) + }) + } +} + func TestSqlStore_GetAccountPeersWithExpiration(t *testing.T) { store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) t.Cleanup(cleanup) @@ -878,7 +920,7 @@ func TestSqlStore_ApproveAccountPeers(t *testing.T) { require.NoError(t, err) assert.Equal(t, 2, count) - allPeers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "") + allPeers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "", "") require.NoError(t, err) for _, peer := range allPeers { diff --git a/management/server/store/store.go b/management/server/store/store.go index 465f84413..177c2a47c 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -160,7 +160,7 @@ type Store interface { RemoveResourceFromGroup(ctx context.Context, accountId string, groupID string, resourceID string) error AddPeerToAccount(ctx context.Context, peer *nbpeer.Peer) error GetPeerByPeerPubKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (*nbpeer.Peer, error) - GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) + GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*nbpeer.Peer, error) GetPeerByID(ctx context.Context, lockStrength LockingStrength, accountID string, peerID string) (*nbpeer.Peer, error) GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*nbpeer.Peer, error) diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index cd9e7334d..53e35b866 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -1330,18 +1330,18 @@ func (mr *MockStoreMockRecorder) GetAccountOwner(ctx, lockStrength, accountID an } // GetAccountPeers mocks base method. -func (m *MockStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter string) ([]*peer.Peer, error) { +func (m *MockStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*peer.Peer, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccountPeers", ctx, lockStrength, accountID, nameFilter, ipFilter) + ret := m.ctrl.Call(m, "GetAccountPeers", ctx, lockStrength, accountID, nameFilter, ipFilter, macFilter) ret0, _ := ret[0].([]*peer.Peer) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAccountPeers indicates an expected call of GetAccountPeers. -func (mr *MockStoreMockRecorder) GetAccountPeers(ctx, lockStrength, accountID, nameFilter, ipFilter any) *gomock.Call { +func (mr *MockStoreMockRecorder) GetAccountPeers(ctx, lockStrength, accountID, nameFilter, ipFilter, macFilter any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockStore)(nil).GetAccountPeers), ctx, lockStrength, accountID, nameFilter, ipFilter) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockStore)(nil).GetAccountPeers), ctx, lockStrength, accountID, nameFilter, ipFilter, macFilter) } // GetAccountPeersWithExpiration mocks base method. diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 3dd9f41f1..4b7077cac 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -826,6 +826,20 @@ components: - ssh_enabled - login_expiration_enabled - inactivity_expiration_enabled + NetworkAddress: + type: object + properties: + net_ip: + description: IP address with CIDR of the interface + type: string + example: 192.168.0.11/24 + mac: + description: MAC address of the interface + type: string + example: "00:93:37:bd:83:0f" + required: + - net_ip + - mac Peer: allOf: - $ref: '#/components/schemas/PeerMinimum' @@ -845,6 +859,11 @@ components: type: string format: ipv6 example: "fd00:4e42:ab12::1" + network_addresses: + description: Network interfaces (IP + MAC) reported by the peer + type: array + items: + $ref: '#/components/schemas/NetworkAddress' connection_ip: description: Peer's public connection IP address type: string @@ -7516,6 +7535,11 @@ paths: schema: type: string description: Filter peers by IP address + - in: query + name: mac + schema: + type: string + description: Filter peers by MAC address of a network interface security: - BearerAuth: [ ] - TokenAuth: [ ] diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 9a90a72d3..009a9a7a7 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -3829,6 +3829,15 @@ type Network struct { RoutingPeersCount int `json:"routing_peers_count"` } +// NetworkAddress defines model for NetworkAddress. +type NetworkAddress struct { + // Mac MAC address of the interface + Mac string `json:"mac"` + + // NetIp IP address with CIDR of the interface + NetIp string `json:"net_ip"` +} + // NetworkRequest defines model for NetworkRequest. type NetworkRequest struct { // Description Network description @@ -4278,6 +4287,9 @@ type Peer struct { // Name Peer's hostname Name string `json:"name"` + // NetworkAddresses Network interfaces (IP + MAC) reported by the peer + NetworkAddresses *[]NetworkAddress `json:"network_addresses,omitempty"` + // Os Peer's operating system and version Os string `json:"os"` @@ -4372,6 +4384,9 @@ type PeerBatch struct { // Name Peer's hostname Name string `json:"name"` + // NetworkAddresses Network interfaces (IP + MAC) reported by the peer + NetworkAddresses *[]NetworkAddress `json:"network_addresses,omitempty"` + // Os Peer's operating system and version Os string `json:"os"` @@ -6294,6 +6309,9 @@ type GetApiPeersParams struct { // Ip Filter peers by IP address Ip *string `form:"ip,omitempty" json:"ip,omitempty"` + + // Mac Filter peers by MAC address of a network interface + Mac *string `form:"mac,omitempty" json:"mac,omitempty"` } // GetApiPeersPeerIdIngressPortsParams defines parameters for GetApiPeersPeerIdIngressPorts. From 1b89880e30fa542ae6ff93fd1423e8a923b98e8d Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 2 Oct 2026 15:11:38 +0200 Subject: [PATCH 091/209] [misc] Move the FreeBSD port test to release 15.1 (#7999) * [misc] Move the FreeBSD port test to release 15.1 FreeBSD 15.0 reached end of life on 2026-09-30 and the ports tree marks it unsupported since freebsd/freebsd-ports@ed90b23fe9 (2026-10-01), so `make package` refuses to run on the 15.0 VM and the FreeBSD Port job fails on every PR. The pinned vmactions/freebsd-vm v1.4.8 ships a 15.1 image, so only the release needs to move. * [misc] Run the FreeBSD unit tests on release 15.1 too The job installs binary packages instead of building from the ports tree, so it kept passing on the EOL 15.0 image, but the client should be tested on the same supported release the port is built on, and the EOL image is only served from the archive mirror from now on. --- .github/workflows/golang-test-freebsd.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml index 65c39147a..7bd48e3d0 100644 --- a/.github/workflows/golang-test-freebsd.yml +++ b/.github/workflows/golang-test-freebsd.yml @@ -33,7 +33,7 @@ jobs: with: usesh: true copyback: false - release: "15.0" + release: "15.1" envs: "GO_VERSION" prepare: | pkg install -y curl pkgconf xorg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 673fcc281..9c9af5f17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,7 +69,7 @@ jobs: with: usesh: true copyback: false - release: "15.0" + release: "15.1" envs: "GO_VERSION" prepare: | # Install required packages From 9f8ddc71315bc40f5c98dca82cd902ec1a9f59dd Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:24:39 +0200 Subject: [PATCH 092/209] [client] Discover interfaces lazily in stdnet instead of at construction (#7346) * [client] Discover interfaces lazily in stdnet instead of at construction stdnet.NewNet and NewNetWithDiscover ended with return n, n.UpdateInterfaces() handing back a non-nil *Net together with the discovery error. Three of the five call sites (Engine.newWgIface, ice.NewAgent, SingleSocketUDPMux) logged the error and kept using the instance, which is only safe as long as the instance still works after a failed discovery. That stopped being true when Interfaces() gained a lazily refreshed cache: updateInterfaces sets lastUpdate only on success, so after a failed construction the 30s cache guard never holds and Interfaces() returns an error rather than the empty list it used to return. Feeding such an instance to pion is worse than passing nothing at all - ice.NewAgent falls back to its own stdnet when Net is nil, and the interface blacklist is applied separately through AgentConfig.InterfaceFilter, so the fallback loses nothing. Instead, a transient discovery failure (the Android bridge at boot, or an interface disappearing between net.Interfaces() and Interface.Addrs()) turned into a hard "error getting local interfaces" from ice.NewAgent, and aborted the STUN and TURN probes, which never even need the interface list. Since the accessors already refresh a stale cache on demand, the eager discovery in the constructors is redundant: drop it, make both constructors infallible, and let the discovery error surface at the call that actually needs the interfaces. UpdateInterfaces had no callers left and is not part of transport.Net, so it is removed along with it. InterfaceByIndex and InterfaceByName read the cached slice directly and never refreshed it, so they would have kept reporting ErrInterfaceNotFound forever on an instance whose first discovery failed. They now go through the same refresh path as Interfaces(). * [client] Warm the stdnet interface cache at construction Moving discovery to first use regressed the privileged suites on the three platforms that always build an ICE bind: Darwin, FreeBSD and Windows time out in TestWGIface_UpdateAddr, TestRecreation, TestEngine_SSH and TestEngine_MultiplePeers, while Linux stays green because a host with the WireGuard kernel module takes the kernel-device branch and never drives the mux that asks for interfaces. interfaceFilter probes with wgctrl every interface the disallow list does not already exclude. Discovering at construction ran that probe before the caller had an overlay interface of its own; discovering at first use runs it after, so on a userspace WireGuard platform the probe reaches the UAPI socket of the same process. The tests reach it because they construct with a nil disallow list, where the client passes DefaultInterfaceBlacklist and its own interface is excluded by prefix. Restore the original timing with an explicit warm-up. The constructors stay infallible and the error is still reported by the accessor that needs the interfaces, so the contract this branch is about is unchanged. * Revert "[client] Warm the stdnet interface cache at construction" This reverts commit 947e25288f78b1afb8b2cb5a0d21925ba694fe0b. * [client] Give the privileged tests the interface blacklist the client uses The suites that create a WireGuard interface construct stdnet with a nil disallow list, which the client never does: Engine passes profilemanager.DefaultInterfaceBlacklist, whose "wt" and "utun" prefixes exclude the overlay interface before the filter reaches its wgctrl probe. With an empty list every interface reaches that probe, the one the test has just created included, and on a userspace WireGuard platform the probe talks to the UAPI socket of the same process. That is why Darwin, FreeBSD and Windows timed out here while Linux, which takes the kernel-device branch on a host with the module loaded, stayed green. Pass the blacklist in both suites so they exercise the configuration the client ships. client/iface declares the prefixes locally because profilemanager imports it. Also cover the constructors directly: the existing tests build the struct literal, so nothing asserted that NewNet and NewNetWithDiscover leave the cache cold. * [client] Pass the blacklist in the remaining tests that build an interface Same reason as the previous commit, four call sites it missed: engine_test, the route manager and systemops suites, and the privileged DNS server suite all construct stdnet with a nil disallow list and then create a WireGuard interface. TestAddVPNRoute surfaced it on FreeBSD once the earlier two files stopped timing out first. client/internal/dns declares the prefixes locally; profilemanager imports that package, so it cannot import profilemanager back. --- client/iface/iface_test.go | 52 +++---- client/iface/udpmux/mux.go | 5 +- client/internal/dns/server_privileged_test.go | 17 +-- client/internal/dns/server_test.go | 6 +- client/internal/engine.go | 5 +- client/internal/engine_privileged_test.go | 26 ++-- client/internal/engine_stdnet.go | 2 +- client/internal/engine_stdnet_android.go | 2 +- client/internal/engine_test.go | 11 +- client/internal/peer/ice/agent.go | 5 +- client/internal/peer/ice/stdnet.go | 2 +- client/internal/peer/ice/stdnet_android.go | 2 +- client/internal/relay/relay.go | 12 +- client/internal/routemanager/manager_test.go | 6 +- .../systemops/systemops_generic_test.go | 4 +- client/internal/stdnet/stdnet.go | 85 ++++++----- client/internal/stdnet/stdnet_test.go | 136 ++++++++++++++++++ 17 files changed, 237 insertions(+), 141 deletions(-) create mode 100644 client/internal/stdnet/stdnet_test.go diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go index fff0d4e30..cb50ca4a1 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -40,14 +40,18 @@ func init() { peerPubKey = peerPrivateKey.PublicKey().String() } +// testIFaceBlackList mirrors the prefixes profilemanager.DefaultInterfaceBlacklist +// carries for the overlay interface. These tests create their own utun device, and +// stdnet's filter probes with wgctrl every interface it is not told to skip, which +// on a userspace WireGuard platform reaches the UAPI socket of this same process. +// Declared here rather than imported because profilemanager imports this package. +var testIFaceBlackList = []string{"wt", "utun", "tun0"} + func TestWGIface_UpdateAddr(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+4) addr := "100.64.0.1/8" wgPort := 33100 - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) opts := WGIFaceOpts{ IFaceName: ifaceName, @@ -127,10 +131,7 @@ func getIfaceAddrs(ifaceName string) ([]net.Addr, error) { func Test_CreateInterface(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+1) wgIP := "10.99.99.1/32" - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) opts := WGIFaceOpts{ IFaceName: ifaceName, Address: wgaddr.MustParseWGAddress(wgIP), @@ -170,10 +171,7 @@ func Test_Close(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+2) wgIP := "10.99.99.2/32" wgPort := 33100 - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) opts := WGIFaceOpts{ IFaceName: ifaceName, @@ -215,10 +213,7 @@ func TestRecreation(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+2) wgIP := "10.99.99.2/32" wgPort := 33100 - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) opts := WGIFaceOpts{ IFaceName: ifaceName, @@ -288,10 +283,7 @@ func Test_ConfigureInterface(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+3) wgIP := "10.99.99.5/30" wgPort := 33100 - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) opts := WGIFaceOpts{ IFaceName: ifaceName, Address: wgaddr.MustParseWGAddress(wgIP), @@ -343,10 +335,7 @@ func Test_ConfigureInterface(t *testing.T) { func Test_UpdatePeer(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+4) wgIP := "10.99.99.9/30" - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) opts := WGIFaceOpts{ IFaceName: ifaceName, @@ -413,10 +402,7 @@ func Test_UpdatePeer(t *testing.T) { func Test_RemovePeer(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+4) wgIP := "10.99.99.13/30" - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) opts := WGIFaceOpts{ IFaceName: ifaceName, @@ -477,10 +463,7 @@ func Test_ConnectPeers(t *testing.T) { peer2wgPort := 33200 keepAlive := 1 * time.Second - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) guid := fmt.Sprintf("{%s}", uuid.New().String()) device.CustomWindowsGUIDString = strings.ToLower(guid) @@ -516,10 +499,7 @@ func Test_ConnectPeers(t *testing.T) { guid = fmt.Sprintf("{%s}", uuid.New().String()) device.CustomWindowsGUIDString = strings.ToLower(guid) - newNet, err = stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet = stdnet.NewNet(context.Background(), testIFaceBlackList) optsPeer2 := WGIFaceOpts{ IFaceName: peer2ifaceName, diff --git a/client/iface/udpmux/mux.go b/client/iface/udpmux/mux.go index c5d2de4a5..68cecc953 100644 --- a/client/iface/udpmux/mux.go +++ b/client/iface/udpmux/mux.go @@ -200,10 +200,7 @@ func (m *SingleSocketUDPMux) updateLocalAddresses() { } if len(networks) > 0 { if m.params.Net == nil { - var err error - if m.params.Net, err = stdnet.NewNet(context.Background(), nil); err != nil { - m.params.Logger.Errorf("failed to get create network: %v", err) - } + m.params.Net = stdnet.NewNet(context.Background(), nil) } ips, err := localInterfaces(m.params.Net, m.params.InterfaceFilter, nil, networks, true) diff --git a/client/internal/dns/server_privileged_test.go b/client/internal/dns/server_privileged_test.go index a17044cf5..270e3bf91 100644 --- a/client/internal/dns/server_privileged_test.go +++ b/client/internal/dns/server_privileged_test.go @@ -9,9 +9,9 @@ import ( "os" "testing" - "go.uber.org/mock/gomock" "github.com/miekg/dns" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/iface" @@ -24,6 +24,10 @@ import ( nbdns "github.com/netbirdio/netbird/dns" ) +// testIFaceBlackList mirrors the overlay prefixes profilemanager.DefaultInterfaceBlacklist +// carries. Declared here rather than imported because profilemanager imports this package. +var testIFaceBlackList = []string{"wt", "utun", "tun0"} + func TestUpdateDNSServer(t *testing.T) { nameServers := []nbdns.NameServer{ @@ -243,10 +247,7 @@ func TestUpdateDNSServer(t *testing.T) { for n, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { privKey, _ := wgtypes.GenerateKey() - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), testIFaceBlackList) opts := iface.WGIFaceOpts{ IFaceName: fmt.Sprintf("utun230%d", n), @@ -348,11 +349,7 @@ func TestDNSFakeResolverHandleUpdates(t *testing.T) { defer t.Setenv("NB_WG_KERNEL_DISABLED", ov) t.Setenv("NB_WG_KERNEL_DISABLED", "true") - newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"}) - if err != nil { - t.Errorf("create stdnet: %v", err) - return - } + newNet := stdnet.NewNet(context.Background(), []string{"utun2301"}) privKey, _ := wgtypes.GeneratePrivateKey() opts := iface.WGIFaceOpts{ diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go index 0144a4a8b..414890158 100644 --- a/client/internal/dns/server_test.go +++ b/client/internal/dns/server_test.go @@ -394,11 +394,7 @@ func createWgInterfaceWithBind(t *testing.T) (*iface.WGIface, error) { defer t.Setenv("NB_WG_KERNEL_DISABLED", ov) t.Setenv("NB_WG_KERNEL_DISABLED", "true") - newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"}) - if err != nil { - t.Fatalf("create stdnet: %v", err) - return nil, err - } + newNet := stdnet.NewNet(context.Background(), []string{"utun2301"}) privKey, _ := wgtypes.GeneratePrivateKey() diff --git a/client/internal/engine.go b/client/internal/engine.go index fc7dce869..7e9375771 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -2178,10 +2178,7 @@ func (e *Engine) close() { } func (e *Engine) newWgIface() (*iface.WGIface, error) { - transportNet, err := e.newStdNet() - if err != nil { - log.Errorf("failed to create pion's stdnet: %s", err) - } + transportNet := e.newStdNet() opts := iface.WGIFaceOpts{ IFaceName: e.config.WgIfaceName, diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go index 1b047e017..2db0cd5ed 100644 --- a/client/internal/engine_privileged_test.go +++ b/client/internal/engine_privileged_test.go @@ -12,12 +12,12 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/google/uuid" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" + "go.uber.org/mock/gomock" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc" "google.golang.org/grpc/keepalive" @@ -27,6 +27,7 @@ import ( "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/dns" "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/profilemanager" nbssh "github.com/netbirdio/netbird/client/ssh" "github.com/netbirdio/netbird/client/system" nbdns "github.com/netbirdio/netbird/dns" @@ -81,6 +82,7 @@ func TestEngine_SSH(t *testing.T) { WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), WgPrivateKey: key, WgPort: 33100, + IFaceBlackList: profilemanager.DefaultInterfaceBlacklist, ServerSSHAllowed: true, MTU: iface.DefaultMTU, SSHKey: sshKey, @@ -204,11 +206,12 @@ func TestEngine_Sync(t *testing.T) { } relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) engine := NewEngine(ctx, cancel, &EngineConfig{ - WgIfaceName: "utun103", - WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), - WgPrivateKey: key, - WgPort: 33100, - MTU: iface.DefaultMTU, + WgIfaceName: "utun103", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), + WgPrivateKey: key, + WgPort: 33100, + IFaceBlackList: profilemanager.DefaultInterfaceBlacklist, + MTU: iface.DefaultMTU, }, EngineServices{ SignalClient: &signal.MockClient{}, MgmClient: &mgmt.MockClient{SyncFunc: syncFunc}, @@ -412,11 +415,12 @@ func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey strin wgPort := 33100 + i conf := &EngineConfig{ - WgIfaceName: ifaceName, - WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address), - WgPrivateKey: key, - WgPort: wgPort, - MTU: iface.DefaultMTU, + WgIfaceName: ifaceName, + WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address), + WgPrivateKey: key, + WgPort: wgPort, + IFaceBlackList: profilemanager.DefaultInterfaceBlacklist, + MTU: iface.DefaultMTU, } relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) diff --git a/client/internal/engine_stdnet.go b/client/internal/engine_stdnet.go index 1ebb5779c..86f6d297a 100644 --- a/client/internal/engine_stdnet.go +++ b/client/internal/engine_stdnet.go @@ -6,6 +6,6 @@ import ( "github.com/netbirdio/netbird/client/internal/stdnet" ) -func (e *Engine) newStdNet() (*stdnet.Net, error) { +func (e *Engine) newStdNet() *stdnet.Net { return stdnet.NewNet(e.clientCtx, e.config.IFaceBlackList) } diff --git a/client/internal/engine_stdnet_android.go b/client/internal/engine_stdnet_android.go index de3c80bcf..b14deeadf 100644 --- a/client/internal/engine_stdnet_android.go +++ b/client/internal/engine_stdnet_android.go @@ -2,6 +2,6 @@ package internal import "github.com/netbirdio/netbird/client/internal/stdnet" -func (e *Engine) newStdNet() (*stdnet.Net, error) { +func (e *Engine) newStdNet() *stdnet.Net { return stdnet.NewNetWithDiscover(e.clientCtx, e.mobileDep.IFaceDiscover, e.config.IFaceBlackList) } diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 2a7ecd652..3856cae22 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -161,7 +161,6 @@ func (m *MockWGIface) GetProxy() wgproxy.Proxy { return m.GetProxyFunc() } - func (m *MockWGIface) GetNet() *netstack.Net { return m.GetNetFunc() } @@ -689,10 +688,7 @@ func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) { StatusRecorder: peer.NewRecorder("https://mgm"), }, MobileDependency{}) engine.ctx = ctx - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), profilemanager.DefaultInterfaceBlacklist) opts := iface.WGIFaceOpts{ IFaceName: wgIfaceName, @@ -897,10 +893,7 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) { }, MobileDependency{}) engine.ctx = ctx - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), profilemanager.DefaultInterfaceBlacklist) opts := iface.WGIFaceOpts{ IFaceName: wgIfaceName, Address: wgaddr.MustParseWGAddress(wgAddr), diff --git a/client/internal/peer/ice/agent.go b/client/internal/peer/ice/agent.go index c74b46d10..6cd8c48de 100644 --- a/client/internal/peer/ice/agent.go +++ b/client/internal/peer/ice/agent.go @@ -39,10 +39,7 @@ func NewAgent(ctx context.Context, iFaceDiscover stdnet.ExternalIFaceDiscover, c iceFailedTimeout := iceFailedTimeout() iceRelayAcceptanceMinWait := iceRelayAcceptanceMinWait() - transportNet, err := newStdNet(ctx, iFaceDiscover, config.InterfaceBlackList) - if err != nil { - log.Errorf("failed to create pion's stdnet: %s", err) - } + transportNet := newStdNet(ctx, iFaceDiscover, config.InterfaceBlackList) fac := logging.NewDefaultLoggerFactory() diff --git a/client/internal/peer/ice/stdnet.go b/client/internal/peer/ice/stdnet.go index 685ed0363..0c819ff66 100644 --- a/client/internal/peer/ice/stdnet.go +++ b/client/internal/peer/ice/stdnet.go @@ -8,6 +8,6 @@ import ( "github.com/netbirdio/netbird/client/internal/stdnet" ) -func newStdNet(ctx context.Context, _ stdnet.ExternalIFaceDiscover, ifaceBlacklist []string) (*stdnet.Net, error) { +func newStdNet(ctx context.Context, _ stdnet.ExternalIFaceDiscover, ifaceBlacklist []string) *stdnet.Net { return stdnet.NewNet(ctx, ifaceBlacklist) } diff --git a/client/internal/peer/ice/stdnet_android.go b/client/internal/peer/ice/stdnet_android.go index 5033ec1b9..2962ecf66 100644 --- a/client/internal/peer/ice/stdnet_android.go +++ b/client/internal/peer/ice/stdnet_android.go @@ -6,6 +6,6 @@ import ( "github.com/netbirdio/netbird/client/internal/stdnet" ) -func newStdNet(ctx context.Context, iFaceDiscover stdnet.ExternalIFaceDiscover, ifaceBlacklist []string) (*stdnet.Net, error) { +func newStdNet(ctx context.Context, iFaceDiscover stdnet.ExternalIFaceDiscover, ifaceBlacklist []string) *stdnet.Net { return stdnet.NewNetWithDiscover(ctx, iFaceDiscover, ifaceBlacklist) } diff --git a/client/internal/relay/relay.go b/client/internal/relay/relay.go index 051717608..f0c65301e 100644 --- a/client/internal/relay/relay.go +++ b/client/internal/relay/relay.go @@ -201,11 +201,7 @@ func (p *StunTurnProbe) probeSTUN(ctx context.Context, uri *stun.URI) (addr stri } }() - net, err := stdnet.NewNet(ctx, nil) - if err != nil { - probeErr = fmt.Errorf("new net: %w", err) - return - } + net := stdnet.NewNet(ctx, nil) client, err := stun.DialURI(uri, &stun.DialConfig{ Net: net, @@ -290,11 +286,7 @@ func (p *StunTurnProbe) probeTURN(ctx context.Context, uri *stun.URI) (addr stri } }() - net, err := stdnet.NewNet(ctx, nil) - if err != nil { - probeErr = fmt.Errorf("new net: %w", err) - return - } + net := stdnet.NewNet(ctx, nil) cfg := &turn.ClientConfig{ STUNServerAddr: turnServerAddr, TURNServerAddr: turnServerAddr, diff --git a/client/internal/routemanager/manager_test.go b/client/internal/routemanager/manager_test.go index 18b44820a..a1624cf46 100644 --- a/client/internal/routemanager/manager_test.go +++ b/client/internal/routemanager/manager_test.go @@ -8,6 +8,7 @@ import ( "net/netip" "testing" + "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/stdnet" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" @@ -406,10 +407,7 @@ func TestManagerUpdateRoutes(t *testing.T) { for n, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { peerPrivateKey, _ := wgtypes.GeneratePrivateKey() - newNet, err := stdnet.NewNet(context.Background(), nil) - if err != nil { - t.Fatal(err) - } + newNet := stdnet.NewNet(context.Background(), profilemanager.DefaultInterfaceBlacklist) opts := iface.WGIFaceOpts{ IFaceName: fmt.Sprintf("utun43%d", n), Address: wgaddr.MustParseWGAddress("100.65.65.2/24"), diff --git a/client/internal/routemanager/systemops/systemops_generic_test.go b/client/internal/routemanager/systemops/systemops_generic_test.go index c4f739c30..5b569ebd6 100644 --- a/client/internal/routemanager/systemops/systemops_generic_test.go +++ b/client/internal/routemanager/systemops/systemops_generic_test.go @@ -15,6 +15,7 @@ import ( "syscall" "testing" + "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/stdnet" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -436,8 +437,7 @@ func createWGInterface(t *testing.T, interfaceName, ipAddressCIDR string, listen peerPrivateKey, err := wgtypes.GeneratePrivateKey() require.NoError(t, err) - newNet, err := stdnet.NewNet(context.Background(), nil) - require.NoError(t, err) + newNet := stdnet.NewNet(context.Background(), profilemanager.DefaultInterfaceBlacklist) opts := iface.WGIFaceOpts{ IFaceName: interfaceName, diff --git a/client/internal/stdnet/stdnet.go b/client/internal/stdnet/stdnet.go index 381886ac6..c3a9d3d97 100644 --- a/client/internal/stdnet/stdnet.go +++ b/client/internal/stdnet/stdnet.go @@ -45,7 +45,7 @@ type Net struct { } // NewNetWithDiscover creates a new StdNet instance. -func NewNetWithDiscover(ctx context.Context, iFaceDiscover ExternalIFaceDiscover, disallowList []string) (*Net, error) { +func NewNetWithDiscover(ctx context.Context, iFaceDiscover ExternalIFaceDiscover, disallowList []string) *Net { if ctx == nil { ctx = context.Background() } @@ -60,20 +60,19 @@ func NewNetWithDiscover(ctx context.Context, iFaceDiscover ExternalIFaceDiscover } else { n.iFaceDiscover = newMobileIFaceDiscover(iFaceDiscover) } - return n, n.UpdateInterfaces() + return n } // NewNet creates a new StdNet instance. -func NewNet(ctx context.Context, disallowList []string) (*Net, error) { +func NewNet(ctx context.Context, disallowList []string) *Net { if ctx == nil { ctx = context.Background() } - n := &Net{ + return &Net{ iFaceDiscover: pionDiscover{}, interfaceFilter: InterfaceFilter(disallowList), ctx: ctx, } - return n, n.UpdateInterfaces() } // resolveAddr performs DNS resolution with context support and timeout. @@ -122,45 +121,18 @@ func (n *Net) resolveAddr(network, address string) (netip.AddrPort, error) { return netip.AddrPortFrom(addrs[0], uint16(port)), nil } -// UpdateInterfaces updates the internal list of network interfaces -// and associated addresses filtering them by name. -// The interfaces are discovered by an external iFaceDiscover function or by a default discoverer if the external one -// wasn't specified. -func (n *Net) UpdateInterfaces() (err error) { - n.mu.Lock() - defer n.mu.Unlock() - - return n.updateInterfaces() -} - -func (n *Net) updateInterfaces() (err error) { - allIfaces, err := n.iFaceDiscover.iFaces() - if err != nil { - return err - } - - n.interfaces = n.filterInterfaces(allIfaces) - - n.lastUpdate = time.Now() - - return nil -} - // Interfaces returns a slice of interfaces which are available on the // system func (n *Net) Interfaces() ([]*transport.Interface, error) { n.mu.Lock() defer n.mu.Unlock() - if time.Since(n.lastUpdate) < updateInterval { - return slices.Clone(n.interfaces), nil + iFaces, err := n.freshInterfacesLocked() + if err != nil { + return nil, err } - if err := n.updateInterfaces(); err != nil { - return nil, fmt.Errorf("update interfaces: %w", err) - } - - return slices.Clone(n.interfaces), nil + return slices.Clone(iFaces), nil } // InterfaceByIndex returns the interface specified by index. @@ -171,7 +143,13 @@ func (n *Net) Interfaces() ([]*transport.Interface, error) { func (n *Net) InterfaceByIndex(index int) (*transport.Interface, error) { n.mu.Lock() defer n.mu.Unlock() - for _, ifc := range n.interfaces { + + iFaces, err := n.freshInterfacesLocked() + if err != nil { + return nil, err + } + + for _, ifc := range iFaces { if ifc.Index == index { return ifc, nil } @@ -184,7 +162,13 @@ func (n *Net) InterfaceByIndex(index int) (*transport.Interface, error) { func (n *Net) InterfaceByName(name string) (*transport.Interface, error) { n.mu.Lock() defer n.mu.Unlock() - for _, ifc := range n.interfaces { + + iFaces, err := n.freshInterfacesLocked() + if err != nil { + return nil, err + } + + for _, ifc := range iFaces { if ifc.Name == name { return ifc, nil } @@ -193,6 +177,31 @@ func (n *Net) InterfaceByName(name string) (*transport.Interface, error) { return nil, fmt.Errorf("%w: %s", transport.ErrInterfaceNotFound, name) } +func (n *Net) freshInterfacesLocked() ([]*transport.Interface, error) { + if time.Since(n.lastUpdate) < updateInterval { + return n.interfaces, nil + } + + if err := n.updateInterfacesLocked(); err != nil { + return nil, fmt.Errorf("update interfaces: %w", err) + } + + return n.interfaces, nil +} + +func (n *Net) updateInterfacesLocked() error { + allIFaces, err := n.iFaceDiscover.iFaces() + if err != nil { + return err + } + + n.interfaces = n.filterInterfaces(allIFaces) + + n.lastUpdate = time.Now() + + return nil +} + func (n *Net) filterInterfaces(interfaces []*transport.Interface) []*transport.Interface { if n.interfaceFilter == nil { return interfaces diff --git a/client/internal/stdnet/stdnet_test.go b/client/internal/stdnet/stdnet_test.go new file mode 100644 index 000000000..822972f39 --- /dev/null +++ b/client/internal/stdnet/stdnet_test.go @@ -0,0 +1,136 @@ +package stdnet + +import ( + "context" + "errors" + "net" + "testing" + + "github.com/pion/transport/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type countingDiscover struct { + calls int + list []*transport.Interface + err error +} + +func (d *countingDiscover) iFaces() ([]*transport.Interface, error) { + d.calls++ + if d.err != nil { + return nil, d.err + } + return d.list, nil +} + +func newTestNet(t *testing.T, d iFaceDiscover) *Net { + t.Helper() + return &Net{ + iFaceDiscover: d, + ctx: context.Background(), + } +} + +func testIFace(index int, name string) *transport.Interface { + return transport.NewInterface(net.Interface{Index: index, Name: name}) +} + +func TestNet_InterfacesDiscoversLazilyAndCaches(t *testing.T) { + d := &countingDiscover{list: []*transport.Interface{testIFace(1, "eth0")}} + n := newTestNet(t, d) + + require.Zero(t, d.calls, "construction must not discover interfaces") + + iFaces, err := n.Interfaces() + require.NoError(t, err) + require.Len(t, iFaces, 1) + assert.Equal(t, 1, d.calls) + + _, err = n.Interfaces() + require.NoError(t, err) + assert.Equal(t, 1, d.calls) +} + +func TestNewNet_DoesNotDiscoverAtConstruction(t *testing.T) { + n := NewNet(context.Background(), nil) + require.NotNil(t, n) + assert.True(t, n.lastUpdate.IsZero(), "constructor must leave the cache cold") +} + +func TestNewNetWithDiscover_DoesNotDiscoverAtConstruction(t *testing.T) { + n := NewNetWithDiscover(context.Background(), nil, nil) + require.NotNil(t, n) + assert.True(t, n.lastUpdate.IsZero(), "constructor must leave the cache cold") +} + +func TestNet_InterfacesRetryAfterDiscoveryFailure(t *testing.T) { + discoverErr := errors.New("discover failed") + d := &countingDiscover{err: discoverErr} + n := newTestNet(t, d) + + _, err := n.Interfaces() + require.ErrorIs(t, err, discoverErr) + + d.err = nil + d.list = []*transport.Interface{testIFace(1, "eth0")} + + iFaces, err := n.Interfaces() + require.NoError(t, err) + require.Len(t, iFaces, 1) + assert.Equal(t, 2, d.calls) +} + +func TestNet_InterfaceByNameRefreshes(t *testing.T) { + d := &countingDiscover{list: []*transport.Interface{testIFace(3, "eth0")}} + n := newTestNet(t, d) + + ifc, err := n.InterfaceByName("eth0") + require.NoError(t, err) + assert.Equal(t, "eth0", ifc.Name) + assert.Equal(t, 1, d.calls) + + _, err = n.InterfaceByName("nope") + require.ErrorIs(t, err, transport.ErrInterfaceNotFound) +} + +func TestNet_InterfaceByIndexRefreshes(t *testing.T) { + d := &countingDiscover{list: []*transport.Interface{testIFace(3, "eth0")}} + n := newTestNet(t, d) + + ifc, err := n.InterfaceByIndex(3) + require.NoError(t, err) + assert.Equal(t, "eth0", ifc.Name) + assert.Equal(t, 1, d.calls) + + _, err = n.InterfaceByIndex(99) + require.ErrorIs(t, err, transport.ErrInterfaceNotFound) +} + +func TestNet_InterfaceLookupPropagatesDiscoveryError(t *testing.T) { + discoverErr := errors.New("discover failed") + n := newTestNet(t, &countingDiscover{err: discoverErr}) + + _, err := n.InterfaceByName("eth0") + require.ErrorIs(t, err, discoverErr) + + _, err = n.InterfaceByIndex(1) + require.ErrorIs(t, err, discoverErr) +} + +func TestNet_InterfacesReturnsCopy(t *testing.T) { + d := &countingDiscover{list: []*transport.Interface{testIFace(1, "eth0")}} + n := newTestNet(t, d) + + iFaces, err := n.Interfaces() + require.NoError(t, err) + require.Len(t, iFaces, 1) + + iFaces[0] = testIFace(2, "tampered") + + iFaces, err = n.Interfaces() + require.NoError(t, err) + require.Len(t, iFaces, 1) + assert.Equal(t, "eth0", iFaces[0].Name) +} From 3c4358dd3625e458fef379dd15bd356b31147bfd Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Fri, 2 Oct 2026 19:03:00 +0200 Subject: [PATCH 093/209] [management] Require a private proxy cluster for cluster and direct upstream targets (#7984) * [management] Require a private proxy cluster for cluster and direct upstream targets Cluster targets and direct upstream targets make the proxy dial the upstream from its own host network instead of through the embedded NetBird client. Only clusters running in private mode are meant to do that, but the service API accepted these targets on any cluster. Service create and update now reject such targets unless the service's proxy cluster reports the private capability. An unreported capability is treated as unsupported. * [management] Require every proxy in the cluster to be private The private capability is aggregated as any-true, so a cluster where only one proxy runs in private mode passed the check. The mapping is delivered to every proxy in the cluster, so the non-private ones would serve cluster and direct upstream targets from their host network too. Validate these targets against a unanimous aggregation instead. The existing any-true lookup stays as is for the dashboard flags and the agent network gateway. --- .../modules/reverseproxy/proxy/manager.go | 1 + .../reverseproxy/proxy/manager/manager.go | 6 + .../proxy/manager/manager_test.go | 3 + .../reverseproxy/proxy/manager_mock.go | 14 ++ .../reverseproxy/service/manager/manager.go | 50 ++++ .../service/manager/private_cluster_test.go | 218 ++++++++++++++++++ management/server/store/sql_store_proxy.go | 8 + management/server/store/store.go | 1 + management/server/store/store_mock.go | 14 ++ proxy/management_integration_test.go | 4 + 10 files changed, 319 insertions(+) create mode 100644 management/internals/modules/reverseproxy/service/manager/private_cluster_test.go diff --git a/management/internals/modules/reverseproxy/proxy/manager.go b/management/internals/modules/reverseproxy/proxy/manager.go index c0b8435ec..9350ad9b9 100644 --- a/management/internals/modules/reverseproxy/proxy/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager.go @@ -20,6 +20,7 @@ type Manager interface { ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + ClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool ClusterSupportsSessionCode(ctx context.Context, clusterAddr string) bool CleanupStale(ctx context.Context, inactivityDuration time.Duration) error GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager.go b/management/internals/modules/reverseproxy/proxy/manager/manager.go index 7ddb66eec..5a95ea94a 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager.go @@ -23,6 +23,7 @@ type store interface { GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + GetClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error) @@ -149,6 +150,11 @@ func (m Manager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) return m.store.GetClusterSupportsPrivate(ctx, clusterAddr) } +// ClusterAllProxiesPrivate reports whether every active proxy claims the private capability (nil = unreported). +func (m Manager) ClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool { + return m.store.GetClusterAllProxiesPrivate(ctx, clusterAddr) +} + // ClusterSupportsSessionCode reports whether all active proxies support session codes. func (m Manager) ClusterSupportsSessionCode(ctx context.Context, clusterAddr string) bool { versions, err := m.store.GetActiveProxyVersions(ctx, clusterAddr) diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go index 66ddb95bd..56806613a 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go @@ -105,6 +105,9 @@ func (m *mockStore) GetClusterSupportsCrowdSec(_ context.Context, _ string) *boo func (m *mockStore) GetClusterSupportsPrivate(_ context.Context, _ string) *bool { return nil } +func (m *mockStore) GetClusterAllProxiesPrivate(_ context.Context, _ string) *bool { + return nil +} func (m *mockStore) GetActiveProxyVersions(ctx context.Context, clusterAddress string) ([]string, error) { if m.getActiveProxyVersionsFunc != nil { return m.getActiveProxyVersionsFunc(ctx, clusterAddress) diff --git a/management/internals/modules/reverseproxy/proxy/manager_mock.go b/management/internals/modules/reverseproxy/proxy/manager_mock.go index d6f7197d7..5f3404096 100644 --- a/management/internals/modules/reverseproxy/proxy/manager_mock.go +++ b/management/internals/modules/reverseproxy/proxy/manager_mock.go @@ -56,6 +56,20 @@ func (mr *MockManagerMockRecorder) CleanupStale(ctx, inactivityDuration any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupStale", reflect.TypeOf((*MockManager)(nil).CleanupStale), ctx, inactivityDuration) } +// ClusterAllProxiesPrivate mocks base method. +func (m *MockManager) ClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClusterAllProxiesPrivate", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// ClusterAllProxiesPrivate indicates an expected call of ClusterAllProxiesPrivate. +func (mr *MockManagerMockRecorder) ClusterAllProxiesPrivate(ctx, clusterAddr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterAllProxiesPrivate", reflect.TypeOf((*MockManager)(nil).ClusterAllProxiesPrivate), ctx, clusterAddr) +} + // ClusterRequireSubdomain mocks base method. func (m *MockManager) ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool { m.ctrl.T.Helper() diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go index 62897c9ae..900b7759f 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager.go +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -84,6 +84,7 @@ type CapabilityProvider interface { ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + ClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool } type Manager struct { @@ -332,6 +333,10 @@ func (m *Manager) persistNewService(ctx context.Context, accountID string, svc * return err } + if err := m.validatePrivateClusterTargets(ctx, svc.Targets, svc.ProxyCluster); err != nil { + return err + } + return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err := m.validateServiceDomain(ctx, transaction, accountID, svc, svc.ProxyCluster); err != nil { return err @@ -369,6 +374,43 @@ func (m *Manager) clusterCustomPorts(ctx context.Context, svc *service.Service) return m.capabilities.ClusterSupportsCustomPorts(ctx, svc.ProxyCluster) } +// validatePrivateClusterTargets rejects cluster and direct upstream targets unless +// every active proxy in the service's cluster reports the private capability. The +// mapping reaches all proxies in the cluster, so one non-private proxy would serve +// these targets too. An unreported capability is treated as unsupported. Must be +// called outside a transaction, like clusterCustomPorts. +func (m *Manager) validatePrivateClusterTargets(ctx context.Context, targets []*service.Target, cluster string) error { + target := firstPrivateClusterTarget(targets) + if target == nil { + return nil + } + + if private := m.capabilities.ClusterAllProxiesPrivate(ctx, cluster); private != nil && *private { + return nil + } + + if target.TargetType == service.TargetTypeCluster { + return status.Errorf(status.InvalidArgument, + "target_type %q requires a proxy cluster with private mode enabled, cluster %s does not support it", + service.TargetTypeCluster, cluster) + } + return status.Errorf(status.InvalidArgument, + "direct_upstream requires a proxy cluster with private mode enabled, cluster %s does not support it", cluster) +} + +// firstPrivateClusterTarget returns the first target that only a private cluster may serve. +func firstPrivateClusterTarget(targets []*service.Target) *service.Target { + for _, target := range targets { + if target == nil { + continue + } + if target.TargetType == service.TargetTypeCluster || target.Options.DirectUpstream { + return target + } + } + return nil +} + // ensureL4Port auto-assigns a listen port when needed and validates cluster support. // customPorts must be pre-computed via clusterCustomPorts before entering a transaction. func (m *Manager) ensureL4Port(ctx context.Context, tx store.Store, svc *service.Service, customPorts *bool, serviceUpdate bool) error { @@ -464,6 +506,10 @@ func (m *Manager) persistNewEphemeralService(ctx context.Context, accountID, pee return err } + if err := m.validatePrivateClusterTargets(ctx, svc.Targets, svc.ProxyCluster); err != nil { + return err + } + return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err := m.validateServiceDomain(ctx, transaction, accountID, svc, svc.ProxyCluster); err != nil { return err @@ -584,6 +630,10 @@ func (m *Manager) persistServiceUpdate(ctx context.Context, accountID string, se return nil, err } + if err := m.validatePrivateClusterTargets(ctx, service.Targets, effectiveCluster); err != nil { + return nil, err + } + // Validate subdomain requirement *before* the transaction: the underlying // capability lookup talks to the main DB pool, and SQLite's single-connection // pool would self-deadlock if this ran while the tx already held the only diff --git a/management/internals/modules/reverseproxy/service/manager/private_cluster_test.go b/management/internals/modules/reverseproxy/service/manager/private_cluster_test.go new file mode 100644 index 000000000..1f507294e --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/private_cluster_test.go @@ -0,0 +1,218 @@ +package manager + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/metric/noop" + "go.uber.org/mock/gomock" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" +) + +// setupPrivateClusterTest wires the real proxy manager as the capability +// provider and connects one proxy to testCluster reporting the given private +// capability. A nil private connects no proxy, so the capability is unreported. +func setupPrivateClusterTest(t *testing.T, private *bool) (*Manager, store.Store) { + t.Helper() + + mgr, testStore := setupIntegrationTest(t) + + proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter("")) + require.NoError(t, err) + mgr.capabilities = proxyMgr + + if private != nil { + connectTestProxy(t, proxyMgr, "proxy-1", &proxy.Capabilities{Private: private}) + } + + return mgr, testStore +} + +func connectTestProxy(t *testing.T, proxyMgr *proxymanager.Manager, proxyID string, caps *proxy.Capabilities) { + t.Helper() + _, err := proxyMgr.Connect(context.Background(), proxyID, "session-"+proxyID, testCluster, "127.0.0.1", "", nil, caps) + require.NoError(t, err) +} + +func clusterTarget() *rpservice.Target { + return &rpservice.Target{ + TargetId: testCluster, + TargetType: rpservice.TargetTypeCluster, + Host: "backend.lan", + Port: 8080, + Protocol: "http", + Enabled: true, + Options: rpservice.TargetOptions{DirectUpstream: true}, + } +} + +func directUpstreamPeerTarget() *rpservice.Target { + return &rpservice.Target{ + TargetId: testPeerID, + TargetType: rpservice.TargetTypePeer, + Host: "backend.lan", + Port: 8080, + Protocol: "http", + Enabled: true, + Options: rpservice.TargetOptions{DirectUpstream: true}, + } +} + +func TestCreateService_PrivateClusterTargets(t *testing.T) { + tests := []struct { + name string + private *bool + target *rpservice.Target + wantErr string + }{ + {name: "cluster target on private cluster", private: boolPtr(true), target: clusterTarget()}, + {name: "direct upstream on private cluster", private: boolPtr(true), target: directUpstreamPeerTarget()}, + {name: "cluster target on non-private cluster", private: boolPtr(false), target: clusterTarget(), wantErr: `target_type "cluster" requires a proxy cluster with private mode enabled`}, + {name: "direct upstream on non-private cluster", private: boolPtr(false), target: directUpstreamPeerTarget(), wantErr: "direct_upstream requires a proxy cluster with private mode enabled"}, + {name: "cluster target with unreported capability", private: nil, target: clusterTarget(), wantErr: `target_type "cluster" requires a proxy cluster with private mode enabled`}, + {name: "direct upstream with unreported capability", private: nil, target: directUpstreamPeerTarget(), wantErr: "direct_upstream requires a proxy cluster with private mode enabled"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupPrivateClusterTest(t, tc.private) + + svc := newTestService("app.test.netbird.io") + svc.Targets = []*rpservice.Target{tc.target} + + _, err := mgr.CreateService(ctx, testAccountID, testUserID, svc) + + services, listErr := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, listErr) + + if tc.wantErr == "" { + require.NoError(t, err) + assert.Len(t, services, 1, "the service should be persisted") + return + } + + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + sErr, ok := status.FromError(err) + require.True(t, ok, "the caller must receive a typed error") + assert.Equal(t, status.InvalidArgument, sErr.Type(), "the rejection should be an invalid argument") + assert.Empty(t, services, "a rejected service must not be persisted") + }) + } +} + +// A cluster where only some proxies run in private mode must not accept these +// targets: the mapping is delivered to every proxy in the cluster, so the +// non-private ones would serve the target from their host network as well. +func TestCreateService_MixedClusterRejectsPrivateTargets(t *testing.T) { + tests := []struct { + name string + secondCaps *proxy.Capabilities + }{ + {name: "second proxy reports not private", secondCaps: &proxy.Capabilities{Private: boolPtr(false)}}, + {name: "second proxy predates capability reporting", secondCaps: nil}, + } + + for _, tc := range tests { + for _, target := range []*rpservice.Target{clusterTarget(), directUpstreamPeerTarget()} { + t.Run(tc.name+"/"+string(target.TargetType), func(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupPrivateClusterTest(t, boolPtr(true)) + connectTestProxy(t, mgr.capabilities.(*proxymanager.Manager), "proxy-2", tc.secondCaps) + + svc := newTestService("app.test.netbird.io") + svc.Targets = []*rpservice.Target{target} + + _, err := mgr.CreateService(ctx, testAccountID, testUserID, svc) + require.Error(t, err, "a cluster with a non-private proxy must not accept the target") + assert.Contains(t, err.Error(), "requires a proxy cluster with private mode enabled") + + services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + assert.Empty(t, services, "a rejected service must not be persisted") + }) + } + } +} + +func TestCreateService_RegularTargetIgnoresPrivateCapability(t *testing.T) { + ctx := context.Background() + mgr, _ := setupPrivateClusterTest(t, boolPtr(false)) + + _, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.test.netbird.io")) + require.NoError(t, err, "a peer target without direct upstream must not need a private cluster") +} + +func TestUpdateService_PrivateClusterTargets(t *testing.T) { + tests := []struct { + name string + target *rpservice.Target + wantErr string + }{ + {name: "switch to cluster target", target: clusterTarget(), wantErr: `target_type "cluster" requires a proxy cluster with private mode enabled`}, + {name: "enable direct upstream", target: directUpstreamPeerTarget(), wantErr: "direct_upstream requires a proxy cluster with private mode enabled"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupPrivateClusterTest(t, boolPtr(false)) + + created, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.test.netbird.io")) + require.NoError(t, err) + + updated := newTestService("app.test.netbird.io") + updated.ID = created.ID + updated.AccountID = testAccountID + updated.Targets = []*rpservice.Target{tc.target} + + _, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + + stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, created.ID) + require.NoError(t, err) + require.Len(t, stored.Targets, 1) + assert.Equal(t, rpservice.TargetTypePeer, stored.Targets[0].TargetType, "the stored target must be unchanged") + assert.False(t, stored.Targets[0].Options.DirectUpstream, "the stored target must keep direct upstream disabled") + }) + } +} + +func TestUpdateService_PrivateClusterAllowsClusterTarget(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupPrivateClusterTest(t, boolPtr(true)) + + created, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.test.netbird.io")) + require.NoError(t, err) + + updated := newTestService("app.test.netbird.io") + updated.ID = created.ID + updated.AccountID = testAccountID + updated.Targets = []*rpservice.Target{clusterTarget()} + + _, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated) + require.NoError(t, err) + + stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, created.ID) + require.NoError(t, err) + require.Len(t, stored.Targets, 1) + assert.Equal(t, rpservice.TargetTypeCluster, stored.Targets[0].TargetType, "the cluster target should be stored") +} + +func TestValidatePrivateClusterTargets_NoLookupWithoutPrivateTargets(t *testing.T) { + ctrl := gomock.NewController(t) + // No ClusterAllProxiesPrivate expectation: a lookup would fail the test. + mgr := &Manager{capabilities: proxy.NewMockManager(ctrl)} + + targets := []*rpservice.Target{{TargetId: testPeerID, TargetType: rpservice.TargetTypePeer}} + require.NoError(t, mgr.validatePrivateClusterTargets(context.Background(), targets, testCluster)) +} diff --git a/management/server/store/sql_store_proxy.go b/management/server/store/sql_store_proxy.go index 58fa86468..bdccd282c 100644 --- a/management/server/store/sql_store_proxy.go +++ b/management/server/store/sql_store_proxy.go @@ -358,6 +358,14 @@ func (s *SqlStore) GetClusterSupportsPrivate(ctx context.Context, clusterAddr st return s.getClusterCapability(ctx, clusterAddr, "private") } +// GetClusterAllProxiesPrivate reports whether every active proxy in the cluster +// has the private capability. Returns nil when no proxy reported the capability. +// Use it where any proxy in the cluster may serve the result, since a single +// non-private proxy would serve it without the private guarantees. +func (s *SqlStore) GetClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool { + return s.getClusterUnanimousCapability(ctx, clusterAddr, "private") +} + // GetClusterSupportsCrowdSec returns whether all active proxies in the cluster // have CrowdSec configured. Returns nil when no proxy reported the capability. // Unlike other capabilities that use ANY-true (for rolling upgrades), CrowdSec diff --git a/management/server/store/store.go b/management/server/store/store.go index 177c2a47c..01aaf4892 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -336,6 +336,7 @@ type Store interface { GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + GetClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 53e35b866..956cac4b8 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -1870,6 +1870,20 @@ func (mr *MockStoreMockRecorder) GetAnyAccountID(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAnyAccountID", reflect.TypeOf((*MockStore)(nil).GetAnyAccountID), ctx) } +// GetClusterAllProxiesPrivate mocks base method. +func (m *MockStore) GetClusterAllProxiesPrivate(ctx context.Context, clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetClusterAllProxiesPrivate", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// GetClusterAllProxiesPrivate indicates an expected call of GetClusterAllProxiesPrivate. +func (mr *MockStoreMockRecorder) GetClusterAllProxiesPrivate(ctx, clusterAddr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterAllProxiesPrivate", reflect.TypeOf((*MockStore)(nil).GetClusterAllProxiesPrivate), ctx, clusterAddr) +} + // GetClusterRequireSubdomain mocks base method. func (m *MockStore) GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool { m.ctrl.T.Helper() diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index 000d8ce72..03a9855de 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -246,6 +246,10 @@ func (m *testProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) * return nil } +func (m *testProxyManager) ClusterAllProxiesPrivate(_ context.Context, _ string) *bool { + return nil +} + func (m *testProxyManager) ClusterSupportsSessionCode(_ context.Context, _ string) bool { return m.supportsSessionCode } From 88b26bb74f50f12f25ad7b5db8eddb171d91a1e5 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Fri, 2 Oct 2026 22:53:36 +0200 Subject: [PATCH 094/209] [infrastructure] Create the preflight artifacts directory before submitting (#7947) * [infrastructure] Create the preflight artifacts directory before submitting * [infrastructure] Add a workflow to certify UBI images on demand Move the Red Hat certification job into redhat-certify.yml so it can be run by hand for any released version and component, or for all of them. release.yml calls it with component "all" on stable tags. Component IDs now come from REDHAT_CERT_ID_ repository variables. With "all", components without a variable are skipped. * [infrastructure] Fail Red Hat certification on missing IDs or timeout Fail before certifying when a selected component's REDHAT_CERT_ID_* variable is missing, listing every missing variable. Filter the Pyxis poll by tag so older versions are found past the first page, and fail the job when both architectures are not certified within 10 minutes. --- .github/workflows/redhat-certify.yml | 199 +++++++++++++++++++++++++++ .github/workflows/release.yml | 126 ++--------------- 2 files changed, 208 insertions(+), 117 deletions(-) create mode 100644 .github/workflows/redhat-certify.yml diff --git a/.github/workflows/redhat-certify.yml b/.github/workflows/redhat-certify.yml new file mode 100644 index 000000000..e592dabc2 --- /dev/null +++ b/.github/workflows/redhat-certify.yml @@ -0,0 +1,199 @@ +name: Red Hat Certification + +# Certify published UBI images in the Red Hat Ecosystem Catalog. Called by +# release.yml on stable tags, or run by hand to (re)certify any released +# version. preflight submits every architecture of an image's manifest list +# to Pyxis; auto-publish on the component makes it public once certified. +# +# Each component's Partner Connect ID comes from the REDHAT_CERT_ID_ +# repository variable, e.g. REDHAT_CERT_ID_CLIENT_ROOTLESS. The run fails +# before certifying anything if a selected component's variable is not set. + +on: + workflow_call: + inputs: + component: + type: string + required: true + version: + type: string + required: true + secrets: + PYXIS_API_TOKEN: + required: true + workflow_dispatch: + inputs: + component: + description: "Component to certify" + type: choice + required: true + default: all + options: + - all + - client-rootless + - reverse-proxy + version: + description: "Released version, e.g. v0.80.0" + type: string + required: true + +permissions: + contents: read + +jobs: + resolve: + name: Resolve components + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.resolve.outputs.version }} + matrix: ${{ steps.resolve.outputs.matrix }} + steps: + - name: Resolve components and images + id: resolve + env: + COMPONENT: ${{ inputs.component }} + INPUT_VERSION: ${{ inputs.version }} + REPO_VARS: ${{ toJSON(vars) }} + run: | + set -euo pipefail + version="${INPUT_VERSION#v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Only stable x.y.z versions are certified, got '${INPUT_VERSION}'" + exit 1 + fi + # name, image repository, tag suffix (must match .goreleaser.yaml). + # Keep the names in sync with the workflow_dispatch options above. + components=( + "client-rootless ghcr.io/netbirdio/netbird -rootless-ubi" + "reverse-proxy ghcr.io/netbirdio/reverse-proxy -ubi" + ) + matrix="[]" + missing=() + for c in "${components[@]}"; do + read -r name repo suffix <<< "$c" + [[ "$COMPONENT" == all || "$COMPONENT" == "$name" ]] || continue + var="REDHAT_CERT_ID_${name^^}"; var="${var//-/_}" + id="$(jq -r --arg v "$var" '.[$v] // empty' <<< "$REPO_VARS")" + if [[ -z "$id" ]]; then + missing+=("$var") + continue + fi + matrix="$(jq -c --arg n "$name" --arg t "${version}${suffix}" --arg r "${repo}:${version}${suffix}" --arg i "$id" \ + '. + [{component: $n, tag: $t, ref: $r, component_id: $i}]' <<< "$matrix")" + done + if (( ${#missing[@]} )); then + echo "::error::Set these repository variables to the Partner Connect component IDs: ${missing[*]}" + exit 1 + fi + if [[ "$matrix" == "[]" ]]; then + echo "::error::No component to certify for '${COMPONENT}'" + exit 1 + fi + echo "Components to certify: ${matrix}" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "matrix=${matrix}" >> "$GITHUB_OUTPUT" + + certify: + name: "Certify ${{ matrix.component }} UBI image" + needs: resolve + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.resolve.outputs.matrix) }} + env: + PREFLIGHT_VERSION: "1.21.0" + # sha256 of preflight-linux-amd64 from the 1.21.0 GitHub release. + # Red Hat publishes no checksum file, so the value is pinned here. + PREFLIGHT_SHA256: "5e653135503c72f8702bbe31d7643197d12937c68086879133dd6b9650a9a449" + steps: + - name: Verify the multi-arch image is on ghcr.io + env: + IMAGE_REF: ${{ matrix.ref }} + run: | + set -euo pipefail + docker buildx imagetools inspect "$IMAGE_REF" --raw > manifest.json + for arch in amd64 arm64; do + if ! jq -e --arg a "$arch" '.manifests[] | select(.platform.architecture == $a)' manifest.json > /dev/null; then + echo "::error::${IMAGE_REF} has no ${arch} manifest" + exit 1 + fi + done + echo "Manifest list for ${IMAGE_REF}:" + jq -r '.manifests[] | "\(.platform.os)/\(.platform.architecture) \(.digest)"' manifest.json + + - name: Install preflight + run: | + set -euo pipefail + curl -fsSL --proto '=https' --proto-redir '=https' -o preflight \ + "https://github.com/redhat-openshift-ecosystem/openshift-preflight/releases/download/${PREFLIGHT_VERSION}/preflight-linux-amd64" + echo "${PREFLIGHT_SHA256} preflight" | sha256sum -c - + chmod +x preflight + ./preflight --version + + - name: Run preflight checks and submit to Red Hat + env: + IMAGE_REF: ${{ matrix.ref }} + PFLT_PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }} + PFLT_CERTIFICATION_COMPONENT_ID: ${{ matrix.component_id }} + PFLT_ARTIFACTS: artifacts + PFLT_LOGFILE: artifacts/preflight.log + PFLT_LOGLEVEL: info + PFLT_JUNIT: "true" + run: | + set -euo pipefail + # No --platform: preflight walks the manifest list and submits every + # architecture in one run, grouped under one manifest-list digest. + # preflight does not create the PFLT_LOGFILE directory, and --submit + # fails if the log file is missing. + mkdir -p artifacts + ./preflight check container "$IMAGE_REF" --submit + + - name: Fail if any check did not pass + run: | + set -euo pipefail + shopt -s nullglob + results=(artifacts/results.json artifacts/*/results.json) + if [[ ${#results[@]} -eq 0 ]]; then + echo "::error::preflight produced no results.json" + exit 1 + fi + status=0 + for f in "${results[@]}"; do + arch="$(basename "$(dirname "$f")")" + passed="$(jq -r '.passed' "$f")" + failed="$(jq -r '[.results.failed[]?.name] | join(", ")' "$f")" + echo "${arch}: passed=${passed} ${failed:+failed checks: ${failed}}" + [[ "$passed" == "true" ]] || status=1 + done + exit $status + + - name: Upload preflight artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: redhat-preflight-${{ matrix.component }}-${{ needs.resolve.outputs.version }} + path: artifacts/ + retention-days: 30 + + - name: Wait for Pyxis to mark both architectures certified + env: + TAG: ${{ matrix.tag }} + COMPONENT_ID: ${{ matrix.component_id }} + PFLT_PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }} + run: | + set -euo pipefail + # Filter on the tag server-side so older versions are found past the first page. + url="https://catalog.redhat.com/api/containers/v1/projects/certification/id/${COMPONENT_ID}/images?filter=repositories.tags.name==${TAG}&page_size=100" + for attempt in $(seq 1 20); do + certified="$(curl -fsS --proto '=https' --proto-redir '=https' -H "X-API-KEY: ${PFLT_PYXIS_API_TOKEN}" "$url" \ + | jq -r --arg t "$TAG" '[.data[] | select(.repositories[]?.tags[]?.name == $t) | select(.certified == true) | .architecture] | unique | join(",")')" + echo "attempt ${attempt}: certified architectures for ${TAG}: ${certified:-none}" + if [[ "$certified" == "amd64,arm64" ]]; then + echo "Both architectures certified. Auto-publish is enabled on the component, so the catalog updates on its own." + exit 0 + fi + sleep 30 + done + echo "::error::Pyxis has not marked both architectures certified after 10 minutes. Check https://connect.redhat.com/component/view/${COMPONENT_ID}/images" + exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c9af5f17..dee4d398d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -380,131 +380,23 @@ jobs: path: dist/netbird_darwin** retention-days: 7 - # Certify and publish the rootless UBI client image in the Red Hat Ecosystem - # Catalog. Stable tags only: goreleaser pushes -rootless-ubi to - # ghcr.io in the release job above, and preflight submits every architecture - # of that manifest list to Pyxis. Auto-publish on the component makes the new - # version public once certification passes. + # Certify the UBI images in the Red Hat Ecosystem Catalog on stable tags. + # See redhat-certify.yml, which can also be run by hand for any released version. redhat_certification: - name: "Red Hat / Certify rootless UBI image" + name: "Red Hat" needs: release if: | github.repository == 'netbirdio/netbird' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') - runs-on: ubuntu-24.04 permissions: contents: read - env: - PREFLIGHT_VERSION: "1.21.0" - # sha256 of preflight-linux-amd64 from the 1.21.0 GitHub release. - # Red Hat publishes no checksum file, so the value is pinned here. - PREFLIGHT_SHA256: "5e653135503c72f8702bbe31d7643197d12937c68086879133dd6b9650a9a449" - IMAGE_REPOSITORY: "ghcr.io/netbirdio/netbird" - # Component "NetBird Client Container Image (rootless)" in Partner Connect. - # Override with the REDHAT_CERT_COMPONENT_ID repository variable if it changes. - DEFAULT_COMPONENT_ID: "6aa3ca4b4676aefdf07aaa97" - steps: - - name: Resolve image reference - id: image - env: - INPUT_VERSION: ${{ github.ref_name }} - run: | - set -euo pipefail - version="${INPUT_VERSION#v}" - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::Only stable x.y.z versions are certified, got '${INPUT_VERSION}'" - exit 1 - fi - echo "version=${version}" >> "$GITHUB_OUTPUT" - echo "ref=${IMAGE_REPOSITORY}:${version}-rootless-ubi" >> "$GITHUB_OUTPUT" - - - name: Verify the multi-arch image is on ghcr.io - env: - IMAGE_REF: ${{ steps.image.outputs.ref }} - run: | - set -euo pipefail - docker buildx imagetools inspect "$IMAGE_REF" --raw > manifest.json - for arch in amd64 arm64; do - if ! jq -e --arg a "$arch" '.manifests[] | select(.platform.architecture == $a)' manifest.json > /dev/null; then - echo "::error::${IMAGE_REF} has no ${arch} manifest" - exit 1 - fi - done - echo "Manifest list for ${IMAGE_REF}:" - jq -r '.manifests[] | "\(.platform.os)/\(.platform.architecture) \(.digest)"' manifest.json - - - name: Install preflight - run: | - set -euo pipefail - curl -fsSL --proto '=https' --proto-redir '=https' -o preflight \ - "https://github.com/redhat-openshift-ecosystem/openshift-preflight/releases/download/${PREFLIGHT_VERSION}/preflight-linux-amd64" - echo "${PREFLIGHT_SHA256} preflight" | sha256sum -c - - chmod +x preflight - ./preflight --version - - - name: Run preflight checks and submit to Red Hat - env: - IMAGE_REF: ${{ steps.image.outputs.ref }} - PFLT_PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }} - PFLT_CERTIFICATION_COMPONENT_ID: ${{ vars.REDHAT_CERT_COMPONENT_ID || env.DEFAULT_COMPONENT_ID }} - PFLT_ARTIFACTS: artifacts - PFLT_LOGFILE: artifacts/preflight.log - PFLT_LOGLEVEL: info - PFLT_JUNIT: "true" - run: | - set -euo pipefail - # No --platform: preflight walks the manifest list and submits every - # architecture in one run, grouped under one manifest-list digest. - ./preflight check container "$IMAGE_REF" --submit - - - name: Fail if any check did not pass - run: | - set -euo pipefail - shopt -s nullglob - results=(artifacts/results.json artifacts/*/results.json) - if [[ ${#results[@]} -eq 0 ]]; then - echo "::error::preflight produced no results.json" - exit 1 - fi - status=0 - for f in "${results[@]}"; do - arch="$(basename "$(dirname "$f")")" - passed="$(jq -r '.passed' "$f")" - failed="$(jq -r '[.results.failed[]?.name] | join(", ")' "$f")" - echo "${arch}: passed=${passed} ${failed:+failed checks: ${failed}}" - [[ "$passed" == "true" ]] || status=1 - done - exit $status - - - name: Upload preflight artifacts - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: redhat-preflight-${{ steps.image.outputs.version }} - path: artifacts/ - retention-days: 30 - - - name: Wait for Pyxis to mark both architectures certified - env: - VERSION: ${{ steps.image.outputs.version }} - PFLT_PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }} - COMPONENT_ID: ${{ vars.REDHAT_CERT_COMPONENT_ID || env.DEFAULT_COMPONENT_ID }} - run: | - set -euo pipefail - tag="${VERSION}-rootless-ubi" - url="https://catalog.redhat.com/api/containers/v1/projects/certification/id/${COMPONENT_ID}/images?page_size=100" - for attempt in $(seq 1 20); do - certified="$(curl -fsS --proto '=https' --proto-redir '=https' -H "X-API-KEY: ${PFLT_PYXIS_API_TOKEN}" "$url" \ - | jq -r --arg t "$tag" '[.data[] | select(.repositories[]?.tags[]?.name == $t) | select(.certified == true) | .architecture] | unique | join(",")')" - echo "attempt ${attempt}: certified architectures for ${tag}: ${certified:-none}" - if [[ "$certified" == "amd64,arm64" ]]; then - echo "Both architectures certified. Auto-publish is enabled on the component, so the catalog updates on its own." - exit 0 - fi - sleep 30 - done - echo "::warning::Pyxis has not marked both architectures certified after 10 minutes. Check https://connect.redhat.com/component/view/${COMPONENT_ID}/images" + uses: ./.github/workflows/redhat-certify.yml + with: + component: all + version: ${{ github.ref_name }} + secrets: + PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }} release_ui: runs-on: ubuntu-latest From 7b8fa29031add85380adab1c504186eae4a90247 Mon Sep 17 00:00:00 2001 From: PizzaLovingNerd Date: Mon, 5 Oct 2026 02:23:47 -0700 Subject: [PATCH 095/209] [self-hosted] Replace "which" dependency by "command" from configure.sh script (#8007) --- infrastructure_files/configure.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/infrastructure_files/configure.sh b/infrastructure_files/configure.sh index 92252d0b3..ce1a041e6 100755 --- a/infrastructure_files/configure.sh +++ b/infrastructure_files/configure.sh @@ -1,14 +1,14 @@ #!/bin/bash set -e -if ! which curl >/dev/null 2>&1; then +if ! command -v curl >/dev/null 2>&1; then echo "This script uses curl fetch OpenID configuration from IDP." echo "Please install curl and re-run the script https://curl.se/" echo "" exit 1 fi -if ! which jq >/dev/null 2>&1; then +if ! command -v jq >/dev/null 2>&1; then echo "This script uses jq to load OpenID configuration from IDP." echo "Please install jq and re-run the script https://stedolan.github.io/jq/" echo "" @@ -18,13 +18,13 @@ fi source setup.env source base.setup.env -if ! which envsubst >/dev/null 2>&1; then +if ! command -v envsubst >/dev/null 2>&1; then echo "envsubst is needed to run this script" if [[ $(uname) == "Darwin" ]]; then echo "you can install it with homebrew (https://brew.sh):" echo "brew install gettext" else - if which apt-get >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then echo "you can install it by running" echo "apt-get update && apt-get install gettext-base" else From 0cd27ca14b52ba01fd3e57bf3f18328d10047fc3 Mon Sep 17 00:00:00 2001 From: Redouan El Rhazouani <81578195+redouan-rhazouani@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:31:22 +0200 Subject: [PATCH 096/209] [management] Improve Base62 encoding/decoding performance and robustness (#3391) --- base62/base62.go | 85 ++++++++++++++++++++++++++----------------- base62/base62_test.go | 64 +++++++++++++++++++++++++------- 2 files changed, 102 insertions(+), 47 deletions(-) diff --git a/base62/base62.go b/base62/base62.go index efafbc768..1a02e98e2 100644 --- a/base62/base62.go +++ b/base62/base62.go @@ -3,56 +3,75 @@ package base62 import ( "fmt" "math" - "strings" ) const ( - alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - base = uint32(len(alphabet)) + alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + base = uint32(len(alphabet)) + maxBase62Digits = 6 // max number of digits required to encode MaxUint32 + ) +var ( + ErrEmptyString = fmt.Errorf("empty string") + ErrInvalidChar = fmt.Errorf("invalid character") + ErrOverflow = fmt.Errorf("integer overflow") +) + +// Fixed-size arrays have better performance and lower memory overhead compared to maps for small static sets of data +var charToIndex [123]int8 // Assuming ASCII from '\0' - 'z' + +func init() { + for i := range charToIndex { + charToIndex[i] = -1 + } + for i, c := range alphabet { + charToIndex[c] = int8(i) + } +} + // Encode encodes a uint32 value to a base62 string. -func Encode(num uint32) string { - if num == 0 { - return string(alphabet[0]) +// The returned string will be between 1-6 characters long. +func Encode(n uint32) string { + if n < base { + return string(alphabet[n]) + } + // avoid dynamic memory usage for small, fixed size data + buf := [maxBase62Digits]byte{} + idx := len(buf) + + for n > 0 { + idx-- + buf[idx] = alphabet[n%base] + n /= base } - var encoded strings.Builder - - for num > 0 { - remainder := num % base - encoded.WriteByte(alphabet[remainder]) - num /= base - } - - // Reverse the encoded string - encodedString := encoded.String() - reversed := reverse(encodedString) - return reversed + return string(buf[idx:]) } // Decode decodes a base62 string to a uint32 value. +// Returns an error if the input string is empty, contains invalid characters, +// or would result in integer overflow. func Decode(encoded string) (uint32, error) { + if len(encoded) == 0 { + return 0, ErrEmptyString + } var decoded uint32 - strLen := len(encoded) - - for i, char := range encoded { - index := strings.IndexRune(alphabet, char) + for _, char := range encoded { + index := int8(-1) + if int(char) < len(charToIndex) { + index = charToIndex[char] + } if index < 0 { - return 0, fmt.Errorf("invalid character: %c", char) + return 0, fmt.Errorf("%w: %c", ErrInvalidChar, char) + } + // Add overflow check when calculating the decoded value to prevent silent overflow of uint32 + if decoded > (math.MaxUint32-uint32(index))/base { + return 0, fmt.Errorf("%w: %s", ErrOverflow, encoded) } - decoded += uint32(index) * uint32(math.Pow(float64(base), float64(strLen-i-1))) + decoded = decoded*base + uint32(index) } return decoded, nil } - -// Reverse a string. -func reverse(s string) string { - runes := []rune(s) - for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { - runes[i], runes[j] = runes[j], runes[i] - } - return string(runes) -} diff --git a/base62/base62_test.go b/base62/base62_test.go index 00da2124a..f2ad06d6f 100644 --- a/base62/base62_test.go +++ b/base62/base62_test.go @@ -1,31 +1,67 @@ package base62 import ( + "errors" + "math" "testing" ) func TestEncodeDecode(t *testing.T) { - tests := []struct { - num uint32 + testCases := []struct { + input uint32 + expected string }{ - {0}, - {1}, - {42}, - {12345}, - {99999}, - {123456789}, + {0, "0"}, + {1, "1"}, + {5, "5"}, + {9, "9"}, + {10, "A"}, + {42, "g"}, + {61, "z"}, + {62, "10"}, + {'0', "m"}, + {'9', "v"}, + {'A', "13"}, + {'Z', "1S"}, + {'a', "1Z"}, + {'z', "1y"}, + {99999, "Q0t"}, + {12345, "3D7"}, + {123456789, "8M0kX"}, + {math.MaxUint32, "4gfFC3"}, } - for _, tt := range tests { - encoded := Encode(tt.num) + for _, tc := range testCases { + encoded := Encode(tc.input) + if encoded != tc.expected { + t.Errorf("Encode(%d) = %s; want %s", tc.input, encoded, tc.expected) + } decoded, err := Decode(encoded) - if err != nil { - t.Errorf("Decode error: %v", err) + t.Errorf("Expected error nil, got %v", err) } - if decoded != tt.num { - t.Errorf("Decode(%v) = %v, want %v", encoded, decoded, tt.num) + if decoded != tc.input { + t.Errorf("Decode(%v) = %v, want %v", encoded, decoded, tc.input) } } } + +// Decode handles empty string input with appropriate error +func TestDecodeEmptyString(t *testing.T) { + if _, err := Decode(""); !errors.Is(err, ErrEmptyString) { + t.Errorf("Expected error %v, got %v", ErrEmptyString, err) + } +} + +func TestDecodeOverflow(t *testing.T) { + if _, err := Decode("4gfFC4"); !errors.Is(err, ErrOverflow) { + t.Errorf("Expected error %v, got %v", ErrOverflow, err) + } +} + +func TestDecodeInvalid(t *testing.T) { + if _, err := Decode("/"); !errors.Is(err, ErrInvalidChar) { + t.Errorf("Expected error %v, got %v", ErrInvalidChar, err) + } +} From 19c54b82264965bb20ca18886324fb41b55f7fe8 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 5 Oct 2026 12:48:53 +0200 Subject: [PATCH 097/209] [management] Refresh only affected peers on DNS zone and record changes (#8050) Zone and record changes refreshed every peer in the account, and zone create/update passed the request context to the update goroutine, so it could be cancelled when the handler returned. They now compute affected peers from the zone's distribution groups inside the transaction and dispatch through ExpandAndUpdateAffected, which detaches the context. The resolver did not know about zones, so changing a group referenced only by a zone never pushed the zone to its added or removed members. It now folds the distribution groups of shipped zones on whole-group changes. --- .../modules/zones/manager/manager.go | 84 +++++++--- .../modules/zones/records/manager/manager.go | 29 +++- management/server/affected_peers_zone_test.go | 145 ++++++++++++++++++ management/server/affectedpeers/resolver.go | 29 +++- 4 files changed, 258 insertions(+), 29 deletions(-) create mode 100644 management/server/affected_peers_zone_test.go diff --git a/management/internals/modules/zones/manager/manager.go b/management/internals/modules/zones/manager/manager.go index d5348d3d0..6f6ba6c40 100644 --- a/management/internals/modules/zones/manager/manager.go +++ b/management/internals/modules/zones/manager/manager.go @@ -3,15 +3,16 @@ package manager import ( "context" "fmt" + "slices" "github.com/netbirdio/netbird/management/internals/modules/zones" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/status" ) @@ -69,6 +70,9 @@ func (m *managerImpl) CreateZone(ctx context.Context, accountID, userID string, } zone = zones.NewZone(accountID, zone.Name, zone.Domain, zone.Enabled, zone.EnableSearchDomain, zone.DistributionGroups) + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{DistributionGroupIDs: zone.DistributionGroups} + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { existingZone, err := transaction.GetZoneByDomain(ctx, accountID, zone.Domain) if err != nil { @@ -88,7 +92,15 @@ func (m *managerImpl) CreateZone(ctx context.Context, accountID, userID string, } if err = transaction.CreateZone(ctx, zone); err != nil { - return fmt.Errorf("failed to create zone: %w", err) + return fmt.Errorf("create zone: %w", err) + } + + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return fmt.Errorf("load affected peers: %w", err) + } + + if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) } return nil @@ -99,6 +111,8 @@ func (m *managerImpl) CreateZone(ctx context.Context, accountID, userID string, m.accountManager.StoreEvent(ctx, userID, zone.ID, accountID, activity.DNSZoneCreated, zone.EventMeta()) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) + return zone, nil } @@ -111,21 +125,26 @@ func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string, return nil, status.NewPermissionDeniedError() } - zone, err := m.store.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, updatedZone.ID) - if err != nil { - return nil, fmt.Errorf("failed to get zone: %w", err) - } - - if zone.Domain != updatedZone.Domain { - return nil, status.Errorf(status.InvalidArgument, "zone domain cannot be updated") - } - - zone.Name = updatedZone.Name - zone.Enabled = updatedZone.Enabled - zone.EnableSearchDomain = updatedZone.EnableSearchDomain - zone.DistributionGroups = updatedZone.DistributionGroups + var zone *zones.Zone + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, updatedZone.ID) + if err != nil { + return fmt.Errorf("get zone: %w", err) + } + + if zone.Domain != updatedZone.Domain { + return status.Errorf(status.InvalidArgument, "zone domain cannot be updated") + } + + oldGroups := zone.DistributionGroups + zone.Name = updatedZone.Name + zone.Enabled = updatedZone.Enabled + zone.EnableSearchDomain = updatedZone.EnableSearchDomain + zone.DistributionGroups = updatedZone.DistributionGroups + for _, groupID := range zone.DistributionGroups { _, err = transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID) if err != nil { @@ -134,7 +153,16 @@ func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string, } if err = transaction.UpdateZone(ctx, zone); err != nil { - return fmt.Errorf("failed to update zone: %w", err) + return fmt.Errorf("update zone: %w", err) + } + + change = affectedpeers.Change{DistributionGroupIDs: slices.Concat(zone.DistributionGroups, oldGroups)} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return fmt.Errorf("load affected peers: %w", err) + } + + if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) } return nil @@ -145,7 +173,7 @@ func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string, m.accountManager.StoreEvent(ctx, userID, zone.ID, accountID, activity.DNSZoneUpdated, zone.EventMeta()) - go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZone, Operation: types.UpdateOperationUpdate}) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) return zone, nil } @@ -159,13 +187,23 @@ func (m *managerImpl) DeleteZone(ctx context.Context, accountID, userID, zoneID return status.NewPermissionDeniedError() } - zone, err := m.store.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID) - if err != nil { - return fmt.Errorf("failed to get zone: %w", err) - } - + var zone *zones.Zone + var snap *affectedpeers.Snapshot + var change affectedpeers.Change var eventsToStore []func() + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID) + if err != nil { + return fmt.Errorf("get zone: %w", err) + } + + // Load before delete: the post-delete state no longer references the groups. + change = affectedpeers.Change{DistributionGroupIDs: zone.DistributionGroups} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return fmt.Errorf("load affected peers: %w", err) + } + records, err := transaction.GetZoneDNSRecords(ctx, store.LockingStrengthNone, accountID, zoneID) if err != nil { return fmt.Errorf("failed to get records: %w", err) @@ -207,7 +245,7 @@ func (m *managerImpl) DeleteZone(ctx context.Context, accountID, userID, zoneID event() } - go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZone, Operation: types.UpdateOperationDelete}) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } diff --git a/management/internals/modules/zones/records/manager/manager.go b/management/internals/modules/zones/records/manager/manager.go index b041aca30..16839c1b4 100644 --- a/management/internals/modules/zones/records/manager/manager.go +++ b/management/internals/modules/zones/records/manager/manager.go @@ -9,11 +9,11 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/zones/records" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/status" ) @@ -65,6 +65,8 @@ func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneI } var zone *zones.Zone + var snap *affectedpeers.Snapshot + var change affectedpeers.Change record = records.NewRecord(accountID, zoneID, record.Name, record.Type, record.Content, record.TTL) err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { @@ -82,6 +84,11 @@ func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneI return fmt.Errorf("failed to create dns record: %w", err) } + change = affectedpeers.Change{DistributionGroupIDs: zone.DistributionGroups} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return fmt.Errorf("load affected peers: %w", err) + } + err = transaction.IncrementNetworkSerial(ctx, accountID) if err != nil { return fmt.Errorf("failed to increment network serial: %w", err) @@ -96,7 +103,7 @@ func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneI meta := record.EventMeta(zone.ID, zone.Name) m.accountManager.StoreEvent(ctx, userID, record.ID, accountID, activity.DNSRecordCreated, meta) - go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZoneRecord, Operation: types.UpdateOperationCreate}) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) return record, nil } @@ -112,6 +119,8 @@ func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneI var zone *zones.Zone var record *records.Record + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID) @@ -141,6 +150,11 @@ func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneI return fmt.Errorf("failed to update dns record: %w", err) } + change = affectedpeers.Change{DistributionGroupIDs: zone.DistributionGroups} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return fmt.Errorf("load affected peers: %w", err) + } + err = transaction.IncrementNetworkSerial(ctx, accountID) if err != nil { return fmt.Errorf("failed to increment network serial: %w", err) @@ -155,7 +169,7 @@ func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneI meta := record.EventMeta(zone.ID, zone.Name) m.accountManager.StoreEvent(ctx, userID, record.ID, accountID, activity.DNSRecordUpdated, meta) - go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZoneRecord, Operation: types.UpdateOperationUpdate}) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) return record, nil } @@ -171,6 +185,8 @@ func (m *managerImpl) DeleteRecord(ctx context.Context, accountID, userID, zoneI var record *records.Record var zone *zones.Zone + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID) @@ -188,6 +204,11 @@ func (m *managerImpl) DeleteRecord(ctx context.Context, accountID, userID, zoneI return fmt.Errorf("failed to delete dns record: %w", err) } + change = affectedpeers.Change{DistributionGroupIDs: zone.DistributionGroups} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return fmt.Errorf("load affected peers: %w", err) + } + err = transaction.IncrementNetworkSerial(ctx, accountID) if err != nil { return fmt.Errorf("failed to increment network serial: %w", err) @@ -202,7 +223,7 @@ func (m *managerImpl) DeleteRecord(ctx context.Context, accountID, userID, zoneI meta := record.EventMeta(zone.ID, zone.Name) m.accountManager.StoreEvent(ctx, userID, recordID, accountID, activity.DNSRecordDeleted, meta) - go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZoneRecord, Operation: types.UpdateOperationDelete}) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } diff --git a/management/server/affected_peers_zone_test.go b/management/server/affected_peers_zone_test.go new file mode 100644 index 000000000..4d622325c --- /dev/null +++ b/management/server/affected_peers_zone_test.go @@ -0,0 +1,145 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/internals/modules/zones" + "github.com/netbirdio/netbird/management/internals/modules/zones/records" + "github.com/netbirdio/netbird/management/server/affectedpeers" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +const affectedZoneDomain = "zone.test" + +// createAffectedZone stores a zone distributed to the given groups, optionally with +// one A record so the network map actually ships it. +func createAffectedZone(t *testing.T, s store.Store, accountID, domain string, enabled, withRecord bool, groups []string) *zones.Zone { + t.Helper() + ctx := context.Background() + + zone := zones.NewZone(accountID, domain, domain, enabled, false, groups) + require.NoError(t, s.CreateZone(ctx, zone)) + + if withRecord { + record := records.NewRecord(accountID, zone.ID, "host."+domain, records.RecordTypeA, "10.0.0.1", 300) + require.NoError(t, s.CreateDNSRecord(ctx, record)) + } + + return zone +} + +func TestCollectGroupChange_ZoneLinked(t *testing.T) { + _, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + createAffectedZone(t, s, accountID, affectedZoneDomain, true, true, []string{groupIDs[0]}) + + groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.Contains(t, groups, groupIDs[0], "group distributed a zone should be affected by its own change") + + groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.Empty(t, groups, "group not referenced by any zone should not be affected") +} + +func TestCollectGroupChange_UnshippedZoneNotLinked(t *testing.T) { + _, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + // Disabled zone and zone without records are never shipped by the network map. + createAffectedZone(t, s, accountID, "disabled."+affectedZoneDomain, false, true, []string{groupIDs[0]}) + createAffectedZone(t, s, accountID, "empty."+affectedZoneDomain, true, false, []string{groupIDs[1]}) + + groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0], groupIDs[1]}) + assert.Empty(t, groups, "groups referenced only by unshipped zones should not be affected") +} + +func TestResolveAffectedPeers_ZoneGroupMembershipChange(t *testing.T) { + _, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + + createAffectedZone(t, s, accountID, affectedZoneDomain, true, true, []string{groupIDs[0]}) + + // Same change shape UpdateGroup builds: the group changed as a whole and peer1 + // left it, so peer1 must refresh to drop the zone. + change := affectedpeers.Change{ + ChangedGroupIDs: []string{groupIDs[0]}, + RemovedPeersByGroup: map[string][]string{groupIDs[0]: {peerIDs[1]}}, + } + + result := resolveAffected(t, s, accountID, change) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result, "current and removed members of the zone group should be affected") +} + +func TestResolveAffectedPeers_ZoneDistributionChange(t *testing.T) { + _, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + + // Zone create/update/delete passes old and new distribution groups. + change := affectedpeers.Change{DistributionGroupIDs: []string{groupIDs[0], groupIDs[2]}} + + result := resolveAffected(t, s, accountID, change) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2]}, result, "only members of the distribution groups should be affected") +} + +// TestAffectedPeers_ZoneGroupUpdate_NewMemberReceivesZone verifies that adding a peer +// to a group referenced only by a zone pushes the zone to the new member and leaves +// unrelated peers alone. +func TestAffectedPeers_ZoneGroupUpdate_NewMemberReceivesZone(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) + } + + zoneGroup := &types.Group{ID: "zone-grp", Name: "ZoneGroup", Peers: []string{peer1.ID}} + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, zoneGroup)) + + createAffectedZone(t, manager.Store, accountID, affectedZoneDomain, true, true, []string{zoneGroup.ID}) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + zoneGroup.Peers = []string{peer1.ID, peer2.ID} + require.NoError(t, manager.UpdateGroup(ctx, accountID, userID, zoneGroup)) + + peerShouldReceiveUpdate(t, updMsg1) + msg := receivePeerUpdate(t, updMsg2) + assert.True(t, syncHasCustomZone(msg, affectedZoneDomain+"."), "new zone group member should receive the zone") + peerShouldNotReceiveUpdate(t, updMsg3) +} + +func receivePeerUpdate(t *testing.T, ch <-chan *network_map.UpdateMessage) *network_map.UpdateMessage { + t.Helper() + select { + case msg := <-ch: + require.NotNil(t, msg, "update message should not be nil") + return msg + case <-time.After(peerUpdateTimeout): + require.FailNow(t, "timed out waiting for update message") + return nil + } +} + +func syncHasCustomZone(msg *network_map.UpdateMessage, domain string) bool { + for _, zone := range msg.Update.GetNetworkMap().GetDNSConfig().GetCustomZones() { + if zone.GetDomain() == domain { + return true + } + } + return false +} diff --git a/management/server/affectedpeers/resolver.go b/management/server/affectedpeers/resolver.go index cb2063ac9..895e4fd36 100644 --- a/management/server/affectedpeers/resolver.go +++ b/management/server/affectedpeers/resolver.go @@ -22,6 +22,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/internals/modules/zones" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -50,6 +51,7 @@ type Snapshot struct { policies []*types.Policy routes []*route.Route nsGroups []*nbdns.NameServerGroup + zones []*zones.Zone dnsSettings *types.DNSSettings routers []*routerTypes.NetworkRouter resources []*resourceTypes.NetworkResource @@ -127,12 +129,15 @@ func (snap *Snapshot) loadRoutesAndProxy(ctx context.Context, s store.Store, acc return snap.loadProxyServices(ctx, s, accountID) } -// loadDNS loads the nameserver groups and account DNS settings. +// loadDNS loads the nameserver groups, custom DNS zones and account DNS settings. func (snap *Snapshot) loadDNS(ctx context.Context, s store.Store, accountID string) error { var err error if snap.nsGroups, err = s.GetAccountNameServerGroups(ctx, store.LockingStrengthNone, accountID); err != nil { return err } + if snap.zones, err = s.GetAccountZones(ctx, store.LockingStrengthNone, accountID); err != nil { + return err + } snap.dnsSettings, err = s.GetAccountDNSSettings(ctx, store.LockingStrengthNone, accountID) return err } @@ -357,7 +362,7 @@ func (s policySide) opposite() policySide { // - a changed router/resource/network sits on a NETWORK -> fold the SOURCE side of // the policies whose destination reaches it (and the routers it implies). // -// Routes, nameserver groups, DNS and embedded-proxy services distribute to their own +// Routes, nameserver groups, DNS zones, DNS and embedded-proxy services distribute to their own // member peers, outside the policy graph, and are folded here too. func (r *resolver) walk() { for _, policy := range r.bothSidesPolicies() { @@ -369,6 +374,7 @@ func (r *resolver) walk() { r.collectFromPolicies() r.collectFromRoutes() r.collectFromNameServers() + r.collectFromZones() r.collectFromDNSSettings() r.collectFromNetworkRouters() r.collectFromProxyServices() @@ -829,6 +835,25 @@ func (r *resolver) collectFromNameServers() { } } +// collectFromZones folds the distribution groups of the custom DNS zones that +// reference a linked group. Like nameserver groups, a zone has no opposite side, so +// only a whole-group change folds its groups. Zones the network map does not ship +// (disabled or without records) are skipped. +func (r *resolver) collectFromZones() { + if len(r.linkGroups) == 0 { + return + } + for _, zone := range r.snap.zones { + if !zone.Enabled || len(zone.Records) == 0 { + continue + } + if anyInSet(zone.DistributionGroups, r.linkGroups) { + log.WithContext(r.ctx).Tracef("collectFromZones: zone %s references a linked group -> folding its groups %v (outputGroups only)", zone.ID, zone.DistributionGroups) + r.foldOutputGroups(zone.DistributionGroups) + } + } +} + // collectFromSSHAuthorizedGroups folds the destinations of the enabled SSH rules that // authorize a group whose user membership changed. Those destination peers carry the // group -> user mapping for the groups they authorize, so they refresh even when no From 1c7d87d5fc618babc6f524dfbe885760ed208bdc Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 5 Oct 2026 14:29:28 +0200 Subject: [PATCH 098/209] [client,android] Generate debug bundle to file (#7528) * [client] Add a debug bundle file export to the Android bridge The Android app can only upload a debug bundle and hand the user a key. Users who want to inspect what leaves their device before sharing it have no way to get the zip itself. Add DebugBundleFile, which generates the bundle into the cache directory and returns its path instead of uploading; the app copies it wherever the user chose and removes it. DebugBundle keeps its behavior. Both entry points share the unexported debugBundle with an upload switch, so the body stays where it was and merges cleanly with the MDM overlay change on main. Because the file variant leaves the zip to the caller and the upload variant only removes it after the upload finishes, a process killed in between leaves a zip behind in the cache. Remove stale bundles before generating a new one: RemoveStaleBundles deletes zips matching the generator's pattern that are older than an hour. Remote debug jobs write to the same directory, so younger files are treated as still in use. * Preserve network map for debug bundle on Android * [client] Keep exported Android debug bundles out of the stale cleanup DebugBundleFile hands the zip to the caller, but the file kept the netbird.debug.*.zip name that RemoveStaleBundles matches, so a later debug run could delete it once it was older than an hour. Rename the exported bundle to netbird.debug-file.*.zip after generation so the cleanup only ever touches bundles no caller owns. * [client] Warn when a stale debug bundle cannot be removed A failed removal means bundles pile up in the cache directory, so log it at Warn instead of Debug. A file that is already gone was removed by a concurrent cleanup and is skipped silently. * [client] Drop the outdated debugBundle comment The comment still said the file variant leaves the zip in place, but it is renamed by debug.ExportBundle since the stale-cleanup change. * [client] Test that the network map reaches the debug bundle Cover both halves of the path Android now relies on: the engine keeps the latest sync response once persistence is enabled, and the bundle generator writes it to network_map.json (anonymized or not) and omits the file when there is no sync response. * [client] Remove abandoned exported debug bundles after a day An exported bundle is owned by the caller, but if the app is killed before it copies and deletes the file, nothing ever removes it from the cache directory. Let RemoveStaleBundles also match exported bundles, with a 24 hour max age instead of the caller-provided one, so a bundle that is still being saved survives while an abandoned one goes. --- client/android/client.go | 23 ++++++++++++ client/internal/debug/debug.go | 54 ++++++++++++++++++++++++++++- client/internal/debug/debug_test.go | 50 ++++++++++++++++++++++++++ client/internal/engine_test.go | 18 ++++++++++ 4 files changed, 144 insertions(+), 1 deletion(-) diff --git a/client/android/client.go b/client/android/client.go index e47a1c13d..b870337d1 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -213,6 +213,7 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, internal.WithNetEvents(c.netMgr)) c.setState(cfg, cacheDir, cfgFile, connectClient) + connectClient.SetSyncResponsePersistence(true) // This path runs the interactive SSO flow, so reaching here means the peer // is authenticated again — release the latch Status() reports from. Clear // only once the fresh connect client is installed: until then Status() @@ -256,6 +257,7 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR connectClient := internal.NewConnectClient(ctx, cfg, c.recorder, internal.WithNetEvents(c.netMgr)) c.setState(cfg, cacheDir, cfgFile, connectClient) + connectClient.SetSyncResponsePersistence(true) return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir) } @@ -327,6 +329,19 @@ func (c *Client) NotifyNetworkChange() { // or "strict"; strict also anonymizes internal IP ranges, peer names, and // WireGuard public keys, and implies anonymize. func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) { + return c.debugBundle(platformFiles, anonymize, anonymizeLevel, true) +} + +// DebugBundleFile generates a debug bundle and returns the path of the zip in +// the cache directory instead of uploading it, so the app can hand the file to +// the user for inspection. The caller owns the file and removes it once done; +// the stale-bundle cleanup of later runs removes it only after a day. +// anonymize and anonymizeLevel behave as in DebugBundle. +func (c *Client) DebugBundleFile(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string) (string, error) { + return c.debugBundle(platformFiles, anonymize, anonymizeLevel, false) +} + +func (c *Client) debugBundle(platformFiles PlatformFiles, anonymize bool, anonymizeLevel string, upload bool) (string, error) { cfg, cacheDir, cc := c.stateSnapshot() // If the engine hasn't been started, load config from disk @@ -342,6 +357,11 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym cacheDir = platformFiles.CacheDir() } + // Clear what an interrupted earlier run may have left in the cache before + // adding to it. Remote debug jobs write to the same directory, so anything + // younger than an hour is treated as possibly still in use. + debug.RemoveStaleBundles(cacheDir, time.Hour) + deps := debug.GeneratorDependencies{ InternalConfig: cfg, StatusRecorder: c.recorder, @@ -379,6 +399,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool, anonym if err != nil { return "", fmt.Errorf("generate debug bundle: %w", err) } + if !upload { + return debug.ExportBundle(path) + } defer func() { if err := os.Remove(path); err != nil { log.Errorf("failed to remove debug bundle file: %v", err) diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index b362ae293..f4d1c3598 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -379,9 +379,38 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen } } +// bundleFilePattern names the bundle zips Generate creates in tempDir; the +// asterisk is filled in by os.CreateTemp. +const bundleFilePattern = "netbird.debug.*.zip" + +const exportedBundlePrefix = "netbird.debug-file." + +const exportedBundleMaxAge = 24 * time.Hour + +// RemoveStaleBundles deletes bundle zips that an interrupted generation or +// upload left behind in dir. Only files older than maxAge go, so a bundle that +// another caller is still writing or uploading in the same directory survives. +// Exported bundles are kept for exportedBundleMaxAge instead. +func RemoveStaleBundles(dir string, maxAge time.Duration) { + removeStaleFiles(dir, bundleFilePattern, maxAge) + removeStaleFiles(dir, exportedBundlePrefix+"*.zip", exportedBundleMaxAge) +} + +// ExportBundle renames a generated bundle out of the RemoveStaleBundles pattern +// and returns the new path. The caller owns the file from then on; an export +// abandoned for longer than exportedBundleMaxAge is removed by RemoveStaleBundles. +func ExportBundle(path string) (string, error) { + base := strings.TrimPrefix(filepath.Base(path), strings.SplitN(bundleFilePattern, "*", 2)[0]) + exported := filepath.Join(filepath.Dir(path), exportedBundlePrefix+base) + if err := os.Rename(path, exported); err != nil { + return "", fmt.Errorf("export debug bundle: %w", err) + } + return exported, nil +} + // Generate creates a debug bundle and returns the location. func (g *BundleGenerator) Generate() (resp string, err error) { - bundlePath, err := os.CreateTemp(g.tempDir, "netbird.debug.*.zip") + bundlePath, err := os.CreateTemp(g.tempDir, bundleFilePattern) if err != nil { return "", fmt.Errorf("create zip file: %w", err) } @@ -1725,3 +1754,26 @@ func anonymizeSlice(v []any, anonymizer *anonymize.Anonymizer) []any { } return v } + +func removeStaleFiles(dir, pattern string, maxAge time.Duration) { + matches, err := filepath.Glob(filepath.Join(dir, pattern)) + if err != nil { + log.Debugf("glob stale debug bundles in %s: %v", dir, err) + return + } + + cutoff := time.Now().Add(-maxAge) + for _, path := range matches { + info, err := os.Stat(path) + if err != nil || info.ModTime().After(cutoff) { + continue + } + if err := os.Remove(path); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + log.Warnf("remove stale debug bundle %s: %v", path, err) + } + continue + } + log.Infof("removed stale debug bundle %s", path) + } +} diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 17d520358..6a810bccc 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "encoding/json" + "fmt" "net" "net/netip" "net/url" @@ -969,3 +970,52 @@ func renderAddConfigSpecific(g *BundleGenerator) string { func newAnonymizerForTest() *anonymize.Anonymizer { return anonymize.NewAnonymizer(anonymize.DefaultAddresses()) } + +func TestRemoveStaleBundles(t *testing.T) { + dir := t.TempDir() + stale := filepath.Join(dir, "netbird.debug.111.zip") + fresh := filepath.Join(dir, "netbird.debug.222.zip") + other := filepath.Join(dir, "netbird.debug.333.txt") + owned := filepath.Join(dir, "netbird.debug.444.zip") + abandoned := filepath.Join(dir, "netbird.debug.555.zip") + for _, p := range []string{stale, fresh, other, owned, abandoned} { + require.NoError(t, os.WriteFile(p, []byte("x"), 0o600)) + } + exported, err := ExportBundle(owned) + require.NoError(t, err) + exportedAbandoned, err := ExportBundle(abandoned) + require.NoError(t, err) + old := time.Now().Add(-2 * time.Hour) + for _, p := range []string{stale, other, exported} { + require.NoError(t, os.Chtimes(p, old, old)) + } + ancient := time.Now().Add(-exportedBundleMaxAge - time.Hour) + require.NoError(t, os.Chtimes(exportedAbandoned, ancient, ancient)) + + RemoveStaleBundles(dir, time.Hour) + + assert.NoFileExists(t, stale, "bundle older than maxAge should be removed") + assert.FileExists(t, fresh, "bundle younger than maxAge must survive, it may still be uploading") + assert.FileExists(t, other, "files outside the bundle pattern must not be touched") + assert.NoFileExists(t, owned) + assert.FileExists(t, exported, "exported bundle is caller-owned and must survive maxAge") + assert.NoFileExists(t, exportedAbandoned, "exported bundle older than exportedBundleMaxAge is abandoned") +} + +func TestBundleIncludesNetworkMap(t *testing.T) { + for _, anonymize := range []bool{false, true} { + t.Run(fmt.Sprintf("anonymize=%t", anonymize), func(t *testing.T) { + g := NewBundleGenerator(GeneratorDependencies{ + SyncResponse: &mgmProto.SyncResponse{NetworkMap: &mgmProto.NetworkMap{Serial: 1}}, + }, BundleConfig{Anonymize: anonymize}) + + require.Contains(t, bundleEntries(t, g), "network_map.json") + }) + } +} + +func TestBundleOmitsNetworkMapWithoutSyncResponse(t *testing.T) { + g := NewBundleGenerator(GeneratorDependencies{}, BundleConfig{}) + + require.NotContains(t, bundleEntries(t, g), "network_map.json") +} diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 3856cae22..14076c051 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -1493,3 +1493,21 @@ func TestOverlayAddrsFromAllowedIPs(t *testing.T) { }) } } + +func TestEngine_SyncResponsePersistence(t *testing.T) { + e := &Engine{} + + _, err := e.GetLatestSyncResponse() + require.Error(t, err, "persistence is disabled by default") + + e.SetSyncResponsePersistence(true) + e.persistSyncResponse(&mgmtProto.SyncResponse{NetworkMap: &mgmtProto.NetworkMap{Serial: 7}}) + + got, err := e.GetLatestSyncResponse() + require.NoError(t, err) + assert.Equal(t, uint64(7), got.GetNetworkMap().GetSerial()) + + e.SetSyncResponsePersistence(false) + _, err = e.GetLatestSyncResponse() + require.Error(t, err) +} From 6b3cfbabd2d6efe270029e5a52769970214f4af9 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 5 Oct 2026 15:11:08 +0200 Subject: [PATCH 099/209] [client] Resolve the Android network route peer by HA unique ID instead of scanning the full status (#7705) * [client] Build the Android network list from a single peer snapshot Networks() called GetFullStatus() once per network to find the peer that serves it, copying every peer state and taking eight recorder locks each time. With 100+ peers and the UI calling Networks() from every peer list change, this queued hundreds of callers on the status recorder lock. Take one snapshot per call and index it by route. * [client] Track the active route peer by HA unique ID in the status recorder The Android network list resolved the route owner by scanning peer state route keys. Those keys are the handler string: a prefix for static routes and the domain pattern for dynamic ones, so the prefix-based lookup never matched dynamic routes, and two networks sharing a prefix resolved to the same owner. The route watcher now records the chosen route peer under the route's HA unique ID in the status recorder, and the Android binding looks the owner up by that ID. The key is unique per network and independent of the handler string format, so both anomalies are gone. The prefix-based routeOwners helper is removed. * [client] Record the active route peer before notifying listeners AddPeerStateRoute and RemovePeerStateRoute fire the peer list change callback and wake the status subscribers. The active route peer mapping was written after those calls, so a Networks() call landing in between found no mapping for the network and fell back to the first connected peer, or kept showing the previous peer on removal. Nothing re-notified after the mapping write, so the wrong peer stayed until the next peer list change. Write and delete the mapping before the notifying calls so a listener reacting to the notification always reads the current owner. --- client/android/client.go | 16 ++++++------- client/internal/peer/status.go | 20 ++++++++++++++++ client/internal/peer/status_test.go | 23 +++++++++++++++++++ client/internal/routemanager/client/client.go | 2 ++ 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/client/android/client.go b/client/android/client.go index b870337d1..6f5eaacf3 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -498,6 +498,7 @@ func (c *Client) Networks() *NetworkArray { routesMap := routeManager.GetClientRoutesWithNetID() v6Merged := route.V6ExitMergeSet(routesMap) resolvedDomains := c.recorder.GetResolvedDomainsStates() + activeRoutePeers := c.recorder.GetActiveRoutePeers() networkArray := &NetworkArray{ items: make([]Network, 0), @@ -511,7 +512,7 @@ func (c *Client) Networks() *NetworkArray { continue } - network := c.buildNetwork(id, routes, routeSelector.IsSelected(id), resolvedDomains, v6Merged) + network := c.buildNetwork(id, routes, routeSelector.IsSelected(id), resolvedDomains, v6Merged, activeRoutePeers) if network == nil { continue } @@ -520,14 +521,14 @@ func (c *Client) Networks() *NetworkArray { return networkArray } -func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bool, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo, v6Merged map[route.NetID]struct{}) *Network { +func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bool, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo, v6Merged map[route.NetID]struct{}, activeRoutePeers map[route.HAUniqueID]string) *Network { r := routes[0] netStr := r.Network.String() if r.IsDynamic() { netStr = r.Domains.SafeString() } - routePeer, err := c.findBestRoutePeer(routes) + routePeer, err := c.findBestRoutePeer(routes, activeRoutePeers) if err != nil { log.Errorf("could not get peer info for route %s: %v", id, err) return nil @@ -551,12 +552,9 @@ func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bo // findBestRoutePeer returns the peer actively routing traffic for the given // HA route group. Falls back to the first connected peer, then the first peer. -func (c *Client) findBestRoutePeer(routes []*route.Route) (peer.State, error) { - netStr := routes[0].Network.String() - - fullStatus := c.recorder.GetFullStatus() - for _, p := range fullStatus.Peers { - if _, ok := p.GetRoutes()[netStr]; ok { +func (c *Client) findBestRoutePeer(routes []*route.Route, activeRoutePeers map[route.HAUniqueID]string) (peer.State, error) { + if peerKey, ok := activeRoutePeers[routes[0].GetHAUniqueID()]; ok { + if p, err := c.recorder.GetPeer(peerKey); err == nil { return p, nil } } diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index d753ee43e..826bf6fe0 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -196,6 +196,7 @@ type Status struct { muxRelays sync.RWMutex peers map[string]State ipToKey map[string]string + activeRoutePeers map[route.HAUniqueID]string changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription signalState bool signalError error @@ -257,6 +258,7 @@ func NewRecorder(mgmAddress string) *Status { return &Status{ peers: make(map[string]State), ipToKey: make(map[string]string), + activeRoutePeers: make(map[route.HAUniqueID]string), changeNotify: make(map[string]map[string]*StatusChangeSubscription), eventStreams: make(map[string]chan *proto.SystemEvent), eventQueue: NewEventQueue(eventQueueSize), @@ -481,6 +483,24 @@ func (d *Status) RemovePeerStateRoute(peer string, route string) error { return nil } +func (d *Status) AddActiveRoutePeer(haID route.HAUniqueID, peer string) { + d.mux.Lock() + defer d.mux.Unlock() + d.activeRoutePeers[haID] = peer +} + +func (d *Status) RemoveActiveRoutePeer(haID route.HAUniqueID) { + d.mux.Lock() + defer d.mux.Unlock() + delete(d.activeRoutePeers, haID) +} + +func (d *Status) GetActiveRoutePeers() map[route.HAUniqueID]string { + d.mux.RLock() + defer d.mux.RUnlock() + return maps.Clone(d.activeRoutePeers) +} + // CheckRoutes checks if the source and destination addresses are within the same route // and returns the resource ID of the route that contains the addresses func (d *Status) CheckRoutes(ip netip.Addr) ([]byte, bool) { diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go index 82dff0d6f..b3f01b217 100644 --- a/client/internal/peer/status_test.go +++ b/client/internal/peer/status_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/route" ) func TestAddPeer(t *testing.T) { @@ -372,3 +374,24 @@ func TestMarkServerStateDoesNotNotifyWhenUnchanged(t *testing.T) { status.MarkManagementDisconnected(err) assert.False(t, notified(ch), "redundant disconnect should not notify") } + +func TestActiveRoutePeers(t *testing.T) { + status := NewRecorder("https://mgm") + netA := route.HAUniqueID("net-a-10.0.0.0/24") + netB := route.HAUniqueID("net-b-10.0.0.0/24") + + status.AddActiveRoutePeer(netA, "peerA") + status.AddActiveRoutePeer(netB, "peerB") + + active := status.GetActiveRoutePeers() + assert.Equal(t, "peerA", active[netA]) + assert.Equal(t, "peerB", active[netB]) + + status.RemoveActiveRoutePeer(netA) + delete(active, netB) + + active = status.GetActiveRoutePeers() + _, ok := active[netA] + assert.False(t, ok) + assert.Equal(t, "peerB", active[netB]) +} diff --git a/client/internal/routemanager/client/client.go b/client/internal/routemanager/client/client.go index c691c54f8..973cf1ab8 100644 --- a/client/internal/routemanager/client/client.go +++ b/client/internal/routemanager/client/client.go @@ -294,6 +294,7 @@ func (w *Watcher) addAllowedIPs(route *route.Route) error { return fmt.Errorf("add allowed IPs for peer %s: %w", route.Peer, err) } + w.statusRecorder.AddActiveRoutePeer(route.GetHAUniqueID(), route.Peer) if err := w.statusRecorder.AddPeerStateRoute(route.Peer, w.handler.String(), route.GetResourceID()); err != nil { log.Warnf("Failed to update peer state: %v", err) } @@ -303,6 +304,7 @@ func (w *Watcher) addAllowedIPs(route *route.Route) error { } func (w *Watcher) removeAllowedIPs(route *route.Route, rsn reason) error { + w.statusRecorder.RemoveActiveRoutePeer(route.GetHAUniqueID()) if err := w.statusRecorder.RemovePeerStateRoute(route.Peer, w.handler.String()); err != nil { log.Warnf("Failed to update peer state: %v", err) } From bc44cdc37a3b41298187ab76799d327c8a1152cc Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 5 Oct 2026 15:32:48 +0200 Subject: [PATCH 100/209] [client] Fix browser login popup show from go (#7408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client] Show the SSO login popup and open the browser from Go The browser-login popup was created hidden and relied on its own webview to size and show itself and to launch the external browser. On macOS a hidden WKWebView gets throttled or suspended (App Nap / hidden-window throttling), so on the first-use path nothing appeared and the browser never opened, leaving the session-expiration dialog disabled until the PKCE flow timed out. Reproduced by freezing the popup's WebContent process: the old code showed nothing, the new code shows the popup and opens the browser within 30 ms regardless of the webview state. Show and focus the popup from Go right after creation and launch the browser from Go on both the create and reuse paths. The popup's frontend no longer shows or focuses itself, so the browser keeps the foreground once it activates. This also fixes the reuse path, where a fragment-only SetURL kept the mounted React tree and the once-only guard skipped opening the browser for the new URI. Browser launch failures surface in the error dialog instead of being swallowed. * [client] Show every dialog window from Go once its frontend has painted Dialog windows (browser-login, session-expiration, install-progress, welcome, error) were created hidden and made visible only by their own webview's Show call after sizing. A hidden WKWebView on macOS can be throttled or suspended before that code runs, which left the window hidden forever. The main and settings windows already avoided this with the painted event plus a fallback timer, but that timer was armed on WindowRuntimeReady, which a frozen webview never reaches either. Route all dialogs through the same mechanism: the auto-size hook emits the painted event instead of showing the window, Go shows and focuses it on that event, and a fallback timer armed at creation shows it after 3 s regardless. The browser-login popup opens the browser in an after-show callback so the browser still lands in front of the popup, also on the fallback path. * [client] Tie install-progress hidden-window restore to the current popup CloseInstallProgress nils s.installProgress before calling w.Close(), so a replacement popup can open before the old window's WindowClosing event runs. The old callback then restored the windows the replacement had just hidden, because the restore sat outside the identity check. Guard the restore with the same check the state reset uses, and restore from CloseInstallProgress itself so the programmatic close path still re-shows the hidden windows — mirroring how CloseBrowserLogin already handles it. Co-Authored-By: Claude Opus 5 (1M context) * [client] Correlate painted reports with the window generation that sent them A painted report carried only the window name, so a late report from a popup that was already closed and replaced marked its replacement ready. The replacement was then shown before its own frontend had rendered, which is the blank-dialog case this flow exists to prevent. Each dialog start URL now carries a monotonic generation token, echoed back by ReadySignal, and a report whose token no longer matches the live window is dropped. * [client] Separate a window being painted from its frontend being mounted One flag gated both showing a window and emitting to it, so the fallback timer set it for a frontend that had not subscribed yet: the queued events were flushed into a window that could not hear them, losing the login trigger and the settings tab selection. Showing is now gated on painted and emitting on mounted, and only a real frontend report sets mounted. The fallback timer also moved to its own helper so the runtime-ready hook can rearm it, giving the frontend a full budget to mount rather than sharing one with webview boot. * [client] Tag hidden windows with the popup that hid them Windows hidden while a popup owned the screen went into one untagged list, so whichever popup closed first restored all of them and emptied the list. An install started during SSO login re-showed the main window the login popup had deliberately hidden, and left the login popup with nothing to restore. Each entry now records the popup that hid it, and a restore releases only that popup's own entries. This also subsumes the manual filtering CloseRenewFlow did to keep its own session-expiration window from being re-shown. * [client] Cover the hidden-window bookkeeping with tests application.Window carries unexported methods, so the hide/restore paths could not be faked and the earlier tests could only assert which entries survived a restore, never which windows were actually shown. The bookkeeping now goes through hideableWindow, the four methods it needs, with the window enumeration and the main-window raise behind seams that are nil in production. That makes the case the owner tag exists for testable end to end: an install started during SSO login restores only the login popup it hid, and leaves the main window hidden until the login popup itself closes. * [client] Report the first paint from unstamped windows too The main and settings windows carry no generation token, so ReadySignal saw an empty generation that already matched the ref's initial value and never emitted the painted event. Those windows only became visible through the fallback timer, and their frontend was never marked mounted, so the login trigger and the requested settings tab stayed queued. Start the ref from null so the first report goes out regardless of the generation value. * [client] Hand covered windows over when a popup closes under another Closing the browser-login popup while the install-progress popup was still up restored the main window the login had hidden, even though the install popup was meant to own the screen until it finished. The owner tag on each hidden entry only stops a popup from restoring another's windows; it says nothing about what to do with its own when a second popup still covers them. Track which popups currently own the screen and, on restore, re-tag the entries another live popup covers to that popup instead of showing them. A popup is never handed its own window, so closing the popup on top still brings the one below back. --------- Co-authored-by: Claude Opus 5 (1M context) --- .../frontend/src/components/ReadySignal.tsx | 13 +- .../frontend/src/hooks/useAutoSizeWindow.ts | 24 +- .../login/LoginWaitingForBrowserDialog.tsx | 10 +- client/ui/services/connection.go | 30 +- client/ui/services/windowmanager.go | 484 +++++++++++++----- client/ui/services/windowmanager_test.go | 293 ++++++++++- 6 files changed, 695 insertions(+), 159 deletions(-) diff --git a/client/ui/frontend/src/components/ReadySignal.tsx b/client/ui/frontend/src/components/ReadySignal.tsx index 0d040cabc..6a98b30ea 100644 --- a/client/ui/frontend/src/components/ReadySignal.tsx +++ b/client/ui/frontend/src/components/ReadySignal.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; +import { useSearchParams } from "react-router-dom"; import { Events } from "@wailsio/runtime"; import { useStatus } from "@/contexts/StatusContext.tsx"; @@ -6,13 +7,15 @@ const EVENT_WINDOW_PAINTED = "netbird:window-painted"; export const ReadySignal = () => { const { isReady } = useStatus(); - const sent = useRef(false); + const [params] = useSearchParams(); + const generation = params.get("gen") ?? ""; + const sent = useRef(null); useEffect(() => { - if (!isReady || sent.current) return; - sent.current = true; - void Events.Emit(EVENT_WINDOW_PAINTED); - }, [isReady]); + if (!isReady || sent.current === generation) return; + sent.current = generation; + void Events.Emit(EVENT_WINDOW_PAINTED, generation); + }, [isReady, generation]); return null; }; diff --git a/client/ui/frontend/src/hooks/useAutoSizeWindow.ts b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts index d4f4d80b2..6623e72c2 100644 --- a/client/ui/frontend/src/hooks/useAutoSizeWindow.ts +++ b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts @@ -1,23 +1,27 @@ import { useLayoutEffect, useRef } from "react"; -import { Window } from "@wailsio/runtime"; +import { useSearchParams } from "react-router-dom"; +import { Events, Window } from "@wailsio/runtime"; import i18next from "@/lib/i18n"; import { isLinux } from "@/lib/platform"; +const EVENT_WINDOW_PAINTED = "netbird:window-painted"; + // Sizes the current Wails window to the measured content height (keeping `width`), -// then shows it. Re-applies on content resize and language change. +// then reports it as painted so Go shows it. Re-applies on content resize and language change. export function useAutoSizeWindow(width: number, ready: boolean = true) { const ref = useRef(null); + const [params] = useSearchParams(); + const generation = params.get("gen") ?? ""; useLayoutEffect(() => { const el = ref.current; if (!el) return; - let shown = false; + let painted = false; let raf1 = 0; let raf2 = 0; - const showOnce = () => { - if (shown) return; - shown = true; - Window.Show().catch(() => {}); - Window.Focus().catch(() => {}); + const paintedOnce = () => { + if (painted) return; + painted = true; + Events.Emit(EVENT_WINDOW_PAINTED, generation).catch(() => {}); }; const apply = async () => { if (!ready) return; @@ -33,7 +37,7 @@ export function useAutoSizeWindow(width: number, ready: b await Window.SetMaxSize(width, targetH); } await Window.SetSize(width, targetH); - showOnce(); + paintedOnce(); } catch { // window gone / not ready — ignore } @@ -55,6 +59,6 @@ export function useAutoSizeWindow(width: number, ready: b cancelAnimationFrame(raf2); i18next.off("languageChanged", scheduleApply); }; - }, [width, ready]); + }, [width, ready, generation]); return ref; } diff --git a/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx index efbd1ee84..f03751c4d 100644 --- a/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx +++ b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; import { Events } from "@wailsio/runtime"; @@ -21,7 +21,6 @@ export default function LoginWaitingForBrowserDialog() { const [params] = useSearchParams(); const uri = params.get("uri") ?? ""; const contentRef = useAutoSizeWindow(WINDOW_WIDTH); - const openedRef = useRef(false); const reportOpenFailure = useCallback( (e: unknown) => { @@ -33,13 +32,6 @@ export default function LoginWaitingForBrowserDialog() { [t], ); - // Open the browser only after mount, or it lands on top of the still-hidden popup. - useEffect(() => { - if (!uri || openedRef.current) return; - openedRef.current = true; - Connection.OpenURL(uri).catch(reportOpenFailure); - }, [uri, reportOpenFailure]); - const tryAgain = useCallback(() => { if (!uri) return; Connection.OpenURL(uri).catch(reportOpenFailure); diff --git a/client/ui/services/connection.go b/client/ui/services/connection.go index f78ce4c0f..f6a8eca72 100644 --- a/client/ui/services/connection.go +++ b/client/ui/services/connection.go @@ -205,19 +205,7 @@ func (s *Connection) Down(ctx context.Context) error { // window.open, so the SSO verification page can't pop inline. Honors $BROWSER // before the platform default. func (s *Connection) OpenURL(url string) error { - if browser := os.Getenv("BROWSER"); browser != "" { - return exec.Command(browser, url).Start() - } - switch runtime.GOOS { - case "windows": - return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() - case "darwin": - return exec.Command("open", url).Start() - case "linux": - return exec.Command("xdg-open", url).Start() - default: - return fmt.Errorf("unsupported platform") - } + return openURL(url) } func (s *Connection) Logout(ctx context.Context, p LogoutParams) error { @@ -288,3 +276,19 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string, func (s *Connection) classifyDaemonError(err error) *ClientError { return s.classifier.classify(err) } + +func openURL(url string) error { + if browser := os.Getenv("BROWSER"); browser != "" { + return exec.Command(browser, url).Start() + } + switch runtime.GOOS { + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + return exec.Command("open", url).Start() + case "linux": + return exec.Command("xdg-open", url).Start() + default: + return fmt.Errorf("unsupported platform") + } +} diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 24319dae0..af6d726a3 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -5,6 +5,7 @@ package services import ( "net/url" "strconv" + "strings" "sync" "sync/atomic" "time" @@ -26,6 +27,16 @@ type windowOp func(w *application.WebviewWindow, created bool) type windowCloser func(w *application.WebviewWindow) +// hideableWindow is the slice of application.Window the hide/restore bookkeeping needs. +// Narrow enough to fake in tests, which application.Window itself is not: it carries +// unexported methods. +type hideableWindow interface { + Show() application.Window + Hide() application.Window + IsVisible() bool + Name() string +} + // EventTriggerLogin asks the frontend's startLogin() to begin an SSO flow. const EventTriggerLogin = "trigger-login" @@ -37,7 +48,10 @@ const EventSettingsOpen = "netbird:settings:open" const EventWindowPainted = "netbird:window-painted" -const paintedFallback = 2 * time.Second +// generationParam carries the painted-report token in each dialog's start URL. +const generationParam = "gen" + +const paintedFallback = 3 * time.Second const headlessTeardownDelay = 2 * time.Second @@ -201,6 +215,12 @@ func DialogWindowOptions(name, title, url string, linuxIcon []byte) application. } } +// hiddenWindow records a window hidden by owner, the name of the popup that hid it. +type hiddenWindow struct { + win hideableWindow + owner string +} + type WindowManager struct { app *application.App mainWindow *application.WebviewWindow @@ -213,19 +233,35 @@ type WindowManager struct { installProgress *application.WebviewWindow welcome *application.WebviewWindow errorDialog *application.WebviewWindow - // hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close. - hiddenForLogin []application.Window - mu sync.Mutex - newMain func(startURL string) *application.WebviewWindow - creating map[string]bool - pendingOps map[string][]windowOp - pendingClose map[string]windowCloser - restoreGen uint64 - ready map[uint]bool + // hiddenWindows holds windows hidden while a popup owns the screen, each tagged with + // the popup that hid it so closing one popup cannot restore what another still hides. + hiddenWindows []hiddenWindow + hiding map[string]bool + // allWindows and raiseMain are the seams the hide/restore tests replace; both are nil + // in production, where the Wails app and the platform helper are used directly. + allWindows func() []hideableWindow + raiseMain func() + mu sync.Mutex + newMain func(startURL string) *application.WebviewWindow + creating map[string]bool + pendingOps map[string][]windowOp + pendingClose map[string]windowCloser + restoreGen map[string]uint64 + // painted gates showing a window: set by the frontend's first render, or by the + // fallback timer so a webview that never wakes up still becomes visible. + painted map[uint]bool + // mounted gates emitting to a window: set only by a real frontend report, since an + // event emitted to a frontend that has not subscribed yet is dropped, not queued. + mounted map[uint]bool showPending map[uint]bool pendingTab map[uint]string pendingEmits map[uint][]string fallbackTimers map[uint]*time.Timer + afterShow map[uint]func() + // generation maps a window name to the token stamped into its current start URL, so a + // painted report from a replaced window can be told apart from the live one's. + generation map[string]uint64 + lastGeneration uint64 headlessMain bool headlessTimer *time.Timer // recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor @@ -243,11 +279,16 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo creating: map[string]bool{}, pendingOps: map[string][]windowOp{}, pendingClose: map[string]windowCloser{}, - ready: map[uint]bool{}, + restoreGen: map[string]uint64{}, + hiding: map[string]bool{}, + painted: map[uint]bool{}, + mounted: map[uint]bool{}, showPending: map[uint]bool{}, pendingTab: map[uint]string{}, pendingEmits: map[uint][]string{}, fallbackTimers: map[uint]*time.Timer{}, + afterShow: map[uint]func(){}, + generation: map[string]uint64{}, } s.watchPainted() s.watchTriggerLogin() @@ -307,13 +348,13 @@ func (s *WindowManager) OpenSettings(tab string) { s.withWindow(windowSettings, &s.settings, s.newSettingsWindow, func(w *application.WebviewWindow, _ bool) { s.mu.Lock() - ready := s.ready[w.ID()] - if !ready { + mounted := s.mounted[w.ID()] + if !mounted { s.pendingTab[w.ID()] = target } s.mu.Unlock() - if ready { + if mounted { s.app.Event.Emit(EventSettingsOpen, target) } s.showWhenReady(w) @@ -327,21 +368,37 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri) } s.withWindow(windowBrowserLogin, &s.browserLogin, func() *application.WebviewWindow { - return s.newBrowserLoginWindow(startURL) + return s.newBrowserLoginWindow(s.stampGeneration(windowBrowserLogin, startURL)) }, func(w *application.WebviewWindow, created bool) { - if created { - s.centerOnCursorScreen(w) - return - } - if uri != "" { - w.SetURL(startURL) + if !created && uri != "" { + w.SetURL(s.stampGeneration(windowBrowserLogin, startURL)) } s.centerOnCursorScreen(w) - w.Show() - w.Focus() + s.showThenOpenBrowser(w, uri) }) } +func (s *WindowManager) showThenOpenBrowser(w *application.WebviewWindow, uri string) { + if uri != "" { + s.mu.Lock() + s.afterShow[w.ID()] = func() { s.openBrowser(uri) } + s.mu.Unlock() + } + s.showWhenReady(w) +} + +func (s *WindowManager) openBrowser(uri string) { + if uri == "" { + return + } + go func() { + if err := openURL(uri); err != nil { + log.Errorf("open browser for SSO login: %v", err) + s.OpenError(s.title("browserLogin.openFailedTitle"), err.Error(), "") + } + }() +} + func (s *WindowManager) newBrowserLoginWindow(startURL string) *application.WebviewWindow { s.hideOtherWindows(windowBrowserLogin) opts := DialogWindowOptions(windowBrowserLogin, s.title("window.title.signIn"), startURL, s.linuxIcon) @@ -360,12 +417,14 @@ func (s *WindowManager) newBrowserLoginWindow(startURL string) *application.Webv if userClosed { s.browserLogin = nil } + s.forgetWindowLocked(w) s.mu.Unlock() if userClosed { - s.restoreHiddenWindows() + s.restoreHiddenWindows(windowBrowserLogin) s.app.Event.Emit(EventBrowserLoginCancel) } }) + s.armReady(w) return w } @@ -386,13 +445,11 @@ func (s *WindowManager) InstallProgressWindow() *application.WebviewWindow { } func (s *WindowManager) CloseBrowserLogin() { - // The WindowClosing hook no-ops on a programmatic close, so restore here — - // but only if a popup was actually open. The frontend calls this even when no - // popup was ever shown (e.g. resetDialog() after an early RequestExtend failure, - // or connection.ts's catch path), and hiddenForLogin is shared with - // OpenInstallProgress, so an unconditional restore could re-show windows a - // still-running install-progress is hiding. - s.closeWindow(windowBrowserLogin, &s.browserLogin, s.restoreAndClose) + // The WindowClosing hook no-ops on a programmatic close, so the closer restores. + // The frontend calls this even when no popup was ever shown (resetDialog() after an + // early RequestExtend failure, or connection.ts's catch path); closeWindow skips the + // closer then, and an owner-scoped restore cannot touch what install-progress hides. + s.closeWindow(windowBrowserLogin, &s.browserLogin, s.restoringCloser(windowBrowserLogin)) } // OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds @@ -404,16 +461,13 @@ func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10) } s.withWindow(windowSessionExpiration, &s.sessionExpiration, func() *application.WebviewWindow { - return s.newSessionExpirationWindow(startURL) + return s.newSessionExpirationWindow(s.stampGeneration(windowSessionExpiration, startURL)) }, func(w *application.WebviewWindow, created bool) { - if created { - s.centerOnCursorScreen(w) - return + if !created { + w.SetURL(s.stampGeneration(windowSessionExpiration, startURL)) } - w.SetURL(startURL) s.centerOnCursorScreen(w) - w.Show() - w.Focus() + s.showWhenReady(w) }) } @@ -427,8 +481,10 @@ func (s *WindowManager) newSessionExpirationWindow(startURL string) *application if s.sessionExpiration == w { s.sessionExpiration = nil } + s.forgetWindowLocked(w) s.mu.Unlock() }) + s.armReady(w) return w } @@ -440,20 +496,20 @@ func (s *WindowManager) CloseSessionExpiration() { // closes the browser-login popup and the session-expiration window together. func (s *WindowManager) CloseRenewFlow() { s.mu.Lock() - bl := s.takeWindowLocked(windowBrowserLogin, &s.browserLogin, s.restoreAndClose) + bl := s.takeWindowLocked(windowBrowserLogin, &s.browserLogin, s.restoringCloser(windowBrowserLogin)) se := s.takeWindowLocked(windowSessionExpiration, &s.sessionExpiration, closeOnly) if se != nil { - kept := s.hiddenForLogin[:0] - for _, w := range s.hiddenForLogin { - if w != se { - kept = append(kept, w) + kept := s.hiddenWindows[:0] + for _, hidden := range s.hiddenWindows { + if !sameWindow(hidden.win, se) { + kept = append(kept, hidden) } } - s.hiddenForLogin = kept + s.hiddenWindows = kept } s.mu.Unlock() - s.restoreHiddenWindows() + s.restoreHiddenWindows(windowBrowserLogin) // Close after unlock so the re-entrant handlers can take s.mu. if bl != nil { bl.Close() @@ -471,14 +527,12 @@ func (s *WindowManager) OpenInstallProgress(version string) { startURL = "/#/dialog/install-progress?version=" + url.QueryEscape(version) } s.withWindow(windowInstallProgress, &s.installProgress, func() *application.WebviewWindow { - return s.newInstallProgressWindow(startURL) + return s.newInstallProgressWindow(s.stampGeneration(windowInstallProgress, startURL)) }, func(w *application.WebviewWindow, created bool) { if !created { - w.SetURL(startURL) - w.Show() - w.Focus() + w.SetURL(s.stampGeneration(windowInstallProgress, startURL)) } - s.centerWhenReady(w) + s.showWhenReady(w) }) } @@ -489,32 +543,33 @@ func (s *WindowManager) newInstallProgressWindow(startURL string) *application.W ) w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { s.mu.Lock() - if s.installProgress == w { + userClosed := s.installProgress == w + if userClosed { s.installProgress = nil } + s.forgetWindowLocked(w) s.mu.Unlock() - s.restoreHiddenWindows() + if userClosed { + s.restoreHiddenWindows(windowInstallProgress) + } }) + s.armReady(w) return w } func (s *WindowManager) CloseInstallProgress() { - s.closeWindow(windowInstallProgress, &s.installProgress, closeOnly) + s.closeWindow(windowInstallProgress, &s.installProgress, s.restoringCloser(windowInstallProgress)) } // OpenWelcome shows the first-launch onboarding window. Singleton, destroyed on close. func (s *WindowManager) OpenWelcome() { - s.withWindow(windowWelcome, &s.welcome, s.newWelcomeWindow, func(w *application.WebviewWindow, created bool) { - if !created { - w.Show() - w.Focus() - } - s.centerWhenReady(w) + s.withWindow(windowWelcome, &s.welcome, s.newWelcomeWindow, func(w *application.WebviewWindow, _ bool) { + s.showWhenReady(w) }) } func (s *WindowManager) newWelcomeWindow() *application.WebviewWindow { - opts := DialogWindowOptions(windowWelcome, s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon) + opts := DialogWindowOptions(windowWelcome, s.title("window.title.welcome"), s.stampGeneration(windowWelcome, "/#/dialog/welcome"), s.linuxIcon) opts.Width = 420 opts.InitialPosition = application.WindowCentered w := s.app.Window.NewWithOptions(opts) @@ -523,8 +578,10 @@ func (s *WindowManager) newWelcomeWindow() *application.WebviewWindow { if s.welcome == w { s.welcome = nil } + s.forgetWindowLocked(w) s.mu.Unlock() }) + s.armReady(w) return w } @@ -542,14 +599,12 @@ func (s *WindowManager) OpenError(title, message, command string) { } startURL := errorDialogURL(title, message, command) s.withWindow(windowError, &s.errorDialog, func() *application.WebviewWindow { - return s.newErrorWindow(startURL) + return s.newErrorWindow(s.stampGeneration(windowError, startURL)) }, func(w *application.WebviewWindow, created bool) { if !created { - w.SetURL(startURL) - w.Show() - w.Focus() + w.SetURL(s.stampGeneration(windowError, startURL)) } - s.centerWhenReady(w) + s.showWhenReady(w) }) } @@ -562,8 +617,10 @@ func (s *WindowManager) newErrorWindow(startURL string) *application.WebviewWind if s.errorDialog == w { s.errorDialog = nil } + s.forgetWindowLocked(w) s.mu.Unlock() }) + s.armReady(w) return w } @@ -589,14 +646,14 @@ func (s *WindowManager) ShowMainAndEmit(event string) { s.ensureMain("/", func(w *application.WebviewWindow, _ bool) { id := w.ID() s.mu.Lock() - ready := s.ready[id] - if !ready { + mounted := s.mounted[id] + if !mounted { s.pendingEmits[id] = append(s.pendingEmits[id], event) } s.mu.Unlock() s.showWhenReady(w) - if ready { + if mounted { s.app.Event.Emit(event) } }) @@ -741,31 +798,66 @@ func (s *WindowManager) releaseCreationLocked(name string) { delete(s.pendingClose, name) } -func (s *WindowManager) restoreAndClose(w *application.WebviewWindow) { - s.restoreHiddenWindows() - w.Close() +func (s *WindowManager) restoringCloser(owner string) windowCloser { + return func(w *application.WebviewWindow) { + s.restoreHiddenWindows(owner) + w.Close() + } } +// armReady starts the fallback that shows w even if its frontend never reports a first +// render. The timer starts at creation, because a hidden webview can be suspended before +// it reaches WindowRuntimeReady — the very case this fallback covers. That makes the first +// budget cover webview boot as well, so the runtime-ready hook rearms it to give the +// frontend its own full budget to mount and paint. func (s *WindowManager) armReady(w *application.WebviewWindow) { if w == nil { return } + s.armPaintedFallback(w) w.RegisterHook(events.Common.WindowRuntimeReady, func(_ *application.WindowEvent) { - timer := time.AfterFunc(paintedFallback, func() { - log.Warnf("window %q never reported a first render, showing it anyway", w.Name()) - s.markReady(w) - }) - s.mu.Lock() - s.fallbackTimers[w.ID()] = timer - s.mu.Unlock() + s.armPaintedFallback(w) }) } +func (s *WindowManager) armPaintedFallback(w *application.WebviewWindow) { + id := w.ID() + timer := time.AfterFunc(paintedFallback, func() { + s.mu.Lock() + painted := s.painted[id] + s.mu.Unlock() + if painted { + return + } + log.Warnf("window %q never reported a first render, showing it anyway", w.Name()) + s.markPainted(w) + }) + + s.mu.Lock() + if prev := s.fallbackTimers[id]; prev != nil { + prev.Stop() + } + if s.painted[id] { + timer.Stop() + delete(s.fallbackTimers, id) + } else { + s.fallbackTimers[id] = timer + } + s.mu.Unlock() +} + func (s *WindowManager) watchPainted() { s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) { - if w := s.windowByName(e.Sender); w != nil { - s.markReady(w) + w := s.windowByName(e.Sender) + if w == nil { + return } + if !s.matchesGeneration(e.Sender, paintedGeneration(e.Data)) { + log.Debugf("ignoring stale painted report for window %q", e.Sender) + return + } + s.markPainted(w) + s.markMounted(w) }) } @@ -777,7 +869,7 @@ func (s *WindowManager) watchTriggerLogin() { s.headlessTimer = nil } w := s.mainWindow - ready := w != nil && s.ready[w.ID()] + ready := w != nil && s.mounted[w.ID()] s.mu.Unlock() if ready { return @@ -788,7 +880,7 @@ func (s *WindowManager) watchTriggerLogin() { if created { s.headlessMain = true } - pending := !s.ready[w.ID()] + pending := !s.mounted[w.ID()] if pending { s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin) } @@ -850,18 +942,67 @@ func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) { timer.Stop() } delete(s.fallbackTimers, id) - delete(s.ready, id) + delete(s.painted, id) + delete(s.mounted, id) delete(s.showPending, id) delete(s.pendingTab, id) delete(s.pendingEmits, id) + delete(s.afterShow, id) - kept := s.hiddenForLogin[:0] - for _, hidden := range s.hiddenForLogin { - if hidden != application.Window(w) { + kept := s.hiddenWindows[:0] + for _, hidden := range s.hiddenWindows { + if !sameWindow(hidden.win, w) { kept = append(kept, hidden) } } - s.hiddenForLogin = kept + s.hiddenWindows = kept +} + +func (s *WindowManager) stampGeneration(name, startURL string) string { + s.mu.Lock() + defer s.mu.Unlock() + s.lastGeneration++ + s.generation[name] = s.lastGeneration + return appendGeneration(startURL, s.lastGeneration) +} + +func (s *WindowManager) matchesGeneration(name string, gen uint64) bool { + s.mu.Lock() + defer s.mu.Unlock() + want, tracked := s.generation[name] + if !tracked { + return true + } + return want == gen +} + +func (s *WindowManager) hideableWindows() []hideableWindow { + if s.allWindows != nil { + return s.allWindows() + } + all := s.app.Window.GetAll() + windows := make([]hideableWindow, 0, len(all)) + for _, w := range all { + windows = append(windows, w) + } + return windows +} + +func (s *WindowManager) isMainWindow(w hideableWindow, mainWindow *application.WebviewWindow) bool { + if s.allWindows != nil { + return w != nil && w.Name() == windowMain + } + return sameWindow(w, mainWindow) +} + +func (s *WindowManager) raiseMainWindow(mainWindow *application.WebviewWindow) { + if s.raiseMain != nil { + s.raiseMain() + return + } + if mainWindow != nil { + raiseToForeground(mainWindow) + } } func (s *WindowManager) windowByName(name string) *application.WebviewWindow { @@ -872,24 +1013,50 @@ func (s *WindowManager) windowByName(name string) *application.WebviewWindow { return s.mainWindow case windowSettings: return s.settings + case windowBrowserLogin: + return s.browserLogin + case windowSessionExpiration: + return s.sessionExpiration + case windowInstallProgress: + return s.installProgress + case windowWelcome: + return s.welcome + case windowError: + return s.errorDialog default: return nil } } -func (s *WindowManager) markReady(w *application.WebviewWindow) { +func (s *WindowManager) markPainted(w *application.WebviewWindow) { id := w.ID() s.mu.Lock() - already := s.ready[id] - s.ready[id] = true + already := s.painted[id] + s.painted[id] = true wanted := s.showPending[id] - tab, hasTab := s.pendingTab[id] - emits := s.pendingEmits[id] + delete(s.showPending, id) if timer := s.fallbackTimers[id]; timer != nil { timer.Stop() delete(s.fallbackTimers, id) } - delete(s.showPending, id) + s.mu.Unlock() + + if already || !wanted { + return + } + s.showNow(w) +} + +// markMounted records that the window's frontend is subscribed, and flushes the events +// held back for it. The fallback timer never calls this: showing a blank window is +// recoverable, emitting into a frontend that cannot hear it is not. +func (s *WindowManager) markMounted(w *application.WebviewWindow) { + id := w.ID() + s.mu.Lock() + already := s.mounted[id] + s.mounted[id] = true + tab, hasTab := s.pendingTab[id] + emits := s.pendingEmits[id] delete(s.pendingTab, id) delete(s.pendingEmits, id) s.mu.Unlock() @@ -902,10 +1069,6 @@ func (s *WindowManager) markReady(w *application.WebviewWindow) { s.app.Event.Emit(EventSettingsOpen, tab) } - if wanted { - s.showNow(w) - } - for _, event := range emits { s.app.Event.Emit(event) } @@ -918,18 +1081,19 @@ func (s *WindowManager) showWhenReady(w *application.WebviewWindow) { id := w.ID() s.mu.Lock() - ready := s.ready[id] - if !ready { + painted := s.painted[id] + if !painted { s.showPending[id] = true } s.mu.Unlock() - if ready { + if painted { s.showNow(w) } } func (s *WindowManager) showNow(w *application.WebviewWindow) { + id := w.ID() s.mu.Lock() if w == s.mainWindow { s.headlessMain = false @@ -938,10 +1102,15 @@ func (s *WindowManager) showNow(w *application.WebviewWindow) { s.headlessTimer = nil } } + after := s.afterShow[id] + delete(s.afterShow, id) s.mu.Unlock() w.Show() w.Focus() s.centerWhenReady(w) + if after != nil { + after() + } } func (s *WindowManager) ShowMainAt(url string) { @@ -1070,13 +1239,19 @@ func (s *WindowManager) retitleAll() { } } +// hideOtherWindows hides every visible window except keepName, recording them against +// keepName so only its own restore brings them back. A window already hidden by an +// earlier popup is skipped, leaving it tagged to the popup that actually hid it. The +// per-owner generation catches a restore for keepName that ran between the snapshot and +// the record, in which case the windows are re-shown rather than stranded. func (s *WindowManager) hideOtherWindows(keepName string) { s.mu.Lock() - gen := s.restoreGen + s.hiding[keepName] = true + gen := s.restoreGen[keepName] s.mu.Unlock() - var hidden []application.Window - for _, w := range s.app.Window.GetAll() { + var hidden []hideableWindow + for _, w := range s.hideableWindows() { if w == nil || w.Name() == keepName || !w.IsVisible() { continue } @@ -1088,9 +1263,11 @@ func (s *WindowManager) hideOtherWindows(keepName string) { } s.mu.Lock() - restored := s.restoreGen != gen + restored := s.restoreGen[keepName] != gen if !restored { - s.hiddenForLogin = append(s.hiddenForLogin, hidden...) + for _, w := range hidden { + s.hiddenWindows = append(s.hiddenWindows, hiddenWindow{win: w, owner: keepName}) + } } s.mu.Unlock() if !restored { @@ -1101,33 +1278,58 @@ func (s *WindowManager) hideOtherWindows(keepName string) { } } -// restoreHiddenWindows re-shows windows hidden by hideOtherWindows. If the main -// window was among them, raiseToForeground lifts it above the SSO browser, which -// still owns the foreground — a plain Show/Focus would be demoted to a taskbar -// flash and leave it stranded behind. -func (s *WindowManager) restoreHiddenWindows() { +// restoreHiddenWindows re-shows the windows owner hid, unless another popup still covers +// them, in which case they are handed to that popup. If the main window was among them, +// raiseToForeground lifts it above the SSO browser, which still owns the foreground — a +// plain Show/Focus would be demoted to a taskbar flash and leave it stranded behind. +func (s *WindowManager) restoreHiddenWindows(owner string) { s.mu.Lock() - hidden := s.hiddenForLogin - s.hiddenForLogin = nil - s.restoreGen++ mainWindow := s.mainWindow + delete(s.hiding, owner) + var restore []hideableWindow + kept := s.hiddenWindows[:0] + for _, hidden := range s.hiddenWindows { + if hidden.owner != owner { + kept = append(kept, hidden) + continue + } + if coverer, covered := s.coveringPopupLocked(hidden.win); covered { + hidden.owner = coverer + kept = append(kept, hidden) + continue + } + if hidden.win != nil { + restore = append(restore, hidden.win) + } + } + s.hiddenWindows = kept + s.restoreGen[owner]++ s.mu.Unlock() mainRestored := false - for _, w := range hidden { - if w == nil { - continue - } + for _, w := range restore { w.Show() - if w == mainWindow { + if s.isMainWindow(w, mainWindow) { mainRestored = true } } - if mainRestored && mainWindow != nil { - raiseToForeground(mainWindow) + if mainRestored { + s.raiseMainWindow(mainWindow) } } +func (s *WindowManager) coveringPopupLocked(w hideableWindow) (string, bool) { + if w == nil { + return "", false + } + for name := range s.hiding { + if name != w.Name() { + return name, true + } + } + return "", false +} + // getScreenBasedOnCursorPosition returns the cursor's display, falling back to the // main-window screen, then nil (OS-default placement). func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen { @@ -1169,6 +1371,48 @@ func errorDialogURL(title, message, command string) string { return startURL } +// appendGeneration adds the painted-report token to a dialog start URL, keeping any +// existing query params intact across the "/#/path?params" hash-router form. +func appendGeneration(startURL string, gen uint64) string { + sep := "?" + if strings.Contains(startURL, "?") { + sep = "&" + } + return startURL + sep + generationParam + "=" + strconv.FormatUint(gen, 10) +} + +// paintedGeneration reads the token a painted report carries back, returning 0 when the +// frontend sent none (an older bundle, or the main window, which is never stamped). +func paintedGeneration(data any) uint64 { + switch v := data.(type) { + case string: + gen, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return 0 + } + return gen + case float64: + return uint64(v) + case []any: + if len(v) == 0 { + return 0 + } + return paintedGeneration(v[0]) + default: + return 0 + } +} + +// sameWindow reports whether a hidden entry refers to w, comparing through the interface +// so a nil entry never matches a live window. +func sameWindow(hidden hideableWindow, w *application.WebviewWindow) bool { + if hidden == nil || w == nil { + return false + } + other, ok := hidden.(*application.WebviewWindow) + return ok && other == w +} + // u32ptr returns a pointer to v, for the optional *uint32 Wails theme fields. func u32ptr(v uint32) *uint32 { return &v } diff --git a/client/ui/services/windowmanager_test.go b/client/ui/services/windowmanager_test.go index 13c8548ab..890fba24f 100644 --- a/client/ui/services/windowmanager_test.go +++ b/client/ui/services/windowmanager_test.go @@ -17,9 +17,65 @@ func newTestWindowManager() *WindowManager { creating: map[string]bool{}, pendingOps: map[string][]windowOp{}, pendingClose: map[string]windowCloser{}, + restoreGen: map[string]uint64{}, + hiding: map[string]bool{}, + generation: map[string]uint64{}, } } +type fakeWindow struct { + name string + visible bool + shown int + hidden int +} + +func newFakeWindow(name string) *fakeWindow { + return &fakeWindow{name: name, visible: true} +} + +func (f *fakeWindow) Show() application.Window { + f.visible = true + f.shown++ + return nil +} + +func (f *fakeWindow) Hide() application.Window { + f.visible = false + f.hidden++ + return nil +} + +func (f *fakeWindow) IsVisible() bool { return f.visible } + +func (f *fakeWindow) Name() string { return f.name } + +type fakeDesktop struct { + windows []*fakeWindow + raised int +} + +func newFakeDesktop(s *WindowManager, windows ...*fakeWindow) *fakeDesktop { + d := &fakeDesktop{windows: windows} + s.allWindows = func() []hideableWindow { + all := make([]hideableWindow, 0, len(d.windows)) + for _, w := range d.windows { + all = append(all, w) + } + return all + } + s.raiseMain = func() { d.raised++ } + return d +} + +func ownersOf(hidden []hiddenWindow) []string { + owners := make([]string, 0, len(hidden)) + for _, h := range hidden { + owners = append(owners, h.owner) + } + return owners +} + func waitDone(t *testing.T, done <-chan struct{}, msg string) { t.Helper() select { @@ -339,12 +395,245 @@ func TestCloseRenewFlowDuringBrowserLoginCreationRestoresHiddenWindows(t *testin // Seeded after the call so the deferred closer, not CloseRenewFlow's own // immediate restore, is what has to drain it. A nil entry is skipped by // restoreHiddenWindows, so no Wails window is needed. - s.hiddenForLogin = []application.Window{nil} + s.hiddenWindows = []hiddenWindow{{owner: windowBrowserLogin}} return &application.WebviewWindow{} }, func(*application.WebviewWindow, bool) {}) require.Nil(t, s.browserLogin) - require.Empty(t, s.hiddenForLogin) + require.Empty(t, s.hiddenWindows) require.Empty(t, s.creating) require.Empty(t, s.pendingClose) } + +func TestHideOtherWindowsSkipsKeepNameAndInvisible(t *testing.T) { + main := newFakeWindow(windowMain) + settings := newFakeWindow(windowSettings) + settings.visible = false + popup := newFakeWindow(windowBrowserLogin) + s := newTestWindowManager() + newFakeDesktop(s, main, settings, popup) + + s.hideOtherWindows(windowBrowserLogin) + + require.False(t, main.visible) + require.Equal(t, 1, main.hidden) + require.Equal(t, 0, settings.hidden, "an already hidden window must not be recorded") + require.Equal(t, 0, popup.hidden, "the popup itself must stay visible") + require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows)) +} + +func TestInstallDuringLoginKeepsMainHiddenUntilLoginCloses(t *testing.T) { + main := newFakeWindow(windowMain) + login := newFakeWindow(windowBrowserLogin) + install := newFakeWindow(windowInstallProgress) + install.visible = false + s := newTestWindowManager() + d := newFakeDesktop(s, main, login, install) + + s.hideOtherWindows(windowBrowserLogin) + require.False(t, main.visible) + + install.visible = true + s.hideOtherWindows(windowInstallProgress) + require.False(t, login.visible, "the install popup hides the login popup") + + s.restoreHiddenWindows(windowInstallProgress) + require.True(t, login.visible, "the install popup restores the login popup it hid") + require.False(t, main.visible, "the main window stays hidden for the login popup") + require.Equal(t, 0, d.raised) + require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows)) + + s.restoreHiddenWindows(windowBrowserLogin) + require.True(t, main.visible) + require.Equal(t, 1, d.raised, "restoring the main window raises it above the SSO browser") + require.Empty(t, s.hiddenWindows) +} + +func TestLoginClosingUnderInstallHandsMainToInstall(t *testing.T) { + main := newFakeWindow(windowMain) + login := newFakeWindow(windowBrowserLogin) + install := newFakeWindow(windowInstallProgress) + install.visible = false + s := newTestWindowManager() + d := newFakeDesktop(s, main, login, install) + + s.hideOtherWindows(windowBrowserLogin) + install.visible = true + s.hideOtherWindows(windowInstallProgress) + require.False(t, main.visible) + require.False(t, login.visible, "the install popup hides the login popup") + + // The login popup closes while the install popup is still up: the main window it + // hid must not resurface under the install popup, it is handed over instead. + s.restoreHiddenWindows(windowBrowserLogin) + require.False(t, main.visible, "the install popup still covers the main window") + require.Equal(t, 0, d.raised) + require.Equal(t, []string{windowInstallProgress, windowInstallProgress}, ownersOf(s.hiddenWindows)) + + s.restoreHiddenWindows(windowInstallProgress) + require.True(t, main.visible, "the install popup restores the handed-over main window") + require.Equal(t, 1, d.raised) + require.Empty(t, s.hiddenWindows) +} + +func TestInstallClosingUnderLoginHandsMainToLogin(t *testing.T) { + main := newFakeWindow(windowMain) + install := newFakeWindow(windowInstallProgress) + login := newFakeWindow(windowBrowserLogin) + login.visible = false + s := newTestWindowManager() + d := newFakeDesktop(s, main, install, login) + + s.hideOtherWindows(windowInstallProgress) + login.visible = true + s.hideOtherWindows(windowBrowserLogin) + require.False(t, install.visible, "the login popup hides the install popup") + + s.restoreHiddenWindows(windowInstallProgress) + require.False(t, main.visible, "the login popup still covers the main window") + require.Equal(t, 0, d.raised) + require.Equal(t, []string{windowBrowserLogin, windowBrowserLogin}, ownersOf(s.hiddenWindows)) + + s.restoreHiddenWindows(windowBrowserLogin) + require.True(t, main.visible) + require.Equal(t, 1, d.raised) + require.Empty(t, s.hiddenWindows) +} + +func TestPopupClosingReshowsTheCoveringPopupItself(t *testing.T) { + main := newFakeWindow(windowMain) + install := newFakeWindow(windowInstallProgress) + login := newFakeWindow(windowBrowserLogin) + login.visible = false + s := newTestWindowManager() + d := newFakeDesktop(s, main, install, login) + + s.hideOtherWindows(windowInstallProgress) + login.visible = true + s.hideOtherWindows(windowBrowserLogin) + + // The login popup hid the install popup itself; closing the login popup must bring + // the install popup back rather than hand it over to its own owner. + s.restoreHiddenWindows(windowBrowserLogin) + require.True(t, install.visible, "a popup is never handed over to itself") + require.False(t, main.visible, "the main window stays with the install popup") + require.Equal(t, 0, d.raised) + require.Equal(t, []string{windowInstallProgress}, ownersOf(s.hiddenWindows)) +} + +func TestRestoreHiddenWindowsUnknownOwnerKeepsEverything(t *testing.T) { + main := newFakeWindow(windowMain) + s := newTestWindowManager() + d := newFakeDesktop(s, main) + s.hideOtherWindows(windowBrowserLogin) + + s.restoreHiddenWindows(windowWelcome) + + require.False(t, main.visible) + require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows)) + require.Equal(t, 0, d.raised) +} + +func TestRestoreHiddenWindowsWithoutMainDoesNotRaise(t *testing.T) { + settings := newFakeWindow(windowSettings) + s := newTestWindowManager() + d := newFakeDesktop(s, settings) + s.hideOtherWindows(windowBrowserLogin) + + s.restoreHiddenWindows(windowBrowserLogin) + + require.True(t, settings.visible) + require.Equal(t, 0, d.raised) +} + +func TestRestoreHiddenWindowsEmptyIsNoop(t *testing.T) { + s := newTestWindowManager() + require.NotPanics(t, func() { s.restoreHiddenWindows(windowBrowserLogin) }) + require.Empty(t, s.hiddenWindows) +} + +func TestHideOtherWindowsRacingOwnRestoreReshowsWhatItHid(t *testing.T) { + main := newFakeWindow(windowMain) + s := newTestWindowManager() + d := newFakeDesktop(s, main) + enumerate := s.allWindows + // A restore for the same owner lands between the generation snapshot and the record. + s.allWindows = func() []hideableWindow { + s.restoreHiddenWindows(windowBrowserLogin) + return enumerate() + } + + s.hideOtherWindows(windowBrowserLogin) + + require.True(t, main.visible) + require.Equal(t, 1, main.hidden) + require.Empty(t, s.hiddenWindows) + require.Equal(t, 0, d.raised) +} + +func TestHideOtherWindowsIgnoresRestoreOfAnotherOwner(t *testing.T) { + main := newFakeWindow(windowMain) + s := newTestWindowManager() + newFakeDesktop(s, main) + enumerate := s.allWindows + s.allWindows = func() []hideableWindow { + s.restoreHiddenWindows(windowInstallProgress) + return enumerate() + } + + s.hideOtherWindows(windowBrowserLogin) + + require.False(t, main.visible) + require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows)) +} + +func TestRestoringCloserRestoresOnlyItsOwner(t *testing.T) { + main := newFakeWindow(windowMain) + s := newTestWindowManager() + newFakeDesktop(s, main) + s.hideOtherWindows(windowBrowserLogin) + s.hiddenWindows = append(s.hiddenWindows, hiddenWindow{owner: windowInstallProgress}) + + s.restoringCloser(windowBrowserLogin)(&application.WebviewWindow{}) + + require.True(t, main.visible) + require.Equal(t, []string{windowInstallProgress}, ownersOf(s.hiddenWindows)) +} + +func TestStampGenerationTracksLatestPerWindow(t *testing.T) { + s := newTestWindowManager() + + first := s.stampGeneration(windowBrowserLogin, "/#/dialog/browser-login") + require.Equal(t, "/#/dialog/browser-login?gen=1", first) + require.True(t, s.matchesGeneration(windowBrowserLogin, 1)) + + second := s.stampGeneration(windowBrowserLogin, "/#/dialog/browser-login?uri=x") + require.Equal(t, "/#/dialog/browser-login?uri=x&gen=2", second) + require.False(t, s.matchesGeneration(windowBrowserLogin, 1)) + require.True(t, s.matchesGeneration(windowBrowserLogin, 2)) +} + +func TestMatchesGenerationUntrackedWindowAccepts(t *testing.T) { + s := newTestWindowManager() + require.True(t, s.matchesGeneration(windowMain, 0)) +} + +func TestPaintedGeneration(t *testing.T) { + tests := []struct { + name string + data any + want uint64 + }{ + {"string", "7", 7}, + {"float", float64(7), 7}, + {"slice", []any{"7"}, 7}, + {"empty slice", []any{}, 0}, + {"unparsable", "abc", 0}, + {"nil", nil, 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, paintedGeneration(tc.data)) + }) + } +} From ad03081e1fb8a65f052a6795d5812a5476a7c16c Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 5 Oct 2026 15:37:06 +0200 Subject: [PATCH 101/209] [management] Refresh only affected peers on IPv6 settings changes (#8051) Account settings changes refreshed every peer, even an IPv6 group toggle that re-addresses a few, and the refresh goroutine got the request context, so it could be cancelled when the handler returned. Group paths that reconcile IPv6 addresses only walked the changed group, so peers reaching a re-addressed peer through its other groups missed the new address. The IPv6 reconcile now returns the peers whose address changed and callers pass them as changed peers. An IPv6-only settings change dispatches affected peers, adding every IPv6 holder on a range change since the interface prefix comes from the range. IPv4 range and account-wide changes keep the full refresh with a detached context. --- management/server/account.go | 115 +++++++-- management/server/affected_peers_ipv6_test.go | 243 ++++++++++++++++++ management/server/affected_peers_user_test.go | 10 +- management/server/group.go | 46 +++- management/server/user.go | 4 +- 5 files changed, 372 insertions(+), 46 deletions(-) create mode 100644 management/server/affected_peers_ipv6_test.go diff --git a/management/server/account.go b/management/server/account.go index 1c09c8252..038c5d8db 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -334,6 +334,9 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco var groupChangesAffectPeers bool var reloadReverseProxy bool var effectiveOldNetworkRange netip.Prefix + var ipv6Changed bool + var ipv6Snap *affectedpeers.Snapshot + var ipv6Change affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { var groupsUpdated bool @@ -379,10 +382,10 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco } if ipv6SettingsChanged(oldSettings, newSettings) { - if err = am.updatePeerIPv6Addresses(ctx, transaction, accountID, newSettings); err != nil { + if ipv6Change, err = am.applyIPv6SettingsChange(ctx, transaction, accountID, oldSettings, newSettings); err != nil { return err } - updateAccountPeers = true + ipv6Changed = true } if oldSettings.RoutingPeerDNSResolutionEnabled != newSettings.RoutingPeerDNSResolutionEnabled || @@ -419,12 +422,20 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco return err } - if updateAccountPeers || groupsUpdated { + if updateAccountPeers || groupsUpdated || ipv6Changed { if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { return err } } + // A full account refresh already covers the IPv6 change, so the affected-peers + // snapshot is only needed when nothing account-wide changed. + if ipv6Changed && !updateAccountPeers && !groupChangesAffectPeers { + if ipv6Snap, err = affectedpeers.Load(ctx, transaction, accountID, ipv6Change); err != nil { + return fmt.Errorf("load affected peers: %w", err) + } + } + return nil }) if err != nil { @@ -486,13 +497,34 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco } } - if updateAccountPeers || extraSettingsChanged || groupChangesAffectPeers { - go am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceAccountSettings, Operation: types.UpdateOperationUpdate}) + switch { + case updateAccountPeers || extraSettingsChanged || groupChangesAffectPeers: + go am.UpdateAccountPeers(context.WithoutCancel(ctx), accountID, types.UpdateReason{Resource: types.UpdateResourceAccountSettings, Operation: types.UpdateOperationUpdate}) + case ipv6Snap != nil: + am.ExpandAndUpdateAffected(ctx, accountID, ipv6Snap, ipv6Change) } return newSettings, nil } +// applyIPv6SettingsChange reconciles peer IPv6 addresses for new IPv6 settings and +// returns the affected-peers change: peers whose address changed refresh together +// with every peer that reaches them. On a range change every peer holding an address +// also refreshes itself, since its interface prefix comes from the account range even +// when its address stays inside the new one. +func (am *DefaultAccountManager) applyIPv6SettingsChange(ctx context.Context, transaction store.Store, accountID string, oldSettings, newSettings *types.Settings) (affectedpeers.Change, error) { + result, err := am.updatePeerIPv6Addresses(ctx, transaction, accountID, newSettings) + if err != nil { + return affectedpeers.Change{}, err + } + + change := affectedpeers.Change{ChangedPeerIDs: result.changed} + if oldSettings.NetworkRangeV6 != newSettings.NetworkRangeV6 { + change.OutputPeerIDs = result.withIPv6 + } + return change, nil +} + func ipv6SettingsChanged(old, updated *types.Settings) bool { if old.NetworkRangeV6 != updated.NetworkRangeV6 { return true @@ -1742,9 +1774,11 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth change.LinkGroups = allGroupChanges - if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, userAuth.AccountId, allGroupChanges); err != nil { + ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, userAuth.AccountId, allGroupChanges) + if err != nil { return fmt.Errorf("reconcile IPv6 for group changes: %w", err) } + change.ChangedPeerIDs = append(change.ChangedPeerIDs, ipv6Changed...) if err = transaction.IncrementNetworkSerial(ctx, userAuth.AccountId); err != nil { return fmt.Errorf("error incrementing network serial: %w", err) @@ -2334,7 +2368,8 @@ func (am *DefaultAccountManager) propagateUserGroupMemberships(ctx context.Conte return false, false, err } - if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, updatedGroups); err != nil { + ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, updatedGroups) + if err != nil { return false, false, fmt.Errorf("reconcile IPv6 for group changes: %w", err) } @@ -2343,7 +2378,7 @@ func (am *DefaultAccountManager) propagateUserGroupMemberships(ctx context.Conte return false, false, fmt.Errorf("error checking if group changes affect peers: %w", err) } - return len(updatedGroups) > 0, peersAffected, nil + return len(updatedGroups) > 0, peersAffected || len(ipv6Changed) > 0, nil } // propagateAutoGroupsForUsers adds each user's peers to their AutoGroups where not already present. @@ -2440,56 +2475,78 @@ func (am *DefaultAccountManager) checkIPv6Collision(ctx context.Context, transac return nil } -func (am *DefaultAccountManager) updatePeerIPv6Addresses(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings) error { +// ipv6Reassignment reports the outcome of an IPv6 address reconciliation. +type ipv6Reassignment struct { + // changed are the peers whose IPv6 address was assigned, removed or reallocated. + changed []string + // withIPv6 are all peers holding an IPv6 address after the reconciliation. + withIPv6 []string +} + +func (am *DefaultAccountManager) updatePeerIPv6Addresses(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings) (ipv6Reassignment, error) { peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "", "") if err != nil { - return fmt.Errorf("get peers: %w", err) + return ipv6Reassignment{}, fmt.Errorf("get peers: %w", err) } network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthUpdate, accountID) if err != nil { - return fmt.Errorf("get network: %w", err) + return ipv6Reassignment{}, fmt.Errorf("get network: %w", err) } if err := am.ensureIPv6Subnet(ctx, transaction, accountID, settings, network); err != nil { - return err + return ipv6Reassignment{}, err } allowedPeers, err := am.buildIPv6AllowedPeers(ctx, transaction, accountID, settings) if err != nil { - return err + return ipv6Reassignment{}, err } v6Prefix, err := netip.ParsePrefix(network.NetV6.String()) if err != nil { - return fmt.Errorf("parse IPv6 prefix: %w", err) + return ipv6Reassignment{}, fmt.Errorf("parse IPv6 prefix: %w", err) } - if err := am.assignPeerIPv6Addresses(ctx, transaction, accountID, peers, network, allowedPeers, v6Prefix); err != nil { - return err + changed, err := am.assignPeerIPv6Addresses(ctx, transaction, accountID, peers, network, allowedPeers, v6Prefix) + if err != nil { + return ipv6Reassignment{}, err } - log.WithContext(ctx).Infof("updated IPv6 addresses for %d peers in account %s (groups=%d)", - len(peers), accountID, len(settings.IPv6EnabledGroups)) + result := ipv6Reassignment{changed: changed} + for _, peer := range peers { + if peer.IPv6.IsValid() { + result.withIPv6 = append(result.withIPv6, peer.ID) + } + } - return nil + log.WithContext(ctx).Infof("updated IPv6 addresses for %d of %d peers in account %s (groups=%d)", + len(changed), len(peers), accountID, len(settings.IPv6EnabledGroups)) + + return result, nil } // reconcileIPv6ForGroupChanges checks whether the given group IDs overlap with // the account's IPv6EnabledGroups. If they do, it runs a full IPv6 address // reconciliation so that peers gaining or losing membership in an IPv6-enabled -// group get their addresses assigned or removed. -func (am *DefaultAccountManager) reconcileIPv6ForGroupChanges(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) error { +// group get their addresses assigned or removed. It returns the peers whose IPv6 +// address changed, which callers pass as changed peers so every peer that can +// reach them refreshes. +func (am *DefaultAccountManager) reconcileIPv6ForGroupChanges(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) ([]string, error) { settings, err := transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { - return fmt.Errorf("get account settings: %w", err) + return nil, fmt.Errorf("get account settings: %w", err) } if !ipv6ReconcileNeeded(settings, groupIDs) { - return nil + return nil, nil } - return am.updatePeerIPv6Addresses(ctx, transaction, accountID, settings) + result, err := am.updatePeerIPv6Addresses(ctx, transaction, accountID, settings) + if err != nil { + return nil, err + } + return result.changed, nil } // ipv6ReconcileNeeded reports whether changes to the given groups trigger an IPv6 @@ -2528,7 +2585,7 @@ func (am *DefaultAccountManager) assignPeerIPv6Addresses( ctx context.Context, transaction store.Store, accountID string, peers []*nbpeer.Peer, network *types.Network, allowedPeers map[string]struct{}, v6Prefix netip.Prefix, -) error { +) ([]string, error) { takenV6 := make(map[netip.Addr]struct{}) for _, peer := range peers { if _, ok := allowedPeers[peer.ID]; ok && peer.IPv6.IsValid() && network.NetV6.Contains(peer.IPv6.AsSlice()) { @@ -2536,6 +2593,7 @@ func (am *DefaultAccountManager) assignPeerIPv6Addresses( } } + var changed []string for _, peer := range peers { _, allowed := allowedPeers[peer.ID] oldIPv6 := peer.IPv6 @@ -2545,7 +2603,7 @@ func (am *DefaultAccountManager) assignPeerIPv6Addresses( } else if !peer.IPv6.IsValid() || !network.NetV6.Contains(peer.IPv6.AsSlice()) { newIP, err := allocateIPv6WithRetry(v6Prefix, takenV6, peer.ID) if err != nil { - return err + return nil, err } peer.IPv6 = newIP } @@ -2555,10 +2613,11 @@ func (am *DefaultAccountManager) assignPeerIPv6Addresses( } if err := transaction.SavePeer(ctx, accountID, peer); err != nil { - return fmt.Errorf("save peer %s: %w", peer.ID, err) + return nil, fmt.Errorf("save peer %s: %w", peer.ID, err) } + changed = append(changed, peer.ID) } - return nil + return changed, nil } func allocateIPv6WithRetry(prefix netip.Prefix, taken map[netip.Addr]struct{}, peerID string) (netip.Addr, error) { diff --git a/management/server/affected_peers_ipv6_test.go b/management/server/affected_peers_ipv6_test.go new file mode 100644 index 000000000..c64360016 --- /dev/null +++ b/management/server/affected_peers_ipv6_test.go @@ -0,0 +1,243 @@ +package server + +import ( + "context" + "net/netip" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +const ( + ipv6GroupA = "ipv6-grp-a" + ipv6GroupB = "ipv6-grp-b" + ipv6GroupC = "ipv6-grp-c" + ipv6GroupD = "ipv6-grp-d" +) + +// ipv6AffectedTest holds three peers: peer1 in group A, peer2 in group B, peer3 in +// group C, with a single A<->B policy. peer3 is unrelated to peer1 and peer2. Group D +// is empty and referenced by nothing. +type ipv6AffectedTest struct { + manager *DefaultAccountManager + accountID string + peer1, peer2, peer3 *nbpeer.Peer + updMsg1, updMsg2, updMsg3 <-chan *network_map.UpdateMessage +} + +func setupIPv6AffectedTest(t *testing.T, ipv6Groups []string) *ipv6AffectedTest { + t.Helper() + + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) + } + + for _, g := range []*types.Group{ + {ID: ipv6GroupA, Name: "IPv6-A", Peers: []string{peer1.ID}}, + {ID: ipv6GroupB, Name: "IPv6-B", Peers: []string{peer2.ID}}, + {ID: ipv6GroupC, Name: "IPv6-C", Peers: []string{peer3.ID}}, + {ID: ipv6GroupD, Name: "IPv6-D"}, + } { + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, g)) + } + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{{ + Enabled: true, + Sources: []string{ipv6GroupA}, + Destinations: []string{ipv6GroupB}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }}, + }, true) + require.NoError(t, err) + + // New accounts enable IPv6 for the All group; start from the requested groups. + updateIPv6TestSettings(t, manager, accountID, func(s *types.Settings) { + s.IPv6EnabledGroups = ipv6Groups + }) + + tc := &ipv6AffectedTest{ + manager: manager, + accountID: accountID, + peer1: peer1, + peer2: peer2, + peer3: peer3, + } + tc.updMsg1 = updateManager.CreateChannel(ctx, peer1.ID) + tc.updMsg2 = updateManager.CreateChannel(ctx, peer2.ID) + tc.updMsg3 = updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + // The setup changes above dispatch asynchronously and can land after the + // channels open, so drop them before the test acts. + drainPeerUpdates(tc.updMsg1) + drainPeerUpdates(tc.updMsg2) + drainPeerUpdates(tc.updMsg3) + + return tc +} + +// updateIPv6TestSettings applies mutate to a copy of the current settings, so only +// the mutated fields differ from what is stored. +func updateIPv6TestSettings(t *testing.T, manager *DefaultAccountManager, accountID string, mutate func(*types.Settings)) { + t.Helper() + ctx := context.Background() + + current, err := manager.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + + updated := current.Copy() + mutate(updated) + + _, err = manager.UpdateAccountSettings(ctx, accountID, userID, updated) + require.NoError(t, err) +} + +func (tc *ipv6AffectedTest) peerIPv6(t *testing.T, peerID string) netip.Addr { + t.Helper() + peer, err := tc.manager.Store.GetPeerByID(context.Background(), store.LockingStrengthNone, tc.accountID, peerID) + require.NoError(t, err) + return peer.IPv6 +} + +func TestAffectedPeers_IPv6GroupEnabled_RefreshesOnlyReachablePeers(t *testing.T) { + tc := setupIPv6AffectedTest(t, nil) + + updateIPv6TestSettings(t, tc.manager, tc.accountID, func(s *types.Settings) { + s.IPv6EnabledGroups = []string{ipv6GroupA} + }) + require.True(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should get an IPv6 address") + + peerShouldReceiveUpdate(t, tc.updMsg1) + peerShouldReceiveUpdate(t, tc.updMsg2) + peerShouldNotReceiveUpdate(t, tc.updMsg3) +} + +func TestAffectedPeers_IPv6GroupDisabled_RefreshesOnlyReachablePeers(t *testing.T) { + tc := setupIPv6AffectedTest(t, []string{ipv6GroupA}) + require.True(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should start with an IPv6 address") + + updateIPv6TestSettings(t, tc.manager, tc.accountID, func(s *types.Settings) { + s.IPv6EnabledGroups = []string{} + }) + require.False(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should lose its IPv6 address") + + peerShouldReceiveUpdate(t, tc.updMsg1) + peerShouldReceiveUpdate(t, tc.updMsg2) + peerShouldNotReceiveUpdate(t, tc.updMsg3) +} + +// Widening the IPv6 range keeps peer addresses, but each holder's interface prefix +// comes from the range, so holders refresh while peers that only reach them do not. +func TestAffectedPeers_IPv6RangeWidened_RefreshesAddressHolders(t *testing.T) { + tc := setupIPv6AffectedTest(t, []string{ipv6GroupA}) + oldIPv6 := tc.peerIPv6(t, tc.peer1.ID) + require.True(t, oldIPv6.IsValid(), "peer1 should start with an IPv6 address") + + // The range is allocated on the account network; settings may leave it empty. + network, err := tc.manager.Store.GetAccountNetwork(context.Background(), store.LockingStrengthNone, tc.accountID) + require.NoError(t, err) + current := prefixFromIPNet(network.NetV6) + require.True(t, current.IsValid(), "account should have an IPv6 range") + widened := netip.PrefixFrom(current.Addr(), current.Bits()-8).Masked() + + updateIPv6TestSettings(t, tc.manager, tc.accountID, func(s *types.Settings) { + s.NetworkRangeV6 = widened + }) + require.Equal(t, oldIPv6, tc.peerIPv6(t, tc.peer1.ID), "peer1 should keep its address inside the widened range") + + peerShouldReceiveUpdate(t, tc.updMsg1) + peerShouldNotReceiveUpdate(t, tc.updMsg2) + peerShouldNotReceiveUpdate(t, tc.updMsg3) +} + +func TestAffectedPeers_IPv4RangeChange_RefreshesWholeAccount(t *testing.T) { + tc := setupIPv6AffectedTest(t, nil) + + updateIPv6TestSettings(t, tc.manager, tc.accountID, func(s *types.Settings) { + s.NetworkRange = netip.MustParsePrefix("100.70.0.0/16") + }) + + peerShouldReceiveUpdate(t, tc.updMsg1) + peerShouldReceiveUpdate(t, tc.updMsg2) + peerShouldReceiveUpdate(t, tc.updMsg3) +} + +func TestAffectedPeers_IPv6WithAccountWideChange_RefreshesWholeAccount(t *testing.T) { + tc := setupIPv6AffectedTest(t, nil) + + updateIPv6TestSettings(t, tc.manager, tc.accountID, func(s *types.Settings) { + s.IPv6EnabledGroups = []string{ipv6GroupA} + s.LazyConnectionEnabled = !s.LazyConnectionEnabled + }) + + peerShouldReceiveUpdate(t, tc.updMsg1) + peerShouldReceiveUpdate(t, tc.updMsg2) + peerShouldReceiveUpdate(t, tc.updMsg3) +} + +// Joining an IPv6-enabled group that no policy references gives peer1 an address. +// peer2 reaches peer1 through group A, not through the joined group, and must still +// learn the new address. +func TestAffectedPeers_GroupAddPeerIPv6_RefreshesPeersReachingThroughOtherGroups(t *testing.T) { + tc := setupIPv6AffectedTest(t, []string{ipv6GroupD}) + + require.NoError(t, tc.manager.GroupAddPeer(context.Background(), tc.accountID, ipv6GroupD, tc.peer1.ID)) + require.True(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should get an IPv6 address") + + peerShouldReceiveUpdate(t, tc.updMsg1) + peerShouldReceiveUpdate(t, tc.updMsg2) + peerShouldNotReceiveUpdate(t, tc.updMsg3) +} + +func TestAffectedPeers_UpdateGroupIPv6_RefreshesPeersReachingThroughOtherGroups(t *testing.T) { + tc := setupIPv6AffectedTest(t, []string{ipv6GroupD}) + + require.NoError(t, tc.manager.UpdateGroup(context.Background(), tc.accountID, userID, &types.Group{ + ID: ipv6GroupD, + Name: "IPv6-D", + Peers: []string{tc.peer1.ID}, + })) + require.True(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should get an IPv6 address") + + peerShouldReceiveUpdate(t, tc.updMsg1) + peerShouldReceiveUpdate(t, tc.updMsg2) + peerShouldNotReceiveUpdate(t, tc.updMsg3) +} + +// Deleting an IPv6-enabled group removes its members' addresses after the +// pre-delete snapshot was taken. +func TestAffectedPeers_DeleteIPv6Group_RefreshesFormerMembersAndReachablePeers(t *testing.T) { + tc := setupIPv6AffectedTest(t, []string{ipv6GroupD}) + ctx := context.Background() + + require.NoError(t, tc.manager.GroupAddPeer(ctx, tc.accountID, ipv6GroupD, tc.peer1.ID)) + require.True(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should get an IPv6 address") + drainPeerUpdates(tc.updMsg1) + drainPeerUpdates(tc.updMsg2) + drainPeerUpdates(tc.updMsg3) + + require.NoError(t, tc.manager.DeleteGroup(ctx, tc.accountID, userID, ipv6GroupD)) + require.False(t, tc.peerIPv6(t, tc.peer1.ID).IsValid(), "peer1 should lose its IPv6 address") + + peerShouldReceiveUpdate(t, tc.updMsg1) + peerShouldReceiveUpdate(t, tc.updMsg2) + peerShouldNotReceiveUpdate(t, tc.updMsg3) +} diff --git a/management/server/affected_peers_user_test.go b/management/server/affected_peers_user_test.go index c0dbbb84f..3d73bbed0 100644 --- a/management/server/affected_peers_user_test.go +++ b/management/server/affected_peers_user_test.go @@ -108,11 +108,13 @@ func TestAffectedPeers_SaveUser_OnlyAffectedPeersUpdated(t *testing.T) { }) t.Run("auto group change reassigning IPv6 refreshes the changed peers and their observers", func(t *testing.T) { - account, err := manager.Store.GetAccount(ctx, accountID) - require.NoError(t, err) - account.Settings.IPv6EnabledGroups = []string{"ug-v6"} - require.NoError(t, manager.Store.SaveAccount(ctx, account)) require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-v6", Name: "ug-v6"})) + // Apply through the settings API so the reconciliation that strips the other + // peers' addresses happens here, leaving the target as the only peer the + // user update reassigns. + updateIPv6TestSettings(t, manager, accountID, func(s *types.Settings) { + s.IPv6EnabledGroups = []string{"ug-v6"} + }) drainPeerUpdates(updTarget) drainPeerUpdates(upd2) diff --git a/management/server/group.go b/management/server/group.go index 88295e2f6..8d91df3ab 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -166,9 +166,11 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use return err } - if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID}); err != nil { + ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID}) + if err != nil { return err } + change.ChangedPeerIDs = ipv6Changed // A membership change does not alter which entities reference the group, so // the dependency walk runs once against the post-change snapshot. The new @@ -321,7 +323,7 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us var globalErr error for _, newGroup := range groups { change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}} - events, snap, err := am.updateSingleGroup(ctx, accountID, userID, newGroup, change) + events, snap, change, err := am.updateSingleGroup(ctx, accountID, userID, newGroup, change) if err != nil { log.WithContext(ctx).Errorf("failed to update group %s: %v", newGroup.ID, err) if len(groups) == 1 { @@ -344,7 +346,7 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us return globalErr } -func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountID, userID string, newGroup *types.Group, change affectedpeers.Change) ([]func(), *affectedpeers.Snapshot, error) { +func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountID, userID string, newGroup *types.Group, change affectedpeers.Change) ([]func(), *affectedpeers.Snapshot, affectedpeers.Change, error) { var events []func() var snap *affectedpeers.Snapshot err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { @@ -364,9 +366,11 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI return err } - if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID}); err != nil { + ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID}) + if err != nil { return err } + change.ChangedPeerIDs = ipv6Changed if err := transaction.IncrementNetworkSerial(ctx, accountID); err != nil { return err @@ -377,7 +381,7 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI snap, err = affectedpeers.Load(ctx, transaction, accountID, change) return err }) - return events, snap, err + return events, snap, change, err } // prepareGroupEvents prepares a list of event functions to be stored. @@ -480,8 +484,8 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us var allErrors error var groupIDsToDelete []string var deletedGroups []*types.Group - var snap *affectedpeers.Snapshot - var change affectedpeers.Change + var snap, ipv6Snap *affectedpeers.Snapshot + var change, ipv6Change affectedpeers.Change extraSettings, err := am.settingsManager.GetExtraSettings(ctx, accountID) if err != nil { @@ -510,10 +514,20 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us return err } - if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, groupIDsToDelete); err != nil { + ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, groupIDsToDelete) + if err != nil { return err } + // Members of a deleted IPv6-enabled group lose their address, which the + // pre-delete snapshot cannot see, so they are resolved post-delete. + if len(ipv6Changed) > 0 { + ipv6Change = affectedpeers.Change{ChangedPeerIDs: ipv6Changed} + if ipv6Snap, err = affectedpeers.Load(ctx, transaction, accountID, ipv6Change); err != nil { + return err + } + } + return transaction.IncrementNetworkSerial(ctx, accountID) }) if err != nil { @@ -524,7 +538,7 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us am.StoreEvent(ctx, userID, group.ID, accountID, activity.GroupDeleted, group.EventMeta()) } - am.ExpandAndUpdateAffected(ctx, accountID, snap, change) + go am.dispatchAffected(ctx, accountID, []*affectedpeers.Snapshot{snap, ipv6Snap}, []affectedpeers.Change{change, ipv6Change}) return allErrors } @@ -564,11 +578,14 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr return err } - if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil { + ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}) + if err != nil { return err } + // A peer whose IPv6 address changed is visible to every peer that reaches it + // through any of its groups, not only through this one. + change.ChangedPeerIDs = ipv6Changed - var err error if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -634,11 +651,14 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, return err } - if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil { + ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}) + if err != nil { return err } + // A peer whose IPv6 address changed is visible to every peer that reaches it + // through any of its groups, not only through this one. + change.ChangedPeerIDs = ipv6Changed - var err error if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } diff --git a/management/server/user.go b/management/server/user.go index 3510a624b..5f29f4df7 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -861,9 +861,11 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact allGroupChanges := slices.Concat(removedGroups, addedGroups) change.LinkGroups = allGroupChanges - if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, allGroupChanges); err != nil { + ipv6Changed, err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, allGroupChanges) + if err != nil { return change, nil, nil, nil, fmt.Errorf("reconcile IPv6 for group changes: %w", err) } + change.ChangedPeerIDs = append(change.ChangedPeerIDs, ipv6Changed...) } userEventsToAdd := am.prepareUserUpdateEvents(ctx, updatedUser.AccountID, initiatorUserId, oldUser, updatedUser, transferredOwnerRole, isNewUser, removedGroups, addedGroups, transaction) From f175e402c7d2b7a4caa1315310206c6434de2fb3 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:59:31 +0200 Subject: [PATCH 102/209] [client] stop offering to every peer when the relay transport drops (#7092) * [client] stop offering to every peer when the relay transport drops The relay transport is shared: one connection per relay server carries the streams of every peer using it. When it drops, each of those peers gets a Disconnected verdict from evalConnStatus even when ICE is still carrying its traffic, because peerUsesRelay comes from HasRelayAddress(), which only reports that management offered relay servers, not that we are connected to one. The guard answers Disconnected with the aggressive retry, so every peer starts sending offers over signal for a transport that no offer can restore: the relay client's own guard is what reconnects it. Feed relayManager.Ready() into the status inputs and return PartiallyConnected when ICE is up and the missing side is the shared transport. That is the existing "one path works, the other does not" branch, which retries three times and then hourly instead of walking the exponential ladder forever. Peers are not left waiting for the hourly tick: when the transport comes back, Manager.onServerConnected notifies srWatcher, the guard resets the ticker to 800ms and iceState.reset() clears the hourly mode. The verdict is unchanged when the transport is up but this peer is unreachable over relay - it may have moved to another server, and only an offer carries its new relay address - and in force-relay mode, where relay is the only transport. * Renaming according to actual meanings * Don't consider an in progress ICE as "partially connected" when the relay is not.. * Aligns tests * Address wrong comments --- client/internal/peer/conn.go | 28 ++++--- client/internal/peer/conn_status.go | 15 ++-- client/internal/peer/conn_status_eval_test.go | 83 ++++++++++++++++--- client/internal/peer/guard/guard.go | 8 +- client/internal/peer/worker_relay.go | 4 + 5 files changed, 104 insertions(+), 34 deletions(-) diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index d73144773..17823e043 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -828,7 +828,8 @@ func (conn *Conn) evalStatus() ConnStatus { // // The result is a tri-state: // - ConnStatusConnected: all available transports are up -// - ConnStatusPartiallyConnected: relay is up but ICE is still pending/reconnecting +// - ConnStatusPartiallyConnected: one transport carries the traffic and the other does +// not: relay up with ICE down, or ICE up with the shared relay transport down // - ConnStatusDisconnected: no working transport func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) { defer func() { @@ -845,13 +846,14 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) { } return evalConnStatus(connStatusInputs{ - forceRelay: IsForceRelayed(), - peerUsesRelay: conn.workerRelay.IsRelayConnectionSupportedWithPeer(), - relayConnected: conn.statusRelay.Get() == worker.StatusConnected, - remoteSupportsICE: conn.handshaker.RemoteICESupported(), - iceWorkerCreated: iceWorkerCreated, - iceStatusConnecting: conn.statusICE.Get() != worker.StatusDisconnected, - iceInProgress: iceInProgress, + forceRelay: IsForceRelayed(), + peerUsesRelay: conn.workerRelay.IsRelayConnectionSupportedWithPeer(), + relayConnected: conn.statusRelay.Get() == worker.StatusConnected, + relayTransportConnected: conn.workerRelay.IsTransportConnected(), + remoteSupportsICE: conn.handshaker.RemoteICESupported(), + iceWorkerCreated: iceWorkerCreated, + iceStatusConnected: conn.statusICE.Get() == worker.StatusConnected, + iceInProgress: iceInProgress, }) } @@ -1060,19 +1062,21 @@ func evalConnStatus(in connStatusInputs) guard.ConnStatus { return boolToConnStatus(relayUsedAndUp) } - // ICE counts as "up" when the status is anything other than Disconnected, OR - // when a negotiation is currently in progress (so we don't spam offers while one is in flight). - iceUp := in.iceStatusConnecting || in.iceInProgress + // ICE counts as "running" when either connected or attempting to connect. + iceRunning := in.iceStatusConnected || in.iceInProgress // Relay side is acceptable if the peer doesn't rely on relay, or relay is connected. relayOK := !in.peerUsesRelay || in.relayConnected switch { - case iceUp && relayOK: + case iceRunning && relayOK: return guard.ConnStatusConnected case relayUsedAndUp: // Relay is up but ICE is down — partially connected. return guard.ConnStatusPartiallyConnected + case in.iceStatusConnected && !in.relayTransportConnected: + // ICE is up and the shared relay transport is down — offers cannot restore it. + return guard.ConnStatusPartiallyConnected default: return guard.ConnStatusDisconnected } diff --git a/client/internal/peer/conn_status.go b/client/internal/peer/conn_status.go index d6ad37b70..acf271534 100644 --- a/client/internal/peer/conn_status.go +++ b/client/internal/peer/conn_status.go @@ -17,13 +17,14 @@ const ( // tri-state connection classification. Extracted so the decision logic can be unit-tested // without constructing full Worker/Handshaker objects. type connStatusInputs struct { - forceRelay bool // NB_FORCE_RELAY or JS/WASM - peerUsesRelay bool // remote peer advertises relay support AND local has relay - relayConnected bool // statusRelay reports Connected (independent of whether peer uses relay) - remoteSupportsICE bool // remote peer sent ICE credentials - iceWorkerCreated bool // local WorkerICE exists (false in force-relay mode) - iceStatusConnecting bool // statusICE is anything other than Disconnected - iceInProgress bool // a negotiation is currently in flight + forceRelay bool // NB_FORCE_RELAY or JS/WASM + peerUsesRelay bool // remote peer advertises relay support AND local has relay + relayConnected bool // statusRelay reports Connected (independent of whether peer uses relay) + relayTransportConnected bool // the relay transport shared by all peers on that server is up + remoteSupportsICE bool // remote peer sent ICE credentials + iceWorkerCreated bool // local WorkerICE exists (false in force-relay mode) + iceStatusConnected bool // statusICE reports Connected + iceInProgress bool // a negotiation is currently in flight } // ConnStatus describe the status of a peer's connection diff --git a/client/internal/peer/conn_status_eval_test.go b/client/internal/peer/conn_status_eval_test.go index 66393cafe..a239196dc 100644 --- a/client/internal/peer/conn_status_eval_test.go +++ b/client/internal/peer/conn_status_eval_test.go @@ -30,6 +30,21 @@ func TestEvalConnStatus_ForceRelay(t *testing.T) { }, want: guard.ConnStatusDisconnected, }, + { + name: "force relay, relay up but the shared transport reports down", + in: connStatusInputs{ + forceRelay: true, + peerUsesRelay: true, + relayConnected: true, + relayTransportConnected: false, + // The ICE inputs are set so that the force-relay return is the only branch + // that can produce Connected here: without it the peer would fall through to + // relayUsedAndUp and report PartiallyConnected. + remoteSupportsICE: true, + iceWorkerCreated: true, + }, + want: guard.ConnStatusConnected, + }, { name: "force relay, peer does NOT use relay - disconnected forever", in: connStatusInputs{ @@ -123,24 +138,28 @@ func TestEvalConnStatus_FullyAvailable(t *testing.T) { mutator: func(in *connStatusInputs) { in.peerUsesRelay = true in.relayConnected = true - in.iceStatusConnecting = true + in.relayTransportConnected = true + in.iceStatusConnected = true }, want: guard.ConnStatusConnected, }, { - name: "ICE connected, peer does NOT use relay", + name: "ICE connected, peer does NOT use relay, shared transport down", mutator: func(in *connStatusInputs) { in.peerUsesRelay = false in.relayConnected = false - in.iceStatusConnecting = true + in.relayTransportConnected = false + in.iceStatusConnected = true }, + // A peer that does not rely on relay is unaffected by the shared transport: + // relayOK is true, so the first arm matches before the transport is considered. want: guard.ConnStatusConnected, }, { name: "ICE InProgress only, peer does NOT use relay", mutator: func(in *connStatusInputs) { in.peerUsesRelay = false - in.iceStatusConnecting = false + in.iceStatusConnected = false in.iceInProgress = true }, want: guard.ConnStatusConnected, @@ -150,7 +169,8 @@ func TestEvalConnStatus_FullyAvailable(t *testing.T) { mutator: func(in *connStatusInputs) { in.peerUsesRelay = true in.relayConnected = true - in.iceStatusConnecting = false + in.relayTransportConnected = true + in.iceStatusConnected = false in.iceInProgress = false }, want: guard.ConnStatusPartiallyConnected, @@ -160,21 +180,60 @@ func TestEvalConnStatus_FullyAvailable(t *testing.T) { mutator: func(in *connStatusInputs) { in.peerUsesRelay = false in.relayConnected = false - in.iceStatusConnecting = false + in.iceStatusConnected = false in.iceInProgress = false }, want: guard.ConnStatusDisconnected, }, { - name: "ICE up, peer uses relay but relay down -> partial (relay required, ICE ignored)", + name: "ICE connected, relay down for this peer but the shared transport is up -> disconnected", mutator: func(in *connStatusInputs) { in.peerUsesRelay = true in.relayConnected = false - in.iceStatusConnecting = true + in.relayTransportConnected = true + in.iceStatusConnected = true + }, + // The transport is fine, so the peer itself is unreachable over relay: it may have + // moved to another server, and only an offer carries its new relay address. + want: guard.ConnStatusDisconnected, + }, + { + name: "ICE connected, the shared relay transport is down -> partial", + mutator: func(in *connStatusInputs) { + in.peerUsesRelay = true + in.relayConnected = false + in.relayTransportConnected = false + in.iceStatusConnected = true + }, + // ICE carries the traffic and the relay transport is restored by the relay client's + // own guard, not by offers, so this must not trigger the aggressive retry. + want: guard.ConnStatusPartiallyConnected, + }, + { + name: "ICE only negotiating while the shared relay transport is down -> disconnected", + mutator: func(in *connStatusInputs) { + in.peerUsesRelay = true + in.relayConnected = false + in.relayTransportConnected = false + in.iceStatusConnected = false + in.iceInProgress = true + }, + // A negotiation in flight is not a working transport, so this peer has no path at + // all and must keep the aggressive retry. Calling it partially connected spends the + // ICE retry budget and parks the guard on the hourly ticker, and nothing wakes it + // when the negotiation then fails: onICEStateDisconnected is only reached once ICE + // has reached Connected (worker_ice.go onConnectionStateChange). + want: guard.ConnStatusDisconnected, + }, + { + name: "ICE down and the shared relay transport is down -> disconnected", + mutator: func(in *connStatusInputs) { + in.peerUsesRelay = true + in.relayConnected = false + in.relayTransportConnected = false + in.iceStatusConnected = false + in.iceInProgress = false }, - // relayOK = false (peer uses relay but it's down), iceUp = true - // first switch arm fails (relayOK false), relayUsedAndUp = false (relay down), - // falls into default: Disconnected. want: guard.ConnStatusDisconnected, }, { @@ -182,7 +241,7 @@ func TestEvalConnStatus_FullyAvailable(t *testing.T) { mutator: func(in *connStatusInputs) { in.peerUsesRelay = false in.relayConnected = true // not actually used since peer doesn't rely on it - in.iceStatusConnecting = false + in.iceStatusConnected = false in.iceInProgress = false }, want: guard.ConnStatusDisconnected, diff --git a/client/internal/peer/guard/guard.go b/client/internal/peer/guard/guard.go index 73bab2a89..15028d91c 100644 --- a/client/internal/peer/guard/guard.go +++ b/client/internal/peer/guard/guard.go @@ -14,7 +14,8 @@ type ConnStatus int const ( // ConnStatusDisconnected means neither ICE nor Relay is connected. ConnStatusDisconnected ConnStatus = iota - // ConnStatusPartiallyConnected means Relay is connected but ICE is not. + // ConnStatusPartiallyConnected means one transport is usable and the other is not: + // relay connected with ICE down, or ICE connected with the shared relay transport down. ConnStatusPartiallyConnected // ConnStatusConnected means all required connections are established. ConnStatusConnected @@ -87,8 +88,9 @@ func (g *Guard) SetICEConnDisconnected() { // - Connected: no action, the peer is fully reachable. // - Disconnected (neither ICE nor Relay): retries aggressively with exponential backoff (800ms doubling // up to timeout), never gives up. This ensures rapid recovery when the peer has no connectivity at all. -// - PartiallyConnected (Relay up, ICE not): retries up to 3 times with exponential backoff, then switches -// to one attempt per hour. This limits signaling traffic when relay already provides connectivity. +// - PartiallyConnected (one transport usable, the other not): retries up to 3 times +// with exponential backoff, then switches to one attempt per hour. This limits +// signaling traffic while the peer still has a working path. // // External events (relay/ICE disconnect, signal/relay reconnect, candidate changes) reset the retry // counter and backoff ticker, giving ICE a fresh chance after network conditions change. diff --git a/client/internal/peer/worker_relay.go b/client/internal/peer/worker_relay.go index fc3489992..694207847 100644 --- a/client/internal/peer/worker_relay.go +++ b/client/internal/peer/worker_relay.go @@ -101,6 +101,10 @@ func (w *WorkerRelay) RelayIsSupportedLocally() bool { return w.relayManager.HasRelayAddress() } +func (w *WorkerRelay) IsTransportConnected() bool { + return w.relayManager.Ready() +} + func (w *WorkerRelay) CloseConn() { w.relayLock.Lock() conn := w.relayedConn From 2b5293687f3e6aa4a6a1f070a6e95353155e21c4 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 5 Oct 2026 16:31:43 +0200 Subject: [PATCH 103/209] [client] Skip late session warnings on desktop and schedule them in the app on Android (#7548) * Skip session warnings that fire after their window The warning timers run on the monotonic clock, which does not advance while an Android device is suspended. A timer armed for T-10 or T-2 can therefore fire long after the window it was armed for, delivering a "session expires soon" notification once that window is already gone. Gate both callbacks on the wall clock at fire time: the T-10 warning is skipped once the final-warning window has been reached, and the final warning is skipped once the deadline itself has passed. Both set their edge guard before returning so a skipped warning cannot fire again for the same deadline. * Harden the late-warning guards Clamp a non-positive final lead to zero in the T-10 guard so a disabled final warning cannot move the cutoff past the deadline, matching how armTimerLocked already treats it. Strip the monotonic reading from both sides of the comparison so the guard measures wall-clock time regardless of how the caller built the deadline. The production deadline comes from a protobuf timestamp and has no monotonic reading; this keeps the guard correct for callers that derive one from time.Now. * Log the deadline and lateness on skipped warnings Include the deadline and how far past the cutoff the timer fired, so a debug bundle shows how long the device was suspended. * Inject the clock into the late-warning guard and cover it with tests The guard read time.Now internally, so the skip paths were reachable only through a deadline already in the past and the boundary depended on real time. Extract the comparison into isLate and read the time through a nowFn field, so tests can place a resume anywhere around the deadline without sleeping. * Send the final warning when the T-10 timer fires inside its window A suspend between roughly eight and ten minutes long made the T-10 timer fire inside the final-warning window and the final timer fire after the deadline, so both were skipped and a user who resumed with time left got no warning at all. When the T-10 timer fires late but before the deadline, send the final warning in its place and mark it fired so the delayed final timer does not repeat it. * Respect dismissal when promoting a late warning to the final one fireFinal skips the final warning once the user dismissed the deadline, but the promoted path did not, so a dismissed deadline could still get a final warning. Check the dismissal first, and give each skip reason its own log line so an already-fired final warning no longer logs a negative lateness. * Add a deadline-only mode to the session watcher Android will schedule its own expiry warnings from the deadline, so the engine must not arm the T-10 and T-2 timers there. NewDeadlineOnly keeps the deadline validation, the recorder propagation and the logging, and skips only the timers, so the status snapshot the app reads stays correct and an out-of-range deadline is still rejected. * Use the deadline-only watcher on Android and drop the warning callbacks The warning timers run on the monotonic clock, which does not advance while the device sleeps, so a warning armed for T-10 could fire long after its window. The app now schedules the warnings itself with WorkManager, anchored to the wall clock, from the deadline it reads through SessionExpiresAtUnix on every OnStateChanged. Wire the deadline-only watcher into the android build and remove the event-driven path from the gomobile surface: OnSessionExpiring, the event subscription behind it and DismissSessionWarning, which the app never called. * Describe the late-warning guard without naming Android The guard stays for the desktop builds, where a timer can also stall across a sleep. Android no longer arms the timers at all. --- client/android/client.go | 3 +- client/android/session.go | 91 +------- client/internal/auth/sessionwatch/watcher.go | 70 ++++++- .../auth/sessionwatch/watcher_test.go | 198 ++++++++++++++++++ client/internal/engine_sessionwatch.go | 12 +- .../internal/engine_sessionwatch_android.go | 12 ++ 6 files changed, 295 insertions(+), 91 deletions(-) create mode 100644 client/internal/engine_sessionwatch_android.go diff --git a/client/android/client.go b/client/android/client.go index 6f5eaacf3..9705db8e0 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -104,8 +104,7 @@ type Client struct { stateChangeMu sync.Mutex stateChangeSubID string - eventSub *peer.EventSubscription - // Closed to stop the watch goroutines from delivering buffered items to a + // Closed to stop the watch goroutine from delivering buffered ticks to a // listener that has been removed or replaced. See stopStateChangeWatchLocked. stateChangeDone chan struct{} diff --git a/client/android/session.go b/client/android/session.go index d5da09c93..1ce97f074 100644 --- a/client/android/session.go +++ b/client/android/session.go @@ -6,13 +6,8 @@ import ( "context" "fmt" - log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" - "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" - "github.com/netbirdio/netbird/client/internal/peer" - cProto "github.com/netbirdio/netbird/client/proto" ) // StateChangeListener receives client state notifications. @@ -21,16 +16,11 @@ import ( // changed: connection state, the run-loop status label (e.g. NeedsLogin) or // the session deadline. It mirrors the daemon's SubscribeStatus stream // trigger — on each signal the consumer pulls the fresh values via -// Status() / SessionExpiresAtUnix(). -// -// OnSessionExpiring forwards the engine's session-expiry warnings, fired at -// sessionwatch.WarningLead before the deadline and again at FinalWarningLead -// (finalWarning true). The second one is suppressed when the user dismissed -// the first via DismissSessionWarning. The daemon turns the same events into -// its tray notification. +// Status() / SessionExpiresAtUnix(). The engine arms no expiry-warning +// timers on Android; the app schedules the warnings from the deadline it +// reads here. type StateChangeListener interface { OnStateChanged() - OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool) } // Status returns the connect run-loop's status label — the same value the @@ -110,11 +100,11 @@ func (c *Client) SetStateChangeListener(listener StateChangeListener) { return } - // Both subscriptions are buffered (one pending tick, ten pending events), - // so unsubscribing is not enough to stop callbacks: the loops would drain - // what is already queued and deliver it to a listener the caller has - // already removed or replaced. Gate every callback on this registration's - // own signal, which is closed before unsubscribing. + // The subscription is buffered (one pending tick), so unsubscribing is + // not enough to stop callbacks: the loop would drain what is already + // queued and deliver it to a listener the caller has already removed or + // replaced. Gate every callback on this registration's own signal, which + // is closed before unsubscribing. done := make(chan struct{}) c.stateChangeDone = done @@ -133,9 +123,6 @@ func (c *Client) SetStateChangeListener(listener StateChangeListener) { listener.OnStateChanged() } }() - - c.eventSub = c.recorder.SubscribeToEvents() - go watchSessionWarnings(c.eventSub, listener, done) } // RemoveStateChangeListener unregisters the state notification listener. @@ -145,21 +132,6 @@ func (c *Client) RemoveStateChangeListener() { c.stopStateChangeWatchLocked() } -// DismissSessionWarning records the user's "Dismiss" on the first expiry -// warning and suppresses the final one for the current deadline. A refreshed -// deadline re-arms both. No-op while the engine is not running. -func (c *Client) DismissSessionWarning() { - cc := c.getConnectClient() - if cc == nil { - return - } - engine := cc.Engine() - if engine == nil { - return - } - engine.DismissSessionWarning() -} - // ExtendAuthSession runs the interactive SSO flow to obtain a fresh JWT and // asks the management server to extend the session deadline. The tunnel is // untouched: no resync, no reconnect. Async; the result arrives on the @@ -201,8 +173,8 @@ func (c *Client) CancelExtendAuthSession() { } func (c *Client) stopStateChangeWatchLocked() { - // Signal first, unsubscribe second: closing the channels only stops new - // items, and the loops would still hand whatever is buffered to a listener + // Signal first, unsubscribe second: closing the channel only stops new + // items, and the loop would still hand whatever is buffered to a listener // that is no longer registered. if c.stateChangeDone != nil { close(c.stateChangeDone) @@ -212,49 +184,6 @@ func (c *Client) stopStateChangeWatchLocked() { c.recorder.UnsubscribeFromStateChanges(c.stateChangeSubID) c.stateChangeSubID = "" } - if c.eventSub != nil { - // Closes the channel, which ends watchSessionWarnings. - c.recorder.UnsubscribeFromEvents(c.eventSub) - c.eventSub = nil - } -} - -// watchSessionWarnings forwards the engine's session-expiry warnings to the -// listener. The event stream also carries unrelated traffic — network-map -// updates on every sync, DNS and route errors — so everything but an -// AUTHENTICATION event carrying the session-warning marker is dropped. Exits -// when the subscription is closed by UnsubscribeFromEvents, or earlier when -// done is closed — the stream buffers up to ten events, and a deregistered -// listener must not receive the ones already queued. -func watchSessionWarnings(sub *peer.EventSubscription, listener StateChangeListener, done <-chan struct{}) { - for ev := range sub.Events() { - select { - case <-done: - return - default: - } - if ev.GetCategory() != cProto.SystemEvent_AUTHENTICATION { - continue - } - meta := ev.GetMetadata() - if meta[sessionwatch.MetaSessionWarning] != "true" { - // Other AUTHENTICATION events exist (e.g. a deadline rejected as - // out of range); they carry no warning marker. - continue - } - deadline, err := sessionwatch.ParseExpiresAt(meta[sessionwatch.MetaSessionExpiresAt]) - if err != nil { - log.Warnf("session warning event with unparsable deadline: %v", err) - continue - } - lead, err := sessionwatch.ParseLeadMinutes(meta[sessionwatch.MetaSessionLeadMinutes]) - if err != nil { - // Informational only — the deadline above is what drives the UI. - lead = 0 - } - listener.OnSessionExpiring(deadline.Unix(), int64(lead), - meta[sessionwatch.MetaSessionFinal] == "true") - } } func (c *Client) beginExtend() (context.Context, error) { diff --git a/client/internal/auth/sessionwatch/watcher.go b/client/internal/auth/sessionwatch/watcher.go index e685c28d0..496903044 100644 --- a/client/internal/auth/sessionwatch/watcher.go +++ b/client/internal/auth/sessionwatch/watcher.go @@ -90,8 +90,9 @@ type StatusRecorder interface { // fallback T-FinalWarningLead dialog (suppressed when the user dismissed // the first one for the same deadline). Safe for concurrent use. type Watcher struct { - lead time.Duration - finalLead time.Duration + lead time.Duration + finalLead time.Duration + deadlineOnly bool mu sync.Mutex current time.Time @@ -102,6 +103,7 @@ type Watcher struct { dismissedAt time.Time // deadline value the user dismissed via Dismiss(); gates fireFinal closed bool recorder StatusRecorder + nowFn func() time.Time } // New returns a watcher with the package defaults WarningLead and @@ -122,9 +124,17 @@ func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher { lead: lead, finalLead: final, recorder: recorder, + nowFn: time.Now, } } +// NewDeadlineOnly returns a watcher that validates and records deadlines but arms no warning timers. +func NewDeadlineOnly(recorder StatusRecorder) *Watcher { + w := New(recorder) + w.deadlineOnly = true + return w +} + // Update sets the latest deadline. Pass the zero time to clear (e.g. when // a Sync push from the server omits the field because login expiration // was disabled). @@ -181,7 +191,7 @@ func (w *Watcher) Update(deadline time.Time) error { w.finalFiredAt = time.Time{} w.dismissedAt = time.Time{} - if deadline.After(now) { + if deadline.After(now) && !w.deadlineOnly { w.armTimerLocked(deadline) } recorder := w.recorder @@ -303,6 +313,11 @@ func (w *Watcher) fire(armedFor time.Time) { w.mu.Unlock() return } + now := w.nowFn() + if isLate(now, armedFor, max(w.finalLead, 0)) { + w.fireLateLocked(armedFor, now) + return + } w.firedAt = armedFor recorder := w.recorder w.mu.Unlock() @@ -331,6 +346,14 @@ func (w *Watcher) fireFinal(armedFor time.Time) { log.Infof("auth session final-warning skipped (dismissed by user)") return } + now := w.nowFn() + if isLate(now, armedFor, 0) { + w.finalFiredAt = armedFor + w.mu.Unlock() + log.Infof("auth session final-warning skipped for deadline %s (passed %s ago)", + armedFor.Format(time.RFC3339), now.Round(0).Sub(armedFor).Round(time.Second)) + return + } w.finalFiredAt = armedFor recorder := w.recorder w.mu.Unlock() @@ -341,6 +364,39 @@ func (w *Watcher) fireFinal(armedFor time.Time) { publishWarning(recorder, armedFor, true) } +// fireLateLocked handles a T-WarningLead callback that fired inside the +// final-warning window: it sends the final warning in its place while the +// deadline has not passed and the user has not dismissed it, so a resume +// with time left still warns. The caller must hold w.mu; this helper +// releases it. +func (w *Watcher) fireLateLocked(armedFor, now time.Time) { + w.firedAt = armedFor + switch { + case w.dismissedAt.Equal(armedFor): + w.mu.Unlock() + log.Infof("auth session expiry soon warning skipped (dismissed by user)") + return + case w.finalFiredAt.Equal(armedFor): + w.mu.Unlock() + log.Infof("auth session expiry soon warning skipped (final warning already fired)") + return + case isLate(now, armedFor, 0): + w.mu.Unlock() + log.Infof("auth session expiry soon warning skipped for deadline %s (passed %s ago)", + armedFor.Format(time.RFC3339), now.Round(0).Sub(armedFor).Round(time.Second)) + return + } + w.finalFiredAt = armedFor + recorder := w.recorder + w.mu.Unlock() + if recorder == nil { + return + } + log.Infof("auth session expiry soon warning fired inside the final-warning window, sending final warning for deadline %s", + armedFor.Format(time.RFC3339)) + publishWarning(recorder, armedFor, true) +} + // armOneShotLocked schedules cb at fireAt. When fireAt is already in the // past it dispatches on the next scheduler tick so a state-change recorder // notification (invoked after w.mu is released) lands first. Caller must @@ -380,3 +436,11 @@ func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) { meta, ) } + +// isLate reports whether the wall clock now has already reached armedFor +// minus cutoffLead. The timers run on the monotonic clock, which can stall +// while the host sleeps, so a timer can fire long after the window it was +// armed for. +func isLate(now, armedFor time.Time, cutoffLead time.Duration) bool { + return !now.Round(0).Before(armedFor.Add(-cutoffLead).Round(0)) +} diff --git a/client/internal/auth/sessionwatch/watcher_test.go b/client/internal/auth/sessionwatch/watcher_test.go index 4b49a94b6..cb2800978 100644 --- a/client/internal/auth/sessionwatch/watcher_test.go +++ b/client/internal/auth/sessionwatch/watcher_test.go @@ -527,3 +527,201 @@ func TestDismissBeforeUpdateIsNoop(t *testing.T) { } t.Fatalf("final-warning did not publish after no-op pre-Update Dismiss, events=%+v", r.snapshot()) } + +func TestIsLate(t *testing.T) { + armedFor := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + lead := 2 * time.Minute + tests := []struct { + name string + now time.Time + cutoffLead time.Duration + want bool + }{ + {"before cutoff", armedFor.Add(-3 * time.Minute), lead, false}, + {"at cutoff", armedFor.Add(-lead), lead, true}, + {"after cutoff", armedFor.Add(-time.Minute), lead, true}, + {"zero lead before deadline", armedFor.Add(-time.Second), 0, false}, + {"zero lead at deadline", armedFor, 0, true}, + {"zero lead after deadline", armedFor.Add(time.Second), 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isLate(tt.now, armedFor, tt.cutoffLead); got != tt.want { + t.Fatalf("isLate(%s, %s, %s) = %v, want %v", tt.now, armedFor, tt.cutoffLead, got, tt.want) + } + }) + } +} + +func TestIsLateIgnoresMonotonicReading(t *testing.T) { + now := time.Now() + wallOnly := now.Round(0) + if isLate(now, wallOnly.Add(time.Second), 0) { + t.Fatalf("now with monotonic reading must compare as wall clock before a later wall-only deadline") + } + if !isLate(now, wallOnly, 0) { + t.Fatalf("now with monotonic reading must compare as wall clock at an equal wall-only deadline") + } +} + +func TestLateTimerFiring(t *testing.T) { + tests := []struct { + name string + final bool + beforeDl time.Duration + wantWarns int + wantFinals int + }{ + {"warning on resume inside window", false, 3 * time.Minute, 1, 0}, + {"warning promoted to final inside final window", false, time.Minute, 0, 1}, + {"warning skipped past deadline", false, -time.Minute, 0, 0}, + {"final on resume before deadline", true, time.Minute, 0, 1}, + {"final skipped past deadline", true, -time.Minute, 0, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &fakeRecorder{} + w := New(r) + defer w.Close() + + // The deadline is an hour out so the real timers never fire + // during the test; the late callback is invoked directly with an + // injected clock that simulates a resume near the deadline. + d := time.Now().Add(time.Hour).Round(0) + w.nowFn = func() time.Time { return d.Add(-tt.beforeDl) } + if err := w.Update(d); err != nil { + t.Fatalf("Update: %v", err) + } + + if tt.final { + w.fireFinal(d) + } else { + w.fire(d) + } + + events := r.snapshot() + if got := countWhere(events, event.isWarning); got != tt.wantWarns { + t.Fatalf("expected %d warning publishes, got %d: %+v", tt.wantWarns, got, events) + } + if got := countWhere(events, event.isFinalWarning); got != tt.wantFinals { + t.Fatalf("expected %d final-warning publishes, got %d: %+v", tt.wantFinals, got, events) + } + }) + } +} + +func TestPromotedFinalWarningIsNotRepeated(t *testing.T) { + r := &fakeRecorder{} + w := New(r) + defer w.Close() + + d := time.Now().Add(time.Hour).Round(0) + now := d.Add(-time.Minute) + w.nowFn = func() time.Time { return now } + if err := w.Update(d); err != nil { + t.Fatalf("Update: %v", err) + } + + w.fire(d) + // The final timer was suspended too, so it fires even later than the + // warning timer, here still just before the deadline. + now = d.Add(-30 * time.Second) + w.fireFinal(d) + + events := r.snapshot() + if got := countWhere(events, event.isFinalWarning); got != 1 { + t.Fatalf("expected exactly 1 final-warning publish, got %d: %+v", got, events) + } + if got := countWhere(events, event.isWarning); got != 0 { + t.Fatalf("expected no regular warning publish, got %d: %+v", got, events) + } +} + +func TestPromotionRespectsDismiss(t *testing.T) { + r := &fakeRecorder{} + w := New(r) + defer w.Close() + + d := time.Now().Add(time.Hour).Round(0) + w.nowFn = func() time.Time { return d.Add(-time.Minute) } + if err := w.Update(d); err != nil { + t.Fatalf("Update: %v", err) + } + + w.Dismiss() + w.fire(d) + + events := r.snapshot() + if got := countWhere(events, func(e event) bool { return e.kind == publish }); got != 0 { + t.Fatalf("expected no publish after dismiss, got %d: %+v", got, events) + } +} + +func TestPromotionSkippedWhenFinalAlreadyFired(t *testing.T) { + r := &fakeRecorder{} + w := New(r) + defer w.Close() + + // Both timers fall in the past after a long suspend and are dispatched + // with a zero delay, so the final callback can run before the warning one. + d := time.Now().Add(time.Hour).Round(0) + w.nowFn = func() time.Time { return d.Add(-time.Minute) } + if err := w.Update(d); err != nil { + t.Fatalf("Update: %v", err) + } + + w.fireFinal(d) + w.fire(d) + + events := r.snapshot() + if got := countWhere(events, event.isFinalWarning); got != 1 { + t.Fatalf("expected exactly 1 final-warning publish, got %d: %+v", got, events) + } + if got := countWhere(events, event.isWarning); got != 0 { + t.Fatalf("expected no regular warning publish, got %d: %+v", got, events) + } +} + +func TestDeadlineOnlyRecordsDeadlineWithoutWarnings(t *testing.T) { + r := &fakeRecorder{} + w := NewDeadlineOnly(r) + defer w.Close() + + // With the default leads this deadline would otherwise fire both + // timers on the next tick. + d := time.Now().Add(50 * time.Millisecond).Round(0) + if err := w.Update(d); err != nil { + t.Fatalf("Update: %v", err) + } + if got := r.deadline(); !got.Equal(d) { + t.Fatalf("expected recorder deadline %v, got %v", d, got) + } + + time.Sleep(100 * time.Millisecond) + + events := r.snapshot() + if got := countWhere(events, func(e event) bool { return e.kind == publish }); got != 0 { + t.Fatalf("expected no publish in deadline-only mode, got %d: %+v", got, events) + } + if w.timer != nil || w.finalTimer != nil { + t.Fatal("expected no timers armed in deadline-only mode") + } +} + +func TestDeadlineOnlyStillRejectsOutOfRangeDeadlines(t *testing.T) { + r := &fakeRecorder{} + w := NewDeadlineOnly(r) + defer w.Close() + + if err := w.Update(time.Now().Add(time.Hour)); err != nil { + t.Fatalf("Update: %v", err) + } + + err := w.Update(time.Now().Add(-maxPastHorizon - time.Hour)) + if !errors.Is(err, ErrDeadlineInPast) { + t.Fatalf("expected ErrDeadlineInPast, got %v", err) + } + if got := r.deadline(); !got.IsZero() { + t.Fatalf("expected recorder cleared after rejection, got %v", got) + } +} diff --git a/client/internal/engine_sessionwatch.go b/client/internal/engine_sessionwatch.go index a46d73f87..05b46a465 100644 --- a/client/internal/engine_sessionwatch.go +++ b/client/internal/engine_sessionwatch.go @@ -1,4 +1,4 @@ -//go:build !js +//go:build !js && !android package internal @@ -7,10 +7,12 @@ import ( "github.com/netbirdio/netbird/client/internal/peer" ) -// newSessionWatcher returns the real SSO session expiry watcher for every -// non-wasm build. The js/wasm build gets a no-op stub from -// engine_sessionwatch_js.go so the sessionwatch package (and its timer -// machinery) never links into the wasm binary. +// newSessionWatcher returns the real SSO session expiry watcher. The js/wasm +// build gets a no-op stub from engine_sessionwatch_js.go so the sessionwatch +// package (and its timer machinery) never links into the wasm binary; the +// android build gets a deadline-only watcher from +// engine_sessionwatch_android.go because the app schedules the warnings +// itself. func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher { return sessionwatch.New(recorder) } diff --git a/client/internal/engine_sessionwatch_android.go b/client/internal/engine_sessionwatch_android.go new file mode 100644 index 000000000..8317f9165 --- /dev/null +++ b/client/internal/engine_sessionwatch_android.go @@ -0,0 +1,12 @@ +//go:build android + +package internal + +import ( + "github.com/netbirdio/netbird/client/internal/auth/sessionwatch" + "github.com/netbirdio/netbird/client/internal/peer" +) + +func newSessionWatcher(recorder *peer.Status) sessionDeadlineWatcher { + return sessionwatch.NewDeadlineOnly(recorder) +} From eb5a98c059891a2ba78284daf5446cc8c4bfa5fd Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:48:48 +0200 Subject: [PATCH 104/209] [management] add tenant delete endpoint to openapi (#8054) --- shared/management/http/api/openapi.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 4b7077cac..90b87462f 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -10625,6 +10625,28 @@ paths: $ref: "#/components/responses/requires_authentication" "500": $ref: "#/components/responses/internal_error" + delete: + summary: Delete MSP tenant + tags: + - MSP + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The unique identifier of a tenant account + responses: + "200": + description: Successfully deleted the tenant + "400": + $ref: "#/components/responses/bad_request" + "403": + $ref: "#/components/responses/requires_authentication" + "404": + description: The tenant was not found + "500": + $ref: "#/components/responses/internal_error" /api/integrations/msp/tenants/{id}/unlink: post: summary: Unlink a tenant From d6340ba0de1d1448623fe906e985fdf2a5b53bdd Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Tue, 6 Oct 2026 01:04:36 +0200 Subject: [PATCH 105/209] [self-hosted] Add a UBI image variant for the combined server (#7953) * [self-hosted] Add a UBI image variant for the combined server OpenShift and other Red Hat environments expect UBI-based images that run as an arbitrary non-root UID. The proxy and rootless client already ship -ubi variants; this adds the same for netbird-server, published as -ubi and ubi-latest for amd64 and arm64. * [self-hosted] Check the license output path before creating temp files The existing-output exit ran before the cleanup trap was registered, so it left the two mktemp files behind. * [self-hosted] Certify the netbird-server UBI image Adds netbird-server to the Red Hat certification components. Its Partner Connect component ID goes in the REDHAT_CERT_ID_NETBIRD_SERVER repository variable. --- .github/workflows/redhat-certify.yml | 2 + .goreleaser.yaml | 35 ++++++++++++ combined/Dockerfile.ubi | 31 +++++++++++ combined/collect-licenses.sh | 81 ++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 combined/Dockerfile.ubi create mode 100644 combined/collect-licenses.sh diff --git a/.github/workflows/redhat-certify.yml b/.github/workflows/redhat-certify.yml index e592dabc2..fa0a87c64 100644 --- a/.github/workflows/redhat-certify.yml +++ b/.github/workflows/redhat-certify.yml @@ -32,6 +32,7 @@ on: - all - client-rootless - reverse-proxy + - netbird-server version: description: "Released version, e.g. v0.80.0" type: string @@ -66,6 +67,7 @@ jobs: components=( "client-rootless ghcr.io/netbirdio/netbird -rootless-ubi" "reverse-proxy ghcr.io/netbirdio/reverse-proxy -ubi" + "netbird-server ghcr.io/netbirdio/netbird-server -ubi" ) matrix="[]" missing=() diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 275c1cd7b..ba9e56a50 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -511,6 +511,41 @@ dockers_v2: "org.opencontainers.image.revision": "{{.FullCommit}}" "org.opencontainers.image.source": "{{.GitURL}}" "maintainer": "dev@netbird.io" + - id: netbird-server-ubi + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-server + images: + - netbirdio/netbird-server + - ghcr.io/netbirdio/netbird-server + tags: + - "{{ .Version }}-ubi" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}ubi-latest{{ end }}" + dockerfile: combined/Dockerfile.ubi + platforms: + - linux/amd64 + - linux/arm64 + build_args: + VERSION: "{{ .Version }}" + RELEASE: "{{ .Timestamp }}" + hooks: + pre: + - cmd: 'sh combined/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + env: + - GOOS=linux + - CGO_ENABLED=1 + labels: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" - id: netbird-proxy disable: "{{ .Env.SKIP_DOCKER_PUSH }}" ids: diff --git a/combined/Dockerfile.ubi b/combined/Dockerfile.ubi new file mode 100644 index 000000000..66ef55a34 --- /dev/null +++ b/combined/Dockerfile.ubi @@ -0,0 +1,31 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 + +ARG TARGETPLATFORM +ARG VERSION=dev +ARG RELEASE=1 + +LABEL name="netbird-server" \ + maintainer="NetBird " \ + vendor="NetBird GmbH" \ + version="${VERSION}" \ + release="${RELEASE}" \ + summary="NetBird Server" \ + description="NetBird Server runs the Management, Signal, Relay and STUN services of a self-hosted NetBird deployment in a single process." + +COPY --chmod=0555 ${TARGETPLATFORM}/netbird-server /go/bin/netbird-server +COPY licenses/ /licenses/ +# Only the data directory shares the root group for arbitrary non-root UIDs. +# Runtime-created keys and databases retain the application's restrictive modes. +RUN mkdir -p /var/lib/netbird /etc/netbird && \ + chown 1000:0 /var/lib/netbird && \ + chmod 0770 /var/lib/netbird && \ + chmod -R a+rX /licenses + +USER 1000:0 +ENV HOME=/var/lib/netbird +# Runtimes such as OpenShift and Podman reserve ports below 1024 for root, so +# the mounted config must set server.listenAddress to an unprivileged port. +EXPOSE 8443 3478/udp +STOPSIGNAL SIGTERM +ENTRYPOINT ["/go/bin/netbird-server"] +CMD ["--config", "/etc/netbird/config.yaml"] diff --git a/combined/collect-licenses.sh b/combined/collect-licenses.sh new file mode 100644 index 000000000..ae618e46b --- /dev/null +++ b/combined/collect-licenses.sh @@ -0,0 +1,81 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 2 ]; then + printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 + exit 2 +fi + +repo_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) +output_name=$(basename "$1") +if [ -z "$output_name" ] || [ "$output_name" = . ] || [ "$output_name" = .. ] || [ "$output_name" = / ]; then + printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2 + exit 2 +fi +output_parent=$(CDPATH='' cd -- "$(dirname "$1")" && pwd) +output="$output_parent/$output_name" +shift + +if [ -e "$output" ] || [ -L "$output" ]; then + printf 'output directory already exists: %s\n' "$output" >&2 + exit 1 +fi +modules=$(mktemp "${TMPDIR:-/tmp}/netbird-server-licenses.modules.XXXXXX") +sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-server-licenses.sorted.XXXXXX") +# Assemble beside the target and rename on success, so a failed run leaves +# nothing behind that would block the next attempt. +staging=$(mktemp -d "$output_parent/.$output_name.XXXXXX") +trap 'rm -f "$modules" "$sorted_modules"; rm -rf "$staging"' EXIT HUP INT TERM +mkdir "$staging/third_party" + +cp "$repo_root/combined/LICENSE" "$staging/AGPL-3.0.txt" +cp "$repo_root/LICENSE" "$staging/BSD-3-Clause.txt" + +cd "$repo_root" +for arch in "$@"; do + GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-1} \ + go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' ./combined >>"$modules" +done +LC_ALL=C sort -u "$modules" >"$sorted_modules" + +goroot=$(go env GOROOT) +for term in LICENSE PATENTS; do + if [ ! -f "$goroot/$term" ]; then + printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2 + exit 1 + fi + cp "$goroot/$term" "$staging/Go-$term" +done + +while IFS=' ' read -r module version module_dir; do + [ -n "$module" ] || continue + [ "$module" = "github.com/netbirdio/netbird" ] && continue + + if [ -z "$version" ] || [ ! -d "$module_dir" ]; then + printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2 + exit 1 + fi + + destination="$staging/third_party/$module/$version" + mkdir -p "$destination" + printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE" + + found=false + for term in \ + "$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \ + "$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \ + "$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \ + "$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \ + "$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do + [ -f "$term" ] || continue + cp "$term" "$destination/" + found=true + done + + if [ "$found" = false ]; then + printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2 + exit 1 + fi +done <"$sorted_modules" + +mv "$staging" "$output" From ab79aebd88a359da506b4443203b15808b1e6ccb Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Tue, 6 Oct 2026 10:25:57 +0200 Subject: [PATCH 106/209] [misc] Share one license collection script across the UBI images (#8055) * [self-hosted] Add a UBI image variant for the combined server OpenShift and other Red Hat environments expect UBI-based images that run as an arbitrary non-root UID. The proxy and rootless client already ship -ubi variants; this adds the same for netbird-server, published as -ubi and ubi-latest for amd64 and arm64. * [self-hosted] Check the license output path before creating temp files The existing-output exit ran before the cleanup trap was registered, so it left the two mktemp files behind. * [self-hosted] Certify the netbird-server UBI image Adds netbird-server to the Red Hat certification components. Its Partner Connect component ID goes in the REDHAT_CERT_ID_NETBIRD_SERVER repository variable. * [misc] Share one license collection script across the UBI images The client, proxy and combined images each carried a near-identical copy of collect-licenses.sh, and the signal and relay variants would add two more. The copies differed only in the Go package, build tags, component license and the proxy's web licenses, which are now options of one script in release_files/. The client gains the staged write the others already had. --- .github/workflows/release.yml | 2 +- .goreleaser.yaml | 6 +- client/collect-licenses.sh | 77 ----------------- proxy/collect-licenses.sh | 82 ------------------- .../collect-licenses.sh | 46 +++++++++-- 5 files changed, 41 insertions(+), 172 deletions(-) delete mode 100644 client/collect-licenses.sh delete mode 100644 proxy/collect-licenses.sh rename {combined => release_files}/collect-licenses.sh (61%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dee4d398d..a79357505 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -199,7 +199,7 @@ jobs: with: node-version: '22' - name: Install proxy web dependencies for license collection - # proxy/collect-licenses.sh reads the UI's license terms from node_modules. + # release_files/collect-licenses.sh -w reads the proxy UI's license terms from node_modules. working-directory: proxy/web run: npm ci --ignore-scripts - name: Set up QEMU diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ba9e56a50..59d8274e9 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -385,7 +385,7 @@ dockers_v2: RELEASE: "{{ .Timestamp }}" hooks: pre: - - cmd: 'sh client/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + - cmd: 'sh release_files/collect-licenses.sh -t load_wgnt_from_rsrc "{{ .ContextDir }}/licenses" ./client amd64 arm64' env: - GOOS=linux - CGO_ENABLED=0 @@ -530,7 +530,7 @@ dockers_v2: RELEASE: "{{ .Timestamp }}" hooks: pre: - - cmd: 'sh combined/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + - cmd: 'sh release_files/collect-licenses.sh -l combined/LICENSE "{{ .ContextDir }}/licenses" ./combined amd64 arm64' env: - GOOS=linux - CGO_ENABLED=1 @@ -587,7 +587,7 @@ dockers_v2: RELEASE: "{{ .Timestamp }}" hooks: pre: - - cmd: 'sh proxy/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + - cmd: 'sh release_files/collect-licenses.sh -l proxy/LICENSE -w "{{ .ContextDir }}/licenses" ./proxy/cmd/proxy amd64 arm64' env: - GOOS=linux - CGO_ENABLED=0 diff --git a/client/collect-licenses.sh b/client/collect-licenses.sh deleted file mode 100644 index 7dfabada9..000000000 --- a/client/collect-licenses.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/sh -set -eu - -if [ "$#" -lt 2 ]; then - printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 - exit 2 -fi - -repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) -output_name=$(basename "$1") -if [ -z "$output_name" ] || [ "$output_name" = "." ] || - [ "$output_name" = ".." ] || [ "$output_name" = "/" ]; then - printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2 - exit 2 -fi -output_parent=$(CDPATH= cd -- "$(dirname "$1")" && pwd) -output="$output_parent/$output_name" -shift -modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.modules.XXXXXX") -sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.sorted.XXXXXX") -trap 'rm -f "$modules" "$sorted_modules"' EXIT HUP INT TERM - -if [ -e "$output" ] || [ -L "$output" ]; then - printf 'output directory already exists: %s\n' "$output" >&2 - exit 1 -fi -mkdir "$output" -mkdir "$output/third_party" - -cp "$repo_root/LICENSE" "$output/BSD-3-Clause.txt" - -cd "$repo_root" -for arch in "$@"; do - GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \ - go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' -tags load_wgnt_from_rsrc ./client >>"$modules" -done -LC_ALL=C sort -u "$modules" >"$sorted_modules" - -goroot=$(go env GOROOT) -for term in LICENSE PATENTS; do - if [ ! -f "$goroot/$term" ]; then - printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2 - exit 1 - fi - cp "$goroot/$term" "$output/Go-$term" -done - -while IFS=' ' read -r module version module_dir; do - [ -n "$module" ] || continue - [ "$module" = "github.com/netbirdio/netbird" ] && continue - - if [ -z "$version" ] || [ ! -d "$module_dir" ]; then - printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2 - exit 1 - fi - - destination="$output/third_party/$module/$version" - mkdir -p "$destination" - printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE" - - found=false - for term in \ - "$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \ - "$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \ - "$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \ - "$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \ - "$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do - [ -f "$term" ] || continue - cp "$term" "$destination/" - found=true - done - - if [ "$found" = false ]; then - printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2 - exit 1 - fi -done <"$sorted_modules" diff --git a/proxy/collect-licenses.sh b/proxy/collect-licenses.sh deleted file mode 100644 index ccf5f4dd6..000000000 --- a/proxy/collect-licenses.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/bin/sh -set -eu - -if [ "$#" -lt 2 ]; then - printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 - exit 2 -fi - -repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) -output_name=$(basename "$1") -if [ -z "$output_name" ] || [ "$output_name" = . ] || [ "$output_name" = .. ] || [ "$output_name" = / ]; then - printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2 - exit 2 -fi -output_parent=$(CDPATH= cd -- "$(dirname "$1")" && pwd) -output="$output_parent/$output_name" -shift -modules=$(mktemp "${TMPDIR:-/tmp}/netbird-proxy-licenses.modules.XXXXXX") -sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-proxy-licenses.sorted.XXXXXX") - -if [ -e "$output" ] || [ -L "$output" ]; then - printf 'output directory already exists: %s\n' "$output" >&2 - exit 1 -fi -# Assemble beside the target and rename on success, so a failed run leaves -# nothing behind that would block the next attempt. -staging=$(mktemp -d "$output_parent/.$output_name.XXXXXX") -trap 'rm -f "$modules" "$sorted_modules"; rm -rf "$staging"' EXIT HUP INT TERM -mkdir "$staging/third_party" - -cp "$repo_root/proxy/LICENSE" "$staging/AGPL-3.0.txt" -cp "$repo_root/LICENSE" "$staging/BSD-3-Clause.txt" -node "$repo_root/proxy/web/scripts/third-party-licenses.mjs" >"$staging/Web-THIRD-PARTY-LICENSES" - -cd "$repo_root" -for arch in "$@"; do - GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \ - go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' ./proxy/cmd/proxy >>"$modules" -done -LC_ALL=C sort -u "$modules" >"$sorted_modules" - -goroot=$(go env GOROOT) -for term in LICENSE PATENTS; do - if [ ! -f "$goroot/$term" ]; then - printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2 - exit 1 - fi - cp "$goroot/$term" "$staging/Go-$term" -done - -while IFS=' ' read -r module version module_dir; do - [ -n "$module" ] || continue - [ "$module" = "github.com/netbirdio/netbird" ] && continue - - if [ -z "$version" ] || [ ! -d "$module_dir" ]; then - printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2 - exit 1 - fi - - destination="$staging/third_party/$module/$version" - mkdir -p "$destination" - printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE" - - found=false - for term in \ - "$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \ - "$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \ - "$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \ - "$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \ - "$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do - [ -f "$term" ] || continue - cp "$term" "$destination/" - found=true - done - - if [ "$found" = false ]; then - printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2 - exit 1 - fi -done <"$sorted_modules" - -mv "$staging" "$output" diff --git a/combined/collect-licenses.sh b/release_files/collect-licenses.sh similarity index 61% rename from combined/collect-licenses.sh rename to release_files/collect-licenses.sh index ae618e46b..8481c9c3e 100644 --- a/combined/collect-licenses.sh +++ b/release_files/collect-licenses.sh @@ -1,10 +1,32 @@ #!/bin/sh +# +# Collect the license terms shipped in /licenses of the UBI images: NetBird's +# own licenses, the Go standard library terms, and the root license files of +# every module the Go package links on the given architectures. +# +# -l FILE component license, copied as AGPL-3.0.txt (path from repo root) +# -t TAGS build tags used for the dependency walk +# -w add the proxy web UI's third-party licenses (needs proxy/web/node_modules) set -eu -if [ "$#" -lt 2 ]; then - printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 +usage() { + printf '%s\n' "usage: $0 [-l LICENSE_FILE] [-t TAGS] [-w] OUTPUT_DIRECTORY PACKAGE GOARCH..." >&2 exit 2 -fi +} + +component_license="" +tags="" +web=false +while getopts l:t:w opt; do + case "$opt" in + l) component_license=$OPTARG ;; + t) tags=$OPTARG ;; + w) web=true ;; + *) usage ;; + esac +done +shift $((OPTIND - 1)) +[ "$#" -ge 3 ] || usage repo_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) output_name=$(basename "$1") @@ -14,27 +36,33 @@ if [ -z "$output_name" ] || [ "$output_name" = . ] || [ "$output_name" = .. ] || fi output_parent=$(CDPATH='' cd -- "$(dirname "$1")" && pwd) output="$output_parent/$output_name" -shift +package=$2 +shift 2 if [ -e "$output" ] || [ -L "$output" ]; then printf 'output directory already exists: %s\n' "$output" >&2 exit 1 fi -modules=$(mktemp "${TMPDIR:-/tmp}/netbird-server-licenses.modules.XXXXXX") -sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-server-licenses.sorted.XXXXXX") +modules=$(mktemp "${TMPDIR:-/tmp}/netbird-licenses.modules.XXXXXX") +sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-licenses.sorted.XXXXXX") # Assemble beside the target and rename on success, so a failed run leaves # nothing behind that would block the next attempt. staging=$(mktemp -d "$output_parent/.$output_name.XXXXXX") trap 'rm -f "$modules" "$sorted_modules"; rm -rf "$staging"' EXIT HUP INT TERM mkdir "$staging/third_party" -cp "$repo_root/combined/LICENSE" "$staging/AGPL-3.0.txt" +if [ -n "$component_license" ]; then + cp "$repo_root/$component_license" "$staging/AGPL-3.0.txt" +fi cp "$repo_root/LICENSE" "$staging/BSD-3-Clause.txt" +if [ "$web" = true ]; then + node "$repo_root/proxy/web/scripts/third-party-licenses.mjs" >"$staging/Web-THIRD-PARTY-LICENSES" +fi cd "$repo_root" for arch in "$@"; do - GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-1} \ - go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' ./combined >>"$modules" + GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \ + go list -deps -tags "$tags" -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' "$package" >>"$modules" done LC_ALL=C sort -u "$modules" >"$sorted_modules" From d622e03d4019e5ca5324530f6e150417de810c32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janek=20H=C3=A4rtter?= <108095150+janekhaertter@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:32:23 +0200 Subject: [PATCH 107/209] [misc] Use ASCII hyphens in the LICENSE header (#8065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first line of LICENSE spelled BSD-3-Clause with non-breaking hyphens (U+2011). The Windows NSIS installer shows LICENSE on its license page and reads it in the system ANSI code page, so the UTF-8 bytes rendered as "BSD‑3‑Clause". Plain hyphens also match the SPDX identifier and keep the file ASCII-only. --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index d922f155a..cea6f8f0b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -This BSD‑3‑Clause license applies to all parts of the repository except for the directories management/, signal/, relay/ and combined/. +This BSD-3-Clause license applies to all parts of the repository except for the directories management/, signal/, relay/ and combined/. Those directories are licensed under the GNU Affero General Public License version 3.0 (AGPLv3). See the respective LICENSE files inside each directory. BSD 3-Clause License From a8dff998ef3cb38f435c1e4c9648dcd129ce48c1 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:39:22 +0200 Subject: [PATCH 108/209] [client] Gate settings updates on value, not on field presence (#7398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client] Gate settings updates on value, not on field presence The update-settings kill switch (--disable-update-settings / NB_DISABLE_UPDATE_SETTINGS / the MDM DisableUpdateSettings key) forbids changing settings, but it decided what a "change" was by looking at whether a field was present in the request. The CLI fills the whole config surface of SetConfigRequest and LoginRequest from its flags and environment on every `netbird up` (setupSetConfigReq in cmd/up.go), so a client configured by environment restates its own configuration on every start and tripped the gate every time. SetConfig only warned about that, but Login carries the same fields and was gated the same way, and Login runs inside the CLI's backoff loop: the daemon answered every attempt with codes.Unavailable, `netbird up` never completed, and a container with NB_DISABLE_UPDATE_SETTINGS plus any config env var (NB_MANAGEMENT_URL, for one) could not come up at all. Both gates now compare values. Config.WouldChange is the dry-run half of UpdateConfig: it runs the very same diff logic (Config.apply) against a copy of the stored config, so the gate cannot drift from what an actual update would do, nor go stale when a field is added. A request that restates what the profile already holds changes nothing and is allowed; a request that diverges is refused exactly as before, and a dry run that cannot be evaluated fails closed. A profile with no config on disk yet is judged against the config the daemon would create for it. For Login, the compared input comes from loginOverridesInput, which persistLoginOverrides also uses to perform the write, so the gate judges precisely the two fields a login can persist (management URL, pre-shared key) and no field it ignores. Two adjacent defects surfaced while making the comparison exact: - Config.apply compared URLs as raw strings, so the same endpoint spelled without its default port ("https://api.netbird.io" vs "https://api.netbird.io:443") counted as a new value and rewrote the config. It now compares the parsed forms. - UpdateConfig did not collapse the redacted pre-shared key, unlike UpdateOrCreateConfig and DirectUpdateConfig, so a UI round-trip of the mask replaced the stored key with asterisks. The CLI warning for a refused SetConfig said the method was not available in the daemon, which sent people looking for a version mismatch that was not there; it now reports the refusal. * [client] Do not write the profile config while only reading it to decide The update-settings gate needs the stored config to decide whether a request changes anything, so the previous commit moved that read ahead of the refusal. The read is not side-effect free: profilemanager.GetConfig writes the config back whenever apply() has to fill in a default the file was missing. A request that the gate then refuses had therefore already rewritten the profile file. PeekConfig is GetConfig without that write-back. The returned config is still normalized in memory, which is what the decision needs; the file is left exactly as it was found. Every caller of storedConfigAtPath feeds a gate that can refuse, so they all peek. Note for reviewers: the daemon still normalizes the file on startup and on every real update, so nothing depends on a read performing that write. * [client] Compare service URLs as endpoints, not as strings Three places in one request path each had their own notion of "same management URL": the config layer compared the parsed URLs as strings, the privileged-change gate compared scheme + host + effective port, and the MDM conflict check compared strings after filling in the default port. Only the middle one was right. A string comparison answers the wrong question. "https://api.netbird.io", "https://api.netbird.io/" and "https://API.netbird.io:443" are one endpoint written three ways, so a client restating its own management URL with a trailing slash — a normal way to write it — was still read as a client asking to be repointed, and the update-settings gate refused it. The MDM check had the same flaw against the enforced value. profilemanager.SameServiceURL is now the single comparison: same scheme, same host case-insensitively as DNS names are, same effective port. The config layer, the privileged-change gate and the MDM conflict check all defer to it, so there is one answer to "did this URL change?" instead of three. * [client] Stop the config dry run from generating throwaway keys The dry run's baseline for a profile with no config file yet went through createNewConfig, and apply() generates a WireGuard and an SSH key whenever it finds those fields empty. The baseline is compared against and discarded, so every evaluation minted a keypair it threw away — and logged "generated new Wireguard key". The CLI retries Login in a backoff loop, so a first `netbird up` on a fresh profile filled the daemon log with what reads like peer-key rotation. The baseline now starts from the shared skeleton with placeholder keys, so apply() has nothing to generate. No ConfigInput field maps to either key, so the comparison is unaffected. * [client] Cover the login the update-settings gate used to refuse The gate's decision procedure was tested directly, but no test drove the Login RPC that the refusal actually broke: the CLI retries Login in a backoff loop, so a refused no-op login is what kept a client configured by environment from ever coming up. The handler-level coverage stopped at the refusal case, which passes on the pre-fix code too. This test fails on the pre-fix daemon with "update settings are disabled" and passes now. Past the gate the handler does real work the test does not stand up, so it asserts only that the refusal did not happen. * [client] Re-take the update-settings decision under the config lock Login checks twice on purpose: the first check refuses the ordinary case early, and authorizeAndPrepareLogin re-takes the authoritative one under guardedConfigMu because the first is unsynchronized against a concurrent privileged request. The update-settings decision is now equally value-dependent — it compares the request against the stored config — but it was taken only in the first, unlocked check. So a login that was a no-op when it was checked could be written after a concurrent writer had repointed the profile, which is exactly the window the lock exists to close. The decision is now re-taken alongside the privilege one, which also makes it the last read before persistLoginOverrides writes. The test drives that interleaving through the existing afterLoginPreCheck seam and fails without the re-check. * [client] Drop an unreachable guard and fix two stale comments - loginOverridesInput's nil-message guard cannot be reached: Login dereferences the message well before it, in storedLoginConfig. - The docstring above afterLoginPreCheck described persistLoginOverrides, which lives further down the file and now carries its own. - UpdateConfig's comment named DirectUpdateConfig; the function is DirectUpdateOrCreateConfig. * [client] Make config reads pure and provision the identity explicitly Reading a config wrote it back. profilemanager.readConfig persisted whatever apply() had filled in, and ReadConfig created and wrote the file outright when it was absent, so every reader was quietly a writer: a gate deciding whether to refuse a request, a UI listing profiles, a mobile getter reading one preference. The previous commit worked around that with a PeekConfig variant, which left two read functions with opposite side effects and the antipattern still there for everyone else. Only one thing in a read genuinely had to be persisted: apply() generated the WireGuard and SSH keys when it found them empty, and a generated key cannot be recomputed — losing it means the peer comes back with a different identity and registers again. Everything else apply() fills in is a deterministic default that the next read recomputes anyway. So identity provisioning is now its own step, Config.EnsureIdentity, and the callers that provision write the result out themselves, in the open: - Server.getConfig, the daemon's provisioning point; - the CLI's foreground login, which is about to dial management; - update() / directUpdate(), the config write paths — a stored profile can legitimately carry no identity, since a mobile logout clears the keys in place, and the next write is what has to mint a new one. ReadConfig and GetConfig no longer write anything, PeekConfig is gone, and the dry-run baseline no longer needs placeholder keys to keep apply() from minting real ones. One deliberate leftover: readConfig still calls util.EnforcePermission, which chmods a config file whose permissions are too broad. It changes no content and is idempotent, and dropping it would leave a legacy file world-readable until its first write. * [client] Name the two config readers for what they do ReadConfig and GetConfig differed in one thing — what happens when the file is absent — and neither name said which was which: - ReadConfig -> ReadOrGenerateConfig (reads it, or generates one in memory) - GetConfig -> GetExistingConfig (reads it, or fails) Three comments went with them: - GetConfig's said "return with Config and if it was created. Errors out if it does not exist", which described a bool it does not return and a creation it never performs. - ReadConfig's explained that it does not write, which is what a reader is supposed to do anyway. - Server.getConfig's said it "errors out if it does not exist", which it does not — it resolves a default config, and now provisions the identity too. * [client] Do not panic on a config with no sync message version apply() wrote the incoming sync message version through the stored pointer, without checking it was there: a config that carries no version yet made it dereference nil. Reachable from the update-settings dry run, which runs inside a request handler — where failing closed is the worst acceptable outcome, and a panic is not one. The field is now reassigned like every other optional one, which also means apply() no longer mutates anything the caller still holds through a pointer, so the dry run's copy has one less field to detach. Reported by cubic-dev-ai on PR #7398. * [client] Compare the client certificate paths before reporting a change apply() assigned the incoming mTLS certificate and key paths and set updated unconditionally, without comparing them to what the config already held. It is the same presence-instead-of-value mistake this branch set out to fix, one layer down: a caller restating its own certificate paths was reported as changing them, which trips the value-aware update-settings gate. Reported by cubic-dev-ai on PR #7398. * [client] Address the remaining bot findings on PR #7398 - Login logged the active-profile-state error and returned the same cause; the repo's guidelines call for one or the other, and the wrapped error is the one that carries context. (CodeRabbit) - `netbird up` reported a codes.Unavailable SetConfig failure as "the daemon refused the settings update", but that code also covers a daemon that became unreachable. It now reports what the daemon said without asserting why. (cubic-dev-ai) - TestLogin_ChangingTheManagementURLIsRefused asserted the error and nothing else, while "refused before it can touch daemon state" is the contract. It now checks the stored management URL, the in-progress login and the active profile, matching its SetConfig counterpart. (cubic-dev-ai) * [client] Keep the peer identity out of a read that finds no file ReadOrGenerateConfig resolves a default config when the profile has no file yet, and createNewConfig was minting the WireGuard and SSH keys while doing so. That defeated the provisioning pair it was meant to serve: the CLI's foreground login calls EnsureIdentity to find out whether it has to persist the keys, got generated == false because the read had already generated them, and so never wrote them out. The login then dialed management with an identity that only existed in memory, and the next login registered a second peer. createNewConfig no longer provisions. createProvisionedConfig is the variant that does, and the callers whose contract is "usable as it comes back" use it: CreateInMemoryConfig, whose callers connect with the result, and the two create-and-write branches. A read gets a config with no identity, so the caller's own EnsureIdentity reports the work and triggers the write. Reported by CodeRabbit and cubic-dev-ai on PR #7398, both on the same defect. * [client] Stop the gate test from dialing the real management server TestLogin_RestatingTheStoredConfigPassesTheGate asserts that the gate lets a no-op login through, and the handler then went on to do the login for real: isLoginRequired builds an auth client when isLoginRequiredFn is unset, so the test dialed the profile's management URL — api.netbird.io:443. It took 1.05s locally and would hang on a runner with no egress, for a fact about the gate that needs no network at all. Stubbed like the login_outcome tests do. The test now runs in 0.00s. Reported by cubic-dev-ai on PR #7398. * [client] Keep the admin panel path part of its identity The endpoint comparison introduced for the management URL was applied to the admin URL too, and that one is opened in a browser rather than dialed over gRPC: a panel served under /netbird is not the panel served at the root. So a config whose admin URL differed only by path reported no change, and the new path was never persisted — a custom panel URL could not be updated at all. SameServiceURLIncludingPath adds what a URL carries past its endpoint (path, query, fragment, userinfo) while still treating equivalent spellings as equal: a missing path and "/" are the same root, and so is a trailing slash. The management URL keeps the endpoint-only comparison, since only the endpoint is ever dialed. Ports are also normalized numerically now, so ":0443" and ":443" are one port. Reported by cubic-dev-ai on PR #7398 (two findings). * [client] Treat a profile with no identity as already deregistered Two findings on the same consequence of pure reads: a profile can legitimately carry no keys, because logging out clears them in place. - sendLogoutRequestWithConfig went straight to wgtypes.ParseKey and failed with "incorrect key size: 0" on the second logout of the same profile. There is nothing to deregister for a peer that was never registered, so it returns cleanly. Before pure reads this case was hidden: the read minted a key and the daemon dialed management with one it had never seen. - The mobile logout read the config with the generating reader right after checking the file exists. The two are not atomic, so a profile removed in between was resolved from the defaults and recreated by the write that follows. It uses the existing-file reader now. Reported by cubic-dev-ai and CodeRabbit on PR #7398. * [client] Fail `netbird up` when the daemon refuses the settings update With the update-settings kill switch on, `netbird up --enable-rosenpass` connected and said almost nothing: SetConfig refused the change, the CLI downgraded that to a warning, and Login carries no rosenpass field to apply, so the flag was silently dropped. The setting stayed disabled, which is the point of the switch, but the caller was never told their request had been ignored. The refusal now travels as codes.FailedPrecondition instead of codes.Unavailable, and the CLI fails on it. Unavailable means "the daemon cannot serve this call", which is why the CLI downgraded it and why client/ui/services reads it as an unreachable daemon — both wrong for a daemon that answered and refused. FailedPrecondition also matches what the MDM gate already returns for a managed field, so both refusals are now one class of error, and it is added to the login backoff's early-exit codes so a refused login stops instead of retrying for 30s. This does not put the container back in the deadlock: with the value-aware gate, a client restating its own configuration is not refused at all, so nothing reaches this path unless a real change was asked for. * [client] Name the reader storedConfigAtPath actually calls The purity note still said profilemanager.GetConfig, which the rename two commits later turned into GetExistingConfig. Reported by cubic-dev-ai on PR #7398. * [client] Restore the gofmt alignment of the error constants The comment added above errUpdateSettingsDisabled in the previous commit split the const block's alignment group, so gofmt wants the two constants above it re-aligned. CI runs gofmt, so this would have failed the lint job. * [client] Let an unprivileged caller log out a profile with no identity The empty-key check sat behind requirePrivilegeForDeregistration, so an unprivileged logout of an identity-less profile was refused with PermissionDenied instead of completing as the no-op it is. And it was refused for most profiles, not a corner case: the gate arms whenever the SSH server is enabled, and sshServerEnabled reads an absent ServerSSHAllowed as enabled, so every legacy profile qualifies. The check now runs first. What the gate protects against is handing this machine's registered key to another management server; with no key there is nothing to hand over and nothing to protect. Reported by CodeRabbit and cubic-dev-ai on PR #7398, both on the same defect. * [client] Stop `netbird login` from retrying a refusal for 30 seconds `netbird up` and `netbird login` both run Login through the backoff cycle, and each carried its own copy of the list of codes that end it. Only up.go learned about codes.FailedPrecondition, so a refused `netbird login` kept retrying and then reported "login backoff cycle failed" instead of what the daemon said. terminalLoginError is now that list, once, next to WithBackOff — the duplicated copies are what let the two commands disagree in the first place. Reported by cubic-dev-ai on PR #7398. * [client] Answer terminalLoginError's nil case on its own terms A successful Login reaches terminalLoginError with a nil error, and nothing covered that. It happens to work on grpc v1.80.0 — gstatus.FromError(nil) answers (nil, true), and Status.Code tolerates a nil receiver by returning codes.OK, which is not in the terminal set — but that is a chain of internal details to be relying on for the common path, and none of it was asserted. Now the nil error is handled where it is obvious, and the table covers it. Reported by CodeRabbit on PR #7398, which called it a panic; measured on v1.80.0 it is not one. The gap was the untested reliance, not a crash. * [client] Treat an unset optional field as its default when diffing a config Seven Config fields mean "the effective default" when they hold no value: the five SSH toggles, the SSH JWT cache TTL, and the network monitor. Every consumer already reads a nil as that default, but apply() diffed them by presence — `config.X == nil || *input.X != *config.X` — so an input restating the default counted as a change. That made the update-settings gate refuse `netbird up` outright. The CLI sends every flag whose value came from an environment variable (SetFlagsFromEnvVars goes through pflag's FlagSet.Set, which marks the flag Changed), and the config a plain login writes leaves all seven unset, so a container configured with, say, NB_ENABLE_SSH_ROOT=false restated a default the file held as null on every start and was answered with FailedPrecondition. apply() now resolves the seven up front, the way it already did for ServerSSHAllowed and RemoteJobsAllowed, which also repairs such a profile on its next write. With the values named, the comparisons below diff values instead of presence, so their nil branches are gone. The network monitor keeps its platform default — on for windows and darwin — and naming it as false elsewhere is what createEngineConfig already read a nil to be. getJWTCacheTTL reaches the same 0 through its own default, and Android's GetEnableSSH* getters already answered nil with false. * [client] Normalize the config before diffing it in WouldChange apply() reports two different things through one bool: an input that changed a value, and a field it had to fill in because the config carried none. The update-settings gate reads that bool as "the caller asked for a change", so any config still missing a default answered a request that asks for nothing with a refusal. Readers already hand out normalized configs — readConfig applies an empty input for exactly this reason — which is why the gate got away with it. But a handler that refuses a request must not depend on where its caller obtained the config, and it must not start reading "this profile predates a field" as "the caller asked for a change" the day someone adds one with a default. WouldChange now runs the filling-in as a pass of its own and discards its verdict, so the pass that answers the caller measures only what the input did. * [client] Stop the last config write that skipped normalization Every path that creates or updates a profile config goes through apply(), which resolves an optional field to its default — except RenameProfile, which read the file with a bare json.Unmarshal, set the name, and wrote it straight back. That copied whatever the file held, so a config written by a client that stored these fields as null kept them null. It could not introduce a null, only carry one forward, but renaming a profile is a poor place to leave a half-resolved config behind. It now reads through GetExistingConfig, which normalizes what it hands out. The tests state the invariant the fix completes, over the *bool fields of Config listed by reflection so a field added later is covered without touching them: none may come out of apply() unset, and no write may store one as null. An optional bool that can be nil, true or false forces every reader to invent the meaning of nil, and makes a diff of the config compare presence rather than value — which is exactly what refused `netbird up` for a client restating its own defaults. SyncMessageVersion stays a genuine three-state field and is not covered: it is an *int whose absence means the client pins no version, and it travels to management that way. * [client] Refuse a serialized config that carries no peer identity ConfigFromJSON still promised a "fully initialized" config after this PR moved key generation out of apply() into EnsureIdentity, but identity stopped being one of the defaults it applies. Its two callers both connect with what they get back: the iOS SDK's Client.SetConfigFromJSON keeps it as the preloaded config Run() uses on tvOS, and Auth.SetConfigFromJSON as the config it authenticates with. No caller feeds it a document without keys today — every stored document comes from Auth.GetConfigJSON, whose config is provisioned by DirectUpdateOrCreateConfig or CreateInMemoryConfig, and the tvOS app only ever edits fields of a document it already has. This is a safety net for the next caller, not a live bug. Provisioning the identity here would be the wrong net. Neither caller can hand a generated key back to the store the document came from — Client exports no config at all — so the peer would connect under an identity nothing persists and register anew on every launch, which is the failure the EnsureIdentity split exists to prevent. A document with no identity means nobody has logged in yet, and saying so is the only useful answer. Both keys are required because both are dead ends when missing: an empty WireGuard key fails the management login on its size, and an empty SSH key fails ssh.GeneratePublicKey in ConnectClient before the engine starts. * [client] Say that the null-on-disk fixture is synthesized, not written The test comment described the null state in the present tense — "the config a plain login writes leaves every one of them unset" — which was true before this branch and is not any more: apply() now resolves those fields, so a login writes them set. unsetOnDisk puts the null state back deliberately, to stand in for a profile an older client wrote. Comments only. * [client] Gather the optional-field defaults into one function Resolving an unset optional field was spread over five places: the two values newConfigSkeleton pre-sets, the block this branch added for the SSH toggles, the network monitor's own if, the `else if` tails of ServerSSHAllowed and RemoteJobsAllowed, and a trailing if for DisableNotifications several hundred lines further down. Reading apply() left no single answer to "what does this field default to, and who decides". They now live in Config.resolveUnsetDefaults, which apply() calls before it compares anything — the ordering being the point, since it is what lets every comparison below diff values instead of presence. The comparisons for ServerSSHAllowed, RemoteJobsAllowed and DisableNotifications lose their `config.X == nil ||` clauses accordingly, as the other six already had. newConfigSkeleton keeps its two, and that is the one asymmetry worth naming: ServerSSHAllowed defaults to false for a new profile and to true for a legacy one, and it only works because the skeleton runs first. The doc comment says so, where before it was implied by the order of two distant blocks. Pure refactor. Verified as one: for the four fields whose branches moved, plus two that did not and the JWT TTL, all 63 combinations of stored value (nil/false/true) against input value (absent/false/true) produce byte- identical resolved values and `updated` verdicts before and after. * [client] Resolve the merge conflicts left in the tree 262ce8c3b landed with the conflict markers still in it, so client/server and the iOS SDK did not compile. Four regions, resolved as follows. client/server/mdm.go — main moved the MDM conflict-check machinery into the mdm package (mdm.ResolveConflicts, mdm.ConflictBool, mdm.ConflictURL, ...). This branch had edited the local copies, which are now dead: dropped, along with the profilemanager import that only the local conflictURL needed. client/server/server.go, Login gate — this branch's value-aware gate stays (the point of the PR: refuse a real divergence, let a restatement through), so main's presence-based `loginRequestHasConfigOverrides` block goes; that helper no longer exists here anyway. Main's other change in the same lines is real and kept: the MDM policy now comes from the daemon-owned s.mdmLoader.Load() instead of the package-level loadMDMPolicy, which main removed. The stale call right below the conflict was the reason the file would not have compiled even with the markers gone. client/server/server.go, getConfig — both sides add something and both are needed. The identity is provisioned and persisted first, then the MDM overlay is applied, so what reaches disk stays the profile's own config: the overlay is runtime-only and re-derived on every load. client/ios/NetBirdSDK/client.go — main reworked SetConfigFromJSON to store the JSON and re-parse it on each load, which is the shape kept; the parse is now only a validity check, and this branch's reason for it (a document with no peer identity is refused, not just an unparseable one) moves into that comment. client/server/update_settings_gate_test.go — follows the sentinel constant to its new home, mdm.PreSharedKeyRedactedSentinel. * [client] Reuse util's service-URL comparison instead of a second copy The endpoint-comparison rules this branch introduced now live in util (PR #7472 moved them there so the MDM conflict check could stop comparing URLs as strings). Keeping a copy here is what produced that bug in the first place: two implementations of "is this the same endpoint?" drift, and the one that drifts starts refusing a URL that addresses the very server it already points at. So SameServiceURL delegates the port normalization to util.ServiceURLPort and drops the local one, and SameServiceURLIncludingPath — endpoint plus path, for the admin panel URL, which is opened rather than dialed — is util.SameServiceURL plus the query, fragment and userinfo it adds on top, so the local path normalization goes too. What stays here is the distinction util does not make: SameServiceURL is endpoint-only, because a management URL is dialed and only its host and port are, while util.SameServiceURL includes the path. Pure refactor. Verified as one: all 198 pairs of a 14-spelling matrix (default and zero-padded ports, host case, trailing slash, path, query, fragment, userinfo, both schemes, nil operands) answer identically for both functions before and after. * [client] Give a newly added profile its identity (review item 1) AddProfile writes the config it builds straight to disk, but built it with createNewConfig, which stopped generating the peer's keys when identity generation moved out of apply() into EnsureIdentity. The profile file landed with an empty PrivateKey and SSHKey. Nothing lost the keys permanently — the daemon's own getConfig provisions and persists them on first use — but every reader that does not write got a config that cannot connect in the meantime, which is exactly the set this branch grew: the update-settings gate deciding whether to refuse a request, and the mobile SDKs loading a stored profile. createProvisionedConfig exists for callers that persist or connect, and this is one; before the split, createNewConfig produced the keys here too. * [client] Let a logged-out profile deserialize again (review item 2) ConfigFromJSON refused a document with no WireGuard or SSH key. A config legitimately has none between a logout and the next login: mobile LogoutProfile clears both in place and writes the profile back, so the peer re-registers on the next login instead of returning as itself. So the refusal broke the mobile flows it was meant to protect. On iOS and tvOS the stored JSON of a logged-out profile stopped loading through Client.SetConfigFromJSON and Auth.SetConfigFromJSON, and copyConfig — which round-trips a Config through JSON to take an in-memory copy before applying the MDM overlay — failed on the same document. Where the old code silently minted a key, this returned an error, which is worse for logout and profile switching alike: neither is asking to connect. The deserializer now stays out of the identity question in both directions: it does not generate one (a read cannot hand back keys nothing will write down) and does not refuse one that is absent. Whoever goes on to connect is where an absent identity has to be answered — and it already is, by the login path that provisions and persists. ErrConfigWithoutIdentity goes with it; nothing else used it. * [client] Fold the scheme case here too, like util does (review item 6) profilemanager.SameServiceURL compared the scheme with ==, util.SameServiceURL with EqualFold. No observable difference — net/url lowercases the scheme when it parses, and both functions take parsed URLs — but two functions of the same name with two different rules is a trap for whoever reads one and assumes the other. * [client] Classify the daemon's refusals in the GUI (review item 3) FailedPrecondition reached the classifier unmatched, so a refusal showed as "Operation failed". It is the code both of the daemon's deliberate refusals carry: the update-settings kill switch, and a field an MDM policy manages. Both are now named — settings_locked and settings_managed_by_mdm, matched on the message the daemon composes — and FailedPrecondition itself falls back to change_refused, so a refusal the daemon grows later still reads as a refusal rather than a failure. Only the English strings are added. Bundle.Translate falls back to the default language for a missing key, so other locales show English until the usual translation pass, rather than the bare "error." the classifier would otherwise surface. Note: the package needs GTK4/WebKit to build, which this machine has not, so the test is type-checked (go vet, GOOS=windows) but was not executed locally; CI's Linux job runs it. * [client] Cover the mobile profile round trip: create, logout, reload Both mobile regressions this branch's review turned up lived on the same path, and neither was visible from the desktop client: a profile created without an identity, and a logged-out profile that would no longer deserialize. The desktop never meets the second one — it is mobile logout that clears the peer's keys in place, so the next login registers a new peer instead of bringing the old one back. The test walks a profile through the round its user puts it through — created, logged out, loaded again, switched away from and back — and loads it at each step the way the SDKs do: read the stored config, serialize it, load it back. That is Client.SetConfigFromJSON storing the document for tvOS, Auth.SetConfigFromJSON authenticating with it, and copyConfig taking an in-memory copy before the MDM overlay. Verified to fail on each regression separately: restoring the bare constructor in AddProfile fails it with "a new profile was written with no identity", and restoring the identity check in ConfigFromJSON fails it at "load the profile back". client/mobile already had the coverage for the first one in TestLogoutProfile_DisableProfiles — which arrived from main with the MDM work, and which I had not been running. * [client] Name only the refusals, not every FailedPrecondition The classifier gained a blanket FailedPrecondition -> change_refused fallback so a refusal would stop reading as "Operation failed". It reaches too far: the daemon returns that code for two dozen states that are not settings refusals — "not logged in", "client is not running", "another capture is already running", "session can no longer be extended, log in again to reconnect" — and errorClassifier is shared with the session and connection services, not just the settings save. So the user was told the service had refused their change while what they actually had to do was log in again. The two refusals the daemon composes stay named by their message; everything else goes back to the generic message, which says nothing rather than something wrong. Reported by cubic on the PR. * [client] Say what each assertion was checking in the mobile test AGENTS.md asks for a context message on comparison and boolean assertions, and four of the ones added with this test had none, so a failure would have read as a bare Empty/Equal with no hint of which step of the round trip broke. Reported by cubic on the PR. * [client] Translate the two new error strings into every locale The GUI classifier gained error.settings_locked and error.settings_managed_by_mdm, and only the English strings were added: the bundle falls back to the default language for a missing key, so nothing would have shown a bare "error." to a user. CI disagrees, and it is right to: check-translations.mjs requires every locale to carry the full English key set, so English-only fails the gate rather than degrading quietly. The ten locales now carry both strings. These are my translations, not a localization pass — worth a second pass by whoever owns the language, in particular for the phrasing of "an administrator has locked them". The uk file also loses two lines of stray 8-space indentation, normalized by rewriting the file; no key or value changed with it. * [client] Persist the profile before overlaying MDM on it (review item) `netbird login` read the config, applied the MDM policy on top, and only then provisioned the identity and wrote the result out. On a profile with no identity yet — a first login — that write persisted the enforced values into the user's own config file: an MDM-managed management URL or pre-shared key became indistinguishable from one the user set, and stayed behind once the policy was withdrawn. Provisioning and its write now come first, and the overlay is applied to the in-memory config afterwards, where it belongs: it is re-derived on every load and never meant to reach disk from here. Server.getConfig already orders the two this way; the two paths now agree. Reported by cubic on the PR. * [client] Assert against the stored config, not a resolved default (review item) The login-gate test read the profile back with ReadOrGenerateConfig, which resolves a default config in memory when the file is missing — and that default's management URL is the very value the assertion checks. An erased or mislocated profile would have passed the test instead of failing it. The file is written by the test itself, so GetExistingConfig is the right reader: it errors when the file is gone. Reported by cubic on the PR. * [client] Keep the mTLS pair off the gate's dry run (review item) WouldChange runs the real apply() against a throwaway copy, and apply() loads the client mTLS certificate and key from disk whenever the config names them. So every gated SetConfig and Login read the pair — twice per request, once for the normalization pass and once for the verdict — including requests that were about to be refused or that changed nothing, and logged an error per request when the files were missing. The gate used to be presence-based and never called apply(), so this was new work on a request path. The loaded pair feeds the connection and never the comparison: nothing in apply() reads it back, and it does not move the `updated` verdict. A config built only to be compared against now says so, and apply() skips the load for it. Reported by cubic on the PR. * Makes it explicit that RenameProfile does write on disk * [client] Provision the peer identity under the config lock (review item) Login took the authoritative update-settings and privilege decisions under guardedConfigMu, then released it and called getConfig, which mints the peer's identity and writes the config out. Between that read and that write, a SetConfig holding the same lock could land a change and answer its caller — and then be overwritten by the config the login had already read. The window is narrow: getConfig only writes when the profile has no identity or no file, so in practice a first login racing a settings change on the same profile. It is also narrower than before this branch, where the write happened inside the reader on every read that filled in a default. Provisioning now runs where the decision it belongs to runs: at the end of authorizeAndPrepareLogin, with the lock already held, next to persistLoginOverrides, which writes there too. No lock is taken that was not held before, so the documented guardedConfigMu-then-mutex order is untouched. getConfig keeps its behaviour by calling the same extracted helper; on the login path it now finds the identity already there and writes nothing. The other callers are unchanged, and still provision outside any lock — a concurrent SetConfig is not part of their flow. Reported by cubic on the PR. * [client] Declare the probe marker to the debug-bundle field check TestAddConfig_AllFieldsCovered walks Config by reflection and fails until every field is either rendered in the debug bundle or listed as excluded with a reason. The probe marker added for the gate's dry run was neither, so the client unit suite went red on every platform. It is excluded: it marks a throwaway copy built to be compared against and discarded, so it is never set on a config anyone runs with, and rendering it would only ever print false. * [client] Provision the peer identity on the iOS login path Key generation used to happen inside apply(), so a config loaded from JSON with no keys got them in memory on the way in, the login worked, and the app stored the result. This branch moved generation into EnsureIdentity, and nothing in the iOS SDK called it. The consequence lands on the flow the mobile logout sets up: logout clears both keys in place so the next login registers a new peer. The app then hands that keyless JSON to Auth.SetConfigFromJSON, and the login calls auth.NewAuth with an empty WireGuard key, which fails on key size before the SSO flow starts — the user cannot sign back in. Auth.setBaseConfig now provisions, which covers both entry points (NewAuth and SetConfigFromJSON). It mints on the base config, the one GetConfigJSON returns for the caller to persist, and writes it to disk itself when the profile has a file — non-atomically, like NewAuth's own write, since the tvOS App Group sandbox blocks temp-file-and-rename. Not covered by a test: the package builds only under GOOS=ios, which the test jobs do not run. Verified by building and vetting for GOOS=ios/arm64. Reported by pappz in review. * [client] Name the resolving reader for what it does, not what it makes ReadOrGenerateConfig reads the profile config and falls back to the defaults in memory when there is no file. "Generate" reads as "produces and stores", which is the opposite of the property the rename it came from was meant to advertise: the read is pure, writes nothing and mints no identity. ReadConfigOrDefault says the same without the side effect, and pairs with GetExistingConfig, which fails where this one falls back. Its doc comment now states the absence of a write rather than only the fallback. Pure rename; the two remaining mentions of the pre-branch name ReadConfig in the tests go with it. Reported by pappz in review. * [client] Read an emptied NAT list as the absent one it matches apply() compared NATExternalIPs with reflect.DeepEqual, which calls a nil slice and an empty slice different. Both mean the same thing — no NAT mappings — and the two meet on a perfectly ordinary start: a profile stores the absent list as JSON null and reads it back nil, while `netbird up` sends CleanNATExternalIPs, an empty list, whenever NB_EXTERNAL_IP_MAP is set to nothing, which a deployment template does by default. So the gate saw a change where nothing changed and refused the request with FailedPrecondition. That is the same deadlock this branch exists to remove, reached through another field: a container with the kill switch on could not come up, and `netbird up` reported "the daemon refused the settings update". The DNS label list next to it already used slices.Equal, which treats nil and empty as the same list. The NAT list now does too, and the last use of reflect in the package goes with it. Reported by pappz in review. --- client/android/preferences.go | 34 +- client/cmd/login.go | 27 +- client/cmd/root.go | 39 ++ client/cmd/up.go | 35 +- client/cmd/up_setconfig_refusal_test.go | 85 +++ client/internal/debug/debug_test.go | 1 + client/internal/profilemanager/config.go | 483 ++++++++++++---- .../profilemanager/config_json_test.go | 44 ++ .../config_optional_fields_test.go | 131 +++++ .../profilemanager/config_probe_test.go | 96 ++++ client/internal/profilemanager/config_test.go | 2 +- .../config_would_change_test.go | 529 ++++++++++++++++++ client/internal/profilemanager/service.go | 33 +- .../internal/profilemanager/service_test.go | 24 + client/ios/NetBirdSDK/client.go | 4 + client/ios/NetBirdSDK/login.go | 29 + client/ios/NetBirdSDK/preferences.go | 14 +- client/mobile/profile_lifecycle_test.go | 97 ++++ client/mobile/profile_manager.go | 5 +- client/server/login_gate_test.go | 2 +- client/server/login_overrides_test.go | 8 +- client/server/logout_gate_test.go | 2 +- client/server/mdm.go | 86 --- client/server/provision_identity_test.go | 59 ++ client/server/server.go | 204 ++++--- client/server/setconfig_mdm_test.go | 2 +- client/server/setconfig_test.go | 2 +- client/server/ssh_gate.go | 18 +- client/server/update_settings_gate.go | 55 ++ client/server/update_settings_gate_test.go | 390 +++++++++++++ client/ui/i18n/locales/de/common.json | 6 + client/ui/i18n/locales/en/common.json | 8 + client/ui/i18n/locales/es/common.json | 6 + client/ui/i18n/locales/fr/common.json | 6 + client/ui/i18n/locales/hu/common.json | 6 + client/ui/i18n/locales/it/common.json | 6 + client/ui/i18n/locales/ja/common.json | 6 + client/ui/i18n/locales/pt/common.json | 6 + client/ui/i18n/locales/ru/common.json | 6 + client/ui/i18n/locales/uk/common.json | 6 + client/ui/i18n/locales/zh-CN/common.json | 6 + client/ui/services/errors.go | 11 + client/ui/services/errors_test.go | 23 + 43 files changed, 2315 insertions(+), 327 deletions(-) create mode 100644 client/cmd/up_setconfig_refusal_test.go create mode 100644 client/internal/profilemanager/config_json_test.go create mode 100644 client/internal/profilemanager/config_optional_fields_test.go create mode 100644 client/internal/profilemanager/config_probe_test.go create mode 100644 client/internal/profilemanager/config_would_change_test.go create mode 100644 client/mobile/profile_lifecycle_test.go create mode 100644 client/server/provision_identity_test.go create mode 100644 client/server/update_settings_gate.go create mode 100644 client/server/update_settings_gate_test.go diff --git a/client/android/preferences.go b/client/android/preferences.go index 5ce31026c..3623de23f 100644 --- a/client/android/preferences.go +++ b/client/android/preferences.go @@ -46,7 +46,7 @@ func (p *Preferences) GetManagementURL() (string, error) { return p.configInput.ManagementURL, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return "", err } @@ -64,7 +64,7 @@ func (p *Preferences) GetAdminURL() (string, error) { return p.configInput.AdminURL, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return "", err } @@ -86,7 +86,7 @@ func (p *Preferences) HasPreSharedKey() (bool, error) { return *p.configInput.PreSharedKey != "", nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -112,7 +112,7 @@ func (p *Preferences) GetRosenpassEnabled() (bool, error) { return *p.configInput.RosenpassEnabled, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -133,7 +133,7 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) { return *p.configInput.RosenpassPermissive, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -149,7 +149,7 @@ func (p *Preferences) GetDisableClientRoutes() (bool, error) { return *p.configInput.DisableClientRoutes, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -170,7 +170,7 @@ func (p *Preferences) GetDisableServerRoutes() (bool, error) { return *p.configInput.DisableServerRoutes, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -188,7 +188,7 @@ func (p *Preferences) GetDisableDNS() (bool, error) { return *p.configInput.DisableDNS, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -206,7 +206,7 @@ func (p *Preferences) GetDisableFirewall() (bool, error) { return *p.configInput.DisableFirewall, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -227,7 +227,7 @@ func (p *Preferences) GetServerSSHAllowed() (bool, error) { return *p.configInput.ServerSSHAllowed, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -249,7 +249,7 @@ func (p *Preferences) GetEnableSSHRoot() (bool, error) { return *p.configInput.EnableSSHRoot, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -271,7 +271,7 @@ func (p *Preferences) GetEnableSSHSFTP() (bool, error) { return *p.configInput.EnableSSHSFTP, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -293,7 +293,7 @@ func (p *Preferences) GetEnableSSHLocalPortForwarding() (bool, error) { return *p.configInput.EnableSSHLocalPortForwarding, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -315,7 +315,7 @@ func (p *Preferences) GetEnableSSHRemotePortForwarding() (bool, error) { return *p.configInput.EnableSSHRemotePortForwarding, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -340,7 +340,7 @@ func (p *Preferences) GetBlockInbound() (bool, error) { return *p.configInput.BlockInbound, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -358,7 +358,7 @@ func (p *Preferences) GetDisableIPv6() (bool, error) { return *p.configInput.DisableIPv6, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -377,7 +377,7 @@ func (p *Preferences) GetRemoteJobsAllowed() (bool, error) { return *p.configInput.RemoteJobsAllowed, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } diff --git a/client/cmd/login.go b/client/cmd/login.go index 11867be09..1dc2d0d09 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -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 diff --git a/client/cmd/root.go b/client/cmd/root.go index be6479440..2ca14c39c 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -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" @@ -285,6 +287,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) { diff --git a/client/cmd/up.go b/client/cmd/up.go index f5fac9749..120a25595 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -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 diff --git a/client/cmd/up_setconfig_refusal_test.go b/client/cmd/up_setconfig_refusal_test.go new file mode 100644 index 000000000..fdf580102 --- /dev/null +++ b/client/cmd/up_setconfig_refusal_test.go @@ -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)) + }) + } +} diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 6a810bccc..0f74490f2 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -846,6 +846,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { "ClientCertKeyPair": "non-config: parsed cert pair, not serialized", "Name": "non-config: profile name is not needed for debug purposes", "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields", + "probing": "non-config: marks a throwaway copy built to be diffed against; never set on a config anyone runs with", "DebugBundleUploadURL": "sensitive: MDM-provided upload URL may carry credentials or query tokens; kept out of the shared bundle", } diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index 412f81b5c..ac1b90a62 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -10,7 +10,6 @@ import ( "os" "os/user" "path/filepath" - "reflect" "runtime" "slices" "strings" @@ -198,6 +197,11 @@ type Config struct { MTU uint16 + // probing marks a config that exists only to be compared against and then + // thrown away, so apply() can skip the work that feeds no verdict. + // Unexported, so it never reaches the JSON. + probing bool + // policy is the MDM policy that produced the currently-set values // for any MDM-enforced fields. Set by ApplyMDMPolicy on every // invocation. Never persisted to disk. Callers query enforcement @@ -300,9 +304,11 @@ func fileExists(path string) (bool, error) { return false, err } -// createNewConfig creates a new config generating a new Wireguard key and saving to file -func createNewConfig(input ConfigInput) (*Config, error) { - config := &Config{ +// newConfigSkeleton returns the field values a brand-new profile config starts +// from, before apply() fills in the rest. Shared with the dry-run baseline so +// the two cannot disagree about what "a new config" means. +func newConfigSkeleton() *Config { + return &Config{ // defaults to false only for new (post 0.26) configurations ServerSSHAllowed: util.False(), // Remote jobs are an explicit opt-in and default off, including for @@ -310,6 +316,91 @@ func createNewConfig(input ConfigInput) (*Config, error) { RemoteJobsAllowed: util.False(), WgPort: iface.DefaultWgPort, } +} + +// resolveUnsetDefaults is the single place where an optional field that carries +// no value gets one, and the only place that states what each of those defaults +// is. apply() runs it before it compares anything, and that ordering is the +// point: with the values named, every comparison below it diffs values instead +// of presence. +// +// Presence-based comparison is what broke `netbird up` for a client configured +// through the environment. These fields mean "the effective default" when they +// hold nothing — every consumer already reads a nil as the value resolved here, +// the SSH toggles in engine_ssh.go and the network monitor in +// createEngineConfig — so naming them changes nothing about what runs. But +// while they stayed nil, an input restating the default read as a change, and +// since the CLI sends every flag whose value came from an environment variable +// on each `netbird up`, a client with NB_ENABLE_SSH_ROOT=false restated it +// every time and the update-settings gate refused it. +// +// Filling a field in is not a settings change, so a caller measuring change +// must not read the returned bool as one: see WouldChange, which runs a pass +// for this and discards its verdict. +// +// ServerSSHAllowed is the one field whose default depends on the config's age. +// A brand-new profile gets false from newConfigSkeleton, which runs before +// this, so what is resolved here is only the legacy case: a config written by a +// version that had no such field keeps SSH on, for backwards compatibility. +func (config *Config) resolveUnsetDefaults() (updated bool) { + // Fields that default to false on every platform. + for _, field := range []**bool{ + &config.EnableSSHRoot, + &config.EnableSSHSFTP, + &config.EnableSSHLocalPortForwarding, + &config.EnableSSHRemotePortForwarding, + &config.DisableSSHAuth, + // Remote jobs are an explicit opt-in: unlike SSH, a pre-existing config + // with no value defaults to disabled rather than being turned on. + &config.RemoteJobsAllowed, + } { + if *field == nil { + *field = util.False() + updated = true + } + } + + if config.DisableNotifications == nil { + log.Infof("setting notifications to disabled by default") + config.DisableNotifications = util.True() + updated = true + } + + if config.SSHJWTCacheTTL == nil { + // A zero TTL disables the JWT cache, which is what no value meant. + config.SSHJWTCacheTTL = new(int) + updated = true + } + + if config.NetworkMonitor == nil { + // network monitoring is on by default on windows and darwin clients + enabled := runtime.GOOS == "windows" || runtime.GOOS == "darwin" + config.NetworkMonitor = &enabled + updated = true + } + + if config.ServerSSHAllowed == nil { + if runtime.GOOS == "android" { + // default to disabled SSH on Android for security + log.Infof("setting SSH server to false by default on Android") + config.ServerSSHAllowed = util.False() + } else { + // enables SSH for configs from old versions to preserve backwards compatibility + log.Infof("falling back to enabled SSH server for pre-existing configuration") + config.ServerSSHAllowed = util.True() + } + updated = true + } + + return updated +} + +// createNewConfig resolves a new config in memory, with no identity: whoever +// needs the peer's keys calls EnsureIdentity and persists the result, so a read +// that lands on a missing file cannot hand back a config carrying keys that +// nothing will ever write down. +func createNewConfig(input ConfigInput) (*Config, error) { + config := newConfigSkeleton() if _, err := config.apply(input); err != nil { return nil, err @@ -318,6 +409,52 @@ func createNewConfig(input ConfigInput) (*Config, error) { return config, nil } +// createProvisionedConfig is createNewConfig plus the peer's identity, for the +// callers that go on to persist the config or to connect with it. +func createProvisionedConfig(input ConfigInput) (*Config, error) { + config, err := createNewConfig(input) + if err != nil { + return nil, err + } + + if _, err := config.EnsureIdentity(); err != nil { + return nil, err + } + + return config, nil +} + +// EnsureIdentity generates the keys that identify this peer if the config does +// not carry them yet, reporting whether it had to generate any. +// +// It is deliberately not part of apply(). Everything apply() fills in is a +// default it can recompute on the next read, but a generated key is not: it +// has to be persisted, or the peer comes back with a different WireGuard +// identity and re-registers. Having apply() generate keys is what forced every +// read of a config to write it back — so identity provisioning is its own step +// now, and the callers that perform it write the result out explicitly. +func (config *Config) EnsureIdentity() (bool, error) { + generated := false + + if config.PrivateKey == "" { + log.Infof("generated new Wireguard key") + config.PrivateKey = generateKey() + generated = true + } + + if config.SSHKey == "" { + log.Infof("generated new SSH key") + pem, err := ssh.GeneratePrivateKey(ssh.ED25519) + if err != nil { + return generated, err + } + config.SSHKey = string(pem) + generated = true + } + + return generated, nil +} + func (config *Config) apply(input ConfigInput) (updated bool, err error) { if config.Name != "" { sanitized, err := sanitizeDisplayName(config.Name) @@ -329,6 +466,13 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } } + + // Every optional field gets its value here, before anything below compares + // one. See resolveUnsetDefaults for why that ordering is the point. + if config.resolveUnsetDefaults() { + updated = true + } + if config.ManagementURL == nil { log.Infof("using default Management URL %s", DefaultManagementURL) config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL) @@ -336,20 +480,21 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { return false, err } } - if input.ManagementURL != "" && input.ManagementURL != config.ManagementURL.String() { - log.Infof("new Management URL provided, updated to %#v (old value %#v)", - input.ManagementURL, config.ManagementURL.String()) + // The comparison is on the endpoint the URL addresses, not on its + // spelling: the same endpoint can be written several ways (an implicit + // :443, a trailing slash, a different host case), and treating an + // equivalent URL as new would rewrite the config and report a settings + // change where the configuration does not actually change. + if input.ManagementURL != "" { URL, err := parseURL("Management URL", input.ManagementURL) if err != nil { return false, err } - config.ManagementURL = URL - updated = true - } else if config.ManagementURL == nil { - log.Infof("using default Management URL %s", DefaultManagementURL) - config.ManagementURL, err = parseURL("Management URL", DefaultManagementURL) - if err != nil { - return false, err + if !SameServiceURL(URL, config.ManagementURL) { + log.Infof("new Management URL provided, updated to %#v (old value %#v)", + URL.String(), config.ManagementURL.String()) + config.ManagementURL = URL + updated = true } } @@ -360,31 +505,20 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { return false, err } } - if input.AdminURL != "" && input.AdminURL != config.AdminURL.String() { - log.Infof("new Admin Panel URL provided, updated to %#v (old value %#v)", - input.AdminURL, config.AdminURL.String()) + // The admin panel is opened, not dialed, so unlike the Management URL its + // path is part of what identifies it: a panel served under /netbird is not + // the one served at the root. + if input.AdminURL != "" { newURL, err := parseURL("Admin Panel URL", input.AdminURL) if err != nil { return updated, err } - config.AdminURL = newURL - updated = true - } - - if config.PrivateKey == "" { - log.Infof("generated new Wireguard key") - config.PrivateKey = generateKey() - updated = true - } - - if config.SSHKey == "" { - log.Infof("generated new SSH key") - pem, err := ssh.GeneratePrivateKey(ssh.ED25519) - if err != nil { - return false, err + if !SameServiceURLIncludingPath(newURL, config.AdminURL) { + log.Infof("new Admin Panel URL provided, updated to %#v (old value %#v)", + newURL.String(), config.AdminURL.String()) + config.AdminURL = newURL + updated = true } - config.SSHKey = string(pem) - updated = true } if input.WireguardPort != nil && *input.WireguardPort != config.WgPort { @@ -405,7 +539,14 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.NATExternalIPs != nil && !reflect.DeepEqual(config.NATExternalIPs, input.NATExternalIPs) { + // slices.Equal, not reflect.DeepEqual, and for the same reason the DNS + // labels below use it: DeepEqual calls a nil slice and an empty one + // different, while both mean "no NAT mappings". A profile stores the + // absent list as JSON null and reads it back nil, and `netbird up` sends + // CleanNATExternalIPs — an empty list — whenever NB_EXTERNAL_IP_MAP is set + // to nothing, so the two met on every start and the gate read a no-op as a + // settings change. + if input.NATExternalIPs != nil && !slices.Equal(config.NATExternalIPs, input.NATExternalIPs) { log.Infof("updating NAT External IP [ %s ] (old value: [ %s ])", strings.Join(input.NATExternalIPs, " "), strings.Join(config.NATExternalIPs, " ")) @@ -443,21 +584,12 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.NetworkMonitor != nil && (config.NetworkMonitor == nil || *input.NetworkMonitor != *config.NetworkMonitor) { + if input.NetworkMonitor != nil && *input.NetworkMonitor != *config.NetworkMonitor { log.Infof("switching Network Monitor to %t", *input.NetworkMonitor) config.NetworkMonitor = input.NetworkMonitor updated = true } - if config.NetworkMonitor == nil { - // enable network monitoring by default on windows and darwin clients - if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { - enabled := true - config.NetworkMonitor = &enabled - updated = true - } - } - if input.CustomDNSAddress != nil && string(input.CustomDNSAddress) != config.CustomDNSAddress { log.Infof("updating custom DNS address %#v (old value %#v)", string(input.CustomDNSAddress), config.CustomDNSAddress) @@ -490,7 +622,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.ServerSSHAllowed != nil && (config.ServerSSHAllowed == nil || *input.ServerSSHAllowed != *config.ServerSSHAllowed) { + if input.ServerSSHAllowed != nil && *input.ServerSSHAllowed != *config.ServerSSHAllowed { if *input.ServerSSHAllowed { log.Infof("enabling SSH server") } else { @@ -498,20 +630,9 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { } config.ServerSSHAllowed = input.ServerSSHAllowed updated = true - } else if config.ServerSSHAllowed == nil { - if runtime.GOOS == "android" { - // default to disabled SSH on Android for security - log.Infof("setting SSH server to false by default on Android") - config.ServerSSHAllowed = util.False() - } else { - // enables SSH for configs from old versions to preserve backwards compatibility - log.Infof("falling back to enabled SSH server for pre-existing configuration") - config.ServerSSHAllowed = util.True() - } - updated = true } - if input.RemoteJobsAllowed != nil && (config.RemoteJobsAllowed == nil || *input.RemoteJobsAllowed != *config.RemoteJobsAllowed) { + if input.RemoteJobsAllowed != nil && *input.RemoteJobsAllowed != *config.RemoteJobsAllowed { if *input.RemoteJobsAllowed { log.Infof("enabling remote jobs") } else { @@ -519,14 +640,9 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { } config.RemoteJobsAllowed = input.RemoteJobsAllowed updated = true - } else if config.RemoteJobsAllowed == nil { - // Remote jobs are an explicit opt-in: unlike SSH, a pre-existing config - // with no value defaults to disabled rather than being turned on. - config.RemoteJobsAllowed = util.False() - updated = true } - if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) { + if input.EnableSSHRoot != nil && *input.EnableSSHRoot != *config.EnableSSHRoot { if *input.EnableSSHRoot { log.Infof("enabling SSH root login") } else { @@ -536,7 +652,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHSFTP != nil && (config.EnableSSHSFTP == nil || *input.EnableSSHSFTP != *config.EnableSSHSFTP) { + if input.EnableSSHSFTP != nil && *input.EnableSSHSFTP != *config.EnableSSHSFTP { if *input.EnableSSHSFTP { log.Infof("enabling SSH SFTP subsystem") } else { @@ -546,7 +662,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHLocalPortForwarding != nil && (config.EnableSSHLocalPortForwarding == nil || *input.EnableSSHLocalPortForwarding != *config.EnableSSHLocalPortForwarding) { + if input.EnableSSHLocalPortForwarding != nil && *input.EnableSSHLocalPortForwarding != *config.EnableSSHLocalPortForwarding { if *input.EnableSSHLocalPortForwarding { log.Infof("enabling SSH local port forwarding") } else { @@ -556,7 +672,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.EnableSSHRemotePortForwarding != nil && (config.EnableSSHRemotePortForwarding == nil || *input.EnableSSHRemotePortForwarding != *config.EnableSSHRemotePortForwarding) { + if input.EnableSSHRemotePortForwarding != nil && *input.EnableSSHRemotePortForwarding != *config.EnableSSHRemotePortForwarding { if *input.EnableSSHRemotePortForwarding { log.Infof("enabling SSH remote port forwarding") } else { @@ -566,7 +682,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.DisableSSHAuth != nil && (config.DisableSSHAuth == nil || *input.DisableSSHAuth != *config.DisableSSHAuth) { + if input.DisableSSHAuth != nil && *input.DisableSSHAuth != *config.DisableSSHAuth { if *input.DisableSSHAuth { log.Infof("disabling SSH authentication") } else { @@ -576,7 +692,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.SSHJWTCacheTTL != nil && (config.SSHJWTCacheTTL == nil || *input.SSHJWTCacheTTL != *config.SSHJWTCacheTTL) { + if input.SSHJWTCacheTTL != nil && *input.SSHJWTCacheTTL != *config.SSHJWTCacheTTL { log.Infof("updating SSH JWT cache TTL to %d seconds", *input.SSHJWTCacheTTL) config.SSHJWTCacheTTL = input.SSHJWTCacheTTL updated = true @@ -659,13 +775,16 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.SyncMessageVersion != nil && *input.SyncMessageVersion != *config.SyncMessageVersion { + // Assigning the pointer, not writing through it: a config that carries no + // version yet would otherwise be a nil dereference, and a panic inside a + // request handler is not a way to fail. + if input.SyncMessageVersion != nil && (config.SyncMessageVersion == nil || *input.SyncMessageVersion != *config.SyncMessageVersion) { log.Infof("setting SyncMessageVersion to %v", *input.SyncMessageVersion) - *config.SyncMessageVersion = *input.SyncMessageVersion + config.SyncMessageVersion = input.SyncMessageVersion updated = true } - if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) { + if input.DisableNotifications != nil && *input.DisableNotifications != *config.DisableNotifications { if *input.DisableNotifications { log.Infof("disabling notifications") } else { @@ -675,24 +794,24 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if config.DisableNotifications == nil { - disabled := true - config.DisableNotifications = &disabled - log.Infof("setting notifications to disabled by default") - updated = true - } - - if input.ClientCertKeyPath != "" { + // Compared, not just assigned: restating the path a config already holds + // changes nothing, and reporting it as an update makes a caller that + // re-sends its own configuration look like one asking to change it. + if input.ClientCertKeyPath != "" && input.ClientCertKeyPath != config.ClientCertKeyPath { config.ClientCertKeyPath = input.ClientCertKeyPath updated = true } - if input.ClientCertPath != "" { + if input.ClientCertPath != "" && input.ClientCertPath != config.ClientCertPath { config.ClientCertPath = input.ClientCertPath updated = true } - if config.ClientCertPath != "" && config.ClientCertKeyPath != "" { + // Not on a probe: the loaded pair feeds the connection, never the + // comparison, and this would otherwise run on every gated SetConfig and + // Login — twice per request — including those that are refused or change + // nothing, logging an error per request when the files are missing. + if !config.probing && config.ClientCertPath != "" && config.ClientCertKeyPath != "" { cert, err := tls.LoadX509KeyPair(config.ClientCertPath, config.ClientCertKeyPath) if err != nil { log.Error("Failed to load mTLS cert/key pair: ", err) @@ -886,6 +1005,49 @@ func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) { return parseURL(serviceName, serviceURL) } +// SameServiceURL reports whether two service URLs address the same endpoint: +// same scheme, same host compared case-insensitively as DNS names are, and +// same effective port, where an absent port means the scheme's default. +// +// This is the one comparison every caller deciding "did this URL change?" must +// use. A string comparison answers a different question: "https://host", +// "https://host/" and "https://HOST:443" are one endpoint written three ways, +// and reading them as three values makes a client that restates its own +// management URL look like a client asking to be repointed. A nil operand +// matches only another nil one. +// +// The path plays no part: a management URL is dialed, and only its host and +// port are. util.SameServiceURL is this comparison plus the path, which is +// what SameServiceURLIncludingPath needs and delegates to. +func SameServiceURL(a, b *url.URL) bool { + if a == nil || b == nil { + return a == b + } + + return strings.EqualFold(a.Scheme, b.Scheme) && + strings.EqualFold(a.Hostname(), b.Hostname()) && + util.ServiceURLPort(a) == util.ServiceURLPort(b) +} + +// SameServiceURLIncludingPath is SameServiceURL plus everything a URL carries +// past its endpoint: path, query, fragment and userinfo. +// +// Use it for a URL that gets opened rather than dialed. The admin panel can +// live under a path, so two URLs with the same endpoint and different paths are +// two different panels — where for a URL the client dials over gRPC only the +// endpoint is ever used. Equivalent spellings still compare equal: a missing +// path and "/" are the same root, and so is a trailing slash on any path. +func SameServiceURLIncludingPath(a, b *url.URL) bool { + if a == nil || b == nil { + return a == b + } + + return util.SameServiceURL(a, b) && + a.RawQuery == b.RawQuery && + a.Fragment == b.Fragment && + a.User.String() == b.User.String() +} + func parseURL(serviceName, serviceURL string) (*url.URL, error) { parsedMgmtURL, err := url.ParseRequestURI(serviceURL) if err != nil { @@ -930,6 +1092,84 @@ func isPreSharedKeyHidden(preSharedKey *string) bool { return false } +// WouldChange reports whether applying input would modify any field the +// config persists, leaving the receiver untouched. It is the dry-run half of +// UpdateConfig and reuses the very same diff logic (Config.apply), so a +// caller asking "is this a settings change?" cannot drift from what an +// actual update would do, nor go stale when a new field is added. +// +// A redacted pre-shared key is collapsed to "unset" exactly as +// UpdateOrCreateConfig does, so a UI that round-trips the mask is not read as +// a request for a new key. +// +// A nil receiver means the profile holds no config yet, so the baseline is the +// config the daemon would create for it: input values matching those defaults +// change nothing, anything else does. +func (config *Config) WouldChange(input ConfigInput) (bool, error) { + probe := config.clone() + if probe == nil { + baseline, err := newDryRunBaseline(input.ConfigPath) + if err != nil { + return true, fmt.Errorf("build default config baseline: %w", err) + } + probe = baseline + } + probe.probing = true + + // Normalize before measuring. apply() reports two different things through + // one bool: an input that changed a value, and a field it had to fill in + // because the config carried none. Only the first is a settings change, so + // the filling-in gets a pass of its own whose verdict is discarded, and the + // pass that answers the caller runs against a config with nothing left to + // fill in. + // + // Readers already hand out normalized configs — readConfig applies an empty + // input for this very reason — so this is normally a no-op. But a gate that + // refuses a request must not depend on where its caller got the config + // from, and it must not start reading "this profile predates a field" as + // "the caller asked for a change" the day someone adds one. + if _, err := probe.apply(ConfigInput{ConfigPath: input.ConfigPath}); err != nil { + return true, fmt.Errorf("normalize the config to diff against: %w", err) + } + + if isPreSharedKeyHidden(input.PreSharedKey) { + input.PreSharedKey = nil + } + + return probe.apply(input) +} + +// newDryRunBaseline builds the config a brand-new profile would start from, for +// a dry run to compare an input against. It is createNewConfig without the +// identity: this config exists only to be compared against and thrown away, and +// no ConfigInput field maps to either key. +func newDryRunBaseline(configPath string) (*Config, error) { + baseline := newConfigSkeleton() + + if _, err := baseline.apply(ConfigInput{ConfigPath: configPath}); err != nil { + return nil, err + } + + return baseline, nil +} + +// clone returns a copy of the config that apply can be run against without the +// original observing the writes, or nil for a nil receiver. Only what apply +// mutates in place needs detaching, which is the slices it replaces or appends +// to: every pointer field it touches is reassigned rather than written through, +// and ClientCertKeyPair is only overwritten. +func (config *Config) clone() *Config { + if config == nil { + return nil + } + + probe := *config + probe.IFaceBlackList = slices.Clone(config.IFaceBlackList) + probe.NATExternalIPs = slices.Clone(config.NATExternalIPs) + probe.DNSLabels = slices.Clone(config.DNSLabels) + return &probe +} + // UpdateConfig update existing configuration according to input configuration and return with the configuration func UpdateConfig(input ConfigInput) (*Config, error) { configExists, err := fileExists(input.ConfigPath) @@ -940,6 +1180,14 @@ func UpdateConfig(input ConfigInput) (*Config, error) { return nil, fmt.Errorf("config file %s does not exist", input.ConfigPath) } + // A UI that round-trips the mask GetConfig hands it back is asking to keep + // the stored key, not to set the mask as the new one. UpdateOrCreateConfig + // and DirectUpdateOrCreateConfig already collapse it; this one did not, so + // the same round-trip through SetConfig replaced the key with asterisks. + if isPreSharedKeyHidden(input.PreSharedKey) { + input.PreSharedKey = nil + } + return update(input) } @@ -951,7 +1199,7 @@ func UpdateOrCreateConfig(input ConfigInput) (*Config, error) { } if !configExists { log.Infof("generating new config %s", input.ConfigPath) - cfg, err := createNewConfig(input) + cfg, err := createProvisionedConfig(input) if err != nil { return nil, err } @@ -976,12 +1224,20 @@ func update(input ConfigInput) (*Config, error) { return nil, err } + // A write path is a provisioning point: a stored profile can legitimately + // carry no identity (a mobile logout clears the keys in place), and the + // next config write is what has to mint a new one. Reads leave that alone. + identityGenerated, err := config.EnsureIdentity() + if err != nil { + return nil, err + } + updated, err := config.apply(input) if err != nil { return nil, err } - if updated { + if updated || identityGenerated { if err := util.WriteJson(context.Background(), input.ConfigPath, config); err != nil { return nil, err } @@ -990,8 +1246,8 @@ func update(input ConfigInput) (*Config, error) { return config, nil } -// GetConfig read config file and return with Config and if it was created. Errors out if it does not exist -func GetConfig(configPath string) (*Config, error) { +// GetExistingConfig reads and returns the config if it exists on disk. Fails otherwise. +func GetExistingConfig(configPath string) (*Config, error) { return readConfig(configPath, false) } @@ -1074,17 +1330,27 @@ func UpdateOldManagementURL(ctx context.Context, config *Config, configPath stri return newConfig, nil } -// CreateInMemoryConfig generate a new config but do not write out it to the store +// CreateInMemoryConfig generate a new config but do not write out it to the store. +// It carries an identity: callers connect with what they get back. func CreateInMemoryConfig(input ConfigInput) (*Config, error) { - return createNewConfig(input) + return createProvisionedConfig(input) } -// ReadConfig read config file and return with Config. If it is not exists create a new with default values -func ReadConfig(configPath string) (*Config, error) { +// ReadConfigOrDefault reads the profile config at configPath, or resolves the +// default config in memory when the file does not exist. It never writes, and +// never mints an identity — EnsureIdentity is where that happens, so the +// caller that provisions is also the one that persists. +func ReadConfigOrDefault(configPath string) (*Config, error) { return readConfig(configPath, true) } -// ReadConfig read config file and return with Config. If it is not exists create a new with default values +// readConfig reads the profile config at configPath. createIfMissing resolves a +// default config in memory when the file is absent, rather than erroring. +// +// Reads are pure. This used to write the config back whenever apply() had to +// fill in a default the file was missing, which quietly made every reader a +// writer: a gate deciding whether to refuse a request, a UI listing profiles, +// a mobile getter reading a single preference. func readConfig(configPath string, createIfMissing bool) (*Config, error) { configExists, err := fileExists(configPath) if err != nil { @@ -1102,12 +1368,8 @@ func readConfig(configPath string, createIfMissing bool) (*Config, error) { return nil, err } // initialize through apply() without changes - if changed, err := config.apply(ConfigInput{}); err != nil { + if _, err := config.apply(ConfigInput{}); err != nil { return nil, err - } else if changed { - if err = WriteOutConfig(configPath, config); err != nil { - return nil, err - } } return config, nil @@ -1115,13 +1377,7 @@ func readConfig(configPath string, createIfMissing bool) (*Config, error) { return nil, fmt.Errorf("config file %s does not exist", configPath) } - cfg, err := createNewConfig(ConfigInput{ConfigPath: configPath}) - if err != nil { - return nil, err - } - - err = WriteOutConfig(configPath, cfg) - return cfg, err + return createNewConfig(ConfigInput{ConfigPath: configPath}) } // WriteOutConfig write put the prepared config to the given path @@ -1144,7 +1400,7 @@ func DirectUpdateOrCreateConfig(input ConfigInput) (*Config, error) { } if !configExists { log.Infof("generating new config %s", input.ConfigPath) - cfg, err := createNewConfig(input) + cfg, err := createProvisionedConfig(input) if err != nil { return nil, err } @@ -1171,12 +1427,18 @@ func directUpdate(input ConfigInput) (*Config, error) { return nil, err } + // Same provisioning point as update(); see the note there. + identityGenerated, err := config.EnsureIdentity() + if err != nil { + return nil, err + } + updated, err := config.apply(input) if err != nil { return nil, err } - if updated { + if updated || identityGenerated { if err := util.DirectWriteJson(context.Background(), input.ConfigPath, config); err != nil { return nil, err } @@ -1198,7 +1460,16 @@ func ConfigToJSON(config *Config) (string, error) { // ConfigFromJSON deserializes a JSON string to a Config struct. // This is useful for restoring config from alternative storage mechanisms. -// After unmarshaling, defaults are applied to ensure the config is fully initialized. +// After unmarshaling, defaults are applied to ensure the config is fully +// initialized. +// +// The peer identity is deliberately none of its business, in either direction. +// It does not generate one: a read cannot hand back keys that nothing will +// write down (see ReadConfigOrDefault). Nor does it refuse a document that +// carries none, because a config legitimately has no identity between a logout +// and the next login — mobile logout clears both keys in place — and this is +// also the deserializer the iOS SDK copies a config through. Whoever goes on +// to connect is where an absent identity has to be answered. func ConfigFromJSON(jsonStr string) (*Config, error) { config := &Config{} err := json.Unmarshal([]byte(jsonStr), config) diff --git a/client/internal/profilemanager/config_json_test.go b/client/internal/profilemanager/config_json_test.go new file mode 100644 index 000000000..9a6d820c4 --- /dev/null +++ b/client/internal/profilemanager/config_json_test.go @@ -0,0 +1,44 @@ +package profilemanager + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// The serialized form is how the tvOS SDK stores a profile and how the iOS SDK +// copies one in memory, so it must round-trip whatever a profile legitimately +// holds — including no identity at all, which is the state mobile logout leaves +// behind when it clears both keys in place. Refusing that document here broke +// logout, profile switching and the login that follows them. +func TestConfigFromJSONRoundTripsALoggedOutProfile(t *testing.T) { + path := filepath.Join(t.TempDir(), "exported.json") + stored, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path, ManagementURL: DefaultManagementURL}) + require.NoError(t, err) + require.NotEmpty(t, stored.PrivateKey, "a provisioned config is the fixture this test starts from") + require.NotEmpty(t, stored.SSHKey) + + exported, err := ConfigToJSON(stored) + require.NoError(t, err) + + restored, err := ConfigFromJSON(exported) + require.NoError(t, err, "a config exported after a login must load") + require.Equal(t, stored.PrivateKey, restored.PrivateKey, "the restored peer is not the stored one") + require.Equal(t, stored.SSHKey, restored.SSHKey) + + // What mobile logout leaves on disk. + loggedOut := stored.clone() + loggedOut.PrivateKey = "" + loggedOut.SSHKey = "" + + document, err := ConfigToJSON(loggedOut) + require.NoError(t, err) + + reloaded, err := ConfigFromJSON(document) + require.NoError(t, err, "a logged-out profile must still load") + require.Empty(t, reloaded.PrivateKey, "loading must not mint a key nothing will write down") + require.Empty(t, reloaded.SSHKey) + require.Equal(t, stored.ManagementURL.String(), reloaded.ManagementURL.String(), + "the rest of the profile survives the logout") +} diff --git a/client/internal/profilemanager/config_optional_fields_test.go b/client/internal/profilemanager/config_optional_fields_test.go new file mode 100644 index 000000000..9b74e2217 --- /dev/null +++ b/client/internal/profilemanager/config_optional_fields_test.go @@ -0,0 +1,131 @@ +package profilemanager + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/stretchr/testify/require" +) + +// optionalBoolFields lists the *bool fields of Config by name, derived from the +// type so a field added later is covered without touching these tests. +func optionalBoolFields() []string { + pointerToBool := reflect.TypeOf((*bool)(nil)) + + var fields []string + configType := reflect.TypeOf(Config{}) + for i := range configType.NumField() { + field := configType.Field(i) + if field.Type == pointerToBool && field.Tag.Get("json") != "-" { + fields = append(fields, field.Name) + } + } + return fields +} + +func requireNoUnsetOptionalBool(t *testing.T, config *Config, context string) { + t.Helper() + + value := reflect.ValueOf(*config) + for _, name := range optionalBoolFields() { + require.False(t, value.FieldByName(name).IsNil(), + "%s left %s unset, so its readers have to invent a default and a diff of it compares presence instead of value", context, name) + } +} + +// An optional bool must not be tristate. While one can be nil, true or false, +// every reader has to invent the meaning of nil, and — the reason this test +// exists — a diff of the config ends up comparing presence rather than value: +// that is what made the update-settings gate refuse `netbird up` for a client +// restating its own defaults. apply() is where a config becomes complete, so +// the invariant belongs to it: no *bool may come out of apply() unset. +func TestApplyLeavesNoOptionalBoolUnset(t *testing.T) { + require.NotEmpty(t, optionalBoolFields(), "the invariant is only meaningful while Config has optional bools") + + t.Run("a config built from scratch", func(t *testing.T) { + config := newConfigSkeleton() + _, err := config.apply(ConfigInput{}) + require.NoError(t, err) + + requireNoUnsetOptionalBool(t, config, "apply on a new config") + }) + + t.Run("a config file that predates every optional field", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.json") + require.NoError(t, os.WriteFile(path, []byte(`{"WgIface":"wt0"}`), 0o600)) + + config, err := GetExistingConfig(path) + require.NoError(t, err) + + requireNoUnsetOptionalBool(t, config, "a read of a legacy config") + }) + + t.Run("a config file that stores them as null", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "null.json") + _, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path}) + require.NoError(t, err) + unsetOnDisk(t, path, optionalBoolFields()...) + + config, err := GetExistingConfig(path) + require.NoError(t, err) + + requireNoUnsetOptionalBool(t, config, "a read of a config storing nulls") + }) +} + +// The same invariant on disk: what a write leaves in the file is what the next +// client to read it starts from, so no write may store a null. +func TestNoWriteStoresAnUnsetOptionalBool(t *testing.T) { + requireNoNullOnDisk := func(t *testing.T, path string, context string) { + t.Helper() + + raw, err := os.ReadFile(path) + require.NoError(t, err) + + var stored map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &stored)) + + for _, name := range optionalBoolFields() { + value, present := stored[name] + require.True(t, present, "%s did not store %s at all", context, name) + require.NotEqual(t, "null", string(value), "%s stored %s as null", context, name) + } + } + + t.Run("UpdateOrCreateConfig", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "created.json") + _, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path, ManagementURL: DefaultManagementURL}) + require.NoError(t, err) + + requireNoNullOnDisk(t, path, "UpdateOrCreateConfig") + }) + + t.Run("UpdateConfig over a config storing nulls", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "stored.json") + _, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path}) + require.NoError(t, err) + unsetOnDisk(t, path, optionalBoolFields()...) + + _, err = UpdateConfig(ConfigInput{ConfigPath: path, ManagementURL: "https://mgmt.example.com"}) + require.NoError(t, err) + + requireNoNullOnDisk(t, path, "UpdateConfig") + }) + + // Renaming used to copy the file back through a bare Unmarshal, which + // preserved the nulls a pre-fix client had written. + t.Run("RenameProfile", func(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + unsetOnDisk(t, created.Path, optionalBoolFields()...) + + require.NoError(t, sm.RenameProfile(created.ID, username, "office")) + + requireNoNullOnDisk(t, created.Path, "RenameProfile") + }) + }) +} diff --git a/client/internal/profilemanager/config_probe_test.go b/client/internal/profilemanager/config_probe_test.go new file mode 100644 index 000000000..35a179a84 --- /dev/null +++ b/client/internal/profilemanager/config_probe_test.go @@ -0,0 +1,96 @@ +package profilemanager + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// writeCertPair writes a throwaway certificate and key, so apply() has +// something real to load rather than a missing file it would only log about. +func writeCertPair(t *testing.T) (certPath, keyPath string) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "probe-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + require.NoError(t, err) + + keyDER, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + + dir := t.TempDir() + certPath = filepath.Join(dir, "client.crt") + keyPath = filepath.Join(dir, "client.key") + require.NoError(t, os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600)) + require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600)) + return certPath, keyPath +} + +// The dry run behind the update-settings gate must not read the mTLS pair off +// disk. The loaded pair feeds the connection, never the comparison, and the +// gate runs it on every SetConfig and Login — twice per request — including the +// ones it refuses. +func TestProbeDoesNotLoadTheCertificatePair(t *testing.T) { + certPath, keyPath := writeCertPair(t) + + t.Run("a real apply loads it", func(t *testing.T) { + config := newConfigSkeleton() + config.ClientCertPath, config.ClientCertKeyPath = certPath, keyPath + + _, err := config.apply(ConfigInput{}) + require.NoError(t, err) + require.NotNil(t, config.ClientCertKeyPair, "the connection would have no client certificate") + }) + + t.Run("a probe does not", func(t *testing.T) { + config := newConfigSkeleton() + config.ClientCertPath, config.ClientCertKeyPath = certPath, keyPath + config.probing = true + + _, err := config.apply(ConfigInput{}) + require.NoError(t, err) + require.Nil(t, config.ClientCertKeyPair, "the dry run read the certificate off disk") + }) + + // And the verdict is the same either way, which is the only thing the gate + // asks of the probe. + t.Run("the verdict is unaffected", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "mtls.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: DefaultManagementURL, + ClientCertPath: certPath, + ClientCertKeyPath: keyPath, + }) + require.NoError(t, err) + + stored, err := GetExistingConfig(path) + require.NoError(t, err) + + changed, err := stored.WouldChange(ConfigInput{ClientCertPath: certPath, ClientCertKeyPath: keyPath}) + require.NoError(t, err) + require.False(t, changed, "restating the stored certificate paths is not a change") + + changed, err = stored.WouldChange(ConfigInput{ClientCertPath: filepath.Join(t.TempDir(), "other.crt")}) + require.NoError(t, err) + require.True(t, changed, "a different certificate path is a change") + }) +} diff --git a/client/internal/profilemanager/config_test.go b/client/internal/profilemanager/config_test.go index 248920b5e..a461aa71f 100644 --- a/client/internal/profilemanager/config_test.go +++ b/client/internal/profilemanager/config_test.go @@ -196,7 +196,7 @@ func TestWireguardPortZeroExplicit(t *testing.T) { assert.Equal(t, 0, config.WgPort, "WgPort should be 0 when explicitly set by user") // Verify it persists - readConfig, err := GetConfig(configPath) + readConfig, err := GetExistingConfig(configPath) require.NoError(t, err) assert.Equal(t, 0, readConfig.WgPort, "WgPort should remain 0 after reading from file") } diff --git a/client/internal/profilemanager/config_would_change_test.go b/client/internal/profilemanager/config_would_change_test.go new file mode 100644 index 000000000..6b140030f --- /dev/null +++ b/client/internal/profilemanager/config_would_change_test.go @@ -0,0 +1,529 @@ +package profilemanager + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/shared/management/domain" +) + +func seededConfig(t *testing.T) *Config { + t.Helper() + + path := filepath.Join(t.TempDir(), "seeded.json") + cfg, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: "https://api.netbird.io:443", + PreSharedKey: strPointer("stored-key"), + }) + require.NoError(t, err) + return cfg +} + +func strPointer(s string) *string { return &s } + +func intPtr(i int) *int { return &i } + +func TestWouldChange(t *testing.T) { + tests := []struct { + name string + input ConfigInput + want bool + }{ + {name: "empty input", input: ConfigInput{}, want: false}, + {name: "same management URL", input: ConfigInput{ManagementURL: "https://api.netbird.io:443"}, want: false}, + {name: "management URL without its default port", input: ConfigInput{ManagementURL: "https://api.netbird.io"}, want: false}, + {name: "different management URL", input: ConfigInput{ManagementURL: "https://other.example:443"}, want: true}, + {name: "same pre-shared key", input: ConfigInput{PreSharedKey: strPointer("stored-key")}, want: false}, + {name: "redacted pre-shared key", input: ConfigInput{PreSharedKey: strPointer("**********")}, want: false}, + {name: "different pre-shared key", input: ConfigInput{PreSharedKey: strPointer("other-key")}, want: true}, + {name: "new interface blacklist entry", input: ConfigInput{ExtraIFaceBlackList: []string{"nb-probe0"}}, want: true}, + {name: "blacklist entry already present", input: ConfigInput{ExtraIFaceBlackList: []string{"lo"}}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := seededConfig(t) + + changed, err := cfg.WouldChange(tt.input) + require.NoError(t, err) + require.Equal(t, tt.want, changed) + }) + } +} + +// The dry run must not be observable on the config it is run against: it +// decides whether a write is allowed, it does not perform one. +func TestWouldChangeLeavesTheConfigAlone(t *testing.T) { + cfg := seededConfig(t) + blacklist := len(cfg.IFaceBlackList) + + changed, err := cfg.WouldChange(ConfigInput{ + ManagementURL: "https://other.example:443", + PreSharedKey: strPointer("other-key"), + ExtraIFaceBlackList: []string{"nb-probe0"}, + DNSLabels: domain.FromPunycodeList([]string{"probe"}), + NATExternalIPs: []string{"1.2.3.4"}, + }) + require.NoError(t, err) + require.True(t, changed) + + require.Equal(t, "https://api.netbird.io:443", cfg.ManagementURL.String()) + require.Equal(t, "stored-key", cfg.PreSharedKey) + require.Len(t, cfg.IFaceBlackList, blacklist) + require.Empty(t, cfg.DNSLabels) + require.Empty(t, cfg.NATExternalIPs) +} + +// A nil config means the profile holds nothing yet, so the baseline is what +// the daemon would create for it. +func TestWouldChangeWithoutAStoredConfig(t *testing.T) { + var cfg *Config + + changed, err := cfg.WouldChange(ConfigInput{}) + require.NoError(t, err) + require.False(t, changed, "a request carrying nothing cannot change anything") + + changed, err = cfg.WouldChange(ConfigInput{ManagementURL: DefaultManagementURL}) + require.NoError(t, err) + require.False(t, changed, "the default management URL is what would be written anyway") + + changed, err = cfg.WouldChange(ConfigInput{ManagementURL: "https://other.example:443"}) + require.NoError(t, err) + require.True(t, changed) +} + +func TestWouldChangeReportsAnInvalidInput(t *testing.T) { + cfg := seededConfig(t) + + _, err := cfg.WouldChange(ConfigInput{ManagementURL: "not-a-url"}) + require.Error(t, err) +} + +// Reads must not write. A config file missing a field apply() fills in (MTU, +// here) is what used to trigger the write-back. +func TestReadsDoNotWriteTheConfigBack(t *testing.T) { + denormalized := []byte(`{"WgIface":"wt0"}`) + + for name, read := range map[string]func(string) (*Config, error){ + "GetExistingConfig": GetExistingConfig, + "ReadConfigOrDefault": ReadConfigOrDefault, + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "profile.json") + require.NoError(t, os.WriteFile(path, denormalized, 0o600)) + + cfg, err := read(path) + require.NoError(t, err) + require.Equal(t, uint16(iface.DefaultMTU), cfg.MTU, "the returned config is still normalized in memory") + require.Empty(t, cfg.PrivateKey, "a read must not mint an identity either") + + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, string(denormalized), string(after), "%s rewrote the config file", name) + }) + } +} + +// ReadConfigOrDefault resolves a default config for a profile that has no file +// yet, and that must not create the file either. +func TestReadConfigDoesNotCreateTheFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "absent.json") + + cfg, err := ReadConfigOrDefault(path) + require.NoError(t, err) + require.Equal(t, DefaultManagementURL, cfg.ManagementURL.String()) + + _, err = os.Stat(path) + require.True(t, os.IsNotExist(err), "ReadConfigOrDefault created the config file") +} + +// The identity is the one thing a read cannot recompute, so it is provisioned +// on request and its caller persists it. +func TestEnsureIdentity(t *testing.T) { + cfg := newConfigSkeleton() + + generated, err := cfg.EnsureIdentity() + require.NoError(t, err) + require.True(t, generated) + require.NotEmpty(t, cfg.PrivateKey) + require.NotEmpty(t, cfg.SSHKey) + + key := cfg.PrivateKey + generated, err = cfg.EnsureIdentity() + require.NoError(t, err) + require.False(t, generated, "a config that already has an identity keeps it") + require.Equal(t, key, cfg.PrivateKey) +} + +// One endpoint written several ways is one endpoint. A gate that compared +// spellings refused a client restating its own management URL with a trailing +// slash, which is a normal way to write it. +func TestSameServiceURL(t *testing.T) { + tests := []struct { + a, b string + want bool + }{ + {a: "https://mgmt.example.com", b: "https://mgmt.example.com:443", want: true}, + {a: "https://mgmt.example.com", b: "https://mgmt.example.com/", want: true}, + {a: "https://mgmt.example.com/", b: "https://mgmt.example.com:443/", want: true}, + {a: "https://MGMT.example.com", b: "https://mgmt.example.com", want: true}, + {a: "http://mgmt.example.com", b: "http://mgmt.example.com:80", want: true}, + {a: "https://mgmt.example.com", b: "http://mgmt.example.com", want: false}, + {a: "https://mgmt.example.com", b: "https://mgmt.example.com:8443", want: false}, + {a: "https://mgmt.example.com", b: "https://other.example.com", want: false}, + } + + for _, tt := range tests { + t.Run(tt.a+" vs "+tt.b, func(t *testing.T) { + a, err := ParseServiceURL("a", tt.a) + require.NoError(t, err) + b, err := ParseServiceURL("b", tt.b) + require.NoError(t, err) + + require.Equal(t, tt.want, SameServiceURL(a, b)) + require.Equal(t, tt.want, SameServiceURL(b, a), "the comparison must be symmetric") + }) + } +} + +// The same spellings, through the dry run the update-settings gate uses. +func TestWouldChangeIgnoresURLSpelling(t *testing.T) { + path := filepath.Join(t.TempDir(), "seeded.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: "https://mgmt.example.com", + }) + require.NoError(t, err) + + cfg, err := GetExistingConfig(path) + require.NoError(t, err) + + for _, spelling := range []string{ + "https://mgmt.example.com", + "https://mgmt.example.com/", + "https://mgmt.example.com:443", + "https://mgmt.example.com:443/", + "https://MGMT.example.com", + } { + changed, err := cfg.WouldChange(ConfigInput{ManagementURL: spelling}) + require.NoError(t, err) + require.False(t, changed, "%q is the stored endpoint written differently", spelling) + } + + changed, err := cfg.WouldChange(ConfigInput{ManagementURL: "https://mgmt.example.com:8443"}) + require.NoError(t, err) + require.True(t, changed, "a different port is a different endpoint") +} + +// The dry-run baseline exists to be compared against and discarded, so it must +// not mint keys — the CLI's login backoff loop would otherwise log a fresh +// "generated new Wireguard key" on every attempt. +func TestDryRunBaselineDoesNotGenerateKeys(t *testing.T) { + baseline, err := newDryRunBaseline(filepath.Join(t.TempDir(), "absent.json")) + require.NoError(t, err) + + require.Empty(t, baseline.PrivateKey, "generated a WireGuard key for a throwaway config") + require.Empty(t, baseline.SSHKey, "generated an SSH key for a throwaway config") + + // Everything the comparison actually looks at is still the default config. + require.Equal(t, DefaultManagementURL, baseline.ManagementURL.String()) + require.Equal(t, uint16(iface.DefaultMTU), baseline.MTU) + require.Equal(t, iface.DefaultWgPort, baseline.WgPort) +} + +// A stored profile can carry no identity — a mobile logout clears the keys in +// place — so the next config write has to mint one, which is what keeps the +// following login from dialing management with an empty key. +func TestUpdateConfigProvisionsAMissingIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), "logged-out.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: "https://api.netbird.io:443", + }) + require.NoError(t, err) + + // Stand in for the logout, which zeroes the keys and writes the config out. + loggedOut, err := GetExistingConfig(path) + require.NoError(t, err) + loggedOut.PrivateKey = "" + loggedOut.SSHKey = "" + require.NoError(t, WriteOutConfig(path, loggedOut)) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path}) + require.NoError(t, err) + require.NotEmpty(t, cfg.PrivateKey, "the write path did not provision an identity") + require.NotEmpty(t, cfg.SSHKey) + + persisted, err := GetExistingConfig(path) + require.NoError(t, err) + require.Equal(t, cfg.PrivateKey, persisted.PrivateKey, "the provisioned identity was not persisted") +} + +// A config that carries no sync message version must not make the dry run +// panic: the gate runs inside a request handler, where failing closed is the +// worst acceptable outcome. +func TestWouldChangeWithoutAStoredSyncMessageVersion(t *testing.T) { + cfg := seededConfig(t) + require.Nil(t, cfg.SyncMessageVersion, "the fixture is only useful while the field starts out unset") + + version := 2 + changed, err := cfg.WouldChange(ConfigInput{SyncMessageVersion: &version}) + require.NoError(t, err) + require.True(t, changed) + require.Nil(t, cfg.SyncMessageVersion, "the dry run set the version on the stored config") +} + +// Restating the certificate paths a config already holds is not a change, for +// the same reason restating any other value is not. +func TestWouldChangeIgnoresRestatedCertificatePaths(t *testing.T) { + path := filepath.Join(t.TempDir(), "mtls.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: "https://api.netbird.io:443", + ClientCertPath: "/etc/netbird/client.crt", + ClientCertKeyPath: "/etc/netbird/client.key", + }) + require.NoError(t, err) + + cfg, err := GetExistingConfig(path) + require.NoError(t, err) + + changed, err := cfg.WouldChange(ConfigInput{ + ClientCertPath: "/etc/netbird/client.crt", + ClientCertKeyPath: "/etc/netbird/client.key", + }) + require.NoError(t, err) + require.False(t, changed, "the stored certificate paths were restated") + + changed, err = cfg.WouldChange(ConfigInput{ClientCertPath: "/etc/netbird/other.crt"}) + require.NoError(t, err) + require.True(t, changed, "a different certificate path is a change") +} + +// A read that lands on a missing file must not hand back keys: nothing would +// write them down, so the caller would connect with an identity that changes on +// the next run and registers a second peer. +func TestReadConfigOrDefaultCarriesNoIdentity(t *testing.T) { + cfg, err := ReadConfigOrDefault(filepath.Join(t.TempDir(), "absent.json")) + require.NoError(t, err) + + require.Empty(t, cfg.PrivateKey, "a read minted a WireGuard key") + require.Empty(t, cfg.SSHKey, "a read minted an SSH key") + + // So the caller's own EnsureIdentity is the one that reports the work, and + // therefore the one that triggers the write. + generated, err := cfg.EnsureIdentity() + require.NoError(t, err) + require.True(t, generated, "the provisioning caller could not tell it had to persist the identity") +} + +// CreateInMemoryConfig is the opposite contract: its callers connect with what +// they get back, so it does carry an identity. +func TestCreateInMemoryConfigCarriesAnIdentity(t *testing.T) { + cfg, err := CreateInMemoryConfig(ConfigInput{ManagementURL: "https://api.netbird.io:443"}) + require.NoError(t, err) + + require.NotEmpty(t, cfg.PrivateKey) + require.NotEmpty(t, cfg.SSHKey) +} + +// The admin panel is opened, not dialed, so its path identifies it. Comparing +// it as a bare endpoint left a custom panel URL unable to change. +func TestAdminURLPathIsPartOfTheIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), "panel.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + AdminURL: "https://app.example.com/netbird", + }) + require.NoError(t, err) + + cfg, err := GetExistingConfig(path) + require.NoError(t, err) + require.Equal(t, "https://app.example.com:443/netbird", cfg.AdminURL.String()) + + // Equivalent spellings of the same panel are still not a change. + for _, same := range []string{ + "https://app.example.com/netbird", + "https://app.example.com:443/netbird", + "https://app.example.com/netbird/", + "https://APP.example.com/netbird", + } { + changed, err := cfg.WouldChange(ConfigInput{AdminURL: same}) + require.NoError(t, err) + require.False(t, changed, "%q is the stored panel written differently", same) + } + + // A different path is a different panel, and it must be persisted. + changed, err := cfg.WouldChange(ConfigInput{AdminURL: "https://app.example.com/other"}) + require.NoError(t, err) + require.True(t, changed, "a different panel path is a change") + + updated, err := UpdateConfig(ConfigInput{ConfigPath: path, AdminURL: "https://app.example.com/other"}) + require.NoError(t, err) + require.Equal(t, "https://app.example.com:443/other", updated.AdminURL.String(), "the new panel path was not persisted") +} + +// unsetOnDisk rewrites the stored config so the named fields carry a JSON null, +// which is how a profile written before apply() resolved them looks on disk. +// It synthesizes that state: no write produces it any more. +func unsetOnDisk(t *testing.T, path string, fields ...string) { + t.Helper() + + raw, err := os.ReadFile(path) + require.NoError(t, err) + + var stored map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &stored)) + + for _, field := range fields { + _, present := stored[field] + require.True(t, present, "%s is not a field of the stored config", field) + stored[field] = json.RawMessage("null") + } + + rewritten, err := json.Marshal(stored) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, rewritten, 0600)) +} + +// Seven fields mean "the effective default" when they hold no value, and every +// profile written before apply() resolved them holds them as null. Restating +// that default is asking for no change — and the CLI restates it on every +// `netbird up`, because a flag set through an environment variable is a flag +// pflag reports as Changed. Judging those restatements as changes made the +// update-settings gate refuse `netbird up` outright for a client configured +// through the environment, which is the shape of a Kubernetes deployment. +// +// A login now writes those fields set, so the fixture puts the null state back +// on disk with unsetOnDisk instead of getting it from a login. +func TestWouldChangeIgnoresRestatedDefaultsOfUnsetFields(t *testing.T) { + networkMonitorDefault := runtime.GOOS == "windows" || runtime.GOOS == "darwin" + + tests := []struct { + field string + theDefault ConfigInput + theOtherWay ConfigInput + }{ + {"EnableSSHRoot", + ConfigInput{EnableSSHRoot: boolPtr(false)}, ConfigInput{EnableSSHRoot: boolPtr(true)}}, + {"EnableSSHSFTP", + ConfigInput{EnableSSHSFTP: boolPtr(false)}, ConfigInput{EnableSSHSFTP: boolPtr(true)}}, + {"EnableSSHLocalPortForwarding", + ConfigInput{EnableSSHLocalPortForwarding: boolPtr(false)}, ConfigInput{EnableSSHLocalPortForwarding: boolPtr(true)}}, + {"EnableSSHRemotePortForwarding", + ConfigInput{EnableSSHRemotePortForwarding: boolPtr(false)}, ConfigInput{EnableSSHRemotePortForwarding: boolPtr(true)}}, + {"DisableSSHAuth", + ConfigInput{DisableSSHAuth: boolPtr(false)}, ConfigInput{DisableSSHAuth: boolPtr(true)}}, + {"SSHJWTCacheTTL", + ConfigInput{SSHJWTCacheTTL: intPtr(0)}, ConfigInput{SSHJWTCacheTTL: intPtr(300)}}, + {"NetworkMonitor", + ConfigInput{NetworkMonitor: boolPtr(networkMonitorDefault)}, ConfigInput{NetworkMonitor: boolPtr(!networkMonitorDefault)}}, + } + + for _, tt := range tests { + t.Run(tt.field, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "unset.json") + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: path, + ManagementURL: "https://api.netbird.io:443", + }) + require.NoError(t, err) + unsetOnDisk(t, path, tt.field) + + cfg, err := GetExistingConfig(path) + require.NoError(t, err) + + changed, err := cfg.WouldChange(tt.theDefault) + require.NoError(t, err) + require.False(t, changed, "restating the default of an unset %s was judged a change", tt.field) + + // The gate still has to refuse a request that does ask for something. + changed, err = cfg.WouldChange(tt.theOtherWay) + require.NoError(t, err) + require.True(t, changed, "asking for a non-default %s is a change", tt.field) + }) + } +} + +// The verdict must not depend on where the caller got the config from. Readers +// normalize what they hand out, but apply() signals "I filled in a default" +// through the same bool as "the input changed something", so a config that +// never passed through a read would otherwise report a change for an input +// that asks for nothing. +func TestWouldChangeNormalizesBeforeMeasuring(t *testing.T) { + rawConfig := func(t *testing.T) *Config { + t.Helper() + + cfg := &Config{WgIface: iface.WgInterfaceDefault} + require.Nil(t, cfg.ServerSSHAllowed, "the fixture is only useful while the config is not normalized") + require.Nil(t, cfg.EnableSSHRoot) + require.Empty(t, cfg.IFaceBlackList) + return cfg + } + + changed, err := rawConfig(t).WouldChange(ConfigInput{}) + require.NoError(t, err) + require.False(t, changed, "an input carrying nothing cannot change anything") + + changed, err = rawConfig(t).WouldChange(ConfigInput{EnableSSHRoot: boolPtr(false)}) + require.NoError(t, err) + require.False(t, changed, "the default of a field the config never held is not a change") + + changed, err = rawConfig(t).WouldChange(ConfigInput{EnableSSHRoot: boolPtr(true)}) + require.NoError(t, err) + require.True(t, changed, "a non-default value is still a change") +} + +// A zero-padded port addresses the same port. The normalization itself belongs +// to util.ServiceURLPort and is tested there; this asserts that the comparison +// this package hands its callers inherits it. +func TestServiceURLPortIsNormalizedNumerically(t *testing.T) { + padded, err := ParseServiceURL("padded", "https://mgmt.example.com:0443") + require.NoError(t, err) + plain, err := ParseServiceURL("plain", "https://mgmt.example.com:443") + require.NoError(t, err) + + require.True(t, SameServiceURL(padded, plain)) +} + +// A list the profile does not have and a list the request empties are the same +// thing: no NAT mappings, no DNS labels. The profile stores an absent list as +// JSON null and reads it back as a nil slice, while `netbird up` sends the +// emptied list — CleanNATExternalIPs / CleanDNSLabels — whenever the matching +// environment variable is set to nothing, which a deployment template does by +// default. Judging nil and empty as different made the gate refuse that start, +// which is the very deadlock this branch exists to remove, on another field. +func TestWouldChangeIgnoresAnEmptiedListThatWasAlreadyAbsent(t *testing.T) { + path := filepath.Join(t.TempDir(), "lists.json") + _, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path, ManagementURL: DefaultManagementURL}) + require.NoError(t, err) + + stored, err := GetExistingConfig(path) + require.NoError(t, err) + require.Nil(t, stored.NATExternalIPs, "the fixture is only useful while the stored list is absent") + require.Nil(t, stored.DNSLabels) + + changed, err := stored.WouldChange(ConfigInput{NATExternalIPs: make([]string, 0)}) + require.NoError(t, err) + require.False(t, changed, "emptying a NAT list the profile never had is not a change") + + changed, err = stored.WouldChange(ConfigInput{DNSLabels: domain.List{}}) + require.NoError(t, err) + require.False(t, changed, "emptying a DNS label list the profile never had is not a change") + + // A list that does hold something still moves when the request empties it. + withEntries, err := UpdateConfig(ConfigInput{ConfigPath: path, NATExternalIPs: []string{"1.2.3.4"}}) + require.NoError(t, err) + require.Equal(t, []string{"1.2.3.4"}, withEntries.NATExternalIPs) + + changed, err = withEntries.WouldChange(ConfigInput{NATExternalIPs: make([]string, 0)}) + require.NoError(t, err) + require.True(t, changed, "clearing a NAT list that had an entry is a change") +} diff --git a/client/internal/profilemanager/service.go b/client/internal/profilemanager/service.go index ec287f01a..e58f421fd 100644 --- a/client/internal/profilemanager/service.go +++ b/client/internal/profilemanager/service.go @@ -313,7 +313,11 @@ func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, err } profPath := filepath.Join(configDir, id.String()+".json") - cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath}) + // Provisioned, not bare: this config goes straight to disk, and a profile + // file with no identity is one whose first reader has to mint the keys and + // remember to write them back. Before identity generation moved out of + // apply() into EnsureIdentity, createNewConfig produced them here too. + cfg, err := createProvisionedConfig(ConfigInput{ConfigPath: profPath}) if err != nil { return nil, fmt.Errorf("failed to create new config: %w", err) } @@ -330,6 +334,19 @@ func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, err }, nil } +// RenameProfile changes a profile's display name. It rewrites the whole +// profile file, not just the name: the config is read through the normalizing +// reader, so apply()'s resolved values — the optional booleans, the interface +// blacklist, the DNS route interval — are persisted along with the new name. +// +// That is deliberate. A write that skipped apply() is what left profiles on +// disk carrying null where a value was meant, and made a diff of the config +// compare presence instead of value. Two consequences worth knowing: the +// platform-dependent defaults resolved here are the renaming host's +// (ServerSSHAllowed and the network monitor differ per OS), and a profile +// whose stored name does not survive sanitizeDisplayName now fails to rename +// rather than being rewritten — though apply() rejects such a profile on every +// other read too, so it was already unusable. func (s *ServiceManager) RenameProfile(id ID, username string, newName string) error { displayName, err := sanitizeDisplayName(newName) if err != nil { @@ -356,17 +373,17 @@ func (s *ServiceManager) RenameProfile(id ID, username string, newName string) e return ErrProfileNotFound } - data, err := os.ReadFile(target.Path) + // Through the reader, not a bare Unmarshal: this was the one write that + // skipped apply(), so it copied back whatever the file held — including an + // optional field left unset, which every other write resolves to its + // default. Renaming a profile is a poor place to leave that behind. + cfg, err := GetExistingConfig(target.Path) if err != nil { - return err - } - var cfg Config - if err := json.Unmarshal(data, &cfg); err != nil { - return err + return fmt.Errorf("read profile config: %w", err) } cfg.Name = displayName - if err := util.WriteJson(context.Background(), target.Path, cfg); err != nil { + if err := WriteOutConfig(target.Path, cfg); err != nil { return fmt.Errorf("failed to write profile name: %w", err) } return nil diff --git a/client/internal/profilemanager/service_test.go b/client/internal/profilemanager/service_test.go index 5e051b15d..d26ce746a 100644 --- a/client/internal/profilemanager/service_test.go +++ b/client/internal/profilemanager/service_test.go @@ -228,3 +228,27 @@ func TestRemoveProfile_DeletesStateFile(t *testing.T) { assert.True(t, errors.Is(err, os.ErrNotExist), "state file should be removed") }) } + +// A profile file is written here and read back by whoever connects with it, so +// it has to carry the peer's identity. While AddProfile used the bare +// constructor, it wrote a config with no keys: the first reader had to mint +// them, and the paths that read without writing — a gate deciding whether to +// refuse a request, the mobile SDKs loading a stored profile — got a config +// that cannot connect. +func TestAddProfileWritesAnIdentity(t *testing.T) { + withTestSM(t, func(sm *ServiceManager, username string) { + created, err := sm.AddProfile("work", username) + require.NoError(t, err) + + stored, err := GetExistingConfig(created.Path) + require.NoError(t, err) + + require.NotEmpty(t, stored.PrivateKey, "the profile was written without a WireGuard key") + require.NotEmpty(t, stored.SSHKey, "the profile was written without an SSH key") + + // And the identity is the one on disk, not one minted per read. + reread, err := GetExistingConfig(created.Path) + require.NoError(t, err) + require.Equal(t, stored.PrivateKey, reread.PrivateKey) + }) +} diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go index 96c747ae4..a6315a4ab 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -130,6 +130,10 @@ func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osV // SetConfigFromJSON stores the JSON config that later loads resolve instead of the config file (tvOS). func (c *Client) SetConfigFromJSON(jsonStr string) error { + // Parsed only to reject an unreadable document early; the JSON itself is + // what is stored, and every load re-parses it. A document carrying no peer + // identity is readable and accepted: that is a logged-out profile, and the + // login that follows provisions the keys. if _, err := profilemanager.ConfigFromJSON(jsonStr); err != nil { log.Errorf("SetConfigFromJSON: failed to parse config JSON: %v", err) return err diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 0dfff620e..6a9a6d3d0 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -379,6 +379,35 @@ func (a *Auth) SetConfigFromJSON(jsonStr string) error { } func (a *Auth) setBaseConfig(base *profilemanager.Config) error { + // A logged-out profile carries no keys: the mobile logout clears them in + // place so the next login registers a new peer instead of resurrecting the + // old one. This is that login, and auth.NewAuth parses the WireGuard key + // before the SSO flow even starts, so an absent identity fails the login on + // key size rather than asking the user to sign in. + // + // Minted on the base config, which is the one GetConfigJSON hands back for + // the caller to store — the overlaid copy below is runtime-only. + generated, err := base.EnsureIdentity() + if err != nil { + return fmt.Errorf("ensure profile identity: %w", err) + } + if generated { + if a.cfgPath != "" { + // Non-atomic, like NewAuth's own write: the tvOS App Group sandbox + // blocks the temp-file-and-rename an atomic write needs. + if err := profilemanager.DirectWriteOutConfig(a.cfgPath, base); err != nil { + return fmt.Errorf("write out profile config: %w", err) + } + } else { + // No file to write to — this is the tvOS path, where the profile + // lives in the caller's own store. It persists the new identity by + // calling GetConfigJSON once the login completes; until then the + // keys exist only here, and a login that never completes leaves + // nothing behind. + log.Infof("provisioned a peer identity for a config with no file on disk") + } + } + overlaid, err := copyConfig(base) if err != nil { return err diff --git a/client/ios/NetBirdSDK/preferences.go b/client/ios/NetBirdSDK/preferences.go index 5297920a3..642f9e160 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -49,7 +49,7 @@ func (p *Preferences) GetManagementURL() (string, error) { return p.configInput.ManagementURL, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return "", err } @@ -67,7 +67,7 @@ func (p *Preferences) GetAdminURL() (string, error) { return p.configInput.AdminURL, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return "", err } @@ -89,7 +89,7 @@ func (p *Preferences) HasPreSharedKey() (bool, error) { return *p.configInput.PreSharedKey != "", nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -115,7 +115,7 @@ func (p *Preferences) GetRosenpassEnabled() (bool, error) { return *p.configInput.RosenpassEnabled, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -136,7 +136,7 @@ func (p *Preferences) GetRosenpassPermissive() (bool, error) { return *p.configInput.RosenpassPermissive, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -149,7 +149,7 @@ func (p *Preferences) GetDisableIPv6() (bool, error) { return *p.configInput.DisableIPv6, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } @@ -168,7 +168,7 @@ func (p *Preferences) GetRemoteJobsAllowed() (bool, error) { return *p.configInput.RemoteJobsAllowed, nil } - cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) if err != nil { return false, err } diff --git a/client/mobile/profile_lifecycle_test.go b/client/mobile/profile_lifecycle_test.go new file mode 100644 index 000000000..9612f550d --- /dev/null +++ b/client/mobile/profile_lifecycle_test.go @@ -0,0 +1,97 @@ +package mobile + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// loadAsTheMobileSDKsDo replays what the iOS SDK does with a stored profile: +// read the config, serialize it, and load it back. Client.SetConfigFromJSON +// stores that document for tvOS, Auth.SetConfigFromJSON authenticates with it, +// and copyConfig round-trips a Config through the same pair to take an +// in-memory copy before applying the MDM overlay. +func loadAsTheMobileSDKsDo(t *testing.T, configPath string) *profilemanager.Config { + t.Helper() + + stored, err := profilemanager.GetExistingConfig(configPath) + require.NoError(t, err, "read the stored profile") + + document, err := profilemanager.ConfigToJSON(stored) + require.NoError(t, err, "serialize the stored profile") + + reloaded, err := profilemanager.ConfigFromJSON(document) + require.NoError(t, err, "load the profile back") + return reloaded +} + +// A profile survives the whole round its user puts it through: created, logged +// out, loaded again, and switched away from and back. +// +// Logout is the step that makes this worth asserting. It clears the peer's +// keys in place so the next login registers a new peer rather than bringing +// the old one back, which leaves a profile that legitimately carries no +// identity — and both mobile SDKs go on loading that profile through the +// serialized form. A load that refused it, or a creation that never wrote an +// identity in the first place, breaks logout and profile switching on iOS and +// Android without any of it being visible from the desktop client. +func TestProfileSurvivesLogoutAndReload(t *testing.T) { + pm := newTestProfileManager(t) + + created, err := pm.AddProfile("work") + require.NoError(t, err) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + + // Created: the profile carries the identity it will connect with. + require.NotEmpty(t, privateKeyOf(t, pm, created.ID), "a new profile was written with no identity") + + configPath, err := pm.GetConfigPath(created.ID) + require.NoError(t, err) + + before := loadAsTheMobileSDKsDo(t, configPath) + require.NotEmpty(t, before.PrivateKey) + managementURL := before.ManagementURL.String() + + // Logged out: the identity is gone, on purpose. + require.NoError(t, pm.LogoutProfile(created.ID)) + require.Empty(t, privateKeyOf(t, pm, created.ID), "logout left the peer's key behind") + + // Loaded again: the profile is still readable, and loading it neither + // fails nor mints a key that nothing would write down. + after := loadAsTheMobileSDKsDo(t, configPath) + assert.Empty(t, after.PrivateKey, "loading a logged-out profile minted a key nothing will persist") + assert.Empty(t, after.SSHKey, "loading a logged-out profile minted an SSH key") + assert.Equal(t, managementURL, after.ManagementURL.String(), "the rest of the profile did not survive the logout") + + // Switched away from and back: still the same profile, still loadable. + require.NoError(t, pm.SwitchProfile(created.ID)) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + require.NoError(t, pm.SwitchProfile(created.ID)) + + active, err := pm.GetActiveProfile() + require.NoError(t, err) + assert.Equal(t, created.ID, active.ID, "the profile switched to is not the active one") + + assert.Equal(t, managementURL, loadAsTheMobileSDKsDo(t, configPath).ManagementURL.String(), + "the profile did not survive the round of switches") +} + +// The profile the SDKs fall back to gets the same treatment, since it is the +// one a mobile client without an explicit profile runs on. +func TestDefaultProfileSurvivesLogoutAndReload(t *testing.T) { + pm := newTestProfileManager(t) + require.NoError(t, pm.SwitchProfile(profilemanager.DefaultProfileName)) + + configPath, err := pm.GetConfigPath(profilemanager.DefaultProfileName) + require.NoError(t, err) + require.NotEmpty(t, loadAsTheMobileSDKsDo(t, configPath).PrivateKey) + + require.NoError(t, pm.LogoutProfile(profilemanager.DefaultProfileName)) + + reloaded := loadAsTheMobileSDKsDo(t, configPath) + assert.Empty(t, reloaded.PrivateKey, "loading the logged-out default profile minted a key") + assert.NotNil(t, reloaded.ManagementURL, "the profile lost its management URL") +} diff --git a/client/mobile/profile_manager.go b/client/mobile/profile_manager.go index 348b7253b..ad79d80c0 100644 --- a/client/mobile/profile_manager.go +++ b/client/mobile/profile_manager.go @@ -192,7 +192,10 @@ func (pm *ProfileManager) LogoutProfile(id string) error { return fmt.Errorf("profile %q does not exist", id) } - config, err := profilemanager.ReadConfig(configPath) + // The existing-file reader, not the generating one: the check above is not + // atomic with this read, so a profile removed in between would otherwise be + // resolved from the defaults here and recreated by the write below. + config, err := profilemanager.GetExistingConfig(configPath) if err != nil { return fmt.Errorf("read profile config: %w", err) } diff --git a/client/server/login_gate_test.go b/client/server/login_gate_test.go index de62a8180..17ae3ecad 100644 --- a/client/server/login_gate_test.go +++ b/client/server/login_gate_test.go @@ -93,7 +93,7 @@ func TestLogin_ChangeThatBecomesPrivilegedMidRequestHasNoSideEffects(t *testing. require.NoError(t, err) require.Equal(t, profilemanager.ID(activeProfile), active.ID, "the refused login switched the active profile anyway") - stored, err := profilemanager.ReadConfig(targetPath) + stored, err := profilemanager.GetExistingConfig(targetPath) require.NoError(t, err) require.Equal(t, "https://api.netbird.io:443", stored.ManagementURL.String(), "the refused login moved the management URL") } diff --git a/client/server/login_overrides_test.go b/client/server/login_overrides_test.go index 5a2298764..f858c6059 100644 --- a/client/server/login_overrides_test.go +++ b/client/server/login_overrides_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" ) func TestPersistLoginOverrides(t *testing.T) { @@ -80,10 +81,13 @@ func TestPersistLoginOverrides(t *testing.T) { require.NoError(t, err, "seed config") activeProf := &profilemanager.ActiveProfileState{ID: "default"} - err = persistLoginOverrides(activeProf, tt.newMgmtURL, tt.newPSK) + err = persistLoginOverrides(activeProf, &proto.LoginRequest{ + ManagementUrl: tt.newMgmtURL, + OptionalPreSharedKey: tt.newPSK, + }) require.NoError(t, err, "persistLoginOverrides") - cfg, err := profilemanager.ReadConfig(profilemanager.DefaultConfigPath) + cfg, err := profilemanager.ReadConfigOrDefault(profilemanager.DefaultConfigPath) require.NoError(t, err, "read back config") require.Equal(t, tt.wantMgmtURL, cfg.ManagementURL.String(), "management URL") diff --git a/client/server/logout_gate_test.go b/client/server/logout_gate_test.go index 2d84d1b6a..d88801959 100644 --- a/client/server/logout_gate_test.go +++ b/client/server/logout_gate_test.go @@ -129,7 +129,7 @@ func TestLogout_ForeignUserProfileDoesNotUseTheRunningConfig(t *testing.T) { // refused with PermissionDenied. The namesake profile does not, so the // correct path gets as far as dialing its own unreachable management URL. enableSSHOnProfile(t, cfgPath) - running, err := profilemanager.GetConfig(cfgPath) + running, err := profilemanager.GetExistingConfig(cfgPath) require.NoError(t, err) s.config = running s.connectClient = newDummyConnectClient(context.Background()) diff --git a/client/server/mdm.go b/client/server/mdm.go index 7a47b2a57..b22c3b0a3 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -180,92 +180,6 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [ }) } -// setConfigRequestHasConfigOverrides reports whether the SetConfigRequest -// carries ANY field that would actually mutate the persisted config. -// The CLI builds a SetConfigRequest unconditionally on every -// `netbird up` (see setupSetConfigReq in cmd/up.go) — a plain -// `netbird up` produces a request with every field at its zero value; -// the gate must skip such no-op invocations or it would always fire -// even when the user did not pass any --flag. Returns false on a nil -// msg; true when any management/admin URL, PSK, DNS/NAT list+clean -// flag, interface/port/MTU, or any optional bool/duration field is set. -func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool { - if msg == nil { - return false - } - return msg.ManagementUrl != "" || - msg.AdminURL != "" || - msg.OptionalPreSharedKey != nil || - len(msg.CustomDNSAddress) > 0 || - len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs || - len(msg.ExtraIFaceBlacklist) > 0 || - len(msg.DnsLabels) > 0 || msg.CleanDNSLabels || - msg.DnsRouteInterval != nil || - msg.RosenpassEnabled != nil || - msg.RosenpassPermissive != nil || - msg.InterfaceName != nil || - msg.WireguardPort != nil || - msg.Mtu != nil || - msg.DisableAutoConnect != nil || - msg.ServerSSHAllowed != nil || - msg.RemoteJobsAllowed != nil || - msg.NetworkMonitor != nil || - msg.DisableClientRoutes != nil || - msg.DisableServerRoutes != nil || - msg.DisableDns != nil || - msg.DisableFirewall != nil || - msg.BlockLanAccess != nil || - msg.DisableNotifications != nil || - msg.BlockInbound != nil || - msg.DisableIpv6 != nil || - msg.EnableSSHRoot != nil || - msg.EnableSSHSFTP != nil || - msg.EnableSSHLocalPortForwarding != nil || - msg.EnableSSHRemotePortForwarding != nil || - msg.DisableSSHAuth != nil || - msg.SshJWTCacheTTL != nil || - msg.EnableLocalMetrics != nil || - msg.LocalMetricsAddress != nil -} - -// loginRequestHasConfigOverrides reports whether the LoginRequest -// carries ANY field that would mutate persisted daemon configuration -// (as opposed to pure-auth fields like setupKey, hostname, hint, -// profileName, username). Used by the Login handler to decide whether -// the `--disable-update-settings` / MDM gates must run: a re-auth that -// changes nothing about the configuration is always allowed. -func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool { - if msg == nil { - return false - } - return msg.ManagementUrl != "" || - msg.AdminURL != "" || - msg.PreSharedKey != "" || //nolint:staticcheck // SA1019: legacy proto field still accepted by Login - msg.OptionalPreSharedKey != nil || - len(msg.CustomDNSAddress) > 0 || - len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs || - msg.RosenpassEnabled != nil || - msg.InterfaceName != nil || - msg.WireguardPort != nil || - msg.DisableAutoConnect != nil || - msg.ServerSSHAllowed != nil || - msg.RemoteJobsAllowed != nil || - msg.RosenpassPermissive != nil || - len(msg.ExtraIFaceBlacklist) > 0 || - msg.NetworkMonitor != nil || - msg.DnsRouteInterval != nil || - msg.DisableClientRoutes != nil || - msg.DisableServerRoutes != nil || - msg.DisableDns != nil || - msg.DisableFirewall != nil || - msg.BlockLanAccess != nil || - msg.DisableNotifications != nil || - len(msg.DnsLabels) > 0 || msg.CleanDNSLabels || - msg.BlockInbound != nil || - msg.EnableLocalMetrics != nil || - msg.LocalMetricsAddress != nil -} - // loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the // LoginRequest surface. Same value-aware semantics: a field set to the // MDM-enforced value is a no-op echo, not a conflict; only a divergent diff --git a/client/server/provision_identity_test.go b/client/server/provision_identity_test.go new file mode 100644 index 000000000..c944cdb1a --- /dev/null +++ b/client/server/provision_identity_test.go @@ -0,0 +1,59 @@ +package server + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +// The daemon provisions the peer's identity and persists it, because a key that +// stayed in memory would come back different on the next start and register a +// second peer. Provisioning is idempotent: a profile that already has an +// identity keeps the one on disk. +func TestProvisionProfileIdentity(t *testing.T) { + origDir := profilemanager.DefaultConfigPathDir + origPath := profilemanager.DefaultConfigPath + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDir + profilemanager.DefaultConfigPath = origPath + }) + + dir := t.TempDir() + profilemanager.DefaultConfigPathDir = dir + profilemanager.DefaultConfigPath = filepath.Join(dir, "default.json") + + activeProf := &profilemanager.ActiveProfileState{ID: "default"} + + t.Run("a profile with no file is provisioned and written", func(t *testing.T) { + _, err := os.Stat(profilemanager.DefaultConfigPath) + require.True(t, os.IsNotExist(err), "the fixture starts without a config file") + + config, existed, err := provisionProfileIdentity(activeProf) + require.NoError(t, err) + require.False(t, existed, "the file was reported as pre-existing") + require.NotEmpty(t, config.PrivateKey) + + stored, err := profilemanager.GetExistingConfig(profilemanager.DefaultConfigPath) + require.NoError(t, err, "provisioning did not write the config out") + require.Equal(t, config.PrivateKey, stored.PrivateKey, "the persisted identity is not the one returned") + require.NotEmpty(t, stored.SSHKey) + }) + + t.Run("a second call keeps the identity on disk", func(t *testing.T) { + before, err := profilemanager.GetExistingConfig(profilemanager.DefaultConfigPath) + require.NoError(t, err) + + config, existed, err := provisionProfileIdentity(activeProf) + require.NoError(t, err) + require.True(t, existed) + require.Equal(t, before.PrivateKey, config.PrivateKey, "provisioning minted a second identity") + + after, err := profilemanager.GetExistingConfig(profilemanager.DefaultConfigPath) + require.NoError(t, err) + require.Equal(t, before.PrivateKey, after.PrivateKey, "provisioning rewrote the stored identity") + }) +} diff --git a/client/server/server.go b/client/server/server.go index 108aa8a41..f7f81b688 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -58,8 +58,13 @@ const ( // JWT token cache TTL for the client daemon (disabled by default) defaultJWTCacheTTL = 0 - errRestoreResidualState = "failed to restore residual state: %v" - errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled" + errRestoreResidualState = "failed to restore residual state: %v" + errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled" + // errUpdateSettingsDisabled is returned with codes.FailedPrecondition, not + // codes.Unavailable: the daemon answered, and it refused. Unavailable means + // "the daemon cannot serve this", which is why the CLI downgrades it to a + // warning and the GUI reads it as an unreachable daemon — both wrong for a + // refusal the caller has to act on. errUpdateSettingsDisabled = "update settings are disabled, you cannot use this feature without update settings enabled" errNetworksDisabled = "network selection is disabled by the administrator" ) @@ -492,16 +497,27 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques s.mutex.Lock() defer s.mutex.Unlock() - // Skip the update-settings gate when the request carries no actual - // overrides: the CLI builds a SetConfigRequest unconditionally on - // every `netbird up` (setupSetConfigReq in cmd/up.go), so a plain - // `netbird up` would otherwise always trip the gate and surface a - // misleading "setConfig method is not available" warning, even when - // the user did not pass any config flag. - if setConfigRequestHasConfigOverrides(msg) { - if s.checkUpdateSettingsDisabled() { - return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled) - } + stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username) + if err != nil { + return nil, err + } + + config, err := s.setConfigInputFromRequest(msg) + if err != nil { + return nil, err + } + + // Update-settings gate: refuse the request only when it would actually + // change a persisted setting. The CLI builds a SetConfigRequest + // unconditionally on every `netbird up` (setupSetConfigReq in + // cmd/up.go) and fills it from its flags and environment, so a service + // or container that restates the configuration it already runs with + // must pass the gate. Deciding this on field presence alone refused + // those callers, and — through the identical gate in Login — refused + // their login too, which left a client configured by environment + // (NB_MANAGEMENT_URL and friends) unable to come up at all. + if s.checkUpdateSettingsDisabled() && configChangeRequested(stored, config) { + return nil, gstatus.Errorf(codes.FailedPrecondition, errUpdateSettingsDisabled) } // MDM gate: refuse the whole request if any of its fields is enforced @@ -513,19 +529,10 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques return nil, err } - stored, err := s.storedProfileConfig(msg.ProfileName, msg.Username) - if err != nil { - return nil, err - } if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromSetConfig(msg)); err != nil { return nil, err } - config, err := s.setConfigInputFromRequest(msg) - if err != nil { - return nil, err - } - updatedConf, err := profilemanager.UpdateConfig(config) if err != nil { log.Errorf("failed to update profile config: %v", err) @@ -641,37 +648,45 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile // Login uses setup key to prepare configuration for the daemon. func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*proto.LoginResponse, error) { + activeProf, err := s.profileManager.GetActiveProfileState() + if err != nil { + return nil, fmt.Errorf("failed to get active profile state: %w", err) + } + + // The stored config of the profile this request targets backs all three + // gates below. It is read before anything changes daemon state, so a + // refused login neither switches the profile nor cancels a login already + // in progress, and it is the profile the switch further down would + // activate. + stored, err := s.storedLoginConfig(activeProf, msg) + if err != nil { + return nil, err + } + // Config-override gates. LoginRequest carries the same surface as // SetConfigRequest (managementUrl, PSK, ssh/rosenpass/port toggles, // ...), so the same protections must apply. Without these the CLI // command `netbird up --management-url=X` (which falls through to // Login when SetConfig is rejected — see cmd/up.go) would silently // bypass `--disable-update-settings` and any MDM policy. - if loginRequestHasConfigOverrides(msg) { - if s.checkUpdateSettingsDisabled() { - return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled) - } - policy := s.mdmLoader.Load() - if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil { - return nil, err - } + // + // The update-settings gate is value-aware, as in SetConfig: it looks at + // what a login would actually persist (loginOverridesInput) and refuses + // only a real divergence from the stored config. A login that restates + // the values already on disk changes nothing, so it must go through — + // that is what keeps a re-login, or a container restart carrying + // NB_MANAGEMENT_URL, working with the kill switch on. + if s.checkUpdateSettingsDisabled() && configChangeRequested(stored, loginOverridesInput(msg)) { + return nil, gstatus.Errorf(codes.FailedPrecondition, errUpdateSettingsDisabled) } - activeProf, err := s.profileManager.GetActiveProfileState() - if err != nil { - log.Errorf("failed to get active profile state: %v", err) - return nil, fmt.Errorf("failed to get active profile state: %w", err) + policy := s.mdmLoader.Load() + if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil { + return nil, err } // Privilege gate: same restrictions as SetConfig, since LoginRequest can carry - // the same fields. It runs before anything here changes daemon state, so a - // refused login neither switches the profile nor cancels a login already in - // progress, and it reads the profile the request targets, which is the one the - // switch below would activate. - stored, err := s.storedLoginConfig(activeProf, msg) - if err != nil { - return nil, err - } + // the same fields. if err := requirePrivilegeForConfigChange(callerCtx, stored, privilegedChangeFromLogin(msg)); err != nil { return nil, err } @@ -1174,6 +1189,10 @@ func (s *Server) storedLoginConfig(activeProf *profilemanager.ActiveProfileState // storedConfigAtPath reads a profile config file, yielding nil when it does not // exist yet. +// +// Reading it has no side effect: profilemanager.GetExistingConfig does not +// write, so a request that the gates go on to refuse leaves the profile file as +// it found it. func (s *Server) storedConfigAtPath(path string) (*profilemanager.Config, error) { if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { @@ -1182,7 +1201,7 @@ func (s *Server) storedConfigAtPath(path string) (*profilemanager.Config, error) return nil, fmt.Errorf("stat profile config: %w", err) } - cfg, err := profilemanager.GetConfig(path) + cfg, err := profilemanager.GetExistingConfig(path) if err != nil { return nil, fmt.Errorf("read profile config: %w", err) } @@ -1485,8 +1504,16 @@ func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutRe return &proto.LogoutResponse{}, nil } -// getConfig reads config file and returns Config and whether the config file already existed. Errors out if it does not exist -func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, bool, error) { +// provisionProfileIdentity resolves the active profile's config and puts the +// keys that identify the peer on disk, reporting whether the config file +// already existed. +// +// This is the daemon's provisioning point: the config resolved here is the one +// the peer runs with, so it needs its identity, and that has to reach disk — a +// key that stays in memory would come back different on the next start and +// re-register the peer. Reads themselves are pure, so the write is here, in +// the open, instead of hiding inside the reader. +func provisionProfileIdentity(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, bool, error) { cfgPath, err := activeProf.FilePath() if err != nil { return nil, false, fmt.Errorf("failed to get active profile file path: %w", err) @@ -1497,15 +1524,38 @@ func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*prof log.Infof("active profile config existed: %t, err %v", configExisted, err) - config, err := profilemanager.ReadConfig(cfgPath) + config, err := profilemanager.ReadConfigOrDefault(cfgPath) if err != nil { return nil, false, fmt.Errorf("failed to get config: %w", err) } - // Apply the daemon-owned MDM policy on top of the just-resolved - // Config. profilemanager's apply() initialises the policy to - // empty — the Loader lives outside Config, so this overlay step - // is driven externally here. + generated, err := config.EnsureIdentity() + if err != nil { + return nil, false, fmt.Errorf("ensure profile identity: %w", err) + } + + if generated || !configExisted { + if err := profilemanager.WriteOutConfig(cfgPath, config); err != nil { + return nil, false, fmt.Errorf("write out profile config: %w", err) + } + } + + return config, configExisted, nil +} + +// getConfig resolves the active profile's config, provisions its identity and +// reports whether the config file already existed. +func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, bool, error) { + config, configExisted, err := provisionProfileIdentity(activeProf) + if err != nil { + return nil, false, err + } + + // Apply the daemon-owned MDM policy on top of the just-resolved Config. + // profilemanager's apply() initialises the policy to empty — the Loader + // lives outside Config, so this overlay step is driven externally here. + // After the write above, on purpose: the overlay is runtime-only and + // re-derived on every load, so the file keeps the profile's own values. config.ApplyMDMPolicy(s.mdmLoader.Load()) return config, configExisted, nil @@ -1560,7 +1610,7 @@ func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager. cfgPath = profilemanager.DefaultConfigPath } - config, err := profilemanager.GetConfig(cfgPath) + config, err := profilemanager.GetExistingConfig(cfgPath) if err != nil { return fmt.Errorf("profile '%s' not found", profile.ID) } @@ -1579,6 +1629,19 @@ func (s *Server) sendLogoutRequestWithConfig(ctx context.Context, config *profil // Privilege gate: deregistering frees this machine's key to be registered // against another management server, which is only restricted while the SSH // server makes that a privilege handover. + // Ahead of the privilege gate on purpose. A profile with no identity was + // never registered — a logout clears the keys in place, so logging the same + // profile out twice lands here — so there is nothing to deregister and + // nothing for the gate to protect: what it guards against is handing this + // machine's registered key to another management server. Behind the gate, + // an unprivileged caller would be refused instead, and for a profile whose + // ServerSSHAllowed is unset that is every caller, since an absent value + // counts as SSH enabled. + if config.PrivateKey == "" { + log.Infof("profile carries no identity, nothing to deregister") + return nil + } + if err := requirePrivilegeForDeregistration(ctx, config); err != nil { return err } @@ -2196,7 +2259,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p cfgPath = profilemanager.DefaultConfigPath } - cfg, err := profilemanager.GetConfig(cfgPath) + cfg, err := profilemanager.GetExistingConfig(cfgPath) if err != nil { log.Errorf("failed to get active profile config: %v", err) return nil, fmt.Errorf("failed to get active profile config: %w", err) @@ -2659,8 +2722,6 @@ func sendTerminalNotification() error { return wallCmd.Wait() } -// persistLoginOverrides writes management URL and pre-shared key from a LoginRequest to the -// active profile config so that subsequent reads pick them up. Empty/nil values are ignored. // afterLoginPreCheck is a seam for tests to run a concurrent config change // between Login's first privilege check and the authoritative one. var afterLoginPreCheck func() @@ -2691,6 +2752,15 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto. return nil, nil, err } + // The update-settings decision is re-taken here for the same reason as the + // privilege one: Login's earlier check ran outside this lock, so the stored + // config it compared against could have moved since. This one is the + // authoritative check, and it is the last read before persistLoginOverrides + // writes. + if s.checkUpdateSettingsDisabled() && configChangeRequested(stored, loginOverridesInput(msg)) { + return nil, nil, gstatus.Errorf(codes.FailedPrecondition, errUpdateSettingsDisabled) + } + s.mutex.Lock() if s.actCancel != nil { s.actCancel() @@ -2717,18 +2787,28 @@ func (s *Server) authorizeAndPrepareLogin(callerCtx context.Context, msg *proto. return nil, nil, fmt.Errorf("active profile state: %w", err) } - if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil { + if err := persistLoginOverrides(activeProf, msg); err != nil { return nil, nil, fmt.Errorf("persist login overrides: %w", err) } + // Provisioning under the same lock as the decision above, and next to the + // write it guards. getConfig would otherwise mint the identity and persist + // it once this returns: between its read and its write, a SetConfig that + // had already answered its caller would be overwritten by the config this + // login read before it landed. + if _, _, err := provisionProfileIdentity(activeProf); err != nil { + return nil, nil, err + } + return ctx, activeProf, nil } -func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, managementURL string, preSharedKey *string) error { - if preSharedKey != nil && *preSharedKey == "" { - preSharedKey = nil - } - if managementURL == "" && preSharedKey == nil { +// persistLoginOverrides writes the config fields a login request is allowed to +// carry into the active profile. It shares its input builder with the +// update-settings gate, so the gate judges exactly the fields this writes. +func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, msg *proto.LoginRequest) error { + input := loginOverridesInput(msg) + if input.ManagementURL == "" && input.PreSharedKey == nil { return nil } @@ -2737,11 +2817,7 @@ func persistLoginOverrides(activeProf *profilemanager.ActiveProfileState, manage return fmt.Errorf("active profile file path: %w", err) } - input := profilemanager.ConfigInput{ - ConfigPath: cfgPath, - ManagementURL: managementURL, - PreSharedKey: preSharedKey, - } + input.ConfigPath = cfgPath if _, err := profilemanager.UpdateOrCreateConfig(input); err != nil { return fmt.Errorf("update config: %w", err) } diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index a392af6d3..d174dc47b 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -290,7 +290,7 @@ func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) { // Confirm RosenpassEnabled was NOT applied even though it was not // in the conflict list: the request was rejected as a whole. - reloaded, err := profilemanager.GetConfig(cfgPath) + reloaded, err := profilemanager.GetExistingConfig(cfgPath) require.NoError(t, err) assert.False(t, reloaded.RosenpassEnabled, "non-conflicting field must not be applied when request is rejected") } diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index 7442b718e..d7f7b2bd5 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -125,7 +125,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { cfgPath, err := profState.FilePath() require.NoError(t, err) - cfg, err := profilemanager.GetConfig(cfgPath) + cfg, err := profilemanager.GetExistingConfig(cfgPath) require.NoError(t, err) require.Equal(t, "https://new-api.netbird.io:443", cfg.ManagementURL.String()) diff --git a/client/server/ssh_gate.go b/client/server/ssh_gate.go index 01d24687e..40d66b7a5 100644 --- a/client/server/ssh_gate.go +++ b/client/server/ssh_gate.go @@ -331,21 +331,5 @@ func sameManagementURL(stored *url.URL, requested string) bool { return false } - return stored.Scheme == parsed.Scheme && - stored.Hostname() == parsed.Hostname() && - effectivePort(stored) == effectivePort(parsed) -} - -func effectivePort(u *url.URL) string { - if port := u.Port(); port != "" { - return port - } - switch u.Scheme { - case "https": - return "443" - case "http": - return "80" - default: - return "" - } + return profilemanager.SameServiceURL(stored, parsed) } diff --git a/client/server/update_settings_gate.go b/client/server/update_settings_gate.go new file mode 100644 index 000000000..b4d32754f --- /dev/null +++ b/client/server/update_settings_gate.go @@ -0,0 +1,55 @@ +package server + +import ( + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/proto" +) + +// configChangeRequested reports whether applying input would move the target +// profile away from the configuration it already persists. It is the decision +// procedure of the update-settings kill switch (--disable-update-settings / +// NB_DISABLE_UPDATE_SETTINGS / the MDM DisableUpdateSettings key): that switch +// forbids *changing* settings, so a request that restates the stored values is +// not a change and must not be refused. +// +// This has to be judged on values, not on field presence. `netbird up` rebuilds +// the whole config surface of SetConfigRequest and LoginRequest from its flags +// and environment on every invocation, so a service or container configured by +// environment restates its own configuration on every start. A presence-based +// gate refused those requests, and because Login carries the same fields it +// refused the login too — leaving such a client unable to come up at all. +// +// A dry run that cannot be evaluated fails closed: the request counts as a +// change, so a malformed field can never open the gate. The error itself is +// reported to the caller by the real update path. +func configChangeRequested(stored *profilemanager.Config, input profilemanager.ConfigInput) bool { + changed, err := stored.WouldChange(input) + if err != nil { + log.Warnf("cannot evaluate the requested config change, treating it as a change: %v", err) + return true + } + return changed +} + +// loginOverridesInput builds the ConfigInput a login request persists. The +// management URL and the pre-shared key are the only config fields the daemon +// applies from a LoginRequest; everything else on that message is either pure +// auth or ignored. An empty pre-shared key is dropped rather than written, so +// a login cannot clear the stored key by omission. +// +// Both the write (persistLoginOverrides) and the update-settings gate go +// through this builder, so the gate can neither refuse a field the write +// ignores nor miss one it applies. +func loginOverridesInput(msg *proto.LoginRequest) profilemanager.ConfigInput { + preSharedKey := msg.OptionalPreSharedKey + if preSharedKey != nil && *preSharedKey == "" { + preSharedKey = nil + } + + return profilemanager.ConfigInput{ + ManagementURL: msg.ManagementUrl, + PreSharedKey: preSharedKey, + } +} diff --git a/client/server/update_settings_gate_test.go b/client/server/update_settings_gate_test.go new file mode 100644 index 000000000..0d2cd8810 --- /dev/null +++ b/client/server/update_settings_gate_test.go @@ -0,0 +1,390 @@ +package server + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" + "github.com/netbirdio/netbird/client/proto" +) + +// The seeded profile of setupServerWithProfile is created with this management +// URL, so a request carrying it restates what the profile already holds. +const storedManagementURL = "https://api.netbird.io:443" + +// A client configured by environment re-sends its whole configuration on every +// `netbird up`: the CLI fills the request from its flags and env regardless of +// what changed. With the update-settings kill switch on, such a request must +// pass — nothing about the configuration moves. +func TestSetConfig_RestatingTheStoredConfigPassesTheGate(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: storedManagementURL, + }) + require.NoError(t, err, "restating the stored management URL is not a settings change") +} + +// The same endpoint written without its default port is the same endpoint. A +// gate that compared raw strings refused NB_MANAGEMENT_URL=https://host, which +// is how the URL is normally spelled. +func TestSetConfig_EquivalentManagementURLPassesTheGate(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://api.netbird.io", + }) + require.NoError(t, err, "an implicit :443 is the same management URL") +} + +// The kill switch still has to do its job: a request that moves a setting is +// refused, and the profile keeps the value it had. +func TestSetConfig_ChangingASettingIsRefused(t *testing.T) { + s, ctx, profName, username, cfgPath := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://mgmt.elsewhere.example:443", + }) + require.Error(t, err, "moving the management URL is a settings change") + require.Equal(t, codes.FailedPrecondition, gstatus.Code(err), "want the update-settings refusal, got %v", err) + + cfg, err := profilemanager.GetExistingConfig(cfgPath) + require.NoError(t, err) + require.Equal(t, storedManagementURL, cfg.ManagementURL.String(), "the refused request changed the config anyway") +} + +// A field whose requested value differs from the stored one is a change even +// when the rest of the request restates the configuration. +func TestSetConfig_SingleDivergingFieldIsRefused(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + rosenpass := true + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: storedManagementURL, + RosenpassEnabled: &rosenpass, + }) + require.Error(t, err, "enabling Rosenpass is a settings change") + require.Equal(t, codes.FailedPrecondition, gstatus.Code(err), "want the update-settings refusal, got %v", err) +} + +// With the switch off, the same diverging request goes through: the gate must +// not leak into a daemon that never enabled it. +func TestSetConfig_ChangeAllowedWhenTheSwitchIsOff(t *testing.T) { + s, ctx, profName, username, cfgPath := setupServerWithProfile(t) + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://mgmt.elsewhere.example:443", + }) + require.NoError(t, err) + + cfg, err := profilemanager.GetExistingConfig(cfgPath) + require.NoError(t, err) + require.Equal(t, "https://mgmt.elsewhere.example:443", cfg.ManagementURL.String()) +} + +// Login carries the same config surface as SetConfig, so it is gated the same +// way: a login that would move a protected setting is refused before it can +// touch daemon state. +func TestLogin_ChangingTheManagementURLIsRefused(t *testing.T) { + s, _, profName, username, cfgPath := setupServerWithProfile(t) + s.updateSettingsDisabled = true + s.rootCtx = internal.CtxInitState(context.Background()) + + cancelled := false + s.actCancel = func() { cancelled = true } + + _, err := s.Login(userCtx(), &proto.LoginRequest{ + Username: &username, + ManagementUrl: "https://mgmt.elsewhere.example:443", + }) + require.Error(t, err, "moving the management URL through Login is a settings change") + require.Equal(t, codes.FailedPrecondition, gstatus.Code(err), "want the update-settings refusal, got %v", err) + + // "Refused before it can touch daemon state" is the contract, so check the + // state as well as the error. + cfg, err := profilemanager.GetExistingConfig(cfgPath) + require.NoError(t, err) + require.Equal(t, storedManagementURL, cfg.ManagementURL.String(), "the refused login moved the management URL") + require.False(t, cancelled, "the refused login cancelled the login already in progress") + + active, err := s.profileManager.GetActiveProfileState() + require.NoError(t, err) + require.Equal(t, profilemanager.ID(profName), active.ID, "the refused login switched the active profile") +} + +// seedProfileConfig writes a profile config carrying the given management URL +// and pre-shared key into a temp dir, and returns its path. +func seedProfileConfig(t *testing.T, managementURL, preSharedKey string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "seeded.json") + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: path, + ManagementURL: managementURL, + PreSharedKey: &preSharedKey, + }) + require.NoError(t, err, "seed profile config") + return path +} + +// The decision procedure itself, over the fields a login actually persists. +// A login that restates the stored values must not be refused: that is what +// keeps a re-login, or a container restart carrying NB_MANAGEMENT_URL, working +// with the kill switch on. +func TestLoginGateDecision(t *testing.T) { + stored, err := profilemanager.GetExistingConfig(seedProfileConfig(t, storedManagementURL, "stored-key")) + require.NoError(t, err) + + redacted := mdm.PreSharedKeyRedactedSentinel + empty := "" + sameKey := "stored-key" + otherKey := "other-key" + + tests := []struct { + name string + msg *proto.LoginRequest + wantChanged bool + }{ + { + name: "pure auth carries no config", + msg: &proto.LoginRequest{SetupKey: "ABC"}, + wantChanged: false, + }, + { + name: "stored management URL restated", + msg: &proto.LoginRequest{ManagementUrl: storedManagementURL}, + wantChanged: false, + }, + { + name: "stored management URL without its default port", + msg: &proto.LoginRequest{ManagementUrl: "https://api.netbird.io"}, + wantChanged: false, + }, + { + name: "different management URL", + msg: &proto.LoginRequest{ManagementUrl: "https://mgmt.elsewhere.example:443"}, + wantChanged: true, + }, + { + name: "stored pre-shared key restated", + msg: &proto.LoginRequest{OptionalPreSharedKey: &sameKey}, + wantChanged: false, + }, + { + name: "redacted pre-shared key echoed back", + msg: &proto.LoginRequest{OptionalPreSharedKey: &redacted}, + wantChanged: false, + }, + { + name: "empty pre-shared key is not a request to clear it", + msg: &proto.LoginRequest{OptionalPreSharedKey: &empty}, + wantChanged: false, + }, + { + name: "different pre-shared key", + msg: &proto.LoginRequest{OptionalPreSharedKey: &otherKey}, + wantChanged: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.wantChanged, configChangeRequested(stored, loginOverridesInput(tt.msg))) + }) + } +} + +// A profile with no config on disk yet is judged against the config the daemon +// would create for it, so a first login that asks for the defaults is not a +// change while one that asks for a different management URL is. +func TestGateDecisionWithoutStoredConfig(t *testing.T) { + require.False(t, configChangeRequested(nil, profilemanager.ConfigInput{}), + "a request carrying nothing cannot change anything") + require.False(t, configChangeRequested(nil, profilemanager.ConfigInput{ManagementURL: profilemanager.DefaultManagementURL}), + "asking for the default management URL is what the daemon would write anyway") + require.True(t, configChangeRequested(nil, profilemanager.ConfigInput{ManagementURL: "https://mgmt.elsewhere.example:443"}), + "asking for a non-default management URL is a change") +} + +// A dry run that cannot be evaluated must fail closed, or a malformed field +// would open the gate. +func TestGateDecisionFailsClosedOnAnInvalidRequest(t *testing.T) { + require.True(t, configChangeRequested(nil, profilemanager.ConfigInput{ManagementURL: "not-a-url"}), + "an unevaluable request must count as a change") +} + +// The gate reads the stored config to decide, and reading it must not write it: +// a refused request has to leave the profile file byte-for-byte as it was. +// A config file missing a field the config layer fills in (MTU, here) is what +// makes the normalization write fire. +func TestSetConfig_RefusedRequestLeavesTheConfigFileUntouched(t *testing.T) { + s, ctx, profName, username, cfgPath := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + require.NoError(t, os.WriteFile(cfgPath, []byte(`{"WgIface":"wt0"}`), 0o600)) + before, err := os.ReadFile(cfgPath) + require.NoError(t, err) + + _, err = s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://mgmt.elsewhere.example:443", + }) + require.Error(t, err) + require.Equal(t, codes.FailedPrecondition, gstatus.Code(err), "want the update-settings refusal, got %v", err) + + after, err := os.ReadFile(cfgPath) + require.NoError(t, err) + require.Equal(t, string(before), string(after), "the refused request rewrote the profile config") +} + +// The container case that the string comparison still broke: the management URL +// supplied through the environment is the stored one, written with a trailing +// slash. +func TestSetConfig_ManagementURLSpellingsPassTheGate(t *testing.T) { + for _, spelling := range []string{ + "https://api.netbird.io", + "https://api.netbird.io/", + "https://api.netbird.io:443/", + "https://API.netbird.io:443", + } { + t.Run(spelling, func(t *testing.T) { + s, ctx, profName, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: spelling, + }) + require.NoError(t, err, "%q is the stored management URL written differently", spelling) + }) + } +} + +// The RPC the whole fix hangs on. Login is retried by the CLI in a backoff +// loop, so a login that restates the stored configuration — which is what a +// container configured by environment sends on every start — must get past the +// gate, or the client never comes up at all. +// +// Past the gate the handler goes on to do real work this test does not stand +// up, so the assertion is only that the refusal did not happen. +func TestLogin_RestatingTheStoredConfigPassesTheGate(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + s.rootCtx = internal.CtxInitState(context.Background()) + + // Stand in for the management round trip the handler makes once the gate + // lets it through, so this test exercises the gate and not the network: + // without it the profile's management URL is dialed for real. + s.isLoginRequiredFn = func(context.Context) (bool, error) { return false, nil } + + _, err := s.Login(userCtx(), &proto.LoginRequest{ + Username: &username, + ManagementUrl: storedManagementURL, + }) + if err != nil { + require.NotEqual(t, codes.FailedPrecondition, gstatus.Code(err), + "the gate refused a login that changes nothing: %v", err) + require.NotContains(t, err.Error(), "update settings are disabled", + "the gate refused a login that changes nothing: %v", err) + } +} + +// The value-aware decision has the same synchronization problem as the +// privileged-change one: Login's first check runs outside guardedConfigMu, so +// the stored config it compared against can move before the write. A login that +// was a no-op when it was checked must not be written once it has become a +// change. +func TestLogin_ChangeThatAppearsMidRequestIsRefused(t *testing.T) { + s, _, _, username, _ := setupServerWithProfile(t) + s.updateSettingsDisabled = true + s.rootCtx = internal.CtxInitState(context.Background()) + + target := "moved-under-us" + targetPath := filepath.Join(profilemanager.DefaultConfigPathDir, target+".json") + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: targetPath, + ManagementURL: storedManagementURL, + }) + require.NoError(t, err) + + cancelled := false + s.actCancel = func() { cancelled = true } + + // Stand in for a concurrent writer that repoints the profile between the two + // checks, which is the interleaving the lock has to make safe. The login + // restates the URL the profile held when it was checked, so the first check + // sees a no-op and lets it through. + afterLoginPreCheck = func() { + _, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: targetPath, + ManagementURL: "https://mgmt.elsewhere.example:443", + }) + require.NoError(t, err) + } + t.Cleanup(func() { afterLoginPreCheck = nil }) + + _, err = s.Login(userCtx(), &proto.LoginRequest{ + ProfileName: &target, + Username: &username, + ManagementUrl: storedManagementURL, + }) + require.Error(t, err, "the login became a settings change before it was written") + require.Equal(t, codes.FailedPrecondition, gstatus.Code(err), "want the update-settings refusal, got %v", err) + require.False(t, cancelled, "the refused login cancelled the login already in progress") + + stored, err := profilemanager.GetExistingConfig(targetPath) + require.NoError(t, err) + require.Equal(t, "https://mgmt.elsewhere.example:443", stored.ManagementURL.String(), + "the refused login wrote the management URL it was asked for") +} + +// Logging out a profile that was already logged out must not fail: the logout +// clears the keys in place, so the second attempt finds a profile with no +// identity, which was never registered and has nothing to deregister. +func TestLogout_ProfileWithoutAnIdentityIsANoOp(t *testing.T) { + s, _, _, _, cfgPath := setupServerWithProfile(t) + + loggedOut, err := profilemanager.GetExistingConfig(cfgPath) + require.NoError(t, err) + loggedOut.PrivateKey = "" + loggedOut.SSHKey = "" + require.NoError(t, profilemanager.WriteOutConfig(cfgPath, loggedOut)) + + stored, err := profilemanager.GetExistingConfig(cfgPath) + require.NoError(t, err) + require.NoError(t, s.sendLogoutRequestWithConfig(privilegedTestCtx(), stored), + "logging out an identity-less profile must not fail") + + // And for an unprivileged caller too: the deregistration privilege gate + // guards the handover of a registered key, so with no key there is nothing + // to guard. An unset SSH setting is what arms that gate — sshServerEnabled + // reads an absent value as enabled — so this stands in for every legacy + // profile, where behind the gate the caller would be refused. + stored.ServerSSHAllowed = nil + require.NoError(t, s.sendLogoutRequestWithConfig(userCtx(), stored), + "an unprivileged caller could not log out a profile with nothing to deregister") +} diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index c39584992..dcce2f908 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "Der NetBird-Dienst antwortet nicht. Bitte prüfen Sie, ob der Dienst läuft." }, + "error.settings_locked": { + "message": "Die Einstellungen können auf diesem Gerät nicht geändert werden: Ein Administrator hat sie gesperrt." + }, + "error.settings_managed_by_mdm": { + "message": "Diese Einstellung wird von Ihrer Organisation verwaltet und kann nicht geändert werden." + }, "error.unknown": { "message": "Vorgang fehlgeschlagen." }, diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index e9ee26de4..94d741b3e 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -1815,6 +1815,14 @@ "message": "The NetBird daemon is not responding. Please check that the service is running.", "description": "Error: the NetBird background service isn't responding. 'daemon' = the background service." }, + "error.settings_locked": { + "message": "Settings cannot be changed on this device: an administrator has locked them.", + "description": "Error: the local daemon was started with update-settings disabled, so it refuses configuration changes." + }, + "error.settings_managed_by_mdm": { + "message": "This setting is managed by your organization and cannot be changed.", + "description": "Error: the setting is enforced by an MDM policy. 'MDM' = mobile device management, the organization's device-management system." + }, "error.unknown": { "message": "Operation failed.", "description": "Generic fallback error message used when no specific error applies." diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 245b5aa5f..979879680 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "El daemon de NetBird no responde. Compruebe que el servicio esté en ejecución." }, + "error.settings_locked": { + "message": "La configuración no se puede cambiar en este dispositivo: un administrador la ha bloqueado." + }, + "error.settings_managed_by_mdm": { + "message": "Esta configuración está gestionada por su organización y no se puede cambiar." + }, "error.unknown": { "message": "La operación falló." }, diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index 6da66a643..f9961d864 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "Le daemon NetBird ne répond pas. Veuillez vérifier que le service est en cours d’exécution." }, + "error.settings_locked": { + "message": "Les paramètres ne peuvent pas être modifiés sur cet appareil : un administrateur les a verrouillés." + }, + "error.settings_managed_by_mdm": { + "message": "Ce paramètre est géré par votre organisation et ne peut pas être modifié." + }, "error.unknown": { "message": "L’opération a échoué." }, diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 1b4d2fb9d..94dcb578c 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "A NetBird szolgáltatás nem válaszol. Kérjük, ellenőrizze, hogy fut-e a szolgáltatás." }, + "error.settings_locked": { + "message": "A beállítások ezen az eszközön nem módosíthatók: egy rendszergazda zárolta őket." + }, + "error.settings_managed_by_mdm": { + "message": "Ezt a beállítást a szervezete kezeli, ezért nem módosítható." + }, "error.unknown": { "message": "A művelet meghiúsult." }, diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 4cee0f842..8c1312535 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "Il daemon NetBird non risponde. Verifichi che il servizio sia in esecuzione." }, + "error.settings_locked": { + "message": "Le impostazioni non possono essere modificate su questo dispositivo: un amministratore le ha bloccate." + }, + "error.settings_managed_by_mdm": { + "message": "Questa impostazione è gestita dalla sua organizzazione e non può essere modificata." + }, "error.unknown": { "message": "Operazione non riuscita." }, diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index 4fc81d283..a3138de6c 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "NetBird デーモンが応答していません。サービスが実行されているか確認してください。" }, + "error.settings_locked": { + "message": "この端末では設定を変更できません。管理者によってロックされています。" + }, + "error.settings_managed_by_mdm": { + "message": "この設定は組織によって管理されているため、変更できません。" + }, "error.unknown": { "message": "操作に失敗しました。" }, diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index cb4a542d0..a75ae3dfc 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "O daemon do NetBird não está respondendo. Verifique se o serviço está em execução." }, + "error.settings_locked": { + "message": "As configurações não podem ser alteradas neste dispositivo: um administrador bloqueou-as." + }, + "error.settings_managed_by_mdm": { + "message": "Esta configuração é gerida pela sua organização e não pode ser alterada." + }, "error.unknown": { "message": "A operação falhou." }, diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index 61ece03b8..e11ed26a4 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "Демон NetBird не отвечает. Проверьте, запущена ли служба." }, + "error.settings_locked": { + "message": "Настройки на этом устройстве изменить нельзя: администратор заблокировал их." + }, + "error.settings_managed_by_mdm": { + "message": "Эта настройка управляется вашей организацией и не может быть изменена." + }, "error.unknown": { "message": "Не удалось выполнить операцию." }, diff --git a/client/ui/i18n/locales/uk/common.json b/client/ui/i18n/locales/uk/common.json index f8fe71562..01d2f4452 100644 --- a/client/ui/i18n/locales/uk/common.json +++ b/client/ui/i18n/locales/uk/common.json @@ -1361,6 +1361,12 @@ "error.daemon_unreachable": { "message": "Служба NetBird не відповідає. Будь ласка, перевірте, чи запущена служба." }, + "error.settings_locked": { + "message": "Налаштування на цьому пристрої змінити неможливо: адміністратор їх заблокував." + }, + "error.settings_managed_by_mdm": { + "message": "Це налаштування керується вашою організацією і не може бути змінене." + }, "error.unknown": { "message": "Помилка операції." }, diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 126b11851..64725a69f 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -1363,6 +1363,12 @@ "error.daemon_unreachable": { "message": "NetBird 守护进程无响应。请检查服务是否正在运行。" }, + "error.settings_locked": { + "message": "此设备上的设置无法更改:管理员已将其锁定。" + }, + "error.settings_managed_by_mdm": { + "message": "此设置由您的组织管理,无法更改。" + }, "error.unknown": { "message": "操作失败。" }, diff --git a/client/ui/services/errors.go b/client/ui/services/errors.go index 0c6f2f20f..d193e9f02 100644 --- a/client/ui/services/errors.go +++ b/client/ui/services/errors.go @@ -134,8 +134,19 @@ func (c errorClassifier) classify(err error) *ClientError { strings.Contains(lower, "connection refused"), strings.Contains(lower, "context deadline exceeded"): code = "daemon_unreachable" + case strings.Contains(lower, "update settings are disabled"): + code = "settings_locked" + case strings.Contains(lower, "managed by mdm"): + code = "settings_managed_by_mdm" } + // Deliberately no blanket mapping for FailedPrecondition below: the daemon + // returns it for two dozen states that are not settings refusals at all — + // "not logged in", "client is not running", "session can no longer be + // extended" — and this classifier is shared with the session and connection + // services. Only the two refusals the daemon composes are named, by their + // message. + // Fall back to the gRPC status code when the message didn't match a known // substring — the daemon now forwards the innermost code with a clean desc // that no longer contains the English marker text. diff --git a/client/ui/services/errors_test.go b/client/ui/services/errors_test.go index 2f8f3d039..c2a10442f 100644 --- a/client/ui/services/errors_test.go +++ b/client/ui/services/errors_test.go @@ -34,6 +34,29 @@ func TestErrorClassifier_Classify(t *testing.T) { require.Equal(t, "session_expired", ce.Code) }) + t.Run("the update-settings kill switch is a refusal, not a failure", func(t *testing.T) { + err := gstatus.Error(gcodes.FailedPrecondition, + "update settings are disabled, you cannot use this feature without update settings enabled") + + ce := c.classify(err) + require.NotNil(t, ce) + require.Equal(t, "settings_locked", ce.Code) + }) + + t.Run("an MDM-managed field is named as such", func(t *testing.T) { + err := gstatus.Error(gcodes.FailedPrecondition, + "fields managed by MDM cannot be modified: [managementURL]") + + require.Equal(t, "settings_managed_by_mdm", c.classify(err).Code) + }) + + t.Run("an unrelated FailedPrecondition is not called a refusal", func(t *testing.T) { + // The daemon uses this code for states that are not settings refusals, + // and this classifier is shared with the session and connection + // services, so only the two refusals it composes are named. + require.Equal(t, "unknown", c.classify(gstatus.Error(gcodes.FailedPrecondition, "not logged in")).Code) + }) + t.Run("unavailable code maps to daemon_unreachable", func(t *testing.T) { ce := c.classify(gstatus.Error(gcodes.Unavailable, "transport closing")) require.Equal(t, "daemon_unreachable", ce.Code) From f5707c348532a4b15ea39fb53264339f6045932c Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 6 Oct 2026 12:08:11 +0200 Subject: [PATCH 109/209] [client] Add RTL layout support to the desktop UI (#8076) * [client] Add RTL layout support to the desktop UI follows the active language, Radix primitives get the matching dir, physical spacing/positioning uses logical utilities, and directional icons, animations, arrow-key navigation and tooltip sides flip in RTL. * [client] Address RTL review feedback Force LTR with isolation for monospace values, let truncated names take their direction from their content, and keep the profile name field in the UI direction. * [client] Keep translated monospace labels in their own direction The forced-LTR rule for monospace values now skips elements with an explicit dir, translated development labels use dir=auto, and monospace values rendered through TruncatedText opt into LTR. --------- Co-authored-by: Edward <43848523+thomashacker@users.noreply.github.com> --- .../src/components/CopyToClipboard.tsx | 6 ++-- .../frontend/src/components/DropdownMenu.tsx | 26 +++++++++------- .../src/components/LanguagePicker.tsx | 10 +++++-- client/ui/frontend/src/components/Tooltip.tsx | 11 ++++++- .../frontend/src/components/TruncatedText.tsx | 11 +++++-- .../frontend/src/components/VerticalTabs.tsx | 11 ++++--- .../src/components/dialog/ConfirmModal.tsx | 6 ++-- .../frontend/src/components/dialog/Dialog.tsx | 2 +- .../components/dialog/DialogDescription.tsx | 6 ++-- .../src/components/dialog/DialogHeading.tsx | 6 ++-- .../empty-state/DaemonOutdatedOverlay.tsx | 4 +-- .../frontend/src/components/inputs/Input.tsx | 19 ++++++------ .../frontend/src/components/inputs/Select.tsx | 2 +- .../components/switches/FancyToggleSwitch.tsx | 8 ++--- .../components/switches/SwitchItemGroup.tsx | 3 ++ .../src/components/switches/ToggleSwitch.tsx | 6 ++-- client/ui/frontend/src/globals.css | 5 ++++ client/ui/frontend/src/hooks/useDirection.ts | 6 ++++ .../ui/frontend/src/layouts/AppRightPanel.tsx | 7 +++-- client/ui/frontend/src/lib/i18n.ts | 1 + .../src/modules/error/ErrorDialog.tsx | 2 +- .../main/MainConnectionStatusSwitch.tsx | 9 +++--- .../src/modules/main/MainExitNodeSwitcher.tsx | 14 ++++++--- .../frontend/src/modules/main/MainHeader.tsx | 20 +++++++++---- .../ui/frontend/src/modules/main/MainPage.tsx | 4 +-- .../src/modules/main/advanced/Navigation.tsx | 13 +++++--- .../main/advanced/networks/NetworkFilters.tsx | 2 +- .../main/advanced/networks/Networks.tsx | 19 ++++++++---- .../main/advanced/peers/PeerDetailPanel.tsx | 30 +++++++++++++------ .../main/advanced/peers/PeerFilters.tsx | 2 +- .../src/modules/main/advanced/peers/Peers.tsx | 17 +++++++---- .../modules/profiles/ProfileCreationModal.tsx | 5 ++-- .../src/modules/profiles/ProfileDropdown.tsx | 5 +++- .../src/modules/profiles/ProfilesTab.tsx | 2 +- .../src/modules/settings/SettingsAbout.tsx | 4 +-- .../src/modules/settings/SettingsAdvanced.tsx | 2 ++ .../src/modules/settings/SettingsGeneral.tsx | 1 + .../src/modules/settings/SettingsPage.tsx | 3 ++ .../src/modules/settings/SettingsSection.tsx | 2 +- .../settings/SettingsTroubleshooting.tsx | 3 +- .../modules/welcome/WelcomeStepManagement.tsx | 7 +++-- .../src/modules/welcome/WelcomeStepTray.tsx | 4 +-- 42 files changed, 218 insertions(+), 108 deletions(-) create mode 100644 client/ui/frontend/src/hooks/useDirection.ts diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx index 4af4ecc8f..b3d5a8715 100644 --- a/client/ui/frontend/src/components/CopyToClipboard.tsx +++ b/client/ui/frontend/src/components/CopyToClipboard.tsx @@ -80,7 +80,7 @@ export const CopyToClipboard = ({ aria-label={resolvedLabel} aria-live={"polite"} className={cn( - "group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-left outline-none", + "group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-start outline-none", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", className, )} @@ -97,14 +97,14 @@ export const CopyToClipboard = ({ ) => { + const dir = useDirection(); + return ; +}; const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; const DropdownMenuGroup = DropdownMenuPrimitive.Group; const DropdownMenuPortal = DropdownMenuPrimitive.Portal; @@ -32,16 +36,16 @@ const DropdownMenuSubTrigger = React.forwardRef< {children} - + )); DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; @@ -102,9 +106,9 @@ const DropdownMenuItem = React.forwardRef< - + @@ -170,7 +174,7 @@ const DropdownMenuRadioItem = React.forwardRef< {children} @@ -190,7 +194,7 @@ const DropdownMenuLabel = React.forwardRef< ref={ref} className={cn( "px-2 py-1.5 text-sm font-semibold text-nb-gray-200", - inset && "pl-8", + inset && "ps-8", className, )} {...props} @@ -212,7 +216,7 @@ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => ( ); diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx index d0a95906f..1e1fdb17b 100644 --- a/client/ui/frontend/src/components/LanguagePicker.tsx +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -12,6 +12,7 @@ import { useFocusVisible } from "@/hooks/useFocusVisible"; import { loadLanguages } from "@/lib/i18n"; import { cn } from "@/lib/cn"; import { errorDialog, formatErrorMessage } from "@/lib/errors"; +import { useDirection } from "@/hooks/useDirection"; // No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/ @@ -21,6 +22,7 @@ const labelFor = (lang: Language): string => : lang.displayName; export function LanguagePicker() { + const dir = useDirection(); const { t, i18n } = useTranslation(); const [languages, setLanguages] = useState([]); const [open, setOpen] = useState(false); @@ -112,7 +114,7 @@ export function LanguagePicker() { aria-hidden={"true"} className={"shrink-0 text-nb-gray-200"} /> - + {current ? labelFor(current) : "—"} - + diff --git a/client/ui/frontend/src/components/Tooltip.tsx b/client/ui/frontend/src/components/Tooltip.tsx index d7a85277a..86589e47f 100644 --- a/client/ui/frontend/src/components/Tooltip.tsx +++ b/client/ui/frontend/src/components/Tooltip.tsx @@ -1,6 +1,7 @@ import { type ReactNode, useEffect, useRef, useState } from "react"; import * as RTooltip from "@radix-ui/react-tooltip"; import { cn } from "@/lib/cn"; +import { useDirection } from "@/hooks/useDirection"; type Props = { content: ReactNode; @@ -29,6 +30,8 @@ export const Tooltip = ({ contentClassName, closeDelay = 0, }: Props) => { + const dir = useDirection(); + const physicalSide = dir === "rtl" ? mirrorSide(side) : side; const [open, setOpen] = useState(false); const hoveringRef = useRef(false); const closeTimer = useRef | null>(null); @@ -73,7 +76,7 @@ export const Tooltip = ({ ); }; + +function mirrorSide(side: Props["side"]): Props["side"] { + if (side === "left") return "right"; + if (side === "right") return "left"; + return side; +} diff --git a/client/ui/frontend/src/components/TruncatedText.tsx b/client/ui/frontend/src/components/TruncatedText.tsx index 5b2d2160c..1174a8b42 100644 --- a/client/ui/frontend/src/components/TruncatedText.tsx +++ b/client/ui/frontend/src/components/TruncatedText.tsx @@ -6,9 +6,16 @@ type Props = { className?: string; tooltipContent?: ReactNode; delayDuration?: number; + dir?: "ltr" | "rtl" | "auto"; }; -export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => { +export const TruncatedText = ({ + text, + className, + tooltipContent, + delayDuration = 600, + dir = "auto", +}: Props) => { const ref = useRef(null); const [overflowing, setOverflowing] = useState(false); @@ -19,7 +26,7 @@ export const TruncatedText = ({ text, className, tooltipContent, delayDuration = }, [text]); const span = ( - + {text} ); diff --git a/client/ui/frontend/src/components/VerticalTabs.tsx b/client/ui/frontend/src/components/VerticalTabs.tsx index 306850ee2..bd1912662 100644 --- a/client/ui/frontend/src/components/VerticalTabs.tsx +++ b/client/ui/frontend/src/components/VerticalTabs.tsx @@ -3,12 +3,15 @@ import * as Tabs from "@radix-ui/react-tabs"; import { type LucideProps } from "lucide-react"; import { cn } from "@/lib/cn"; import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { useDirection } from "@/hooks/useDirection"; const Root = forwardRef>( function VerticalTabsRoot({ className, ...props }, ref) { + const dir = useDirection(); return ( (function VerticalTab return ( ); @@ -46,7 +49,7 @@ const Trigger = forwardRef(function VerticalTab (function VerticalTab size={iconSize} aria-hidden={"true"} className={cn( - "ml-2 shrink-0 transition-colors duration-150", + "ms-2 shrink-0 transition-colors duration-150", "text-nb-gray-350 dark:text-nb-gray-400", "group-data-[state=active]:text-nb-gray-100", )} @@ -75,7 +78,7 @@ const Trigger = forwardRef(function VerticalTab {title} {adornment && ( -
+
{adornment}
)} diff --git a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx index 7a8a1c906..e9c33e677 100644 --- a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx +++ b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx @@ -54,9 +54,9 @@ export const ConfirmModal = ({ onOpenAutoFocus={(e) => e.preventDefault()} >
-
- {title} - +
+ {title} + {description}
diff --git a/client/ui/frontend/src/components/dialog/Dialog.tsx b/client/ui/frontend/src/components/dialog/Dialog.tsx index c43c04b0b..3d0d47fc2 100644 --- a/client/ui/frontend/src/components/dialog/Dialog.tsx +++ b/client/ui/frontend/src/components/dialog/Dialog.tsx @@ -95,7 +95,7 @@ export const Content = forwardRef, Co {showClose && ( = { - left: "text-left", + start: "text-start", center: "text-center", - right: "text-right", + end: "text-end", }; type DialogDescriptionProps = { diff --git a/client/ui/frontend/src/components/dialog/DialogHeading.tsx b/client/ui/frontend/src/components/dialog/DialogHeading.tsx index b9dda72a9..52eb0c369 100644 --- a/client/ui/frontend/src/components/dialog/DialogHeading.tsx +++ b/client/ui/frontend/src/components/dialog/DialogHeading.tsx @@ -1,12 +1,12 @@ import { type ReactNode } from "react"; import { cn } from "@/lib/cn"; -type DialogAlign = "left" | "center" | "right"; +type DialogAlign = "start" | "center" | "end"; const alignClass: Record = { - left: "text-left", + start: "text-start", center: "text-center", - right: "text-right", + end: "text-end", }; type DialogHeadingProps = { diff --git a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx index e8e7108eb..da04bb4d7 100644 --- a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx +++ b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx @@ -65,7 +65,7 @@ export const DaemonOutdatedOverlay = () => { {clientVersion === "development" ? ( {t("settings.about.clientName")}{" "} - + {t("settings.about.development")} @@ -77,7 +77,7 @@ export const DaemonOutdatedOverlay = () => { {guiVersion === "development" ? ( {t("settings.about.guiName")}{" "} - + {t("settings.about.development")} diff --git a/client/ui/frontend/src/components/inputs/Input.tsx b/client/ui/frontend/src/components/inputs/Input.tsx index eada79a1f..9d82f4dad 100644 --- a/client/ui/frontend/src/components/inputs/Input.tsx +++ b/client/ui/frontend/src/components/inputs/Input.tsx @@ -86,13 +86,13 @@ function buildInputClassName( "file:border-0 file:bg-transparent file:text-sm file:font-medium", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-40", - opts.hasCustomPrefix && "!rounded-l-none !border-l-0", - opts.hasSuffix && "!pr-9", - opts.hasIcon && "!pl-10", + opts.hasCustomPrefix && "!rounded-s-none !border-s-0", + opts.hasSuffix && "!pe-9", + opts.hasIcon && "!ps-10", "border", opts.readOnly && "!border-nb-gray-800 !bg-nb-gray-910 text-nb-gray-350", opts.showStepper && - "!rounded-r-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", + "!rounded-e-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", opts.className, ); } @@ -107,7 +107,7 @@ function InputAffix({
@@ -138,7 +138,7 @@ function InputSuffixSlot({ return (
@@ -157,7 +157,7 @@ function NumberStepper({
(function Input( showPasswordToggle = false, copy = false, id, + dir, ...props }, ref, @@ -336,7 +337,7 @@ export const Input = forwardRef(function Input( return (
{label && } -
+
{customPrefix && ( ({ className={"shrink-0 text-nb-gray-200"} /> )} - {current?.label ?? "—"} + {current?.label ?? "—"}
@@ -55,7 +55,7 @@ export default function FancyToggleSwitch({
-
+
{helpText}
-
+
{ const layoutId = useId(); + const dir = useDirection(); const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]); return ( { + const dir = useDirection(); + const parallaxX = dir === "rtl" ? 48 : -48; return (
{ function syncDocumentLang() { if (typeof document !== "undefined") { document.documentElement.lang = i18next.language; + document.documentElement.dir = i18next.dir(i18next.language); } } diff --git a/client/ui/frontend/src/modules/error/ErrorDialog.tsx b/client/ui/frontend/src/modules/error/ErrorDialog.tsx index 4fbb78052..0be13b86e 100644 --- a/client/ui/frontend/src/modules/error/ErrorDialog.tsx +++ b/client/ui/frontend/src/modules/error/ErrorDialog.tsx @@ -66,7 +66,7 @@ export default function ErrorDialog() { wrap variant={"bright"} className={ - "mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-left" + "mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-start" } aria-label={t("common.copy")} > diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx index 2f4014741..df48524c1 100644 --- a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -267,12 +267,13 @@ export const MainConnectionStatusSwitch = () => { tabIndex={show && fqdn ? 0 : -1} className={cn( "mt-1 max-h-[1em] min-h-[1em] max-w-full transition-opacity duration-300", - "relative left-[0.55rem]", + "relative start-[0.55rem]", show && fqdn ? "opacity-100" : "pointer-events-none opacity-0", )} > @@ -345,7 +346,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole size={14} aria-hidden={"true"} className={cn( - "absolute -right-5 top-1/2 -translate-y-1/2", + "absolute -end-5 top-1/2 -translate-y-1/2", "shrink-0 text-nb-gray-300 transition-colors", "group-hover:text-nb-gray-200", "group-data-[state=open]:text-nb-gray-200", @@ -398,7 +399,7 @@ const IpRow = ({ value }: { value: string }) => { aria-label={`${t("common.copy")} ${value}`} className={cn( "group/iprow relative flex items-center justify-between gap-3", - "rounded-md px-2 py-1.5 text-left", + "rounded-md px-2 py-1.5 text-start", "text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50", "cursor-default outline-none transition-colors", isFocusVisible && diff --git a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx index 0d6579dd7..3e6281352 100644 --- a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx +++ b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx @@ -9,10 +9,12 @@ import { TruncatedText } from "@/components/TruncatedText"; import { useNetworks } from "@/contexts/NetworksContext"; import { useStatus } from "@/contexts/StatusContext"; import { useFocusVisible } from "@/hooks/useFocusVisible"; +import { useDirection } from "@/hooks/useDirection"; const NONE_VALUE = "__none__"; export const MainExitNodeSwitcher = () => { + const dir = useDirection(); const { t } = useTranslation(); const { status } = useStatus(); const { exitNodes, toggleExitNode } = useNetworks(); @@ -101,7 +103,11 @@ export const MainExitNodeSwitcher = () => { handleSelect(NONE_VALUE)} /> {hasAny &&
} {hasAny && ( - + {exitNodes.map((n) => ( ( tabIndex={0} disabled={disabled} className={cn( - "flex w-full items-center gap-3 rounded-xl p-2.5 pr-5 text-left outline-none", + "flex w-full items-center gap-3 rounded-xl p-2.5 pe-5 text-start outline-none", "border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920", "transition-colors duration-150", "wails-no-draggable", @@ -212,7 +218,7 @@ const NoneRow = ({ isActive, onSelect }: NoneRowProps) => { value={NONE_VALUE} onSelect={onSelect} className={cn( - "flex items-center gap-2 px-2 py-2 pr-3", + "flex items-center gap-2 px-2 py-2 pe-3", "cursor-default rounded-md text-sm outline-none", "data-[selected=true]:bg-nb-gray-900", )} @@ -237,7 +243,7 @@ const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => ( value={id} onSelect={onSelect} className={cn( - "flex items-center gap-2 px-2 py-2 pr-3", + "flex items-center gap-2 px-2 py-2 pe-3", "cursor-default rounded-md text-sm outline-none", "data-[selected=true]:bg-nb-gray-900", )} diff --git a/client/ui/frontend/src/modules/main/MainHeader.tsx b/client/ui/frontend/src/modules/main/MainHeader.tsx index d79e76cd1..6aa5b7993 100644 --- a/client/ui/frontend/src/modules/main/MainHeader.tsx +++ b/client/ui/frontend/src/modules/main/MainHeader.tsx @@ -25,7 +25,8 @@ import { cn } from "@/lib/cn"; import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut"; import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext"; import { useRestrictions } from "@/contexts/RestrictionsContext"; -import { isWindows } from "@/lib/platform.ts"; +import { isMacOS, isWindows } from "@/lib/platform.ts"; +import { useDirection } from "@/hooks/useDirection"; const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const; @@ -35,6 +36,8 @@ export const MainHeader = () => { const { viewMode, setViewMode } = useViewMode(); const { updateAvailable } = useClientVersion(); const { mdm, features } = useRestrictions(); + const dir = useDirection(); + const pinSettingsRight = isMacOS(); const openSettings = useCallback(() => { setMenuOpen(false); @@ -83,7 +86,7 @@ export const MainHeader = () => { /> { { )} >
-
{profileSlot}
+
{profileSlot}
-
{settingsSlot}
+
+ {settingsSlot} +
); }; diff --git a/client/ui/frontend/src/modules/main/MainPage.tsx b/client/ui/frontend/src/modules/main/MainPage.tsx index c05b3a025..1d8f8d1f6 100644 --- a/client/ui/frontend/src/modules/main/MainPage.tsx +++ b/client/ui/frontend/src/modules/main/MainPage.tsx @@ -55,7 +55,7 @@ const MainBody = () => { > {!features.disableNetworks && ( -
+
)} @@ -79,7 +79,7 @@ const AdvancedAppRightPanel = () => { } overlayOpen={selected !== null} - className={"m-5 ml-0"} + className={"m-5 ms-0"} >
{ diff --git a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx index dc8d7505b..1f0f527d1 100644 --- a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx +++ b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx @@ -5,6 +5,7 @@ import { cn } from "@/lib/cn"; import { useNavSection, type NavSection } from "@/contexts/NavSectionContext"; import { useStatus } from "@/contexts/StatusContext"; import { useRestrictions } from "@/contexts/RestrictionsContext"; +import { useDirection } from "@/hooks/useDirection"; type TabEntry = { value: NavSection; @@ -48,6 +49,10 @@ export const Navigation = () => { requestAnimationFrame(() => tabRefs.current[value]?.focus()); }; + const dir = useDirection(); + const forwardKey = dir === "rtl" ? "ArrowLeft" : "ArrowRight"; + const backwardKey = dir === "rtl" ? "ArrowRight" : "ArrowLeft"; + const handleKeyDown = (e: KeyboardEvent) => { const enabled = tabs.filter((t) => isConnected || t.value === section); if (enabled.length < 2) return; @@ -55,10 +60,10 @@ export const Navigation = () => { if (currentIndex === -1) return; let nextIndex: number; switch (e.key) { - case "ArrowRight": + case forwardKey: nextIndex = (currentIndex + 1) % enabled.length; break; - case "ArrowLeft": + case backwardKey: nextIndex = (currentIndex - 1 + enabled.length) % enabled.length; break; case "Home": @@ -106,8 +111,8 @@ export const Navigation = () => { "group relative flex flex-1 items-center justify-center", "gap-2.5 px-5 py-3.5", "outline-none transition-all", - isFirst && "rounded-tl-xl", - isLast && "rounded-tr-xl", + isFirst && "rounded-ss-xl", + isLast && "rounded-se-xl", "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60", isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300", isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default", diff --git a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx index 0d8c9f4db..3686275f0 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx @@ -52,7 +52,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => {active.label} ({counts[active.value]}) - + {filters.map((f) => { diff --git a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx index a13993aa9..da8028155 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx @@ -23,6 +23,7 @@ import { NoResults } from "@/components/empty-state/NoResults"; import { useStatus } from "@/contexts/StatusContext"; import { useNetworks } from "@/contexts/NetworksContext"; import { type NetworkFilter, NetworkFilters } from "./NetworkFilters"; +import { useDirection } from "@/hooks/useDirection"; // Daemon renders DNS-route prefixes (zero netip.Prefix) as "invalid Prefix". const INVALID_PREFIX = "invalid Prefix"; @@ -70,6 +71,7 @@ const buildOverlapMap = ( }; export const Networks = () => { + const dir = useDirection(); const { t } = useTranslation(); const { status } = useStatus(); const isConnected = status?.status === "Connected"; @@ -175,7 +177,11 @@ export const Networks = () => { {filtered.length === 0 ? ( ) : ( - + {scrollParent && ( { { contentClassName={cn( "max-h-72 max-w-[18rem] overflow-auto", "rounded-lg border border-nb-gray-800 bg-white dark:border-nb-gray-900 dark:bg-nb-gray-935", - "p-2 pr-4", + "p-2 pe-4", )} > {span} @@ -508,7 +515,9 @@ type ToggleProps = { }; const NetworkToggle = ({ checked, mixed }: ToggleProps) => { - const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5"; + const checkedTranslate = checked + ? "translate-x-[1.125rem] rtl:-translate-x-[1.125rem]" + : "translate-x-0.5 rtl:-translate-x-0.5"; return ( { diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx index ee3de6643..e22054c68 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx @@ -43,6 +43,7 @@ import { useStatus } from "@/contexts/StatusContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext"; import { useFocusVisible } from "@/hooks/useFocusVisible"; import { peerStatusLabelKey } from "./Peers"; +import { useDirection } from "@/hooks/useDirection"; const DEFAULT_TRANSITION: Transition = { duration: 0.32, @@ -70,6 +71,8 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { const { t } = useTranslation(); const { selected, setSelected } = usePeerDetail(); const { status, refresh } = useStatus(); + const dir = useDirection(); + const offscreenX = dir === "rtl" ? "-100%" : "100%"; useEffect(() => { if (!selected) return; @@ -111,7 +114,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { setSelected(null); return; } - if (e.key === "ArrowLeft") { + if (e.key === (dir === "rtl" ? "ArrowRight" : "ArrowLeft")) { const target = e.target as HTMLElement | null; const tag = target?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) return; @@ -120,7 +123,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { }; globalThis.addEventListener("keydown", onKey); return () => globalThis.removeEventListener("keydown", onKey); - }, [selected, setSelected]); + }, [selected, setSelected, dir]); const dialogRef = useRef(null); const backButtonRef = useRef(null); @@ -172,9 +175,9 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { aria-modal={"true"} aria-labelledby={"nb-peer-detail-title"} onKeyDown={onDialogKeyDown} - initial={{ x: "100%" }} + initial={{ x: offscreenX }} animate={{ x: 0 }} - exit={{ x: "100%" }} + exit={{ x: offscreenX }} transition={transition} style={{ willChange: "transform" }} className={cn("absolute inset-0 z-20 flex flex-col", "bg-nb-gray-940")} @@ -199,7 +202,11 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { "wails-no-draggable", )} > - + {
- + @@ -466,7 +477,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => { "inline-flex shrink-0 items-center gap-1 rounded", "bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910", "border border-nb-gray-900", - "py-1 pl-2.5 pr-2 text-xs font-medium text-nb-gray-300", + "py-1 pe-2 ps-2.5 text-xs font-medium text-nb-gray-300", "wails-no-draggable cursor-default outline-none transition-all", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", )} @@ -526,7 +537,7 @@ const ResourceRow = ({ value }: { value: string }) => { aria-label={`${t("common.copy")} ${value}`} className={cn( "group/resourcerow relative flex items-center justify-between gap-3", - "rounded-md px-2 py-1.5 text-left", + "rounded-md px-2 py-1.5 text-start", "text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50", "cursor-default outline-none transition-colors", isFocusVisible && @@ -547,6 +558,7 @@ const ResourceRow = ({ value }: { value: string }) => { const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => ( ( {label} { {active.label} ({counts[active.value]}) - + {filters.map((f) => { diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx index f3cb0f139..79a02a471 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -15,6 +15,7 @@ import { useStatus } from "@/contexts/StatusContext"; import { usePeerDetail } from "@/contexts/PeerDetailContext"; import { Tooltip } from "@/components/Tooltip"; import { TruncatedText } from "@/components/TruncatedText"; +import { useDirection } from "@/hooks/useDirection"; import { PeerFilters, type StatusFilter } from "./PeerFilters"; const isOnline = (connStatus: string) => connStatus === "Connected"; @@ -42,6 +43,7 @@ export const peerStatusLabelKey = (connStatus: string): string => { }; export const Peers = () => { + const dir = useDirection(); const { t } = useTranslation(); const { status } = useStatus(); const [search, setSearch] = useState(""); @@ -135,7 +137,11 @@ export const Peers = () => { {filtered.length === 0 ? ( ) : ( - + {scrollParent && } @@ -167,6 +173,7 @@ type PeersListProps = { const PeersList = ({ data, scrollParent }: PeersListProps) => { const { setSelected } = usePeerDetail(); + const dir = useDirection(); const virtuosoRef = useRef(null); const rowRefs = useRef>(new Map()); @@ -200,7 +207,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => { e.preventDefault(); focusRow(Math.max(index - 1, 0)); break; - case "ArrowRight": + case dir === "rtl" ? "ArrowLeft" : "ArrowRight": e.preventDefault(); setSelected(data[index]); break; @@ -223,7 +230,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => { const ctx = useMemo( () => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }), // eslint-disable-next-line react-hooks/exhaustive-deps - [data, setSelected], + [data, setSelected, dir], ); return ( @@ -273,7 +280,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) return (
diff --git a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx index 19313ccb3..487080f3d 100644 --- a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx @@ -175,7 +175,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }:
-
+
@@ -200,7 +200,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }: {!managedManagementUrl && (
-
+
@@ -217,6 +217,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }: {mode === ManagementMode.SelfHosted && ( void; @@ -19,6 +20,7 @@ type ProfileDropdownProps = { const MANAGE_VALUE = "__manage_profiles__"; export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { + const dir = useDirection(); const { t } = useTranslation(); const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile(); const [open, setOpen] = useState(false); @@ -116,6 +118,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { {sortedProfiles.length > 0 && ( <> @@ -252,7 +255,7 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => { value={profile.id} onSelect={() => onSelect(profile.id)} className={cn( - "flex w-auto gap-2 px-2 py-2 pr-3 last:mb-1", + "flex w-auto gap-2 px-2 py-2 pe-3 last:mb-1", "cursor-default rounded-md text-sm outline-none", "data-[selected=true]:bg-nb-gray-900", showEmail ? "items-start" : "items-center", diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx index f2110fda0..509f48061 100644 --- a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx @@ -440,7 +440,7 @@ const ProfileRow = ({ {showEmail && }
- + {t("settings.about.clientName")}{" "} - + {t("settings.about.development")} @@ -124,7 +124,7 @@ export function SettingsAbout() { {guiVersion === "development" ? ( {t("settings.about.guiName")}{" "} - + {t("settings.about.development")} diff --git a/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx index 37b1932d9..6cb54d124 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx @@ -102,6 +102,7 @@ export function SettingsAdvanced() { setValues((v) => ({ ...v, interfaceName: e.target.value }))} @@ -151,6 +152,7 @@ export function SettingsAdvanced() { {t("settings.advanced.psk.help")} setUrl(e.target.value)} diff --git a/client/ui/frontend/src/modules/settings/SettingsPage.tsx b/client/ui/frontend/src/modules/settings/SettingsPage.tsx index bf0db3bec..9fb6e2129 100644 --- a/client/ui/frontend/src/modules/settings/SettingsPage.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsPage.tsx @@ -17,6 +17,7 @@ import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx"; import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx"; import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx"; import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; +import { useDirection } from "@/hooks/useDirection"; const EVENT_SETTINGS_OPEN = "netbird:settings:open"; @@ -43,6 +44,7 @@ const TAB_CONTENT: Record = { }; export const SettingsPage = () => { + const dir = useDirection(); const location = useLocation(); const navState = location.state as { tab?: string } | null; const { mdm, features } = useRestrictions(); @@ -94,6 +96,7 @@ export const SettingsPage = () => { ( <>
-
+
- {showKey && } + {showKey && } {result.path && !showKey && (
- + {t("welcome.management.title")} - + {t("welcome.management.description")}
@@ -103,9 +103,10 @@ export function WelcomeStepManagement({
{mode === ManagementMode.SelfHosted && ( -
+
setUrl(e.target.value)} diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx index 5a8b0d015..8838e5c92 100644 --- a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx @@ -38,10 +38,10 @@ export function WelcomeStepTray({ onContinue }: Readonly)
- + {t(titleKey)} - {t(descriptionKey)} + {t(descriptionKey)}
From a816acd97c416ff2f6b263a2a1a7bd47b2affc11 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:53:16 +0200 Subject: [PATCH 110/209] [management] fix the concurrency handling within the update message tests (#8000) --- management/server/account_test.go | 87 +- management/server/affected_peers_jwt_test.go | 154 +- .../server/affected_peers_oldstate_test.go | 198 +-- .../affected_peers_router_paths_test.go | 264 ++-- .../server/affected_peers_router_test.go | 508 +++---- management/server/affected_peers_test.go | 1320 +++++++++-------- management/server/affected_peers_user_test.go | 238 ++- management/server/cache/idp.go | 12 +- management/server/dns_test.go | 284 ++-- management/server/group_test.go | 635 ++++---- management/server/nameserver_test.go | 260 ++-- management/server/peer_test.go | 696 +++++---- management/server/policy_test.go | 552 +++---- management/server/posture_checks_test.go | 586 ++++---- management/server/route_test.go | 496 ++++--- management/server/setupkey_test.go | 128 +- management/server/user_test.go | 266 ++-- 17 files changed, 3417 insertions(+), 3267 deletions(-) diff --git a/management/server/account_test.go b/management/server/account_test.go index 881ad19d7..6067b6023 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -13,6 +13,7 @@ import ( "strings" "sync" "testing" + "testing/synctest" "time" "github.com/prometheus/client_golang/prometheus/push" @@ -40,6 +41,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/zones" networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory" + networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite" "github.com/netbirdio/netbird/management/internals/server/config" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" nbAccount "github.com/netbirdio/netbird/management/server/account" @@ -1297,7 +1299,9 @@ func TestAccountManager_AddPeerWithUserID(t *testing.T) { } func TestAccountManager_NetworkUpdates_SaveGroup(t *testing.T) { - testAccountManager_NetworkUpdates_SaveGroup(t) + runPeerUpdateTest(t, func(t *testing.T) { + testAccountManager_NetworkUpdates_SaveGroup(t) + }) } func testAccountManager_NetworkUpdates_SaveGroup(t *testing.T) { @@ -1330,6 +1334,8 @@ func testAccountManager_NetworkUpdates_SaveGroup(t *testing.T) { updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) defer updateManager.CloseChannel(context.Background(), peer1.ID) + settleAffectedUpdates(updMsg) + wg := sync.WaitGroup{} wg.Add(1) go func() { @@ -1352,7 +1358,9 @@ func testAccountManager_NetworkUpdates_SaveGroup(t *testing.T) { } func TestAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) { - testAccountManager_NetworkUpdates_DeletePolicy(t) + runPeerUpdateTest(t, func(t *testing.T) { + testAccountManager_NetworkUpdates_DeletePolicy(t) + }) } func testAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) { @@ -1361,13 +1369,7 @@ func testAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) { updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) defer updateManager.CloseChannel(context.Background(), peer1.ID) - // Ensure that we do not receive an update message before the policy is deleted - time.Sleep(time.Second) - select { - case <-updMsg: - t.Logf("received addPeer update message before policy deletion") - default: - } + settleAffectedUpdates(updMsg) wg := sync.WaitGroup{} wg.Add(1) @@ -1390,7 +1392,9 @@ func testAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) { } func TestAccountManager_NetworkUpdates_SavePolicy(t *testing.T) { - testAccountManager_NetworkUpdates_SavePolicy(t) + runPeerUpdateTest(t, func(t *testing.T) { + testAccountManager_NetworkUpdates_SavePolicy(t) + }) } func testAccountManager_NetworkUpdates_SavePolicy(t *testing.T) { @@ -1410,6 +1414,8 @@ func testAccountManager_NetworkUpdates_SavePolicy(t *testing.T) { updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) defer updateManager.CloseChannel(context.Background(), peer1.ID) + settleAffectedUpdates(updMsg) + wg := sync.WaitGroup{} wg.Add(1) go func() { @@ -1443,7 +1449,9 @@ func testAccountManager_NetworkUpdates_SavePolicy(t *testing.T) { } func TestAccountManager_NetworkUpdates_DeletePeer(t *testing.T) { - testAccountManager_NetworkUpdates_DeletePeer(t) + runPeerUpdateTest(t, func(t *testing.T) { + testAccountManager_NetworkUpdates_DeletePeer(t) + }) } func testAccountManager_NetworkUpdates_DeletePeer(t *testing.T) { @@ -1482,6 +1490,8 @@ func testAccountManager_NetworkUpdates_DeletePeer(t *testing.T) { updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) defer updateManager.CloseChannel(context.Background(), peer1.ID) + settleAffectedUpdates(updMsg) + wg := sync.WaitGroup{} wg.Add(1) go func() { @@ -1503,7 +1513,9 @@ func testAccountManager_NetworkUpdates_DeletePeer(t *testing.T) { } func TestAccountManager_NetworkUpdates_DeleteGroup(t *testing.T) { - testAccountManager_NetworkUpdates_DeleteGroup(t) + runPeerUpdateTest(t, func(t *testing.T) { + testAccountManager_NetworkUpdates_DeleteGroup(t) + }) } func testAccountManager_NetworkUpdates_DeleteGroup(t *testing.T) { @@ -1550,6 +1562,8 @@ func testAccountManager_NetworkUpdates_DeleteGroup(t *testing.T) { } } + settleAffectedUpdates(updMsg) + wg := sync.WaitGroup{} wg.Add(1) go func() { @@ -3582,6 +3596,9 @@ func createManagerWithNetworkMapStore(t testing.TB) (*DefaultAccountManager, *up nmdataStore, err := networkmapdbfactory.NewNetworkMapDBStore(context.Background(), types.SqliteStoreEngine, dataDir, MockIntegratedValidator{}, newSettingsMockManager(t)) require.NoError(t, err) + sqliteStore, ok := nmdataStore.Store.(*networkmap_sqlite.SqliteStore) + require.True(t, ok, "network map store is %T, want *networkmap_sqlite.SqliteStore", nmdataStore.Store) + t.Cleanup(func() { assert.NoError(t, sqliteStore.Db.Close()) }) manager, updateManager, err := buildTestManager(t, store, nmdataStore) require.NoError(t, err) @@ -3636,7 +3653,8 @@ func buildTestManager(t testing.TB, store store.Store, nmdataStore *networkmapdb Return(nil). AnyTimes() - cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + // The go-cache janitor only stops via a GC finalizer and would outlive synctest bubbles. + cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 0, 100) if err != nil { return nil, nil, err } @@ -3648,8 +3666,11 @@ func buildTestManager(t testing.TB, store store.Store, nmdataStore *networkmapdb if err != nil { return nil, nil, err } + cacheManager := manager.cacheManager + t.Cleanup(func() { assert.NoError(t, cacheManager.Close()) }) proxyGrpcServer := nbgrpc.NewProxyServiceServer(nil, nil, nil, nbgrpc.ProxyOIDCConfig{}, peersManager, nil, nil, proxyManager, nil) + t.Cleanup(proxyGrpcServer.Close) proxyController, err := proxymanager.NewGRPCController(proxyGrpcServer, noop.Meter{}) if err != nil { return nil, nil, err @@ -3743,6 +3764,33 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel. // when the channel delivers. const peerUpdateTimeout = 5 * time.Second +// peerUpdateSettleTime bounds how far settleAffectedUpdates advances the fake clock. It must exceed +// the account request and peer update buffer intervals. +const peerUpdateSettleTime = time.Second + +// runPeerUpdateTest runs f inside synctest.Test, so the peer update helpers observe every background +// goroutine of the test, and lets the updates still in flight when f returns finish before the bubble +// ends, since the bubble's clock stops with it. +func runPeerUpdateTest(t *testing.T, f func(t *testing.T)) { + synctest.Test(t, func(t *testing.T) { + defer settleAffectedUpdates() + f(t) + }) +} + +// settleAffectedUpdates runs the synctest bubble's fake clock past every update buffer interval until +// all goroutines are blocked, then discards the updates already delivered to chans, so the next +// assertion only observes updates from the action under test. It must be called inside synctest.Test. +func settleAffectedUpdates(chans ...<-chan *network_map.UpdateMessage) { + time.Sleep(peerUpdateSettleTime) + synctest.Wait() + for _, ch := range chans { + for len(ch) > 0 { + <-ch + } + } +} + func drainPeerUpdates(ch <-chan *network_map.UpdateMessage) { for { select { @@ -3756,6 +3804,19 @@ func drainPeerUpdates(ch <-chan *network_map.UpdateMessage) { } } +// step runs f as one named stage of a test inside synctest.Test, which forbids t.Run, and names the +// stage when it fails the test. Stages share t, so a fatal failure ends the remaining stages as well. +func step(t *testing.T, name string, f func(t *testing.T)) { + t.Helper() + failedBefore := t.Failed() + defer func() { + if !failedBefore && t.Failed() { + t.Logf("step %q failed", name) + } + }() + f(t) +} + func peerShouldNotReceiveUpdate(t *testing.T, updateMessage <-chan *network_map.UpdateMessage) { t.Helper() select { diff --git a/management/server/affected_peers_jwt_test.go b/management/server/affected_peers_jwt_test.go index 766745cd7..0b0eba1c4 100644 --- a/management/server/affected_peers_jwt_test.go +++ b/management/server/affected_peers_jwt_test.go @@ -87,93 +87,93 @@ func TestAffectedPeers_AllowedUsersChange_RefreshesSSHDestinations(t *testing.T) // auto-group change updates only the user's peers and the peers linked to the changed // group through policies, instead of fanning out to the whole account. func TestAffectedPeers_SyncUserJWTGroups_OnlyAffectedPeersUpdated(t *testing.T) { - manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) - userPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", userID, &nbpeer.Peer{ - Key: key.PublicKey().String(), - Meta: nbpeer.PeerSystemMeta{Hostname: "user-peer"}, - }, false) - require.NoError(t, err) + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + userPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", userID, &nbpeer.Peer{ + Key: key.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "user-peer"}, + }, false) + require.NoError(t, err) - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) - } + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) + } - account, err = manager.Store.GetAccount(ctx, accountID) - require.NoError(t, err) - account.Settings.JWTGroupsEnabled = true - account.Settings.JWTGroupsClaimName = "groups" - account.Settings.GroupsPropagationEnabled = true - require.NoError(t, manager.Store.SaveAccount(ctx, account)) + account, err = manager.Store.GetAccount(ctx, accountID) + require.NoError(t, err) + account.Settings.JWTGroupsEnabled = true + account.Settings.JWTGroupsClaimName = "groups" + account.Settings.GroupsPropagationEnabled = true + require.NoError(t, manager.Store.SaveAccount(ctx, account)) - require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-grp", Name: "jwt-linked", Issued: types.GroupIssuedJWT, Peers: []string{}})) - require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-dest", Name: "jwt-dest", Peers: []string{peer2.ID}})) + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-grp", Name: "jwt-linked", Issued: types.GroupIssuedJWT, Peers: []string{}})) + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "jwt-dest", Name: "jwt-dest", Peers: []string{peer2.ID}})) - _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"jwt-grp"}, - Destinations: []string{"jwt-dest"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"jwt-grp"}, + Destinations: []string{"jwt-dest"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, }, - }, - }, true) - require.NoError(t, err) - - updUser := updateManager.CreateChannel(ctx, userPeer.ID) - upd2 := updateManager.CreateChannel(ctx, peer2.ID) - upd3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, userPeer.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - userAuth := auth.UserAuth{ - AccountId: accountID, - UserId: userID, - Groups: []string{"jwt-linked"}, - } - - t.Run("adding JWT group updates only linked peers", func(t *testing.T) { - drainPeerUpdates(updUser) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) - - require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth)) - - peerShouldReceiveUpdate(t, updUser) - peerShouldReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) - - user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + }, true) require.NoError(t, err) - assert.Contains(t, user.AutoGroups, "jwt-grp") - }) - t.Run("removing JWT group updates only linked peers", func(t *testing.T) { - drainPeerUpdates(updUser) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) + updUser := updateManager.CreateChannel(ctx, userPeer.ID) + upd2 := updateManager.CreateChannel(ctx, peer2.ID) + upd3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, userPeer.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) - userAuth.Groups = nil - require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth)) + userAuth := auth.UserAuth{ + AccountId: accountID, + UserId: userID, + Groups: []string{"jwt-linked"}, + } - peerShouldReceiveUpdate(t, updUser) - peerShouldReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) + step(t, "adding JWT group updates only linked peers", func(t *testing.T) { + settleAffectedUpdates(updUser, upd2, upd3) - user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) - require.NoError(t, err) - assert.NotContains(t, user.AutoGroups, "jwt-grp") + require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth)) + + peerShouldReceiveUpdate(t, updUser) + peerShouldReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) + + user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + require.NoError(t, err) + assert.Contains(t, user.AutoGroups, "jwt-grp") + }) + + step(t, "removing JWT group updates only linked peers", func(t *testing.T) { + drainPeerUpdates(updUser) + drainPeerUpdates(upd2) + drainPeerUpdates(upd3) + + userAuth.Groups = nil + require.NoError(t, manager.SyncUserJWTGroups(ctx, userAuth)) + + peerShouldReceiveUpdate(t, updUser) + peerShouldReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) + + user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + require.NoError(t, err) + assert.NotContains(t, user.AutoGroups, "jwt-grp") + }) }) } diff --git a/management/server/affected_peers_oldstate_test.go b/management/server/affected_peers_oldstate_test.go index bcb78a660..bfc0a51b9 100644 --- a/management/server/affected_peers_oldstate_test.go +++ b/management/server/affected_peers_oldstate_test.go @@ -24,120 +24,124 @@ import ( // detaching one of them must still refresh that group's policy source peers, even // though the post-update resource no longer maps to it. func TestAffectedPeers_E2E_UpdateResource_DetachGroup_RefreshesOldGroupSources(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - // A second resource group + a second source group/peer that reaches the - // resource only through that second group. - const detachGroupID = "rs-detach-grp" - require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ID: detachGroupID, Name: "rs-detach"})) + // A second resource group + a second source group/peer that reaches the + // resource only through that second group. + const detachGroupID = "rs-detach-grp" + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ID: detachGroupID, Name: "rs-detach"})) - const secondSourceGroupID = "rs-source-grp-2" - setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-detach-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) - require.NoError(t, err) - secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) - require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ - ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID}, - })) + const secondSourceGroupID = "rs-source-grp-2" + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-detach-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ + ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID}, + })) - resourcesManager, _, _ := s.managers() + resourcesManager, _, _ := s.managers() - // Attach the resource to the detach group as well: now in [resourceGroup, detachGroup]. - _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ - ID: s.resourceID, - AccountID: s.accountID, - NetworkID: s.networkID, - Name: "rs-resource-host", - Address: "10.20.30.0/24", - GroupIDs: []string{s.resourceGroupID, detachGroupID}, - Enabled: true, + // Attach the resource to the detach group as well: now in [resourceGroup, detachGroup]. + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{s.resourceGroupID, detachGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + // Policy granting the second source group access via the detach group. + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(secondSourceGroupID, detachGroupID), true) + require.NoError(t, err) + + secondSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID) + t.Cleanup(func() { s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) }) + settleAffectedUpdates(secondSrcCh) + + done := make(chan struct{}) + go func() { + // Detaching the resource from detachGroup removes the second source's + // access; that source peer must be refreshed even though the post-update + // resource no longer maps to detachGroup. + peerShouldReceiveUpdate(t, secondSrcCh) + close(done) + }() + + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{s.resourceGroupID}, // detached detachGroup + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: detaching a resource group did not refresh the old group's policy source peer") + } }) - require.NoError(t, err) - - // Policy granting the second source group access via the detach group. - _, err = s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(secondSourceGroupID, detachGroupID), true) - require.NoError(t, err) - - secondSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID) - t.Cleanup(func() { s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) }) - settleAffectedUpdates(secondSrcCh) - - done := make(chan struct{}) - go func() { - // Detaching the resource from detachGroup removes the second source's - // access; that source peer must be refreshed even though the post-update - // resource no longer maps to detachGroup. - peerShouldReceiveUpdate(t, secondSrcCh) - close(done) - }() - - _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ - ID: s.resourceID, - AccountID: s.accountID, - NetworkID: s.networkID, - Name: "rs-resource-host", - Address: "10.20.30.0/24", - GroupIDs: []string{s.resourceGroupID}, // detached detachGroup - Enabled: true, - }) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: detaching a resource group did not refresh the old group's policy source peer") - } } // TestAffectedPeers_E2E_UpdateRouter_RepointPeer_RefreshesOldRoutingPeer: // changing router.Peer within the same network must still refresh the OLD routing // peer, which loses its routing role. func TestAffectedPeers_E2E_UpdateRouter_RepointPeer_RefreshesOldRoutingPeer(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - _, routersManager, _ := s.managers() + _, routersManager, _ := s.managers() - routers, err := s.manager.Store.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, s.accountID, s.networkID) - require.NoError(t, err) - require.Len(t, routers, 1) - router := routers[0] - oldRoutingPeer := router.Peer - require.NotEmpty(t, oldRoutingPeer) + routers, err := s.manager.Store.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, s.accountID, s.networkID) + require.NoError(t, err) + require.Len(t, routers, 1) + router := routers[0] + oldRoutingPeer := router.Peer + require.NotEmpty(t, oldRoutingPeer) - // A new peer to become the routing peer in place of the old one. - setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-newrouter-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) - require.NoError(t, err) - newRoutingPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + // A new peer to become the routing peer in place of the old one. + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-newrouter-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + newRoutingPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) - oldCh := s.updateManager.CreateChannel(ctx, oldRoutingPeer) - t.Cleanup(func() { s.updateManager.CloseChannel(ctx, oldRoutingPeer) }) - settleAffectedUpdates(oldCh) + oldCh := s.updateManager.CreateChannel(ctx, oldRoutingPeer) + t.Cleanup(func() { s.updateManager.CloseChannel(ctx, oldRoutingPeer) }) + settleAffectedUpdates(oldCh) - done := make(chan struct{}) - go func() { - // The old routing peer stops serving the resource and must be refreshed. - peerShouldReceiveUpdate(t, oldCh) - close(done) - }() + done := make(chan struct{}) + go func() { + // The old routing peer stops serving the resource and must be refreshed. + peerShouldReceiveUpdate(t, oldCh) + close(done) + }() - _, err = routersManager.UpdateRouter(ctx, userID, &routerTypes.NetworkRouter{ - ID: router.ID, - NetworkID: s.networkID, - AccountID: s.accountID, - Peer: newRoutingPeer.ID, // repoint within the same network - Masquerade: true, - Metric: 9999, - Enabled: true, + _, err = routersManager.UpdateRouter(ctx, userID, &routerTypes.NetworkRouter{ + ID: router.ID, + NetworkID: s.networkID, + AccountID: s.accountID, + Peer: newRoutingPeer.ID, // repoint within the same network + Masquerade: true, + Metric: 9999, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: re-pointing the router peer did not refresh the old routing peer") + } }) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: re-pointing the router peer did not refresh the old routing peer") - } } diff --git a/management/server/affected_peers_router_paths_test.go b/management/server/affected_peers_router_paths_test.go index d5868a5c1..185c03ddb 100644 --- a/management/server/affected_peers_router_paths_test.go +++ b/management/server/affected_peers_router_paths_test.go @@ -165,148 +165,154 @@ func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context } func TestAffectedPeers_E2E_SavePostureCheck_RefreshesRoutingPeer(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - checkID := s.createPostureCheckGatedPolicy(t, ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)) + checkID := s.createPostureCheckGatedPolicy(t, ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)) - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) - s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh, unrelatedCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + peerShouldNotReceiveUpdate(t, unrelatedCh) + close(done) + }() + + _, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{ + ID: checkID, + Name: "rs-min-version", + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.31.0"}, + }, + }, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: editing a posture check did not refresh source + routing peers") + } }) - - settleAffectedUpdates(srcCh, routerCh, unrelatedCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - peerShouldNotReceiveUpdate(t, unrelatedCh) - close(done) - }() - - _, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{ - ID: checkID, - Name: "rs-min-version", - Checks: posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.31.0"}, - }, - }, false) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: editing a posture check did not refresh source + routing peers") - } } func TestAffectedPeers_E2E_UpdateResource_DestinationResourcePolicy_RefreshesSourcePeer(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) - require.NoError(t, err) + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) - resourcesManager, _, _ := s.managers() + resourcesManager, _, _ := s.managers() - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) - s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh, unrelatedCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + peerShouldNotReceiveUpdate(t, unrelatedCh) + close(done) + }() + + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/25", + GroupIDs: []string{s.resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: updating a DestinationResource-targeted resource did not refresh its policy source peer") + } }) - - settleAffectedUpdates(srcCh, routerCh, unrelatedCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - peerShouldNotReceiveUpdate(t, unrelatedCh) - close(done) - }() - - _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ - ID: s.resourceID, - AccountID: s.accountID, - NetworkID: s.networkID, - Name: "rs-resource-host", - Address: "10.20.30.0/25", - GroupIDs: []string{s.resourceGroupID}, - Enabled: true, - }) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: updating a DestinationResource-targeted resource did not refresh its policy source peer") - } } // A disabled sibling router routes to nobody, so updating a resource on its network // must NOT refresh its peer (the enabled router carries the bridge instead). func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouterNotBridged(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - resourcesManager, routersManager, _ := s.managers() + resourcesManager, routersManager, _ := s.managers() - setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-disabled", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) - require.NoError(t, err) - disabledRouterPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) - _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{ - NetworkID: s.networkID, - AccountID: s.accountID, - Peer: disabledRouterPeer.ID, - Masquerade: true, - Metric: 9000, - Enabled: false, + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-disabled", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + disabledRouterPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{ + NetworkID: s.networkID, + AccountID: s.accountID, + Peer: disabledRouterPeer.ID, + Masquerade: true, + Metric: 9000, + Enabled: false, + }) + require.NoError(t, err) + + disabledCh := s.updateManager.CreateChannel(ctx, disabledRouterPeer.ID) + enabledCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(disabledCh, enabledCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, enabledCh) + peerShouldNotReceiveUpdate(t, disabledCh) + close(done) + }() + + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/25", + GroupIDs: []string{s.resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } }) - require.NoError(t, err) - - disabledCh := s.updateManager.CreateChannel(ctx, disabledRouterPeer.ID) - enabledCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) - }) - - settleAffectedUpdates(disabledCh, enabledCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, enabledCh) - peerShouldNotReceiveUpdate(t, disabledCh) - close(done) - }() - - _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ - ID: s.resourceID, - AccountID: s.accountID, - NetworkID: s.networkID, - Name: "rs-resource-host", - Address: "10.20.30.0/25", - GroupIDs: []string{s.resourceGroupID}, - Enabled: true, - }) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") - } } func TestAffectedPeers_GroupChange_RouterInOtherNetworkNotAffected(t *testing.T) { @@ -346,8 +352,10 @@ func TestAffectedPeers_PeerChange_RouterInOtherNetworkNotAffected(t *testing.T) // shortcut (the denied peer's map holds no router) and the allow direction // depends on which meta field moved, leaving the routers with a stale map. func TestAffectedPeers_E2E_PostureFlip_RefreshesRoutingPeer(t *testing.T) { - runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy { - return peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + runPeerUpdateTest(t, func(t *testing.T) { + runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy { + return peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + }) }) } @@ -355,8 +363,10 @@ func TestAffectedPeers_E2E_PostureFlip_RefreshesRoutingPeer(t *testing.T) { // scenario with the source peer named directly in the rule: it must receive its posture // checks and have its flips detected exactly like a group member. func TestAffectedPeers_E2E_PostureFlip_DirectSourcePeer_RefreshesRoutingPeer(t *testing.T) { - runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy { - return peerToResourcePolicyByPeer(s.sourcePeerID, s.resourceGroupID) + runPeerUpdateTest(t, func(t *testing.T) { + runPostureFlipRefreshesRoutingPeer(t, func(s *routerScenario) *types.Policy { + return peerToResourcePolicyByPeer(s.sourcePeerID, s.resourceGroupID) + }) }) } diff --git a/management/server/affected_peers_router_test.go b/management/server/affected_peers_router_test.go index 7e3f02b27..e2bdff4ad 100644 --- a/management/server/affected_peers_router_test.go +++ b/management/server/affected_peers_router_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/netbirdio/netbird/management/internals/controllers/network_map" "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/groups" @@ -320,190 +319,189 @@ func TestAffectedPeers_PolicyToResource_UnrelatedPeerNotAffected(t *testing.T) { } func TestAffectedPeers_ResourceSideBridgesToRoutingPeer_DirectRouter(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - // A pre-existing policy grants the source group access to the resource. - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + // A pre-existing policy grants the source group access to the resource. + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - // Drive an update through the resource manager and assert the routing peer - // is among the affected set by observing the channel. This path walks - // policies whose destinations reference the resource's groups, folds in the - // source groups, and loads the network's routers, so it reaches both the - // source peer and the routing peer. - permissionsManager := permissions.NewManager(s.manager.Store) - groupsManager := groups.NewManager(s.manager.Store, permissionsManager, s.manager) - rm := resources.NewManager(s.manager.Store, permissionsManager, groupsManager, s.manager, s.manager.serviceManager) + // Drive an update through the resource manager and assert the routing peer + // is among the affected set by observing the channel. This path walks + // policies whose destinations reference the resource's groups, folds in the + // source groups, and loads the network's routers, so it reaches both the + // source peer and the routing peer. + permissionsManager := permissions.NewManager(s.manager.Store) + groupsManager := groups.NewManager(s.manager.Store, permissionsManager, s.manager) + rm := resources.NewManager(s.manager.Store, permissionsManager, groupsManager, s.manager, s.manager.serviceManager) - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err = rm.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{s.resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: resource update did not refresh source peer + routing peer") + } }) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - _, err = rm.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ - ID: s.resourceID, - AccountID: s.accountID, - NetworkID: s.networkID, - Name: "rs-resource-host", - Address: "10.20.30.0/24", - GroupIDs: []string{s.resourceGroupID}, - Enabled: true, - }) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: resource update did not refresh source peer + routing peer") - } -} - -// settleAffectedUpdates waits for in-flight async updates to arrive, then drains -// every given channel so subsequent assertions start from a clean slate. -// -// Setup (CreateNetwork/CreateResource/CreateRouter) fires async UpdateAffectedPeers -// goroutines; draining first means the assertion only observes updates from the -// action under test, not setup stragglers. -func settleAffectedUpdates(chans ...<-chan *network_map.UpdateMessage) { - time.Sleep(300 * time.Millisecond) - for _, ch := range chans { - drainPeerUpdates(ch) - } } func TestAffectedPeers_E2E_CreatePolicy_RoutingPeer_DirectRouter(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) - s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh, unrelatedCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + peerShouldNotReceiveUpdate(t, unrelatedCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: creating peer->resource policy did not refresh the routing peer") + } }) - - settleAffectedUpdates(srcCh, routerCh, unrelatedCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - peerShouldNotReceiveUpdate(t, unrelatedCh) - close(done) - }() - - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: creating peer->resource policy did not refresh the routing peer") - } } func TestAffectedPeers_E2E_CreatePolicy_RoutingPeer_RouterPeerGroups(t *testing.T) { - s := setupRouterScenario(t, false) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerGroupPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerGroupPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: routing peer (PeerGroups) not refreshed on policy create") + } }) - - settleAffectedUpdates(srcCh, routerCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: routing peer (PeerGroups) not refreshed on policy create") - } } func TestAffectedPeers_E2E_DestResource_RoutingPeer(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: routing peer not refreshed when policy targets DestinationResource") + } }) - - settleAffectedUpdates(srcCh, routerCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: routing peer not refreshed when policy targets DestinationResource") - } } func TestAffectedPeers_E2E_DeletePolicy_RoutingPeer(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + require.NoError(t, s.manager.DeletePolicy(ctx, s.accountID, policy.ID, userID)) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: deleting peer->resource policy did not refresh the routing peer") + } }) - - settleAffectedUpdates(srcCh, routerCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - require.NoError(t, s.manager.DeletePolicy(ctx, s.accountID, policy.ID, userID)) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: deleting peer->resource policy did not refresh the routing peer") - } } func (s *routerScenario) managers() (resources.Manager, routers.Manager, networks.Manager) { @@ -572,113 +570,119 @@ func (s *routerScenario) addSecondTopology(t *testing.T, suffix string) secondTo } func TestAffectedPeers_E2E_UpdatePolicy_BothRoutingPeers(t *testing.T) { - s := setupRouterScenario(t, true) - second := s.addSecondTopology(t, "b") - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + second := s.addSecondTopology(t, "b") + ctx := context.Background() - policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerACh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - routerBCh := s.updateManager.CreateChannel(ctx, second.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) - s.updateManager.CloseChannel(ctx, second.routerPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerACh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + routerBCh := s.updateManager.CreateChannel(ctx, second.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, second.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerACh, routerBCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerACh) + peerShouldReceiveUpdate(t, routerBCh) + close(done) + }() + + policy.Rules[0].Destinations = []string{second.resourceGroupID} + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: re-pointing the policy destination did not refresh both routing peers") + } }) - - settleAffectedUpdates(srcCh, routerACh, routerBCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerACh) - peerShouldReceiveUpdate(t, routerBCh) - close(done) - }() - - policy.Rules[0].Destinations = []string{second.resourceGroupID} - _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: re-pointing the policy destination did not refresh both routing peers") - } } func TestAffectedPeers_E2E_UpdatePolicy_AddSource(t *testing.T) { - s := setupRouterScenario(t, true) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() - const secondSourceGroupID = "rs-source-grp-2" - setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-2", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) - require.NoError(t, err) - secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) - require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ - ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID}, - })) + const secondSourceGroupID = "rs-source-grp-2" + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-2", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ + ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID}, + })) - policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) - require.NoError(t, err) + policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) - newSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) - s.updateManager.CloseChannel(ctx, s.routerPeerID) + newSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(newSrcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, newSrcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + policy.Rules[0].Sources = []string{s.sourceGroupID, secondSourceGroupID} + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: adding a source group did not refresh the new source peer + routing peer") + } }) - - settleAffectedUpdates(newSrcCh, routerCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, newSrcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - policy.Rules[0].Sources = []string{s.sourceGroupID, secondSourceGroupID} - _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: adding a source group did not refresh the new source peer + routing peer") - } } func TestAffectedPeers_E2E_DestResource_RouterPeerGroups(t *testing.T) { - s := setupRouterScenario(t, false) - ctx := context.Background() + runPeerUpdateTest(t, func(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() - srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) - routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID) - t.Cleanup(func() { - s.updateManager.CloseChannel(ctx, s.sourcePeerID) - s.updateManager.CloseChannel(ctx, s.routerGroupPeerID) + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerGroupPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: DestinationResource policy with PeerGroups router did not refresh the routing peer") + } }) - - settleAffectedUpdates(srcCh, routerCh) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, srcCh) - peerShouldReceiveUpdate(t, routerCh) - close(done) - }() - - _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout: DestinationResource policy with PeerGroups router did not refresh the routing peer") - } } func TestAffectedPeers_AllRoutingPeers_Network(t *testing.T) { diff --git a/management/server/affected_peers_test.go b/management/server/affected_peers_test.go index 235128693..90408f70a 100644 --- a/management/server/affected_peers_test.go +++ b/management/server/affected_peers_test.go @@ -955,73 +955,77 @@ func TestAffectedPeers_IsolatedRouteAndPolicy(t *testing.T) { } func TestAffectedPeers_GroupUpdateOnlyAffectsLinkedPeers(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "ap-grpA", Name: "AP-A", Peers: []string{peer1.ID}}, - {ID: "ap-grpB", Name: "AP-B", Peers: []string{peer2.ID}}, - {ID: "ap-grpC", Name: "AP-C", Peers: []string{peer3.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"ap-grpA"}, - Destinations: []string{"ap-grpB"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - }, true) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - result := manager.resolveAffectedPeersForPeerChanges(ctx, manager.Store, accountID, []string{peer1.ID}) - assert.ElementsMatch(t, []string{peer1.ID, peer2.ID}, result) - - t.Run("group change updates all peers in policy groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ - ID: "ap-grpA", - Name: "AP-A", - Peers: []string{peer1.ID, peer3.ID}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "ap-grpA", Name: "AP-A", Peers: []string{peer1.ID}}, + {ID: "ap-grpB", Name: "AP-B", Peers: []string{peer2.ID}}, + {ID: "ap-grpC", Name: "AP-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"ap-grpA"}, + Destinations: []string{"ap-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, manager.Store, accountID, []string{peer1.ID}) + assert.ElementsMatch(t, []string{peer1.ID, peer2.ID}, result) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "group change updates all peers in policy groups", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "ap-grpA", + Name: "AP-A", + Peers: []string{peer1.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } @@ -1037,243 +1041,259 @@ func TestAffectedPeers_UnlinkedPeerChange_RefreshesSelfOnly(t *testing.T) { // TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate verifies that creating/deleting a // policy only sends updates to peers in the policy's groups, not to unrelated peers. func TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "pol-grpA", Name: "Pol-A", Peers: []string{peer1.ID}}, - {ID: "pol-grpB", Name: "Pol-B", Peers: []string{peer2.ID}}, - {ID: "pol-grpC", Name: "Pol-C", Peers: []string{peer3.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("create policy only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"pol-grpA"}, - Destinations: []string{"pol-grpB"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - }, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "pol-grpA", Name: "Pol-A", Peers: []string{peer1.ID}}, + {ID: "pol-grpB", Name: "Pol-B", Peers: []string{peer2.ID}}, + {ID: "pol-grpC", Name: "Pol-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "create policy only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"pol-grpA"}, + Destinations: []string{"pol-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_RouteChange_UnrelatedPeerNoUpdate verifies that creating a route // only sends updates to peers in the route's groups, not to unrelated peers. func TestAffectedPeers_RouteChange_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "rt-grpA", Name: "Rt-A", Peers: []string{peer1.ID}}, - {ID: "rt-grpB", Name: "Rt-B", Peers: []string{peer2.ID}}, - {ID: "rt-grpC", Name: "Rt-C", Peers: []string{peer3.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("create route only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - _, err := manager.CreateRoute(ctx, accountID, - netip.MustParsePrefix("10.10.0.0/24"), - route.IPv4Network, - nil, - "", - []string{"rt-grpA"}, - "test route", - "routenoaffect", - false, - 9999, - []string{"rt-grpB"}, - nil, - true, - userID, - false, - false, - ) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "rt-grpA", Name: "Rt-A", Peers: []string{peer1.ID}}, + {ID: "rt-grpB", Name: "Rt-B", Peers: []string{peer2.ID}}, + {ID: "rt-grpC", Name: "Rt-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "create route only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + _, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.10.0.0/24"), + route.IPv4Network, + nil, + "", + []string{"rt-grpA"}, + "test route", + "routenoaffect", + false, + 9999, + []string{"rt-grpB"}, + nil, + true, + userID, + false, + false, + ) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_NameServerChange_UnrelatedPeerNoUpdate verifies that creating a // nameserver group only sends updates to peers in its groups, not to unrelated peers. func TestAffectedPeers_NameServerChange_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "ns-grpA", Name: "NS-A", Peers: []string{peer1.ID}}, - {ID: "ns-grpB", Name: "NS-B", Peers: []string{peer2.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("create nameserver group only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldNotReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-unrelated", "NS Unrelated", - []nbdns.NameServer{{ - IP: netip.MustParseAddr("1.1.1.1"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"ns-grpA"}, - true, nil, true, userID, false, - ) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "ns-grpA", Name: "NS-A", Peers: []string{peer1.ID}}, + {ID: "ns-grpB", Name: "NS-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "create nameserver group only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-unrelated", "NS Unrelated", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"ns-grpA"}, + true, nil, true, userID, false, + ) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate verifies that changing DNS // settings only sends updates to peers in the affected groups, not to unrelated peers. func TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "dns-grpA", Name: "DNS-A", Peers: []string{peer1.ID}}, - {ID: "dns-grpB", Name: "DNS-B", Peers: []string{peer2.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("dns settings change only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldNotReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{"dns-grpA"}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "dns-grpA", Name: "DNS-A", Peers: []string{peer1.ID}}, + {ID: "dns-grpB", Name: "DNS-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "dns settings change only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"dns-grpA"}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } @@ -1281,429 +1301,451 @@ func TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate(t *testing.T) { // updating a group that is NOT referenced by any policy/route/ns/dns should not send // updates to any peer. func TestAffectedPeers_UnlinkedGroupChange_NoUpdateIntegration(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } - err = manager.CreateGroup(ctx, accountID, userID, &types.Group{ - ID: "unlinked-grp", - Name: "Unlinked", - Peers: []string{peer1.ID}, - }) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("updating unlinked group sends no peer updates", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg1) - peerShouldNotReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + err = manager.CreateGroup(ctx, accountID, userID, &types.Group{ ID: "unlinked-grp", Name: "Unlinked", - Peers: []string{peer1.ID, peer2.ID}, + Peers: []string{peer1.ID}, }) - assert.NoError(t, err) + require.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") - } + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "updating unlinked group sends no peer updates", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "unlinked-grp", + Name: "Unlinked", + Peers: []string{peer1.ID, peer2.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate: a network router with peer // groups updates only those groups' peers (and resource policy sources), not others. func TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate(t *testing.T) { - // Delete the default policy before adding peers so AddPeer schedules no async - // update that races with the test. - manager, updateManager, err := createManager(t) - require.NoError(t, err) - - ctx := context.Background() - - account, err := createAccount(manager, "nr_test_account", userID, "") - require.NoError(t, err) - accountID := account.Id - - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + runPeerUpdateTest(t, func(t *testing.T) { + // Delete the default policy before adding peers so AddPeer schedules no async + // update that races with the test. + manager, updateManager, err := createManager(t) require.NoError(t, err) - } - setupKey, err := manager.CreateSetupKey(ctx, accountID, "test-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) - require.NoError(t, err) + ctx := context.Background() - peer1 := addPeerToAccount(t, manager, accountID, setupKey.Key) - peer2 := addPeerToAccount(t, manager, accountID, setupKey.Key) - peer3 := addPeerToAccount(t, manager, accountID, setupKey.Key) - - for _, g := range []*types.Group{ - {ID: "nr-grpA", Name: "NR-A", Peers: []string{peer1.ID}}, - {ID: "nr-grpB", Name: "NR-B", Peers: []string{peer2.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) + account, err := createAccount(manager, "nr_test_account", userID, "") require.NoError(t, err) - } + accountID := account.Id - net1 := &networkTypes.Network{ - ID: "nr-net-test", - AccountID: accountID, - Name: "nr-test-network", - } - err = manager.Store.SaveNetwork(ctx, net1) - require.NoError(t, err) - - err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{ - ID: "nr-router-test", - NetworkID: net1.ID, - AccountID: accountID, - PeerGroups: []string{"nr-grpA"}, - Enabled: true, - }) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("network router group change only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldNotReceiveUpdate(t, updMsg2) - peerShouldReceiveUpdate(t, updMsg3) - close(done) - }() - - err = manager.UpdateGroup(ctx, accountID, userID, &types.Group{ - ID: "nr-grpA", - Name: "NR-A", - Peers: []string{peer1.ID, peer3.ID}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + setupKey, err := manager.CreateSetupKey(ctx, accountID, "test-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + + peer1 := addPeerToAccount(t, manager, accountID, setupKey.Key) + peer2 := addPeerToAccount(t, manager, accountID, setupKey.Key) + peer3 := addPeerToAccount(t, manager, accountID, setupKey.Key) + + for _, g := range []*types.Group{ + {ID: "nr-grpA", Name: "NR-A", Peers: []string{peer1.ID}}, + {ID: "nr-grpB", Name: "NR-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + net1 := &networkTypes.Network{ + ID: "nr-net-test", + AccountID: accountID, + Name: "nr-test-network", + } + err = manager.Store.SaveNetwork(ctx, net1) + require.NoError(t, err) + + err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{ + ID: "nr-router-test", + NetworkID: net1.ID, + AccountID: accountID, + PeerGroups: []string{"nr-grpA"}, + Enabled: true, + }) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "network router group change only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldReceiveUpdate(t, updMsg3) + close(done) + }() + + err = manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "nr-grpA", + Name: "NR-A", + Peers: []string{peer1.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_IsolatedEntitiesOnlyAffectTheirPeers: with a policy (peer1<->peer2) // and a separate route (peer3), changing one entity's groups affects only its peers. func TestAffectedPeers_IsolatedEntitiesOnlyAffectTheirPeers(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "iso-grpA", Name: "ISO-A", Peers: []string{peer1.ID}}, - {ID: "iso-grpB", Name: "ISO-B", Peers: []string{peer2.ID}}, - {ID: "iso-grpC", Name: "ISO-C", Peers: []string{peer3.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"iso-grpA"}, - Destinations: []string{"iso-grpB"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - }, true) - require.NoError(t, err) - - _, err = manager.CreateRoute(ctx, accountID, - netip.MustParsePrefix("10.20.0.0/24"), - route.IPv4Network, - nil, - "", - []string{"iso-grpC"}, - "isolated route", - "isonet2", - false, - 9999, - []string{"iso-grpC"}, - nil, - true, - userID, - false, - false, - ) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - // The setup policy/route above dispatch affected-peer updates asynchronously; - // drain any in-flight ones so the assertions only observe the UpdateGroup below. - settleAffectedUpdates(updMsg1, updMsg2, updMsg3) - - t.Run("policy group change does not affect route-only peer", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ - ID: "iso-grpA", - Name: "ISO-A-updated", - Peers: []string{peer1.ID}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "iso-grpA", Name: "ISO-A", Peers: []string{peer1.ID}}, + {ID: "iso-grpB", Name: "ISO-B", Peers: []string{peer2.ID}}, + {ID: "iso-grpC", Name: "ISO-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"iso-grpA"}, + Destinations: []string{"iso-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.20.0.0/24"), + route.IPv4Network, + nil, + "", + []string{"iso-grpC"}, + "isolated route", + "isonet2", + false, + 9999, + []string{"iso-grpC"}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + // The setup policy/route above dispatch affected-peer updates asynchronously; + // drain any in-flight ones so the assertions only observe the UpdateGroup below. + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "policy group change does not affect route-only peer", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "iso-grpA", + Name: "ISO-A-updated", + Peers: []string{peer1.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_DeleteRoute_UnrelatedPeerNoUpdate verifies that deleting a route // only sends updates to peers in the route's groups. func TestAffectedPeers_DeleteRoute_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "del-rt-grpA", Name: "Del-Rt-A", Peers: []string{peer1.ID}}, - {ID: "del-rt-grpB", Name: "Del-Rt-B", Peers: []string{peer2.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - newRoute, err := manager.CreateRoute(ctx, accountID, - netip.MustParsePrefix("10.30.0.0/24"), - route.IPv4Network, - nil, - "", - []string{"del-rt-grpA"}, - "deletable route", - "delnet", - false, - 9999, - []string{"del-rt-grpB"}, - nil, - true, - userID, - false, - false, - ) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("delete route only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.DeleteRoute(ctx, accountID, newRoute.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "del-rt-grpA", Name: "Del-Rt-A", Peers: []string{peer1.ID}}, + {ID: "del-rt-grpB", Name: "Del-Rt-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + newRoute, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.30.0.0/24"), + route.IPv4Network, + nil, + "", + []string{"del-rt-grpA"}, + "deletable route", + "delnet", + false, + 9999, + []string{"del-rt-grpB"}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "delete route only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.DeleteRoute(ctx, accountID, newRoute.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_DeletePolicy_UnrelatedPeerNoUpdate verifies that deleting a policy // only sends updates to peers in the policy's groups. func TestAffectedPeers_DeletePolicy_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - for _, g := range []*types.Group{ - {ID: "del-pol-grpA", Name: "Del-Pol-A", Peers: []string{peer1.ID}}, - {ID: "del-pol-grpB", Name: "Del-Pol-B", Peers: []string{peer2.ID}}, - } { - err := manager.CreateGroup(ctx, accountID, userID, g) - require.NoError(t, err) - } - - policy, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"del-pol-grpA"}, - Destinations: []string{"del-pol-grpB"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - }, true) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("delete policy only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.DeletePolicy(ctx, accountID, policy.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + for _, g := range []*types.Group{ + {ID: "del-pol-grpA", Name: "Del-Pol-A", Peers: []string{peer1.ID}}, + {ID: "del-pol-grpB", Name: "Del-Pol-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + policy, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"del-pol-grpA"}, + Destinations: []string{"del-pol-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "delete policy only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.DeletePolicy(ctx, accountID, policy.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } // TestAffectedPeers_DeleteNameServer_UnrelatedPeerNoUpdate verifies that deleting a // nameserver group only sends updates to peers in its groups. func TestAffectedPeers_DeleteNameServer_UnrelatedPeerNoUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) require.NoError(t, err) - } - - err = manager.CreateGroup(ctx, accountID, userID, &types.Group{ - ID: "del-ns-grpA", - Name: "Del-NS-A", - Peers: []string{peer1.ID}, - }) - require.NoError(t, err) - - nsGroup, err := manager.CreateNameServerGroup(ctx, accountID, "del-ns", "Del NS", - []nbdns.NameServer{{ - IP: netip.MustParseAddr("8.8.4.4"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"del-ns-grpA"}, - true, nil, true, userID, false, - ) - require.NoError(t, err) - - updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) - updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) - updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, peer1.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("delete nameserver group only affects linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - peerShouldNotReceiveUpdate(t, updMsg2) - peerShouldNotReceiveUpdate(t, updMsg3) - close(done) - }() - - err := manager.DeleteNameServerGroup(ctx, accountID, nsGroup.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout") + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) } + + err = manager.CreateGroup(ctx, accountID, userID, &types.Group{ + ID: "del-ns-grpA", + Name: "Del-NS-A", + Peers: []string{peer1.ID}, + }) + require.NoError(t, err) + + nsGroup, err := manager.CreateNameServerGroup(ctx, accountID, "del-ns", "Del NS", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("8.8.4.4"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"del-ns-grpA"}, + true, nil, true, userID, false, + ) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + step(t, "delete nameserver group only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.DeleteNameServerGroup(ctx, accountID, nsGroup.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) }) } diff --git a/management/server/affected_peers_user_test.go b/management/server/affected_peers_user_test.go index 3d73bbed0..3f4658de4 100644 --- a/management/server/affected_peers_user_test.go +++ b/management/server/affected_peers_user_test.go @@ -17,156 +17,152 @@ import ( // A user update refreshes only the peers its auto-group change reaches, and a user // update that changes no group membership refreshes nobody. func TestAffectedPeers_SaveUser_OnlyAffectedPeersUpdated(t *testing.T) { - manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t) - ctx := context.Background() - accountID := account.Id + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, _, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id - const targetUserID = "target-user" - require.NoError(t, manager.Store.SaveUser(ctx, &types.User{ - Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, - })) + const targetUserID = "target-user" + require.NoError(t, manager.Store.SaveUser(ctx, &types.User{ + Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, + })) - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) - targetPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", targetUserID, &nbpeer.Peer{ - Key: key.PublicKey().String(), - Meta: nbpeer.PeerSystemMeta{Hostname: "target-peer"}, - }, false) - require.NoError(t, err) + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + targetPeer, _, _, _, err := manager.AddPeer(ctx, accountID, "", targetUserID, &nbpeer.Peer{ + Key: key.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "target-peer"}, + }, false) + require.NoError(t, err) - policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - require.NoError(t, err) - for _, p := range policies { - require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) - } + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) + } - account, err = manager.Store.GetAccount(ctx, accountID) - require.NoError(t, err) - account.Settings.GroupsPropagationEnabled = true - require.NoError(t, manager.Store.SaveAccount(ctx, account)) + account, err = manager.Store.GetAccount(ctx, accountID) + require.NoError(t, err) + account.Settings.GroupsPropagationEnabled = true + require.NoError(t, manager.Store.SaveAccount(ctx, account)) - require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-linked", Name: "ug-linked"})) - require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-dest", Name: "ug-dest", Peers: []string{peer2.ID}})) + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-linked", Name: "ug-linked"})) + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-dest", Name: "ug-dest", Peers: []string{peer2.ID}})) - _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"ug-linked"}, - Destinations: []string{"ug-dest"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"ug-linked"}, + Destinations: []string{"ug-dest"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, }, - }, - }, true) - require.NoError(t, err) + }, true) + require.NoError(t, err) - updTarget := updateManager.CreateChannel(ctx, targetPeer.ID) - upd2 := updateManager.CreateChannel(ctx, peer2.ID) - upd3 := updateManager.CreateChannel(ctx, peer3.ID) - t.Cleanup(func() { - updateManager.CloseChannel(ctx, targetPeer.ID) - updateManager.CloseChannel(ctx, peer2.ID) - updateManager.CloseChannel(ctx, peer3.ID) - }) - - t.Run("auto group change updates only linked peers", func(t *testing.T) { - drainPeerUpdates(updTarget) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) - - _, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ - Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, - AutoGroups: []string{"ug-linked"}, + updTarget := updateManager.CreateChannel(ctx, targetPeer.ID) + upd2 := updateManager.CreateChannel(ctx, peer2.ID) + upd3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, targetPeer.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) }) - require.NoError(t, err) - peerShouldReceiveUpdate(t, updTarget) - peerShouldReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) - }) + step(t, "auto group change updates only linked peers", func(t *testing.T) { + settleAffectedUpdates(updTarget, upd2, upd3) - t.Run("update without group changes refreshes nobody", func(t *testing.T) { - drainPeerUpdates(updTarget) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) + _, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ + Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, + AutoGroups: []string{"ug-linked"}, + }) + require.NoError(t, err) - _, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ - Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, - AutoGroups: []string{"ug-linked"}, Name: "renamed", + peerShouldReceiveUpdate(t, updTarget) + peerShouldReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) }) - require.NoError(t, err) - peerShouldNotReceiveUpdate(t, updTarget) - peerShouldNotReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) + step(t, "update without group changes refreshes nobody", func(t *testing.T) { + drainPeerUpdates(updTarget) + drainPeerUpdates(upd2) + drainPeerUpdates(upd3) - user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID) - require.NoError(t, err) - assert.Equal(t, "renamed", user.Name) - }) + _, err := manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ + Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, + AutoGroups: []string{"ug-linked"}, Name: "renamed", + }) + require.NoError(t, err) - t.Run("auto group change reassigning IPv6 refreshes the changed peers and their observers", func(t *testing.T) { - require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-v6", Name: "ug-v6"})) - // Apply through the settings API so the reconciliation that strips the other + peerShouldNotReceiveUpdate(t, updTarget) + peerShouldNotReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) + + user, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID) + require.NoError(t, err) + assert.Equal(t, "renamed", user.Name) + }) + + step(t, "auto group change reassigning IPv6 refreshes the changed peers and their observers", func(t *testing.T) { + + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, &types.Group{ID: "ug-v6", Name: "ug-v6"}))// Apply through the settings API so the reconciliation that strips the other // peers' addresses happens here, leaving the target as the only peer the // user update reassigns. updateIPv6TestSettings(t, manager, accountID, func(s *types.Settings) { s.IPv6EnabledGroups = []string{"ug-v6"} }) - drainPeerUpdates(updTarget) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) + settleAffectedUpdates(updTarget, upd2, upd3) - _, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ - Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, - AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed", + _, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ + Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, + AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed", + }) + require.NoError(t, err) + + // The reassigned peer refreshes with everyone it can reach: peer2 via the + // policy, but not peer3, which shares no group or policy with it. + peerShouldReceiveUpdate(t, updTarget) + peerShouldReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) }) - require.NoError(t, err) - // The reassigned peer refreshes with everyone it can reach: peer2 via the - // policy, but not peer3, which shares no group or policy with it. - peerShouldReceiveUpdate(t, updTarget) - peerShouldReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) - }) + step(t, "unblocking a user refreshes only the SSH rule destinations", func(t *testing.T) { + // An SSH rule that authorizes no group of its own ships the account's + // allowed-user set to its destinations, so those are the peers an unblock + // reaches — not the whole account. + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{{ + Enabled: true, + Sources: []string{"ug-linked"}, + Destinations: []string{"ug-dest"}, + Protocol: types.PolicyRuleProtocolNetbirdSSH, + Action: types.PolicyTrafficActionAccept, + }}, + }, true) + require.NoError(t, err) - t.Run("unblocking a user refreshes only the SSH rule destinations", func(t *testing.T) { - // An SSH rule that authorizes no group of its own ships the account's - // allowed-user set to its destinations, so those are the peers an unblock - // reaches — not the whole account. - _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{{ - Enabled: true, - Sources: []string{"ug-linked"}, - Destinations: []string{"ug-dest"}, - Protocol: types.PolicyRuleProtocolNetbirdSSH, - Action: types.PolicyTrafficActionAccept, - }}, - }, true) - require.NoError(t, err) + blocked, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID) + require.NoError(t, err) + blocked.Blocked = true + require.NoError(t, manager.Store.SaveUser(ctx, blocked)) - blocked, err := manager.Store.GetUserByUserID(ctx, store.LockingStrengthNone, targetUserID) - require.NoError(t, err) - blocked.Blocked = true - require.NoError(t, manager.Store.SaveUser(ctx, blocked)) + settleAffectedUpdates(updTarget, upd2, upd3) - drainPeerUpdates(updTarget) - drainPeerUpdates(upd2) - drainPeerUpdates(upd3) + // Same auto-groups as the previous subtest left them, so no group change and + // no IPv6 reconciliation interferes: the unblock alone drives the refresh. + _, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ + Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, + AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed", + }) + require.NoError(t, err) - // Same auto-groups as the previous subtest left them, so no group change and - // no IPv6 reconciliation interferes: the unblock alone drives the refresh. - _, err = manager.SaveUser(ctx, accountID, activity.SystemInitiator, &types.User{ - Id: targetUserID, AccountID: accountID, Role: types.UserRoleUser, - AutoGroups: []string{"ug-linked", "ug-v6"}, Name: "renamed", + peerShouldReceiveUpdate(t, upd2) + peerShouldNotReceiveUpdate(t, upd3) }) - require.NoError(t, err) - - peerShouldReceiveUpdate(t, upd2) - peerShouldNotReceiveUpdate(t, upd3) }) } diff --git a/management/server/cache/idp.go b/management/server/cache/idp.go index 6ec42e217..ec2684d6f 100644 --- a/management/server/cache/idp.go +++ b/management/server/cache/idp.go @@ -88,7 +88,8 @@ func NewUserDataCache(store store.StoreInterface) *UserDataCacheImpl { // AccountUserDataCache wraps the basic Get, Set and Delete methods for []*idp.UserData objects. type AccountUserDataCache struct { - cache Marshaler + cache Marshaler + loadable *cache.LoadableCache[any] } func (a *AccountUserDataCache) Get(ctx context.Context, key string) ([]*idp.UserData, error) { @@ -127,13 +128,18 @@ func (a *AccountUserDataCache) Delete(ctx context.Context, key string) error { return a.cache.Delete(ctx, key) } +// Close stops the goroutine that stores loaded values. The cache must not be used afterwards. +func (a *AccountUserDataCache) Close() error { + return a.loadable.Close() +} + // NewAccountUserDataCache creates a new AccountUserDataCache object. func NewAccountUserDataCache(loadableFunc cache.LoadFunction[any], store store.StoreInterface) *AccountUserDataCache { simpleCache := cache.New[any](store) loadable := cache.NewLoadable[any](loadableFunc, simpleCache) if store.GetType() == redis.RedisType { m := marshaler.New(loadable) - return &AccountUserDataCache{cache: m} + return &AccountUserDataCache{cache: m, loadable: loadable} } - return &AccountUserDataCache{cache: &marshalerWraper{loadable}} + return &AccountUserDataCache{cache: &marshalerWraper{loadable}, loadable: loadable} } diff --git a/management/server/dns_test.go b/management/server/dns_test.go index 25bef664c..d21864cbb 100644 --- a/management/server/dns_test.go +++ b/management/server/dns_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" @@ -363,163 +363,167 @@ func initTestDNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account } func TestDNSAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - err := manager.CreateGroups(context.Background(), account.Id, userID, []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - }) - assert.NoError(t, err) - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // Saving DNS settings with groups that have no peers should not trigger updates to account peers or send peer updates - t.Run("saving dns setting with unused groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{"groupA"}, + err := manager.CreateGroups(context.Background(), account.Id, userID, []*types.Group{ + { + ID: "groupA", + Name: "GroupA", + Peers: []string{}, + }, + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, + }, }) assert.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Creating DNS settings with groups that have no peers should not update account peers or send peer update - t.Run("creating dns setting with unused groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "ns-group", "ns-group", []nbdns.NameServer{{ - IP: netip.MustParseAddr(peer1.IP.String()), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupB"}, - true, []string{}, true, userID, false, - ) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Creating DNS settings with groups that have peers should update account peers and send peer update - t.Run("creating dns setting with used groups", func(t *testing.T) { - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) }) - assert.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Saving DNS settings with groups that have no peers should not trigger updates to account peers or send peer updates + step(t, "saving dns setting with unused groups", func(t *testing.T) { + settleAffectedUpdates(updMsg) - _, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "ns-group-1", "ns-group-1", []nbdns.NameServer{{ - IP: netip.MustParseAddr(peer1.IP.String()), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupA"}, - true, []string{}, true, userID, false, - ) - assert.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"groupA"}, + }) + assert.NoError(t, err) - // Saving DNS settings with groups that have peers should update account peers and send peer update - t.Run("saving dns setting with used groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{"groupA", "groupB"}, + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Creating DNS settings with groups that have no peers should not update account peers or send peer update + step(t, "creating dns setting with unused groups", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - // Removing group with no peers from DNS settings should not trigger updates to account peers or send peer updates - t.Run("removing group with no peers from dns settings", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + _, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "ns-group", "ns-group", []nbdns.NameServer{{ + IP: netip.MustParseAddr(peer1.IP.String()), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupB"}, + true, []string{}, true, userID, false, + ) + assert.NoError(t, err) - err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{"groupA"}, + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + // Creating DNS settings with groups that have peers should update account peers and send peer update + step(t, "creating dns setting with used groups", func(t *testing.T) { + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }) + assert.NoError(t, err) - // Removing group with peers from DNS settings should trigger updates to account peers and send peer updates - t.Run("removing group with peers from dns settings", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{}, + _, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "ns-group-1", "ns-group-1", []nbdns.NameServer{{ + IP: netip.MustParseAddr(peer1.IP.String()), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupA"}, + true, []string{}, true, userID, false, + ) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } + // Saving DNS settings with groups that have peers should update account peers and send peer update + step(t, "saving dns setting with used groups", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"groupA", "groupB"}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Removing group with no peers from DNS settings should not trigger updates to account peers or send peer updates + step(t, "removing group with no peers from dns settings", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"groupA"}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Removing group with peers from DNS settings should trigger updates to account peers and send peer updates + step(t, "removing group with peers from dns settings", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) }) } diff --git a/management/server/group_test.go b/management/server/group_test.go index fa351a43e..f0ccd385c 100644 --- a/management/server/group_test.go +++ b/management/server/group_test.go @@ -23,6 +23,7 @@ import ( "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/networks" "github.com/netbirdio/netbird/management/server/networks/resources" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" "github.com/netbirdio/netbird/management/server/networks/routers" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -685,342 +686,368 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t } func TestGroupAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - g := []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - { - ID: "groupC", - Name: "GroupC", - Peers: []string{peer1.ID, peer3.ID}, - }, - { - ID: "groupD", - Name: "GroupD", - Peers: []string{}, - }, - { - ID: "groupE", - Name: "GroupE", - Peers: []string{peer2.ID}, - }, - } - for _, group := range g { - err := manager.CreateGroup(context.Background(), account.Id, userID, group) - assert.NoError(t, err) - } - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // Saving a group that is not linked to any resource should not update account peers - t.Run("saving unlinked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupB", - Name: "GroupB", - Peers: []string{peer1.ID, peer2.ID}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Adding a peer to a group that is not linked to any resource should not update account peers - // and not send peer update - t.Run("adding peer to unlinked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.GroupAddPeer(context.Background(), account.Id, "groupB", peer3.ID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Removing a peer from a group that is not linked to any resource should not update account peers - // and not send peer update - t.Run("removing peer from unliked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.GroupDeletePeer(context.Background(), account.Id, "groupB", peer3.ID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Deleting group should not update account peers and not send peer update - t.Run("deleting group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeleteGroup(context.Background(), account.Id, userID, "groupB") - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // adding a group to policy - _, err := manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ + g := []*types.Group{ { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupA"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID}, + }, + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, + }, + { + ID: "groupC", + Name: "GroupC", + Peers: []string{peer1.ID, peer3.ID}, + }, + { + ID: "groupD", + Name: "GroupD", + Peers: []string{}, + }, + { + ID: "groupE", + Name: "GroupE", + Peers: []string{peer2.ID}, }, - }, - }, true) - assert.NoError(t, err) - - // Saving a group linked to policy should update account peers and send peer update - t.Run("saving linked group to policy", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID}, - }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") } - }) - - // adding peer to a used group should update account peers and send peer update - t.Run("adding peer to linked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.GroupAddPeer(context.Background(), account.Id, "groupA", peer3.ID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") + for _, group := range g { + err := manager.CreateGroup(context.Background(), account.Id, userID, group) + assert.NoError(t, err) } - }) - // removing peer from a linked group should update account peers and send peer update - t.Run("removing peer from linked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) - err := manager.GroupDeletePeer(context.Background(), account.Id, "groupA", peer3.ID) + // Saving a group that is not linked to any resource should not update account peers + step(t, "saving unlinked group", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupB", + Name: "GroupB", + Peers: []string{peer1.ID, peer2.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Adding a peer to a group that is not linked to any resource should not update account peers + // and not send peer update + step(t, "adding peer to unlinked group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.GroupAddPeer(context.Background(), account.Id, "groupB", peer3.ID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Removing a peer from a group that is not linked to any resource should not update account peers + // and not send peer update + step(t, "removing peer from unliked group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.GroupDeletePeer(context.Background(), account.Id, "groupB", peer3.ID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Deleting group should not update account peers and not send peer update + step(t, "deleting group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeleteGroup(context.Background(), account.Id, userID, "groupB") + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // adding a group to policy + _, err := manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupA"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Saving a group linked to policy should update account peers and send peer update + step(t, "saving linked group to policy", func(t *testing.T) { + settleAffectedUpdates(updMsg) - // Saving a group linked to name server group should update account peers and send peer update - t.Run("saving group linked to name server group", func(t *testing.T) { - _, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{ - IP: netip.MustParseAddr("1.1.1.1"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupC"}, - true, nil, true, userID, false, - ) - assert.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID}, + }) + assert.NoError(t, err) - err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupC", - Name: "GroupC", - Peers: []string{peer1.ID, peer3.ID}, + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // adding peer to a used group should update account peers and send peer update + step(t, "adding peer to linked group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - // Saving a group linked to route should update account peers and send peer update - t.Run("saving group linked to route", func(t *testing.T) { - newRoute := route.Route{ - ID: "route", - Network: netip.MustParsePrefix("192.168.0.0/16"), - NetID: "superNet", - NetworkType: route.IPv4Network, - PeerGroups: []string{"groupA"}, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{"groupC"}, - } - _, err := manager.CreateRoute( - context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, - newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, - newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, newRoute.SkipAutoApply, - ) - require.NoError(t, err) + err := manager.GroupAddPeer(context.Background(), account.Id, "groupA", peer3.ID) + assert.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // removing peer from a linked group should update account peers and send peer update + step(t, "removing peer from linked group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - // Saving a group linked to dns settings should update account peers and send peer update - t.Run("saving group linked to dns settings", func(t *testing.T) { - err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ - DisabledManagementGroups: []string{"groupD"}, + err := manager.GroupDeletePeer(context.Background(), account.Id, "groupA", peer3.ID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Saving a group linked to name server group should update account peers and send peer update + step(t, "saving group linked to name server group", func(t *testing.T) { + _, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupC"}, + true, nil, true, userID, false, + ) + assert.NoError(t, err) - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupD", - Name: "GroupD", - Peers: []string{peer1.ID}, + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupC", + Name: "GroupC", + Peers: []string{peer1.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Saving a group linked to route should update account peers and send peer update + step(t, "saving group linked to route", func(t *testing.T) { + newRoute := route.Route{ + ID: "route", + Network: netip.MustParsePrefix("192.168.0.0/16"), + NetID: "superNet", + NetworkType: route.IPv4Network, + PeerGroups: []string{"groupA"}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{"groupC"}, + } + _, err := manager.CreateRoute( + context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, + newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, + newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, newRoute.SkipAutoApply, + ) + require.NoError(t, err) - // Saving a group linked to network router should update account peers and send peer update - t.Run("saving group linked to network router", func(t *testing.T) { - permissionsManager := permissions.NewManager(manager.Store) - groupsManager := groups.NewManager(manager.Store, permissionsManager, manager) - resourcesManager := resources.NewManager(manager.Store, permissionsManager, groupsManager, manager, manager.serviceManager) - routersManager := routers.NewManager(manager.Store, permissionsManager, manager) - networksManager := networks.NewManager(manager.Store, permissionsManager, resourcesManager, routersManager, manager) + settleAffectedUpdates(updMsg) - network, err := networksManager.CreateNetwork(context.Background(), userID, &networkTypes.Network{ - ID: "network_test", - AccountID: account.Id, - Name: "network_test", - Description: "", + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - require.NoError(t, err) - _, err = routersManager.CreateRouter(context.Background(), userID, &routerTypes.NetworkRouter{ - ID: "router_test", - NetworkID: network.ID, - AccountID: account.Id, - PeerGroups: []string{"groupE"}, - Masquerade: true, - Metric: 9999, - Enabled: true, + // Saving a group linked to dns settings should update account peers and send peer update + step(t, "saving group linked to dns settings", func(t *testing.T) { + err := manager.SaveDNSSettings(context.Background(), account.Id, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"groupD"}, + }) + assert.NoError(t, err) + + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupD", + Name: "GroupD", + Peers: []string{peer1.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - require.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Saving a group linked to network router should update account peers and send peer update + step(t, "saving group linked to network router", func(t *testing.T) { + permissionsManager := permissions.NewManager(manager.Store) + groupsManager := groups.NewManager(manager.Store, permissionsManager, manager) + resourcesManager := resources.NewManager(manager.Store, permissionsManager, groupsManager, manager, manager.serviceManager) + routersManager := routers.NewManager(manager.Store, permissionsManager, manager) + networksManager := networks.NewManager(manager.Store, permissionsManager, resourcesManager, routersManager, manager) - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupE", - Name: "GroupE", - Peers: []string{peer2.ID, peer3.ID}, + network, err := networksManager.CreateNetwork(context.Background(), userID, &networkTypes.Network{ + ID: "network_test", + AccountID: account.Id, + Name: "network_test", + Description: "", + }) + require.NoError(t, err) + + _, err = routersManager.CreateRouter(context.Background(), userID, &routerTypes.NetworkRouter{ + ID: "router_test", + NetworkID: network.ID, + AccountID: account.Id, + PeerGroups: []string{"groupE"}, + Masquerade: true, + Metric: 9999, + Enabled: true, + }) + require.NoError(t, err) + + resource, err := resourcesManager.CreateResource(context.Background(), userID, &resourceTypes.NetworkResource{ + AccountID: account.Id, + NetworkID: network.ID, + Name: "resource_test", + Address: "10.20.30.0/24", + Enabled: true, + }) + require.NoError(t, err) + + _, err = manager.SavePolicy(context.Background(), account.Id, userID, peerToResourcePolicyByResource("groupA", resource.ID), true) + require.NoError(t, err) + + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupE", + Name: "GroupE", + Peers: []string{peer2.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } }) } diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go index deed9c34f..1460893cf 100644 --- a/management/server/nameserver_test.go +++ b/management/server/nameserver_test.go @@ -6,9 +6,9 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" @@ -966,147 +966,151 @@ func TestValidateDomain(t *testing.T) { } func TestNameServerAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - var newNameServerGroupA *nbdns.NameServerGroup - var newNameServerGroupB *nbdns.NameServerGroup + var newNameServerGroupA *nbdns.NameServerGroup + var newNameServerGroupB *nbdns.NameServerGroup - err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{}, - }) - assert.NoError(t, err) - - err = manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupB", - Name: "GroupB", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, - }) - assert.NoError(t, err) - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // Creating a nameserver group with a distribution group no peers should not update account peers - // and not send peer update - t.Run("creating nameserver group with distribution group no peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - newNameServerGroupA, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "nsGroupA", "nsGroupA", []nbdns.NameServer{{ - IP: netip.MustParseAddr("1.1.1.1"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupA"}, - true, []string{}, true, userID, false, - ) + err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{}, + }) assert.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // saving a nameserver group with a distribution group with no peers should not update account peers - // and not send peer update - t.Run("saving nameserver group with distribution group no peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err = manager.SaveNameServerGroup(context.Background(), account.Id, userID, newNameServerGroupA) + err = manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupB", + Name: "GroupB", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }) assert.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) - // Creating a nameserver group with a distribution group no peers should update account peers and send peer update - t.Run("creating nameserver group with distribution group has peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Creating a nameserver group with a distribution group no peers should not update account peers + // and not send peer update + step(t, "creating nameserver group with distribution group no peers", func(t *testing.T) { + settleAffectedUpdates(updMsg) - newNameServerGroupB, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "nsGroupB", "nsGroupB", []nbdns.NameServer{{ - IP: netip.MustParseAddr("1.1.1.1"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupB"}, - true, []string{}, true, userID, false, - ) - assert.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + newNameServerGroupA, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "nsGroupA", "nsGroupA", []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupA"}, + true, []string{}, true, userID, false, + ) + assert.NoError(t, err) - // saving a nameserver group with a distribution group with peers should update account peers and send peer update - t.Run("saving nameserver group with distribution group has peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) - newNameServerGroupB.NameServers = []nbdns.NameServer{ - { - IP: netip.MustParseAddr("1.1.1.2"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }, - { - IP: netip.MustParseAddr("8.8.8.8"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }, - } - err = manager.SaveNameServerGroup(context.Background(), account.Id, userID, newNameServerGroupB) - assert.NoError(t, err) + // saving a nameserver group with a distribution group with no peers should not update account peers + // and not send peer update + step(t, "saving nameserver group with distribution group no peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + err = manager.SaveNameServerGroup(context.Background(), account.Id, userID, newNameServerGroupA) + assert.NoError(t, err) - // Deleting a nameserver group should update account peers and send peer update - t.Run("deleting nameserver group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) - err = manager.DeleteNameServerGroup(context.Background(), account.Id, newNameServerGroupB.ID, userID) - assert.NoError(t, err) + // Creating a nameserver group with a distribution group no peers should update account peers and send peer update + step(t, "creating nameserver group with distribution group has peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } + newNameServerGroupB, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "nsGroupB", "nsGroupB", []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupB"}, + true, []string{}, true, userID, false, + ) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // saving a nameserver group with a distribution group with peers should update account peers and send peer update + step(t, "saving nameserver group with distribution group has peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + newNameServerGroupB.NameServers = []nbdns.NameServer{ + { + IP: netip.MustParseAddr("1.1.1.2"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }, + { + IP: netip.MustParseAddr("8.8.8.8"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }, + } + err = manager.SaveNameServerGroup(context.Background(), account.Id, userID, newNameServerGroupB) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Deleting a nameserver group should update account peers and send peer update + step(t, "deleting nameserver group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.DeleteNameServerGroup(context.Background(), account.Id, newNameServerGroupB.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) }) } diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 5c3e02af5..5307300d6 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -1844,374 +1844,336 @@ func Test_LoginPeer(t *testing.T) { } func TestPeerAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - err := manager.DeletePolicy(context.Background(), account.Id, account.Policies[0].ID, userID) - require.NoError(t, err) - - g := []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - { - ID: "groupC", - Name: "GroupC", - Peers: []string{}, - }, - } - for _, group := range g { - err = manager.CreateGroup(context.Background(), account.Id, userID, group) - require.NoError(t, err) - } - - // create a user with auto groups - _, err = manager.SaveOrAddUsers(context.Background(), account.Id, userID, []*types.User{ - { - Id: "regularUser1", - AccountID: account.Id, - Role: types.UserRoleAdmin, - Issued: types.UserIssuedAPI, - AutoGroups: []string{"groupA"}, - }, - { - Id: "regularUser2", - AccountID: account.Id, - Role: types.UserRoleAdmin, - Issued: types.UserIssuedAPI, - AutoGroups: []string{"groupB"}, - }, - { - Id: "regularUser3", - AccountID: account.Id, - Role: types.UserRoleAdmin, - Issued: types.UserIssuedAPI, - AutoGroups: []string{"groupC"}, - }, - }, true) - require.NoError(t, err) - - var peer4 *nbpeer.Peer - var peer5 *nbpeer.Peer - var peer6 *nbpeer.Peer - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // Updating not expired peer and peer expiration is enabled should not update account peers and not send peer update - t.Run("updating not expired peer and peer expiration is enabled", func(t *testing.T) { - t.Skip("Currently all updates will trigger a network map") - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err := manager.UpdatePeer(context.Background(), account.Id, userID, peer2) + err := manager.DeletePolicy(context.Background(), account.Id, account.Policies[0].ID, userID) require.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") + g := []*types.Group{ + { + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }, + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, + }, + { + ID: "groupC", + Name: "GroupC", + Peers: []string{}, + }, } - }) - - // Adding peer to unlinked group should not update account peers and not send peer update - t.Run("adding peer to unlinked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) - - expectedPeerKey := key.PublicKey().String() - peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ - Key: expectedPeerKey, - Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, - }, false) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Deleting peer with unlinked group should not update account peers and not send peer update - t.Run("deleting peer with unlinked group", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err = manager.DeletePeer(context.Background(), account.Id, peer4.ID, userID) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Updating peer label should update account peers and send peer update - t.Run("updating peer label", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - peer1.Name = "peer-1" - _, err = manager.UpdatePeer(context.Background(), account.Id, userID, peer1) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - t.Run("validator requires update", func(t *testing.T) { - requireUpdateFunc := func(_ context.Context, update *nbpeer.Peer, peer *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error) { - return update, true, nil + for _, group := range g { + err = manager.CreateGroup(context.Background(), account.Id, userID, group) + require.NoError(t, err) } - manager.integratedPeerValidator = MockIntegratedValidator{ValidatePeerFunc: requireUpdateFunc} - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.UpdatePeer(context.Background(), account.Id, userID, peer1) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - t.Run("validator requires no update", func(t *testing.T) { - t.Skip("Currently all updates will trigger a network map") - - requireNoUpdateFunc := func(_ context.Context, update *nbpeer.Peer, peer *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error) { - return update, false, nil - } - - manager.integratedPeerValidator = MockIntegratedValidator{ValidatePeerFunc: requireNoUpdateFunc} - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.UpdatePeer(context.Background(), account.Id, userID, peer1) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Adding peer to group linked with policy should update account peers and send peer update - t.Run("adding peer to group linked with policy", func(t *testing.T) { - _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - AccountID: account.Id, - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupA"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, + // create a user with auto groups + _, err = manager.SaveOrAddUsers(context.Background(), account.Id, userID, []*types.User{ + { + Id: "regularUser1", + AccountID: account.Id, + Role: types.UserRoleAdmin, + Issued: types.UserIssuedAPI, + AutoGroups: []string{"groupA"}, + }, + { + Id: "regularUser2", + AccountID: account.Id, + Role: types.UserRoleAdmin, + Issued: types.UserIssuedAPI, + AutoGroups: []string{"groupB"}, + }, + { + Id: "regularUser3", + AccountID: account.Id, + Role: types.UserRoleAdmin, + Issued: types.UserIssuedAPI, + AutoGroups: []string{"groupC"}, }, }, true) require.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + var peer4 *nbpeer.Peer + var peer5 *nbpeer.Peer + var peer6 *nbpeer.Peer - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) - expectedPeerKey := key.PublicKey().String() - peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ - Key: expectedPeerKey, - LoginExpirationEnabled: true, - Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, - }, false) - require.NoError(t, err) + // Adding peer to unlinked group should not update account peers and not send peer update + step(t, "adding peer to unlinked group", func(t *testing.T) { + settleAffectedUpdates(updMsg) + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) - // Deleting peer with linked group to policy should update account peers and send peer update - t.Run("deleting peer with linked group to policy", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + expectedPeerKey := key.PublicKey().String() + peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ + Key: expectedPeerKey, + Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, + }, false) + require.NoError(t, err) - err = manager.DeletePeer(context.Background(), account.Id, peer4.ID, userID) - require.NoError(t, err) + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Deleting peer with unlinked group should not update account peers and not send peer update + step(t, "deleting peer with unlinked group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - // drain any buffered updates from previous subtests - drainPeerUpdates(updMsg) + err = manager.DeletePeer(context.Background(), account.Id, peer4.ID, userID) + require.NoError(t, err) - // Adding peer to group linked with route should update peers in that group, not unrelated peers - t.Run("adding peer to group linked with route", func(t *testing.T) { - route := nbroute.Route{ - ID: "testingRoute1", - Network: netip.MustParsePrefix("100.65.250.202/32"), - NetID: "superNet", - NetworkType: nbroute.IPv4Network, - PeerGroups: []string{"groupB"}, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{"groupB"}, - } + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) - _, err := manager.CreateRoute( - context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, - route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, - route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, - ) - require.NoError(t, err) + // Updating peer label should update account peers and send peer update + step(t, "updating peer label", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + peer1.Name = "peer-1" + _, err = manager.UpdatePeer(context.Background(), account.Id, userID, peer1) + require.NoError(t, err) - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - expectedPeerKey := key.PublicKey().String() - peer5, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ - Key: expectedPeerKey, - LoginExpirationEnabled: true, - Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, - }, false) - require.NoError(t, err) + step(t, "validator requires update", func(t *testing.T) { + requireUpdateFunc := func(_ context.Context, update *nbpeer.Peer, peer *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error) { + return update, true, nil + } - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + manager.integratedPeerValidator = MockIntegratedValidator{ValidatePeerFunc: requireUpdateFunc} + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - // Deleting peer with linked group to route should update peers in that group, not unrelated peers - t.Run("deleting peer with linked group to route", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + _, err = manager.UpdatePeer(context.Background(), account.Id, userID, peer1) + require.NoError(t, err) - err = manager.DeletePeer(context.Background(), account.Id, peer5.ID, userID) - require.NoError(t, err) + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + // Adding peer to group linked with policy should update account peers and send peer update + step(t, "adding peer to group linked with policy", func(t *testing.T) { + _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + AccountID: account.Id, + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupA"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) - // Adding peer to group linked with name server group should update peers in that group, not unrelated peers - t.Run("adding peer to group linked with name server group", func(t *testing.T) { - _, err = manager.CreateNameServerGroup( - context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{ - IP: netip.MustParseAddr("1.1.1.1"), - NSType: nbdns.UDPNameServerType, - Port: nbdns.DefaultDNSPort, - }}, - []string{"groupC"}, - true, []string{}, true, userID, false, - ) - require.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) + expectedPeerKey := key.PublicKey().String() + peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ + Key: expectedPeerKey, + LoginExpirationEnabled: true, + Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, + }, false) + require.NoError(t, err) - expectedPeerKey := key.PublicKey().String() - peer6, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser3", &nbpeer.Peer{ - Key: expectedPeerKey, - LoginExpirationEnabled: true, - Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, - }, false) - require.NoError(t, err) + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + // Deleting peer with linked group to policy should update account peers and send peer update + step(t, "deleting peer with linked group to policy", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - // Deleting peer with linked group to name server group should update peers in that group, not unrelated peers - t.Run("deleting peer with linked group to route", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + err = manager.DeletePeer(context.Background(), account.Id, peer4.ID, userID) + require.NoError(t, err) - err = manager.DeletePeer(context.Background(), account.Id, peer6.ID, userID) - require.NoError(t, err) + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } + // drain any buffered updates from previous subtests + drainPeerUpdates(updMsg) + + // Adding peer to group linked with route should update peers in that group, not unrelated peers + step(t, "adding peer to group linked with route", func(t *testing.T) { + route := nbroute.Route{ + ID: "testingRoute1", + Network: netip.MustParsePrefix("100.65.250.202/32"), + NetID: "superNet", + NetworkType: nbroute.IPv4Network, + PeerGroups: []string{"groupB"}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{"groupB"}, + } + + _, err := manager.CreateRoute( + context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, + route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, + route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, + ) + require.NoError(t, err) + + settleAffectedUpdates(updMsg) + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + expectedPeerKey := key.PublicKey().String() + peer5, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ + Key: expectedPeerKey, + LoginExpirationEnabled: true, + Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, + }, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Deleting peer with linked group to route should update peers in that group, not unrelated peers + step(t, "deleting peer with linked group to route", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.DeletePeer(context.Background(), account.Id, peer5.ID, userID) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Adding peer to group linked with name server group should update peers in that group, not unrelated peers + step(t, "adding peer to group linked with name server group", func(t *testing.T) { + _, err = manager.CreateNameServerGroup( + context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"groupC"}, + true, []string{}, true, userID, false, + ) + require.NoError(t, err) + + settleAffectedUpdates(updMsg) + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + expectedPeerKey := key.PublicKey().String() + peer6, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser3", &nbpeer.Peer{ + Key: expectedPeerKey, + LoginExpirationEnabled: true, + Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, + }, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Deleting peer with linked group to name server group should update peers in that group, not unrelated peers + step(t, "deleting peer with linked group to name server group", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.DeletePeer(context.Background(), account.Id, peer6.ID, userID) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) }) } @@ -2859,45 +2821,47 @@ func TestPeerWillHaveIPv6(t *testing.T) { // flipping --disable-ipv6) without bumping its WtVersion, other account peers // receive a fresh network map so their AAAA records for it become unstale. func TestSyncPeer_IPv6CapabilityChangePropagates(t *testing.T) { - manager, updateManager, _, peer1, peer2, _ := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, _, peer1, peer2, _ := setupNetworkMapTest(t) - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) - // Drain any initial updates from setup. - drain := func() { - for { - select { - case <-updMsg: - case <-time.After(200 * time.Millisecond): - return + // Drain any initial updates from setup. + drain := func() { + for { + select { + case <-updMsg: + case <-time.After(200 * time.Millisecond): + return + } } } - } - drain() + drain() - t.Run("no propagation when capabilities are unchanged", func(t *testing.T) { - _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ - WireGuardPubKey: peer2.Key, - Meta: peer2.Meta, - }, peer2.AccountID) - require.NoError(t, err) - peerShouldNotReceiveUpdate(t, updMsg) - }) + step(t, "no propagation when capabilities are unchanged", func(t *testing.T) { + _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ + WireGuardPubKey: peer2.Key, + Meta: peer2.Meta, + }, peer2.AccountID) + require.NoError(t, err) + peerShouldNotReceiveUpdate(t, updMsg) + }) - t.Run("propagation when IPv6 capability is added", func(t *testing.T) { - newMeta := peer2.Meta - newMeta.Capabilities = append([]int32{}, peer2.Meta.Capabilities...) - newMeta.Capabilities = append(newMeta.Capabilities, nbpeer.PeerCapabilityIPv6Overlay) + step(t, "propagation when IPv6 capability is added", func(t *testing.T) { + newMeta := peer2.Meta + newMeta.Capabilities = append([]int32{}, peer2.Meta.Capabilities...) + newMeta.Capabilities = append(newMeta.Capabilities, nbpeer.PeerCapabilityIPv6Overlay) - _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ - WireGuardPubKey: peer2.Key, - Meta: newMeta, - }, peer2.AccountID) - require.NoError(t, err) - peerShouldReceiveUpdate(t, updMsg) + _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ + WireGuardPubKey: peer2.Key, + Meta: newMeta, + }, peer2.AccountID) + require.NoError(t, err) + peerShouldReceiveUpdate(t, updMsg) + }) }) } diff --git a/management/server/policy_test.go b/management/server/policy_test.go index 6fb573b9e..7607a7567 100644 --- a/management/server/policy_test.go +++ b/management/server/policy_test.go @@ -1135,287 +1135,291 @@ func sortFunc() func(a *types.FirewallRule, b *types.FirewallRule) int { } func TestPolicyAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - g := []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer3.ID}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - { - ID: "groupC", - Name: "GroupC", - Peers: []string{}, - }, - { - ID: "groupD", - Name: "GroupD", - Peers: []string{peer1.ID, peer2.ID}, - }, - } - for _, group := range g { - err := manager.CreateGroup(context.Background(), account.Id, userID, group) - assert.NoError(t, err) - } - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - var policyWithGroupRulesNoPeers *types.Policy - var policyWithDestinationPeersOnly *types.Policy - var policyWithSourceAndDestinationPeers *types.Policy - var err error - - // Saving policy with rule groups with no peers should not update account's peers and not send peer update - t.Run("saving policy with rule groups with no peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithGroupRulesNoPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - AccountID: account.Id, - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupB"}, - Destinations: []string{"groupC"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, + g := []*types.Group{ + { + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer3.ID}, }, - }, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Saving policy with source group containing peers, but destination group without peers should - // update account's peers and send peer update - t.Run("saving policy where source has peers but destination does not", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - AccountID: account.Id, - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupB"}, - Protocol: types.PolicyRuleProtocolTCP, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, }, - }, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Saving policy with destination group containing peers, but source group without peers should - // update account's peers and send peer update - t.Run("saving policy where destination has peers but source does not", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithDestinationPeersOnly, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - AccountID: account.Id, - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupC"}, - Destinations: []string{"groupD"}, - Bidirectional: true, - Protocol: types.PolicyRuleProtocolTCP, - Action: types.PolicyTrafficActionAccept, - }, + { + ID: "groupC", + Name: "GroupC", + Peers: []string{}, }, - }, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Saving policy with destination and source groups containing peers should update account's peers - // and send peer update - t.Run("saving policy with source and destination groups with peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - AccountID: account.Id, - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupD"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, + { + ID: "groupD", + Name: "GroupD", + Peers: []string{peer1.ID, peer2.ID}, }, - }, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") } - }) - - // Disabling policy with destination and source groups containing peers should update account's peers - // and send peer update - t.Run("disabling policy with source and destination groups with peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithSourceAndDestinationPeers.Enabled = false - policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Updating disabled policy with destination and source groups containing peers should still update account's peers - // because affected peer resolution does not filter by policy enabled state - t.Run("updating disabled policy with source and destination groups with peers", func(t *testing.T) { - drainPeerUpdates(updMsg) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithSourceAndDestinationPeers.Description = "updated description" - policyWithSourceAndDestinationPeers.Rules[0].Destinations = []string{"groupA"} - policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Enabling policy with destination and source groups containing peers should update account's peers - // and send peer update - t.Run("enabling policy with source and destination groups with peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policyWithSourceAndDestinationPeers.Enabled = true - policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Deleting policy should trigger account peers update and send peer update - t.Run("deleting policy with source and destination groups with peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeletePolicy(context.Background(), account.Id, policyWithSourceAndDestinationPeers.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") + for _, group := range g { + err := manager.CreateGroup(context.Background(), account.Id, userID, group) + assert.NoError(t, err) } + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) + + var policyWithGroupRulesNoPeers *types.Policy + var policyWithDestinationPeersOnly *types.Policy + var policyWithSourceAndDestinationPeers *types.Policy + var err error + + // Saving policy with rule groups with no peers should not update account's peers and not send peer update + step(t, "saving policy with rule groups with no peers", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithGroupRulesNoPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + AccountID: account.Id, + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupB"}, + Destinations: []string{"groupC"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Saving policy with source group containing peers, but destination group without peers should + // update account's peers and send peer update + step(t, "saving policy where source has peers but destination does not", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + AccountID: account.Id, + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupB"}, + Protocol: types.PolicyRuleProtocolTCP, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Saving policy with destination group containing peers, but source group without peers should + // update account's peers and send peer update + step(t, "saving policy where destination has peers but source does not", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithDestinationPeersOnly, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + AccountID: account.Id, + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupC"}, + Destinations: []string{"groupD"}, + Bidirectional: true, + Protocol: types.PolicyRuleProtocolTCP, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Saving policy with destination and source groups containing peers should update account's peers + // and send peer update + step(t, "saving policy with source and destination groups with peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + AccountID: account.Id, + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupD"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Disabling policy with destination and source groups containing peers should update account's peers + // and send peer update + step(t, "disabling policy with source and destination groups with peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithSourceAndDestinationPeers.Enabled = false + policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Updating disabled policy with destination and source groups containing peers should still update account's peers + // because affected peer resolution does not filter by policy enabled state + step(t, "updating disabled policy with source and destination groups with peers", func(t *testing.T) { + drainPeerUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithSourceAndDestinationPeers.Description = "updated description" + policyWithSourceAndDestinationPeers.Rules[0].Destinations = []string{"groupA"} + policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Enabling policy with destination and source groups containing peers should update account's peers + // and send peer update + step(t, "enabling policy with source and destination groups with peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policyWithSourceAndDestinationPeers.Enabled = true + policyWithSourceAndDestinationPeers, err = manager.SavePolicy(context.Background(), account.Id, userID, policyWithSourceAndDestinationPeers, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Deleting policy should trigger account peers update and send peer update + step(t, "deleting policy with source and destination groups with peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeletePolicy(context.Background(), account.Id, policyWithSourceAndDestinationPeers.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + + }) + + // Deleting policy with destination group containing peers, but source group without peers should + // update account's peers and send peer update + step(t, "deleting policy where destination has peers but source does not", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeletePolicy(context.Background(), account.Id, policyWithDestinationPeersOnly.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Deleting policy with no peers in groups should not update account's peers and not send peer update + step(t, "deleting policy with no peers in groups", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeletePolicy(context.Background(), account.Id, policyWithGroupRulesNoPeers.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + }) - - // Deleting policy with destination group containing peers, but source group without peers should - // update account's peers and send peer update - t.Run("deleting policy where destination has peers but source does not", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeletePolicy(context.Background(), account.Id, policyWithDestinationPeersOnly.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Deleting policy with no peers in groups should not update account's peers and not send peer update - t.Run("deleting policy with no peers in groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeletePolicy(context.Background(), account.Id, policyWithGroupRulesNoPeers.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - } diff --git a/management/server/posture_checks_test.go b/management/server/posture_checks_test.go index 74738e72d..601294eda 100644 --- a/management/server/posture_checks_test.go +++ b/management/server/posture_checks_test.go @@ -123,324 +123,334 @@ func initTestPostureChecksAccount(am *DefaultAccountManager) (*types.Account, er } func TestPostureCheckAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - g := []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - { - ID: "groupC", - Name: "GroupC", - Peers: []string{}, - }, - } - for _, group := range g { - err := manager.CreateGroup(context.Background(), account.Id, userID, group) - assert.NoError(t, err) - } - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - postureCheckA := &posture.Checks{ - Name: "postureCheckA", - AccountID: account.Id, - Checks: posture.ChecksDefinition{ - ProcessCheck: &posture.ProcessCheck{ - Processes: []posture.Process{ - {LinuxPath: "/usr/bin/netbird", MacPath: "/usr/local/bin/netbird"}, - }, - }, - }, - } - postureCheckA, err := manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckA, true) - require.NoError(t, err) - - postureCheckB := &posture.Checks{ - Name: "postureCheckB", - AccountID: account.Id, - Checks: posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.28.0", - }, - }, - } - - // Saving unused posture check should not update account peers and not send peer update - t.Run("saving unused posture check", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - postureCheckB, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Updating unused posture check should not update account peers and not send peer update - t.Run("updating unused posture check", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - postureCheckB.Checks = posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.29.0", - }, - } - _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - policy := &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ + g := []*types.Group{ { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupA"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, }, - }, - SourcePostureChecks: []string{postureCheckB.ID}, - } - - // Linking posture check to policy should trigger update account peers and send peer update - t.Run("linking posture check to policy with peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policy, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Updating linked posture checks should update account peers and send peer update - t.Run("updating linked to posture check with peers", func(t *testing.T) { - postureCheckB.Checks = posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.29.0", + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, }, - ProcessCheck: &posture.ProcessCheck{ - Processes: []posture.Process{ - {LinuxPath: "/usr/bin/netbird", MacPath: "/usr/local/bin/netbird"}, - }, + { + ID: "groupC", + Name: "GroupC", + Peers: []string{}, }, } - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") + for _, group := range g { + err := manager.CreateGroup(context.Background(), account.Id, userID, group) + assert.NoError(t, err) } - }) - // Removing posture check from policy should trigger account peers update and send peer update - t.Run("removing posture check from policy", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - policy.SourcePostureChecks = []string{} - _, err := manager.SavePolicy(context.Background(), account.Id, userID, policy, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Deleting unused posture check should not trigger account peers update and not send peer update - t.Run("deleting unused posture check", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeletePostureChecks(context.Background(), account.Id, postureCheckA.ID, userID) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - - // Updating linked posture check to policy with no peers should not trigger account peers update and not send peer update - t.Run("updating linked posture check to policy with no peers", func(t *testing.T) { - _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupB"}, - Destinations: []string{"groupC"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - SourcePostureChecks: []string{postureCheckB.ID}, - }, true) - assert.NoError(t, err) - - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - postureCheckB.Checks = posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.29.0", - }, - } - _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) - - // Updating linked posture check to policy where destination has peers but source does not - // should trigger account peers update and send peer update - t.Run("updating linked posture check to policy where destination has peers but source does not", func(t *testing.T) { - updMsg1 := updateManager.CreateChannel(context.Background(), peer2.ID) + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer2.ID) + updateManager.CloseChannel(context.Background(), peer1.ID) }) - _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + postureCheckA := &posture.Checks{ + Name: "postureCheckA", + AccountID: account.Id, + Checks: posture.ChecksDefinition{ + ProcessCheck: &posture.ProcessCheck{ + Processes: []posture.Process{ + {LinuxPath: "/usr/bin/netbird", MacPath: "/usr/local/bin/netbird"}, + }, + }, + }, + } + postureCheckA, err := manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckA, true) + require.NoError(t, err) + + postureCheckB := &posture.Checks{ + Name: "postureCheckB", + AccountID: account.Id, + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.28.0", + }, + }, + } + + // Saving unused posture check should not update account peers and not send peer update + step(t, "saving unused posture check", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + postureCheckB, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Updating unused posture check should not update account peers and not send peer update + step(t, "updating unused posture check", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + postureCheckB.Checks = posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.29.0", + }, + } + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + policy := &types.Policy{ Enabled: true, Rules: []*types.PolicyRule{ { Enabled: true, - Sources: []string{"groupB"}, + Sources: []string{"groupA"}, Destinations: []string{"groupA"}, Bidirectional: true, Action: types.PolicyTrafficActionAccept, }, }, SourcePostureChecks: []string{postureCheckB.ID}, - }, true) - assert.NoError(t, err) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg1) - close(done) - }() - - postureCheckB.Checks = posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.29.0", - }, } - _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Linking posture check to policy should trigger update account peers and send peer update + step(t, "linking posture check to policy with peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - // Updating linked client posture check to policy where source has peers but destination does not, - // should trigger account peers update and send peer update - t.Run("updating linked posture check to policy where source has peers but destination does not", func(t *testing.T) { - _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupB"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + policy, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Updating linked posture checks should update account peers and send peer update + step(t, "updating linked to posture check with peers", func(t *testing.T) { + postureCheckB.Checks = posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.29.0", }, - }, - SourcePostureChecks: []string{postureCheckB.ID}, - }, true) - assert.NoError(t, err) - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - postureCheckB.Checks = posture.ChecksDefinition{ - ProcessCheck: &posture.ProcessCheck{ - Processes: []posture.Process{ - { - LinuxPath: "/usr/bin/netbird", + ProcessCheck: &posture.ProcessCheck{ + Processes: []posture.Process{ + {LinuxPath: "/usr/bin/netbird", MacPath: "/usr/local/bin/netbird"}, }, }, - }, - } + } + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Removing posture check from policy should trigger account peers update and send peer update + step(t, "removing posture check from policy", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + policy.SourcePostureChecks = []string{} + _, err := manager.SavePolicy(context.Background(), account.Id, userID, policy, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Deleting unused posture check should not trigger account peers update and not send peer update + step(t, "deleting unused posture check", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeletePostureChecks(context.Background(), account.Id, postureCheckA.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } + // Updating linked posture check to policy with no peers should not trigger account peers update and not send peer update + step(t, "updating linked posture check to policy with no peers", func(t *testing.T) { + _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupB"}, + Destinations: []string{"groupC"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + SourcePostureChecks: []string{postureCheckB.ID}, + }, true) + assert.NoError(t, err) + + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + postureCheckB.Checks = posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.29.0", + }, + } + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Updating linked posture check to policy where destination has peers but source does not + // should trigger account peers update and send peer update + step(t, "updating linked posture check to policy where destination has peers but source does not", func(t *testing.T) { + updMsg1 := updateManager.CreateChannel(context.Background(), peer2.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer2.ID) + }) + + _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupB"}, + Destinations: []string{"groupA"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + SourcePostureChecks: []string{postureCheckB.ID}, + }, true) + assert.NoError(t, err) + + settleAffectedUpdates(updMsg, updMsg1) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + close(done) + }() + + postureCheckB.Checks = posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.29.0", + }, + } + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Updating linked client posture check to policy where source has peers but destination does not, + // should trigger account peers update and send peer update + step(t, "updating linked posture check to policy where source has peers but destination does not", func(t *testing.T) { + _, err = manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + SourcePostureChecks: []string{postureCheckB.ID}, + }, true) + assert.NoError(t, err) + + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + postureCheckB.Checks = posture.ChecksDefinition{ + ProcessCheck: &posture.ProcessCheck{ + Processes: []posture.Process{ + { + LinuxPath: "/usr/bin/netbird", + }, + }, + }, + } + _, err = manager.SavePostureChecks(context.Background(), account.Id, userID, postureCheckB, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) }) } diff --git a/management/server/route_test.go b/management/server/route_test.go index 4ca9ee48f..69b9aec6c 100644 --- a/management/server/route_test.go +++ b/management/server/route_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/rs/xid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" @@ -1262,7 +1262,10 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel. } eventStore := &activity.InMemoryEventStore{} - metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + metrics, err := telemetry.NewDefaultAppMetrics(ctx) require.NoError(t, err) ctrl := gomock.NewController(t) @@ -1290,9 +1293,8 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel. permissionsManager := permissions.NewManager(store) peersManager := peers.NewManager(store, permissionsManager) - ctx := context.Background() - - cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + // A go-cache janitor only stops through a GC finalizer, which would leave synctest bubbles with a goroutine that never exits. + cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 0, 100) if err != nil { return nil, nil, err } @@ -1301,10 +1303,12 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel. requestBuffer := NewAccountRequestBuffer(ctx, store) networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(ctx, nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, nil, err } + cacheManager := am.cacheManager + t.Cleanup(func() { _ = cacheManager.Close() }) return am, updateManager, nil } @@ -1893,265 +1897,269 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) { } func TestRouteAccountPeersUpdate(t *testing.T) { - manager, updateManager, err := createRouterManager(t) - require.NoError(t, err, "failed to create account manager") + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, err := createRouterManager(t) + require.NoError(t, err, "failed to create account manager") - account, err := initTestRouteAccount(t, manager) - require.NoError(t, err, "failed to init testing account") + account, err := initTestRouteAccount(t, manager) + require.NoError(t, err, "failed to init testing account") - g := []*types.Group{ - { - ID: "groupA", - Name: "GroupA", - Peers: []string{}, - }, - { - ID: "groupB", - Name: "GroupB", - Peers: []string{}, - }, - { - ID: "groupC", - Name: "GroupC", - Peers: []string{}, - }, - } - for _, group := range g { - err = manager.CreateGroup(context.Background(), account.Id, userID, group) - require.NoError(t, err, "failed to create group %s", group.Name) - } + g := []*types.Group{ + { + ID: "groupA", + Name: "GroupA", + Peers: []string{}, + }, + { + ID: "groupB", + Name: "GroupB", + Peers: []string{}, + }, + { + ID: "groupC", + Name: "GroupC", + Peers: []string{}, + }, + } + for _, group := range g { + err = manager.CreateGroup(context.Background(), account.Id, userID, group) + require.NoError(t, err, "failed to create group %s", group.Name) + } - updMsg := updateManager.CreateChannel(context.Background(), peer1ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1ID) - }) + updMsg := updateManager.CreateChannel(context.Background(), peer1ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1ID) + }) - // Creating a route with no routing peer and no peers in PeerGroups or Groups should not update account peers and not send peer update - t.Run("creating route no routing peer and no peers in groups", func(t *testing.T) { - route := route.Route{ - ID: "testingRoute1", - Network: netip.MustParsePrefix("100.65.250.202/32"), + // Creating a route with no routing peer and no peers in PeerGroups or Groups should not update account peers and not send peer update + step(t, "creating route no routing peer and no peers in groups", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + route := route.Route{ + ID: "testingRoute1", + Network: netip.MustParsePrefix("100.65.250.202/32"), + NetID: "superNet", + NetworkType: route.IPv4Network, + PeerGroups: []string{"groupA"}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{"groupA"}, + } + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + _, err := manager.CreateRoute( + context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, + route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, + route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, + ) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + + }) + + // Creating a route with no routing peer and having peers in groups that don't include peer1 should not send peer1 an update + step(t, "creating a route with peers in PeerGroups and Groups", func(t *testing.T) { + drainPeerUpdates(updMsg) + + route := route.Route{ + ID: "testingRoute2", + Network: netip.MustParsePrefix("192.0.2.0/32"), + NetID: "superNet", + NetworkType: route.IPv4Network, + PeerGroups: []string{routeGroup3}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{routeGroup3}, + } + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + _, err := manager.CreateRoute( + context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, + route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, + route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, + ) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + + }) + + baseRoute := route.Route{ + ID: "testingRoute3", + Network: netip.MustParsePrefix("192.168.0.0/16"), NetID: "superNet", NetworkType: route.IPv4Network, - PeerGroups: []string{"groupA"}, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{"groupA"}, - } - - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err := manager.CreateRoute( - context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, - route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, - route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, - ) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - - }) - - // Creating a route with no routing peer and having peers in groups that don't include peer1 should not send peer1 an update - t.Run("creating a route with peers in PeerGroups and Groups", func(t *testing.T) { - drainPeerUpdates(updMsg) - - route := route.Route{ - ID: "testingRoute2", - Network: netip.MustParsePrefix("192.0.2.0/32"), - NetID: "superNet", - NetworkType: route.IPv4Network, - PeerGroups: []string{routeGroup3}, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{routeGroup3}, - } - - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err := manager.CreateRoute( - context.Background(), account.Id, route.Network, route.NetworkType, route.Domains, route.Peer, - route.PeerGroups, route.Description, route.NetID, route.Masquerade, route.Metric, - route.Groups, []string{}, true, userID, route.KeepRoute, route.SkipAutoApply, - ) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - - }) - - baseRoute := route.Route{ - ID: "testingRoute3", - Network: netip.MustParsePrefix("192.168.0.0/16"), - NetID: "superNet", - NetworkType: route.IPv4Network, - Peer: peer1ID, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{routeGroup1}, - } - - // Creating route should update account peers and send peer update - t.Run("creating route with a routing peer", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - newRoute, err := manager.CreateRoute( - context.Background(), account.Id, baseRoute.Network, baseRoute.NetworkType, baseRoute.Domains, baseRoute.Peer, - baseRoute.PeerGroups, baseRoute.Description, baseRoute.NetID, baseRoute.Masquerade, baseRoute.Metric, - baseRoute.Groups, []string{}, true, userID, baseRoute.KeepRoute, !baseRoute.SkipAutoApply, - ) - require.NoError(t, err) - baseRoute = *newRoute - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Updating the route should update account peers and send peer update when there is peers in group - t.Run("updating route", func(t *testing.T) { - baseRoute.Groups = []string{routeGroup1, routeGroup2} - - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.SaveRoute(context.Background(), account.Id, userID, &baseRoute) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Deleting the route should update account peers and send peer update - t.Run("deleting route", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - err := manager.DeleteRoute(context.Background(), account.Id, baseRoute.ID, userID) - require.NoError(t, err) - - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) - - // Adding peer to route peer groups that do not have any peers should update account peers and send peer update - t.Run("adding peer to route peer groups that do not have any peers", func(t *testing.T) { - newRoute := route.Route{ - Network: netip.MustParsePrefix("192.168.12.0/16"), - NetID: "superNet", - NetworkType: route.IPv4Network, - PeerGroups: []string{"groupB"}, + Peer: peer1ID, Description: "super", Masquerade: false, Metric: 9999, Enabled: true, Groups: []string{routeGroup1}, } - _, err := manager.CreateRoute( - context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, - newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, - newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, !newRoute.SkipAutoApply, - ) - require.NoError(t, err) - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Creating route should update account peers and send peer update + step(t, "creating route with a routing peer", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupB", - Name: "GroupB", - Peers: []string{peer1ID}, + newRoute, err := manager.CreateRoute( + context.Background(), account.Id, baseRoute.Network, baseRoute.NetworkType, baseRoute.Domains, baseRoute.Peer, + baseRoute.PeerGroups, baseRoute.Description, baseRoute.NetID, baseRoute.Masquerade, baseRoute.Metric, + baseRoute.Groups, []string{}, true, userID, baseRoute.KeepRoute, !baseRoute.SkipAutoApply, + ) + require.NoError(t, err) + baseRoute = *newRoute + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // Updating the route should update account peers and send peer update when there is peers in group + step(t, "updating route", func(t *testing.T) { + baseRoute.Groups = []string{routeGroup1, routeGroup2} - // Adding peer to route groups that do not have any peers should update account peers and send peer update - t.Run("adding peer to route groups that do not have any peers", func(t *testing.T) { - newRoute := route.Route{ - Network: netip.MustParsePrefix("192.168.13.0/16"), - NetID: "superNet", - NetworkType: route.IPv4Network, - PeerGroups: []string{"groupB"}, - Description: "super", - Masquerade: false, - Metric: 9999, - Enabled: true, - Groups: []string{"groupC"}, - } - _, err := manager.CreateRoute( - context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, - newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, - newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, !newRoute.SkipAutoApply, - ) - require.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + err := manager.SaveRoute(context.Background(), account.Id, userID, &baseRoute) + require.NoError(t, err) - err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupC", - Name: "GroupC", - Peers: []string{peer1ID}, + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } }) - assert.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } + // Deleting the route should update account peers and send peer update + step(t, "deleting route", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.DeleteRoute(context.Background(), account.Id, baseRoute.ID, userID) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Adding peer to route peer groups that do not have any peers should update account peers and send peer update + step(t, "adding peer to route peer groups that do not have any peers", func(t *testing.T) { + newRoute := route.Route{ + Network: netip.MustParsePrefix("192.168.12.0/16"), + NetID: "superNet", + NetworkType: route.IPv4Network, + PeerGroups: []string{"groupB"}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{routeGroup1}, + } + _, err := manager.CreateRoute( + context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, + newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, + newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, !newRoute.SkipAutoApply, + ) + require.NoError(t, err) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupB", + Name: "GroupB", + Peers: []string{peer1ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + // Adding peer to route groups that do not have any peers should update account peers and send peer update + step(t, "adding peer to route groups that do not have any peers", func(t *testing.T) { + newRoute := route.Route{ + Network: netip.MustParsePrefix("192.168.13.0/16"), + NetID: "superNet", + NetworkType: route.IPv4Network, + PeerGroups: []string{"groupB"}, + Description: "super", + Masquerade: false, + Metric: 9999, + Enabled: true, + Groups: []string{"groupC"}, + } + _, err := manager.CreateRoute( + context.Background(), account.Id, newRoute.Network, newRoute.NetworkType, newRoute.Domains, newRoute.Peer, + newRoute.PeerGroups, newRoute.Description, newRoute.NetID, newRoute.Masquerade, newRoute.Metric, + newRoute.Groups, []string{}, true, userID, newRoute.KeepRoute, !newRoute.SkipAutoApply, + ) + require.NoError(t, err) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err = manager.UpdateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupC", + Name: "GroupC", + Peers: []string{peer1ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) }) } diff --git a/management/server/setupkey_test.go b/management/server/setupkey_test.go index 2d43ea28b..87959113d 100644 --- a/management/server/setupkey_test.go +++ b/management/server/setupkey_test.go @@ -397,75 +397,77 @@ func TestSetupKey_Copy(t *testing.T) { } func TestSetupKeyAccountPeersUpdate(t *testing.T) { - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, - }) - assert.NoError(t, err) - - policy := &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"group"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - } - _, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) - require.NoError(t, err) - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // The setup policy above dispatches affected-peer updates asynchronously; drain - // any in-flight ones so the assertions only observe the setup-key operations. - settleAffectedUpdates(updMsg) - - var setupKey *types.SetupKey - - // Creating setup key should not update account peers and not send peer update - t.Run("creating setup key", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - setupKey, err = manager.CreateSetupKey(context.Background(), account.Id, "key1", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }) assert.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") + policy := &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"group"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, } - }) - - // Saving setup key should not update account peers and not send peer update - t.Run("saving setup key", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.SaveSetupKey(context.Background(), account.Id, setupKey, userID) + _, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) require.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) + + // The setup policy above dispatches affected-peer updates asynchronously; drain + // any in-flight ones so the assertions only observe the setup-key operations. + settleAffectedUpdates(updMsg) + + var setupKey *types.SetupKey + + // Creating setup key should not update account peers and not send peer update + step(t, "creating setup key", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + setupKey, err = manager.CreateSetupKey(context.Background(), account.Id, "key1", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + // Saving setup key should not update account peers and not send peer update + step(t, "saving setup key", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + _, err = manager.SaveSetupKey(context.Background(), account.Id, setupKey, userID) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) }) } diff --git a/management/server/user_test.go b/management/server/user_test.go index 2d1a5f1e9..fcfb7a1e7 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -1544,166 +1544,170 @@ func TestDefaultAccountManager_SaveUser(t *testing.T) { } func TestUserAccountPeersUpdate(t *testing.T) { - // account groups propagation is enabled - manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + runPeerUpdateTest(t, func(t *testing.T) { + // account groups propagation is enabled + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) - err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ - ID: "groupA", - Name: "GroupA", - Peers: []string{peer1.ID, peer2.ID, peer3.ID}, - }) - require.NoError(t, err) + err := manager.CreateGroup(context.Background(), account.Id, userID, &types.Group{ + ID: "groupA", + Name: "GroupA", + Peers: []string{peer1.ID, peer2.ID, peer3.ID}, + }) + require.NoError(t, err) - policy := &types.Policy{ - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupA"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, + policy := &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupA"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, }, - }, - } - _, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) - require.NoError(t, err) - - updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer1.ID) - }) - - // Creating a new regular user should send peer update (as users are not filtered yet) - t.Run("creating new regular user with no groups", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() - - _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ - Id: "regularUser1", - AccountID: account.Id, - Role: types.UserRoleUser, - Issued: types.UserIssuedAPI, - }, true) + } + _, err = manager.SavePolicy(context.Background(), account.Id, userID, policy, true) require.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) - // updating user with no linked peers should update account peers and send peer update (as users are not filtered yet) - t.Run("updating user with no linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + // Creating a new regular user should send peer update (as users are not filtered yet) + step(t, "creating new regular user with no groups", func(t *testing.T) { + settleAffectedUpdates(updMsg) - _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ - Id: "regularUser1", - AccountID: account.Id, - Role: types.UserRoleUser, - Issued: types.UserIssuedAPI, - }, false) - require.NoError(t, err) + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ + Id: "regularUser1", + AccountID: account.Id, + Role: types.UserRoleUser, + Issued: types.UserIssuedAPI, + }, true) + require.NoError(t, err) - // drain any buffered updates from previous subtests - drainPeerUpdates(updMsg) + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - // deleting user with no linked peers should not update account peers and not send peer update - t.Run("deleting user with no linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldNotReceiveUpdate(t, updMsg) - close(done) - }() + // saving an unchanged user with no linked peers should not update account peers and not send peer update + step(t, "updating user with no linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - err = manager.DeleteUser(context.Background(), account.Id, userID, "regularUser1") - require.NoError(t, err) + _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ + Id: "regularUser1", + AccountID: account.Id, + Role: types.UserRoleUser, + Issued: types.UserIssuedAPI, + }, false) + require.NoError(t, err) - select { - case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") - } - }) + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) - // create a user and add new peer with the user - _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ - Id: "regularUser2", - AccountID: account.Id, - Role: types.UserRoleAdmin, - Issued: types.UserIssuedAPI, - }, true) - require.NoError(t, err) + // drain any buffered updates from previous subtests + drainPeerUpdates(updMsg) - key, err := wgtypes.GeneratePrivateKey() - require.NoError(t, err) + // deleting user with no linked peers should not update account peers and not send peer update + step(t, "deleting user with no linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() - expectedPeerKey := key.PublicKey().String() - peer4, _, _, _, err := manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ - Key: expectedPeerKey, - Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, - }, false) - require.NoError(t, err) + err = manager.DeleteUser(context.Background(), account.Id, userID, "regularUser1") + require.NoError(t, err) - // updating user with linked peers should update account peers and send peer update - t.Run("updating user with linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, updMsg) - close(done) - }() + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + // create a user and add new peer with the user _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ Id: "regularUser2", AccountID: account.Id, Role: types.UserRoleAdmin, Issued: types.UserIssuedAPI, + }, true) + require.NoError(t, err) + + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + expectedPeerKey := key.PublicKey().String() + peer4, _, _, _, err := manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ + Key: expectedPeerKey, + Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, }, false) require.NoError(t, err) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } - }) + // updating user with linked peers should update account peers and send peer update + step(t, "updating user with linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() - peer4UpdMsg := updateManager.CreateChannel(context.Background(), peer4.ID) - t.Cleanup(func() { - updateManager.CloseChannel(context.Background(), peer4.ID) - }) + _, err = manager.SaveOrAddUser(context.Background(), account.Id, userID, &types.User{ + Id: "regularUser2", + AccountID: account.Id, + Role: types.UserRoleAdmin, + Issued: types.UserIssuedAPI, + }, false) + require.NoError(t, err) - // deleting user with linked peers should update account peers and send peer update - t.Run("deleting user with linked peers", func(t *testing.T) { - done := make(chan struct{}) - go func() { - peerShouldReceiveUpdate(t, peer4UpdMsg) - close(done) - }() + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) - err = manager.DeleteUser(context.Background(), account.Id, userID, "regularUser2") - require.NoError(t, err) + peer4UpdMsg := updateManager.CreateChannel(context.Background(), peer4.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer4.ID) + }) - select { - case <-done: - case <-time.After(peerUpdateTimeout): - t.Error("timeout waiting for peerShouldReceiveUpdate") - } + // deleting user with linked peers should update account peers and send peer update + step(t, "deleting user with linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, peer4UpdMsg) + close(done) + }() + + err = manager.DeleteUser(context.Background(), account.Id, userID, "regularUser2") + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) }) } From c5aa55d2b911f85fc8d2e7b8c6206be8e7dcdcd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Xos=C3=A9=20M=C3=A9ndez=20Taboada?= Date: Tue, 6 Oct 2026 15:05:01 +0200 Subject: [PATCH 111/209] [client] feat(i18n): Add Galician (gl) localization for desktop client (#7041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Galician (gl) localization for desktop client * [client] Address review feedback and sync latest i18n keys for Galician Fix terms per review feedback (usarase, creen, auditar, JWT cache, poscuántico, etc.) and translate newly added upstream i18n keys. * [client] Fix translations, polish terminology, and sync latest keys for Galician * [client] Fix i18n parity against branch source of truth and drop undeclared uk * [ci] Skip Galician locales in codespell check * [client] Add latest error keys for Galician and revert _index.json formatting Add error.settings_locked and error.settings_managed_by_mdm to Galician locale for 100% key parity with en/common.json. Revert multi-line formatting in _index.json to keep a single-line entry for Galician. --- .github/workflows/golangci-lint.yml | 2 +- client/ui/i18n/locales/_index.json | 3 +- client/ui/i18n/locales/gl/common.json | 1397 +++++++++++++++++++++++++ 3 files changed, 1400 insertions(+), 2 deletions(-) create mode 100644 client/ui/i18n/locales/gl/common.json diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index ff36a0854..843177b88 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -30,7 +30,7 @@ jobs: # segment by codespell and behave the same across versions; the # recursive "**" form did not take effect with the codespell shipped # by this action. - skip: go.mod,go.sum,*/proxy/web/*,*pnpm-lock.yaml,*package-lock.json,*/locales/de/*,*/locales/es/*,*/locales/fr/*,*/locales/hu/*,*/locales/it/*,*/locales/pt/*,*/locales/ru/*,*/locales/zh-CN/*,*/i18n/TRANSLATING.md + skip: go.mod,go.sum,*/proxy/web/*,*pnpm-lock.yaml,*package-lock.json,*/locales/de/*,*/locales/es/*,*/locales/fr/*,*/locales/gl/*,*/locales/hu/*,*/locales/it/*,*/locales/pt/*,*/locales/ru/*,*/locales/zh-CN/*,*/i18n/TRANSLATING.md golangci: strategy: fail-fast: false diff --git a/client/ui/i18n/locales/_index.json b/client/ui/i18n/locales/_index.json index 17fb1d8ea..a52ff5929 100644 --- a/client/ui/i18n/locales/_index.json +++ b/client/ui/i18n/locales/_index.json @@ -10,6 +10,7 @@ {"code": "it", "displayName": "Italiano", "englishName": "Italian"}, {"code": "pt", "displayName": "Português", "englishName": "Portuguese"}, {"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"}, - {"code": "ja", "displayName": "日本語", "englishName": "Japanese"} + {"code": "ja", "displayName": "日本語", "englishName": "Japanese"}, + {"code": "gl", "displayName": "Galego", "englishName": "Galician"} ] } diff --git a/client/ui/i18n/locales/gl/common.json b/client/ui/i18n/locales/gl/common.json new file mode 100644 index 000000000..2b948104e --- /dev/null +++ b/client/ui/i18n/locales/gl/common.json @@ -0,0 +1,1397 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "Desconectado" + }, + "tray.status.daemonUnavailable": { + "message": "Non está en execución" + }, + "tray.status.error": { + "message": "Erro" + }, + "tray.status.connected": { + "message": "Conectado" + }, + "tray.status.connecting": { + "message": "Conectando" + }, + "tray.status.needsLogin": { + "message": "Inicio de sesión requirido" + }, + "tray.status.loginFailed": { + "message": "Erro ao iniciar sesión" + }, + "tray.status.sessionExpired": { + "message": "Sesión caducada" + }, + "tray.session.expiresIn": { + "message": "A sesión caduca en {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "menos dun minuto" + }, + "tray.session.unit.minute": { + "message": "1 minuto" + }, + "tray.session.unit.minutes": { + "message": "{count} minutos" + }, + "tray.session.unit.hour": { + "message": "1 hora" + }, + "tray.session.unit.hours": { + "message": "{count} horas" + }, + "tray.session.unit.day": { + "message": "1 día" + }, + "tray.session.unit.days": { + "message": "{count} días" + }, + "tray.menu.open": { + "message": "Abrir NetBird" + }, + "tray.menu.connect": { + "message": "Conectar" + }, + "tray.menu.disconnect": { + "message": "Desconectar" + }, + "tray.menu.exitNode": { + "message": "Nodo de saída" + }, + "tray.menu.networks": { + "message": "Recursos" + }, + "tray.menu.profiles": { + "message": "Perfís" + }, + "tray.menu.manageProfiles": { + "message": "Xestionar perfís" + }, + "tray.menu.settings": { + "message": "Configuración..." + }, + "tray.menu.debugBundle": { + "message": "Crear paquete de depuración" + }, + "tray.menu.about": { + "message": "Axuda e soporte" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "Documentación" + }, + "tray.menu.troubleshoot": { + "message": "Resolución de problemas" + }, + "tray.menu.downloadLatest": { + "message": "Descargar a última versión" + }, + "tray.menu.installVersion": { + "message": "Instalar versión {version}" + }, + "tray.menu.guiVersion": { + "message": "GUI: {version}" + }, + "tray.menu.daemonVersion": { + "message": "Daemon: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "Saír de NetBird" + }, + "notify.daemonOutdated.title": { + "message": "O servizo NetBird está desactualizado" + }, + "notify.daemonOutdated.body": { + "message": "Actualice o servizo NetBird para usar esta aplicación." + }, + "notify.update.title": { + "message": "Actualización de NetBird dispoñible" + }, + "notify.update.body": { + "message": "NetBird {version} está dispoñible." + }, + "notify.update.enforcedSuffix": { + "message": " O seu administrador require esta actualización." + }, + "notify.error.title": { + "message": "Erro" + }, + "notify.error.connect": { + "message": "Erro ao conectar" + }, + "notify.error.disconnect": { + "message": "Erro ao desconectar" + }, + "notify.error.switchProfile": { + "message": "Erro ao cambiar a {profile}" + }, + "notify.error.exitNode": { + "message": "Erro ao actualizar o nodo de saída {name}" + }, + "notify.sessionExpired.title": { + "message": "Sesión caducada" + }, + "notify.sessionExpired.body": { + "message": "A súa sesión caducou. Volva iniciar sesión para conectar." + }, + "notify.sessionWarning.title": { + "message": "A sesión caduca en breve" + }, + "notify.sessionWarning.body": { + "message": "A súa sesión de NetBird caduca en {remaining}. Prema en Prolongar agora para renovar." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "A súa sesión de NetBird está a punto de caducar. Prema en Prolongar agora para renovar." + }, + "notify.sessionWarning.extend": { + "message": "Prolongar agora" + }, + "notify.sessionWarning.dismiss": { + "message": "Descartar" + }, + "notify.sessionWarning.failed": { + "message": "Erro ao prolongar a sesión de NetBird" + }, + "notify.sessionWarning.successTitle": { + "message": "Sesión de NetBird prolongada" + }, + "notify.sessionWarning.successBody": { + "message": "A súa sesión foi renovada." + }, + "notify.sessionDeadlineRejected.title": { + "message": "Data límite de sesión rexeitada" + }, + "notify.sessionDeadlineRejected.body": { + "message": "O servidor enviou unha data límite de sesión non válida. Por favor, inicie sesión de novo." + }, + "notify.mdm.policyApplied.title": { + "message": "Configuración de NetBird actualizada" + }, + "notify.mdm.policyApplied.body": { + "message": "A súa configuración de NetBird foi actualizada pola súa política de IT." + }, + "common.cancel": { + "message": "Cancelar" + }, + "common.save": { + "message": "Gardar" + }, + "common.saveChanges": { + "message": "Gardar cambios" + }, + "common.saving": { + "message": "Gardando…" + }, + "common.close": { + "message": "Pechar" + }, + "common.copy": { + "message": "Copiar" + }, + "common.togglePasswordVisibility": { + "message": "Amosar ou ocultar o contrasinal" + }, + "common.increase": { + "message": "Aumentar" + }, + "common.decrease": { + "message": "Diminuír" + }, + "common.delete": { + "message": "Eliminar" + }, + "common.create": { + "message": "Crear" + }, + "common.add": { + "message": "Engadir" + }, + "common.remove": { + "message": "Quitar" + }, + "common.refresh": { + "message": "Actualizar" + }, + "common.loading": { + "message": "Cargando…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "Non se puido atopar ningún resultado" + }, + "common.noResults.description": { + "message": "Non puidemos atopar ningún resultado. Por favor, tente cun termo de busca diferente ou cambie os seus filtros." + }, + "notConnected.title": { + "message": "Sen conexión" + }, + "notConnected.description": { + "message": "Conéctese primeiro a NetBird para ver información detallada sobre os seus peers, recursos de rede e nodos de saída." + }, + "connect.status.disconnected": { + "message": "Desconectado" + }, + "connect.status.connecting": { + "message": "Establecendo conexión..." + }, + "connect.status.connected": { + "message": "Conectado á rede NetBird" + }, + "connect.status.disconnecting": { + "message": "Desconectando..." + }, + "connect.status.daemonUnavailable": { + "message": "Daemon non dispoñible" + }, + "connect.status.loginRequired": { + "message": "Inicie sesión para conectar" + }, + "connect.error.loginTitle": { + "message": "Erro ao iniciar sesión" + }, + "connect.error.connectTitle": { + "message": "Erro ao conectar" + }, + "connect.error.disconnectTitle": { + "message": "Erro ao desconectar" + }, + "nav.peers.title": { + "message": "Peers" + }, + "nav.peers.description": { + "message": "{connected} de {total} conectados" + }, + "nav.resources.title": { + "message": "Recursos" + }, + "nav.resources.description": { + "message": "{active} de {total} activos" + }, + "nav.exitNode.title": { + "message": "Nodos de saída" + }, + "nav.exitNode.none": { + "message": "Non activo" + }, + "nav.exitNode.using": { + "message": "Vía {name}" + }, + "header.openSettings": { + "message": "Abrir configuración" + }, + "header.togglePanel": { + "message": "Alternar panel lateral" + }, + "profile.selector.loading": { + "message": "Cargando..." + }, + "profile.selector.noProfile": { + "message": "Sen perfil" + }, + "profile.selector.searchPlaceholder": { + "message": "Buscar perfil por nome..." + }, + "profile.selector.emptyTitle": { + "message": "Non se atoparon perfís" + }, + "profile.selector.emptyDescription": { + "message": "Probe cun termo de busca diferente ou cree un novo perfil." + }, + "profile.selector.newProfile": { + "message": "Novo perfil" + }, + "profile.selector.moreOptions": { + "message": "Máis opcións" + }, + "profile.selector.deregister": { + "message": "Dar de baixa" + }, + "profile.selector.delete": { + "message": "Eliminar" + }, + "profile.selector.switchTo": { + "message": "Cambiar a este perfil" + }, + "profile.selector.edit": { + "message": "Editar" + }, + "profile.edit.title": { + "message": "Editar perfil" + }, + "profile.edit.submit": { + "message": "Gardar cambios" + }, + "profile.dialog.title": { + "message": "Introduza o nome do perfil" + }, + "profile.dialog.nameLabel": { + "message": "Nome do perfil" + }, + "profile.dialog.description": { + "message": "Estableza un nome facilmente identificable para o seu perfil." + }, + "profile.dialog.placeholder": { + "message": "p. ex. Traballo" + }, + "profile.dialog.submit": { + "message": "Engadir perfil" + }, + "profile.dialog.required": { + "message": "Por favor, introduza un nome de perfil, p. ex. traballo, casa" + }, + "profile.dialog.managementHelp": { + "message": "Use NetBird Cloud ou o seu propio servidor." + }, + "profile.dialog.urlUnreachable": { + "message": "Non se puido acadar este servidor. Comprobe o URL ou engada o perfil de todas formas se ten a certeza de que é correcto." + }, + "header.menu.settings": { + "message": "Configuración..." + }, + "header.menu.defaultView": { + "message": "Vista predeterminada" + }, + "header.menu.advancedView": { + "message": "Vista avanzada" + }, + "header.menu.updateAvailable": { + "message": "Actualización dispoñible" + }, + "header.menu.open": { + "message": "Abrir menú" + }, + "header.profile.switch": { + "message": "Cambiar de perfil" + }, + "connect.toggle.label": { + "message": "Alternar conexión de NetBird" + }, + "connect.localIp.label": { + "message": "Enderezos IP locais" + }, + "common.search": { + "message": "Buscar" + }, + "common.filter": { + "message": "Filtrar" + }, + "exitNodes.dropdown.trigger": { + "message": "Seleccionar nodo de saída" + }, + "peers.row.label": { + "message": "Abrir detalles de {name}, {status}" + }, + "peers.dialog.title": { + "message": "Detalles do peer" + }, + "networks.row.toggle": { + "message": "Alternar {name}" + }, + "networks.bulk.label": { + "message": "Alternar todos os recursos visibles" + }, + "profile.switch.title": { + "message": "Cambiar ao perfil «{name}»?" + }, + "profile.switch.message": { + "message": "Ten a certeza de que quere cambiar de perfil?\nO seu perfil actual desconectarase." + }, + "profile.switch.confirm": { + "message": "Confirmar" + }, + "profile.deregister.title": { + "message": "Dar de baixa o perfil «{name}»?" + }, + "profile.deregister.message": { + "message": "Ten a certeza de que quere dar de baixa este perfil?\nTerá que iniciar sesión de novo para usalo." + }, + "profile.deregister.confirm": { + "message": "Dar de baixa" + }, + "profile.delete.title": { + "message": "Eliminar o perfil «{name}»?" + }, + "profile.delete.message": { + "message": "Ten a certeza de que quere eliminar este perfil?\nEsta acción non se pode desfacer." + }, + "profile.delete.disabledActive": { + "message": "Os perfís activos non se poden eliminar. Cambie a un perfil diferente antes de eliminar este perfil." + }, + "profile.delete.disabledDefault": { + "message": "O perfil predeterminado non se pode eliminar." + }, + "profile.error.switchTitle": { + "message": "Erro ao cambiar de perfil" + }, + "profile.error.deregisterTitle": { + "message": "Erro ao dar de baixa o perfil" + }, + "profile.error.deleteTitle": { + "message": "Erro ao eliminar o perfil" + }, + "profile.error.createTitle": { + "message": "Erro ao crear o perfil" + }, + "profile.error.editTitle": { + "message": "Erro ao editar o perfil" + }, + "profile.error.loadTitle": { + "message": "Erro ao cargar os perfís" + }, + "profile.dropdown.activeProfile": { + "message": "Perfil activo" + }, + "profile.dropdown.switchProfile": { + "message": "Cambiar de perfil" + }, + "profile.dropdown.noEmail": { + "message": "Outro" + }, + "profile.dropdown.addProfile": { + "message": "Engadir perfil" + }, + "profile.dropdown.manageProfiles": { + "message": "Xestionar perfís" + }, + "profile.dropdown.settings": { + "message": "Configuración" + }, + "settings.profiles.section.profiles": { + "message": "Perfís" + }, + "settings.profiles.intro": { + "message": "Manteña identidades de NetBird separadas lado a lado, por exemplo contas de traballo e persoais, ou diferentes servidores de xestión. Engada, dea de baixa ou elimine perfís a continuación." + }, + "settings.profiles.addProfile": { + "message": "Engadir perfil" + }, + "settings.profiles.active": { + "message": "Activo" + }, + "settings.profiles.emptyTitle": { + "message": "Sen perfís" + }, + "settings.profiles.emptyDescription": { + "message": "Cree un perfil para conectarse a un servidor de xestión de NetBird." + }, + "settings.error.loadTitle": { + "message": "Erro ao cargar a configuración" + }, + "settings.error.saveTitle": { + "message": "Erro ao gardar a configuración" + }, + "settings.error.debugBundleTitle": { + "message": "Erro no paquete de depuración" + }, + "settings.nav.label": { + "message": "Seccións de configuración" + }, + "settings.tabs.general": { + "message": "Xeral" + }, + "settings.tabs.network": { + "message": "Rede" + }, + "settings.tabs.security": { + "message": "Seguridade" + }, + "settings.tabs.profiles": { + "message": "Perfís" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "Avanzado" + }, + "settings.tabs.troubleshooting": { + "message": "Resolución de problemas" + }, + "settings.tabs.about": { + "message": "Acerca de" + }, + "settings.tabs.updateAvailable": { + "message": "Actualización dispoñible" + }, + "settings.general.section.general": { + "message": "Xeral" + }, + "settings.general.section.connection": { + "message": "Conexión" + }, + "settings.general.connectOnStartup.label": { + "message": "Conectar ao iniciar" + }, + "settings.general.connectOnStartup.help": { + "message": "Establece a conexión automaticamente cando se inicia o servizo." + }, + "settings.general.notifications.label": { + "message": "Notificacións de escritorio" + }, + "settings.general.notifications.help": { + "message": "Amosar notificacións de escritorio para novas actualizacións e eventos de conexión." + }, + "settings.general.autostart.label": { + "message": "Iniciar UI de NetBird ao acceder" + }, + "settings.general.autostart.help": { + "message": "Inicie a interface de NetBird automaticamente cando inicie sesión. Isto afecta só á interface gráfica, non ao servizo en segundo plano." + }, + "settings.general.autostart.errorTitle": { + "message": "Erro ao cambiar o inicio automático" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Manter conectado ao pechar" + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "Mantén a conexión de NetBird en segundo plano cando se pecha a xanela principal." + }, + "settings.general.language.label": { + "message": "Idioma da interface" + }, + "settings.general.language.help": { + "message": "Escolla o idioma no que se amosa a aplicación NetBird." + }, + "settings.general.language.search": { + "message": "Buscar idioma…" + }, + "settings.general.language.empty": { + "message": "Ningún idioma coincide." + }, + "settings.general.theme.label": { + "message": "Tema" + }, + "settings.general.theme.help": { + "message": "Escolla claro ou escuro, ou siga a aparencia do seu sistema." + }, + "settings.general.theme.system": { + "message": "Sistema" + }, + "settings.general.theme.light": { + "message": "Claro" + }, + "settings.general.theme.dark": { + "message": "Escuro" + }, + "settings.general.management.label": { + "message": "Servidor de xestión" + }, + "settings.general.management.help": { + "message": "Conéctese a NetBird Cloud ou ao seu propio servidor de xestión autoaloxado. Os cambios volverán conectar o cliente." + }, + "settings.general.management.cloud": { + "message": "Cloud" + }, + "settings.general.management.selfHosted": { + "message": "Autoaloxado" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "Por favor, introduza un URL válido, p. ex. https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "Non se puido acadar este servidor. Comprobe o URL ou garde de todas formas se ten a certeza de que é correcto." + }, + "settings.general.management.switchCloudTitle": { + "message": "Cambiar a NetBird Cloud?" + }, + "settings.general.management.switchCloudMessage": { + "message": "Isto desconecta o seu servidor autoaloxado.\nPode que teña que iniciar sesión de novo." + }, + "settings.general.management.switchCloudConfirm": { + "message": "Cambiar a Cloud" + }, + "settings.network.section.connectivity": { + "message": "Conectividade" + }, + "settings.network.section.routingDns": { + "message": "Encamiñamento e DNS" + }, + "settings.network.monitor.label": { + "message": "Reconectar ao cambiar de rede" + }, + "settings.network.monitor.help": { + "message": "Supervisa a rede e reconéctase automaticamente ante cambios como o cambio de Wi-Fi ou o reinicio tras a suspensión." + }, + "settings.network.dns.label": { + "message": "Activar DNS" + }, + "settings.network.dns.help": { + "message": "Aplica a configuración DNS xestionada por NetBird ao resolutor do sistema." + }, + "settings.network.clientRoutes.label": { + "message": "Activar rutas de cliente" + }, + "settings.network.clientRoutes.help": { + "message": "Acepta rutas doutros peers para acadar as súas redes." + }, + "settings.network.serverRoutes.label": { + "message": "Activar rutas de servidor" + }, + "settings.network.serverRoutes.help": { + "message": "Anuncia as rutas locais deste equipo a outros peers." + }, + "settings.network.ipv6.label": { + "message": "Activar IPv6" + }, + "settings.network.ipv6.help": { + "message": "Use o enderezamento IPv6 para a rede superposta de NetBird." + }, + "settings.security.section.firewall": { + "message": "Cortafogos" + }, + "settings.security.section.encryption": { + "message": "Cifrado" + }, + "settings.security.blockInbound.label": { + "message": "Bloquear tráfico entrante" + }, + "settings.security.blockInbound.help": { + "message": "Rexeita conexións non solicitadas de peers a este dispositivo e a calquera rede que encamiñe. O tráfico saínte non se ve afectado." + }, + "settings.security.blockLan.label": { + "message": "Bloquear acceso a LAN" + }, + "settings.security.blockLan.help": { + "message": "Evita que os peers acaden a súa rede local ou os seus dispositivos cando este dispositivo encamiñe o seu tráfico." + }, + "settings.security.rosenpass.label": { + "message": "Activar resistencia cuántica" + }, + "settings.security.rosenpass.help": { + "message": "Engade un intercambio de chaves poscuántico mediante Rosenpass sobre WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "Activar modo permisivo" + }, + "settings.security.rosenpassPermissive.help": { + "message": "Permite conexións con peers que non teñan soporte de resistencia cuántica." + }, + "settings.ssh.section.server": { + "message": "Servidor" + }, + "settings.ssh.section.capabilities": { + "message": "Capacidades" + }, + "settings.ssh.section.authentication": { + "message": "Autenticación" + }, + "settings.ssh.server.label": { + "message": "Activar servidor SSH" + }, + "settings.ssh.server.help": { + "message": "Executa o servidor SSH de NetBird neste equipo para que outros peers poidan conectarse a el." + }, + "settings.ssh.root.label": { + "message": "Permitir inicio de sesión de root" + }, + "settings.ssh.root.help": { + "message": "Permite que os peers inicien sesión como usuario root. Desactíveo para requirir unha conta sen privilexios." + }, + "settings.ssh.sftp.label": { + "message": "Permitir SFTP" + }, + "settings.ssh.sftp.help": { + "message": "Transfire ficheiros de xeito seguro usando clientes nativos de SFTP ou SCP." + }, + "settings.ssh.localForward.label": { + "message": "Reenvío de portos locais" + }, + "settings.ssh.localForward.help": { + "message": "Permite que os peers conectados fagan túneles de portos locais cara a servizos accesibles desde este equipo." + }, + "settings.ssh.remoteForward.label": { + "message": "Reenvío de portos remotos" + }, + "settings.ssh.remoteForward.help": { + "message": "Permite que os peers conectados expoñan portos neste equipo cara á súa propia máquina." + }, + "settings.ssh.jwt.label": { + "message": "Activar autenticación JWT" + }, + "settings.ssh.jwt.help": { + "message": "Verifica cada sesión SSH co seu IdP para a identidade do usuario e auditar. Desactíveo para depender só de políticas ACL de rede, útil cando non hai IdP dispoñible." + }, + "settings.ssh.jwtTtl.label": { + "message": "TTL da caché JWT" + }, + "settings.ssh.jwtTtl.help": { + "message": "Canto tempo almacena este cliente un JWT na caché antes de solicitalo de novo nas conexións SSH saíntes. Estableza en 0 para desactivar a caché e autenticar en cada conexión." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "Segundo(s)" + }, + "settings.advanced.section.interface": { + "message": "Interface" + }, + "settings.advanced.section.security": { + "message": "Seguridade" + }, + "settings.advanced.interfaceName.label": { + "message": "Nome" + }, + "settings.advanced.interfaceName.error": { + "message": "Use entre 1 e 15 letras, díxitos, puntos, guións ou guións baixos." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "Debe comezar por \"utun\" seguido dun número (p. ex. utun100)." + }, + "settings.advanced.port.label": { + "message": "Porto" + }, + "settings.advanced.port.error": { + "message": "Introduza un porto entre {min} e {max}." + }, + "settings.advanced.port.help": { + "message": "Se se establece en 0, usarase un porto libre ao azar." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "Introduza un valor de MTU entre {min} e {max}." + }, + "settings.advanced.psk.label": { + "message": "Chave precompartida" + }, + "settings.advanced.psk.help": { + "message": "Chave PSK opcional de WireGuard para cifrado simétrico adicional. Non é o mesmo que unha chave de configuración de NetBird. Só se comunicará con peers que usen a mesma chave precompartida." + }, + "settings.troubleshooting.section.title": { + "message": "Paquete de depuración" + }, + "settings.troubleshooting.anonymize.label": { + "message": "Anonimizar información confidencial" + }, + "settings.troubleshooting.anonymize.help": { + "message": "Oculta enderezos IP públicos e dominios que non sexan de NetBird nos rexistros." + }, + "settings.troubleshooting.anonymize.info": { + "message": "O nivel predeterminado mantén lexibles os enderezos IPv4 internos e os nomes dos peers para o soporte. O nivel estrito anonimiza ademais os enderezos IP privados (RFC 1918), CGNAT e de ligazón local, os nomes dos peers e as chaves públicas de WireGuard. Os valores recorrentes asígnanse ao mesmo marcador, polo que os peers seguen sendo distinguibles. Use Estrito cando comparta o paquete fóra da súa organización." + }, + "settings.troubleshooting.anonymize.none": { + "message": "Ningunha" + }, + "settings.troubleshooting.anonymize.default": { + "message": "Predeterminada" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "Estrita" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "Incluír información do sistema" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "Inclúe SO, kernel, interfaces de rede e táboas de encamiñamento." + }, + "settings.troubleshooting.upload.label": { + "message": "Subir paquete aos servidores de NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "Devolve unha chave de subida para compartir co soporte de NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "Activar rexistros de traza" + }, + "settings.troubleshooting.trace.help": { + "message": "Eleva o nivel de rexistro a TRACE e restaúrao despois." + }, + "settings.troubleshooting.capture.label": { + "message": "Sesión de captura" + }, + "settings.troubleshooting.capture.help": { + "message": "Volve conectar e agarda para que poida reproducir a incidencia." + }, + "settings.troubleshooting.packets.label": { + "message": "Capturar paquetes de rede" + }, + "settings.troubleshooting.packets.help": { + "message": "Garda un ficheiro .pcap do tráfico de rede durante a xanela de captura." + }, + "settings.troubleshooting.duration.label": { + "message": "Duración da captura" + }, + "settings.troubleshooting.duration.help": { + "message": "Canto tempo se executa a sesión de captura." + }, + "settings.troubleshooting.duration.suffix": { + "message": "Minuto(s)" + }, + "settings.troubleshooting.create": { + "message": "Crear paquete" + }, + "settings.troubleshooting.progress.description": { + "message": "Recompilando rexistros, detalles do sistema e estado de conexión. Isto adoita levar un momento. Pode seguir usando NetBird ou pechar a Configuración mentres remata." + }, + "settings.troubleshooting.cancelling": { + "message": "Cancelando…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "Paquete de depuración subido con éxito!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "Paquete gardado" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "Comparta a chave de subida de abaixo co soporte de NetBird. Tamén se gardou unha copia local no seu dispositivo." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "O seu paquete de depuración gardouse localmente." + }, + "settings.troubleshooting.done.copyKey": { + "message": "Copiar chave" + }, + "settings.troubleshooting.done.openFolder": { + "message": "Abrir cartafol" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "Abrir localización do ficheiro" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "Erro na subida: {reason} O paquete aínda está gardado localmente." + }, + "settings.troubleshooting.uploadFailed": { + "message": "Erro na subida. O paquete aínda está gardado localmente." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "Reconectando NetBird…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "Capturando rexistros de depuración" + }, + "settings.troubleshooting.stage.bundling": { + "message": "Xerando paquete de depuración…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "Subindo a NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "Cancelando…" + }, + "settings.about.client": { + "message": "Cliente NetBird v{version}" + }, + "settings.about.clientName": { + "message": "Cliente NetBird" + }, + "settings.about.development": { + "message": "[Desenvolvemento]" + }, + "settings.about.gui": { + "message": "GUI v{version}" + }, + "settings.about.guiName": { + "message": "GUI" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. Todos os dereitos reservados." + }, + "settings.about.links.imprint": { + "message": "Aviso legal" + }, + "settings.about.links.privacy": { + "message": "Privacidade" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "Termos do servizo" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "Foro" + }, + "settings.about.community.documentation": { + "message": "Documentación" + }, + "settings.about.community.feedback": { + "message": "Comentarios" + }, + "update.banner.message": { + "message": "NetBird {version} está listo para instalar." + }, + "update.banner.later": { + "message": "Máis tarde" + }, + "update.banner.installNow": { + "message": "Instalar agora" + }, + "update.card.versionAvailableDownload": { + "message": "A versión {version} está dispoñible para descargar." + }, + "update.card.versionAvailableInstall": { + "message": "A versión {version} está dispoñible para instalar." + }, + "update.card.whatsNew": { + "message": "Que hai de novo?" + }, + "update.card.installNow": { + "message": "Instalar agora" + }, + "update.card.getInstaller": { + "message": "Descargar" + }, + "update.card.autoCheckInterval": { + "message": "NetBird busca actualizacións en segundo plano." + }, + "update.card.changelog": { + "message": "Rexistro de cambios" + }, + "update.card.onLatestVersion": { + "message": "Ten a versión máis recente" + }, + "update.header.tooltip": { + "message": "Actualización dispoñible" + }, + "update.overlay.updatingVersion": { + "message": "Actualizando NetBird á v{version}" + }, + "update.overlay.updating": { + "message": "Actualizando NetBird" + }, + "update.overlay.description": { + "message": "Unha versión máis recente está dispoñible e estase instalando. NetBird reiniciarase automaticamente unha vez remate a actualización." + }, + "update.overlay.error.timeoutTitle": { + "message": "A actualización está tardando demasiado" + }, + "update.overlay.error.timeoutDescription": { + "message": "A instalación de {target} tardou demasiado e non rematou." + }, + "update.overlay.error.canceledTitle": { + "message": "Detívose a actualización" + }, + "update.overlay.error.canceledDescription": { + "message": "A actualización a {target} cancelouse antes de rematar." + }, + "update.overlay.error.failTitle": { + "message": "Non se puido instalar a actualización" + }, + "update.overlay.error.failDescription": { + "message": "Non se puido instalar {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "erro descoñecido" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "a nova versión" + }, + "update.error.loadStateTitle": { + "message": "Erro ao cargar o estado de actualización" + }, + "update.error.triggerTitle": { + "message": "Erro ao iniciar a actualización" + }, + "update.page.versionLine": { + "message": "Actualizando cliente a: {version}." + }, + "update.page.versionLineGeneric": { + "message": "Actualizando cliente." + }, + "update.page.outdated": { + "message": "A súa versión de cliente é máis antiga que a versión de actualización automática establecida na xestión." + }, + "update.page.status.running": { + "message": "Actualizando" + }, + "update.page.status.timeout": { + "message": "Esgotouse o tempo de espera da actualización. Por favor, inténteo de novo." + }, + "update.page.status.canceled": { + "message": "Actualización cancelada." + }, + "update.page.status.failed": { + "message": "Erro na actualización: {message}" + }, + "update.page.status.unknownError": { + "message": "erro de actualización descoñecido" + }, + "update.page.failedTitle": { + "message": "Erro na actualización" + }, + "update.page.timeoutMessage": { + "message": "Esgotouse o tempo de espera da actualización." + }, + "update.page.dontClose": { + "message": "Por favor, non peche esta xanela." + }, + "update.page.updating": { + "message": "Actualizando…" + }, + "update.page.complete": { + "message": "Actualización completada" + }, + "update.page.failed": { + "message": "Erro na actualización" + }, + "window.title.settings": { + "message": "Configuración" + }, + "window.title.signIn": { + "message": "Iniciar sesión" + }, + "window.title.sessionExpiration": { + "message": "Sesión a punto de caducar" + }, + "window.title.updating": { + "message": "Actualizando" + }, + "window.title.welcome": { + "message": "Benvido a NetBird" + }, + "window.title.error": { + "message": "Erro" + }, + "welcome.title": { + "message": "Busque NetBird na súa área de notificación" + }, + "welcome.titleMac": { + "message": "Busque NetBird na súa barra de menú" + }, + "welcome.description": { + "message": "NetBird reside na súa área de notificación. Faga clic na icona para conectar, cambiar de perfil ou abrir a configuración." + }, + "welcome.descriptionMac": { + "message": "NetBird reside na súa barra de menú. Faga clic na icona para conectar, cambiar de perfil ou abrir a configuración." + }, + "welcome.continue": { + "message": "Continuar" + }, + "welcome.back": { + "message": "Volver" + }, + "welcome.management.title": { + "message": "Configurar NetBird" + }, + "welcome.management.description": { + "message": "Faga clic en Continuar para comezar, ou escolla Autoaloxado se ten o seu propio servidor de NetBird." + }, + "welcome.management.cloud.title": { + "message": "NetBird Cloud" + }, + "welcome.management.cloud.description": { + "message": "Use o noso servizo aloxado. Sen necesidade de configuración." + }, + "welcome.management.selfHosted.title": { + "message": "Autoaloxado" + }, + "welcome.management.selfHosted.description": { + "message": "Conéctese ao seu propio servidor de xestión." + }, + "welcome.management.urlLabel": { + "message": "URL do servidor de xestión" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "Por favor, introduza un URL válido, p. ex. https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "Non se puido acadar este servidor. Comprobe o URL ou a súa rede, e continúe se ten a certeza de que é correcto." + }, + "welcome.management.checking": { + "message": "Comprobando…" + }, + "browserLogin.title": { + "message": "Complete o inicio de sesión no navegador" + }, + "browserLogin.notSeeing": { + "message": "Abrimos unha lapela no seu navegador para que poida rematar de iniciar sesión. Non a ve?" + }, + "browserLogin.tryAgain": { + "message": "Tentar de novo" + }, + "browserLogin.openFailedTitle": { + "message": "Erro ao abrir o navegador" + }, + "sessionExpiration.title": { + "message": "A sesión caduca en breve" + }, + "sessionExpiration.titleLater": { + "message": "A súa sesión caducará" + }, + "sessionExpiration.description": { + "message": "Este dispositivo desconectarase en breve. Renove iniciando sesión no navegador." + }, + "sessionExpiration.descriptionLater": { + "message": "Un inicio de sesión no navegador mantén este dispositivo conectado á súa rede." + }, + "sessionExpiration.stay": { + "message": "Renovar sesión" + }, + "sessionExpiration.authenticate": { + "message": "Autenticar" + }, + "sessionExpiration.logout": { + "message": "Pechar sesión" + }, + "sessionExpiration.expired": { + "message": "Sesión caducada" + }, + "sessionExpiration.expiredDescription": { + "message": "Dispositivo desconectado. Autentíquese cun inicio de sesión no navegador para volver conectar." + }, + "sessionExpiration.close": { + "message": "Pechar" + }, + "sessionExpiration.extendFailedTitle": { + "message": "Erro ao prolongar a sesión" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "Erro ao pechar sesión" + }, + "peers.search.placeholder": { + "message": "Buscar por nome ou IP" + }, + "peers.filter.all": { + "message": "Todos" + }, + "peers.filter.online": { + "message": "En liña" + }, + "peers.filter.offline": { + "message": "Fóra de liña" + }, + "peers.empty.title": { + "message": "Sen peers dispoñibles" + }, + "peers.empty.description": { + "message": "Ou non ten ningún peer dispoñible ou non ten acceso a ningún deles." + }, + "peers.details.domain": { + "message": "Dominio" + }, + "peers.details.netbirdIp": { + "message": "IP de NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 de NetBird" + }, + "peers.details.publicKey": { + "message": "Chave pública" + }, + "peers.details.connection": { + "message": "Conexión" + }, + "peers.details.latency": { + "message": "Latencia" + }, + "peers.details.lastHandshake": { + "message": "Último handshake" + }, + "peers.details.statusSince": { + "message": "Última actualización de conexión" + }, + "peers.details.bytes": { + "message": "Bytes" + }, + "peers.details.bytesSent": { + "message": "Enviados" + }, + "peers.details.bytesReceived": { + "message": "Recibidos" + }, + "peers.details.localIce": { + "message": "ICE local" + }, + "peers.details.remoteIce": { + "message": "ICE remoto" + }, + "peers.details.never": { + "message": "Nunca" + }, + "peers.details.justNow": { + "message": "Agora mesmo" + }, + "peers.details.refresh": { + "message": "Actualizar" + }, + "peers.status.connected": { + "message": "Conectado" + }, + "peers.status.connecting": { + "message": "Conectando" + }, + "peers.status.disconnected": { + "message": "Desconectado" + }, + "peers.details.relayAddress": { + "message": "Servidor de retransmisión" + }, + "peers.details.networks": { + "message": "Recursos" + }, + "peers.details.relayed": { + "message": "Retransmitido" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass activado" + }, + "networks.search.placeholder": { + "message": "Buscar por rede ou dominio" + }, + "networks.filter.all": { + "message": "Todos" + }, + "networks.filter.active": { + "message": "Activos" + }, + "networks.filter.overlapping": { + "message": "Sobrepostos" + }, + "networks.empty.title": { + "message": "Sen recursos dispoñibles" + }, + "networks.empty.description": { + "message": "Ou non ten recursos de rede dispoñibles ou non ten acceso a ningún deles." + }, + "networks.selected": { + "message": "Seleccionado" + }, + "networks.unselected": { + "message": "Non seleccionado" + }, + "networks.ips.heading": { + "message": "IPs resoltas" + }, + "networks.bulk.selectionCount": { + "message": "{selected} de {total} Activos" + }, + "networks.bulk.enableAll": { + "message": "Activar todos" + }, + "networks.bulk.disableAll": { + "message": "Desactivar todos" + }, + "exitNodes.search.placeholder": { + "message": "Buscar nodos de saída" + }, + "exitNodes.none": { + "message": "Ningún" + }, + "exitNodes.empty.title": { + "message": "Sen nodos de saída dispoñibles" + }, + "exitNodes.empty.description": { + "message": "Non se compartiron nodos de saída con este peer." + }, + "exitNodes.card.title": { + "message": "Nodo de saída" + }, + "exitNodes.card.statusActive": { + "message": "Activo" + }, + "exitNodes.card.statusInactive": { + "message": "Inactivo" + }, + "exitNodes.dropdown.noneTitle": { + "message": "Ningún" + }, + "exitNodes.dropdown.noneDescription": { + "message": "Conexión directa sen nodo de saída" + }, + "quickActions.connect": { + "message": "Conectar" + }, + "quickActions.disconnect": { + "message": "Desconectar" + }, + "daemon.unavailable.title": { + "message": "O servizo NetBird non está en execución" + }, + "daemon.unavailable.description": { + "message": "A aplicación volverá conectar automaticamente cando o servizo estea en execución." + }, + "daemon.unavailable.docsLink": { + "message": "Documentación" + }, + "daemon.outdated.title": { + "message": "O cliente NetBird está desactualizado" + }, + "daemon.outdated.description": { + "message": "A nova GUI non é compatible co cliente NetBird máis antigo. Actualice o seu cliente para usar a nova aplicación." + }, + "daemon.outdated.download": { + "message": "Descargar a última versión" + }, + "error.jwt_clock_skew": { + "message": "Erro ao iniciar sesión: o reloxo deste dispositivo non está sincronizado co servidor. Por favor, sincronice o reloxo do sistema e inténteo de novo." + }, + "error.jwt_expired": { + "message": "O seu token de inicio de sesión caducou. Por favor, inicie sesión de novo." + }, + "error.jwt_signature_invalid": { + "message": "Erro ao iniciar sesión: a sinatura do token non é válida. Por favor, contacte co seu administrador." + }, + "error.session_expired": { + "message": "A súa sesión caducou. Por favor, inicie sesión de novo." + }, + "error.invalid_setup_key": { + "message": "A chave de configuración falta ou non é válida." + }, + "error.permission_denied": { + "message": "O inicio de sesión foi rexeitado polo servidor." + }, + "error.daemon_unreachable": { + "message": "O daemon de NetBird non responde. Por favor, comprobe que o servizo está en execución." + }, + "error.settings_locked": { + "message": "A configuración non se pode cambiar neste dispositivo: un administrador bloqueouna." + }, + "error.settings_managed_by_mdm": { + "message": "Esta configuración está xestionada pola súa organización e non se pode cambiar." + }, + "error.unknown": { + "message": "A operación fallou." + }, + "error.elevation_unavailable": { + "message": "NetBird non puido solicitar a este sistema os privilexios que o cambio require. Execute isto no seu lugar:" + }, + "error.elevation_failed": { + "message": "O cambio non se puido aplicar con privilexios elevados. Execute isto no seu lugar:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "root" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "privilexios de administrador" + }, + "settings.ssh.privilege.hint": { + "message": "Require {actor}. Execute isto no seu lugar:" + }, + "settings.ssh.privilege.oneWay": { + "message": "Pode desactivalo, pero para volvelo activar requírese {actor}:" + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "Pode activalo, pero para volvelo desactivar requírese {actor}:" + }, + "settings.ssh.privilege.authorizePending": { + "message": "Agardando pola autorización…" + } +} From d56e6fc5f4ae07e352ada103fd6354d95d1715c3 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 6 Oct 2026 15:41:01 +0200 Subject: [PATCH 112/209] [client, android, ios] Coalesce peer list change notifications to the mobile listener (#7546) * [client] Coalesce peer list change notifications to the mobile listener Every peer state change spawned a goroutine to call the platform listener. During a reconnect storm this pinned hundreds of OS threads in JNI and let the UI call back into the engine from each of them. Deliver peer list changes from a single goroutine per listener and collapse pending changes into the latest count. * [client] Cover a pending wake-up when the peer list deliverer is replaced The replacement test waited for the old callback to finish before swapping listeners, so it never exercised the stop check that runs after a wake-up. Block the old callback, queue a peer list change and swap while it is blocked, then assert the old listener never sees the new count. * [client] Signal peer list deliverer exit and wait for it in the test The replacement test sampled the old listener after a fixed sleep, so a late stale delivery could slip past it. Close a done channel when the deliverer goroutine returns and let the test wait on it instead. * [client] Drop the test-only peer list deliverer exit channel The done channel was only read by the replacement test. Production code cannot wait on it, since joining the deliverer would block on a mobile callback. The tests now drive the deliverer loop directly and check that setListener and removeListener close its stop channel. --- client/internal/peer/notifier.go | 60 ++++++++-- client/internal/peer/notifier_test.go | 155 ++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 8 deletions(-) diff --git a/client/internal/peer/notifier.go b/client/internal/peer/notifier.go index 1ee1d32ea..564098bd4 100644 --- a/client/internal/peer/notifier.go +++ b/client/internal/peer/notifier.go @@ -12,6 +12,8 @@ type notifier struct { serverStateLock sync.Mutex listenersLock sync.Mutex listener Listener + peerListWake chan struct{} + peerListStop chan struct{} currentClientState bool lastNotification ClientState lastNumberOfPeers int @@ -62,7 +64,6 @@ func (n *notifier) setNetworkAvailable(available bool) { func (n *notifier) setListener(listener Listener) { n.serverStateLock.Lock() lastNotification := n.effectiveState(n.lastNotification) - numOfPeers := n.lastNumberOfPeers fqdnAddress := n.lastFqdnAddress address := n.lastIPAddress n.serverStateLock.Unlock() @@ -70,17 +71,19 @@ func (n *notifier) setListener(listener Listener) { n.listenersLock.Lock() defer n.listenersLock.Unlock() + n.stopPeerListDelivererLocked() n.listener = listener listener.OnAddressChanged(fqdnAddress, address) notifyListener(listener, lastNotification) - // run on go routine to avoid on Java layer to call go functions on same thread - go listener.OnPeersListChanged(numOfPeers) + n.startPeerListDelivererLocked(listener) + n.wakePeerListDelivererLocked() } func (n *notifier) removeListener() { n.listenersLock.Lock() defer n.listenersLock.Unlock() + n.stopPeerListDelivererLocked() n.listener = nil } @@ -178,15 +181,56 @@ func (n *notifier) peerListChanged(numOfPeers int) { n.serverStateLock.Unlock() n.listenersLock.Lock() - listener := n.listener - n.listenersLock.Unlock() + defer n.listenersLock.Unlock() + n.wakePeerListDelivererLocked() +} - if listener == nil { +func (n *notifier) startPeerListDelivererLocked(listener Listener) { + wake := make(chan struct{}, 1) + stop := make(chan struct{}) + n.peerListWake = wake + n.peerListStop = stop + go n.deliverPeerListChanges(listener, wake, stop) +} + +func (n *notifier) stopPeerListDelivererLocked() { + if n.peerListStop == nil { return } + close(n.peerListStop) + n.peerListStop = nil + n.peerListWake = nil +} - // run on go routine to avoid on Java layer to call go functions on same thread - go listener.OnPeersListChanged(numOfPeers) +func (n *notifier) wakePeerListDelivererLocked() { + if n.peerListWake == nil { + return + } + select { + case n.peerListWake <- struct{}{}: + default: + } +} + +func (n *notifier) deliverPeerListChanges(listener Listener, wake <-chan struct{}, stop <-chan struct{}) { + for { + select { + case <-stop: + return + case <-wake: + } + select { + case <-stop: + return + default: + } + + n.serverStateLock.Lock() + numOfPeers := n.lastNumberOfPeers + n.serverStateLock.Unlock() + + listener.OnPeersListChanged(numOfPeers) + } } func (n *notifier) localAddressChanged(fqdn, address string) { diff --git a/client/internal/peer/notifier_test.go b/client/internal/peer/notifier_test.go index a73016b05..f81866214 100644 --- a/client/internal/peer/notifier_test.go +++ b/client/internal/peer/notifier_test.go @@ -2,7 +2,9 @@ package peer import ( "sync" + "sync/atomic" "testing" + "time" ) type mocListener struct { @@ -115,3 +117,156 @@ func Test_notifier_RemoveListener(t *testing.T) { t.Errorf("invalid state: %d", listener.peers) } } + +type coalescingListener struct { + final int + calls atomic.Int32 + inFlight atomic.Int32 + maxInFlight atomic.Int32 + last atomic.Int32 + done chan struct{} + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (l *coalescingListener) OnStateChanged(ClientState) {} +func (l *coalescingListener) OnConnected() {} +func (l *coalescingListener) OnDisconnected() {} +func (l *coalescingListener) OnConnecting() {} +func (l *coalescingListener) OnDisconnecting() {} +func (l *coalescingListener) OnAddressChanged(string, string) {} + +func (l *coalescingListener) OnPeersListChanged(size int) { + current := l.inFlight.Add(1) + for { + seen := l.maxInFlight.Load() + if current <= seen || l.maxInFlight.CompareAndSwap(seen, current) { + break + } + } + if l.calls.Add(1) == 1 && l.entered != nil { + close(l.entered) + } + if l.release != nil { + <-l.release + } + time.Sleep(time.Millisecond) + l.last.Store(int32(size)) + l.inFlight.Add(-1) + if size == l.final { + l.once.Do(func() { close(l.done) }) + } +} + +func Test_notifier_PeerListChangedCoalesces(t *testing.T) { + const events = 1000 + listener := &coalescingListener{final: events, done: make(chan struct{})} + n := newNotifier() + n.setListener(listener) + + for i := 1; i <= events; i++ { + n.peerListChanged(i) + } + + select { + case <-listener.done: + case <-time.After(5 * time.Second): + t.Fatalf("last peer count not delivered, last seen: %d", listener.last.Load()) + } + + if got := listener.maxInFlight.Load(); got != 1 { + t.Errorf("concurrent deliveries: %d, expected 1", got) + } + if got := listener.calls.Load(); got >= events { + t.Errorf("deliveries not coalesced: %d calls for %d events", got, events) + } +} + +func Test_notifier_SetListenerStopsPreviousDeliverer(t *testing.T) { + old := &coalescingListener{final: -1} + replacement := &coalescingListener{final: 7, done: make(chan struct{})} + n := newNotifier() + n.setListener(old) + oldStop := n.peerListStop + + n.peerListChanged(7) + n.setListener(replacement) + + select { + case <-oldStop: + default: + t.Fatal("old deliverer not stopped on listener replacement") + } + waitFor(t, replacement.done, "replacement listener not notified") +} + +func Test_notifier_RemoveListenerStopsDeliverer(t *testing.T) { + n := newNotifier() + n.setListener(&coalescingListener{final: -1}) + stop := n.peerListStop + + n.removeListener() + + select { + case <-stop: + default: + t.Fatal("deliverer not stopped on listener removal") + } +} + +func Test_notifier_DelivererExitsAfterInFlightCallback(t *testing.T) { + listener := &coalescingListener{ + final: -1, + entered: make(chan struct{}), + release: make(chan struct{}), + } + n := newNotifier() + wake := make(chan struct{}, 1) + stop := make(chan struct{}) + exited := make(chan struct{}) + go func() { + n.deliverPeerListChanges(listener, wake, stop) + close(exited) + }() + + wake <- struct{}{} + waitFor(t, listener.entered, "listener not called") + + n.peerListChanged(7) + wake <- struct{}{} + close(stop) + close(listener.release) + + waitFor(t, exited, "deliverer did not exit after stop") + if got := listener.calls.Load(); got != 1 { + t.Errorf("deliverer ran %d callbacks after stop, expected only the in-flight one", got) + } + if got := listener.last.Load(); got == 7 { + t.Errorf("deliverer delivered the peer count queued after stop") + } +} + +func Test_notifier_DelivererPrefersStopOverPendingWake(t *testing.T) { + listener := &coalescingListener{final: -1} + n := newNotifier() + wake := make(chan struct{}, 1) + stop := make(chan struct{}) + + wake <- struct{}{} + close(stop) + n.deliverPeerListChanges(listener, wake, stop) + + if got := listener.calls.Load(); got != 0 { + t.Errorf("deliverer ran %d callbacks with stop closed, expected 0", got) + } +} + +func waitFor(t *testing.T, ch <-chan struct{}, msg string) { + t.Helper() + select { + case <-ch: + case <-time.After(5 * time.Second): + t.Fatal(msg) + } +} From 2623feeb5b7ca6338f98de697076bc835bed6fee Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:35:31 +0200 Subject: [PATCH 113/209] [management] remove ingress ports (#8062) --- client/cmd/forwarding_rules.go | 98 -- client/cmd/root.go | 3 - client/cmd/testutil_test.go | 7 +- client/embed/embed_test.go | 7 +- client/firewall/iptables/dnat_linux.go | 166 ---- .../iptables/dnat_refcount_linux_test.go | 240 ----- client/firewall/iptables/family_linux.go | 4 - client/firewall/iptables/filter_linux.go | 9 - client/firewall/iptables/manager_linux.go | 25 - .../firewall/iptables/manager_linux_test.go | 10 - client/firewall/manager/firewall.go | 6 - client/firewall/manager/forward_rule.go | 27 - client/firewall/nftables/dnat_linux.go | 321 ------- .../nftables/dnat_refcount_linux_test.go | 249 ----- client/firewall/nftables/family_linux.go | 8 - client/firewall/nftables/filter_linux.go | 5 - client/firewall/nftables/manager_linux.go | 47 +- .../firewall/nftables/manager_linux_test.go | 24 - client/firewall/nftables/routing_linux.go | 35 - client/firewall/uspfilter/nat.go | 10 - client/internal/engine.go | 92 +- client/internal/engine_privileged_test.go | 5 +- client/internal/ingressgw/manager.go | 111 --- client/internal/ingressgw/manager_test.go | 281 ------ client/internal/message_convert.go | 43 - client/internal/peer/status.go | 35 - .../routemanager/ipfwdstate/ipfwdstate.go | 28 +- .../ipfwdstate_privileged_linux_test.go | 10 +- client/proto/daemon.pb.go | 55 +- client/proto/daemon.proto | 18 +- client/proto/daemon_grpc.pb.go | 5 + client/server/forwardingrules.go | 54 -- client/server/server_privileged_test.go | 7 +- client/status/status.go | 94 +- client/status/status_test.go | 2 - client/ui/frontend/WAILS-API.md | 17 +- client/ui/main.go | 1 - client/ui/services/forwarding.go | 83 -- go.mod | 2 +- go.sum | 4 +- .../network_map/controller/controller.go | 122 +-- .../network_map/controller/controller_test.go | 3 +- .../network_map/controller/repository_mock.go | 15 + .../controllers/network_map/interface.go | 2 +- .../controllers/network_map/interface_mock.go | 11 +- .../network_map/nmaptest/canonicalize.go | 11 - .../network_map/nmaptest/runner.go | 2 +- management/internals/server/controllers.go | 11 +- management/internals/server/modules.go | 4 +- .../shared/grpc/components_encoder.go | 6 - .../shared/grpc/components_encoder_test.go | 60 -- .../grpc/components_envelope_response.go | 43 - .../internals/shared/grpc/conversion.go | 8 - management/internals/shared/grpc/server.go | 4 +- management/server/account.go | 4 - management/server/account_test.go | 5 +- management/server/dns_test.go | 5 +- .../testing/testing_tools/channel/channel.go | 11 +- management/server/identity_provider_test.go | 5 +- .../port_forwarding/controller.go | 38 - management/server/management_proto_test.go | 7 +- management/server/management_test.go | 6 +- management/server/nameserver_test.go | 5 +- management/server/peer.go | 9 - management/server/peer_test.go | 39 +- management/server/route_test.go | 5 +- .../server/types/account_networkmapdata.go | 3 +- management/server/types/aliases.go | 1 - management/server/types/legacynmap/aliases.go | 1 - .../server/types/legacynmap/converters.go | 1 - .../types/legacynmap/equivalence_test.go | 12 - .../server/types/legacynmap/proto_legacy.go | 8 - shared/management/client/client_test.go | 7 +- shared/management/client/rest/client.go | 5 - shared/management/client/rest/ingress.go | 92 -- shared/management/client/rest/ingress_test.go | 184 ---- shared/management/client/rest/peers.go | 92 -- shared/management/client/rest/peers_test.go | 145 --- shared/management/http/api/openapi.yml | 554 ----------- shared/management/http/api/types.gen.go | 223 ----- shared/management/networkmap/envelope.go | 68 +- shared/management/proto/management.pb.go | 907 +++++++++--------- shared/management/proto/management.proto | 35 +- shared/management/types/network.go | 104 -- shared/management/types/network_test.go | 41 - 85 files changed, 667 insertions(+), 4505 deletions(-) delete mode 100644 client/cmd/forwarding_rules.go delete mode 100644 client/firewall/iptables/dnat_refcount_linux_test.go delete mode 100644 client/firewall/manager/forward_rule.go delete mode 100644 client/firewall/nftables/dnat_refcount_linux_test.go delete mode 100644 client/internal/ingressgw/manager.go delete mode 100644 client/internal/ingressgw/manager_test.go delete mode 100644 client/internal/message_convert.go delete mode 100644 client/server/forwardingrules.go delete mode 100644 client/ui/services/forwarding.go delete mode 100644 management/server/integrations/port_forwarding/controller.go delete mode 100644 shared/management/client/rest/ingress.go delete mode 100644 shared/management/client/rest/ingress_test.go delete mode 100644 shared/management/types/network_test.go diff --git a/client/cmd/forwarding_rules.go b/client/cmd/forwarding_rules.go deleted file mode 100644 index b3052746a..000000000 --- a/client/cmd/forwarding_rules.go +++ /dev/null @@ -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" - } -} diff --git a/client/cmd/root.go b/client/cmd/root.go index 2ca14c39c..4525a9bd6 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -177,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) @@ -185,8 +184,6 @@ func init() { networksCMD.AddCommand(routesListCmd) networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd) - forwardingRulesCmd.AddCommand(forwardingRulesListCmd) - debugCmd.AddCommand(debugBundleCmd) debugCmd.AddCommand(logCmd) logCmd.AddCommand(logLevelCmd) diff --git a/client/cmd/testutil_test.go b/client/cmd/testutil_test.go index 328a15454..46bf31837 100644 --- a/client/cmd/testutil_test.go +++ b/client/cmd/testutil_test.go @@ -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) } diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go index 4ff5c9978..a818af055 100644 --- a/client/embed/embed_test.go +++ b/client/embed/embed_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "google.golang.org/grpc" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" @@ -21,7 +21,6 @@ import ( nbcache "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -146,8 +145,8 @@ func startManagement(t *testing.T, signalAddr string) string { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore) - networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg, nil) - accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", manager.NewEphemeralManager(testStore, peersManager), cfg, nil) + accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, settingsMockManager, permissionsManager, false, cacheStore) require.NoError(t, err) secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, cfg.TURNConfig, cfg.Relay, settingsMockManager, groupsManager) diff --git a/client/firewall/iptables/dnat_linux.go b/client/firewall/iptables/dnat_linux.go index eca8386c0..f118c9dfe 100644 --- a/client/firewall/iptables/dnat_linux.go +++ b/client/firewall/iptables/dnat_linux.go @@ -8,177 +8,11 @@ import ( "strconv" "strings" - "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" - nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" ) -func (r *family) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - ruleID := rule.ID() - if _, exists := r.rules[ruleID+dnatSuffix]; exists { - return rule, nil - } - - toDestination := rule.TranslatedAddress.String() - switch { - case len(rule.TranslatedPort.Values) == 0: - // no translated port, use original port - case len(rule.TranslatedPort.Values) == 1: - toDestination += fmt.Sprintf(":%d", rule.TranslatedPort.Values[0]) - case rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2: - // need the "/originalport" suffix to avoid dnat port randomization - toDestination += fmt.Sprintf(":%d-%d/%d", rule.TranslatedPort.Values[0], rule.TranslatedPort.Values[1], rule.DestinationPort.Values[0]) - default: - return nil, fmt.Errorf("invalid translated port: %v", rule.TranslatedPort) - } - - proto := strings.ToLower(string(rule.Protocol)) - - rules := make(map[firewall.RuleID]ruleInfo, 3) - - // DNAT rule - dnatRule := []string{ - "!", "-i", r.wgIface.Name(), - "-p", proto, - "-j", "DNAT", - "--to-destination", toDestination, - } - dnatRule = append(dnatRule, applyPort("--dport", &rule.DestinationPort)...) - rules[ruleID+dnatSuffix] = ruleInfo{ - table: tableNat, - chain: chainRTRdr, - rule: dnatRule, - } - - // SNAT rule - snatRule := []string{ - "-o", r.wgIface.Name(), - "-p", proto, - "-d", rule.TranslatedAddress.String(), - "-j", "MASQUERADE", - } - snatRule = append(snatRule, applyPort("--dport", &rule.TranslatedPort)...) - rules[ruleID+snatSuffix] = ruleInfo{ - table: tableNat, - chain: chainRTNAT, - rule: snatRule, - } - - // Forward filtering rule, if fwd policy is DROP - forwardRule := []string{ - "-o", r.wgIface.Name(), - "-p", proto, - "-d", rule.TranslatedAddress.String(), - "-j", "ACCEPT", - } - forwardRule = append(forwardRule, applyPort("--dport", &rule.TranslatedPort)...) - rules[ruleID+fwdSuffix] = ruleInfo{ - table: tableFilter, - chain: chainRTFwdOut, - rule: forwardRule, - } - - for key, ruleInfo := range rules { - if err := r.iptablesClient.Append(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil { - r.cleanupFailedDNATAdd(rules) - return nil, fmt.Errorf("add rule %s: %w", key, err) - } - r.rules[key] = ruleInfo.rule - } - - if err := r.ipFwdState.RequestForwarding(r.v6); err != nil { - r.cleanupFailedDNATAdd(rules) - return nil, fmt.Errorf("enable forwarding: %w", err) - } - - r.updateState() - return rule, nil -} - -// cleanupFailedDNATAdd removes the bookkeeping written by a partially applied -// AddDNATRule before rolling back the kernel rules, so no entries remain that -// never got a forwarding refcount. rollbackRules re-adds entries it failed to -// remove from the kernel. -func (r *family) cleanupFailedDNATAdd(rules map[firewall.RuleID]ruleInfo) { - for key := range rules { - delete(r.rules, key) - } - if err := r.rollbackRules(rules); err != nil { - log.Errorf("rollback failed: %v", err) - } -} - -func (r *family) rollbackRules(rules map[firewall.RuleID]ruleInfo) error { - var merr *multierror.Error - for key, ruleInfo := range rules { - if err := r.iptablesClient.DeleteIfExists(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("rollback rule %s: %w", key, err)) - // On rollback error, add to rules map for next cleanup - r.rules[key] = ruleInfo.rule - } - } - if merr != nil { - r.updateState() - } - return nberrors.FormatErrorOrNil(merr) -} - -func (r *family) DeleteDNATRule(rule firewall.Rule) error { - ruleID := rule.ID() - - _, hadDNAT := r.rules[ruleID+dnatSuffix] - _, hadSNAT := r.rules[ruleID+snatSuffix] - _, hadFWD := r.rules[ruleID+fwdSuffix] - if !hadDNAT && !hadSNAT && !hadFWD { - return nil - } - - var merr *multierror.Error - if dnatRule, exists := r.rules[ruleID+dnatSuffix]; exists { - if err := r.iptablesClient.Delete(tableNat, chainRTRdr, dnatRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete DNAT rule: %w", err)) - } else { - delete(r.rules, ruleID+dnatSuffix) - } - } - - if snatRule, exists := r.rules[ruleID+snatSuffix]; exists { - if err := r.iptablesClient.Delete(tableNat, chainRTNAT, snatRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete SNAT rule: %w", err)) - } else { - delete(r.rules, ruleID+snatSuffix) - } - } - - if fwdRule, exists := r.rules[ruleID+fwdSuffix]; exists { - if err := r.iptablesClient.Delete(tableFilter, chainRTFwdOut, fwdRule...); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete forward rule: %w", err)) - } else { - delete(r.rules, ruleID+fwdSuffix) - } - } - - // Release the refcount only once all rules are gone from the kernel. On - // partial failure the failed entries stay in r.rules so a retry can remove - // them and release then. - if merr == nil { - r.releaseForwarding() - } - - r.updateState() - - return nberrors.FormatErrorOrNil(merr) -} - -// releaseForwarding drops one IP forwarding reference, logging any error. -func (r *family) releaseForwarding() { - if err := r.ipFwdState.ReleaseForwarding(r.v6); err != nil { - log.Errorf("release IP forwarding: %v", err) - } -} - func (r *family) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) diff --git a/client/firewall/iptables/dnat_refcount_linux_test.go b/client/firewall/iptables/dnat_refcount_linux_test.go deleted file mode 100644 index 40ebc6cc3..000000000 --- a/client/firewall/iptables/dnat_refcount_linux_test.go +++ /dev/null @@ -1,240 +0,0 @@ -//go:build privileged - -package iptables - -import ( - "net/netip" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - fw "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/iface" - "github.com/netbirdio/netbird/client/iface/wgaddr" -) - -func iptRefcountIfaceV4() *iFaceMock { - return &iFaceMock{ - NameFunc: func() string { return "wt-refcount" }, - AddressFunc: func() wgaddr.Address { - return wgaddr.Address{ - IP: netip.MustParseAddr("10.20.0.1"), - Network: netip.MustParsePrefix("10.20.0.0/24"), - } - }, - } -} - -func iptRefcountIfaceDual() *iFaceMock { - return &iFaceMock{ - NameFunc: func() string { return "wt-refcount" }, - AddressFunc: func() wgaddr.Address { - return wgaddr.Address{ - IP: netip.MustParseAddr("10.20.0.1"), - Network: netip.MustParsePrefix("10.20.0.0/24"), - IPv6: netip.MustParseAddr("fd00::1"), - IPv6Net: netip.MustParsePrefix("fd00::/64"), - } - }, - } -} - -func newIptRefcountManager(t *testing.T, dual bool) *Manager { - t.Helper() - var ifMock *iFaceMock - if dual { - ifMock = iptRefcountIfaceDual() - } else { - ifMock = iptRefcountIfaceV4() - } - m, err := Create(ifMock, iface.DefaultMTU) - require.NoError(t, err, "create manager") - require.NoError(t, m.Init(nil), "init manager") - t.Cleanup(func() { - require.NoError(t, m.Close(nil), "close manager") - }) - return m -} - -func iptDnatV4(port uint16) fw.ForwardRule { - return fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{port}}, - TranslatedAddress: netip.MustParseAddr("10.20.0.2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - } -} - -func iptDnatV6(port uint16) fw.ForwardRule { - return fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{port}}, - TranslatedAddress: netip.MustParseAddr("fd00::2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - } -} - -// TestIptablesRouting_RepeatedEnableSingleReference verifies that EnableRouting -// (called on every network-map update) holds at most one reference per family -// and a single DisableRouting drops both back to zero. -func TestIptablesRouting_RepeatedEnableSingleReference(t *testing.T) { - m := newIptRefcountManager(t, true) - state := m.family4.ipFwdState - - require.NoError(t, m.EnableRouting(), "first enable") - require.NoError(t, m.EnableRouting(), "second enable") - require.NoError(t, m.EnableRouting(), "third enable") - v4, v6 := state.Counts() - assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference") - assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference") - - require.NoError(t, m.DisableRouting(), "disable") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "single disable releases the v4 reference") - assert.Equal(t, 0, v6, "single disable releases the v6 reference") -} - -// TestIptablesRouting_DisableKeepsDNATReference verifies that an unpaired -// DisableRouting does not release references held by active DNAT rules. -func TestIptablesRouting_DisableKeepsDNATReference(t *testing.T) { - m := newIptRefcountManager(t, true) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(iptDnatV6(9095)) - require.NoError(t, err, "add v6 dnat") - - require.NoError(t, m.DisableRouting(), "unpaired disable") - _, v6 := state.Counts() - assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting") - - require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat") - _, v6 = state.Counts() - assert.Equal(t, 0, v6, "delete releases the DNAT reference") -} - -// TestIptablesDNAT_RefcountBalancedV4 covers a Balanced Add/Delete pair on v4. -func TestIptablesDNAT_RefcountBalancedV4(t *testing.T) { - m := newIptRefcountManager(t, false) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(iptDnatV4(7081)) - require.NoError(t, err, "add v4 dnat 1") - v4, v6 := state.Counts() - assert.Equal(t, 1, v4, "v4 refcount after first add") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - r2, err := m.AddDNATRule(iptDnatV4(7082)) - require.NoError(t, err, "add v4 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 2, v4, "v4 refcount after second add") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - require.NoError(t, m.DeleteDNATRule(r1)) - v4, v6 = state.Counts() - assert.Equal(t, 1, v4, "v4 refcount after first delete") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - require.NoError(t, m.DeleteDNATRule(r2)) - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "v4 refcount after second delete") - assert.Equal(t, 0, v6, "v6 refcount unchanged") -} - -// TestIptablesDNAT_RefcountBalancedV6 checks the v6 path increments v6 only and -// decrements back to zero. -func TestIptablesDNAT_RefcountBalancedV6(t *testing.T) { - m := newIptRefcountManager(t, true) - require.NotNil(t, m.family6, "v6 family") - require.Same(t, m.family4.ipFwdState, m.family6.ipFwdState, "shared state") - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(iptDnatV6(9081)) - require.NoError(t, err, "add v6 dnat 1") - v4, v6 := state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 1, v6, "v6 refcount after first add") - - r2, err := m.AddDNATRule(iptDnatV6(9082)) - require.NoError(t, err, "add v6 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "v4 refcount unchanged") - assert.Equal(t, 2, v6, "v6 refcount after second add") - - require.NoError(t, m.DeleteDNATRule(r1)) - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "v4 refcount unchanged") - assert.Equal(t, 1, v6, "v6 refcount after first delete") - - require.NoError(t, m.DeleteDNATRule(r2)) - v4, v6 = state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 0, v6, "v6 refcount after second delete") -} - -// TestIptablesDNAT_DuplicateAddNoLeak verifies the duplicate-rule path returns -// without bumping the refcount. -func TestIptablesDNAT_DuplicateAddNoLeak(t *testing.T) { - m := newIptRefcountManager(t, true) - state := m.family4.ipFwdState - - rule := iptDnatV4(7083) - r1, err := m.AddDNATRule(rule) - require.NoError(t, err) - v4, _ := state.Counts() - assert.Equal(t, 1, v4) - - _, err = m.AddDNATRule(rule) - require.NoError(t, err, "duplicate add") - v4, _ = state.Counts() - assert.Equal(t, 1, v4, "duplicate add must not increment") - - require.NoError(t, m.DeleteDNATRule(r1)) - v4, _ = state.Counts() - assert.Equal(t, 0, v4, "single delete must drop to zero") -} - -// TestIptablesDNAT_DeleteMissingNoUnderflow verifies Delete on an unknown rule -// neither errors nor releases the refcount. -func TestIptablesDNAT_DeleteMissingNoUnderflow(t *testing.T) { - m := newIptRefcountManager(t, true) - state := m.family4.ipFwdState - - phantom := iptDnatV4(7099) - require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4") - v4, v6 := state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 0, v6) - - phantom6 := iptDnatV6(9099) - require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 0, v6) - - r1, err := m.AddDNATRule(iptDnatV4(7100)) - require.NoError(t, err) - v4, _ = state.Counts() - assert.Equal(t, 1, v4, "real add still increments after phantom delete") - require.NoError(t, m.DeleteDNATRule(r1)) -} - -// TestIptablesDNAT_DoubleDeleteNoUnderflow verifies a second Delete on the same -// rule is a no-op. -func TestIptablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) { - m := newIptRefcountManager(t, true) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(iptDnatV6(9083)) - require.NoError(t, err) - _, v6 := state.Counts() - assert.Equal(t, 1, v6) - - require.NoError(t, m.DeleteDNATRule(r1), "first delete") - _, v6 = state.Counts() - assert.Equal(t, 0, v6) - - require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op") - _, v6 = state.Counts() - assert.Equal(t, 0, v6, "double delete must not underflow") -} diff --git a/client/firewall/iptables/family_linux.go b/client/firewall/iptables/family_linux.go index 0e1ce5440..2ac860a0a 100644 --- a/client/firewall/iptables/family_linux.go +++ b/client/firewall/iptables/family_linux.go @@ -56,10 +56,6 @@ const ( markManglePost = "mark-mangle-post" matchSet = "--match-set" - dnatSuffix firewall.RuleID = "_dnat" - snatSuffix firewall.RuleID = "_snat" - fwdSuffix firewall.RuleID = "_fwd" - // ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation. ipv4TCPHeaderSize = 40 // ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation. diff --git a/client/firewall/iptables/filter_linux.go b/client/firewall/iptables/filter_linux.go index dc606da2d..30cd81018 100644 --- a/client/firewall/iptables/filter_linux.go +++ b/client/firewall/iptables/filter_linux.go @@ -81,15 +81,6 @@ func (r *family) hasRule(id nbid.RuleID) bool { return ok } -// hasDNATRule reports whether this family owns the DNAT rule set for -// the given user id. DNAT rules live in r.rules under the well-known -// "_dnat" key; the lookup here is used by Manager.DeleteDNATRule -// to pick the right family. -func (r *family) hasDNATRule(id firewall.RuleID) bool { - _, ok := r.rules[id+dnatSuffix] - return ok -} - // DeleteFilterRule removes a previously installed filter rule. The // rule's stored chain/table identify where to delete from; source set // references are recovered from the spec via findSets and dropped diff --git a/client/firewall/iptables/manager_linux.go b/client/firewall/iptables/manager_linux.go index 0f0b0110e..a566909c8 100644 --- a/client/firewall/iptables/manager_linux.go +++ b/client/firewall/iptables/manager_linux.go @@ -323,31 +323,6 @@ func (m *Manager) DisableRouting() error { return m.family4.ipFwdState.ReleaseRouting() } -// AddDNATRule adds a DNAT rule -func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - m.mutex.Lock() - defer m.mutex.Unlock() - - if rule.TranslatedAddress.Is6() { - if !m.hasIPv6() { - return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized) - } - return m.family6.AddDNATRule(rule) - } - return m.family4.AddDNATRule(rule) -} - -// DeleteDNATRule deletes a DNAT rule -func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - if m.hasIPv6() && !m.family4.hasDNATRule(rule.ID()) { - return m.family6.DeleteDNATRule(rule) - } - return m.family4.DeleteDNATRule(rule) -} - // UpdateSet updates the set with the given prefixes func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { m.mutex.Lock() diff --git a/client/firewall/iptables/manager_linux_test.go b/client/firewall/iptables/manager_linux_test.go index 9f53352e1..8435bf6a5 100644 --- a/client/firewall/iptables/manager_linux_test.go +++ b/client/firewall/iptables/manager_linux_test.go @@ -497,16 +497,6 @@ func TestIptablesCloseRemovesAllState(t *testing.T) { require.NoError(t, manager.AddNatRule(pair), "add nat rule") require.NoError(t, manager.EnableRouting(), "enable routing") - // A DNAT redirect, which also holds a forwarding reference. - dnat := fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{8080}}, - TranslatedAddress: netip.MustParseAddr("10.20.0.44"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - } - _, err = manager.AddDNATRule(dnat) - require.NoError(t, err, "add dnat rule") - require.NotEqual(t, before, snapshotIptables(t, ipv4Client), "the manager must have installed state") // Everything above stays in place, so Close is what has to remove it. diff --git a/client/firewall/manager/firewall.go b/client/firewall/manager/firewall.go index 0eb376875..f8de1e2b5 100644 --- a/client/firewall/manager/firewall.go +++ b/client/firewall/manager/firewall.go @@ -172,12 +172,6 @@ type Manager interface { DisableRouting() error - // AddDNATRule adds outbound DNAT rule for forwarding external traffic to the NetBird network. - AddDNATRule(ForwardRule) (Rule, error) - - // DeleteDNATRule deletes the outbound DNAT rule. - DeleteDNATRule(Rule) error - // UpdateSet updates the set with the given prefixes UpdateSet(hash Set, prefixes []netip.Prefix) error diff --git a/client/firewall/manager/forward_rule.go b/client/firewall/manager/forward_rule.go deleted file mode 100644 index c2e9e5c60..000000000 --- a/client/firewall/manager/forward_rule.go +++ /dev/null @@ -1,27 +0,0 @@ -package manager - -import ( - "fmt" - "net/netip" -) - -// ForwardRule todo figure out better place to this to avoid circular imports -type ForwardRule struct { - Protocol Protocol - DestinationPort Port - TranslatedAddress netip.Addr - TranslatedPort Port -} - -func (r ForwardRule) ID() RuleID { - id := fmt.Sprintf("%s;%s;%s;%s", - r.Protocol, - r.DestinationPort.String(), - r.TranslatedAddress.String(), - r.TranslatedPort.String()) - return RuleID(id) -} - -func (r ForwardRule) String() string { - return fmt.Sprintf("protocol: %s, destinationPort: %s, translatedAddress: %s, translatedPort: %s", r.Protocol, r.DestinationPort.String(), r.TranslatedAddress.String(), r.TranslatedPort.String()) -} diff --git a/client/firewall/nftables/dnat_linux.go b/client/firewall/nftables/dnat_linux.go index 8eae694a2..c179d60cc 100644 --- a/client/firewall/nftables/dnat_linux.go +++ b/client/firewall/nftables/dnat_linux.go @@ -9,332 +9,11 @@ import ( "github.com/google/nftables" "github.com/google/nftables/binaryutil" "github.com/google/nftables/expr" - "github.com/google/nftables/xt" - "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" - nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" ) -func (r *family) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - ruleID := rule.ID() - if _, exists := r.rules[ruleID+dnatSuffix]; exists { - return rule, nil - } - - protoNum, err := r.af.protoNum(rule.Protocol) - if err != nil { - return nil, fmt.Errorf("convert protocol to number: %w", err) - } - - // Request forwarding before queueing rules: addDnatRedirect/addDnatMasq - // buffer netlink messages on r.conn that the next caller's Flush would - // commit if we returned without flushing them ourselves. - if err := r.ipFwdState.RequestForwarding(r.isV6()); err != nil { - return nil, fmt.Errorf("enable forwarding: %w", err) - } - - if err := r.addDnatRedirect(rule, protoNum, ruleID); err != nil { - r.releaseForwarding() - return nil, err - } - - if err := r.addDnatMasq(rule, protoNum, ruleID); err != nil { - r.releaseForwarding() - delete(r.rules, ruleID+dnatSuffix) - return nil, err - } - - // Unlike iptables, there's no point in adding "out" rules in the forward chain here as our policy is ACCEPT. - // To overcome DROP policies in other chains, we'd have to add rules to the chains there. - // We also cannot just add "oif accept" there and filter in our own table as we don't know what is supposed to be allowed. - // TODO: find chains with drop policies and add rules there - - if err := r.conn.Flush(); err != nil { - r.releaseForwarding() - delete(r.rules, ruleID+dnatSuffix) - delete(r.rules, ruleID+snatSuffix) - return nil, fmt.Errorf("flush rules: %w", err) - } - - return &rule, nil -} - -func (r *family) addDnatRedirect(rule firewall.ForwardRule, protoNum uint8, ruleID firewall.RuleID) error { - dnatExprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpNeq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{protoNum}, - }, - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseTransportHeader, - Offset: 2, - Len: 2, - }, - } - portExprs, err := r.applyPort(&rule.DestinationPort, false) - if err != nil { - return fmt.Errorf("apply destination port: %w", err) - } - dnatExprs = append(dnatExprs, portExprs...) - - // shifted translated port is not supported in nftables, so we hand this over to xtables - if rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2 { - if rule.TranslatedPort.Values[0] != rule.DestinationPort.Values[0] || - rule.TranslatedPort.Values[1] != rule.DestinationPort.Values[1] { - return r.addXTablesRedirect(dnatExprs, ruleID, rule) - } - } - - additionalExprs, regProtoMin, regProtoMax, err := r.handleTranslatedPort(rule) - if err != nil { - return err - } - dnatExprs = append(dnatExprs, additionalExprs...) - - dnatExprs = append(dnatExprs, - &expr.NAT{ - Type: expr.NATTypeDestNAT, - Family: uint32(r.af.tableFamily), - RegAddrMin: 1, - RegProtoMin: regProtoMin, - RegProtoMax: regProtoMax, - }, - ) - - dnatRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingRdr], - Exprs: dnatExprs, - UserData: []byte(ruleID + dnatSuffix), - } - r.conn.AddRule(dnatRule) - r.rules[ruleID+dnatSuffix] = dnatRule - - return nil -} - -func (r *family) handleTranslatedPort(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - switch { - case rule.TranslatedPort.IsRange && len(rule.TranslatedPort.Values) == 2: - return r.handlePortRange(rule) - case len(rule.TranslatedPort.Values) == 0: - return r.handleAddressOnly(rule) - case len(rule.TranslatedPort.Values) == 1: - return r.handleSinglePort(rule) - default: - return nil, 0, 0, fmt.Errorf("invalid translated port: %v", rule.TranslatedPort) - } -} - -func (r *family) handlePortRange(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - exprs := []expr.Any{ - &expr.Immediate{ - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - &expr.Immediate{ - Register: 2, - Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[0]), - }, - &expr.Immediate{ - Register: 3, - Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[1]), - }, - } - return exprs, 2, 3, nil -} - -func (r *family) handleAddressOnly(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - exprs := []expr.Any{ - &expr.Immediate{ - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - } - return exprs, 0, 0, nil -} - -func (r *family) handleSinglePort(rule firewall.ForwardRule) ([]expr.Any, uint32, uint32, error) { - exprs := []expr.Any{ - &expr.Immediate{ - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - &expr.Immediate{ - Register: 2, - Data: binaryutil.BigEndian.PutUint16(rule.TranslatedPort.Values[0]), - }, - } - return exprs, 2, 0, nil -} - -func (r *family) addXTablesRedirect(dnatExprs []expr.Any, ruleID firewall.RuleID, rule firewall.ForwardRule) error { - dnatExprs = append(dnatExprs, - &expr.Counter{}, - &expr.Target{ - Name: "DNAT", - Rev: 2, - Info: &xt.NatRange2{ - NatRange: xt.NatRange{ - Flags: uint(xt.NatRangeMapIPs | xt.NatRangeProtoSpecified | xt.NatRangeProtoOffset), - MinIP: rule.TranslatedAddress.AsSlice(), - MaxIP: rule.TranslatedAddress.AsSlice(), - MinPort: rule.TranslatedPort.Values[0], - MaxPort: rule.TranslatedPort.Values[1], - }, - BasePort: rule.DestinationPort.Values[0], - }, - }, - ) - - natTable := &nftables.Table{ - Name: tableNat, - Family: r.af.tableFamily, - } - dnatRule := &nftables.Rule{ - Table: natTable, - Chain: &nftables.Chain{ - Name: chainNameNatPrerouting, - Table: natTable, - Type: nftables.ChainTypeNAT, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityNATDest, - }, - Exprs: dnatExprs, - UserData: []byte(ruleID + dnatSuffix), - } - r.conn.AddRule(dnatRule) - r.rules[ruleID+dnatSuffix] = dnatRule - - return nil -} - -func (r *family) addDnatMasq(rule firewall.ForwardRule, protoNum uint8, ruleID firewall.RuleID) error { - portExprs, err := r.applyPort(&rule.TranslatedPort, false) - if err != nil { - return fmt.Errorf("apply translated port: %w", err) - } - - masqExprs := []expr.Any{ - &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: ifname(r.wgIface.Name()), - }, - &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: []byte{protoNum}, - }, - &expr.Payload{ - DestRegister: 1, - Base: expr.PayloadBaseNetworkHeader, - Offset: r.af.dstAddrOffset, - Len: r.af.addrLen, - }, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: rule.TranslatedAddress.AsSlice(), - }, - } - - masqExprs = append(masqExprs, portExprs...) - masqExprs = append(masqExprs, &expr.Masq{}) - - masqRule := &nftables.Rule{ - Table: r.workTable, - Chain: r.chains[chainNameRoutingNat], - Exprs: masqExprs, - UserData: []byte(ruleID + snatSuffix), - } - r.conn.AddRule(masqRule) - r.rules[ruleID+snatSuffix] = masqRule - - return nil -} - -func (r *family) DeleteDNATRule(rule firewall.Rule) error { - ruleID := rule.ID() - - if err := r.refreshRulesMap(); err != nil { - return fmt.Errorf(refreshRulesMapError, err) - } - - var merr *multierror.Error - var needsFlush bool - var found bool - - if dnatRule, exists := r.rules[ruleID+dnatSuffix]; exists { - found = true - if dnatRule.Handle == 0 { - log.Warnf("dnat rule %s has no handle, removing stale entry", ruleID+dnatSuffix) - delete(r.rules, ruleID+dnatSuffix) - } else if err := r.conn.DelRule(dnatRule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete dnat rule: %w", err)) - } else { - needsFlush = true - } - } - - if masqRule, exists := r.rules[ruleID+snatSuffix]; exists { - found = true - if masqRule.Handle == 0 { - log.Warnf("snat rule %s has no handle, removing stale entry", ruleID+snatSuffix) - delete(r.rules, ruleID+snatSuffix) - } else if err := r.conn.DelRule(masqRule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete snat rule: %w", err)) - } else { - needsFlush = true - } - } - - if needsFlush { - if err := r.conn.Flush(); err != nil { - merr = multierror.Append(merr, fmt.Errorf(flushError, err)) - } - } - - if merr != nil { - return nberrors.FormatErrorOrNil(merr) - } - - delete(r.rules, ruleID+dnatSuffix) - delete(r.rules, ruleID+snatSuffix) - - // Release once, only if the rule was present and removed. - if found { - r.releaseForwarding() - } - - return nil -} - -// releaseForwarding drops one IP forwarding reference, logging any error. -func (r *family) releaseForwarding() { - if err := r.ipFwdState.ReleaseForwarding(r.isV6()); err != nil { - log.Errorf("release IP forwarding: %v", err) - } -} - -// isV6 reports whether this family handles the IPv6 table. -func (r *family) isV6() bool { - return r.af.tableFamily == nftables.TableFamilyIPv6 -} - func (r *family) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { ruleID := firewall.RuleID(fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort)) diff --git a/client/firewall/nftables/dnat_refcount_linux_test.go b/client/firewall/nftables/dnat_refcount_linux_test.go deleted file mode 100644 index cdc24e77f..000000000 --- a/client/firewall/nftables/dnat_refcount_linux_test.go +++ /dev/null @@ -1,249 +0,0 @@ -//go:build privileged - -package nftables - -import ( - "net/netip" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - fw "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/iface" - "github.com/netbirdio/netbird/client/iface/wgaddr" -) - -func nftRefcountIfaceV4() *iFaceMock { - return &iFaceMock{ - NameFunc: func() string { return "wt-refcount" }, - AddressFunc: func() wgaddr.Address { - return wgaddr.Address{ - IP: netip.MustParseAddr("100.96.0.1"), - Network: netip.MustParsePrefix("100.96.0.0/16"), - } - }, - } -} - -func nftRefcountIfaceDual() *iFaceMock { - return &iFaceMock{ - NameFunc: func() string { return "wt-refcount" }, - AddressFunc: func() wgaddr.Address { - return wgaddr.Address{ - IP: netip.MustParseAddr("100.96.0.1"), - Network: netip.MustParsePrefix("100.96.0.0/16"), - IPv6: netip.MustParseAddr("fd00::1"), - IPv6Net: netip.MustParsePrefix("fd00::/64"), - } - }, - } -} - -func newNftRefcountManager(t *testing.T, dual bool) *Manager { - t.Helper() - if check() != NFTABLES { - t.Skip("nftables not supported on this system") - } - var ifMock *iFaceMock - if dual { - ifMock = nftRefcountIfaceDual() - } else { - ifMock = nftRefcountIfaceV4() - } - m, err := Create(ifMock, iface.DefaultMTU) - require.NoError(t, err, "create manager") - require.NoError(t, m.Init(nil), "init manager") - t.Cleanup(func() { - require.NoError(t, m.Close(nil), "close manager") - }) - return m -} - -func dnatV4(port uint16) fw.ForwardRule { - return fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{port}}, - TranslatedAddress: netip.MustParseAddr("100.96.0.2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - } -} - -func dnatV6(port uint16) fw.ForwardRule { - return fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{port}}, - TranslatedAddress: netip.MustParseAddr("fd00::2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - } -} - -// TestNftablesDNAT_RefcountBalancedV4 verifies that Add/Delete pairs leave the -// v4 refcount at zero. -func TestNftablesDNAT_RefcountBalancedV4(t *testing.T) { - m := newNftRefcountManager(t, false) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(dnatV4(8081)) - require.NoError(t, err, "add v4 dnat 1") - v4, v6 := state.Counts() - assert.Equal(t, 1, v4, "v4 refcount after first add") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - r2, err := m.AddDNATRule(dnatV4(8082)) - require.NoError(t, err, "add v4 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 2, v4, "v4 refcount after second add") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat 1") - v4, v6 = state.Counts() - assert.Equal(t, 1, v4, "v4 refcount after first delete") - assert.Equal(t, 0, v6, "v6 refcount unchanged") - - require.NoError(t, m.DeleteDNATRule(r2), "delete v4 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "v4 refcount after second delete") - assert.Equal(t, 0, v6, "v6 refcount unchanged") -} - -// TestNftablesDNAT_RefcountBalancedV6 verifies the v6 path increments v6 only -// and decrements back to zero on Delete. -func TestNftablesDNAT_RefcountBalancedV6(t *testing.T) { - m := newNftRefcountManager(t, true) - require.NotNil(t, m.family6, "v6 family") - require.Same(t, m.family4.ipFwdState, m.family6.ipFwdState, "shared state") - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(dnatV6(9091)) - require.NoError(t, err, "add v6 dnat 1") - v4, v6 := state.Counts() - assert.Equal(t, 0, v4, "v4 refcount unchanged") - assert.Equal(t, 1, v6, "v6 refcount after first add") - - r2, err := m.AddDNATRule(dnatV6(9092)) - require.NoError(t, err, "add v6 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 2, v6, "v6 refcount after second add") - - require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat 1") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "v4 refcount unchanged") - assert.Equal(t, 1, v6, "v6 refcount after first delete") - - require.NoError(t, m.DeleteDNATRule(r2), "delete v6 dnat 2") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 0, v6, "v6 refcount after second delete") -} - -// TestNftablesDNAT_DuplicateAddNoLeak verifies that a duplicate Add (same -// ForwardRule) does not double-increment the refcount. -func TestNftablesDNAT_DuplicateAddNoLeak(t *testing.T) { - m := newNftRefcountManager(t, true) - state := m.family4.ipFwdState - - rule := dnatV4(8083) - r1, err := m.AddDNATRule(rule) - require.NoError(t, err, "add v4 dnat") - v4, _ := state.Counts() - assert.Equal(t, 1, v4) - - // duplicate add: same rule ID, must be a no-op for the refcount. - _, err = m.AddDNATRule(rule) - require.NoError(t, err, "duplicate add") - v4, _ = state.Counts() - assert.Equal(t, 1, v4, "duplicate add must not increment") - - require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat") - v4, _ = state.Counts() - assert.Equal(t, 0, v4, "single delete must drop to zero") -} - -// TestNftablesDNAT_DeleteMissingNoUnderflow verifies deleting a rule that was -// never added does not underflow the refcount. -func TestNftablesDNAT_DeleteMissingNoUnderflow(t *testing.T) { - m := newNftRefcountManager(t, true) - state := m.family4.ipFwdState - - // Construct a Rule reference for something never added. The router stores - // rules by ID(), and DeleteDNATRule looks them up in r.rules; a missing - // entry must be a no-op rather than calling Release. - phantom := dnatV4(8099) - require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4 dnat") - v4, v6 := state.Counts() - assert.Equal(t, 0, v4, "v4 refcount unaffected by missing delete") - assert.Equal(t, 0, v6, "v6 refcount unaffected") - - phantom6 := dnatV6(9099) - require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6 dnat") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4) - assert.Equal(t, 0, v6, "v6 refcount unaffected by missing delete") - - // And after a phantom delete, a real add still results in count=1. - r1, err := m.AddDNATRule(dnatV4(8100)) - require.NoError(t, err, "add v4 dnat after phantom delete") - v4, _ = state.Counts() - assert.Equal(t, 1, v4, "real add still increments after phantom delete") - require.NoError(t, m.DeleteDNATRule(r1)) -} - -// TestNftablesRouting_RepeatedEnableSingleReference verifies that EnableRouting -// (called on every network-map update) holds at most one reference per family -// and a single DisableRouting drops both back to zero. -func TestNftablesRouting_RepeatedEnableSingleReference(t *testing.T) { - m := newNftRefcountManager(t, true) - state := m.family4.ipFwdState - - require.NoError(t, m.EnableRouting(), "first enable") - require.NoError(t, m.EnableRouting(), "second enable") - require.NoError(t, m.EnableRouting(), "third enable") - v4, v6 := state.Counts() - assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference") - assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference") - - require.NoError(t, m.DisableRouting(), "disable") - v4, v6 = state.Counts() - assert.Equal(t, 0, v4, "single disable releases the v4 reference") - assert.Equal(t, 0, v6, "single disable releases the v6 reference") -} - -// TestNftablesRouting_DisableKeepsDNATReference verifies that an unpaired -// DisableRouting does not release references held by active DNAT rules. -func TestNftablesRouting_DisableKeepsDNATReference(t *testing.T) { - m := newNftRefcountManager(t, true) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(dnatV6(9095)) - require.NoError(t, err, "add v6 dnat") - - require.NoError(t, m.DisableRouting(), "unpaired disable") - _, v6 := state.Counts() - assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting") - - require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat") - _, v6 = state.Counts() - assert.Equal(t, 0, v6, "delete releases the DNAT reference") -} - -// TestNftablesDNAT_DoubleDeleteNoUnderflow verifies that deleting the same rule -// twice does not underflow the refcount (the second delete is a no-op). -func TestNftablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) { - m := newNftRefcountManager(t, true) - state := m.family4.ipFwdState - - r1, err := m.AddDNATRule(dnatV6(9093)) - require.NoError(t, err) - _, v6 := state.Counts() - assert.Equal(t, 1, v6) - - require.NoError(t, m.DeleteDNATRule(r1), "first delete") - _, v6 = state.Counts() - assert.Equal(t, 0, v6) - - require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op") - _, v6 = state.Counts() - assert.Equal(t, 0, v6, "double delete must not underflow") -} diff --git a/client/firewall/nftables/family_linux.go b/client/firewall/nftables/family_linux.go index 7a5df3ed7..4169c9d2d 100644 --- a/client/firewall/nftables/family_linux.go +++ b/client/firewall/nftables/family_linux.go @@ -24,7 +24,6 @@ const ( tableRaw = "raw" tableSecurity = "security" - chainNameNatPrerouting = "PREROUTING" chainNameRoutingFw = "netbird-rt-fwd" chainNameRoutingNat = "netbird-rt-postrouting" chainNameRoutingRdr = "netbird-rt-redirect" @@ -47,9 +46,6 @@ const ( userDataAcceptForwardRuleOif = "frwacceptoif" userDataAcceptInputRule = "inputaccept" - dnatSuffix firewall.RuleID = "_dnat" - snatSuffix firewall.RuleID = "_snat" - // ipv4TCPHeaderSize is the minimum IPv4 (20) + TCP (20) header size for MSS calculation. ipv4TCPHeaderSize = 40 // ipv6TCPHeaderSize is the minimum IPv6 (40) + TCP (20) header size for MSS calculation. @@ -167,10 +163,6 @@ func (r *family) Reset() error { merr = multierror.Append(merr, err) } - if err := r.removeNatPreroutingRules(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("remove filter prerouting rules: %w", err)) - } - return nberrors.FormatErrorOrNil(merr) } diff --git a/client/firewall/nftables/filter_linux.go b/client/firewall/nftables/filter_linux.go index ebd238063..bb3ac1dfe 100644 --- a/client/firewall/nftables/filter_linux.go +++ b/client/firewall/nftables/filter_linux.go @@ -197,11 +197,6 @@ func (r *family) hasRule(id firewall.RuleID) bool { return ok } -func (r *family) hasDNATRule(id firewall.RuleID) bool { - _, ok := r.rules[id+dnatSuffix] - return ok -} - // DeleteFilterRule removes a previously installed filter rule. Source // set references are recovered from the stored rule's expressions via // findSets and dropped from the shared refcounter. diff --git a/client/firewall/nftables/manager_linux.go b/client/firewall/nftables/manager_linux.go index 87651761f..75405e213 100644 --- a/client/firewall/nftables/manager_linux.go +++ b/client/firewall/nftables/manager_linux.go @@ -252,7 +252,7 @@ func (m *Manager) DeleteFilterRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - fam, err := m.familyForRuleID(rule.ID(), (*family).hasRule, false) + fam, err := m.familyForRuleID(rule.ID(), (*family).hasRule) if err != nil { return err } @@ -260,11 +260,8 @@ func (m *Manager) DeleteFilterRule(rule firewall.Rule) error { } // familyForRuleID picks the family holding the rule with the given id, using -// the supplied lookup. With refresh set, a miss in both cached maps reloads -// the NAT/DNAT rule maps from the kernel once and re-checks before falling -// back to the v4 family. Filter rules are tracked only in memory and have no -// kernel-backed reload, so their callers pass refresh as false. -func (m *Manager) familyForRuleID(id firewall.RuleID, has func(*family, firewall.RuleID) bool, refresh bool) (*family, error) { +// the supplied lookup, and falls back to the v4 family on a miss. +func (m *Manager) familyForRuleID(id firewall.RuleID, has func(*family, firewall.RuleID) bool) (*family, error) { if has(m.family4, id) { return m.family4, nil } @@ -274,18 +271,6 @@ func (m *Manager) familyForRuleID(id firewall.RuleID, has func(*family, firewall if has(m.family6, id) { return m.family6, nil } - if !refresh { - return m.family4, nil - } - if err := m.family4.refreshRulesMap(); err != nil { - return nil, fmt.Errorf("refresh v4 rules: %w", err) - } - if err := m.family6.refreshRulesMap(); err != nil { - return nil, fmt.Errorf("refresh v6 rules: %w", err) - } - if has(m.family6, id) && !has(m.family4, id) { - return m.family6, nil - } return m.family4, nil } @@ -450,32 +435,6 @@ func (m *Manager) Flush() error { return nil } -// AddDNATRule adds a DNAT rule -func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { - m.mutex.Lock() - defer m.mutex.Unlock() - - if rule.TranslatedAddress.Is6() { - if !m.hasIPv6() { - return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized) - } - return m.family6.AddDNATRule(rule) - } - return m.family4.AddDNATRule(rule) -} - -// DeleteDNATRule deletes a DNAT rule -func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - r, err := m.familyForRuleID(rule.ID(), (*family).hasDNATRule, true) - if err != nil { - return err - } - return r.DeleteDNATRule(rule) -} - // UpdateSet updates the set with the given prefixes func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { m.mutex.Lock() diff --git a/client/firewall/nftables/manager_linux_test.go b/client/firewall/nftables/manager_linux_test.go index 0ca56409e..4d6eec3c1 100644 --- a/client/firewall/nftables/manager_linux_test.go +++ b/client/firewall/nftables/manager_linux_test.go @@ -378,18 +378,6 @@ func TestNftablesManagerCompatibilityWithIptables(t *testing.T) { err = manager.AddNatRule(pair) require.NoError(t, err, "failed to add NAT rule") - dnatRule, err := manager.AddDNATRule(fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{8080}}, - TranslatedAddress: netip.MustParseAddr("100.96.0.2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - }) - require.NoError(t, err, "failed to add DNAT rule") - - t.Cleanup(func() { - require.NoError(t, manager.DeleteDNATRule(dnatRule), "failed to delete DNAT rule") - }) - stdout, stderr = runIptablesSave(t) verifyIptablesOutput(t, stdout, stderr) } @@ -453,18 +441,6 @@ func TestNftablesManagerIPv6CompatibilityWithIp6tables(t *testing.T) { }) require.NoError(t, err, "add v6 NAT rule") - dnatRule, err := manager.AddDNATRule(fw.ForwardRule{ - Protocol: fw.ProtocolTCP, - DestinationPort: fw.Port{Values: []uint16{8080}}, - TranslatedAddress: netip.MustParseAddr("fd00::2"), - TranslatedPort: fw.Port{Values: []uint16{80}}, - }) - require.NoError(t, err, "add v6 DNAT rule") - - t.Cleanup(func() { - require.NoError(t, manager.DeleteDNATRule(dnatRule), "delete v6 DNAT rule") - }) - stdout, stderr := runIptablesSave(t) verifyIptablesOutput(t, stdout, stderr) diff --git a/client/firewall/nftables/routing_linux.go b/client/firewall/nftables/routing_linux.go index d619c5543..e98471e8f 100644 --- a/client/firewall/nftables/routing_linux.go +++ b/client/firewall/nftables/routing_linux.go @@ -459,41 +459,6 @@ func (r *family) RemoveAllLegacyRouteRules() error { return nberrors.FormatErrorOrNil(merr) } -func (r *family) removeNatPreroutingRules() error { - table := &nftables.Table{ - Name: tableNat, - Family: r.af.tableFamily, - } - chain := &nftables.Chain{ - Name: chainNameNatPrerouting, - Table: table, - Hooknum: nftables.ChainHookPrerouting, - Priority: nftables.ChainPriorityNATDest, - Type: nftables.ChainTypeNAT, - } - rules, err := r.conn.GetRules(table, chain) - if err != nil { - return fmt.Errorf("get rules from nat table: %w", err) - } - - var merr *multierror.Error - - // Delete rules that have our UserData suffix - for _, rule := range rules { - if len(rule.UserData) == 0 || !strings.HasSuffix(string(rule.UserData), string(dnatSuffix)) { - continue - } - if err := r.conn.DelRule(rule); err != nil { - merr = multierror.Append(merr, fmt.Errorf("delete rule %s: %w", rule.UserData, err)) - } - } - - if err := r.conn.Flush(); err != nil { - merr = multierror.Append(merr, fmt.Errorf(flushError, err)) - } - return nberrors.FormatErrorOrNil(merr) -} - func (r *family) RemoveNatRule(pair firewall.RouterPair) error { if err := r.refreshRulesMap(); err != nil { return fmt.Errorf(refreshRulesMapError, err) diff --git a/client/firewall/uspfilter/nat.go b/client/firewall/uspfilter/nat.go index 06312aabf..49c26766a 100644 --- a/client/firewall/uspfilter/nat.go +++ b/client/firewall/uspfilter/nat.go @@ -486,16 +486,6 @@ func incrementalUpdate(oldChecksum uint16, oldBytes, newBytes []byte) uint16 { return ^uint16(sum) } -// AddDNATRule adds outbound DNAT rule for forwarding external traffic to NetBird network. -func (m *Manager) AddDNATRule(firewall.ForwardRule) (firewall.Rule, error) { - return nil, errNotSupported -} - -// DeleteDNATRule deletes outbound DNAT rule. -func (m *Manager) DeleteDNATRule(firewall.Rule) error { - return errNotSupported -} - // addPortRedirection adds a port redirection rule. func (m *Manager) addPortRedirection(targetIP netip.Addr, protocol gopacket.LayerType, originalPort, translatedPort uint16) error { m.portDNATMutex.Lock() diff --git a/client/internal/engine.go b/client/internal/engine.go index 7e9375771..4d731cbd7 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -42,7 +42,6 @@ import ( dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" "github.com/netbirdio/netbird/client/internal/dnsfwd" "github.com/netbirdio/netbird/client/internal/expose" - "github.com/netbirdio/netbird/client/internal/ingressgw" "github.com/netbirdio/netbird/client/internal/lazyconn" "github.com/netbirdio/netbird/client/internal/metrics" "github.com/netbirdio/netbird/client/internal/netflow" @@ -262,11 +261,10 @@ type Engine struct { statusRecorder *peer.Status - firewall firewallManager.Manager - routeManager routemanager.Manager - acl acl.Manager - dnsForwardMgr *dnsfwd.Manager - ingressGatewayMgr *ingressgw.Manager + firewall firewallManager.Manager + routeManager routemanager.Manager + acl acl.Manager + dnsForwardMgr *dnsfwd.Manager dnsServer dns.Server @@ -448,13 +446,6 @@ func (e *Engine) stopLocked() { e.cleanupSSHConfig() - if e.ingressGatewayMgr != nil { - if err := e.ingressGatewayMgr.Close(); err != nil { - log.Warnf("failed to cleanup forward rules: %v", err) - } - e.ingressGatewayMgr = nil - } - if e.srWatcher != nil { e.srWatcher.Close() } @@ -1627,13 +1618,6 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error { e.updateDNSForwarder(dnsRouteFeatureFlag, fwdEntries) done() - // Ingress forward rules - done = e.phase("forward_rules") - if _, err := e.updateForwardRules(networkMap.GetForwardingRules()); err != nil { - log.Errorf("failed to update forward rules, err: %v", err) - } - done() - log.Debugf("got peers update from Management Service, total peers to connect to = %d", len(networkMap.GetRemotePeers())) done = e.phase("offline_peers") @@ -2733,74 +2717,6 @@ func (e *Engine) setForwarderCapture(pc device.PacketCapture) { } } -func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewallManager.ForwardRule, error) { - if e.firewall == nil { - log.Warn("firewall is disabled, not updating forwarding rules") - return nil, nil - } - - if len(rules) == 0 { - if e.ingressGatewayMgr == nil { - return nil, nil - } - - err := e.ingressGatewayMgr.Close() - e.ingressGatewayMgr = nil - e.statusRecorder.SetIngressGwMgr(nil) - return nil, err - } - - if e.ingressGatewayMgr == nil { - mgr := ingressgw.NewManager(e.firewall) - e.ingressGatewayMgr = mgr - e.statusRecorder.SetIngressGwMgr(mgr) - } - - var merr *multierror.Error - forwardingRules := make([]firewallManager.ForwardRule, 0, len(rules)) - for _, rule := range rules { - proto, err := acl.ConvertToFirewallProtocol(rule.GetProtocol()) - if err != nil { - merr = multierror.Append(merr, fmt.Errorf("failed to convert protocol '%s': %w", rule.GetProtocol(), err)) - continue - } - - dstPortInfo, err := convertPortInfo(rule.GetDestinationPort()) - if err != nil { - merr = multierror.Append(merr, fmt.Errorf("invalid destination port '%v': %w", rule.GetDestinationPort(), err)) - continue - } - - translateIP, err := convertToIP(rule.GetTranslatedAddress()) - if err != nil { - merr = multierror.Append(merr, fmt.Errorf("failed to convert translated address '%s': %w", rule.GetTranslatedAddress(), err)) - continue - } - - translatePort, err := convertPortInfo(rule.GetTranslatedPort()) - if err != nil { - merr = multierror.Append(merr, fmt.Errorf("invalid translate port '%v': %w", rule.GetTranslatedPort(), err)) - continue - } - - forwardRule := firewallManager.ForwardRule{ - Protocol: proto, - DestinationPort: *dstPortInfo, - TranslatedAddress: translateIP, - TranslatedPort: *translatePort, - } - - forwardingRules = append(forwardingRules, forwardRule) - } - - log.Infof("updating forwarding rules: %d", len(forwardingRules)) - if err := e.ingressGatewayMgr.Update(forwardingRules); err != nil { - log.Errorf("failed to update forwarding rules: %v", err) - } - - return forwardingRules, nberrors.FormatErrorOrNil(merr) -} - // toExcludedLazyPeers returns the peers that must have an always-active // connection: those that are not lazy by policy (the per-peer lazy state or the // account flag, subject to the local override). diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go index 2db0cd5ed..4449b5788 100644 --- a/client/internal/engine_privileged_test.go +++ b/client/internal/engine_privileged_test.go @@ -42,7 +42,6 @@ import ( nbcache "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -523,8 +522,8 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) - networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil) - accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", manager.NewEphemeralManager(store, peersManager), config, nil) + accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, "", err } diff --git a/client/internal/ingressgw/manager.go b/client/internal/ingressgw/manager.go deleted file mode 100644 index 605543d1c..000000000 --- a/client/internal/ingressgw/manager.go +++ /dev/null @@ -1,111 +0,0 @@ -package ingressgw - -import ( - "fmt" - "sync" - - "github.com/hashicorp/go-multierror" - log "github.com/sirupsen/logrus" - - nberrors "github.com/netbirdio/netbird/client/errors" - firewall "github.com/netbirdio/netbird/client/firewall/manager" -) - -type DNATFirewall interface { - AddDNATRule(fwdRule firewall.ForwardRule) (firewall.Rule, error) - DeleteDNATRule(rule firewall.Rule) error -} - -type RulePair struct { - firewall.ForwardRule - firewall.Rule -} - -type Manager struct { - dnatFirewall DNATFirewall - - rules map[firewall.RuleID]RulePair - rulesMu sync.Mutex -} - -func NewManager(dnatFirewall DNATFirewall) *Manager { - return &Manager{ - dnatFirewall: dnatFirewall, - rules: make(map[firewall.RuleID]RulePair), - } -} - -func (h *Manager) Update(forwardRules []firewall.ForwardRule) error { - h.rulesMu.Lock() - defer h.rulesMu.Unlock() - - var mErr *multierror.Error - - toDelete := make(map[firewall.RuleID]RulePair, len(h.rules)) - for id, r := range h.rules { - toDelete[id] = r - } - - // Process new/updated rules - for _, fwdRule := range forwardRules { - id := fwdRule.ID() - if _, ok := h.rules[id]; ok { - delete(toDelete, id) - continue - } - - rule, err := h.dnatFirewall.AddDNATRule(fwdRule) - if err != nil { - mErr = multierror.Append(mErr, fmt.Errorf("add forward rule '%s': %v", fwdRule.String(), err)) - continue - } - if rule == nil { - mErr = multierror.Append(mErr, fmt.Errorf("add forward rule '%s': backend returned no rule", fwdRule.String())) - continue - } - log.Infof("forward rule has been added '%s'", fwdRule) - h.rules[id] = RulePair{ - ForwardRule: fwdRule, - Rule: rule, - } - } - - // Remove deleted rules - for id, rulePair := range toDelete { - if err := h.dnatFirewall.DeleteDNATRule(rulePair.Rule); err != nil { - mErr = multierror.Append(mErr, fmt.Errorf("failed to delete forward rule '%s': %v", rulePair.ForwardRule.String(), err)) - } - log.Infof("forward rule has been deleted '%s'", rulePair.ForwardRule) - delete(h.rules, id) - } - - return nberrors.FormatErrorOrNil(mErr) -} - -func (h *Manager) Close() error { - h.rulesMu.Lock() - defer h.rulesMu.Unlock() - - log.Infof("clean up all (%d) forward rules", len(h.rules)) - var mErr *multierror.Error - for _, rule := range h.rules { - if err := h.dnatFirewall.DeleteDNATRule(rule.Rule); err != nil { - mErr = multierror.Append(mErr, fmt.Errorf("failed to delete forward rule '%s': %v", rule, err)) - } - } - - h.rules = make(map[firewall.RuleID]RulePair) - return nberrors.FormatErrorOrNil(mErr) -} - -func (h *Manager) Rules() []firewall.ForwardRule { - h.rulesMu.Lock() - defer h.rulesMu.Unlock() - - rules := make([]firewall.ForwardRule, 0, len(h.rules)) - for _, rulePair := range h.rules { - rules = append(rules, rulePair.ForwardRule) - } - - return rules -} diff --git a/client/internal/ingressgw/manager_test.go b/client/internal/ingressgw/manager_test.go deleted file mode 100644 index 0cd40fcc4..000000000 --- a/client/internal/ingressgw/manager_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package ingressgw - -import ( - "fmt" - "net/netip" - "testing" - - firewall "github.com/netbirdio/netbird/client/firewall/manager" -) - -var ( - _ firewall.Rule = (*MocFwRule)(nil) - _ DNATFirewall = &MockDNATFirewall{} -) - -type MocFwRule struct { - id firewall.RuleID -} - -func (m *MocFwRule) ID() firewall.RuleID { - return m.id -} - -type MockDNATFirewall struct { - throwError bool -} - -func (m *MockDNATFirewall) AddDNATRule(fwdRule firewall.ForwardRule) (firewall.Rule, error) { - if m.throwError { - return nil, fmt.Errorf("moc error") - } - - fwRule := &MocFwRule{ - id: fwdRule.ID(), - } - return fwRule, nil -} - -func (m *MockDNATFirewall) DeleteDNATRule(rule firewall.Rule) error { - if m.throwError { - return fmt.Errorf("moc error") - } - return nil -} - -func (m *MockDNATFirewall) forceToThrowErrors() { - m.throwError = true -} - -func TestManager_AddRule(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - - updates := []firewall.ForwardRule{ - { - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - }, - { - Protocol: firewall.ProtocolUDP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - }} - - if err := mgr.Update(updates); err != nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != len(updates) { - t.Errorf("unexpected rules count: %d", len(rules)) - } -} - -func TestManager_UpdateRule(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - ruleUDP := firewall.ForwardRule{ - Protocol: firewall.ProtocolUDP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.2"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleUDP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != 1 { - t.Errorf("unexpected rules count: %d", len(rules)) - } - - if rules[0].TranslatedAddress.String() != ruleUDP.TranslatedAddress.String() { - t.Errorf("unexpected rule: %v", rules[0]) - } - - if rules[0].TranslatedPort.String() != ruleUDP.TranslatedPort.String() { - t.Errorf("unexpected rule: %v", rules[0]) - } - - if rules[0].DestinationPort.String() != ruleUDP.DestinationPort.String() { - t.Errorf("unexpected rule: %v", rules[0]) - } - - if rules[0].Protocol != ruleUDP.Protocol { - t.Errorf("unexpected rule: %v", rules[0]) - } -} - -func TestManager_ExtendRules(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - ruleUDP := firewall.ForwardRule{ - Protocol: firewall.ProtocolUDP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.2"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP, ruleUDP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != 2 { - t.Errorf("unexpected rules count: %d", len(rules)) - } -} - -func TestManager_UnderlingError(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - ruleUDP := firewall.ForwardRule{ - Protocol: firewall.ProtocolUDP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.2"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - fw.forceToThrowErrors() - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP, ruleUDP}); err == nil { - t.Errorf("expected error") - } - - rules := mgr.Rules() - if len(rules) != 1 { - t.Errorf("unexpected rules count: %d", len(rules)) - } -} - -func TestManager_Cleanup(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - if err := mgr.Update([]firewall.ForwardRule{}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != 0 { - t.Errorf("unexpected rules count: %d", len(rules)) - } -} - -func TestManager_DeleteBrokenRule(t *testing.T) { - fw := &MockDNATFirewall{} - - // force to throw errors when Add DNAT Rule - fw.forceToThrowErrors() - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err == nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != 0 { - t.Errorf("unexpected rules count: %d", len(rules)) - } - - // simulate that to remove a broken rule - if err := mgr.Update([]firewall.ForwardRule{}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - if err := mgr.Close(); err != nil { - t.Errorf("unexpected error: %v", err) - } -} - -func TestManager_Close(t *testing.T) { - fw := &MockDNATFirewall{} - mgr := NewManager(fw) - - port, _ := firewall.NewPort(8080) - ruleTCP := firewall.ForwardRule{ - Protocol: firewall.ProtocolTCP, - DestinationPort: *port, - TranslatedAddress: netip.MustParseAddr("172.16.254.1"), - TranslatedPort: *port, - } - - if err := mgr.Update([]firewall.ForwardRule{ruleTCP}); err != nil { - t.Errorf("unexpected error: %v", err) - } - - if err := mgr.Close(); err != nil { - t.Errorf("unexpected error: %v", err) - } - - rules := mgr.Rules() - if len(rules) != 0 { - t.Errorf("unexpected rules count: %d", len(rules)) - } -} diff --git a/client/internal/message_convert.go b/client/internal/message_convert.go deleted file mode 100644 index 60f19e228..000000000 --- a/client/internal/message_convert.go +++ /dev/null @@ -1,43 +0,0 @@ -package internal - -import ( - "errors" - "fmt" - "net" - "net/netip" - - firewallManager "github.com/netbirdio/netbird/client/firewall/manager" - mgmProto "github.com/netbirdio/netbird/shared/management/proto" -) - -func convertPortInfo(portInfo *mgmProto.PortInfo) (*firewallManager.Port, error) { - if portInfo == nil { - return nil, errors.New("portInfo cannot be nil") - } - - if portInfo.GetPort() != 0 { - return firewallManager.NewPort(int(portInfo.GetPort())) - } - - if portInfo.GetRange() != nil { - return firewallManager.NewPort(int(portInfo.GetRange().Start), int(portInfo.GetRange().End)) - } - - return nil, fmt.Errorf("invalid portInfo: %v", portInfo) -} - -func convertToIP(rawIP []byte) (netip.Addr, error) { - if rawIP == nil { - return netip.Addr{}, errors.New("input bytes cannot be nil") - } - - if len(rawIP) != net.IPv4len && len(rawIP) != net.IPv6len { - return netip.Addr{}, fmt.Errorf("invalid IP length: %d", len(rawIP)) - } - - if len(rawIP) == net.IPv4len { - return netip.AddrFrom4([4]byte(rawIP)), nil - } - - return netip.AddrFrom16([16]byte(rawIP)), nil -} diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 826bf6fe0..6c44178e1 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -18,9 +18,7 @@ import ( "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" - firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface/configurer" - "github.com/netbirdio/netbird/client/internal/ingressgw" "github.com/netbirdio/netbird/client/internal/relay" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/route" @@ -161,7 +159,6 @@ type FullStatus struct { RosenpassState RosenpassState Relays []relay.ProbeResult NSGroupStates []NSGroupState - NumOfForwardingRules int LazyConnectionEnabled bool Events []*proto.SystemEvent } @@ -247,8 +244,6 @@ type Status struct { // read it without taking mux. networksRevision atomic.Uint64 - ingressGwMgr *ingressgw.Manager - routeIDLookup routeIDLookup wgIface WGIfaceStatus } @@ -276,12 +271,6 @@ func (d *Status) SetRelayMgr(manager *relayClient.Manager) { d.relayMgr = manager } -func (d *Status) SetIngressGwMgr(ingressGwMgr *ingressgw.Manager) { - d.mux.Lock() - defer d.mux.Unlock() - d.ingressGwMgr = ingressGwMgr -} - // ReplaceOfflinePeers replaces func (d *Status) ReplaceOfflinePeers(replacement []State) { d.mux.Lock() @@ -332,18 +321,6 @@ func (d *Status) GetPeer(peerPubKey string) (State, error) { return state, nil } -func (d *Status) PeerByIP(ip string) (string, bool) { - d.mux.RLock() - defer d.mux.RUnlock() - - for _, state := range d.peers { - if state.IP == ip { - return state.FQDN, true - } - } - return "", false -} - // PeerStateByIP returns the full peer State for the given tunnel IP. // Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel // address so dual-stack peers are reachable on either family. Only @@ -1163,16 +1140,6 @@ func (d *Status) GetRelayStates() []relay.ProbeResult { return relayStates } -func (d *Status) ForwardingRules() []firewall.ForwardRule { - d.mux.RLock() - defer d.mux.RUnlock() - if d.ingressGwMgr == nil { - return nil - } - - return d.ingressGwMgr.Rules() -} - func (d *Status) GetDNSStates() []NSGroupState { d.mux.RLock() defer d.mux.RUnlock() @@ -1207,7 +1174,6 @@ func (d *Status) GetFullStatus() FullStatus { Relays: d.GetRelayStates(), RosenpassState: d.GetRosenpassState(), NSGroupStates: d.GetDNSStates(), - NumOfForwardingRules: len(d.ForwardingRules()), LazyConnectionEnabled: d.GetLazyConnection(), } @@ -1579,7 +1545,6 @@ func (fs FullStatus) ToProto() *proto.FullStatus { pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort) pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled - pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules) pbFullStatus.LazyConnectionEnabled = fs.LazyConnectionEnabled pbFullStatus.LocalPeerState.Networks = maps.Keys(fs.LocalPeerState.Routes) diff --git a/client/internal/routemanager/ipfwdstate/ipfwdstate.go b/client/internal/routemanager/ipfwdstate/ipfwdstate.go index 3d571e16b..22f7bd07a 100644 --- a/client/internal/routemanager/ipfwdstate/ipfwdstate.go +++ b/client/internal/routemanager/ipfwdstate/ipfwdstate.go @@ -19,8 +19,7 @@ type IPForwardingState struct { // routingV4/routingV6 track whether the routing path currently holds a // reference, so repeated EnableRouting calls (one per network-map update) - // hold at most one reference per family and an unpaired DisableRouting - // can't release references held by DNAT rules. + // hold at most one reference per family. routingV4 bool routingV6 bool @@ -95,31 +94,6 @@ func (f *IPForwardingState) ReleaseRouting() error { return nil } -// RequestForwarding enables the family's forwarding sysctl on first request. -func (f *IPForwardingState) RequestForwarding(v6 bool) error { - f.mu.Lock() - defer f.mu.Unlock() - - if v6 { - return f.requestV6() - } - return f.requestV4() -} - -// ReleaseForwarding decrements the family counter. The last v6 release restores -// what enable captured. v4 stays on: net.ipv4.ip_forward is co-owned by other -// tooling (docker, k8s, libvirt). -func (f *IPForwardingState) ReleaseForwarding(v6 bool) error { - f.mu.Lock() - defer f.mu.Unlock() - - if v6 { - return f.releaseV6() - } - f.releaseV4() - return nil -} - func (f *IPForwardingState) requestV4() error { if f.v4Count == 0 { if err := systemops.EnableV4IPForwarding(); err != nil { diff --git a/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go b/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go index b4615ff02..75209965c 100644 --- a/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go +++ b/client/internal/routemanager/ipfwdstate/ipfwdstate_privileged_linux_test.go @@ -10,8 +10,7 @@ import ( ) // TestRequestRoutingV6ToV4Transition verifies that a v4-only routing request -// releases a previously held routing-owned v6 reference without touching -// references held by DNAT rules. +// releases a previously held routing-owned v6 reference. func TestRequestRoutingV6ToV4Transition(t *testing.T) { f := NewIPForwardingState("wt-fwd-test") @@ -25,13 +24,6 @@ func TestRequestRoutingV6ToV4Transition(t *testing.T) { assert.Equal(t, 1, v4, "v4 reference kept") assert.Equal(t, 0, v6, "routing-owned v6 reference released") - // A DNAT-held reference survives a v4-only routing request. - require.NoError(t, f.RequestForwarding(true), "dnat v6 reference") - require.NoError(t, f.RequestRouting(false), "repeat v4-only request") - _, v6 = f.Counts() - assert.Equal(t, 1, v6, "dnat-held v6 reference survives") - require.NoError(t, f.ReleaseForwarding(true), "release dnat v6 reference") - require.NoError(t, f.ReleaseRouting(), "release routing") v4, v6 = f.Counts() assert.Equal(t, 0, v4, "all v4 references released") diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 7f3ce1bbf..ec0cd6258 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -2176,17 +2176,20 @@ func (x *SSHServerState) GetSessions() []*SSHSessionInfo { // FullStatus contains the full state held by the Status instance type FullStatus struct { - state protoimpl.MessageState `protogen:"open.v1"` - ManagementState *ManagementState `protobuf:"bytes,1,opt,name=managementState,proto3" json:"managementState,omitempty"` - SignalState *SignalState `protobuf:"bytes,2,opt,name=signalState,proto3" json:"signalState,omitempty"` - LocalPeerState *LocalPeerState `protobuf:"bytes,3,opt,name=localPeerState,proto3" json:"localPeerState,omitempty"` - Peers []*PeerState `protobuf:"bytes,4,rep,name=peers,proto3" json:"peers,omitempty"` - Relays []*RelayState `protobuf:"bytes,5,rep,name=relays,proto3" json:"relays,omitempty"` - DnsServers []*NSGroupState `protobuf:"bytes,6,rep,name=dns_servers,json=dnsServers,proto3" json:"dns_servers,omitempty"` - NumberOfForwardingRules int32 `protobuf:"varint,8,opt,name=NumberOfForwardingRules,proto3" json:"NumberOfForwardingRules,omitempty"` - Events []*SystemEvent `protobuf:"bytes,7,rep,name=events,proto3" json:"events,omitempty"` - LazyConnectionEnabled bool `protobuf:"varint,9,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"` - SshServerState *SSHServerState `protobuf:"bytes,10,opt,name=sshServerState,proto3" json:"sshServerState,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + ManagementState *ManagementState `protobuf:"bytes,1,opt,name=managementState,proto3" json:"managementState,omitempty"` + SignalState *SignalState `protobuf:"bytes,2,opt,name=signalState,proto3" json:"signalState,omitempty"` + LocalPeerState *LocalPeerState `protobuf:"bytes,3,opt,name=localPeerState,proto3" json:"localPeerState,omitempty"` + Peers []*PeerState `protobuf:"bytes,4,rep,name=peers,proto3" json:"peers,omitempty"` + Relays []*RelayState `protobuf:"bytes,5,rep,name=relays,proto3" json:"relays,omitempty"` + DnsServers []*NSGroupState `protobuf:"bytes,6,rep,name=dns_servers,json=dnsServers,proto3" json:"dns_servers,omitempty"` + // Unused; the ingress port-forwarding feature was discontinued. + // + // Deprecated: Marked as deprecated in daemon.proto. + NumberOfForwardingRules int32 `protobuf:"varint,8,opt,name=NumberOfForwardingRules,proto3" json:"NumberOfForwardingRules,omitempty"` + Events []*SystemEvent `protobuf:"bytes,7,rep,name=events,proto3" json:"events,omitempty"` + LazyConnectionEnabled bool `protobuf:"varint,9,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"` + SshServerState *SSHServerState `protobuf:"bytes,10,opt,name=sshServerState,proto3" json:"sshServerState,omitempty"` // networksRevision bumps whenever the set of routed networks (route and // exit-node candidates) or their selected state changes. The UI fingerprints // on it to know when to re-fetch ListNetworks via the push stream, instead @@ -2268,6 +2271,7 @@ func (x *FullStatus) GetDnsServers() []*NSGroupState { return nil } +// Deprecated: Marked as deprecated in daemon.proto. func (x *FullStatus) GetNumberOfForwardingRules() int32 { if x != nil { return x.NumberOfForwardingRules @@ -2600,7 +2604,10 @@ func (x *Network) GetResolvedIPs() map[string]*IPList { return nil } -// ForwardingRules +// PortInfo, ForwardingRule and ForwardingRulesResponse are unused; the ingress +// port-forwarding feature was discontinued. +// +// Deprecated: Marked as deprecated in daemon.proto. type PortInfo struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to PortSelection: @@ -2683,6 +2690,7 @@ func (*PortInfo_Port) isPortInfo_PortSelection() {} func (*PortInfo_Range_) isPortInfo_PortSelection() {} +// Deprecated: Marked as deprecated in daemon.proto. type ForwardingRule struct { state protoimpl.MessageState `protogen:"open.v1"` Protocol string `protobuf:"bytes,1,opt,name=protocol,proto3" json:"protocol,omitempty"` @@ -2759,6 +2767,7 @@ func (x *ForwardingRule) GetTranslatedPort() *PortInfo { return nil } +// Deprecated: Marked as deprecated in daemon.proto. type ForwardingRulesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Rules []*ForwardingRule `protobuf:"bytes,1,rep,name=rules,proto3" json:"rules,omitempty"` @@ -7303,7 +7312,7 @@ const file_daemon_proto_rawDesc = "" + "\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" + "\x0eSSHServerState\x12\x18\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" + - "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xdb\x04\n" + + "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xdf\x04\n" + "\n" + "FullStatus\x12A\n" + "\x0fmanagementState\x18\x01 \x01(\v2\x17.daemon.ManagementStateR\x0fmanagementState\x125\n" + @@ -7312,8 +7321,8 @@ const file_daemon_proto_rawDesc = "" + "\x05peers\x18\x04 \x03(\v2\x11.daemon.PeerStateR\x05peers\x12*\n" + "\x06relays\x18\x05 \x03(\v2\x12.daemon.RelayStateR\x06relays\x125\n" + "\vdns_servers\x18\x06 \x03(\v2\x14.daemon.NSGroupStateR\n" + - "dnsServers\x128\n" + - "\x17NumberOfForwardingRules\x18\b \x01(\x05R\x17NumberOfForwardingRules\x12+\n" + + "dnsServers\x12<\n" + + "\x17NumberOfForwardingRules\x18\b \x01(\x05B\x02\x18\x01R\x17NumberOfForwardingRules\x12+\n" + "\x06events\x18\a \x03(\v2\x13.daemon.SystemEventR\x06events\x124\n" + "\x15lazyConnectionEnabled\x18\t \x01(\bR\x15lazyConnectionEnabled\x12>\n" + "\x0esshServerState\x18\n" + @@ -7339,22 +7348,22 @@ const file_daemon_proto_rawDesc = "" + "\vresolvedIPs\x18\x05 \x03(\v2 .daemon.Network.ResolvedIPsEntryR\vresolvedIPs\x1aN\n" + "\x10ResolvedIPsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12$\n" + - "\x05value\x18\x02 \x01(\v2\x0e.daemon.IPListR\x05value:\x028\x01\"\x92\x01\n" + + "\x05value\x18\x02 \x01(\v2\x0e.daemon.IPListR\x05value:\x028\x01\"\x96\x01\n" + "\bPortInfo\x12\x14\n" + "\x04port\x18\x01 \x01(\rH\x00R\x04port\x12.\n" + "\x05range\x18\x02 \x01(\v2\x16.daemon.PortInfo.RangeH\x00R\x05range\x1a/\n" + "\x05Range\x12\x14\n" + "\x05start\x18\x01 \x01(\rR\x05start\x12\x10\n" + - "\x03end\x18\x02 \x01(\rR\x03endB\x0f\n" + - "\rportSelection\"\x80\x02\n" + + "\x03end\x18\x02 \x01(\rR\x03end:\x02\x18\x01B\x0f\n" + + "\rportSelection\"\x84\x02\n" + "\x0eForwardingRule\x12\x1a\n" + "\bprotocol\x18\x01 \x01(\tR\bprotocol\x12:\n" + "\x0fdestinationPort\x18\x02 \x01(\v2\x10.daemon.PortInfoR\x0fdestinationPort\x12,\n" + "\x11translatedAddress\x18\x03 \x01(\tR\x11translatedAddress\x12.\n" + "\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" + - "\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" + + "\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort:\x02\x18\x01\"K\n" + "\x17ForwardingRulesResponse\x12,\n" + - "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x84\x02\n" + + "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules:\x02\x18\x01\"\x84\x02\n" + "\x12DebugBundleRequest\x12\x1c\n" + "\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" + "\n" + @@ -7705,7 +7714,7 @@ const file_daemon_proto_rawDesc = "" + "\n" + "EXPOSE_UDP\x10\x03\x12\x0e\n" + "\n" + - "EXPOSE_TLS\x10\x042\xa3\x1c\n" + + "EXPOSE_TLS\x10\x042\xa6\x1c\n" + "\rDaemonService\x126\n" + "\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" + "\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" + @@ -7716,8 +7725,8 @@ const file_daemon_proto_rawDesc = "" + "\tGetConfig\x12\x18.daemon.GetConfigRequest\x1a\x19.daemon.GetConfigResponse\"\x00\x12K\n" + "\fListNetworks\x12\x1b.daemon.ListNetworksRequest\x1a\x1c.daemon.ListNetworksResponse\"\x00\x12Q\n" + "\x0eSelectNetworks\x12\x1d.daemon.SelectNetworksRequest\x1a\x1e.daemon.SelectNetworksResponse\"\x00\x12S\n" + - "\x10DeselectNetworks\x12\x1d.daemon.SelectNetworksRequest\x1a\x1e.daemon.SelectNetworksResponse\"\x00\x12J\n" + - "\x0fForwardingRules\x12\x14.daemon.EmptyRequest\x1a\x1f.daemon.ForwardingRulesResponse\"\x00\x12H\n" + + "\x10DeselectNetworks\x12\x1d.daemon.SelectNetworksRequest\x1a\x1e.daemon.SelectNetworksResponse\"\x00\x12M\n" + + "\x0fForwardingRules\x12\x14.daemon.EmptyRequest\x1a\x1f.daemon.ForwardingRulesResponse\"\x03\x88\x02\x01\x12H\n" + "\vDebugBundle\x12\x1a.daemon.DebugBundleRequest\x1a\x1b.daemon.DebugBundleResponse\"\x00\x12H\n" + "\vGetLogLevel\x12\x1a.daemon.GetLogLevelRequest\x1a\x1b.daemon.GetLogLevelResponse\"\x00\x12H\n" + "\vSetLogLevel\x12\x1a.daemon.SetLogLevelRequest\x1a\x1b.daemon.SetLogLevelResponse\"\x00\x12E\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 3953f9c15..39a8ea7c6 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -45,7 +45,10 @@ service DaemonService { // Deselect specific routes rpc DeselectNetworks(SelectNetworksRequest) returns (SelectNetworksResponse) {} - rpc ForwardingRules(EmptyRequest) returns (ForwardingRulesResponse) {} + // Unused; the ingress port-forwarding feature was discontinued. + rpc ForwardingRules(EmptyRequest) returns (ForwardingRulesResponse) { + option deprecated = true; + } // DebugBundle creates a debug bundle rpc DebugBundle(DebugBundleRequest) returns (DebugBundleResponse) {} @@ -468,7 +471,8 @@ message FullStatus { repeated PeerState peers = 4; repeated RelayState relays = 5; repeated NSGroupState dns_servers = 6; - int32 NumberOfForwardingRules = 8; + // Unused; the ingress port-forwarding feature was discontinued. + int32 NumberOfForwardingRules = 8 [deprecated = true]; repeated SystemEvent events = 7; @@ -511,8 +515,11 @@ message Network { map resolvedIPs = 5; } -// ForwardingRules +// PortInfo, ForwardingRule and ForwardingRulesResponse are unused; the ingress +// port-forwarding feature was discontinued. message PortInfo { + option deprecated = true; + oneof portSelection { uint32 port = 1; Range range = 2; @@ -525,6 +532,8 @@ message PortInfo { } message ForwardingRule { + option deprecated = true; + string protocol = 1; PortInfo destinationPort = 2; string translatedAddress = 3; @@ -533,10 +542,11 @@ message ForwardingRule { } message ForwardingRulesResponse { + option deprecated = true; + repeated ForwardingRule rules = 1; } - // DebugBundler message DebugBundleRequest { bool anonymize = 1; diff --git a/client/proto/daemon_grpc.pb.go b/client/proto/daemon_grpc.pb.go index 2d01d474d..c9b291e14 100644 --- a/client/proto/daemon_grpc.pb.go +++ b/client/proto/daemon_grpc.pb.go @@ -95,6 +95,8 @@ type DaemonServiceClient interface { SelectNetworks(ctx context.Context, in *SelectNetworksRequest, opts ...grpc.CallOption) (*SelectNetworksResponse, error) // Deselect specific routes DeselectNetworks(ctx context.Context, in *SelectNetworksRequest, opts ...grpc.CallOption) (*SelectNetworksResponse, error) + // Deprecated: Do not use. + // Unused; the ingress port-forwarding feature was discontinued. ForwardingRules(ctx context.Context, in *EmptyRequest, opts ...grpc.CallOption) (*ForwardingRulesResponse, error) // DebugBundle creates a debug bundle DebugBundle(ctx context.Context, in *DebugBundleRequest, opts ...grpc.CallOption) (*DebugBundleResponse, error) @@ -290,6 +292,7 @@ func (c *daemonServiceClient) DeselectNetworks(ctx context.Context, in *SelectNe return out, nil } +// Deprecated: Do not use. func (c *daemonServiceClient) ForwardingRules(ctx context.Context, in *EmptyRequest, opts ...grpc.CallOption) (*ForwardingRulesResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ForwardingRulesResponse) @@ -705,6 +708,8 @@ type DaemonServiceServer interface { SelectNetworks(context.Context, *SelectNetworksRequest) (*SelectNetworksResponse, error) // Deselect specific routes DeselectNetworks(context.Context, *SelectNetworksRequest) (*SelectNetworksResponse, error) + // Deprecated: Do not use. + // Unused; the ingress port-forwarding feature was discontinued. ForwardingRules(context.Context, *EmptyRequest) (*ForwardingRulesResponse, error) // DebugBundle creates a debug bundle DebugBundle(context.Context, *DebugBundleRequest) (*DebugBundleResponse, error) diff --git a/client/server/forwardingrules.go b/client/server/forwardingrules.go deleted file mode 100644 index 3d706c36d..000000000 --- a/client/server/forwardingrules.go +++ /dev/null @@ -1,54 +0,0 @@ -package server - -import ( - "context" - - firewall "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/proto" -) - -func (s *Server) ForwardingRules(context.Context, *proto.EmptyRequest) (*proto.ForwardingRulesResponse, error) { - s.mutex.Lock() - defer s.mutex.Unlock() - - rules := s.statusRecorder.ForwardingRules() - responseRules := make([]*proto.ForwardingRule, 0, len(rules)) - for _, rule := range rules { - respRule := &proto.ForwardingRule{ - Protocol: string(rule.Protocol), - DestinationPort: portToProto(rule.DestinationPort), - TranslatedAddress: rule.TranslatedAddress.String(), - TranslatedHostname: s.hostNameByTranslateAddress(rule.TranslatedAddress.String()), - TranslatedPort: portToProto(rule.TranslatedPort), - } - responseRules = append(responseRules, respRule) - - } - - return &proto.ForwardingRulesResponse{Rules: responseRules}, nil -} - -func (s *Server) hostNameByTranslateAddress(ip string) string { - hostName, ok := s.statusRecorder.PeerByIP(ip) - if !ok { - return ip - } - - return hostName -} - -func portToProto(port firewall.Port) *proto.PortInfo { - var portInfo proto.PortInfo - - if !port.IsRange { - portInfo.PortSelection = &proto.PortInfo_Port{Port: uint32(port.Values[0])} - } else { - portInfo.PortSelection = &proto.PortInfo_Range_{ - Range: &proto.PortInfo_Range{ - Start: uint32(port.Values[0]), - End: uint32(port.Values[1]), - }, - } - } - return &portInfo -} diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go index aa6e99026..bea2e8568 100644 --- a/client/server/server_privileged_test.go +++ b/client/server/server_privileged_test.go @@ -10,9 +10,9 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" + "go.uber.org/mock/gomock" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" @@ -36,7 +36,6 @@ import ( "github.com/netbirdio/netbird/management/server" "github.com/netbirdio/netbird/management/server/activity" nbcache "github.com/netbirdio/netbird/management/server/cache" - "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" @@ -200,8 +199,8 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve requestBuffer := server.NewAccountRequestBuffer(context.Background(), store) peersUpdateManager := update_channel.NewPeersUpdateManager(metrics) - networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil) - accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) + networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", manager.NewEphemeralManager(store, peersManager), config, nil) + accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { return nil, "", err } diff --git a/client/status/status.go b/client/status/status.go index 1c204cdb1..e0585b8f0 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -140,28 +140,27 @@ type SSHServerStateOutput struct { } type OutputOverview struct { - Peers PeersStateOutput `json:"peers" yaml:"peers"` - CliVersion string `json:"cliVersion" yaml:"cliVersion"` - DaemonVersion string `json:"daemonVersion" yaml:"daemonVersion"` - DaemonStatus DaemonStatus `json:"daemonStatus" yaml:"daemonStatus"` - ManagementState ManagementStateOutput `json:"management" yaml:"management"` - SignalState SignalStateOutput `json:"signal" yaml:"signal"` - Relays RelayStateOutput `json:"relays" yaml:"relays"` - IP string `json:"netbirdIp" yaml:"netbirdIp"` - IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"` - PubKey string `json:"publicKey" yaml:"publicKey"` - KernelInterface bool `json:"usesKernelInterface" yaml:"usesKernelInterface"` - WgPort int `json:"wireguardPort" yaml:"wireguardPort"` - FQDN string `json:"fqdn" yaml:"fqdn"` - RosenpassEnabled bool `json:"quantumResistance" yaml:"quantumResistance"` - RosenpassPermissive bool `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"` - Networks []string `json:"networks" yaml:"networks"` - NumberOfForwardingRules int `json:"forwardingRules" yaml:"forwardingRules"` - NSServerGroups []NsServerGroupStateOutput `json:"dnsServers" yaml:"dnsServers"` - Events []SystemEventOutput `json:"events" yaml:"events"` - LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"` - ProfileName string `json:"profileName" yaml:"profileName"` - SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"` + Peers PeersStateOutput `json:"peers" yaml:"peers"` + CliVersion string `json:"cliVersion" yaml:"cliVersion"` + DaemonVersion string `json:"daemonVersion" yaml:"daemonVersion"` + DaemonStatus DaemonStatus `json:"daemonStatus" yaml:"daemonStatus"` + ManagementState ManagementStateOutput `json:"management" yaml:"management"` + SignalState SignalStateOutput `json:"signal" yaml:"signal"` + Relays RelayStateOutput `json:"relays" yaml:"relays"` + IP string `json:"netbirdIp" yaml:"netbirdIp"` + IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"` + PubKey string `json:"publicKey" yaml:"publicKey"` + KernelInterface bool `json:"usesKernelInterface" yaml:"usesKernelInterface"` + WgPort int `json:"wireguardPort" yaml:"wireguardPort"` + FQDN string `json:"fqdn" yaml:"fqdn"` + RosenpassEnabled bool `json:"quantumResistance" yaml:"quantumResistance"` + RosenpassPermissive bool `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"` + Networks []string `json:"networks" yaml:"networks"` + NSServerGroups []NsServerGroupStateOutput `json:"dnsServers" yaml:"dnsServers"` + Events []SystemEventOutput `json:"events" yaml:"events"` + LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"` + ProfileName string `json:"profileName" yaml:"profileName"` + SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"` // SessionExpiresAt is the absolute UTC instant at which the peer's SSO // session expires. nil when the peer is not SSO-tracked or login // expiration is disabled. Pointer (rather than zero-value time.Time) so @@ -190,28 +189,27 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO peersOverview := mapPeers(pbFullStatus.GetPeers(), opts.StatusFilter, opts.PrefixNamesFilter, opts.PrefixNamesFilterMap, opts.IPsFilter, opts.ConnectionTypeFilter) overview := OutputOverview{ - Peers: peersOverview, - CliVersion: version.NetbirdVersion(), - DaemonVersion: opts.DaemonVersion, - DaemonStatus: opts.DaemonStatus, - ManagementState: managementOverview, - SignalState: signalOverview, - Relays: relayOverview, - IP: pbFullStatus.GetLocalPeerState().GetIP(), - IPv6: pbFullStatus.GetLocalPeerState().GetIpv6(), - PubKey: pbFullStatus.GetLocalPeerState().GetPubKey(), - KernelInterface: pbFullStatus.GetLocalPeerState().GetKernelInterface(), - WgPort: int(pbFullStatus.GetLocalPeerState().GetWgPort()), - FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(), - RosenpassEnabled: pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(), - RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(), - Networks: pbFullStatus.GetLocalPeerState().GetNetworks(), - NumberOfForwardingRules: int(pbFullStatus.GetNumberOfForwardingRules()), - NSServerGroups: mapNSGroups(pbFullStatus.GetDnsServers()), - Events: mapEvents(pbFullStatus.GetEvents()), - LazyConnectionEnabled: pbFullStatus.GetLazyConnectionEnabled(), - ProfileName: opts.ProfileName, - SSHServerState: sshServerOverview, + Peers: peersOverview, + CliVersion: version.NetbirdVersion(), + DaemonVersion: opts.DaemonVersion, + DaemonStatus: opts.DaemonStatus, + ManagementState: managementOverview, + SignalState: signalOverview, + Relays: relayOverview, + IP: pbFullStatus.GetLocalPeerState().GetIP(), + IPv6: pbFullStatus.GetLocalPeerState().GetIpv6(), + PubKey: pbFullStatus.GetLocalPeerState().GetPubKey(), + KernelInterface: pbFullStatus.GetLocalPeerState().GetKernelInterface(), + WgPort: int(pbFullStatus.GetLocalPeerState().GetWgPort()), + FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(), + RosenpassEnabled: pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(), + RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(), + Networks: pbFullStatus.GetLocalPeerState().GetNetworks(), + NSServerGroups: mapNSGroups(pbFullStatus.GetDnsServers()), + Events: mapEvents(pbFullStatus.GetEvents()), + LazyConnectionEnabled: pbFullStatus.GetLazyConnectionEnabled(), + ProfileName: opts.ProfileName, + SSHServerState: sshServerOverview, } if !opts.SessionExpiresAt.IsZero() { t := opts.SessionExpiresAt @@ -573,11 +571,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS ) } - var forwardingRulesString string - if o.NumberOfForwardingRules > 0 { - forwardingRulesString = fmt.Sprintf("Forwarding rules: %d\n", o.NumberOfForwardingRules) - } - goos := runtime.GOOS goarch := runtime.GOARCH goarm := "" @@ -619,7 +612,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS "SSH Server: %s\n"+ "Networks: %s\n"+ "%s"+ - "%s"+ "Peers count: %s\n", fmt.Sprintf("%s/%s%s", goos, goarch, goarm), daemonVersion, @@ -638,7 +630,6 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS lazyConnectionEnabledStatus, sshServerStatus, networks, - forwardingRulesString, sessionExpiryString, peersCountString, ) @@ -691,7 +682,6 @@ func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus { pbFullStatus.LocalPeerState.RosenpassPermissive = fullStatus.RosenpassState.Permissive pbFullStatus.LocalPeerState.RosenpassEnabled = fullStatus.RosenpassState.Enabled pbFullStatus.LocalPeerState.Networks = maps.Keys(fullStatus.LocalPeerState.Routes) - pbFullStatus.NumberOfForwardingRules = int32(fullStatus.NumOfForwardingRules) pbFullStatus.LazyConnectionEnabled = fullStatus.LazyConnectionEnabled for _, peerState := range fullStatus.Peers { diff --git a/client/status/status_test.go b/client/status/status_test.go index 2babd9342..1580aca6d 100644 --- a/client/status/status_test.go +++ b/client/status/status_test.go @@ -378,7 +378,6 @@ func TestParsingToJSON(t *testing.T) { "networks": [ "10.10.0.0/24" ], - "forwardingRules": 0, "dnsServers": [ { "servers": [ @@ -496,7 +495,6 @@ quantumResistance: false quantumResistancePermissive: false networks: - 10.10.0.0/24 -forwardingRules: 0 dnsServers: - servers: - 8.8.8.8:53 diff --git a/client/ui/frontend/WAILS-API.md b/client/ui/frontend/WAILS-API.md index 494812d35..6cc1dd79c 100644 --- a/client/ui/frontend/WAILS-API.md +++ b/client/ui/frontend/WAILS-API.md @@ -10,7 +10,7 @@ Every method returns `$CancellablePromise` (a Wails3 wrapper around `Promise` // Services import { Connection, Peers, ProfileSwitcher, Profiles, - Settings, Networks, Forwarding, Debug, Update, WindowManager, + Settings, Networks, Debug, Update, WindowManager, I18n, Preferences, } from "@bindings/services"; @@ -20,7 +20,6 @@ import type { Profile, ProfileRef, ActiveProfile, Config, ConfigParams, SetConfigParams, Features, Network, SelectNetworksParams, - ForwardingRule, PortInfo, PortRange, LoginParams, LoginResult, LogoutParams, WaitSSOParams, UpParams, DebugBundleParams, DebugBundleResult, LogLevel, UpdateResult, UpdateAvailable, UpdateProgress, @@ -129,14 +128,6 @@ Networks.Deselect(p: SelectNetworksParams): Promise Exit-node filter: `range === "0.0.0.0/0" || range === "::/0"`. Domain network: `domains.length > 0`. CIDR overlap check is client-side. -## `Forwarding` - -```ts -Forwarding.List(): Promise -``` - -`PortInfo` is a daemon-side oneof — exactly one of `port?: number` or `range?: PortRange` is populated. `protocol` is the lowercase daemon string (`"tcp"` / `"udp"`). - ## `Debug` ```ts @@ -269,12 +260,6 @@ The tray also reads a tray-only synthetic `"Error"` for icon purposes; the front `Network`: `{ id, range: string; selected: boolean; domains: string[]; resolvedIps: Record }`. -`ForwardingRule`: `{ protocol: string; destinationPort: PortInfo; translatedAddress, translatedHostname: string; translatedPort: PortInfo }`. - -`PortInfo`: `{ port?: number | null; range?: PortRange | null }` (exactly one populated). - -`PortRange`: `{ start, end: number }` (inclusive). - `LoginParams`: `{ profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }`. `LoginResult`: `{ needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }`. diff --git a/client/ui/main.go b/client/ui/main.go index 74a87b4df..764562fe7 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -336,7 +336,6 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) { app.RegisterService(application.NewService(services.NewSession(s.authSession, s.bundle, s.prefStore))) app.RegisterService(application.NewService(s.settings)) 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(s.update)) diff --git a/client/ui/services/forwarding.go b/client/ui/services/forwarding.go deleted file mode 100644 index 4ba979ad0..000000000 --- a/client/ui/services/forwarding.go +++ /dev/null @@ -1,83 +0,0 @@ -//go:build !android && !ios && !freebsd && !js - -package services - -import ( - "context" - - "github.com/netbirdio/netbird/client/proto" -) - -// PortRange is a port range; both ends are inclusive. -type PortRange struct { - Start uint32 `json:"start"` - End uint32 `json:"end"` -} - -// PortInfo holds exactly one of Port or Range (the daemon's oneof). -type PortInfo struct { - Port *uint32 `json:"port,omitempty"` - Range *PortRange `json:"range,omitempty"` -} - -// ForwardingRule is one entry from the daemon's reverse-proxy table. -type ForwardingRule struct { - Protocol string `json:"protocol"` - DestinationPort PortInfo `json:"destinationPort"` - TranslatedAddress string `json:"translatedAddress"` - TranslatedHostname string `json:"translatedHostname"` - TranslatedPort PortInfo `json:"translatedPort"` -} - -// Forwarding groups the daemon RPCs that surface exposed/forwarded services. -type Forwarding struct { - conn DaemonConn -} - -func NewForwarding(conn DaemonConn) *Forwarding { - return &Forwarding{conn: conn} -} - -func (s *Forwarding) List(ctx context.Context) ([]ForwardingRule, error) { - cli, err := s.conn.Client() - if err != nil { - return nil, err - } - resp, err := cli.ForwardingRules(ctx, &proto.EmptyRequest{}) - if err != nil { - return nil, err - } - out := make([]ForwardingRule, 0, len(resp.GetRules())) - for _, r := range resp.GetRules() { - out = append(out, forwardingRuleFromProto(r)) - } - return out, nil -} - -func forwardingRuleFromProto(r *proto.ForwardingRule) ForwardingRule { - return ForwardingRule{ - Protocol: r.GetProtocol(), - DestinationPort: portInfoFromProto(r.GetDestinationPort()), - TranslatedAddress: r.GetTranslatedAddress(), - TranslatedHostname: r.GetTranslatedHostname(), - TranslatedPort: portInfoFromProto(r.GetTranslatedPort()), - } -} - -func portInfoFromProto(p *proto.PortInfo) PortInfo { - if p == nil { - return PortInfo{} - } - switch sel := p.GetPortSelection().(type) { - case *proto.PortInfo_Port: - port := sel.Port - return PortInfo{Port: &port} - case *proto.PortInfo_Range_: - r := sel.Range - if r == nil { - return PortInfo{} - } - return PortInfo{Range: &PortRange{Start: r.GetStart(), End: r.GetEnd()}} - } - return PortInfo{} -} diff --git a/go.mod b/go.mod index 35e254f8e..eeb73cd68 100644 --- a/go.mod +++ b/go.mod @@ -85,7 +85,7 @@ require ( github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/moby/moby/api v1.54.1 github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 - github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87 + github.com/netbirdio/management-integrations/integrations v0.0.0-20261006132740-6e6b0cb01f2e github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 github.com/oapi-codegen/runtime v1.1.2 github.com/okta/okta-sdk-golang/v2 v2.18.0 diff --git a/go.sum b/go.sum index c193a388f..115bb3373 100644 --- a/go.sum +++ b/go.sum @@ -519,8 +519,8 @@ github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 h1:pBxXEsxcsO3qVU github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8/go.mod h1:mFViabv4PpnoDw9w7W21a7xux6APA4q7KQZRsv4BCl8= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI= github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51/go.mod h1:ZSIbPdBn5hePO8CpF1PekH2SfpTxg1PDhEwtbqZS7R8= -github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87 h1:iJeUvSMC0BTpkw7u4JyWcY4/3dl7fEL9DR/TpKf2+1w= -github.com/netbirdio/management-integrations/integrations v0.0.0-20260803100840-78e79ba20f87/go.mod h1:pmsCPx1S0nuZRxCextGpc9AV4hLgGSuTsc4NMuwGeCo= +github.com/netbirdio/management-integrations/integrations v0.0.0-20261006132740-6e6b0cb01f2e h1:SnDCreUnY+QDxKPueUoNrniMALCu2VFkXTCr/RIZjdg= +github.com/netbirdio/management-integrations/integrations v0.0.0-20261006132740-6e6b0cb01f2e/go.mod h1:n47r67ZSPgwSmT/Z1o48JjZQW9YJ6m/6Bd/uAXkL3Pg= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9axERMVN63dqyFqnvuD+EMJHzM7mNGON8= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ= diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 9727ff958..b9c27e57e 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -24,7 +24,6 @@ import ( "github.com/netbirdio/netbird/management/internals/shared/requestbuffer" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/settings" @@ -60,8 +59,6 @@ type Controller struct { requestBuffer account.RequestBuffer - proxyController port_forwarding.Controller - integratedPeerValidator integrated_validator.IntegratedValidator serverSupportedSyncMessageVersion sharedgrpc.SyncMessageVersion @@ -87,7 +84,7 @@ type bufferAffectedUpdate struct { var _ network_map.Controller = (*Controller)(nil) -func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, proxyController port_forwarding.Controller, ephemeralPeersManager ephemeral.Manager, config *config.Config, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) *Controller { +func NewController(ctx context.Context, store store.Store, metrics telemetry.AppMetrics, peersUpdateManager network_map.PeersUpdateManager, requestBuffer account.RequestBuffer, integratedPeerValidator integrated_validator.IntegratedValidator, settingsManager settings.Manager, dnsDomain string, ephemeralPeersManager ephemeral.Manager, config *config.Config, nmdataStore *networkmapdb.NetworkMapDBStoreImpl) *Controller { nMetrics, err := newMetrics(metrics.UpdateChannelMetrics()) if err != nil { log.Fatal(fmt.Errorf("error creating metrics: %w", err)) @@ -104,11 +101,10 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App dnsDomain: dnsDomain, config: config, - proxyController: proxyController, EphemeralPeersManager: ephemeralPeersManager, serverSupportedSyncMessageVersion: sharedgrpc.SyncMessageVersionFromConfig(config.HighestSupportedSyncMessageVersion), perAccountServerSupportedSyncMessageVersions: sharedgrpc.SyncMessageVersionsFromMap(config.PerAccountHighestSupportedSyncMessageVersion), - nmdataStore: nmdataStore, + nmdataStore: nmdataStore, } if nmdataStore != nil { @@ -226,12 +222,6 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin routers := account.GetResourceRoutersMap() groupIDToUserIDs := account.GetActiveGroupUsers() - proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMapsAll(ctx, accountID, account.Peers) - if err != nil { - log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) - return fmt.Errorf("failed to get proxy network maps: %v", err) - } - extraSetting, err := c.settingsManager.GetExtraSettings(ctx, accountID) if err != nil { return fmt.Errorf("failed to get flow enabled status: %v", err) @@ -273,7 +263,6 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin start = time.Now() peerGroups := account.GetPeerGroups(p.ID) - proxyNetworkMap := proxyNetworkMaps[p.ID] var update *proto.SyncResponse commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion( @@ -294,10 +283,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) start = time.Now() - // proxyNetworkMap rides the envelope as a ProxyPatch sidecar; - // the client merges it into Calculate()'s output the same - // way the legacy server did via NetworkMap.Merge. - update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToComponentSyncResponseDuration(time.Since(start)) c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ @@ -313,10 +299,6 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) - if proxyNetworkMap != nil { - nmap.Merge(proxyNetworkMap) - } - start = time.Now() update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToSyncResponseDuration(time.Since(start)) @@ -451,7 +433,7 @@ func (c *Controller) sendUpdatesFromData(ctx context.Context, accountID string, c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) start = time.Now() - update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, nil, dnsDomain, postureChecks, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort) + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, components, dnsDomain, postureChecks, nmData.AccountSettings, extraSettings, peerGroups, dnsFwdPort) c.metrics.CountToComponentSyncResponseDuration(time.Since(start)) c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ @@ -684,12 +666,6 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s routers := account.GetResourceRoutersMap() groupIDToUserIDs := account.GetActiveGroupUsers() - proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMapsAll(ctx, accountID, account.Peers) - if err != nil { - log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) - return fmt.Errorf("failed to get proxy network maps: %v", err) - } - extraSetting, err := c.settingsManager.GetExtraSettings(ctx, accountID) if err != nil { return fmt.Errorf("failed to get flow enabled status: %v", err) @@ -722,7 +698,6 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s start = time.Now() peerGroups := account.GetPeerGroups(p.ID) - proxyNetworkMap := proxyNetworkMaps[p.ID] var update *proto.SyncResponse commonSyncMessageVersion := sharedgrpc.HighestCommonSyncMessageVersion( @@ -743,10 +718,7 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) start = time.Now() - // proxyNetworkMap rides the envelope as a ProxyPatch sidecar; - // the client merges it into Calculate()'s output the same - // way the legacy server did via NetworkMap.Merge. - update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, components, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToComponentSyncResponseDuration(time.Since(start)) c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ @@ -762,10 +734,6 @@ func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID s c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start)) - if proxyNetworkMap != nil { - nmap.Merge(proxyNetworkMap) - } - start = time.Now() update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(p), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToSyncResponseDuration(time.Since(start)) @@ -843,19 +811,12 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe return fmt.Errorf("failed to get posture checks for peer %s: %v", peerId, err) } - proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peer.ID, account.Peers) - if err != nil { - log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) - return err - } - accountZones, err := c.repo.GetAccountZones(ctx, account.Id) if err != nil { log.WithContext(ctx).Errorf("failed to get account zones: %v", err) return err } - proxyNetworkMap := proxyNetworkMaps[peer.ID] extraSettings, err := c.settingsManager.GetExtraSettings(ctx, peer.AccountID) if err != nil { return fmt.Errorf("failed to get extra settings: %v", err) @@ -881,10 +842,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe components := account.GetPeerNetworkMapComponents( ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs) - // proxyNetworkMap rides the envelope as a ProxyPatch sidecar; - // the client merges it into Calculate()'s output the same - // way the legacy server did via NetworkMap.Merge. - update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort) + update = grpc.ToComponentSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, components, dnsDomain, postureChecks, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort) c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{ Update: update, @@ -897,10 +855,6 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe nmap := account.GetPeerNetworkMapFromComponents( ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) - if proxyNetworkMap != nil { - nmap.Merge(proxyNetworkMap) - } - update = grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, types.TwinPeer(peer), nil, nil, nmap, dnsDomain, postureChecks, dnsCache, types.TwinAccountSettings(account.Settings), extraSettings, maps.Keys(peerGroups), dnsFwdPort) c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{ @@ -951,17 +905,16 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str // GetValidatedPeerWithComponents is the components-format counterpart of // GetValidatedPeerWithMap. It returns raw NetworkMapComponents for capable -// peers along with the proxy NetworkMap fragment (BYOP / port-forwarding -// data the legacy server folds in via NetworkMap.Merge). The gRPC layer -// encodes both into the wire envelope. Callers must gate on capability -// themselves before dispatching here — this method does NOT branch on it. -func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) { +// peers, which the gRPC layer encodes into the wire envelope. Callers must +// gate on capability themselves before dispatching here — this method does +// NOT branch on it. +func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, []*nmdata.PostureChecks, int64, error) { if isRequiresApproval { network, err := c.repo.GetAccountNetwork(ctx, accountID) if err != nil { - return nil, nil, nil, nil, 0, err + return nil, nil, nil, 0, err } - return peer, &types.NetworkMapComponents{Network: types.TwinNetwork(network)}, nil, nil, 0, nil + return peer, &types.NetworkMapComponents{Network: types.TwinNetwork(network)}, nil, 0, nil } if nmData := c.getNetworkMapData(ctx, accountID); nmData != nil { @@ -970,39 +923,29 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) if err != nil { - return nil, nil, nil, nil, 0, err + return nil, nil, nil, 0, err } // it's possible that the peer gets deleted between the call to "sendInitialSync()" and here, bail out in this case if _, ok := account.Peers[peer.ID]; !ok { - return nil, nil, nil, nil, 0, fmt.Errorf("peer '%s' no longer exists", peer.ID) + return nil, nil, nil, 0, fmt.Errorf("peer '%s' no longer exists", peer.ID) } c.injectAllProxyPolicies(ctx, account) approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, types.TwinGroups(maps.Values(account.Groups)), types.TwinPeers(maps.Values(account.Peers)), account.Settings.Extra) if err != nil { - return nil, nil, nil, nil, 0, err + return nil, nil, nil, 0, err } postureChecks, err := c.getPeerPostureChecks(account, peer.ID) if err != nil { - return nil, nil, nil, nil, 0, err + return nil, nil, nil, 0, err } accountZones, err := c.repo.GetAccountZones(ctx, account.Id) if err != nil { - return nil, nil, nil, nil, 0, err - } - - // Fetch the proxy network map fragment for this peer alongside the - // components — same single-account-load path the streaming controller - // uses, so initial-sync delivers BYOP/forwarding patches synchronously - // instead of waiting for the next streaming push. - proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peer.ID, account.Peers) - if err != nil { - log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) - return nil, nil, nil, nil, 0, err + return nil, nil, nil, 0, err } dnsDomain := c.GetDNSDomain(account.Settings) @@ -1014,13 +957,12 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi components := account.GetPeerNetworkMapComponents(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, groupIDToUserIDs) dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion) - return peer, components, proxyNetworkMaps[peer.ID], postureChecks, dnsFwdPort, nil + return peer, components, postureChecks, dnsFwdPort, nil } // getValidatedPeerWithComponentsFromData is the account-free variant of -// GetValidatedPeerWithComponents. The proxy network map fragment is omitted -// like on the other nmdata paths. -func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, accountID string, peer *nbpeer.Peer, nmData *networkmap.NetworkMapData) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) { +// GetValidatedPeerWithComponents. +func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, accountID string, peer *nbpeer.Peer, nmData *networkmap.NetworkMapData) (*nbpeer.Peer, *types.NetworkMapComponents, []*nmdata.PostureChecks, int64, error) { postureChecks := peerPostureChecksFromData(nmData, peer.ID) dnsDomain := c.getDNSDomainFromData(nmData.AccountSettings) @@ -1029,7 +971,7 @@ func (c *Controller) getValidatedPeerWithComponentsFromData(ctx context.Context, components := nmData.GetPeerNetworkMapComponents(peer.ID, peersCustomZone) dnsFwdPort := ComputeForwarderPortFromData(nmData.Peers, network_map.DnsForwarderPortMinVersion) - return peer, components, nil, postureChecks, dnsFwdPort, nil + return peer, components, postureChecks, dnsFwdPort, nil } // BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval. @@ -1173,22 +1115,11 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr dnsDomain := c.GetDNSDomain(account.Settings) peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) - proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peerID, account.Peers) - if err != nil { - log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) - return nil, nil, 0, err - } - resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() groupIDToUserIDs := account.GetActiveGroupUsers() networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs) - proxyNetworkMap, ok := proxyNetworkMaps[peerID] - if ok { - networkMap.Merge(proxyNetworkMap) - } - dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion) return networkMap, postureChecks, dnsFwdPort, nil @@ -1457,23 +1388,12 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N dnsDomain := c.GetDNSDomain(account.Settings) peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) - proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peerID, account.Peers) - if err != nil { - log.WithContext(ctx).Errorf("failed to get proxy network maps: %v", err) - return nil, err - } - c.injectAllProxyPolicies(ctx, account) resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() groupIDToUserIDs := account.GetActiveGroupUsers() networkMap := account.GetPeerNetworkMapFromComponents(ctx, peer.ID, peersCustomZone, accountZones, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) - proxyNetworkMap, ok := proxyNetworkMaps[peer.ID] - if ok { - networkMap.Merge(proxyNetworkMap) - } - return networkMap, nil } diff --git a/management/internals/controllers/network_map/controller/controller_test.go b/management/internals/controllers/network_map/controller/controller_test.go index dfbbb2915..74e2553d6 100644 --- a/management/internals/controllers/network_map/controller/controller_test.go +++ b/management/internals/controllers/network_map/controller/controller_test.go @@ -122,11 +122,10 @@ func TestGetValidatedPeerWithComponents_DeletedPeer(t *testing.T) { } mockrequestBuffer.EXPECT().GetAccountWithBackpressure(gomock.Any(), gomock.Any()).Return(&types.Account{}, nil) - peer, components, netmap, posturechecks, dnsforwardPort, err := c.GetValidatedPeerWithComponents(context.TODO(), false, "test-account-id", &nbpeer.Peer{ID: "test-peer-id"}) + peer, components, posturechecks, dnsforwardPort, err := c.GetValidatedPeerWithComponents(context.TODO(), false, "test-account-id", &nbpeer.Peer{ID: "test-peer-id"}) assert.Nil(t, peer) assert.Nil(t, components) - assert.Nil(t, netmap) assert.Nil(t, posturechecks) assert.Equal(t, int64(0), dnsforwardPort) assert.NotNil(t, err) diff --git a/management/internals/controllers/network_map/controller/repository_mock.go b/management/internals/controllers/network_map/controller/repository_mock.go index 5246eef4b..9c6b1af4c 100644 --- a/management/internals/controllers/network_map/controller/repository_mock.go +++ b/management/internals/controllers/network_map/controller/repository_mock.go @@ -89,6 +89,21 @@ func (mr *MockRepositoryMockRecorder) GetAccountPeers(ctx, accountID any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockRepository)(nil).GetAccountPeers), ctx, accountID) } +// GetAccountServices mocks base method. +func (m *MockRepository) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountServices", ctx, accountID) + ret0, _ := ret[0].([]*service.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountServices indicates an expected call of GetAccountServices. +func (mr *MockRepositoryMockRecorder) GetAccountServices(ctx, accountID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountServices", reflect.TypeOf((*MockRepository)(nil).GetAccountServices), ctx, accountID) +} + // GetAccountZones mocks base method. func (m *MockRepository) GetAccountZones(ctx context.Context, accountID string) ([]*zones.Zone, error) { m.ctrl.T.Helper() diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go index 1e8c219b3..f447387b4 100644 --- a/management/internals/controllers/network_map/interface.go +++ b/management/internals/controllers/network_map/interface.go @@ -24,7 +24,7 @@ type Controller interface { UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peerID string) (*types.NetworkMap, []*nmdata.PostureChecks, int64, error) - GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) + GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, []*nmdata.PostureChecks, int64, error) GetDNSDomain(settings *types.Settings) string StartWarmup(context.Context) GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error) diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go index 8b104dfa0..5dcd241e1 100644 --- a/management/internals/controllers/network_map/interface_mock.go +++ b/management/internals/controllers/network_map/interface_mock.go @@ -127,16 +127,15 @@ func (mr *MockControllerMockRecorder) GetNetworkMap(ctx, peerID any) *gomock.Cal } // GetValidatedPeerWithComponents mocks base method. -func (m *MockController) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) { +func (m *MockController) GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *peer.Peer) (*peer.Peer, *types.NetworkMapComponents, []*nmdata.PostureChecks, int64, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetValidatedPeerWithComponents", ctx, isRequiresApproval, accountID, p) ret0, _ := ret[0].(*peer.Peer) ret1, _ := ret[1].(*types.NetworkMapComponents) - ret2, _ := ret[2].(*types.NetworkMap) - ret3, _ := ret[3].([]*nmdata.PostureChecks) - ret4, _ := ret[4].(int64) - ret5, _ := ret[5].(error) - return ret0, ret1, ret2, ret3, ret4, ret5 + ret2, _ := ret[2].([]*nmdata.PostureChecks) + ret3, _ := ret[3].(int64) + ret4, _ := ret[4].(error) + return ret0, ret1, ret2, ret3, ret4 } // GetValidatedPeerWithComponents indicates an expected call of GetValidatedPeerWithComponents. diff --git a/management/internals/controllers/network_map/nmaptest/canonicalize.go b/management/internals/controllers/network_map/nmaptest/canonicalize.go index ec6614d81..643105b89 100644 --- a/management/internals/controllers/network_map/nmaptest/canonicalize.go +++ b/management/internals/controllers/network_map/nmaptest/canonicalize.go @@ -123,7 +123,6 @@ func canonicalize(nm *proto.NetworkMap) { slices.SortFunc(nm.Routes, cmpRoute) slices.SortFunc(nm.FirewallRules, cmpFirewallRule) slices.SortFunc(nm.RoutesFirewallRules, cmpRouteFirewallRule) - slices.SortFunc(nm.ForwardingRules, cmpForwardingRule) for _, r := range nm.FirewallRules { slices.SortFunc(r.SourcePrefixes, bytes.Compare) @@ -353,16 +352,6 @@ func cmpRouteFirewallRule(a, b *proto.RouteFirewallRule) int { return boolCmp(a.IsDynamic, b.IsDynamic) } -func cmpForwardingRule(a, b *proto.ForwardingRule) int { - if a == nil || b == nil { - return boolCmp(a == nil, b == nil) - } - if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 { - return c - } - return bytes.Compare(a.TranslatedAddress, b.TranslatedAddress) -} - func portInfoKey(pi *proto.PortInfo) string { if pi == nil { return "" diff --git a/management/internals/controllers/network_map/nmaptest/runner.go b/management/internals/controllers/network_map/nmaptest/runner.go index ffce6483e..b6fc81df6 100644 --- a/management/internals/controllers/network_map/nmaptest/runner.go +++ b/management/internals/controllers/network_map/nmaptest/runner.go @@ -243,7 +243,7 @@ func computeMode(t *testing.T, ctx context.Context, mode Mode, nmData *networkma case ModeEnvelope: components := nmData.GetPeerNetworkMapComponents(peerID, zone) peerGroups := maps.Keys(nmData.GetPeerGroups(peerID)) - resp := mgmtgrpc.ToComponentSyncResponse(ctx, nil, nil, nil, peer, nil, nil, components, nil, + resp := mgmtgrpc.ToComponentSyncResponse(ctx, nil, nil, nil, peer, nil, nil, components, dnsDomain, nil, nmData.AccountSettings, nil, peerGroups, dnsFwdPort) res, err := networkmap.EnvelopeToNetworkMap(ctx, resp.NetworkMapEnvelope, peer.Key, dnsDomain, false) require.NoError(t, err, "expand envelope") diff --git a/management/internals/server/controllers.go b/management/internals/server/controllers.go index a9293d266..d9c8ee9d8 100644 --- a/management/internals/server/controllers.go +++ b/management/internals/server/controllers.go @@ -5,8 +5,6 @@ import ( log "github.com/sirupsen/logrus" - "github.com/netbirdio/management-integrations/integrations" - "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" @@ -20,7 +18,6 @@ import ( "github.com/netbirdio/netbird/management/server/auth" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" ) @@ -52,12 +49,6 @@ func (s *BaseServer) IntegratedValidator() integrated_validator.IntegratedValida }) } -func (s *BaseServer) ProxyController() port_forwarding.Controller { - return Create(s, func() port_forwarding.Controller { - return integrations.NewController(s.Store()) - }) -} - func (s *BaseServer) SecretsManager() grpc.SecretsManager { return Create(s, func() grpc.SecretsManager { secretsManager, err := grpc.NewTimeBasedAuthSecretsManager(s.PeersUpdateManager(), s.Config.TURNConfig, s.Config.Relay, s.SettingsManager(), s.GroupsManager()) @@ -123,7 +114,7 @@ func (s *BaseServer) EphemeralManager() ephemeral.Manager { func (s *BaseServer) NetworkMapController() network_map.Controller { return Create(s, func() network_map.Controller { - return nmapcontroller.NewController(context.Background(), s.Store(), s.Metrics(), s.PeersUpdateManager(), s.AccountRequestBuffer(), s.IntegratedValidator(), s.SettingsManager(), s.DNSDomain(), s.ProxyController(), s.EphemeralManager(), s.Config, s.NetworkMapStore()) + return nmapcontroller.NewController(context.Background(), s.Store(), s.Metrics(), s.PeersUpdateManager(), s.AccountRequestBuffer(), s.IntegratedValidator(), s.SettingsManager(), s.DNSDomain(), s.EphemeralManager(), s.Config, s.NetworkMapStore()) }) } diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index 4840e40ad..f548e9238 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -8,6 +8,7 @@ import ( "github.com/netbirdio/management-integrations/integrations" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/internals/modules/peers" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" @@ -20,7 +21,6 @@ import ( recordsManager "github.com/netbirdio/netbird/management/internals/modules/zones/records/manager" "github.com/netbirdio/netbird/management/server" "github.com/netbirdio/netbird/management/server/account" - "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/server/geolocation" "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/idp" @@ -96,7 +96,7 @@ func (s *BaseServer) PeersManager() peers.Manager { func (s *BaseServer) AccountManager() account.Manager { return Create(s, func() account.Manager { - accountManager, err := server.BuildManager(context.Background(), s.Config, s.Store(), s.NetworkMapController(), s.JobManager(), s.IdpManager(), s.mgmtSingleAccModeDomain, s.EventStore(), s.GeoLocationManager(), s.userDeleteFromIDPEnabled, s.IntegratedValidator(), s.Metrics(), s.ProxyController(), s.SettingsManager(), s.PermissionsManager(), s.Config.DisableDefaultPolicy, s.CacheStore()) + accountManager, err := server.BuildManager(context.Background(), s.Config, s.Store(), s.NetworkMapController(), s.JobManager(), s.IdpManager(), s.mgmtSingleAccModeDomain, s.EventStore(), s.GeoLocationManager(), s.userDeleteFromIDPEnabled, s.IntegratedValidator(), s.Metrics(), s.SettingsManager(), s.PermissionsManager(), s.Config.DisableDefaultPolicy, s.CacheStore()) if err != nil { log.Fatalf("failed to create account service: %v", err) } diff --git a/management/internals/shared/grpc/components_encoder.go b/management/internals/shared/grpc/components_encoder.go index a2aad19b6..a4516be52 100644 --- a/management/internals/shared/grpc/components_encoder.go +++ b/management/internals/shared/grpc/components_encoder.go @@ -28,10 +28,6 @@ type ComponentsEnvelopeInput struct { // SshAuth.UserIDClaim when reconstructing the NetworkMap. Empty value // is OK — client treats empty as "no SshAuth to build". UserIDClaim string - // ProxyPatch carries pre-expanded NetworkMap fragments injected by - // external controllers (BYOP/port-forwarding). Nil when no proxy data - // is present; encoder skips the field in that case. - ProxyPatch *proto.ProxyPatch } // EncodeNetworkMapEnvelope converts NetworkMapComponents into the component @@ -69,7 +65,6 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel DnsForwarderPort: in.DNSForwarderPort, UserIdClaim: in.UserIDClaim, AccountSettings: &proto.AccountSettingsCompact{}, - ProxyPatch: in.ProxyPatch, }, }, } @@ -101,7 +96,6 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel AccountSettings: toAccountSettingsCompact(c.AccountSettings), DnsForwarderPort: in.DNSForwarderPort, UserIdClaim: in.UserIDClaim, - ProxyPatch: in.ProxyPatch, DnsSettings: enc.encodeDNSSettings(c.DNSSettings), DnsDomain: in.DNSDomain, CustomZoneDomain: c.CustomZoneDomain, diff --git a/management/internals/shared/grpc/components_encoder_test.go b/management/internals/shared/grpc/components_encoder_test.go index 6ee554e8b..a6421af11 100644 --- a/management/internals/shared/grpc/components_encoder_test.go +++ b/management/internals/shared/grpc/components_encoder_test.go @@ -713,66 +713,6 @@ func TestEncodeNetworkMapEnvelope_GroupIDToUserIDs(t *testing.T) { assert.ElementsMatch(t, []string{"user-4"}, full.GroupIdToUserIds["group-users"].UserIds) } -func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) { - assert.Nil(t, toProxyPatch(nil, "netbird.cloud", false, false, false)) - assert.Nil(t, toProxyPatch(&types.NetworkMap{}, "netbird.cloud", false, false, false), - "empty NetworkMap (no peers, rules, routes etc) → nil patch so proto3 omits the field") -} - -func TestToProxyPatch_PopulatesAllFields(t *testing.T) { - nm := &types.NetworkMap{ - Peers: []*nmdata.Peer{{ - ID: "ext-peer", Key: testWgKeyA, IP: netip.AddrFrom4([4]byte{100, 64, 0, 9}), - DNSLabel: "extpeer", Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"}, - }}, - FirewallRules: []*types.FirewallRule{{ - PeerIP: "100.64.0.9", Action: "accept", Direction: 0, Protocol: "tcp", - }}, - } - - patch := toProxyPatch(nm, "netbird.cloud", false, false, false) - - require.NotNil(t, patch) - assert.Len(t, patch.Peers, 1) - assert.Len(t, patch.FirewallRules, 1) -} - -// TestEncodeNetworkMapEnvelope_ProxyPatchPropagated covers the ProxyPatch -// pass-through in both encoder branches (normal path + nil-Components -// graceful-degrade). Guards against a regression that drops `ProxyPatch:` -// from one of the envelope struct literals. -func TestEncodeNetworkMapEnvelope_ProxyPatchPropagated(t *testing.T) { - patch := &proto.ProxyPatch{ - ForwardingRules: []*proto.ForwardingRule{{ - Protocol: proto.RuleProtocol_TCP, - DestinationPort: &proto.PortInfo{PortSelection: &proto.PortInfo_Port{Port: 80}}, - TranslatedAddress: net.IPv4(10, 0, 0, 1).To4(), - TranslatedPort: &proto.PortInfo{PortSelection: &proto.PortInfo_Port{Port: 8080}}, - }}, - } - - t.Run("normal_path", func(t *testing.T) { - c := newTestComponents() - full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ - Components: c, - ProxyPatch: patch, - }).GetFull() - - require.NotNil(t, full.ProxyPatch, "ProxyPatch must propagate through the normal encode path") - assert.Len(t, full.ProxyPatch.ForwardingRules, 1) - }) - - t.Run("empty_components_graceful_degrade", func(t *testing.T) { - full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ - Components: emptyNetworkMapComponents(), - ProxyPatch: patch, - }).GetFull() - - require.NotNil(t, full.ProxyPatch, "ProxyPatch must propagate through the nil-Components branch too") - assert.Len(t, full.ProxyPatch.ForwardingRules, 1) - }) -} - func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) { // nil Components → minimal envelope, no crash. Matches the legacy // behaviour for missing/unvalidated peers. diff --git a/management/internals/shared/grpc/components_envelope_response.go b/management/internals/shared/grpc/components_envelope_response.go index cdd2a7f37..cbf9bb477 100644 --- a/management/internals/shared/grpc/components_envelope_response.go +++ b/management/internals/shared/grpc/components_envelope_response.go @@ -9,7 +9,6 @@ import ( nbconfig "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/types" sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc" - "github.com/netbirdio/netbird/shared/management/networkmap" nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/proto" ) @@ -34,7 +33,6 @@ func ToComponentSyncResponse( turnCredentials *Token, relayCredentials *Token, components *types.NetworkMapComponents, - proxyPatch *types.NetworkMap, dnsName string, checks []*nmdata.PostureChecks, settings *nmdata.AccountSettingsInfo, @@ -51,9 +49,6 @@ func ToComponentSyncResponse( enableSSH := computeSSHEnabledForPeer(components, peer) peerConfig := toPeerConfig(peer, components.Network, dnsName, settings, httpConfig, deviceFlowConfig, enableSSH, components.ForceRoutingPeerDNSResolution) - includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid() - useSourcePrefixes := peer.SupportsSourcePrefixes() - userIDClaim := auth.DefaultUserIDClaim if httpConfig != nil && httpConfig.AuthUserIDClaim != "" { userIDClaim = httpConfig.AuthUserIDClaim @@ -65,7 +60,6 @@ func ToComponentSyncResponse( DNSDomain: dnsName, DNSForwarderPort: dnsFwdPort, UserIDClaim: userIDClaim, - ProxyPatch: toProxyPatch(proxyPatch, dnsName, includeIPv6, useSourcePrefixes, peer.ProxyMeta.Embedded), }) resp := &proto.SyncResponse{ @@ -91,43 +85,6 @@ func ToComponentSyncResponse( return resp } -// toProxyPatch converts a proxy-injected *types.NetworkMap into the wire -// patch the components envelope ships alongside. Returns nil when there are -// no fragments to merge — proto3 omits a nil message field, so the receiver -// sees no patch and skips the merge step entirely. -// -// We reuse the legacy proto-conversion helpers (toProtocolRoutes, -// toProtocolFirewallRules, toProtocolRoutesFirewallRules, -// appendRemotePeerConfig, ForwardingRule.ToProto) because the proxy -// delivers fragments pre-expanded — there's no raw component shape to -// derive them from. Components purity isn't violated: proxy data isn't -// policy-graph-derived, it's externally injected post-Calculate, so the -// client merges it on top of its locally-computed NetworkMap. -func toProxyPatch(nm *types.NetworkMap, dnsName string, includeIPv6, useSourcePrefixes, localIsProxy bool) *proto.ProxyPatch { - if nm == nil { - return nil - } - if len(nm.Peers) == 0 && len(nm.OfflinePeers) == 0 && len(nm.FirewallRules) == 0 && - len(nm.Routes) == 0 && len(nm.RoutesFirewallRules) == 0 && len(nm.ForwardingRules) == 0 { - return nil - } - - patch := &proto.ProxyPatch{ - Peers: networkmap.AppendRemotePeerConfig(nil, nm.Peers, dnsName, includeIPv6, localIsProxy), - OfflinePeers: networkmap.AppendRemotePeerConfig(nil, nm.OfflinePeers, dnsName, includeIPv6, localIsProxy), - FirewallRules: networkmap.ToProtocolFirewallRules(nm.FirewallRules, includeIPv6, useSourcePrefixes), - Routes: networkmap.ToProtocolRoutes(nm.Routes), - RouteFirewallRules: networkmap.ToProtocolRoutesFirewallRules(nm.RoutesFirewallRules), - } - if len(nm.ForwardingRules) > 0 { - patch.ForwardingRules = make([]*proto.ForwardingRule, 0, len(nm.ForwardingRules)) - for _, r := range nm.ForwardingRules { - patch.ForwardingRules = append(patch.ForwardingRules, r.ToProto()) - } - } - return patch -} - // computeSSHEnabledForPeer mirrors the SSH-server-activation bit that // Calculate() folds into NetworkMap.EnableSSH. Components-format peers // receive a freshly-computed PeerConfig.SshConfig.SshEnabled at sync time; diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index 96bd9f1f4..908118aff 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -199,14 +199,6 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb response.NetworkMap.RoutesFirewallRules = routesFirewallRules response.NetworkMap.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0 - if networkMap.ForwardingRules != nil { - forwardingRules := make([]*proto.ForwardingRule, 0, len(networkMap.ForwardingRules)) - for _, rule := range networkMap.ForwardingRules { - forwardingRules = append(forwardingRules, rule.ToProto()) - } - response.NetworkMap.ForwardingRules = forwardingRules - } - if networkMap.AuthorizedUsers != nil { hashedUsers, machineUsers := networkmap.BuildAuthorizedUsersProto(ctx, networkMap.AuthorizedUsers) userIDClaim := auth.DefaultUserIDClaim diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index c178b6fa1..6e95a8998 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -955,12 +955,12 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer // stops doing duplicate work. Deferred until the client-side // decoder lands and there's a real deployment of capability=3 peers // worth optimizing for. - freshPeer, components, proxyPatch, freshPostureChecks, freshDnsFwdPort, err := s.networkMapController.GetValidatedPeerWithComponents(ctx, false, peer.AccountID, peer) + freshPeer, components, freshPostureChecks, freshDnsFwdPort, err := s.networkMapController.GetValidatedPeerWithComponents(ctx, false, peer.AccountID, peer) if err != nil { log.WithContext(ctx).Errorf("failed to build components for peer %s on initial sync: %v", peer.ID, err) return status.Errorf(codes.Internal, "failed to build initial sync envelope") } - plainResp = ToComponentSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(freshPeer), turnToken, relayToken, components, proxyPatch, dnsName, freshPostureChecks, types.TwinAccountSettings(settings), settings.Extra, peerGroups, freshDnsFwdPort) + plainResp = ToComponentSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(freshPeer), turnToken, relayToken, components, dnsName, freshPostureChecks, types.TwinAccountSettings(settings), settings.Extra, peerGroups, freshDnsFwdPort) } else { plainResp = ToSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, types.TwinPeer(peer), turnToken, relayToken, networkMap, dnsName, postureChecks, nil, types.TwinAccountSettings(settings), settings.Extra, peerGroups, dnsFwdPort) } diff --git a/management/server/account.go b/management/server/account.go index 038c5d8db..1b7e4d66c 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -35,7 +35,6 @@ import ( "github.com/netbirdio/netbird/management/server/geolocation" "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" @@ -84,7 +83,6 @@ type DefaultAccountManager struct { requestBuffer *AccountRequestBuffer - proxyController port_forwarding.Controller settingsManager settings.Manager serviceManager service.Manager @@ -225,7 +223,6 @@ func BuildManager( userDeleteFromIDPEnabled bool, integratedPeerValidator integrated_validator.IntegratedValidator, metrics telemetry.AppMetrics, - proxyController port_forwarding.Controller, settingsManager settings.Manager, permissionsManager permissions.Manager, disableDefaultPolicy bool, @@ -253,7 +250,6 @@ func BuildManager( integratedPeerValidator: integratedPeerValidator, metrics: metrics, requestBuffer: NewAccountRequestBuffer(ctx, store), - proxyController: proxyController, settingsManager: settingsManager, permissionsManager: permissionsManager, disableDefaultPolicy: disableDefaultPolicy, diff --git a/management/server/account_test.go b/management/server/account_test.go index 6067b6023..7dc02b428 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -49,7 +49,6 @@ import ( "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" "github.com/netbirdio/netbird/management/server/idp" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" @@ -3661,8 +3660,8 @@ func buildTestManager(t testing.TB, store store.Store, nmdataStore *networkmapdb updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nmdataStore) - manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nmdataStore) + manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, nil, err } diff --git a/management/server/dns_test.go b/management/server/dns_test.go index d21864cbb..bd1b4c923 100644 --- a/management/server/dns_test.go +++ b/management/server/dns_test.go @@ -16,7 +16,6 @@ import ( ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -234,9 +233,9 @@ func createDNSManager(t *testing.T) (*DefaultAccountManager, error) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) - return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) } func createDNSStore(t *testing.T) (store.Store, error) { diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index c3f6a06e0..3f2056c32 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -29,7 +29,6 @@ import ( "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" "github.com/netbirdio/netbird/management/internals/modules/peers" ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server" @@ -88,7 +87,6 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee geoMock := &geolocation.Mock{} validatorMock := server.MockIntegratedValidator{} - proxyController := integrations.NewController(store) userManager := users.NewManager(store) permissionsManager := permissions.NewManager(store) settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager, settings.IdpConfig{}) @@ -102,8 +100,8 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee } requestBuffer := server.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil) - am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil) + am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, settingsManager, permissionsManager, false, cacheStore) if err != nil { t.Fatalf("Failed to create manager: %v", err) } @@ -228,7 +226,6 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin geoMock := &geolocation.Mock{} validatorMock := server.MockIntegratedValidator{} - proxyController := integrations.NewController(store) userManager := users.NewManager(store) permissionsManager := permissions.NewManager(store) settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager, settings.IdpConfig{}) @@ -242,8 +239,8 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin } requestBuffer := server.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil) - am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}, nil) + am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, settingsManager, permissionsManager, false, cacheStore) if err != nil { t.Fatalf("Failed to create manager: %v", err) } diff --git a/management/server/identity_provider_test.go b/management/server/identity_provider_test.go index c7a8af1d2..bb576a71f 100644 --- a/management/server/identity_provider_test.go +++ b/management/server/identity_provider_test.go @@ -23,7 +23,6 @@ import ( "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/idp" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -112,8 +111,8 @@ func createManagerWithEmbeddedIdPModeAndSetup( updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, testStore) - networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{}, nil) - manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, singleAccountModeDomain, eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{}, nil) + manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, singleAccountModeDomain, eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, nil, err } diff --git a/management/server/integrations/port_forwarding/controller.go b/management/server/integrations/port_forwarding/controller.go deleted file mode 100644 index f2ce81839..000000000 --- a/management/server/integrations/port_forwarding/controller.go +++ /dev/null @@ -1,38 +0,0 @@ -package port_forwarding - -import ( - "context" - - "github.com/netbirdio/netbird/management/server/peer" - nbtypes "github.com/netbirdio/netbird/management/server/types" -) - -type Controller interface { - SendUpdate(ctx context.Context, accountID string, affectedProxyID string, affectedPeerIDs []string, accountPeers map[string]*peer.Peer) - GetProxyNetworkMaps(ctx context.Context, accountID, peerID string, accountPeers map[string]*peer.Peer) (map[string]*nbtypes.NetworkMap, error) - GetProxyNetworkMapsAll(ctx context.Context, accountID string, accountPeers map[string]*peer.Peer) (map[string]*nbtypes.NetworkMap, error) - IsPeerInIngressPorts(ctx context.Context, accountID, peerID string) (bool, error) -} - -type ControllerMock struct { -} - -func NewControllerMock() *ControllerMock { - return &ControllerMock{} -} - -func (c *ControllerMock) SendUpdate(ctx context.Context, accountID string, affectedProxyID string, affectedPeerIDs []string, accountPeers map[string]*peer.Peer) { - // noop -} - -func (c *ControllerMock) GetProxyNetworkMaps(ctx context.Context, accountID, peerID string, accountPeers map[string]*peer.Peer) (map[string]*nbtypes.NetworkMap, error) { - return make(map[string]*nbtypes.NetworkMap), nil -} - -func (c *ControllerMock) GetProxyNetworkMapsAll(ctx context.Context, accountID string, accountPeers map[string]*peer.Peer) (map[string]*nbtypes.NetworkMap, error) { - return make(map[string]*nbtypes.NetworkMap), nil -} - -func (c *ControllerMock) IsPeerInIngressPorts(ctx context.Context, accountID, peerID string) (bool, error) { - return false, nil -} diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go index 4f8aa8265..4e21ea7a3 100644 --- a/management/server/management_proto_test.go +++ b/management/server/management_proto_test.go @@ -12,9 +12,9 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -31,7 +31,6 @@ import ( "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/groups" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" @@ -376,9 +375,9 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config return nil, nil, "", cleanup, err } - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config, nil) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeralMgr, config, nil) accountManager, err := BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", - eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) if err != nil { cleanup() diff --git a/management/server/management_test.go b/management/server/management_test.go index 3a8d6ecc2..4d27e6edc 100644 --- a/management/server/management_test.go +++ b/management/server/management_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" pb "github.com/golang/protobuf/proto" //nolint log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -30,7 +30,6 @@ import ( "github.com/netbirdio/netbird/management/server/activity" nbcache "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/groups" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -216,7 +215,7 @@ func startServer( updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := server.NewAccountRequestBuffer(ctx, str) - networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config, nil) + networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config, nil) accountManager, err := server.BuildManager( context.Background(), @@ -231,7 +230,6 @@ func startServer( false, server.MockIntegratedValidator{}, metrics, - port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go index 1460893cf..1e24390be 100644 --- a/management/server/nameserver_test.go +++ b/management/server/nameserver_test.go @@ -18,7 +18,6 @@ import ( "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" @@ -803,9 +802,9 @@ func createNSManager(t *testing.T) (*DefaultAccountManager, error) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) - return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) } func createNSStore(t *testing.T) (store.Store, error) { diff --git a/management/server/peer.go b/management/server/peer.go index 5d5863fa7..8d99bebb0 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -1839,15 +1839,6 @@ func deletePeers(ctx context.Context, am *DefaultAccountManager, transaction sto // validatePeerDelete checks if the peer can be deleted. func (am *DefaultAccountManager) validatePeerDelete(ctx context.Context, transaction store.Store, accountId, peerId string) error { - linkedInIngressPorts, err := am.proxyController.IsPeerInIngressPorts(ctx, accountId, peerId) - if err != nil { - return err - } - - if linkedInIngressPorts { - return status.Errorf(status.PreconditionFailed, "peer is linked to ingress ports: %s", peerId) - } - linked, router := isPeerLinkedToNetworkRouter(ctx, transaction, accountId, peerId) if linked { return status.Errorf(status.PreconditionFailed, "peer is linked to a network router: %s", router.ID) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 5307300d6..ec4f0ef01 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -40,7 +40,6 @@ import ( nbcontext "github.com/netbirdio/netbird/management/server/context" peershandler "github.com/netbirdio/netbird/management/server/http/handlers/peers" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -1226,20 +1225,6 @@ func TestToSyncResponse(t *testing.T) { FirewallRules: []*types.FirewallRule{ {PeerIP: "192.168.1.2", Direction: types.FirewallRuleDirectionIN, Action: string(types.PolicyTrafficActionAccept), Protocol: string(types.PolicyRuleProtocolTCP), Port: "80"}, }, - ForwardingRules: []*types.ForwardingRule{ - { - RuleProtocol: "tcp", - DestinationPorts: types.RulePortRange{ - Start: 1000, - End: 2000, - }, - TranslatedAddress: net.IPv4(192, 168, 1, 2), - TranslatedPorts: types.RulePortRange{ - Start: 11000, - End: 12000, - }, - }, - }, } dnsName := "example.com" checks := []*nmdata.PostureChecks{ @@ -1334,14 +1319,6 @@ func TestToSyncResponse(t *testing.T) { // assert posture checks assert.Equal(t, 1, len(response.Checks)) assert.Equal(t, "/usr/bin/netbird", response.Checks[0].Files[0]) - // assert network map ForwardingRules - assert.Equal(t, 1, len(response.NetworkMap.ForwardingRules)) - assert.Equal(t, proto.RuleProtocol_TCP, response.NetworkMap.ForwardingRules[0].Protocol) - assert.Equal(t, uint32(1000), response.NetworkMap.ForwardingRules[0].DestinationPort.GetRange().Start) - assert.Equal(t, uint32(2000), response.NetworkMap.ForwardingRules[0].DestinationPort.GetRange().End) - assert.Equal(t, net.IPv4(192, 168, 1, 2).To4(), net.IP(response.NetworkMap.ForwardingRules[0].TranslatedAddress)) - assert.Equal(t, uint32(11000), response.NetworkMap.ForwardingRules[0].TranslatedPort.GetRange().Start) - assert.Equal(t, uint32(12000), response.NetworkMap.ForwardingRules[0].TranslatedPort.GetRange().End) } func Test_RegisterPeerByUser(t *testing.T) { @@ -1373,9 +1350,9 @@ func Test_RegisterPeerByUser(t *testing.T) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" @@ -1464,9 +1441,9 @@ func Test_RegisterPeerBySetupKey(t *testing.T) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" @@ -1623,9 +1600,9 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" @@ -1708,9 +1685,9 @@ func Test_LoginPeer(t *testing.T) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" diff --git a/management/server/route_test.go b/management/server/route_test.go index 69b9aec6c..d4bfa417e 100644 --- a/management/server/route_test.go +++ b/management/server/route_test.go @@ -18,7 +18,6 @@ import ( "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/cache" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/job" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" @@ -1301,9 +1300,9 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel. updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}, nil) - am, err := BuildManager(ctx, nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + am, err := BuildManager(ctx, nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, nil, err } diff --git a/management/server/types/account_networkmapdata.go b/management/server/types/account_networkmapdata.go index 80052d393..4ebd23f95 100644 --- a/management/server/types/account_networkmapdata.go +++ b/management/server/types/account_networkmapdata.go @@ -213,8 +213,7 @@ func twinPeer(p *nbpeer.Peer) *nmdata.Peer { } } -// TwinPeer converts a real peer to its slim nmdata twin. Exported for the -// port-forwarding integration, which builds proxy NetworkMaps holding twins. +// TwinPeer converts a real peer to its slim nmdata twin. func TwinPeer(p *nbpeer.Peer) *nmdata.Peer { return twinPeer(p) } diff --git a/management/server/types/aliases.go b/management/server/types/aliases.go index 452a2746d..fa9af12f8 100644 --- a/management/server/types/aliases.go +++ b/management/server/types/aliases.go @@ -15,7 +15,6 @@ import ( type FirewallRule = sharedtypes.FirewallRule type NetworkMap = sharedtypes.NetworkMap -type ForwardingRule = sharedtypes.ForwardingRule type PolicyTrafficActionType = sharedtypes.PolicyTrafficActionType type PolicyRuleProtocolType = sharedtypes.PolicyRuleProtocolType diff --git a/management/server/types/legacynmap/aliases.go b/management/server/types/legacynmap/aliases.go index 82a18192b..6b8f9574b 100644 --- a/management/server/types/legacynmap/aliases.go +++ b/management/server/types/legacynmap/aliases.go @@ -10,7 +10,6 @@ type ( DNSSettings = types.DNSSettings FirewallRule = sharedtypes.FirewallRule - ForwardingRule = sharedtypes.ForwardingRule Group = types.Group Network = types.Network Policy = types.Policy diff --git a/management/server/types/legacynmap/converters.go b/management/server/types/legacynmap/converters.go index 34e709413..d1cae6b63 100644 --- a/management/server/types/legacynmap/converters.go +++ b/management/server/types/legacynmap/converters.go @@ -19,7 +19,6 @@ type NetworkMap struct { OfflinePeers []*ComponentPeer FirewallRules []*FirewallRule RoutesFirewallRules []*RouteFirewallRule - ForwardingRules []*ForwardingRule AuthorizedUsers map[string]map[string]struct{} EnableSSH bool // ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS diff --git a/management/server/types/legacynmap/equivalence_test.go b/management/server/types/legacynmap/equivalence_test.go index d12e666b8..20770e29b 100644 --- a/management/server/types/legacynmap/equivalence_test.go +++ b/management/server/types/legacynmap/equivalence_test.go @@ -320,7 +320,6 @@ func canonicalize(nm *proto.NetworkMap) { slices.SortFunc(nm.Routes, cmpRoute) slices.SortFunc(nm.FirewallRules, cmpFirewallRule) slices.SortFunc(nm.RoutesFirewallRules, cmpRouteFirewallRule) - slices.SortFunc(nm.ForwardingRules, cmpForwardingRule) for _, r := range nm.FirewallRules { slices.SortFunc(r.SourcePrefixes, bytes.Compare) @@ -550,16 +549,6 @@ func cmpRouteFirewallRule(a, b *proto.RouteFirewallRule) int { return boolCmp(a.IsDynamic, b.IsDynamic) } -func cmpForwardingRule(a, b *proto.ForwardingRule) int { - if a == nil || b == nil { - return boolCmp(a == nil, b == nil) - } - if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 { - return c - } - return bytes.Compare(a.TranslatedAddress, b.TranslatedAddress) -} - func portInfoKey(pi *proto.PortInfo) string { if pi == nil { return "" @@ -591,7 +580,6 @@ func describeDivergence(legacy, updated *proto.NetworkMap, accountID, peerID str {"Routes", len(legacy.Routes), len(updated.Routes), func() string { return diffLists(legacy.Routes, updated.Routes) }}, {"FirewallRules", len(legacy.FirewallRules), len(updated.FirewallRules), func() string { return diffLists(legacy.FirewallRules, updated.FirewallRules) }}, {"RoutesFirewallRules", len(legacy.RoutesFirewallRules), len(updated.RoutesFirewallRules), func() string { return diffLists(legacy.RoutesFirewallRules, updated.RoutesFirewallRules) }}, - {"ForwardingRules", len(legacy.ForwardingRules), len(updated.ForwardingRules), func() string { return diffLists(legacy.ForwardingRules, updated.ForwardingRules) }}, } for _, l := range lens { if l.a != l.b { diff --git a/management/server/types/legacynmap/proto_legacy.go b/management/server/types/legacynmap/proto_legacy.go index 74451b268..36dc35401 100644 --- a/management/server/types/legacynmap/proto_legacy.go +++ b/management/server/types/legacynmap/proto_legacy.go @@ -186,14 +186,6 @@ func ToProtoNetworkMap( pm.RoutesFirewallRules = routesFirewallRules pm.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0 - if nm.ForwardingRules != nil { - forwardingRules := make([]*proto.ForwardingRule, 0, len(nm.ForwardingRules)) - for _, rule := range nm.ForwardingRules { - forwardingRules = append(forwardingRules, rule.ToProto()) - } - pm.ForwardingRules = forwardingRules - } - if nm.AuthorizedUsers != nil { hashedUsers, machineUsers := networkmap.BuildAuthorizedUsersProto(ctx, nm.AuthorizedUsers) userIDClaim := auth.DefaultUserIDClaim diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go index e6335dccb..c1d394e7e 100644 --- a/shared/management/client/client_test.go +++ b/shared/management/client/client_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -35,7 +35,6 @@ import ( "github.com/netbirdio/netbird/management/server/activity" nbcache "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/groups" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/mock_server" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -128,8 +127,8 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) { updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManger), config, nil) - accountManager, err := mgmt.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", ephemeral_manager.NewEphemeralManager(store, peersManger), config, nil) + accountManager, err := mgmt.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { t.Fatal(err) } diff --git a/shared/management/client/rest/client.go b/shared/management/client/rest/client.go index 6154a6637..7dea4a3f0 100644 --- a/shared/management/client/rest/client.go +++ b/shared/management/client/rest/client.go @@ -127,10 +127,6 @@ type Client struct { // see more: https://docs.netbird.io/api/resources/identity-providers IdentityProviders *IdentityProvidersAPI - // Ingress NetBird Ingress Peers APIs - // see more: https://docs.netbird.io/api/resources/ingress-ports - Ingress *IngressAPI - // Instance NetBird Instance API // see more: https://docs.netbird.io/api/resources/instance Instance *InstanceAPI @@ -207,7 +203,6 @@ func (c *Client) initialize() { c.OktaScimIDP = &OktaScimIDPAPI{c} c.EventStreaming = &EventStreamingAPI{c} c.IdentityProviders = &IdentityProvidersAPI{c} - c.Ingress = &IngressAPI{c} c.Instance = &InstanceAPI{c} c.ReverseProxyServices = &ReverseProxyServicesAPI{c} c.ReverseProxyClusters = &ReverseProxyClustersAPI{c} diff --git a/shared/management/client/rest/ingress.go b/shared/management/client/rest/ingress.go deleted file mode 100644 index f69288d7e..000000000 --- a/shared/management/client/rest/ingress.go +++ /dev/null @@ -1,92 +0,0 @@ -package rest - -import ( - "bytes" - "context" - "encoding/json" - - "github.com/netbirdio/netbird/shared/management/http/api" -) - -// IngressAPI APIs for Ingress Peers, do not use directly -type IngressAPI struct { - c *Client -} - -// List all ingress peers -// See more: https://docs.netbird.io/api/resources/ingress#list-all-ingress-peers -func (a *IngressAPI) List(ctx context.Context) ([]api.IngressPeer, error) { - resp, err := a.c.NewRequest(ctx, "GET", "/api/ingress/peers", nil, nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[[]api.IngressPeer](resp) - return ret, err -} - -// Get ingress peer info -// See more: https://docs.netbird.io/api/resources/ingress#retrieve-an-ingress-peer -func (a *IngressAPI) Get(ctx context.Context, ingressPeerID string) (*api.IngressPeer, error) { - resp, err := a.c.NewRequest(ctx, "GET", "/api/ingress/peers/"+ingressPeerID, nil, nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPeer](resp) - return &ret, err -} - -// Create new ingress peer -// See more: https://docs.netbird.io/api/resources/ingress#create-an-ingress-peer -func (a *IngressAPI) Create(ctx context.Context, request api.PostApiIngressPeersJSONRequestBody) (*api.IngressPeer, error) { - requestBytes, err := json.Marshal(request) - if err != nil { - return nil, err - } - resp, err := a.c.NewRequest(ctx, "POST", "/api/ingress/peers", bytes.NewReader(requestBytes), nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPeer](resp) - return &ret, err -} - -// Update update ingress peer -// See more: https://docs.netbird.io/api/resources/ingress#update-an-ingress-peer -func (a *IngressAPI) Update(ctx context.Context, ingressPeerID string, request api.PutApiIngressPeersIngressPeerIdJSONRequestBody) (*api.IngressPeer, error) { - requestBytes, err := json.Marshal(request) - if err != nil { - return nil, err - } - resp, err := a.c.NewRequest(ctx, "PUT", "/api/ingress/peers/"+ingressPeerID, bytes.NewReader(requestBytes), nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPeer](resp) - return &ret, err -} - -// Delete delete ingress peer -// See more: https://docs.netbird.io/api/resources/ingress#delete-an-ingress-peer -func (a *IngressAPI) Delete(ctx context.Context, ingressPeerID string) error { - resp, err := a.c.NewRequest(ctx, "DELETE", "/api/ingress/peers/"+ingressPeerID, nil, nil) - if err != nil { - return err - } - if resp.Body != nil { - defer resp.Body.Close() - } - - return nil -} diff --git a/shared/management/client/rest/ingress_test.go b/shared/management/client/rest/ingress_test.go deleted file mode 100644 index c915db094..000000000 --- a/shared/management/client/rest/ingress_test.go +++ /dev/null @@ -1,184 +0,0 @@ -//go:build integration - -package rest_test - -import ( - "context" - "encoding/json" - "io" - "net/http" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/netbirdio/netbird/shared/management/client/rest" - "github.com/netbirdio/netbird/shared/management/http/api" - "github.com/netbirdio/netbird/shared/management/http/util" -) - -var testIngressPeer = api.IngressPeer{ - Connected: true, - Enabled: true, - Id: "Test", -} - -func TestIngress_List_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal([]api.IngressPeer{testIngressPeer}) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.List(context.Background()) - require.NoError(t, err) - assert.Len(t, ret, 1) - assert.Equal(t, testIngressPeer, ret[0]) - }) -} - -func TestIngress_List_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.List(context.Background()) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Empty(t, ret) - }) -} - -func TestIngress_Get_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(testIngressPeer) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Get(context.Background(), "Test") - require.NoError(t, err) - assert.Equal(t, testIngressPeer, *ret) - }) -} - -func TestIngress_Get_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Get(context.Background(), "Test") - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Empty(t, ret) - }) -} - -func TestIngress_Create_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - reqBytes, err := io.ReadAll(r.Body) - require.NoError(t, err) - var req api.PostApiIngressPeersJSONRequestBody - err = json.Unmarshal(reqBytes, &req) - require.NoError(t, err) - assert.Equal(t, "peer-id", req.PeerId) - retBytes, _ := json.Marshal(testIngressPeer) - _, err = w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Create(context.Background(), api.PostApiIngressPeersJSONRequestBody{ - PeerId: "peer-id", - }) - require.NoError(t, err) - assert.Equal(t, testIngressPeer, *ret) - }) -} - -func TestIngress_Create_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Create(context.Background(), api.PostApiIngressPeersJSONRequestBody{ - PeerId: "peer-id", - }) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Nil(t, ret) - }) -} - -func TestIngress_Update_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "PUT", r.Method) - reqBytes, err := io.ReadAll(r.Body) - require.NoError(t, err) - var req api.PutApiIngressPeersIngressPeerIdJSONRequestBody - err = json.Unmarshal(reqBytes, &req) - require.NoError(t, err) - assert.Equal(t, true, req.Enabled) - retBytes, _ := json.Marshal(testIngressPeer) - _, err = w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Update(context.Background(), "Test", api.PutApiIngressPeersIngressPeerIdJSONRequestBody{ - Enabled: true, - }) - require.NoError(t, err) - assert.Equal(t, testIngressPeer, *ret) - }) -} - -func TestIngress_Update_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Ingress.Update(context.Background(), "Test", api.PutApiIngressPeersIngressPeerIdJSONRequestBody{ - Enabled: true, - }) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Nil(t, ret) - }) -} - -func TestIngress_Delete_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "DELETE", r.Method) - w.WriteHeader(200) - }) - err := c.Ingress.Delete(context.Background(), "Test") - require.NoError(t, err) - }) -} - -func TestIngress_Delete_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/ingress/peers/Test", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) - w.WriteHeader(404) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - err := c.Ingress.Delete(context.Background(), "Test") - assert.Error(t, err) - assert.Equal(t, "Not found", err.Error()) - }) -} diff --git a/shared/management/client/rest/peers.go b/shared/management/client/rest/peers.go index b22bcae67..30faff925 100644 --- a/shared/management/client/rest/peers.go +++ b/shared/management/client/rest/peers.go @@ -125,98 +125,6 @@ func (a *PeersAPI) CreateTemporaryAccess(ctx context.Context, peerID string, req return &ret, err } -// PeerIngressPortsAPI APIs for Peer Ingress Ports, do not use directly -type PeerIngressPortsAPI struct { - c *Client - peerID string -} - -// IngressPorts APIs for peer ingress ports -func (a *PeersAPI) IngressPorts(peerID string) *PeerIngressPortsAPI { - return &PeerIngressPortsAPI{ - c: a.c, - peerID: peerID, - } -} - -// List list all ingress port allocations for a peer -// See more: https://docs.netbird.io/api/resources/peers#list-all-ingress-port-allocations -func (a *PeerIngressPortsAPI) List(ctx context.Context) ([]api.IngressPortAllocation, error) { - resp, err := a.c.NewRequest(ctx, "GET", "/api/peers/"+a.peerID+"/ingress/ports", nil, nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[[]api.IngressPortAllocation](resp) - return ret, err -} - -// Get get ingress port allocation info -// See more: https://docs.netbird.io/api/resources/peers#retrieve-an-ingress-port-allocation -func (a *PeerIngressPortsAPI) Get(ctx context.Context, allocationID string) (*api.IngressPortAllocation, error) { - resp, err := a.c.NewRequest(ctx, "GET", "/api/peers/"+a.peerID+"/ingress/ports/"+allocationID, nil, nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPortAllocation](resp) - return &ret, err -} - -// Create create new ingress port allocation -// See more: https://docs.netbird.io/api/resources/peers#create-an-ingress-port-allocation -func (a *PeerIngressPortsAPI) Create(ctx context.Context, request api.PostApiPeersPeerIdIngressPortsJSONRequestBody) (*api.IngressPortAllocation, error) { - requestBytes, err := json.Marshal(request) - if err != nil { - return nil, err - } - resp, err := a.c.NewRequest(ctx, "POST", "/api/peers/"+a.peerID+"/ingress/ports", bytes.NewReader(requestBytes), nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPortAllocation](resp) - return &ret, err -} - -// Update update ingress port allocation -// See more: https://docs.netbird.io/api/resources/peers#update-an-ingress-port-allocation -func (a *PeerIngressPortsAPI) Update(ctx context.Context, allocationID string, request api.PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody) (*api.IngressPortAllocation, error) { - requestBytes, err := json.Marshal(request) - if err != nil { - return nil, err - } - resp, err := a.c.NewRequest(ctx, "PUT", "/api/peers/"+a.peerID+"/ingress/ports/"+allocationID, bytes.NewReader(requestBytes), nil) - if err != nil { - return nil, err - } - if resp.Body != nil { - defer resp.Body.Close() - } - ret, err := parseResponse[api.IngressPortAllocation](resp) - return &ret, err -} - -// Delete delete ingress port allocation -// See more: https://docs.netbird.io/api/resources/peers#delete-an-ingress-port-allocation -func (a *PeerIngressPortsAPI) Delete(ctx context.Context, allocationID string) error { - resp, err := a.c.NewRequest(ctx, "DELETE", "/api/peers/"+a.peerID+"/ingress/ports/"+allocationID, nil, nil) - if err != nil { - return err - } - if resp.Body != nil { - defer resp.Body.Close() - } - - return nil -} - // PeerJobsAPI APIs for Peer Jobs, do not use directly type PeerJobsAPI struct { c *Client diff --git a/shared/management/client/rest/peers_test.go b/shared/management/client/rest/peers_test.go index 5724b57f9..7370b238b 100644 --- a/shared/management/client/rest/peers_test.go +++ b/shared/management/client/rest/peers_test.go @@ -31,11 +31,6 @@ var ( Name: "test-peer", } - testIngressPortAllocation = api.IngressPortAllocation{ - Enabled: true, - Id: "alloc-1", - } - testJobResponse = api.JobResponse{ Id: "job-1", Status: "pending", @@ -221,146 +216,6 @@ func TestPeers_CreateTemporaryAccess_Err(t *testing.T) { }) } -func TestPeerIngressPorts_List_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal([]api.IngressPortAllocation{testIngressPortAllocation}) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").List(context.Background()) - require.NoError(t, err) - assert.Len(t, ret, 1) - assert.Equal(t, testIngressPortAllocation, ret[0]) - }) -} - -func TestPeerIngressPorts_List_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").List(context.Background()) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Empty(t, ret) - }) -} - -func TestPeerIngressPorts_Get_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(testIngressPortAllocation) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Get(context.Background(), "alloc-1") - require.NoError(t, err) - assert.Equal(t, testIngressPortAllocation, *ret) - }) -} - -func TestPeerIngressPorts_Get_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Get(context.Background(), "alloc-1") - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Empty(t, ret) - }) -} - -func TestPeerIngressPorts_Create_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "POST", r.Method) - retBytes, _ := json.Marshal(testIngressPortAllocation) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Create(context.Background(), api.PostApiPeersPeerIdIngressPortsJSONRequestBody{}) - require.NoError(t, err) - assert.Equal(t, testIngressPortAllocation, *ret) - }) -} - -func TestPeerIngressPorts_Create_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Create(context.Background(), api.PostApiPeersPeerIdIngressPortsJSONRequestBody{}) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Nil(t, ret) - }) -} - -func TestPeerIngressPorts_Update_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "PUT", r.Method) - retBytes, _ := json.Marshal(testIngressPortAllocation) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Update(context.Background(), "alloc-1", api.PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody{}) - require.NoError(t, err) - assert.Equal(t, testIngressPortAllocation, *ret) - }) -} - -func TestPeerIngressPorts_Update_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 400}) - w.WriteHeader(400) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - ret, err := c.Peers.IngressPorts("Test").Update(context.Background(), "alloc-1", api.PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody{}) - assert.Error(t, err) - assert.Equal(t, "No", err.Error()) - assert.Nil(t, ret) - }) -} - -func TestPeerIngressPorts_Delete_200(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "DELETE", r.Method) - w.WriteHeader(200) - }) - err := c.Peers.IngressPorts("Test").Delete(context.Background(), "alloc-1") - require.NoError(t, err) - }) -} - -func TestPeerIngressPorts_Delete_Err(t *testing.T) { - withMockClient(func(c *rest.Client, mux *http.ServeMux) { - mux.HandleFunc("/api/peers/Test/ingress/ports/alloc-1", func(w http.ResponseWriter, r *http.Request) { - retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) - w.WriteHeader(404) - _, err := w.Write(retBytes) - require.NoError(t, err) - }) - err := c.Peers.IngressPorts("Test").Delete(context.Background(), "alloc-1") - assert.Error(t, err) - assert.Equal(t, "Not found", err.Error()) - }) -} - func TestPeerJobs_List_200(t *testing.T) { withMockClient(func(c *rest.Client, mux *http.ServeMux) { mux.HandleFunc("/api/peers/Test/jobs", func(w http.ResponseWriter, r *http.Request) { diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 90b87462f..a8e0a7a56 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -31,9 +31,6 @@ tags: description: View information about the account and network events. - name: Accounts description: View information about the accounts. - - name: Ingress Ports - description: Interact with and view information about the ingress peers and ports. - x-cloud-only: true - name: Identity Providers description: Interact with and view information about identity providers. - name: Services @@ -2439,222 +2436,6 @@ components: - initiator_email - target_id - meta - IngressPeerCreateRequest: - type: object - properties: - peer_id: - description: ID of the peer that is used as an ingress peer - type: string - example: ch8i4ug6lnn4g9hqv7m0 - enabled: - description: Defines if an ingress peer is enabled - type: boolean - example: true - fallback: - description: Defines if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - type: boolean - example: true - required: - - peer_id - - enabled - - fallback - IngressPeerUpdateRequest: - type: object - properties: - enabled: - description: Defines if an ingress peer is enabled - type: boolean - example: true - fallback: - description: Defines if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - type: boolean - example: true - required: - - enabled - - fallback - IngressPeer: - type: object - properties: - id: - description: ID of the ingress peer - type: string - example: ch8i4ug6lnn4g9hqv7m0 - peer_id: - description: ID of the peer that is used as an ingress peer - type: string - example: x7p3kqf2rdd8j5zxw4n9 - ingress_ip: - description: Ingress IP address of the ingress peer where the traffic arrives - type: string - example: 192.34.0.123 - available_ports: - $ref: '#/components/schemas/AvailablePorts' - enabled: - description: Indicates if an ingress peer is enabled - type: boolean - example: true - connected: - description: Indicates if an ingress peer is connected to the management server - type: boolean - example: true - fallback: - description: Indicates if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - type: boolean - example: true - region: - description: Region of the ingress peer - type: string - example: germany - required: - - id - - peer_id - - ingress_ip - - available_ports - - enabled - - connected - - fallback - - region - AvailablePorts: - type: object - properties: - tcp: - description: Number of available TCP ports left on the ingress peer - type: integer - example: 45765 - udp: - description: Number of available UDP ports left on the ingress peer - type: integer - example: 50000 - required: - - tcp - - udp - IngressPortAllocationRequest: - type: object - properties: - name: - description: Name of the ingress port allocation - type: string - example: Ingress Port Allocation 1 - enabled: - description: Indicates if an ingress port allocation is enabled - type: boolean - example: true - port_ranges: - description: List of port ranges that are forwarded by the ingress peer - type: array - items: - $ref: '#/components/schemas/IngressPortAllocationRequestPortRange' - direct_port: - description: Direct port allocation - $ref: '#/components/schemas/IngressPortAllocationRequestDirectPort' - required: - - name - - enabled - IngressPortAllocationRequestPortRange: - type: object - properties: - start: - description: The starting port of the range of forwarded ports - type: integer - example: 80 - end: - description: The ending port of the range of forwarded ports - type: integer - example: 320 - protocol: - description: The protocol accepted by the port range - type: string - enum: [ "tcp", "udp", "tcp/udp" ] - example: tcp - required: - - start - - end - - protocol - IngressPortAllocationRequestDirectPort: - type: object - properties: - count: - description: The number of ports to be forwarded - type: integer - example: 5 - protocol: - description: The protocol accepted by the port - type: string - enum: [ "tcp", "udp", "tcp/udp" ] - example: udp - required: - - count - - protocol - IngressPortAllocation: - type: object - properties: - id: - description: ID of the ingress port allocation - type: string - example: ch8i4ug6lnn4g9hqv7m0 - name: - description: Name of the ingress port allocation - type: string - example: Ingress Peer Allocation 1 - ingress_peer_id: - description: ID of the ingress peer that forwards the ports - type: string - example: x7p3kqf2rdd8j5zxw4n9 - region: - description: Region of the ingress peer - type: string - example: germany - enabled: - description: Indicates if an ingress port allocation is enabled - type: boolean - example: true - ingress_ip: - description: Ingress IP address of the ingress peer where the traffic arrives - type: string - example: 192.34.0.123 - port_range_mappings: - description: List of port ranges that are allowed to be used by the ingress peer - type: array - items: - $ref: '#/components/schemas/IngressPortAllocationPortMapping' - required: - - id - - name - - ingress_peer_id - - region - - enabled - - ingress_ip - - port_range_mappings - IngressPortAllocationPortMapping: - type: object - properties: - translated_start: - description: The starting port of the translated range of forwarded ports - type: integer - example: 80 - translated_end: - description: The ending port of the translated range of forwarded ports - type: integer - example: 320 - ingress_start: - description: The starting port of the range of ingress ports mapped to the forwarded ports - type: integer - example: 1080 - ingress_end: - description: The ending port of the range of ingress ports mapped to the forwarded ports - type: integer - example: 1320 - protocol: - description: Protocol accepted by the ports - type: string - enum: [ "tcp", "udp", "tcp/udp" ] - example: tcp - required: - - translated_start - - translated_end - - ingress_start - - ingress_end - - protocol NetworkTrafficLocation: type: object properties: @@ -7719,341 +7500,6 @@ paths: "$ref": "#/components/responses/forbidden" '500': "$ref": "#/components/responses/internal_error" - /api/peers/{peerId}/ingress/ports: - get: - x-cloud-only: true - summary: List all Port Allocations - description: Returns a list of all ingress port allocations for a peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: peerId - required: true - schema: - type: string - description: The unique identifier of a peer - - in: query - name: name - schema: - type: string - description: Filters ingress port allocations by name - responses: - '200': - description: A JSON Array of Ingress Port Allocations - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/IngressPortAllocation' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - post: - x-cloud-only: true - summary: Create a Port Allocation - description: Creates a new ingress port allocation for a peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: peerId - required: true - schema: - type: string - description: The unique identifier of a peer - requestBody: - description: New Ingress Port Allocation request - content: - 'application/json': - schema: - $ref: '#/components/schemas/IngressPortAllocationRequest' - responses: - '200': - description: A Ingress Port Allocation object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPortAllocation' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - /api/peers/{peerId}/ingress/ports/{allocationId}: - get: - x-cloud-only: true - summary: Retrieve a Port Allocation - description: Get information about an ingress port allocation - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: peerId - required: true - schema: - type: string - description: The unique identifier of a peer - - in: path - name: allocationId - required: true - schema: - type: string - description: The unique identifier of an ingress port allocation - responses: - '200': - description: A Ingress Port Allocation object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPortAllocation' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - put: - x-cloud-only: true - summary: Update a Port Allocation - description: Update information about an ingress port allocation - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: peerId - required: true - schema: - type: string - description: The unique identifier of a peer - - in: path - name: allocationId - required: true - schema: - type: string - description: The unique identifier of an ingress port allocation - requestBody: - description: update an ingress port allocation - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPortAllocationRequest' - responses: - '200': - description: A Ingress Port Allocation object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPortAllocation' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - delete: - x-cloud-only: true - summary: Delete a Port Allocation - description: Delete an ingress port allocation - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: peerId - required: true - schema: - type: string - description: The unique identifier of a peer - - in: path - name: allocationId - required: true - schema: - type: string - description: The unique identifier of an ingress port allocation - responses: - '200': - description: Delete status code - content: { } - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - /api/ingress/peers: - get: - x-cloud-only: true - summary: List all Ingress Peers - description: Returns a list of all ingress peers - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - responses: - '200': - description: A JSON Array of Ingress Peers - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/IngressPeer' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - post: - x-cloud-only: true - summary: Create a Ingress Peer - description: Creates a new ingress peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - requestBody: - description: New Ingress Peer request - content: - 'application/json': - schema: - $ref: '#/components/schemas/IngressPeerCreateRequest' - responses: - '200': - description: A Ingress Peer object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPeer' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - /api/ingress/peers/{ingressPeerId}: - get: - x-cloud-only: true - summary: Retrieve a Ingress Peer - description: Get information about an ingress peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: ingressPeerId - required: true - schema: - type: string - description: The unique identifier of an ingress peer - responses: - '200': - description: A Ingress Peer object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPeer' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - put: - x-cloud-only: true - summary: Update a Ingress Peer - description: Update information about an ingress peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: ingressPeerId - required: true - schema: - type: string - description: The unique identifier of an ingress peer - requestBody: - description: update an ingress peer - content: - 'application/json': - schema: - $ref: '#/components/schemas/IngressPeerUpdateRequest' - responses: - '200': - description: A Ingress Peer object - content: - application/json: - schema: - $ref: '#/components/schemas/IngressPeer' - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" - delete: - x-cloud-only: true - summary: Delete a Ingress Peer - description: Delete an ingress peer - tags: [ Ingress Ports ] - security: - - BearerAuth: [ ] - - TokenAuth: [ ] - parameters: - - in: path - name: ingressPeerId - required: true - schema: - type: string - description: The unique identifier of an ingress peer - responses: - '200': - description: Delete status code - content: { } - '400': - "$ref": "#/components/responses/bad_request" - '401': - "$ref": "#/components/responses/requires_authentication" - '403': - "$ref": "#/components/responses/forbidden" - '500': - "$ref": "#/components/responses/internal_error" /api/setup-keys: get: summary: List all Setup Keys diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 009a9a7a7..9be676a91 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -608,69 +608,6 @@ func (e IdentityProviderType) Valid() bool { } } -// Defines values for IngressPortAllocationPortMappingProtocol. -const ( - IngressPortAllocationPortMappingProtocolTcp IngressPortAllocationPortMappingProtocol = "tcp" - IngressPortAllocationPortMappingProtocolTcpudp IngressPortAllocationPortMappingProtocol = "tcp/udp" - IngressPortAllocationPortMappingProtocolUdp IngressPortAllocationPortMappingProtocol = "udp" -) - -// Valid indicates whether the value is a known member of the IngressPortAllocationPortMappingProtocol enum. -func (e IngressPortAllocationPortMappingProtocol) Valid() bool { - switch e { - case IngressPortAllocationPortMappingProtocolTcp: - return true - case IngressPortAllocationPortMappingProtocolTcpudp: - return true - case IngressPortAllocationPortMappingProtocolUdp: - return true - default: - return false - } -} - -// Defines values for IngressPortAllocationRequestDirectPortProtocol. -const ( - IngressPortAllocationRequestDirectPortProtocolTcp IngressPortAllocationRequestDirectPortProtocol = "tcp" - IngressPortAllocationRequestDirectPortProtocolTcpudp IngressPortAllocationRequestDirectPortProtocol = "tcp/udp" - IngressPortAllocationRequestDirectPortProtocolUdp IngressPortAllocationRequestDirectPortProtocol = "udp" -) - -// Valid indicates whether the value is a known member of the IngressPortAllocationRequestDirectPortProtocol enum. -func (e IngressPortAllocationRequestDirectPortProtocol) Valid() bool { - switch e { - case IngressPortAllocationRequestDirectPortProtocolTcp: - return true - case IngressPortAllocationRequestDirectPortProtocolTcpudp: - return true - case IngressPortAllocationRequestDirectPortProtocolUdp: - return true - default: - return false - } -} - -// Defines values for IngressPortAllocationRequestPortRangeProtocol. -const ( - IngressPortAllocationRequestPortRangeProtocolTcp IngressPortAllocationRequestPortRangeProtocol = "tcp" - IngressPortAllocationRequestPortRangeProtocolTcpudp IngressPortAllocationRequestPortRangeProtocol = "tcp/udp" - IngressPortAllocationRequestPortRangeProtocolUdp IngressPortAllocationRequestPortRangeProtocol = "udp" -) - -// Valid indicates whether the value is a known member of the IngressPortAllocationRequestPortRangeProtocol enum. -func (e IngressPortAllocationRequestPortRangeProtocol) Valid() bool { - switch e { - case IngressPortAllocationRequestPortRangeProtocolTcp: - return true - case IngressPortAllocationRequestPortRangeProtocolTcpudp: - return true - case IngressPortAllocationRequestPortRangeProtocolUdp: - return true - default: - return false - } -} - // Defines values for IntegrationResponsePlatform. const ( IntegrationResponsePlatformDatadog IntegrationResponsePlatform = "datadog" @@ -2600,15 +2537,6 @@ type AgentNetworkUsageBucket struct { TotalTokens int64 `json:"total_tokens"` } -// AvailablePorts defines model for AvailablePorts. -type AvailablePorts struct { - // Tcp Number of available TCP ports left on the ingress peer - Tcp int `json:"tcp"` - - // Udp Number of available UDP ports left on the ingress peer - Udp int `json:"udp"` -} - // AzureIntegration defines model for AzureIntegration. type AzureIntegration struct { // ClientId Azure AD application (client) ID @@ -3461,139 +3389,6 @@ type IdpIntegrationSyncLog struct { Timestamp time.Time `json:"timestamp"` } -// IngressPeer defines model for IngressPeer. -type IngressPeer struct { - AvailablePorts AvailablePorts `json:"available_ports"` - - // Connected Indicates if an ingress peer is connected to the management server - Connected bool `json:"connected"` - - // Enabled Indicates if an ingress peer is enabled - Enabled bool `json:"enabled"` - - // Fallback Indicates if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - Fallback bool `json:"fallback"` - - // Id ID of the ingress peer - Id string `json:"id"` - - // IngressIp Ingress IP address of the ingress peer where the traffic arrives - IngressIp string `json:"ingress_ip"` - - // PeerId ID of the peer that is used as an ingress peer - PeerId string `json:"peer_id"` - - // Region Region of the ingress peer - Region string `json:"region"` -} - -// IngressPeerCreateRequest defines model for IngressPeerCreateRequest. -type IngressPeerCreateRequest struct { - // Enabled Defines if an ingress peer is enabled - Enabled bool `json:"enabled"` - - // Fallback Defines if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - Fallback bool `json:"fallback"` - - // PeerId ID of the peer that is used as an ingress peer - PeerId string `json:"peer_id"` -} - -// IngressPeerUpdateRequest defines model for IngressPeerUpdateRequest. -type IngressPeerUpdateRequest struct { - // Enabled Defines if an ingress peer is enabled - Enabled bool `json:"enabled"` - - // Fallback Defines if an ingress peer can be used as a fallback if no ingress peer can be found in the region of the forwarded peer - Fallback bool `json:"fallback"` -} - -// IngressPortAllocation defines model for IngressPortAllocation. -type IngressPortAllocation struct { - // Enabled Indicates if an ingress port allocation is enabled - Enabled bool `json:"enabled"` - - // Id ID of the ingress port allocation - Id string `json:"id"` - - // IngressIp Ingress IP address of the ingress peer where the traffic arrives - IngressIp string `json:"ingress_ip"` - - // IngressPeerId ID of the ingress peer that forwards the ports - IngressPeerId string `json:"ingress_peer_id"` - - // Name Name of the ingress port allocation - Name string `json:"name"` - - // PortRangeMappings List of port ranges that are allowed to be used by the ingress peer - PortRangeMappings []IngressPortAllocationPortMapping `json:"port_range_mappings"` - - // Region Region of the ingress peer - Region string `json:"region"` -} - -// IngressPortAllocationPortMapping defines model for IngressPortAllocationPortMapping. -type IngressPortAllocationPortMapping struct { - // IngressEnd The ending port of the range of ingress ports mapped to the forwarded ports - IngressEnd int `json:"ingress_end"` - - // IngressStart The starting port of the range of ingress ports mapped to the forwarded ports - IngressStart int `json:"ingress_start"` - - // Protocol Protocol accepted by the ports - Protocol IngressPortAllocationPortMappingProtocol `json:"protocol"` - - // TranslatedEnd The ending port of the translated range of forwarded ports - TranslatedEnd int `json:"translated_end"` - - // TranslatedStart The starting port of the translated range of forwarded ports - TranslatedStart int `json:"translated_start"` -} - -// IngressPortAllocationPortMappingProtocol Protocol accepted by the ports -type IngressPortAllocationPortMappingProtocol string - -// IngressPortAllocationRequest defines model for IngressPortAllocationRequest. -type IngressPortAllocationRequest struct { - DirectPort *IngressPortAllocationRequestDirectPort `json:"direct_port,omitempty"` - - // Enabled Indicates if an ingress port allocation is enabled - Enabled bool `json:"enabled"` - - // Name Name of the ingress port allocation - Name string `json:"name"` - - // PortRanges List of port ranges that are forwarded by the ingress peer - PortRanges *[]IngressPortAllocationRequestPortRange `json:"port_ranges,omitempty"` -} - -// IngressPortAllocationRequestDirectPort defines model for IngressPortAllocationRequestDirectPort. -type IngressPortAllocationRequestDirectPort struct { - // Count The number of ports to be forwarded - Count int `json:"count"` - - // Protocol The protocol accepted by the port - Protocol IngressPortAllocationRequestDirectPortProtocol `json:"protocol"` -} - -// IngressPortAllocationRequestDirectPortProtocol The protocol accepted by the port -type IngressPortAllocationRequestDirectPortProtocol string - -// IngressPortAllocationRequestPortRange defines model for IngressPortAllocationRequestPortRange. -type IngressPortAllocationRequestPortRange struct { - // End The ending port of the range of forwarded ports - End int `json:"end"` - - // Protocol The protocol accepted by the port range - Protocol IngressPortAllocationRequestPortRangeProtocol `json:"protocol"` - - // Start The starting port of the range of forwarded ports - Start int `json:"start"` -} - -// IngressPortAllocationRequestPortRangeProtocol The protocol accepted by the port range -type IngressPortAllocationRequestPortRangeProtocol string - // InstanceStatus Instance status information type InstanceStatus struct { // SetupRequired Indicates whether the instance requires initial setup @@ -6314,12 +6109,6 @@ type GetApiPeersParams struct { Mac *string `form:"mac,omitempty" json:"mac,omitempty"` } -// GetApiPeersPeerIdIngressPortsParams defines parameters for GetApiPeersPeerIdIngressPorts. -type GetApiPeersPeerIdIngressPortsParams struct { - // Name Filters ingress port allocations by name - Name *string `form:"name,omitempty" json:"name,omitempty"` -} - // GetApiUsersParams defines parameters for GetApiUsers. type GetApiUsersParams struct { // ServiceUser Filters users and returns either regular users or service users @@ -6401,12 +6190,6 @@ type PostApiIdentityProvidersJSONRequestBody = IdentityProviderRequest // PutApiIdentityProvidersIdpIdJSONRequestBody defines body for PutApiIdentityProvidersIdpId for application/json ContentType. type PutApiIdentityProvidersIdpIdJSONRequestBody = IdentityProviderRequest -// PostApiIngressPeersJSONRequestBody defines body for PostApiIngressPeers for application/json ContentType. -type PostApiIngressPeersJSONRequestBody = IngressPeerCreateRequest - -// PutApiIngressPeersIngressPeerIdJSONRequestBody defines body for PutApiIngressPeersIngressPeerId for application/json ContentType. -type PutApiIngressPeersIngressPeerIdJSONRequestBody = IngressPeerUpdateRequest - // CreateAzureIntegrationJSONRequestBody defines body for CreateAzureIntegration for application/json ContentType. type CreateAzureIntegrationJSONRequestBody = CreateAzureIntegrationRequest @@ -6515,12 +6298,6 @@ type PutApiNetworksNetworkIdRoutersRouterIdJSONRequestBody = NetworkRouterReques // PutApiPeersPeerIdJSONRequestBody defines body for PutApiPeersPeerId for application/json ContentType. type PutApiPeersPeerIdJSONRequestBody = PeerRequest -// PostApiPeersPeerIdIngressPortsJSONRequestBody defines body for PostApiPeersPeerIdIngressPorts for application/json ContentType. -type PostApiPeersPeerIdIngressPortsJSONRequestBody = IngressPortAllocationRequest - -// PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody defines body for PutApiPeersPeerIdIngressPortsAllocationId for application/json ContentType. -type PutApiPeersPeerIdIngressPortsAllocationIdJSONRequestBody = IngressPortAllocationRequest - // PostApiPeersPeerIdJobsJSONRequestBody defines body for PostApiPeersPeerIdJobs for application/json ContentType. type PostApiPeersPeerIdJobsJSONRequestBody = JobRequest diff --git a/shared/management/networkmap/envelope.go b/shared/management/networkmap/envelope.go index fd9dd6bbd..b2fbbef08 100644 --- a/shared/management/networkmap/envelope.go +++ b/shared/management/networkmap/envelope.go @@ -15,7 +15,7 @@ import ( // - NetworkMap is the *proto.NetworkMap shape the engine reads today via // update.GetNetworkMap() — built from the envelope's components by // running Calculate() locally + converting back through the shared -// proto helpers + merging the optional ProxyPatch. +// proto helpers. // - Components is the *types.NetworkMapComponents the engine retains so // future incremental delta updates have a base to apply changes // against. The client keeps it under its sync lock. @@ -26,8 +26,8 @@ type EnvelopeResult struct { // EnvelopeToNetworkMap is the full client-side pipeline: decode the // component envelope back to a typed NetworkMapComponents, run Calculate() -// locally to produce the typed NetworkMap, convert it to the wire form the -// engine consumes, and fold in any ProxyPatch the server attached. +// locally to produce the typed NetworkMap and convert it to the wire form the +// engine consumes. // // localPeerKey is the receiving peer's WG pub key (used to derive // includeIPv6 / useSourcePrefixes from the receiving peer's own record in @@ -107,74 +107,12 @@ func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, lo } } - if typedNM.ForwardingRules != nil { - forwardingRules := make([]*proto.ForwardingRule, 0, len(typedNM.ForwardingRules)) - for _, rule := range typedNM.ForwardingRules { - forwardingRules = append(forwardingRules, rule.ToProto()) - } - protoNM.ForwardingRules = forwardingRules - } - - // Merge the proxy patch the server attached. Mirrors the legacy - // NetworkMap.Merge step that the server runs after Calculate(). - if full != nil && full.ProxyPatch != nil { - mergeProxyPatch(protoNM, full.ProxyPatch) - } - return &EnvelopeResult{ NetworkMap: protoNM, Components: components, }, nil } -// mergeProxyPatch folds a ProxyPatch's pre-expanded fragments into the -// proto.NetworkMap that Calculate() produced. Mirrors types.NetworkMap.Merge -// — same six collections, deduplicated where the legacy merge dedupes. -func mergeProxyPatch(nm *proto.NetworkMap, patch *proto.ProxyPatch) { - nm.RemotePeers = appendUniquePeers(nm.RemotePeers, patch.Peers) - nm.OfflinePeers = appendUniquePeers(nm.OfflinePeers, patch.OfflinePeers) - nm.FirewallRules = append(nm.FirewallRules, patch.FirewallRules...) - nm.Routes = append(nm.Routes, patch.Routes...) - nm.RoutesFirewallRules = append(nm.RoutesFirewallRules, patch.RouteFirewallRules...) - nm.ForwardingRules = append(nm.ForwardingRules, patch.ForwardingRules...) - if len(nm.RemotePeers) > 0 { - nm.RemotePeersIsEmpty = false - } - if len(nm.FirewallRules) > 0 { - nm.FirewallRulesIsEmpty = false - } - if len(nm.RoutesFirewallRules) > 0 { - nm.RoutesFirewallRulesIsEmpty = false - } -} - -// appendUniquePeers dedupes by WgPubKey — mirrors legacy -// mergeUniquePeersByID's intent (legacy keyed off Peer.ID; in proto form the -// closest stable identifier is WgPubKey). -func appendUniquePeers(dst, extra []*proto.RemotePeerConfig) []*proto.RemotePeerConfig { - if len(extra) == 0 { - return dst - } - seen := make(map[string]struct{}, len(dst)) - for _, p := range dst { - if p == nil { - continue - } - seen[p.WgPubKey] = struct{}{} - } - for _, p := range extra { - if p == nil { - continue - } - if _, ok := seen[p.WgPubKey]; ok { - continue - } - seen[p.WgPubKey] = struct{}{} - dst = append(dst, p) - } - return dst -} - func trimKey(s string) string { if len(s) > 12 { return s[:12] diff --git a/shared/management/proto/management.pb.go b/shared/management/proto/management.pb.go index 60cfc71fe..de0fdd84d 100644 --- a/shared/management/proto/management.pb.go +++ b/shared/management/proto/management.pb.go @@ -2739,8 +2739,11 @@ type NetworkMap struct { // RoutesFirewallRules represents a list of routes firewall rules to be applied to peer RoutesFirewallRules []*RouteFirewallRule `protobuf:"bytes,10,rep,name=routesFirewallRules,proto3" json:"routesFirewallRules,omitempty"` // RoutesFirewallRulesIsEmpty indicates whether RouteFirewallRule array is empty or not to bypass protobuf null and empty array equality. - RoutesFirewallRulesIsEmpty bool `protobuf:"varint,11,opt,name=routesFirewallRulesIsEmpty,proto3" json:"routesFirewallRulesIsEmpty,omitempty"` - ForwardingRules []*ForwardingRule `protobuf:"bytes,12,rep,name=forwardingRules,proto3" json:"forwardingRules,omitempty"` + RoutesFirewallRulesIsEmpty bool `protobuf:"varint,11,opt,name=routesFirewallRulesIsEmpty,proto3" json:"routesFirewallRulesIsEmpty,omitempty"` + // Unused; the ingress port-forwarding feature was discontinued. + // + // Deprecated: Do not use. + ForwardingRules []*ForwardingRule `protobuf:"bytes,12,rep,name=forwardingRules,proto3" json:"forwardingRules,omitempty"` // SSHAuth represents SSH authorization configuration SshAuth *SSHAuth `protobuf:"bytes,13,opt,name=sshAuth,proto3" json:"sshAuth,omitempty"` } @@ -2854,6 +2857,7 @@ func (x *NetworkMap) GetRoutesFirewallRulesIsEmpty() bool { return false } +// Deprecated: Do not use. func (x *NetworkMap) GetForwardingRules() []*ForwardingRule { if x != nil { return x.ForwardingRules @@ -4401,19 +4405,18 @@ func (x *RouteFirewallRule) GetRouteID() string { return "" } +// ForwardingRule is unused; the ingress port-forwarding feature was discontinued. +// +// Deprecated: Do not use. type ForwardingRule struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - // Protocol of the forwarding rule - Protocol RuleProtocol `protobuf:"varint,1,opt,name=protocol,proto3,enum=management.RuleProtocol" json:"protocol,omitempty"` - // portInfo is the ingress destination port information, where the traffic arrives in the gateway node - DestinationPort *PortInfo `protobuf:"bytes,2,opt,name=destinationPort,proto3" json:"destinationPort,omitempty"` - // IP address of the translated address (remote peer) to send traffic to - TranslatedAddress []byte `protobuf:"bytes,3,opt,name=translatedAddress,proto3" json:"translatedAddress,omitempty"` - // Translated port information, where the traffic should be forwarded to - TranslatedPort *PortInfo `protobuf:"bytes,4,opt,name=translatedPort,proto3" json:"translatedPort,omitempty"` + Protocol RuleProtocol `protobuf:"varint,1,opt,name=protocol,proto3,enum=management.RuleProtocol" json:"protocol,omitempty"` + DestinationPort *PortInfo `protobuf:"bytes,2,opt,name=destinationPort,proto3" json:"destinationPort,omitempty"` + TranslatedAddress []byte `protobuf:"bytes,3,opt,name=translatedAddress,proto3" json:"translatedAddress,omitempty"` + TranslatedPort *PortInfo `protobuf:"bytes,4,opt,name=translatedPort,proto3" json:"translatedPort,omitempty"` } func (x *ForwardingRule) Reset() { @@ -4907,8 +4910,8 @@ func (*NetworkMapEnvelope_Delta) isNetworkMapEnvelope_Payload() {} // client decodes it into a types.NetworkMapComponents and runs Calculate() // locally to produce the same NetworkMap the legacy server path would have // produced. Every field carries RAW component data — no server-side -// expansion (firewall rules, DNS config, SSH auth, route firewall rules, -// forwarding rules) is shipped; the client computes those itself. +// expansion (firewall rules, DNS config, SSH auth, route firewall rules) +// is shipped; the client computes those itself. type NetworkMapComponentsFull struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -4973,11 +4976,9 @@ type NetworkMapComponentsFull struct { // proto.DNSConfig.ForwarderPort). Computed by the controller from peer // versions; clients fold it into their Calculate() DNS output. DnsForwarderPort int64 `protobuf:"varint,23,opt,name=dns_forwarder_port,json=dnsForwarderPort,proto3" json:"dns_forwarder_port,omitempty"` - // Pre-expanded NetworkMap fragments injected post-Calculate by external - // controllers (BYOP / port-forwarding proxies). The receiving client - // merges these into its locally-computed NetworkMap the same way the - // legacy server does via NetworkMap.Merge — so downstream consumers see - // a unified merged result regardless of source. + // Unused; the ingress port-forwarding feature was discontinued. + // + // Deprecated: Do not use. ProxyPatch *ProxyPatch `protobuf:"bytes,24,opt,name=proxy_patch,json=proxyPatch,proto3" json:"proxy_patch,omitempty"` // SSH UserIDClaim — server-side HttpServerConfig.AuthUserIDClaim, or // "sub" by default. Populated in proto.SSHAuth.UserIDClaim when the @@ -5179,6 +5180,7 @@ func (x *NetworkMapComponentsFull) GetDnsForwarderPort() int64 { return 0 } +// Deprecated: Do not use. func (x *NetworkMapComponentsFull) GetProxyPatch() *ProxyPatch { if x != nil { return x.ProxyPatch @@ -5193,11 +5195,9 @@ func (x *NetworkMapComponentsFull) GetUserIdClaim() string { return "" } -// ProxyPatch carries NetworkMap fragments that don't fit the component-graph -// model — they're pre-expanded by external controllers (BYOP / -// port-forwarding proxies) and injected post-Calculate. Fields use the -// legacy wire types because the proxy delivers them pre-formed; there is -// no raw component shape to convert from. Empty when no proxy is active. +// ProxyPatch is unused; the ingress port-forwarding feature was discontinued. +// +// Deprecated: Do not use. type ProxyPatch struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -7185,7 +7185,7 @@ var file_management_proto_rawDesc = []byte{ 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x61, 0x6c, 0x77, 0x61, 0x79, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x22, 0xe8, 0x05, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, + 0x65, 0x22, 0xec, 0x05, 0x0a, 0x0a, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x53, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x36, 0x0a, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, @@ -7224,444 +7224,445 @@ var file_management_proto_rawDesc = []byte{ 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, - 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x44, 0x0a, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, + 0x49, 0x73, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x48, 0x0a, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x6f, - 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, 0x6f, - 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x2d, 0x0a, - 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, - 0x75, 0x74, 0x68, 0x52, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x22, 0x82, 0x02, 0x0a, - 0x07, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, 0x55, 0x73, 0x65, 0x72, - 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x55, - 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, 0x0a, 0x0f, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, - 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, - 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x5f, - 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, - 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, - 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, - 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, - 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, 0x49, - 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x22, 0x2e, 0x0a, 0x12, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, - 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, - 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, - 0x73, 0x22, 0xf0, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, 0x50, 0x75, 0x62, 0x4b, - 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, 0x70, 0x73, - 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x49, - 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x73, 0x73, - 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, 0x22, 0x0a, 0x0c, 0x61, - 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, - 0x33, 0x0a, 0x09, 0x6c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x09, 0x6c, 0x61, 0x7a, 0x79, 0x53, - 0x74, 0x61, 0x74, 0x65, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, - 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, - 0x33, 0x0a, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, 0x65, 0x76, 0x69, 0x63, - 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, - 0x6f, 0x77, 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, - 0x65, 0x72, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x42, 0x0a, 0x0e, - 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x22, 0x16, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x0a, 0x0a, 0x06, - 0x48, 0x4f, 0x53, 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, 0x50, 0x4b, 0x43, 0x45, - 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, - 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, 0x50, 0x4b, 0x43, 0x45, - 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, - 0x77, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, - 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65, - 0x6e, 0x74, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x43, 0x6c, 0x69, 0x65, - 0x6e, 0x74, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, - 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0c, - 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x16, 0x0a, 0x06, - 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, - 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, 0x65, 0x6e, 0x63, 0x65, - 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, - 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x44, 0x65, - 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, - 0x12, 0x24, 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, - 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, - 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, - 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x34, 0x0a, 0x15, - 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, - 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x41, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, - 0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, - 0x4c, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, - 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, - 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x0b, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, - 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x46, - 0x6c, 0x61, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, 0x6f, 0x67, 0x69, 0x6e, - 0x46, 0x6c, 0x61, 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x0e, - 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x18, - 0x0a, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, 0x4e, 0x65, 0x74, 0x77, - 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x4e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x65, - 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, 0x65, 0x72, 0x12, 0x16, - 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, - 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, - 0x72, 0x61, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x4d, 0x61, 0x73, 0x71, - 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, - 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, - 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, 0x6f, - 0x75, 0x74, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x52, - 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, - 0x41, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x6b, 0x69, - 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, 0x01, 0x0a, 0x09, 0x44, - 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x47, - 0x0a, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, - 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, - 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, - 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, - 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, - 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, - 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0d, 0x46, 0x6f, - 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xb8, 0x01, 0x0a, 0x0a, - 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, - 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x02, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x07, 0x52, - 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, - 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x4e, 0x6f, - 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, - 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x54, 0x79, - 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x14, - 0x0a, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x43, - 0x6c, 0x61, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x03, 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, 0x22, 0xb3, 0x01, 0x0a, - 0x0f, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x12, 0x38, 0x0a, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, - 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x0b, 0x4e, - 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x50, 0x72, - 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x50, 0x72, 0x69, - 0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, - 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x32, - 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, - 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, - 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, - 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, - 0x12, 0x16, 0x0a, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, - 0x52, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xfb, 0x02, 0x0a, - 0x0c, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1a, 0x0a, - 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, - 0x01, 0x52, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, 0x0a, 0x09, 0x44, 0x69, 0x72, - 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x44, 0x69, - 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x41, 0x63, 0x74, 0x69, - 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, - 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x30, 0x0a, 0x08, - 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, - 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1a, - 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, - 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, - 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, - 0x6f, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, - 0x69, 0x78, 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, 0x73, 0x6f, 0x75, 0x72, - 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, 0x38, 0x0a, 0x0e, 0x4e, 0x65, - 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x14, 0x0a, 0x05, - 0x6e, 0x65, 0x74, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x65, 0x74, - 0x49, 0x50, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x03, 0x6d, 0x61, 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x14, - 0x0a, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x46, - 0x69, 0x6c, 0x65, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, - 0x6f, 0x12, 0x14, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x48, - 0x00, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x2e, 0x52, 0x61, 0x6e, - 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x1a, 0x2f, 0x0a, 0x05, 0x52, - 0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d, - 0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03, - 0x0a, 0x11, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, - 0x75, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, - 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, - 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, - 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, - 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, - 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, - 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, - 0x30, 0x0a, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, - 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, - 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x12, - 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x63, 0x75, 0x73, - 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, - 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, - 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x09, 0x20, - 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x18, 0x0a, - 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, - 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x22, 0xf2, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x77, - 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, - 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, - 0x12, 0x3e, 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, - 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, - 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, - 0x12, 0x2c, 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, - 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x11, 0x74, 0x72, 0x61, - 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3c, - 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e, 0x74, 0x72, - 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x8b, 0x02, 0x0a, - 0x14, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, - 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, - 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x70, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, - 0x1f, 0x0a, 0x0b, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, - 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, - 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, - 0x61, 0x6d, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, - 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, - 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45, - 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73, - 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f, - 0x72, 0x74, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c, - 0x0a, 0x12, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13, - 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x0a, 0x11, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, - 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x22, 0x14, 0x0a, 0x12, 0x53, 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x9a, 0x01, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, - 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x3a, 0x0a, - 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, - 0x6c, 0x48, 0x00, 0x52, 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x12, 0x3d, 0x0a, 0x05, 0x64, 0x65, 0x6c, - 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, - 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x48, - 0x00, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, - 0x6f, 0x61, 0x64, 0x22, 0x92, 0x0f, 0x0a, 0x18, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, - 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, - 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, - 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x12, 0x37, 0x0a, 0x0b, 0x70, 0x65, 0x65, 0x72, - 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, - 0x67, 0x12, 0x34, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x07, - 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x4d, 0x0a, 0x10, 0x61, 0x63, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, - 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, - 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x0f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, - 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x64, 0x6e, 0x73, 0x5f, 0x73, 0x65, - 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, - 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x0b, 0x64, 0x6e, - 0x73, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x6e, 0x73, - 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, - 0x6e, 0x73, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x63, 0x75, 0x73, 0x74, - 0x6f, 0x6d, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x07, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, - 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, - 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0d, - 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2d, 0x0a, - 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, - 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x13, - 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, - 0x78, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x11, 0x72, 0x6f, 0x75, 0x74, 0x65, - 0x72, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x35, 0x0a, 0x08, - 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x6c, 0x69, - 0x63, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63, - 0x69, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x0c, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x06, 0x67, - 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x2c, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, - 0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x61, 0x77, 0x52, 0x06, 0x72, 0x6f, 0x75, - 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, - 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, - 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x61, 0x77, 0x52, 0x10, - 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, - 0x12, 0x40, 0x0a, 0x0f, 0x61, 0x6c, 0x6c, 0x5f, 0x64, 0x6e, 0x73, 0x5f, 0x72, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, - 0x6f, 0x72, 0x64, 0x52, 0x0d, 0x61, 0x6c, 0x6c, 0x44, 0x6e, 0x73, 0x52, 0x65, 0x63, 0x6f, 0x72, - 0x64, 0x73, 0x12, 0x3b, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x7a, 0x6f, - 0x6e, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, - 0x65, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, - 0x4b, 0x0a, 0x11, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x73, 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, - 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x77, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, - 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x55, 0x0a, 0x0b, - 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x12, 0x20, 0x03, 0x28, - 0x0b, 0x32, 0x34, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, - 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, - 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, - 0x4d, 0x61, 0x70, 0x12, 0x71, 0x0a, 0x15, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, - 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x13, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x3d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x42, 0x02, 0x18, 0x01, + 0x52, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, + 0x73, 0x12, 0x2d, 0x0a, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x52, 0x07, 0x73, 0x73, 0x68, 0x41, 0x75, 0x74, 0x68, + 0x22, 0x82, 0x02, 0x0a, 0x07, 0x53, 0x53, 0x48, 0x41, 0x75, 0x74, 0x68, 0x12, 0x20, 0x0a, 0x0b, + 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x12, 0x28, + 0x0a, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x6d, 0x61, 0x63, 0x68, + 0x69, 0x6e, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, + 0x41, 0x75, 0x74, 0x68, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, 0x73, 0x65, 0x72, + 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, + 0x73, 0x65, 0x72, 0x73, 0x1a, 0x5f, 0x0a, 0x11, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, + 0x73, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x34, 0x0a, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x55, + 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x2e, 0x0a, 0x12, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, + 0x55, 0x73, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x69, + 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x07, 0x69, 0x6e, + 0x64, 0x65, 0x78, 0x65, 0x73, 0x22, 0xf0, 0x01, 0x0a, 0x10, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, + 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x67, + 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x67, + 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, + 0x64, 0x49, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x6c, 0x6c, 0x6f, + 0x77, 0x65, 0x64, 0x49, 0x70, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x53, 0x48, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x52, 0x09, 0x73, 0x73, 0x68, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x66, + 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x12, + 0x22, 0x0a, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x33, 0x0a, 0x09, 0x6c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x4c, 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x09, 0x6c, + 0x61, 0x7a, 0x79, 0x53, 0x74, 0x61, 0x74, 0x65, 0x22, 0x7e, 0x0a, 0x09, 0x53, 0x53, 0x48, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x73, 0x73, 0x68, 0x45, 0x6e, + 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, 0x4b, + 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x73, 0x68, 0x50, 0x75, 0x62, + 0x4b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x09, 0x6a, 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x4a, 0x57, 0x54, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x09, 0x6a, + 0x77, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x20, 0x0a, 0x1e, 0x44, 0x65, 0x76, 0x69, + 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, + 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xbf, 0x01, 0x0a, 0x17, 0x44, + 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x48, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x22, 0x16, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x12, 0x0a, 0x0a, 0x06, 0x48, 0x4f, 0x53, 0x54, 0x45, 0x44, 0x10, 0x00, 0x22, 0x1e, 0x0a, 0x1c, + 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5b, 0x0a, 0x15, + 0x50, 0x4b, 0x43, 0x45, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x42, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, + 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x76, 0x69, + 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x50, 0x72, 0x6f, 0x76, 0x69, + 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xbc, 0x03, 0x0a, 0x0e, 0x50, 0x72, + 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, + 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x44, 0x12, 0x26, 0x0a, 0x0c, 0x43, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, + 0x18, 0x01, 0x52, 0x0c, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, + 0x12, 0x16, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x41, 0x75, 0x64, 0x69, + 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x41, 0x75, 0x64, 0x69, + 0x65, 0x6e, 0x63, 0x65, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, + 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x12, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x64, 0x70, + 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0d, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x45, 0x6e, 0x64, + 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x53, 0x63, + 0x6f, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x53, 0x63, 0x6f, 0x70, 0x65, + 0x12, 0x1e, 0x0a, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x08, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x55, 0x73, 0x65, 0x49, 0x44, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x12, 0x34, 0x0a, 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x45, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x15, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, + 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, + 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x52, 0x65, + 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x52, 0x4c, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x44, 0x69, + 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, + 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x12, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x50, + 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x4c, 0x6f, + 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x4c, + 0x6f, 0x67, 0x69, 0x6e, 0x46, 0x6c, 0x61, 0x67, 0x22, 0x93, 0x02, 0x0a, 0x05, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, + 0x49, 0x44, 0x12, 0x18, 0x0a, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, 0x20, 0x0a, 0x0b, + 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x0b, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, + 0x0a, 0x04, 0x50, 0x65, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x65, + 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x06, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x12, 0x1e, 0x0a, 0x0a, 0x4d, 0x61, + 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, + 0x4d, 0x61, 0x73, 0x71, 0x75, 0x65, 0x72, 0x61, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x4e, 0x65, + 0x74, 0x49, 0x44, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x4e, 0x65, 0x74, 0x49, 0x44, + 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x6b, 0x65, + 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x6b, + 0x65, 0x65, 0x70, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x73, 0x6b, 0x69, 0x70, + 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x0d, 0x73, 0x6b, 0x69, 0x70, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x22, 0xde, + 0x01, 0x0a, 0x09, 0x44, 0x4e, 0x53, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0d, + 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x0d, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x45, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x12, 0x47, 0x0a, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x10, 0x4e, 0x61, 0x6d, 0x65, 0x53, + 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x43, + 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x75, + 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0b, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, + 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, + 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x42, 0x02, 0x18, 0x01, + 0x52, 0x0d, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x22, + 0xb8, 0x01, 0x0a, 0x0a, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x12, 0x16, + 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, + 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x52, 0x07, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, + 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x44, 0x69, 0x73, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2a, + 0x0a, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, + 0x76, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x4e, 0x6f, 0x6e, 0x41, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x74, 0x61, 0x74, 0x69, 0x76, 0x65, 0x22, 0x74, 0x0a, 0x0c, 0x53, 0x69, + 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x4e, 0x61, + 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, + 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x54, 0x79, + 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x54, 0x54, 0x4c, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x03, 0x54, 0x54, 0x4c, 0x12, 0x14, 0x0a, 0x05, 0x52, 0x44, + 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x52, 0x44, 0x61, 0x74, 0x61, + 0x22, 0xb3, 0x01, 0x0a, 0x0f, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, + 0x72, 0x6f, 0x75, 0x70, 0x12, 0x38, 0x0a, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x52, 0x0b, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x18, + 0x0a, 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x07, 0x50, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x44, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x44, 0x6f, 0x6d, 0x61, 0x69, + 0x6e, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x45, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x48, 0x0a, 0x0a, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x4e, 0x53, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, + 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, + 0x22, 0xfb, 0x02, 0x0a, 0x0c, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, + 0x65, 0x12, 0x1a, 0x0a, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x06, 0x50, 0x65, 0x65, 0x72, 0x49, 0x50, 0x12, 0x37, 0x0a, + 0x09, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, + 0x6c, 0x65, 0x44, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x44, 0x69, 0x72, + 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x06, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x06, + 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x52, 0x08, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x12, 0x0a, 0x04, + 0x50, 0x6f, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, + 0x12, 0x30, 0x0a, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, + 0x66, 0x6f, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x44, 0x12, 0x26, + 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, + 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, + 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x26, 0x0a, 0x0e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0e, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x65, 0x73, 0x22, 0x38, + 0x0a, 0x0e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, + 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x6e, 0x65, 0x74, 0x49, 0x50, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x61, 0x63, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x61, 0x63, 0x22, 0x1e, 0x0a, 0x06, 0x43, 0x68, 0x65, 0x63, + 0x6b, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x05, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x22, 0x96, 0x01, 0x0a, 0x08, 0x50, 0x6f, 0x72, + 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x14, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x72, + 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, + 0x2e, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x48, 0x00, 0x52, 0x05, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x1a, + 0x2f, 0x0a, 0x05, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, + 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x10, + 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x65, 0x6e, 0x64, + 0x42, 0x0f, 0x0a, 0x0d, 0x70, 0x6f, 0x72, 0x74, 0x53, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69, 0x6f, + 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x11, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, + 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x73, 0x6f, 0x75, 0x72, 0x63, + 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x06, 0x61, + 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x41, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x64, + 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x34, 0x0a, + 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, + 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, 0x6c, + 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x63, 0x6f, 0x6c, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x70, 0x6f, 0x72, + 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1c, 0x0a, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, 0x6d, + 0x69, 0x63, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x44, 0x79, 0x6e, 0x61, + 0x6d, 0x69, 0x63, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x07, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x26, 0x0a, + 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, + 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, + 0x44, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, + 0x44, 0x12, 0x18, 0x0a, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x18, 0x0a, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x49, 0x44, 0x22, 0xf6, 0x01, 0x0a, 0x0e, + 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x34, + 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, + 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x75, + 0x6c, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3e, 0x0a, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, + 0x6e, 0x66, 0x6f, 0x52, 0x0f, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x50, 0x6f, 0x72, 0x74, 0x12, 0x2c, 0x0a, 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, + 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x11, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, + 0x73, 0x73, 0x12, 0x3c, 0x0a, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, + 0x50, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x72, 0x74, 0x49, 0x6e, 0x66, 0x6f, + 0x52, 0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x6c, 0x61, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x72, 0x74, + 0x3a, 0x02, 0x18, 0x01, 0x22, 0x8b, 0x02, 0x0a, 0x14, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, + 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x70, 0x6f, 0x72, + 0x74, 0x12, 0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x52, + 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x70, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x75, 0x73, 0x65, 0x72, 0x5f, + 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x75, 0x73, + 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, + 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6e, 0x61, 0x6d, 0x65, 0x50, 0x72, 0x65, 0x66, 0x69, + 0x78, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, + 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, + 0x72, 0x74, 0x22, 0xa1, 0x01, 0x0a, 0x15, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x53, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x21, 0x0a, 0x0c, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x55, 0x72, 0x6c, + 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x6f, 0x72, 0x74, + 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x5f, 0x61, 0x73, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x70, 0x6f, 0x72, 0x74, 0x41, 0x75, 0x74, 0x6f, 0x41, 0x73, + 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x22, 0x2c, 0x0a, 0x12, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, + 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, + 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x15, 0x0a, 0x13, 0x52, 0x65, 0x6e, 0x65, 0x77, 0x45, 0x78, 0x70, + 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x0a, 0x11, 0x53, + 0x74, 0x6f, 0x70, 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x14, 0x0a, 0x12, 0x53, 0x74, 0x6f, 0x70, + 0x45, 0x78, 0x70, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x9a, + 0x01, 0x0a, 0x12, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x76, + 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x3a, 0x0a, 0x04, 0x66, 0x75, 0x6c, 0x6c, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, + 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x48, 0x00, 0x52, 0x04, 0x66, 0x75, 0x6c, + 0x6c, 0x12, 0x3d, 0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, + 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, + 0x74, 0x73, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x48, 0x00, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, + 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x96, 0x0f, 0x0a, 0x18, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, - 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x52, 0x13, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, - 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x6a, 0x0a, 0x14, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, - 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x14, - 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, - 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, - 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, - 0x52, 0x10, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, - 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x75, 0x73, - 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x15, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x61, 0x6c, - 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x12, 0x6e, 0x0a, 0x14, - 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x5f, 0x70, - 0x65, 0x65, 0x72, 0x73, 0x18, 0x16, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3c, 0x2e, 0x6d, 0x61, 0x6e, + 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x65, 0x72, 0x69, + 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, + 0x12, 0x37, 0x0a, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0a, 0x70, + 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x34, 0x0a, 0x07, 0x6e, 0x65, 0x74, + 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e, + 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x12, + 0x4d, 0x0a, 0x10, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, + 0x6e, 0x67, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, + 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x0f, 0x61, + 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x41, + 0x0a, 0x0c, 0x64, 0x6e, 0x73, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, + 0x70, 0x61, 0x63, 0x74, 0x52, 0x0b, 0x64, 0x6e, 0x73, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, + 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x6e, 0x73, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x6e, 0x73, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x12, 0x2c, 0x0a, 0x12, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x5f, + 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x63, 0x75, + 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x25, + 0x0a, 0x0e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x73, + 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2d, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x09, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x05, 0x70, + 0x65, 0x65, 0x72, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x5f, 0x70, + 0x65, 0x65, 0x72, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, + 0x0d, 0x52, 0x11, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, + 0x65, 0x78, 0x65, 0x73, 0x12, 0x35, 0x0a, 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, + 0x18, 0x0b, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, + 0x74, 0x52, 0x08, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x12, 0x30, 0x0a, 0x06, 0x67, + 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x43, 0x6f, + 0x6d, 0x70, 0x61, 0x63, 0x74, 0x52, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x2c, 0x0a, + 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, + 0x52, 0x61, 0x77, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, + 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, + 0x18, 0x0e, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x61, 0x6d, 0x65, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x47, 0x72, + 0x6f, 0x75, 0x70, 0x52, 0x61, 0x77, 0x52, 0x10, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x40, 0x0a, 0x0f, 0x61, 0x6c, 0x6c, 0x5f, + 0x64, 0x6e, 0x73, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, + 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x52, 0x0d, 0x61, 0x6c, 0x6c, + 0x44, 0x6e, 0x73, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x73, 0x12, 0x3b, 0x0a, 0x0d, 0x61, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, + 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5a, 0x6f, 0x6e, 0x65, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x12, 0x4b, 0x0a, 0x11, 0x6e, 0x65, 0x74, 0x77, 0x6f, + 0x72, 0x6b, 0x5f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x18, 0x11, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, + 0x61, 0x77, 0x52, 0x10, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x73, 0x12, 0x55, 0x0a, 0x0b, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x5f, + 0x6d, 0x61, 0x70, 0x18, 0x12, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x34, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, + 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, + 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, + 0x0a, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x71, 0x0a, 0x15, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, + 0x5f, 0x6d, 0x61, 0x70, 0x18, 0x13, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, - 0x2e, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, - 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x12, 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, - 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x2c, 0x0a, 0x12, - 0x64, 0x6e, 0x73, 0x5f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x6f, - 0x72, 0x74, 0x18, 0x17, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x64, 0x6e, 0x73, 0x46, 0x6f, 0x72, - 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x37, 0x0a, 0x0b, 0x70, 0x72, - 0x6f, 0x78, 0x79, 0x5f, 0x70, 0x61, 0x74, 0x63, 0x68, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, - 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, - 0x74, 0x63, 0x68, 0x12, 0x22, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x5f, 0x63, - 0x6c, 0x61, 0x69, 0x6d, 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x75, 0x73, 0x65, 0x72, - 0x49, 0x64, 0x43, 0x6c, 0x61, 0x69, 0x6d, 0x1a, 0x5c, 0x0a, 0x0f, 0x52, 0x6f, 0x75, 0x74, 0x65, - 0x72, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, - 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, - 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5d, 0x0a, 0x18, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, - 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, - 0x6b, 0x65, 0x79, 0x12, 0x2b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x64, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5b, 0x0a, 0x15, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, - 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, + 0x2e, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, + 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x13, 0x72, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x12, 0x6a, + 0x0a, 0x14, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x5f, 0x75, 0x73, + 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x14, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3a, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, + 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, + 0x6c, 0x6c, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, + 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x10, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, + 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x61, 0x6c, + 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x15, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x55, 0x73, 0x65, + 0x72, 0x49, 0x64, 0x73, 0x12, 0x6e, 0x0a, 0x14, 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x5f, + 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x16, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x3c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x4d, 0x61, 0x70, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, + 0x65, 0x6e, 0x74, 0x73, 0x46, 0x75, 0x6c, 0x6c, 0x2e, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, + 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x52, 0x12, 0x70, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, + 0x65, 0x65, 0x72, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x64, 0x6e, 0x73, 0x5f, 0x66, 0x6f, 0x72, 0x77, + 0x61, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x17, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x10, 0x64, 0x6e, 0x73, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x72, 0x50, 0x6f, + 0x72, 0x74, 0x12, 0x3b, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x70, 0x61, 0x74, 0x63, + 0x68, 0x18, 0x18, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x42, + 0x02, 0x18, 0x01, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x12, + 0x22, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x5f, 0x63, 0x6c, 0x61, 0x69, 0x6d, + 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x43, 0x6c, + 0x61, 0x69, 0x6d, 0x1a, 0x5c, 0x0a, 0x0f, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x73, 0x4d, 0x61, + 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x33, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x52, 0x6f, 0x75, 0x74, + 0x65, 0x72, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, + 0x01, 0x1a, 0x5d, 0x0a, 0x18, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x50, 0x6f, 0x6c, + 0x69, 0x63, 0x69, 0x65, 0x73, 0x4d, 0x61, 0x70, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, - 0x2c, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x55, 0x73, 0x65, 0x72, - 0x49, 0x44, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x1a, 0x5f, 0x0a, 0x17, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, - 0x65, 0x64, 0x50, 0x65, 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, - 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2e, - 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x49, - 0x6e, 0x64, 0x65, 0x78, 0x53, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, - 0x38, 0x01, 0x4a, 0x04, 0x08, 0x1a, 0x10, 0x33, 0x22, 0x87, 0x03, 0x0a, 0x0a, 0x50, 0x72, 0x6f, - 0x78, 0x79, 0x50, 0x61, 0x74, 0x63, 0x68, 0x12, 0x32, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, - 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12, 0x41, 0x0a, 0x0d, 0x6f, - 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, + 0x2b, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x6f, 0x6c, 0x69, + 0x63, 0x79, 0x49, 0x64, 0x73, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, + 0x1a, 0x5b, 0x0a, 0x15, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x54, 0x6f, 0x55, 0x73, 0x65, + 0x72, 0x49, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2c, 0x0a, 0x05, 0x76, + 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x49, 0x44, 0x4c, 0x69, + 0x73, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x5f, 0x0a, + 0x17, 0x50, 0x6f, 0x73, 0x74, 0x75, 0x72, 0x65, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x50, 0x65, + 0x65, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2e, 0x0a, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x49, 0x6e, 0x64, 0x65, 0x78, + 0x53, 0x65, 0x74, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x4a, 0x04, + 0x08, 0x1a, 0x10, 0x33, 0x22, 0x8b, 0x03, 0x0a, 0x0a, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x61, + 0x74, 0x63, 0x68, 0x12, 0x32, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, - 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x3f, - 0x0a, 0x0e, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, - 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, - 0x52, 0x0d, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, - 0x29, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x11, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, - 0x74, 0x65, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x4f, 0x0a, 0x14, 0x72, 0x6f, - 0x75, 0x74, 0x65, 0x5f, 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72, 0x75, 0x6c, - 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, - 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x12, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, - 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x45, 0x0a, 0x10, 0x66, - 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, - 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, - 0x65, 0x52, 0x0f, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, - 0x65, 0x73, 0x22, 0x94, 0x01, 0x0a, 0x16, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, + 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x12, 0x41, 0x0a, 0x0d, 0x6f, 0x66, 0x66, 0x6c, 0x69, + 0x6e, 0x65, 0x5f, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x6d, 0x6f, + 0x74, 0x65, 0x50, 0x65, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0c, 0x6f, 0x66, + 0x66, 0x6c, 0x69, 0x6e, 0x65, 0x50, 0x65, 0x65, 0x72, 0x73, 0x12, 0x3f, 0x0a, 0x0e, 0x66, 0x69, + 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0d, 0x66, 0x69, + 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x29, 0x0a, 0x06, 0x72, + 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x06, + 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x4f, 0x0a, 0x14, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x5f, + 0x66, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x05, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, 0x6c, 0x6c, 0x52, + 0x75, 0x6c, 0x65, 0x52, 0x12, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x46, 0x69, 0x72, 0x65, 0x77, 0x61, + 0x6c, 0x6c, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x45, 0x0a, 0x10, 0x66, 0x6f, 0x72, 0x77, 0x61, + 0x72, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x46, + 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x0f, 0x66, + 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x3a, 0x02, + 0x18, 0x01, 0x22, 0x94, 0x01, 0x0a, 0x16, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x43, 0x6f, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x12, 0x41, 0x0a, 0x1d, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, diff --git a/shared/management/proto/management.proto b/shared/management/proto/management.proto index c3d75af11..f7febfe9a 100644 --- a/shared/management/proto/management.proto +++ b/shared/management/proto/management.proto @@ -471,7 +471,8 @@ message NetworkMap { // RoutesFirewallRulesIsEmpty indicates whether RouteFirewallRule array is empty or not to bypass protobuf null and empty array equality. bool routesFirewallRulesIsEmpty = 11; - repeated ForwardingRule forwardingRules = 12; + // Unused; the ingress port-forwarding feature was discontinued. + repeated ForwardingRule forwardingRules = 12 [deprecated = true]; // SSHAuth represents SSH authorization configuration SSHAuth sshAuth = 13; @@ -752,17 +753,13 @@ message RouteFirewallRule { string RouteID = 10; } +// ForwardingRule is unused; the ingress port-forwarding feature was discontinued. message ForwardingRule { - // Protocol of the forwarding rule + option deprecated = true; + RuleProtocol protocol = 1; - - // portInfo is the ingress destination port information, where the traffic arrives in the gateway node PortInfo destinationPort = 2; - - // IP address of the translated address (remote peer) to send traffic to bytes translatedAddress = 3; - - // Translated port information, where the traffic should be forwarded to PortInfo translatedPort = 4; } @@ -808,7 +805,7 @@ message StopExposeResponse {} // Component-based NetworkMap wire format (PeerCapabilityComponentNetworkMap). // // Peers that advertise this capability receive NetworkMap building blocks -// (peers + groups + policies + routes + dns + ssh + forwarding) and run the +// (peers + groups + policies + routes + dns + ssh) and run the // expansion (Calculate) locally instead of receiving a fully-expanded // NetworkMap from the server. // ===================================================================== @@ -826,8 +823,8 @@ message NetworkMapEnvelope { // client decodes it into a types.NetworkMapComponents and runs Calculate() // locally to produce the same NetworkMap the legacy server path would have // produced. Every field carries RAW component data — no server-side -// expansion (firewall rules, DNS config, SSH auth, route firewall rules, -// forwarding rules) is shipped; the client computes those itself. +// expansion (firewall rules, DNS config, SSH auth, route firewall rules) +// is shipped; the client computes those itself. message NetworkMapComponentsFull { uint64 serial = 1; @@ -911,12 +908,8 @@ message NetworkMapComponentsFull { // versions; clients fold it into their Calculate() DNS output. int64 dns_forwarder_port = 23; - // Pre-expanded NetworkMap fragments injected post-Calculate by external - // controllers (BYOP / port-forwarding proxies). The receiving client - // merges these into its locally-computed NetworkMap the same way the - // legacy server does via NetworkMap.Merge — so downstream consumers see - // a unified merged result regardless of source. - ProxyPatch proxy_patch = 24; + // Unused; the ingress port-forwarding feature was discontinued. + ProxyPatch proxy_patch = 24 [deprecated = true]; // SSH UserIDClaim — server-side HttpServerConfig.AuthUserIDClaim, or // "sub" by default. Populated in proto.SSHAuth.UserIDClaim when the @@ -929,12 +922,10 @@ message NetworkMapComponentsFull { reserved 26 to 50; } -// ProxyPatch carries NetworkMap fragments that don't fit the component-graph -// model — they're pre-expanded by external controllers (BYOP / -// port-forwarding proxies) and injected post-Calculate. Fields use the -// legacy wire types because the proxy delivers them pre-formed; there is -// no raw component shape to convert from. Empty when no proxy is active. +// ProxyPatch is unused; the ingress port-forwarding feature was discontinued. message ProxyPatch { + option deprecated = true; + repeated RemotePeerConfig peers = 1; repeated RemotePeerConfig offline_peers = 2; repeated FirewallRule firewall_rules = 3; diff --git a/shared/management/types/network.go b/shared/management/types/network.go index 1269bac4c..ab61d7cb2 100644 --- a/shared/management/types/network.go +++ b/shared/management/types/network.go @@ -1,13 +1,8 @@ package types import ( - "net" - - "golang.org/x/exp/maps" - nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" - "github.com/netbirdio/netbird/shared/management/proto" ) const ( @@ -25,7 +20,6 @@ type NetworkMap struct { OfflinePeers []*nmdata.Peer FirewallRules []*FirewallRule RoutesFirewallRules []*RouteFirewallRule - ForwardingRules []*ForwardingRule AuthorizedUsers map[string]map[string]struct{} EnableSSH bool // ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS @@ -33,101 +27,3 @@ type NetworkMap struct { // domain targets. ForceRoutingPeerDNSResolution bool } - -func (nm *NetworkMap) Merge(other *NetworkMap) { - nm.Peers = mergeUniquePeersByID(nm.Peers, other.Peers) - nm.Routes = mergeUnique(nm.Routes, other.Routes) - nm.OfflinePeers = mergeUniquePeersByID(nm.OfflinePeers, other.OfflinePeers) - nm.FirewallRules = mergeUnique(nm.FirewallRules, other.FirewallRules) - nm.RoutesFirewallRules = mergeUnique(nm.RoutesFirewallRules, other.RoutesFirewallRules) - nm.ForwardingRules = mergeUnique(nm.ForwardingRules, other.ForwardingRules) - nm.ForceRoutingPeerDNSResolution = nm.ForceRoutingPeerDNSResolution || other.ForceRoutingPeerDNSResolution -} - -func mergeUniquePeersByID(peers1, peers2 []*nmdata.Peer) []*nmdata.Peer { - result := make(map[string]*nmdata.Peer) - for _, peer := range peers1 { - result[peer.ID] = peer - } - for _, peer := range peers2 { - if _, ok := result[peer.ID]; !ok { - result[peer.ID] = peer - } - } - - return maps.Values(result) -} - -type ForwardingRule struct { - RuleProtocol string - DestinationPorts RulePortRange - TranslatedAddress net.IP - TranslatedPorts RulePortRange -} - -func (f *ForwardingRule) ToProto() *proto.ForwardingRule { - var protocol proto.RuleProtocol - switch f.RuleProtocol { - case "icmp": - protocol = proto.RuleProtocol_ICMP - case "tcp": - protocol = proto.RuleProtocol_TCP - case "udp": - protocol = proto.RuleProtocol_UDP - case "all": - protocol = proto.RuleProtocol_ALL - default: - protocol = proto.RuleProtocol_UNKNOWN - } - return &proto.ForwardingRule{ - Protocol: protocol, - DestinationPort: f.DestinationPorts.ToProto(), - TranslatedAddress: ipToBytes(f.TranslatedAddress), - TranslatedPort: f.TranslatedPorts.ToProto(), - } -} - -func (f *ForwardingRule) Equal(other *ForwardingRule) bool { - return f.RuleProtocol == other.RuleProtocol && - f.DestinationPorts.Equal(&other.DestinationPorts) && - f.TranslatedAddress.Equal(other.TranslatedAddress) && - f.TranslatedPorts.Equal(&other.TranslatedPorts) -} - -func ipToBytes(ip net.IP) []byte { - if ip4 := ip.To4(); ip4 != nil { - return ip4 - } - return ip.To16() -} - -type comparableObject[T any] interface { - Equal(other T) bool -} - -func mergeUnique[T comparableObject[T]](arr1, arr2 []T) []T { - var result []T - - for _, item := range arr1 { - if !containsEqual(result, item) { - result = append(result, item) - } - } - - for _, item := range arr2 { - if !containsEqual(result, item) { - result = append(result, item) - } - } - - return result -} - -func containsEqual[T comparableObject[T]](slice []T, element T) bool { - for _, item := range slice { - if item.Equal(element) { - return true - } - } - return false -} diff --git a/shared/management/types/network_test.go b/shared/management/types/network_test.go deleted file mode 100644 index 631f38836..000000000 --- a/shared/management/types/network_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package types - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -type mergeTestObject struct { - value int -} - -func (t mergeTestObject) Equal(other mergeTestObject) bool { - return t.value == other.value -} - -func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) { - arr1 := []mergeTestObject{{value: 1}, {value: 2}} - arr2 := []mergeTestObject{{value: 2}, {value: 3}} - result := mergeUnique(arr1, arr2) - assert.Len(t, result, 3) - assert.Contains(t, result, mergeTestObject{value: 1}) - assert.Contains(t, result, mergeTestObject{value: 2}) - assert.Contains(t, result, mergeTestObject{value: 3}) -} - -func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) { - arr1 := []mergeTestObject{} - arr2 := []mergeTestObject{} - result := mergeUnique(arr1, arr2) - assert.Empty(t, result) -} - -func Test_MergeUniqueHandlesOneEmptyArray(t *testing.T) { - arr1 := []mergeTestObject{{value: 1}, {value: 2}} - arr2 := []mergeTestObject{} - result := mergeUnique(arr1, arr2) - assert.Len(t, result, 2) - assert.Contains(t, result, mergeTestObject{value: 1}) - assert.Contains(t, result, mergeTestObject{value: 2}) -} From 51a9d32cfcf053b652e4588297e6c6ef8708ffac Mon Sep 17 00:00:00 2001 From: Edward <43848523+thomashacker@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:59:26 +0200 Subject: [PATCH 114/209] [client] Warn on missing translations and check placeholders (#8090) --- .github/workflows/ui-translations.yml | 5 +- client/ui/i18n/check-translations.mjs | 130 +++++++++++++++++++------- 2 files changed, 99 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ui-translations.yml b/.github/workflows/ui-translations.yml index 24b7c9de2..9ac524495 100644 --- a/.github/workflows/ui-translations.yml +++ b/.github/workflows/ui-translations.yml @@ -36,7 +36,8 @@ jobs: with: node-version: "22" - # English (en) is the source of truth for translation keys; every other - # locale declared in _index.json must carry the exact same key set. + # English (en) is the source of truth for translation keys. Locales declared + # in _index.json fail on orphaned keys or placeholder mismatches; missing + # keys only warn, since they fall back to English at runtime. - name: Check translation key parity run: node client/ui/i18n/check-translations.mjs diff --git a/client/ui/i18n/check-translations.mjs b/client/ui/i18n/check-translations.mjs index bd076e0e0..a6b666337 100644 --- a/client/ui/i18n/check-translations.mjs +++ b/client/ui/i18n/check-translations.mjs @@ -1,13 +1,20 @@ #!/usr/bin/env node -// Validates that every shipped translation bundle carries exactly the same set -// of keys as the English source of truth. English (en) defines the keys; every -// other locale declared in _index.json must match it 1:1: +// Validates every shipped translation bundle against the English source of +// truth. English (en) defines the keys; for every other locale declared in +// _index.json: // -// - no missing keys — a missing key silently falls back to English at runtime -// (see i18n bundle fallback), so the gap never surfaces to users or CI -// without this check; -// - no orphaned keys — keys left behind after an English key is renamed or -// removed are dead weight and a sign the locale is drifting. +// - missing keys are reported as a warning, not a failure — a missing key +// falls back to English at runtime (see i18n bundle fallback), and Crowdin +// exports only translated strings (skip_untranslated_strings in +// crowdin.yml), so a locale is routinely behind English until translators +// catch up; +// - orphaned keys fail — keys left behind after an English key is renamed or +// removed are dead weight and a sign the locale is drifting; +// - empty messages fail — a present key with an empty, whitespace-only or +// missing message renders blank instead of falling back to English; +// - placeholder mismatches fail — a translation must use exactly the +// {placeholders} of its English string, otherwise a value silently never +// renders (or a literal "{name}" leaks into the UI). // // Pure Node, no dependencies, so it runs without installing the frontend // toolchain. @@ -23,17 +30,40 @@ const SOURCE = "en"; const localesDir = join(dirname(fileURLToPath(import.meta.url)), "locales"); const isCI = Boolean(process.env.GITHUB_ACTIONS); +// Matches the i18next interpolation configured in the frontend +// (prefix "{", suffix "}") and the Go bundle's applyPlaceholders. +const PLACEHOLDER = /\{([^{}\s]+)\}/g; + function readJSON(path) { return JSON.parse(readFileSync(path, "utf8")); } -function keysOf(langCode) { - return Object.keys(readJSON(join(localesDir, langCode, "common.json"))); +function messagesOf(langCode) { + const entries = readJSON(join(localesDir, langCode, "common.json")); + const messages = new Map(); + for (const [key, entry] of Object.entries(entries)) { + // null marks an unusable entry (missing or non-string message). + messages.set(key, typeof entry?.message === "string" ? entry.message : null); + } + return messages; } -// Emit a GitHub Actions annotation so failures render inline on the PR diff. -function annotate(file, message) { - if (isCI) console.log(`::error file=${file}::${message}`); +function placeholdersOf(message) { + // Code-point order: placeholder names are identifiers, not prose. + return [...new Set([...message.matchAll(PLACEHOLDER)].map((m) => m[1]))].sort((a, b) => { + if (a < b) return -1; + if (a > b) return 1; + return 0; + }); +} + +function formatPlaceholders(names) { + return names.length ? names.map((n) => `{${n}}`).join(", ") : "none"; +} + +// Emit a GitHub Actions annotation so findings render inline on the PR diff. +function annotate(level, file, message) { + if (isCI) console.log(`::${level} file=${file}::${message}`); } const index = readJSON(join(localesDir, "_index.json")); @@ -44,8 +74,13 @@ if (!declared.includes(SOURCE)) { process.exit(1); } -const sourceKeys = keysOf(SOURCE); -const sourceSet = new Set(sourceKeys); +const source = messagesOf(SOURCE); +const sourceKeys = [...source.keys()]; +const emptySource = sourceKeys.filter((k) => !source.get(k)?.trim()); +if (emptySource.length) { + console.error(`FATAL: ${SOURCE}/common.json has empty or missing messages: ${emptySource.join(", ")}`); + process.exit(1); +} console.log(`Source of truth: ${SOURCE}/common.json — ${sourceKeys.length} keys\n`); let failed = false; @@ -54,40 +89,67 @@ for (const code of declared) { if (code === SOURCE) continue; const file = `client/ui/i18n/locales/${code}/common.json`; - let keys; + let messages; try { - keys = keysOf(code); + messages = messagesOf(code); } catch (e) { failed = true; const msg = `bundle is declared in _index.json but common.json is missing or invalid (${e.message})`; console.error(`✗ ${code}: ${msg}`); - annotate("client/ui/i18n/locales/_index.json", `${code}: ${msg}`); + annotate("error", "client/ui/i18n/locales/_index.json", `${code}: ${msg}`); continue; } - const set = new Set(keys); - const missing = sourceKeys.filter((k) => !set.has(k)); - const extra = keys.filter((k) => !sourceSet.has(k)); - - if (missing.length === 0 && extra.length === 0) { - console.log(`✓ ${code}: ${keys.length} keys`); - continue; + const missing = sourceKeys.filter((k) => !messages.has(k)); + const extra = [...messages.keys()].filter((k) => !source.has(k)); + const empty = []; + const badPlaceholders = []; + for (const [key, message] of messages) { + if (!source.has(key)) continue; + if (!message?.trim()) { + empty.push(key); + continue; + } + const want = placeholdersOf(source.get(key)); + const got = placeholdersOf(message); + if (want.length !== got.length || want.some((name, i) => name !== got[i])) { + badPlaceholders.push(`${key} (expected ${formatPlaceholders(want)}, got ${formatPlaceholders(got)})`); + } } - failed = true; - console.error(`✗ ${code}: ${keys.length} keys (expected ${sourceKeys.length})`); + const translated = sourceKeys.length - missing.length - empty.length; + const coverage = Math.floor((translated / sourceKeys.length) * 100); + const hasErrors = extra.length > 0 || empty.length > 0 || badPlaceholders.length > 0; + let mark = "✓"; + if (hasErrors) mark = "✗"; + else if (missing.length) mark = "⚠"; + const log = hasErrors ? console.error : console.log; + log(`${mark} ${code}: ${translated}/${sourceKeys.length} keys translated (${coverage}%)`); + if (missing.length) { - console.error(` missing ${missing.length}: ${missing.join(", ")}`); - annotate(file, `Missing ${missing.length} key(s) present in ${SOURCE}: ${missing.join(", ")}`); + console.warn(` missing ${missing.length} (falls back to English): ${missing.join(", ")}`); + annotate("warning", file, `Missing ${missing.length} key(s) present in ${SOURCE}, shown in English: ${missing.join(", ")}`); } if (extra.length) { + failed = true; console.error(` extra ${extra.length}: ${extra.join(", ")}`); - annotate(file, `Has ${extra.length} key(s) not present in ${SOURCE}: ${extra.join(", ")}`); + annotate("error", file, `Has ${extra.length} key(s) not present in ${SOURCE}: ${extra.join(", ")}`); + } + if (empty.length) { + failed = true; + console.error(` empty message ${empty.length} (renders blank): ${empty.join(", ")}`); + annotate("error", file, `Empty or missing message in ${empty.length} key(s), renders blank: ${empty.join(", ")}`); + } + if (badPlaceholders.length) { + failed = true; + console.error(` placeholder mismatch ${badPlaceholders.length}: ${badPlaceholders.join("; ")}`); + annotate("error", file, `Placeholders differ from ${SOURCE} in ${badPlaceholders.length} key(s): ${badPlaceholders.join("; ")}`); } } -// Locale directories present on disk but not declared in _index.json are never -// loaded by the app — surface them so dead translation files don't rot silently. +// Locale directories present on disk but not declared in _index.json are not +// offered in the language picker — surface them so dead translation files don't +// rot silently. const onDisk = readdirSync(localesDir, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name); @@ -98,7 +160,7 @@ if (undeclared.length) { console.log(); if (failed) { - console.error("Translation check FAILED — every locale must match the English key set."); + console.error("Translation check FAILED — fix orphaned keys, empty messages and placeholder mismatches above."); process.exit(1); } -console.log("Translation check passed — all locales match the English key set."); +console.log("Translation check passed — no orphaned keys, empty messages or placeholder mismatches."); From d3d27dc08d40959fc0685c4979c4da67a643257d Mon Sep 17 00:00:00 2001 From: Edward <43848523+thomashacker@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:00:21 +0200 Subject: [PATCH 115/209] [client] Only offer locales listed in _index.json (#8091) --- client/ui/frontend/src/lib/i18n.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/client/ui/frontend/src/lib/i18n.ts b/client/ui/frontend/src/lib/i18n.ts index 87cc996dd..25f2b282a 100644 --- a/client/ui/frontend/src/lib/i18n.ts +++ b/client/ui/frontend/src/lib/i18n.ts @@ -40,14 +40,29 @@ function detectBrowserLanguage(available: string[]): string | null { return null; } -// An empty persisted language code is the Go-side signal for first run. +// Only locales listed in _index.json are shipped. A bundle on disk without an +// _index.json row (e.g. a new Crowdin language not released yet) must not be +// auto-detected or loaded, matching the Go side, which rejects saving it. +async function shippedLanguages(): Promise { + const onDisk = Object.keys(resources); + try { + const listed = new Set((await I18n.Languages()).map((l) => l.code)); + return onDisk.filter((code) => listed.has(code)); + } catch (e) { + console.warn("load shipped languages failed, using all bundled locales", e); + return onDisk; + } +} + +// An empty persisted language code is the Go-side signal for first run. A +// persisted code that is no longer shipped is treated the same way. export async function initI18n(): Promise { - const available = Object.keys(resources); + const available = await shippedLanguages(); let language = "en"; let firstRun = false; try { const prefs = await Preferences.Get(); - if (prefs?.language) { + if (prefs?.language && available.includes(prefs.language)) { language = prefs.language; } else { firstRun = true; @@ -68,7 +83,7 @@ export async function initI18n(): Promise { fallbackLng: "en", defaultNS: "common", ns: ["common"], - resources, + resources: Object.fromEntries(available.map((code) => [code, resources[code]])), interpolation: { prefix: "{", suffix: "}", From 2b9c36dc4901d888f4f16c3feb45d223c4beacbc Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Tue, 6 Oct 2026 17:33:54 +0200 Subject: [PATCH 116/209] [client] Close the UI before InstallValidate in the MSI (#7395) * [client] Close the UI before InstallValidate in the MSI The WiX CloseApplication action runs deferred, right before InstallFiles. By then InstallValidate has already asked Restart Manager about the files in use. When msiexec runs as LocalSystem (third-party deployment tools, scheduled tasks) and netbird-ui.exe runs in the interactive user's session, Restart Manager reports a session mismatch, so the installer schedules the UI binary for replacement on the next reboot and returns 3010. Killing the UI afterwards is too late, the file stays on the old version until a real reboot happens. Replace the CloseApplication with an immediate WixQuietExec custom action running taskkill /F /IM netbird-ui.exe, scheduled before InstallValidate. The file is no longer held open when the in-use check runs, InstallFiles overwrites it directly and no reboot is scheduled. Return is ignored because taskkill exits non-zero when no UI is running. The action also runs on uninstall so removal does not require a reboot either. * Keep the deferred UI close as a fallback for the immediate taskkill The immediate taskkill runs with the token of whoever launched msiexec. A non-SYSTEM install cannot terminate a UI running in another user's session, so keep the deferred CloseApplication, which runs as LocalSystem, to stop it before InstallFiles. When taskkill already succeeded it finds no process. * Document why the UI kill runs before InstallValidate --- client/netbird.wxs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/netbird.wxs b/client/netbird.wxs index f30a7aa7e..156b4ff27 100644 --- a/client/netbird.wxs +++ b/client/netbird.wxs @@ -76,6 +76,14 @@ + + + + nblink (loopback, userspace peer) --tunnel--> proxy --http--> vllm +// +// The regular agent needs NET_ADMIN and a TUN device to put the endpoint on +// the network. nblink runs the same client in netstack mode and hands the +// endpoint to whatever can reach a loopback port, so the container is granted +// no capabilities and mounts no device — the whole point of the binary, and +// what fails first if netstack mode regresses. +// +// Three things are being proved together, and none of them holds on its own: +// +// - The forward reaches the endpoint, which means the endpoint's name +// resolved inside the process. Nothing on the container can resolve it: +// the synthesized zone is served by the in-process DNS, /etc/resolv.conf +// is never touched, and a name that leaked to the host resolver would be +// NXDOMAIN. +// - The forwarder's peer is a peer like any other. Its request is authorized +// against the policy that names its group, metered, and written to the +// access log under its own identity. +// - The loopback listener still refuses what a browser can aim at it, which +// is the one new attack surface a forward on localhost creates. +// +// One forwarder carries most of the cases, because each one costs a session: +// it serves three forwards, reads its setup key from a file, and keeps its +// identity on a volume so the last case can restart it. +func TestNBLinkReachesAgentNetwork(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Minute) + defer cancel() + + vllm, err := harness.StartVLLM(ctx, srv) + require.NoError(t, err, "start mock vLLM server") + t.Cleanup(func() { _ = vllm.Terminate(context.Background()) }) + + // allowed holds the forwarder's peer and is named by the policy. denied + // holds a second forwarder's peer and is named by nothing, so the proxy + // has no policy to authorize it under. + allowed, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-nblink-allowed"}) + require.NoError(t, err, "create allowed group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), allowed.Id) }) + + denied, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-nblink-denied"}) + require.NoError(t, err, "create denied group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), denied.Id) }) + + allowedKey := mintSetupKey(ctx, t, "e2e-nblink-allowed-key", allowed.Id) + deniedKey := mintSetupKey(ctx, t, "e2e-nblink-denied-key", denied.Id) + + dummyKey := "sk-nblink-e2e" + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "nblink-vllm", + ProviderId: "vllm", + UpstreamUrl: vllm.URL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: harness.VLLMModel, InputPer1k: 0.001, OutputPer1k: 0.002}, + }, + }) + require.NoError(t, err, "create provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), prov.Id) }) + + // A second provider on the mock's streaming listener, which answers every + // request as server-sent events. + streamProv, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: "nblink-stream", + ProviderId: "anthropic_api", + UpstreamUrl: vllm.StreamURL, + ApiKey: &dummyKey, + Enabled: ptr(true), + Models: &[]api.AgentNetworkProviderModel{ + {Id: streamedModel, InputPer1k: streamInRate, OutputPer1k: streamOutRate}, + }, + }) + require.NoError(t, err, "create streaming provider") + t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), streamProv.Id) }) + + // Caps far above what this test drives, so the limit never blocks but + // usage metering is switched on — that is what writes consumption rows. + pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{ + Name: "e2e-nblink-allow", + Enabled: ptr(true), + SourceGroups: []string{allowed.Id}, + DestinationProviderIds: []string{prov.Id, streamProv.Id}, + Limits: &api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: true, + GroupCap: 10_000_000, + UserCap: 10_000_000, + WindowSeconds: 60, + }, + }, + }) + require.NoError(t, err, "create policy") + t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) }) + + settings, err := srv.GetSettings(ctx) + require.NoError(t, err, "read settings") + require.NotEmpty(t, settings.Endpoint, "endpoint must be assigned") + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-nblink-proxy") + require.NoError(t, err, "mint proxy token") + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + stateVolume := fmt.Sprintf("e2e-nblink-state-%d", time.Now().UnixNano()) + t.Cleanup(func() { _ = harness.RemoveDockerVolume(context.Background(), stateVolume) }) + + // The upstream is the endpoint itself, over HTTPS. nblink verifies the + // certificate like any Go client, so it needs the proxy's self-signed cert + // — the curl helpers elsewhere in this suite sidestep that with -k. + forward := nblinkForward(harness.NBLinkPort, "https://"+settings.Endpoint) + nbOpts := []harness.NBLinkOption{ + harness.WithNBLinkForwards( + nblinkForward(nblinkSecondPort, "https://"+settings.Endpoint), + nblinkForward(nblinkDeadPort, nblinkDeadUpstream), + fmt.Sprintf("http://0.0.0.0:%d=https://%s", nblinkPublicPort, settings.Endpoint), + ), + harness.WithNBLinkEnv("NB_ALLOW_PUBLIC_BIND", "true"), + // The name other containers use for the public forward. + harness.WithNBLinkEnv("NB_ALLOWED_HOST", "nblink"), + harness.WithNBLinkSetupKeyFile(), + harness.WithNBLinkStateVolume(stateVolume), + } + nb, err := harness.StartNBLink(ctx, srv, allowedKey, forward, px.CACertPath(), nbOpts...) + require.NoError(t, err, "start nblink") + // nb is replaced by the restart case, so the cleanup reads the variable + // rather than the first value. + t.Cleanup(func() { _ = nb.Terminate(context.Background()) }) + + diag := func() string { + return "\n=== nblink logs ===\n" + nb.Logs(context.Background()) + + "\n=== proxy logs ===\n" + px.Logs(context.Background()) + } + + before, _ := srv.ListAccessLogs(ctx) + sessionID := "e2e-session-nblink" + + // Retry to absorb the beat between the session coming up and the proxy + // peer being reachable; the forward itself is already bound by now. + code, body := chatUntil(ctx, t, nb, 200, 120*time.Second, sessionID) + require.Equal(t, 200, code, + "a chat through the nblink forward must be served; body: %s%s", body, diag()) + require.Contains(t, body, "chat.completion", + "the body should be the upstream's OpenAI-compatible completion; got: %s", body) + + var peer api.Peer + t.Run("the peer registers under its hostname", func(t *testing.T) { + peers := peersWithHostname(ctx, t, nb.Hostname()) + require.Len(t, peers, 1, "exactly one peer must carry the forwarder's hostname %q", nb.Hostname()) + peer = peers[0] + assert.True(t, peerInGroup(peer, allowed.Id), + "the setup key read from the file must have joined the peer to its auto-group") + }) + + t.Run("the request is attributed to the forwarder's own peer", func(t *testing.T) { + require.NotEmpty(t, peer.Ip, "the peer lookup must have run first") + + var row *api.AgentNetworkAccessLog + require.Eventually(t, func() bool { + logs, lerr := srv.ListAccessLogs(ctx) + if lerr != nil || logs.TotalRecords <= before.TotalRecords { + return false + } + for i, r := range logs.Data { + if r.SessionId != nil && *r.SessionId == sessionID { + row = &logs.Data[i] + return true + } + } + return false + }, 30*time.Second, 2*time.Second, + "session %q must reach the access log; a forward is not a way around accounting%s", sessionID, diag()) + require.NotNil(t, row.SourceIp, "the access log row must carry the caller's overlay address") + assert.Equal(t, peer.Ip, *row.SourceIp, + "the access log must name the forwarder's peer as the caller") + + require.Eventually(t, func() bool { + rows, lerr := srv.ListConsumption(ctx) + if lerr != nil { + return false + } + for _, r := range rows { + if r.TokensInput > 0 && r.TokensOutput > 0 { + return true + } + } + return false + }, 60*time.Second, 3*time.Second, "the upstream's usage must be metered into a consumption row") + }) + + t.Run("every forward in one process serves over the same session", func(t *testing.T) { + scode, sbody, serr := nb.PostOn(ctx, nblinkSecondPort, harness.ChatPath, + harness.ChatBody(harness.VLLMModel, "Reply with exactly: pong"), nil) + require.NoError(t, serr, "the probe must reach the second listener") + assert.Equal(t, 200, scode, "the second forward must be served too; body: %s%s", sbody, diag()) + assert.Contains(t, sbody, "chat.completion", "the second forward must carry the upstream completion back") + }) + + t.Run("an upstream nothing serves answers 502 without hanging", func(t *testing.T) { + start := time.Now() + dcode, dbody, derr := nb.PostOn(ctx, nblinkDeadPort, harness.ChatPath, + harness.ChatBody(harness.VLLMModel, "Reply with exactly: pong"), nil) + elapsed := time.Since(start) + require.NoError(t, derr, "the probe must reach the third listener") + assert.Equal(t, 502, dcode, "a forward whose upstream cannot be dialled must answer 502; body: %s", dbody) + assert.Contains(t, dbody, nblinkUnreachableBody, "the 502 must be the forwarder's own answer") + // The forwarder bounds a dial at 30s; anything near curl's 90s limit + // means a request was left hanging. + assert.Less(t, elapsed, 40*time.Second, "an unreachable upstream must fail fast, took %s", elapsed) + }) + + t.Run("a streamed reply arrives as events and is metered", func(t *testing.T) { + streamSession := "e2e-session-nblink-stream" + body := fmt.Sprintf(`{"model":%q,"max_tokens":64,"stream":true,"messages":[{"role":"user","content":"Reply with exactly: pong"}]}`, streamedModel) + scode, sbody, serr := nb.Post(ctx, "/v1/messages", body, + []string{"anthropic-version: 2023-06-01", "x-session-id: " + streamSession}) + require.NoError(t, serr, "the probe must reach the listener") + require.Equal(t, 200, scode, "a streamed request must be served; body: %s%s", sbody, diag()) + assert.Contains(t, sbody, "event: message_start", "the caller must receive the event stream itself") + assert.Contains(t, sbody, "message_stop", "the stream must arrive complete") + + row := findAccessLogBySession(t, ctx, streamSession) + assert.Equal(t, harness.VLLMStreamInputTokens, int(row.InputTokens), + "the proxy must meter a stream carried by the forwarder like any other") + assert.Equal(t, harness.VLLMStreamOutputTokens, int(row.OutputTokens), + "output tokens must be metered from the stream") + }) + + t.Run("only the public forward is reachable from the network", func(t *testing.T) { + chat := harness.ChatBody(harness.VLLMModel, "Reply with exactly: pong") + + pcode, pbody, perr := nb.PostFromNetwork(ctx, nblinkPublicPort, harness.ChatPath, chat, nil) + require.NoError(t, perr, "the public forward must accept a connection from another container") + assert.Equal(t, 200, pcode, "the public forward must serve a caller under a listed name; body: %s", pbody) + + // The shape a rebound page sends: its own name, with Origin and + // Sec-Fetch-Site agreeing that the request is same-origin. + rcode, rbody, rerr := nb.PostFromNetwork(ctx, nblinkPublicPort, harness.ChatPath, chat, []string{ + "Host: attacker.example:8083", "Origin: http://attacker.example:8083", "Sec-Fetch-Site: same-origin", + }) + require.NoError(t, rerr) + assert.Equal(t, 421, rcode, "a public forward must refuse a name it was not given; body: %s", rbody) + assert.Contains(t, nb.Logs(ctx), fmt.Sprintf("forwarding http://0.0.0.0:%d", nblinkPublicPort), + "a 0.0.0.0 forward must bind the IPv4 wildcard, not the dual-stack one") + + // The Host check only protects loopback listeners, but the browser + // checks still hold on a public one. + ocode, _, oerr := nb.PostFromNetwork(ctx, nblinkPublicPort, harness.ChatPath, chat, + []string{"Origin: https://attacker.example"}) + require.NoError(t, oerr) + assert.Equal(t, 403, ocode, "a cross-origin browser request must be refused on a public forward too") + + _, _, lerr := nb.PostFromNetwork(ctx, harness.NBLinkPort, harness.ChatPath, chat, nil) + assert.Error(t, lerr, "a loopback forward must refuse connections from other machines") + }) + + t.Run("the logs show no secrets and no side effects on the host", func(t *testing.T) { + // The suite runs at debug level, the most an operator can turn on. + logs := nb.Logs(ctx) + require.Contains(t, logs, "over the overlay", "the logs must have been read") + assert.NotContains(t, logs, allowedKey, "the setup key must not be logged") + assert.Contains(t, logs, "NAT port mapper disabled", "nblink must not ask the router for a port mapping") + assert.NotContains(t, logs, "/var/lib/netbird", "nblink must not touch an installed agent's state") + assert.NotContains(t, logs, "failed to login to Management Service", + "registering a new peer must not log an error") + }) + + t.Run("a peer no policy names cannot reach the endpoint", func(t *testing.T) { + other, oerr := harness.StartNBLink(ctx, srv, deniedKey, forward, px.CACertPath(), + harness.WithNBLinkName("nblink-denied")) + require.NoError(t, oerr, "start the second forwarder") + t.Cleanup(func() { _ = other.Terminate(context.Background()) }) + otherDiag := func() string { + return "\n=== denied nblink logs ===\n" + other.Logs(context.Background()) + + "\n=== proxy logs ===\n" + px.Logs(context.Background()) + } + + // Management gives the endpoint's DNS record, the proxy peer and the + // rule allowing 443 to it only to peers in an enabled policy's source + // groups. This peer gets none of them, so it is refused in the overlay + // itself and the request never reaches the proxy's router. The + // forwarder reports that as its own 502. + // + // Keep asking for as long as the allowed forwarder needed to be served, + // so a 502 cannot be blamed on a tunnel that had not settled yet. + deniedSession := "e2e-session-nblink-denied" + deadline := time.Now().Add(60 * time.Second) + var ocode int + var obody string + for time.Now().Before(deadline) { + ocode, obody, _ = other.Chat(ctx, harness.VLLMModel, "Reply with exactly: pong", deniedSession) + if ocode != 502 { + break + } + time.Sleep(5 * time.Second) + } + assert.Equal(t, 502, ocode, + "a forwarder whose peer is in no policy's source group must not get through; body: %s%s", obody, otherDiag()) + assert.Contains(t, obody, nblinkUnreachableBody, + "the refusal must be the forwarder failing to dial, not an answer from the proxy; body: %s", obody) + assert.NotContains(t, obody, "chat.completion", "the refusal must not carry an upstream completion") + + // The forwarder logs why the dial failed at debug level, which this + // suite runs at. Finding the line ties the 502 to this upstream rather + // than to some other failure inside the forwarder. + dialFailure := fmt.Sprintf("dial %s:443 over the overlay failed", settings.Endpoint) + logs := other.Logs(ctx) + require.Contains(t, logs, dialFailure, "the 502 must come from the overlay dial; logs:\n%s", logs) + for _, line := range strings.Split(logs, "\n") { + if strings.Contains(line, dialFailure) { + t.Logf("denied forwarder: %s", strings.TrimSpace(line)) + break + } + } + + // The allowed forwarder is still served at the same moment, so the + // refusal is about this peer and not about the endpoint being down. + acode, abody := chatUntil(ctx, t, nb, 200, 30*time.Second, "") + assert.Equal(t, 200, acode, "the allowed forwarder must still be served; body: %s", abody) + + rows, lerr := srv.ListAccessLogs(ctx) + require.NoError(t, lerr, "list access logs") + for _, r := range rows.Data { + assert.False(t, r.SessionId != nil && *r.SessionId == deniedSession, + "a request that never reached the proxy must leave no access log row") + } + }) + + t.Run("the loopback listener refuses browser-driven callers", func(t *testing.T) { + // A page the user visits can aim a request at a loopback listener in + // several shapes. Each must be refused before anything is proxied + // under the peer's identity, while an ordinary local caller is + // unaffected. + cases := []struct { + name string + headers []string + want int + }{ + { + name: "a foreign Host, which is DNS rebinding", + headers: []string{"Host: attacker.example"}, + want: 421, + }, + { + name: "a cross-origin fetch", + headers: []string{"Origin: https://attacker.example"}, + want: 403, + }, + { + name: "a page on another local port, which is cross-origin too", + // Same machine, different origin: localhost:3000 has no more + // claim on this listener than any other site. + headers: []string{"Origin: http://localhost:3000"}, + want: 403, + }, + { + name: "an embedded no-cors GET, which carries no Origin at all", + // The shape the Host and Origin checks both miss: a loopback + // Host and no Origin. Sec-Fetch-Site is the only signal left. + headers: []string{"Sec-Fetch-Site: cross-site"}, + want: 403, + }, + } + // Every case sends the request already proven to return 200, so the + // only difference between a refusal and a success is the header under + // test. + body := harness.ChatBody(harness.VLLMModel, "Reply with exactly: pong") + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gcode, gbody, gerr := nb.Post(ctx, harness.ChatPath, body, tc.headers) + require.NoError(t, gerr, "the probe itself must reach the listener") + assert.Equal(t, tc.want, gcode, + "%s must be refused by the listener, not proxied; body: %s", tc.name, gbody) + assert.NotContains(t, gbody, "chat.completion", + "a refused request must never carry an upstream completion back") + }) + } + + // The control: the same request with none of those headers is served, + // so the cases above prove a guard rather than a broken forward. + gcode, gbody, gerr := nb.Post(ctx, harness.ChatPath, body, nil) + require.NoError(t, gerr) + assert.Equal(t, 200, gcode, + "a plain local caller must still be served; body: %s%s", gbody, diag()) + }) + + t.Run("an upstream whose certificate is not trusted is refused", func(t *testing.T) { + // No CA mounted: the proxy's self-signed certificate is unknown to the + // forwarder, which must refuse it rather than proxy in the clear. + untrusted, uerr := harness.StartNBLink(ctx, srv, allowedKey, forward, "", + harness.WithNBLinkName("nblink-untrusted")) + require.NoError(t, uerr, "start the forwarder without the proxy's CA") + t.Cleanup(func() { _ = untrusted.Terminate(context.Background()) }) + + // Until the tunnel settles the dial itself fails with the same 502, so + // wait for the handshake failure specifically. + var ucode int + var ubody string + require.Eventually(t, func() bool { + ucode, ubody, _ = untrusted.Chat(ctx, harness.VLLMModel, "Reply with exactly: pong", "") + return strings.Contains(untrusted.Logs(ctx), "x509") + }, 120*time.Second, 5*time.Second, "the forwarder must reach the TLS handshake and fail it; logs:\n%s", untrusted.Logs(ctx)) + assert.Equal(t, 502, ucode, "an unverified upstream must not be served; body: %s", ubody) + assert.NotContains(t, ubody, "chat.completion", "no completion may come back over an unverified connection") + }) + + t.Run("an arbitrary UID in group 0 keeps its identity on a volume", func(t *testing.T) { + // OpenShift's restricted SCC runs the image as a UID from the + // namespace range with group 0. The state directory is group 0 and + // group-writable, and a fresh volume inherits that. + const name = "nblink-openshift" + volume := fmt.Sprintf("e2e-nblink-openshift-%d", time.Now().UnixNano()) + t.Cleanup(func() { _ = harness.RemoveDockerVolume(context.Background(), volume) }) + opts := []harness.NBLinkOption{ + harness.WithNBLinkName(name), + harness.WithNBLinkUser("1000770000:0"), + harness.WithNBLinkStateVolume(volume), + harness.WithNBLinkSetupKeyFile(), + } + + for range 2 { + fwdr, oerr := harness.StartNBLink(ctx, srv, allowedKey, forward, px.CACertPath(), opts...) + require.NoError(t, oerr, "start the forwarder as an arbitrary UID") + // Registered before the assertions below so a failure between here + // and the explicit Terminate cannot leave a container holding the + // volume the cleanup above tries to remove. + t.Cleanup(func() { _ = fwdr.Terminate(context.Background()) }) + ocode, obody := chatUntil(ctx, t, fwdr, 200, 120*time.Second, "") + assert.Equal(t, 200, ocode, "the forwarder must serve as an arbitrary UID; body: %s", obody) + require.NoError(t, fwdr.Terminate(ctx), "stop the forwarder") + } + assert.Len(t, peersWithHostname(ctx, t, name), 1, + "an arbitrary UID must be able to persist the identity it registered") + }) + + t.Run("without a state dir every start is a new peer", func(t *testing.T) { + const name = "nblink-ephemeral" + for range 2 { + eph, eerr := harness.StartNBLink(ctx, srv, allowedKey, forward, px.CACertPath(), harness.WithNBLinkName(name)) + require.NoError(t, eerr, "start the in-memory forwarder") + t.Cleanup(func() { _ = eph.Terminate(context.Background()) }) + require.NoError(t, eph.Terminate(ctx), "stop the in-memory forwarder") + } + peers := peersWithHostname(ctx, t, name) + assert.Len(t, peers, 2, "each in-memory start must register its own peer, as the README warns") + }) + + // Last, because it replaces the forwarder every case above used. + t.Run("a state dir keeps the same peer across restarts", func(t *testing.T) { + require.NotEmpty(t, peer.Id, "the peer lookup must have run first") + + // docker stop sends SIGTERM. The forwarder must drain and exit cleanly + // well inside the grace period rather than be killed at its end. + exitCode, took, serr := nb.Stop(ctx, 30*time.Second) + require.NoError(t, serr, "stop the first forwarder") + assert.Equal(t, 0, exitCode, "SIGTERM must be a clean exit") + assert.Less(t, took, 15*time.Second, "shutdown must not wait out the grace period") + assert.Contains(t, nb.Logs(ctx), "shutting down", "the forwarder must log the signal it handled") + require.NoError(t, nb.Terminate(ctx), "remove the first forwarder") + + restarted, rerr := harness.StartNBLink(ctx, srv, allowedKey, forward, px.CACertPath(), nbOpts...) + require.NoError(t, rerr, "start the forwarder again on the same volume") + nb = restarted + + peers := peersWithHostname(ctx, t, nb.Hostname()) + require.Len(t, peers, 1, + "a restart on the same state dir must not register a second peer; got %d", len(peers)) + assert.Equal(t, peer.Id, peers[0].Id, "the restarted forwarder must come back as the same peer") + + rcode, rbody := chatUntil(ctx, t, nb, 200, 120*time.Second, "") + assert.Equal(t, 200, rcode, "the restarted forwarder must be served again; body: %s%s", rbody, diag()) + }) +} + +// TestNBLinkWithoutManagement covers a forwarder that can never bring its +// session up. It must fail rather than hang, and a signal while it is trying +// must stop it straight away. +func TestNBLinkWithoutManagement(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + env := map[string]string{ + "NB_MANAGEMENT_URL": "https://management.invalid", + "NB_SETUP_KEY": "11111111-2222-3333-4444-555555555555", + "NB_FORWARD": "http://8080=https://grafana.netbird.cloud", + } + + t.Run("an unreachable management server fails the start", func(t *testing.T) { + start := time.Now() + res, err := harness.RunNBLinkIsolated(ctx, env) + took := time.Since(start) + require.NoError(t, err, "run the forwarder") + assert.NotEqual(t, 0, res.ExitCode, "a session that cannot come up must be an error; stderr: %s", res.Stderr) + assert.Contains(t, res.Stderr, "start client", "the error must say the session did not start") + assert.NotContains(t, res.Stderr, "over the overlay", "no forward may be announced without a session") + // startTimeout is 90s; past that something is waiting without a bound. + assert.Less(t, took, 120*time.Second, "the start must give up within its timeout") + }) + + t.Run("SIGTERM during startup exits at once", func(t *testing.T) { + exitCode, took, logs, err := harness.StopNBLinkDuringStartup(ctx, env, 3*time.Second, 30*time.Second) + require.NoError(t, err, "start and stop the forwarder") + assert.Equal(t, 0, exitCode, "a signal during startup is a clean exit; logs:\n%s", logs) + assert.Less(t, took, 5*time.Second, "the signal must not wait for the management dial to time out") + }) +} + +// TestNBLinkCheck runs the image with --check, which must validate the +// configuration and print it without starting a session. The container has no +// network, so a run that tried to reach management could not exit 0. +func TestNBLinkCheck(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + setupKey := "11111111-2222-3333-4444-555555555555" + + t.Run("a valid configuration prints its forwards and exits 0", func(t *testing.T) { + res, err := harness.RunNBLinkCheck(ctx, map[string]string{ + "NB_MANAGEMENT_URL": "https://management.invalid", + "NB_SETUP_KEY": setupKey, + "NB_FORWARD": "http://8080=https://one.netbird.cloud,http://127.0.0.1:0=http://two.netbird.cloud:8000/v1", + }) + require.NoError(t, err, "run the check") + require.Equal(t, 0, res.ExitCode, "a valid configuration must pass; stderr: %s", res.Stderr) + assert.Contains(t, res.Stdout, "forwards: 2", "both comma-separated forwards must be parsed") + assert.Contains(t, res.Stdout, "127.0.0.1:8080 -> https://one.netbird.cloud", + "a port-only spec must bind loopback") + assert.Contains(t, res.Stdout, "127.0.0.1:0 -> http://two.netbird.cloud:8000/v1", + "the second forward must be printed as given") + assert.Contains(t, res.Stdout, "setup-key: true", "the check must report that a key is set") + assert.NotContains(t, res.Stdout+res.Stderr, setupKey, "the check must never print the key itself") + assert.NotContains(t, res.Stderr, "connecting to", "the check must not start a session") + }) + + t.Run("an unsupported forward exits non-zero and prints nothing", func(t *testing.T) { + res, err := harness.RunNBLinkCheck(ctx, map[string]string{ + "NB_SETUP_KEY": setupKey, + "NB_FORWARD": "http://8080=https://one.netbird.cloud,tcp://5432=db.netbird.cloud:5432", + }) + require.NoError(t, err, "run the check") + assert.NotEqual(t, 0, res.ExitCode, "a bad spec must fail the check") + assert.Empty(t, strings.TrimSpace(res.Stdout), "a failed check must not print a configuration") + assert.Contains(t, res.Stderr, "tcp forwarding is not supported yet", + "the error must name the spec that failed") + }) + + t.Run("a public bind without the opt-in exits non-zero", func(t *testing.T) { + res, err := harness.RunNBLinkCheck(ctx, map[string]string{ + "NB_FORWARD": "http://0.0.0.0:8080=https://one.netbird.cloud", + }) + require.NoError(t, err, "run the check") + assert.NotEqual(t, 0, res.ExitCode, "binding beyond loopback must need --allow-public-bind") + assert.Contains(t, res.Stderr, "NB_ALLOW_PUBLIC_BIND", "the error must say how to opt in") + }) +} + +// nblinkForward builds a forward spec: a loopback listener on port, proxying +// to upstream over the overlay. +// +// The listener stays on loopback, so --allow-public-bind is never passed and +// the default an operator gets is what the suite exercises. +func nblinkForward(port int, upstream string) string { + return fmt.Sprintf("http://127.0.0.1:%d=%s", port, upstream) +} + +// peersWithHostname returns every peer registered under hostname. +func peersWithHostname(ctx context.Context, t *testing.T, hostname string) []api.Peer { + t.Helper() + + peers, err := srv.API().Peers.List(ctx) + require.NoError(t, err, "list peers") + var matched []api.Peer + for _, p := range peers { + if p.Hostname == hostname { + matched = append(matched, p) + } + } + return matched +} + +func peerInGroup(p api.Peer, groupID string) bool { + for _, g := range p.Groups { + if g.Id == groupID { + return true + } + } + return false +} + +// mintSetupKey creates a reusable setup key that auto-joins its peer to one +// group, which is how a forwarder's peer lands in (or out of) a policy's +// source groups. +func mintSetupKey(ctx context.Context, t *testing.T, name, groupID string) string { + t.Helper() + + ephemeral := false + sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{ + Name: name, + Type: "reusable", + ExpiresIn: 86400, + UsageLimit: 0, + AutoGroups: []string{groupID}, + Ephemeral: &ephemeral, + }) + require.NoError(t, err, "mint setup key %s", name) + require.NotEmpty(t, sk.Key, "setup key plaintext") + return sk.Key +} + +// chatUntil retries a chat through the forwarder until it answers with want, +// returning the last status and body either way. +func chatUntil(ctx context.Context, t *testing.T, nb *harness.NBLink, want int, timeout time.Duration, sessionID string) (int, string) { + t.Helper() + + var code int + var body string + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + c, b, err := nb.Chat(ctx, harness.VLLMModel, "Reply with exactly: pong", sessionID) + if err == nil { + code, body = c, b + if code == want { + return code, body + } + } + if !waitBeforeRetry(ctx, 5*time.Second) { + break + } + } + return code, body +} diff --git a/e2e/harness/Dockerfile.nblink b/e2e/harness/Dockerfile.nblink new file mode 100644 index 000000000..dac3aceec --- /dev/null +++ b/e2e/harness/Dockerfile.nblink @@ -0,0 +1,37 @@ +# Multistage build for nblink, used in e2e tests. The release image +# (client/Dockerfile-nblink) packages a goreleaser artifact, so this compiles +# from source the way Dockerfile.client does for the agent, while keeping the +# release image's runtime shape: distroless, non-root, numeric UID. +# +# The runtime stage deliberately grants nothing. nblink's claim is that it +# needs no TUN device and no capabilities, so an image or a container request +# that quietly hands it NET_ADMIN would make the suite prove the opposite of +# what it is for. + +FROM golang:1.26.7-bookworm AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -o /out/nblink ./client/link/cmd/nblink \ + && mkdir -p /out/state/nblink && chmod 770 /out/state/nblink + +FROM gcr.io/distroless/static-debian13:nonroot + +# Same state directory as the release image, so a volume mounted over it +# starts out owned by 65532:0 and the identity-persistence case runs against +# the ownership the release image ships. +COPY --from=builder --chown=65532:0 /out/state/ /var/lib/ + +# Numeric, as in the release image: the runtime may substitute its own UID and +# there is no passwd entry to look up. +USER 65532:0 + +# No NB_STATE_DIR by default. State stays in memory and each run registers a +# fresh peer; the persistence case sets it explicitly with a volume. +ENV NB_LOG_LEVEL=info + +COPY --from=builder /out/nblink /usr/local/bin/nblink +ENTRYPOINT ["/usr/local/bin/nblink"] diff --git a/e2e/harness/nblink.go b/e2e/harness/nblink.go new file mode 100644 index 000000000..1c5cc7ab0 --- /dev/null +++ b/e2e/harness/nblink.go @@ -0,0 +1,508 @@ +//go:build e2e + +package harness + +import ( + "context" + "errors" + "fmt" + "net/http" + "os/exec" + "strings" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + nblinkDockerfile = "e2e/harness/Dockerfile.nblink" + // defaultNBLinkImage is the local tag nblink is built under from + // nblinkDockerfile. Override with NB_E2E_NBLINK_IMAGE: a value with a "/" + // is pulled as a published image; a bare tag is built under that name. + defaultNBLinkImage = "netbird-nblink:e2e" + nblinkAlias = "nblink" + + // NBLinkPort is the loopback port every forwarder in this suite listens + // on. Tests reach it from a sidecar container sharing the forwarder's + // network namespace, so loopback is enough and --allow-public-bind stays + // off — the default an operator gets. + NBLinkPort = 8080 + + // nblinkCAPath is where the proxy's self-signed cert is mounted. nblink + // verifies its upstream like any Go HTTP client, so without this the + // forward fails the TLS handshake rather than reaching the endpoint. + nblinkCAPath = "/etc/nblink/upstream-ca.crt" + + // nblinkReadyLog is the line startForwards writes once the overlay session + // is up and every forward is bound. + nblinkReadyLog = "over the overlay" + + // nblinkSecretsDir holds the setup key for WithNBLinkSetupKeyFile, mounted + // read-only the way the README's container example mounts it. + nblinkSecretsDir = "/run/secrets" + nblinkKeyFile = "nb_setup_key" + nblinkKeyPath = nblinkSecretsDir + "/" + nblinkKeyFile + // keyWriterImage writes the key into its volume. The file is root-owned + // and readable by group 0 only, the ownership the README tells operators a + // key needs for UID 65532:0 to read it. + keyWriterImage = "alpine:3.22" + + // nblinkStateDir is the state directory both images create, owned by + // 65532:0, so a volume mounted over it inherits that ownership. + nblinkStateDir = "/var/lib/nblink" +) + +// ChatPath and ChatBody are the request the forwarder is driven with. They are +// exported so a test can send the very same request with one header changed +// and attribute the difference in the answer to that header alone. +const ChatPath = "/v1/chat/completions" + +// NBLink is a running nblink container: a NetBird peer in userspace mode +// forwarding a local port to an upstream reached inside the tunnel. +type NBLink struct { + container testcontainers.Container + keyVolume string + network string + name string + port int +} + +// nblinkOptions is what the NBLinkOption values assemble. +type nblinkOptions struct { + name string + port int + extra []string + keyAsFile bool + stateVolume string + env map[string]string + user string +} + +// NBLinkOption adjusts how StartNBLink runs the forwarder. +type NBLinkOption func(*nblinkOptions) + +// NBLinkCheckResult is what a --check run printed and how it exited. +type NBLinkCheckResult struct { + Stdout string + Stderr string + ExitCode int +} + +// StartNBLink builds the nblink image and runs it on the combined server's +// network, joining with the given setup key and serving one HTTP forward. +// +// forward is a complete spec (scheme://[host:]port=upstream). caCertPath is a +// PEM file on the host holding the upstream's certificate, mounted in and +// pointed at with SSL_CERT_FILE; pass "" when the upstream needs no extra +// trust. +// +// The container is granted no capabilities and no TUN device. That is the +// assertion, not an oversight: nblink that needed either would not be able to +// run where it claims to. +func StartNBLink(ctx context.Context, c *Combined, setupKey, forward, caCertPath string, opts ...NBLinkOption) (*NBLink, error) { + o := nblinkOptions{name: nblinkAlias, port: NBLinkPort} + for _, opt := range opts { + opt(&o) + } + + image, err := nblinkImage(ctx) + if err != nil { + return nil, err + } + + env := map[string]string{ + "NB_MANAGEMENT_URL": combinedExposedURL, + "NB_SETUP_KEY": setupKey, + "NB_FORWARD": strings.Join(append([]string{forward}, o.extra...), ","), + "NB_HOSTNAME": o.name, + "NB_LOG_LEVEL": "debug", + // Match the agent and the proxy: the combined relay is WebSocket-only, + // so the embedded client must use WS to hold a stable relay link. + "NB_RELAY_TRANSPORT": "ws", + } + if caCertPath != "" { + env["SSL_CERT_FILE"] = nblinkCAPath + } + var keyVolume string + if o.keyAsFile { + // The root filesystem is read-only, so the key cannot be copied in; it + // is mounted, as it would be in production. + keyVolume = fmt.Sprintf("e2e-nblink-key-%s-%d", o.name, time.Now().UnixNano()) + if err := writeKeyVolume(ctx, keyVolume, setupKey); err != nil { + return nil, err + } + env["NB_SETUP_KEY"] = "file:" + nblinkKeyPath + } + if o.stateVolume != "" { + env["NB_STATE_DIR"] = nblinkStateDir + } + for k, v := range o.env { + env[k] = v + } + + req := testcontainers.ContainerRequest{ + Image: image, + // The embedded client reports the container hostname to management, so + // this is the name the peer is addressable by in the API. + Hostname: o.name, + User: o.user, + Networks: []string{c.network.Name}, + NetworkAliases: map[string][]string{c.network.Name: {o.name}}, + Env: env, + HostConfigModifier: func(hc *container.HostConfig) { + hardenNBLink(hc) + if caCertPath != "" { + hc.Binds = append(hc.Binds, caCertPath+":"+nblinkCAPath+":ro") + } + if keyVolume != "" { + hc.Mounts = append(hc.Mounts, mount.Mount{ + Type: mount.TypeVolume, + Source: keyVolume, + Target: nblinkSecretsDir, + ReadOnly: true, + }) + } + if o.stateVolume != "" { + hc.Mounts = append(hc.Mounts, mount.Mount{ + Type: mount.TypeVolume, + Source: o.stateVolume, + Target: nblinkStateDir, + }) + } + }, + // The session has 90s of its own to come up, so the wait has to outlast + // that or a slow handshake reads as a missing log line. + WaitingFor: wait.ForLog(nblinkReadyLog).WithStartupTimeout(150 * time.Second), + } + + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + if ctr != nil { + _ = ctr.Terminate(context.Background()) + } + if keyVolume != "" { + _ = RemoveDockerVolume(context.Background(), keyVolume) + } + return nil, fmt.Errorf("start nblink container: %w", err) + } + return &NBLink{container: ctr, keyVolume: keyVolume, network: c.network.Name, name: o.name, port: o.port}, nil +} + +// Hostname returns the container hostname the embedded client reports to +// management — the name the registered peer appears under in the peers API. +func (n *NBLink) Hostname() string { + return n.name +} + +// Post issues a JSON POST to the forwarder's primary listener and returns the +// HTTP status and response body. +func (n *NBLink) Post(ctx context.Context, path, body string, extraHeaders []string) (int, string, error) { + return n.do(ctx, n.port, http.MethodPost, path, body, extraHeaders) +} + +// PostOn issues a JSON POST to the forwarder's listener on port, for a forward +// added with WithNBLinkForwards. +func (n *NBLink) PostOn(ctx context.Context, port int, path, body string, extraHeaders []string) (int, string, error) { + return n.do(ctx, port, http.MethodPost, path, body, extraHeaders) +} + +// Get issues a GET to the forwarder's primary listener. +func (n *NBLink) Get(ctx context.Context, path string, extraHeaders []string) (int, string, error) { + return n.do(ctx, n.port, http.MethodGet, path, "", extraHeaders) +} + +// Chat issues an OpenAI-shaped chat completion to the forwarder, which proxies +// it to the agent-network endpoint over the tunnel. A non-empty sessionID is +// sent as the x-session-id header the proxy records. +func (n *NBLink) Chat(ctx context.Context, model, prompt, sessionID string) (int, string, error) { + return n.Post(ctx, ChatPath, ChatBody(model, prompt), withSessionID(nil, sessionID)) +} + +// do runs curl in a throwaway container sharing the forwarder's network +// namespace, so the request arrives on its loopback listener exactly as a +// process running beside it would send one. +// +// Nothing here pins or rewrites Host: the loopback listener refuses a Host +// that is not its own, and a test that papered over that would stop covering +// it. Callers that want a foreign Host pass it in extraHeaders on purpose. +func (n *NBLink) do(ctx context.Context, port int, method, path, body string, extraHeaders []string) (int, string, error) { + url := fmt.Sprintf("http://127.0.0.1:%d%s", port, path) + return curlStatus(ctx, []string{"--network", "container:" + n.container.GetContainerID()}, url, method, body, extraHeaders) +} + +// PostFromNetwork issues a JSON POST to the forwarder by its network alias from +// a separate container on the same network, the way another machine would +// reach a publicly bound forward. A connection that is refused comes back as +// an error, not a status. +func (n *NBLink) PostFromNetwork(ctx context.Context, port int, path, body string, extraHeaders []string) (int, string, error) { + url := fmt.Sprintf("http://%s:%d%s", n.name, port, path) + return curlStatus(ctx, []string{"--network", n.network}, url, http.MethodPost, body, extraHeaders) +} + +// Stop sends the forwarder SIGTERM, the way docker stop and an orchestrator +// do, and reports how it exited and how long it took. The container is left +// stopped for Logs; Terminate removes it. +func (n *NBLink) Stop(ctx context.Context, grace time.Duration) (int, time.Duration, error) { + start := time.Now() + if err := n.container.Stop(ctx, &grace); err != nil { + return 0, 0, fmt.Errorf("stop nblink: %w", err) + } + took := time.Since(start) + state, err := n.container.State(ctx) + if err != nil { + return 0, took, fmt.Errorf("inspect nblink: %w", err) + } + return state.ExitCode, took, nil +} + +// Logs returns the nblink container logs, for diagnostics on failure. +func (n *NBLink) Logs(ctx context.Context) string { + return containerLogs(ctx, n.container) +} + +// Terminate stops the nblink container. +func (n *NBLink) Terminate(ctx context.Context) error { + if n.container == nil { + return nil + } + err := n.container.Terminate(ctx) + if n.keyVolume != "" { + // Not ctx: a cancelled or expired ctx is the likeliest reason the + // terminate above failed, and the volume has to go either way. + err = errors.Join(err, RemoveDockerVolume(context.Background(), n.keyVolume)) + } + return err +} + +// WithNBLinkName names the forwarder, setting both its network alias and its +// container hostname. The hostname is what the peer registers under, so it is +// the name the peer appears beneath in the peers API. +// +// Required to run a second forwarder against the same server: the default name +// is shared and two containers cannot hold one alias on a network. +func WithNBLinkName(name string) NBLinkOption { + return func(o *nblinkOptions) { o.name = name } +} + +// WithNBLinkForwards adds forwards to the one StartNBLink is given. They are +// passed in the same comma-separated NB_FORWARD, so one process serves all of +// them over one session. +func WithNBLinkForwards(specs ...string) NBLinkOption { + return func(o *nblinkOptions) { o.extra = append(o.extra, specs...) } +} + +// WithNBLinkSetupKeyFile delivers the setup key as a file in the container and +// points NB_SETUP_KEY at it with the file: prefix, instead of putting the key +// itself in the environment. +func WithNBLinkSetupKeyFile() NBLinkOption { + return func(o *nblinkOptions) { o.keyAsFile = true } +} + +// WithNBLinkStateVolume mounts the named Docker volume as the state directory +// and sets NB_STATE_DIR, so the peer identity outlives the container. The +// volume is created on first use; RemoveDockerVolume deletes it. +func WithNBLinkStateVolume(volume string) NBLinkOption { + return func(o *nblinkOptions) { o.stateVolume = volume } +} + +// WithNBLinkUser runs the forwarder as uid:gid instead of the image's user, +// the way OpenShift assigns an arbitrary UID in group 0. +func WithNBLinkUser(user string) NBLinkOption { + return func(o *nblinkOptions) { o.user = user } +} + +// WithNBLinkEnv sets an extra environment variable on the forwarder, for +// settings such as NB_ALLOW_PUBLIC_BIND that have no option of their own. +func WithNBLinkEnv(key, value string) NBLinkOption { + return func(o *nblinkOptions) { + if o.env == nil { + o.env = map[string]string{} + } + o.env[key] = value + } +} + +// ChatBody builds an OpenAI-shaped chat completion request body. +func ChatBody(model, prompt string) string { + return fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":%q}]}`, model, prompt) +} + +// RunNBLinkCheck runs the nblink image with --check and the given environment. +// The container gets no network at all, so a run that tried to reach +// management would fail rather than quietly succeed. +func RunNBLinkCheck(ctx context.Context, env map[string]string) (NBLinkCheckResult, error) { + return RunNBLinkIsolated(ctx, env, "--check") +} + +// RunNBLinkIsolated runs the nblink image to completion with no network and +// the given environment and arguments, the way a forwarder behaves when +// management cannot be reached. +func RunNBLinkIsolated(ctx context.Context, env map[string]string, extraArgs ...string) (NBLinkCheckResult, error) { + image, err := nblinkImage(ctx) + if err != nil { + return NBLinkCheckResult{}, err + } + + args := append(isolatedRunArgs("--rm"), envArgs(env)...) + args = append(args, image) + args = append(args, extraArgs...) + + cmd := exec.CommandContext(ctx, "docker", args...) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + res := NBLinkCheckResult{} + err = cmd.Run() + res.Stdout, res.Stderr = stdout.String(), stderr.String() + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + res.ExitCode = exitErr.ExitCode() + return res, nil + } + if err != nil { + return res, fmt.Errorf("run nblink: %w", err) + } + return res, nil +} + +// StopNBLinkDuringStartup starts the nblink image with no network, so the +// session can never come up, sends it SIGTERM after wait, and reports how it +// exited, how long the stop took, and what it logged. +func StopNBLinkDuringStartup(ctx context.Context, env map[string]string, wait, grace time.Duration) (int, time.Duration, string, error) { + image, err := nblinkImage(ctx) + if err != nil { + return 0, 0, "", err + } + + args := append(isolatedRunArgs("-d"), envArgs(env)...) + out, err := exec.CommandContext(ctx, "docker", append(args, image)...).Output() + if err != nil { + return 0, 0, "", fmt.Errorf("start nblink: %w", err) + } + id := strings.TrimSpace(string(out)) + defer func() { _ = exec.Command("docker", "rm", "-f", id).Run() }() + + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-timer.C: + case <-ctx.Done(): + return 0, 0, "", ctx.Err() + } + + start := time.Now() + stop := exec.CommandContext(ctx, "docker", "stop", "-t", fmt.Sprint(int(grace.Seconds())), id) + if out, err := stop.CombinedOutput(); err != nil { + return 0, 0, "", fmt.Errorf("stop nblink: %w: %s", err, out) + } + took := time.Since(start) + + state, err := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.State.ExitCode}}", id).Output() + if err != nil { + return 0, took, "", fmt.Errorf("inspect nblink: %w", err) + } + exitCode := 0 + _, _ = fmt.Sscanf(strings.TrimSpace(string(state)), "%d", &exitCode) + logs, err := exec.CommandContext(ctx, "docker", "logs", id).CombinedOutput() + if err != nil { + return exitCode, took, "", fmt.Errorf("read nblink logs: %w", err) + } + return exitCode, took, string(logs), nil +} + +// RemoveDockerVolume deletes a volume created by WithNBLinkStateVolume. +func RemoveDockerVolume(ctx context.Context, volume string) error { + if out, err := exec.CommandContext(ctx, "docker", "volume", "rm", "-f", volume).CombinedOutput(); err != nil { + return fmt.Errorf("remove volume %s: %w: %s", volume, err, out) + } + return nil +} + +// writeKeyVolume creates a volume holding the setup key as a root-owned file +// of mode 0440. The key goes in on stdin so it never appears in a process +// listing. +func writeKeyVolume(ctx context.Context, volume, key string) error { + if out, err := exec.CommandContext(ctx, "docker", "volume", "create", volume).CombinedOutput(); err != nil { + return fmt.Errorf("create key volume: %w: %s", err, out) + } + script := fmt.Sprintf("cat > /k/%[1]s && chown 0:0 /k/%[1]s && chmod 440 /k/%[1]s", nblinkKeyFile) + cmd := exec.CommandContext(ctx, "docker", "run", "--rm", "-i", "-v", volume+":/k", keyWriterImage, "sh", "-c", script) + cmd.Stdin = strings.NewReader(key + "\n") + if out, err := cmd.CombinedOutput(); err != nil { + _ = RemoveDockerVolume(context.Background(), volume) + return fmt.Errorf("write key volume: %w: %s", err, out) + } + return nil +} + +// hardenNBLink takes away what Docker grants a container by default: every +// capability, a writable root filesystem, and the ability to gain privileges. +// nblink claims to need none of them, and this is where that claim is held to. +// State, when kept, goes to a mounted volume. +func hardenNBLink(hc *container.HostConfig) { + hc.CapDrop = []string{"ALL"} + hc.ReadonlyRootfs = true + hc.SecurityOpt = append(hc.SecurityOpt, "no-new-privileges") +} + +func nblinkImage(ctx context.Context) (string, error) { + root, err := repoRoot(ctx) + if err != nil { + return "", err + } + return resolveImage(ctx, root, "NB_E2E_NBLINK_IMAGE", defaultNBLinkImage, nblinkDockerfile) +} + +// curlStatus runs curl in a throwaway container with the given docker run +// network arguments and returns the status and body. +func curlStatus(ctx context.Context, network []string, url, method, body string, extraHeaders []string) (int, string, error) { + args := append([]string{"run", "--rm"}, network...) + args = append(args, + curlImage, + "-s", "--connect-timeout", "5", "--max-time", "90", + "-o", "/dev/stderr", "-w", "%{http_code}", + "-X", method, url, + "-H", "Content-Type: application/json", + ) + for _, h := range extraHeaders { + args = append(args, "-H", h) + } + if body != "" { + args = append(args, "--data", body) + } + + cmd := exec.CommandContext(ctx, "docker", args...) + // -w writes the status to stdout, -o /dev/stderr the body to stderr, so + // the two come back separately. + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return 0, stderr.String(), fmt.Errorf("curl through nblink: %w", err) + } + + code := 0 + _, _ = fmt.Sscanf(strings.TrimSpace(stdout.String()), "%d", &code) + return code, stderr.String(), nil +} + +// isolatedRunArgs is docker run with no network and the same hardening +// StartNBLink applies. +func isolatedRunArgs(mode string) []string { + return []string{"run", mode, "--network", "none", "--cap-drop", "ALL", "--read-only", "--security-opt", "no-new-privileges"} +} + +func envArgs(env map[string]string) []string { + var args []string + for k, v := range env { + args = append(args, "-e", k+"="+v) + } + return args +} diff --git a/e2e/harness/proxy.go b/e2e/harness/proxy.go index 6a4251f5e..185aafa8b 100644 --- a/e2e/harness/proxy.go +++ b/e2e/harness/proxy.go @@ -242,6 +242,13 @@ func (p *Proxy) DebugClients(ctx context.Context) ([]ProxyDebugClient, error) { return resp.Clients, nil } +// CACertPath returns the host path of the self-signed certificate the proxy +// serves for the agent-network endpoint. A client that verifies TLS properly — +// nblink does, where the curl helpers pass -k — needs it in its trust store. +func (p *Proxy) CACertPath() string { + return filepath.Join(p.workDir, "tls.crt") +} + // Logs returns the proxy container logs, for diagnostics on failure. func (p *Proxy) Logs(ctx context.Context) string { return containerLogs(ctx, p.container) diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go index c007020f5..6aa9855d4 100644 --- a/shared/management/client/grpc.go +++ b/shared/management/client/grpc.go @@ -621,7 +621,13 @@ func (c *GrpcClient) login(req *proto.LoginRequest) (*proto.LoginResponse, error err = backoff.Retry(operation, nbgrpc.Backoff(c.ctx)) if err != nil { - log.Errorf("failed to login to Management Service: %v", err) + // PermissionDenied is how management asks an unknown peer to register, + // which the caller does next, so it is not an error worth reporting. + if s, ok := gstatus.FromError(err); ok && s.Code() == codes.PermissionDenied { + log.Debugf("login to Management Service refused: %v", err) + } else { + log.Errorf("failed to login to Management Service: %v", err) + } return nil, err } From f6cfa775f87a6fb04beba787377326c0abdb8693 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:01:37 +0200 Subject: [PATCH 148/209] [management] setting to return error on name collision during AddPeer (#8154) --- management/server/account.go | 11 + management/server/activity/codes.go | 11 +- .../handlers/accounts/accounts_handler.go | 4 + .../accounts/accounts_handler_test.go | 9 + management/server/peer.go | 101 +++++++-- management/server/peer_test.go | 211 ++++++++++++++++++ management/server/store/sql_store_account.go | 6 + .../server/store/sql_store_account_test.go | 2 +- management/server/store/sql_store_peer.go | 5 +- management/server/types/settings.go | 5 + shared/management/http/api/openapi.yml | 4 + shared/management/http/api/types.gen.go | 3 + 12 files changed, 344 insertions(+), 28 deletions(-) diff --git a/management/server/account.go b/management/server/account.go index 946b9ad1c..0a8ec9a59 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -459,6 +459,7 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco am.handleAutoUpdateAlwaysSettings(ctx, oldSettings, newSettings, userID, accountID) am.handlePeerExposeSettings(ctx, oldSettings, newSettings, userID, accountID) am.handleMetricsPushSettings(ctx, oldSettings, newSettings, userID, accountID) + am.handlePeerHostnameCollisionSettings(ctx, oldSettings, newSettings, userID, accountID) if err = am.handleInactivityExpirationSettings(ctx, oldSettings, newSettings, userID, accountID); err != nil { return nil, err } @@ -644,6 +645,16 @@ func (am *DefaultAccountManager) handleMetricsPushSettings(ctx context.Context, } } +func (am *DefaultAccountManager) handlePeerHostnameCollisionSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) { + if oldSettings.PeerHostnameCollisionRejected != newSettings.PeerHostnameCollisionRejected { + if newSettings.PeerHostnameCollisionRejected { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountPeerHostnameCollisionRejectionEnabled, nil) + } else { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountPeerHostnameCollisionRejectionDisabled, nil) + } + } +} + func (am *DefaultAccountManager) handlePeerLoginExpirationSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) { if oldSettings.PeerLoginExpirationEnabled != newSettings.PeerLoginExpirationEnabled { event := activity.AccountPeerLoginExpirationEnabled diff --git a/management/server/activity/codes.go b/management/server/activity/codes.go index dca043027..11c309ef4 100644 --- a/management/server/activity/codes.go +++ b/management/server/activity/codes.go @@ -287,6 +287,11 @@ const ( // CustomDomainValidationExpired indicates that an unvalidated domain registration expired. CustomDomainValidationExpired Activity = 143 + // AccountPeerHostnameCollisionRejectionEnabled indicates that a user enabled rejecting peer hostname collisions for the account + AccountPeerHostnameCollisionRejectionEnabled Activity = 144 + // AccountPeerHostnameCollisionRejectionDisabled indicates that a user disabled rejecting peer hostname collisions for the account + AccountPeerHostnameCollisionRejectionDisabled Activity = 145 + AccountDeleted Activity = 99999 ) @@ -461,8 +466,10 @@ var activityMap = map[Activity]Code{ AgentNetworkSettingsUpdated: {"Agent Network settings updated", "agent_network.settings.update"}, AgentNetworkSettingsDeleted: {"Agent Network settings deleted", "agent_network.settings.delete"}, - AccountMetricsPushEnabled: {"Account metrics push enabled", "account.setting.metrics.push.enable"}, - AccountMetricsPushDisabled: {"Account metrics push disabled", "account.setting.metrics.push.disable"}, + AccountMetricsPushEnabled: {"Account metrics push enabled", "account.setting.metrics.push.enable"}, + AccountMetricsPushDisabled: {"Account metrics push disabled", "account.setting.metrics.push.disable"}, + AccountPeerHostnameCollisionRejectionEnabled: {"Account peer hostname collision rejection enabled", "account.setting.peer.hostname.collision.rejection.enable"}, + AccountPeerHostnameCollisionRejectionDisabled: {"Account peer hostname collision rejection disabled", "account.setting.peer.hostname.collision.rejection.disable"}, DomainAdded: {"Domain added", "domain.add"}, DomainDeleted: {"Domain deleted", "domain.delete"}, diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index e5d75007f..797815685 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -286,6 +286,9 @@ func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJS if req.Settings.MetricsPushEnabled != nil { returnSettings.MetricsPushEnabled = *req.Settings.MetricsPushEnabled } + if req.Settings.PeerHostnameCollisionRejected != nil { + returnSettings.PeerHostnameCollisionRejected = *req.Settings.PeerHostnameCollisionRejected + } if req.Settings.AgentNetworkOnly != nil { returnSettings.AgentNetworkOnly = *req.Settings.AgentNetworkOnly } @@ -432,6 +435,7 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A AutoUpdateAlways: &settings.AutoUpdateAlways, Ipv6EnabledGroups: &settings.IPv6EnabledGroups, MetricsPushEnabled: &settings.MetricsPushEnabled, + PeerHostnameCollisionRejected: &settings.PeerHostnameCollisionRejected, AgentNetworkOnly: &settings.AgentNetworkOnly, EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled, LocalAuthDisabled: &settings.LocalAuthDisabled, diff --git a/management/server/http/handlers/accounts/accounts_handler_test.go b/management/server/http/handlers/accounts/accounts_handler_test.go index 1ccac2b04..588258003 100644 --- a/management/server/http/handlers/accounts/accounts_handler_test.go +++ b/management/server/http/handlers/accounts/accounts_handler_test.go @@ -130,6 +130,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), @@ -159,6 +160,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), @@ -188,6 +190,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr("latest"), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), @@ -217,6 +220,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), @@ -246,6 +250,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), @@ -275,6 +280,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), @@ -304,6 +310,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(true), DashboardFeatures: &api.AccountDashboardFeatures{ AgentNetwork: br(true), @@ -345,6 +352,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(false), DashboardFeatures: &api.AccountDashboardFeatures{ AgentNetwork: br(true), @@ -377,6 +385,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { AutoUpdateAlways: br(false), AutoUpdateVersion: sr(""), MetricsPushEnabled: br(false), + PeerHostnameCollisionRejected: br(false), AgentNetworkOnly: br(false), EmbeddedIdpEnabled: br(false), LocalAuthDisabled: br(false), diff --git a/management/server/peer.go b/management/server/peer.go index f3a5ebf46..c60737535 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" b64 "encoding/base64" + "errors" "fmt" "net" "net/netip" @@ -14,6 +15,7 @@ import ( "github.com/rs/xid" log "github.com/sirupsen/logrus" "golang.org/x/exp/maps" + "gorm.io/gorm" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/idp" @@ -288,19 +290,34 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user var newLabel string newLabel, err = nbdns.GetParsedDomainLabel(update.Name) - if err != nil { - newLabel = "" - } else { - _, err := transaction.GetPeerIdByLabel(ctx, store.LockingStrengthNone, accountID, newLabel) - if err == nil { - newLabel = "" - } - } - - if newLabel == "" { - newLabel, err = getPeerIPDNSLabel(peer.IP, update.Name) + if settings.PeerHostnameCollisionRejected { if err != nil { - return fmt.Errorf("failed to get free DNS label: %w", err) + return status.Errorf(status.InvalidArgument, "invalid peer name: %v", err) + } + if newLabel != peer.DNSLabel { + taken, err := peerLabelTaken(ctx, transaction, accountID, newLabel) + if err != nil { + return err + } + if taken { + return errPeerHostnameInUse(update.Name) + } + } + } else { + if err != nil { + newLabel = "" + } else { + _, err := transaction.GetPeerIdByLabel(ctx, store.LockingStrengthNone, accountID, newLabel) + if err == nil { + newLabel = "" + } + } + + if newLabel == "" { + newLabel, err = getPeerIPDNSLabel(peer.IP, update.Name) + if err != nil { + return fmt.Errorf("failed to get free DNS label: %w", err) + } } } peer.Name = update.Name @@ -336,6 +353,9 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user return transaction.SavePeer(ctx, accountID, peer) }) if err != nil { + if peerLabelChanged && isUniqueConstraintError(err) { + return nil, errPeerHostnameInUse(peer.Name) + } return nil, err } @@ -832,6 +852,14 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe return nil, nil, nil, false, fmt.Errorf("failed getting network: %w", err) } + hostLabel, err := nbdns.GetParsedDomainLabel(peer.Meta.Hostname) + if err != nil { + return nil, nil, nil, false, status.Errorf(status.InvalidArgument, "invalid peer hostname: %v", err) + } + rejectLabelCollision := settings.PeerHostnameCollisionRejected + labelTaken := false + + var insertErr error maxAttempts := 10 for attempt := 1; attempt <= maxAttempts; attempt++ { netPrefix, err := netip.ParsePrefix(network.Net.String()) @@ -843,17 +871,12 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe return nil, nil, nil, false, fmt.Errorf("failed to get free IP: %w", err) } - var freeLabel string - if ephemeral || attempt > 1 { + freeLabel := hostLabel + if !rejectLabelCollision && (ephemeral || labelTaken) { freeLabel, err = getPeerIPDNSLabel(freeIP, peer.Meta.Hostname) if err != nil { return nil, nil, nil, false, fmt.Errorf("failed to get free DNS label: %w", err) } - } else { - freeLabel, err = nbdns.GetParsedDomainLabel(peer.Meta.Hostname) - if err != nil { - return nil, nil, nil, false, fmt.Errorf("failed to get free DNS label: %w", err) - } } newPeer.DNSLabel = freeLabel newPeer.IP = freeIP @@ -884,7 +907,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe } } - err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + insertErr = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { err = transaction.AddPeerToAccount(ctx, newPeer) if err != nil { return err @@ -942,16 +965,28 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe log.WithContext(ctx).Debugf("Peer %s added to account %s", newPeer.ID, accountID) return nil }) - if err == nil { + if insertErr == nil { break } - if isUniqueConstraintError(err) { - log.WithContext(ctx).WithFields(log.Fields{"dns_label": freeLabel, "ip": freeIP}).Tracef("Failed to add peer in attempt %d, retrying: %v", attempt, err) + if isUniqueConstraintError(insertErr) { + if freeLabel == hostLabel { + labelTaken, err = peerLabelTaken(ctx, am.Store, accountID, hostLabel) + if err != nil { + return nil, nil, nil, false, err + } + if labelTaken && rejectLabelCollision { + return nil, nil, nil, false, errPeerHostnameInUse(peer.Meta.Hostname) + } + } + log.WithContext(ctx).WithFields(log.Fields{"dns_label": freeLabel, "ip": freeIP}).Tracef("Failed to add peer in attempt %d, retrying: %v", attempt, insertErr) continue } - return nil, nil, nil, false, fmt.Errorf("failed to add peer to database: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed to add peer to database: %w", insertErr) + } + if insertErr != nil { + return nil, nil, nil, false, fmt.Errorf("add peer after %d attempts: %w", maxAttempts, insertErr) } if newPeer == nil { return nil, nil, nil, false, fmt.Errorf("new peer is nil") @@ -985,6 +1020,24 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe return newPeer, network, postureChecks, enableSSH, nil } +// peerLabelTaken reports whether a peer of the account already uses the DNS label. +func peerLabelTaken(ctx context.Context, s store.Store, accountID, label string) (bool, error) { + _, err := s.GetPeerIdByLabel(ctx, store.LockingStrengthNone, accountID, label) + switch { + case err == nil: + return true, nil + case errors.Is(err, gorm.ErrRecordNotFound): + return false, nil + default: + return false, fmt.Errorf("check peer DNS label: %w", err) + } +} + +// errPeerHostnameInUse is a PermissionDenied error because the client treats only that gRPC code as a permanent login failure instead of retrying. +func errPeerHostnameInUse(hostname string) error { + return status.Errorf(status.PermissionDenied, "peer with hostname %q is already registered", hostname) +} + func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) { if !ip.Is4() { return "", fmt.Errorf("DNS label generation requires an IPv4 address, got %s", ip) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index fa3be9a58..93f38f25e 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -3050,3 +3050,214 @@ func TestResolvePeerLocation(t *testing.T) { }) } } + +func TestAddPeer_HostnameCollisionRejected(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) + account.Settings.PeerHostnameCollisionRejected = true + require.NoError(t, manager.Store.SaveAccount(context.Background(), account)) + + setupKey, err := manager.CreateSetupKey(context.Background(), account.Id, "key", types.SetupKeyReusable, time.Hour, nil, 100, "owner", false, false) + require.NoError(t, err) + ephemeralKey, err := manager.CreateSetupKey(context.Background(), account.Id, "ephemeral", types.SetupKeyReusable, time.Hour, nil, 100, "owner", true, false) + require.NoError(t, err) + + addPeer := func(hostname, key string) (*nbpeer.Peer, error) { + wgKey, err := wgtypes.GenerateKey() + require.NoError(t, err) + peer := &nbpeer.Peer{ + Key: wgKey.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: hostname, OS: "linux"}, + } + added, _, _, _, err := manager.AddPeer(context.Background(), "", key, "", peer, false) + return added, err + } + + first, err := addPeer("my-host", setupKey.Key) + require.NoError(t, err) + assert.Equal(t, "my-host", first.DNSLabel) + + _, err = addPeer("My-Host", setupKey.Key) + require.Error(t, err) + e, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.PermissionDenied, e.Type()) + + other, err := addPeer("other-host", setupKey.Key) + require.NoError(t, err) + assert.Equal(t, "other-host", other.DNSLabel) + + _, err = addPeer("my-host", ephemeralKey.Key) + require.Error(t, err) + e, ok = status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.PermissionDenied, e.Type()) + + ephemeral, err := addPeer("ephemeral-host", ephemeralKey.Key) + require.NoError(t, err) + assert.Equal(t, "ephemeral-host", ephemeral.DNSLabel) + + account.Settings.PeerHostnameCollisionRejected = false + require.NoError(t, manager.Store.SaveAccountSettings(context.Background(), account.Id, account.Settings)) + + suffixed, err := addPeer("my-host", setupKey.Key) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(suffixed.DNSLabel, "my-host-")) +} + +func TestUpdatePeer_HostnameCollisionRejected(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) + account.Settings.PeerHostnameCollisionRejected = true + require.NoError(t, manager.Store.SaveAccount(context.Background(), account)) + + addPeer := func(hostname string) *nbpeer.Peer { + wgKey, err := wgtypes.GenerateKey() + require.NoError(t, err) + added, _, _, _, err := manager.AddPeer(context.Background(), "", "", "owner", &nbpeer.Peer{ + Key: wgKey.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: hostname, OS: "linux"}, + }, false) + require.NoError(t, err) + return added + } + first := addPeer("web-server") + second := addPeer("old-name") + + update := second.Copy() + update.Name = "Web-Server" + _, err = manager.UpdatePeer(context.Background(), account.Id, "owner", update) + require.Error(t, err) + e, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.PermissionDenied, e.Type()) + + update = second.Copy() + update.Name = "api-server.example.com" + updated, err := manager.UpdatePeer(context.Background(), account.Id, "owner", update) + require.NoError(t, err) + assert.Equal(t, "api-server", updated.DNSLabel) + + update = first.Copy() + update.Name = "WEB-SERVER" + updated, err = manager.UpdatePeer(context.Background(), account.Id, "owner", update) + require.NoError(t, err, "renaming to a different spelling of the own label must not collide with itself") + assert.Equal(t, "web-server", updated.DNSLabel) +} + +func TestAddPeer_HostnameCollisionRejectedConcurrent(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) + account.Settings.PeerHostnameCollisionRejected = true + require.NoError(t, manager.Store.SaveAccount(context.Background(), account)) + + setupKey, err := manager.CreateSetupKey(context.Background(), account.Id, "key", types.SetupKeyReusable, time.Hour, nil, 100, "owner", false, false) + require.NoError(t, err) + + const attempts = 20 + start := make(chan struct{}) + errs := make(chan error, attempts) + var wg sync.WaitGroup + for i := 0; i < attempts; i++ { + wg.Add(1) + go func() { + defer wg.Done() + wgKey, err := wgtypes.GenerateKey() + if err != nil { + errs <- err + return + } + <-start + _, _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + Key: wgKey.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "same-host", OS: "linux"}, + }, false) + errs <- err + }() + } + close(start) + wg.Wait() + close(errs) + + succeeded := 0 + for err := range errs { + if err == nil { + succeeded++ + continue + } + e, ok := status.FromError(err) + require.True(t, ok, "unexpected error: %v", err) + assert.Equal(t, status.PermissionDenied, e.Type(), "unexpected error: %v", err) + } + assert.Equal(t, 1, succeeded) + + peers, err := manager.Store.GetAccountPeers(context.Background(), store.LockingStrengthNone, account.Id, "", "", "") + require.NoError(t, err) + require.Len(t, peers, 1) + assert.Equal(t, "same-host", peers[0].DNSLabel) +} + +func TestAddPeer_IPCollisionKeepsLabel(t *testing.T) { + for _, rejectCollision := range []bool{false, true} { + t.Run(fmt.Sprintf("collision rejected %t", rejectCollision), func(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) + account.Settings.PeerHostnameCollisionRejected = rejectCollision + _, smallNet, err := net.ParseCIDR("100.64.0.0/26") + require.NoError(t, err) + account.Network.Net = *smallNet + require.NoError(t, manager.Store.SaveAccount(context.Background(), account)) + + const totalPeers = 20 + for i := 0; i < totalPeers; i++ { + wgKey, err := wgtypes.GenerateKey() + require.NoError(t, err) + hostname := fmt.Sprintf("host-%d", i) + added, _, _, _, err := manager.AddPeer(context.Background(), "", "", "owner", &nbpeer.Peer{ + Key: wgKey.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: hostname, OS: "linux"}, + }, false) + require.NoError(t, err) + assert.Equal(t, hostname, added.DNSLabel, "an IP collision must not change the DNS label") + } + }) + } +} + +func TestAddPeer_ExhaustedIPAllocationFails(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) + _, tinyNet, err := net.ParseCIDR("100.64.0.0/30") + require.NoError(t, err) + account.Network.Net = *tinyNet + require.NoError(t, manager.Store.SaveAccount(context.Background(), account)) + + addPeer := func(hostname string) error { + wgKey, err := wgtypes.GenerateKey() + require.NoError(t, err) + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", "owner", &nbpeer.Peer{ + Key: wgKey.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: hostname, OS: "linux"}, + }, false) + return err + } + require.NoError(t, addPeer("host-1")) + require.NoError(t, addPeer("host-2")) + + err = addPeer("host-3") + require.Error(t, err, "no free IP left, registration must fail") + + peers, err := manager.Store.GetAccountPeers(context.Background(), store.LockingStrengthNone, account.Id, "", "", "") + require.NoError(t, err) + assert.Len(t, peers, 2) +} diff --git a/management/server/store/sql_store_account.go b/management/server/store/sql_store_account.go index 43fe09861..3780de77d 100644 --- a/management/server/store/sql_store_account.go +++ b/management/server/store/sql_store_account.go @@ -746,6 +746,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range, settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled, settings_local_mfa_enabled, settings_metrics_push_enabled, settings_agent_network_only, + settings_peer_hostname_collision_rejected, settings_dashboard_features, settings_auto_update_version, settings_auto_update_always, settings_peer_expose_enabled, settings_peer_expose_groups, -- Embedded ExtraSettings @@ -771,6 +772,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc sLazyConnectionEnabled sql.NullBool sLocalMFAEnabled sql.NullBool sMetricsPushEnabled sql.NullBool + sPeerHostnameCollisionRejected sql.NullBool sAgentNetworkOnly sql.NullBool sDashboardFeatures sql.NullString autoUpdateVersion sql.NullString @@ -800,6 +802,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc &sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange, &sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled, &sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly, + &sPeerHostnameCollisionRejected, &sDashboardFeatures, &autoUpdateVersion, &autoUpdateAlways, &peerExposeEnabled, &peerExposeGroups, &sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired, @@ -870,6 +873,9 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc if sMetricsPushEnabled.Valid { account.Settings.MetricsPushEnabled = sMetricsPushEnabled.Bool } + if sPeerHostnameCollisionRejected.Valid { + account.Settings.PeerHostnameCollisionRejected = sPeerHostnameCollisionRejected.Bool + } if sAgentNetworkOnly.Valid { account.Settings.AgentNetworkOnly = sAgentNetworkOnly.Bool } diff --git a/management/server/store/sql_store_account_test.go b/management/server/store/sql_store_account_test.go index ec5976982..ba904bbe4 100644 --- a/management/server/store/sql_store_account_test.go +++ b/management/server/store/sql_store_account_test.go @@ -288,7 +288,7 @@ func Test_AccountSettings_SaveAndRetrieve(t *testing.T) { settings := types.Settings{} numOfExportedFields, err := populateFields.PopulateAll(reflect.ValueOf(&settings).Elem()) assert.NoError(t, err) - assert.Equal(t, 27, numOfExportedFields) + assert.Equal(t, 28, numOfExportedFields) account.Settings = &settings err = store.SaveAccount(context.Background(), account) diff --git a/management/server/store/sql_store_peer.go b/management/server/store/sql_store_peer.go index 9950069fd..ec5e1993c 100644 --- a/management/server/store/sql_store_peer.go +++ b/management/server/store/sql_store_peer.go @@ -715,12 +715,15 @@ func (s *SqlStore) GetPeerIdByLabel(ctx context.Context, lockStrength LockingStr Where("account_id = ? AND dns_label = ?", accountID, hostname). Limit(1). Scan(&peerID) + if result.Error != nil { + return "", result.Error + } if peerID == "" { return "", gorm.ErrRecordNotFound } - return peerID, result.Error + return peerID, nil } // GetEmbeddedProxyPeerIDsByCluster returns peer IDs of all embedded proxy peers diff --git a/management/server/types/settings.go b/management/server/types/settings.go index 815c93ce6..8aa1c7e4c 100644 --- a/management/server/types/settings.go +++ b/management/server/types/settings.go @@ -76,6 +76,10 @@ type Settings struct { // MetricsPushEnabled globally enables or disables client metrics push for the account MetricsPushEnabled bool `gorm:"default:false"` + // PeerHostnameCollisionRejected rejects registering or renaming a peer to a hostname + // already used by another peer of the account instead of suffixing it + PeerHostnameCollisionRejected bool `gorm:"default:false"` + // AgentNetworkOnly limits the dashboard to the Agent Network surface for this account. // Set for accounts created via netbird.ai signups; users can disable it later. AgentNetworkOnly bool `gorm:"default:false"` @@ -123,6 +127,7 @@ func (s *Settings) Copy() *Settings { AutoUpdateAlways: s.AutoUpdateAlways, IPv6EnabledGroups: slices.Clone(s.IPv6EnabledGroups), MetricsPushEnabled: s.MetricsPushEnabled, + PeerHostnameCollisionRejected: s.PeerHostnameCollisionRejected, AgentNetworkOnly: s.AgentNetworkOnly, EmbeddedIdpEnabled: s.EmbeddedIdpEnabled, LocalAuthDisabled: s.LocalAuthDisabled, diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 68de0d1f5..8b97d5f47 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -380,6 +380,10 @@ components: description: Enables or disables client metrics push for all peers in the account type: boolean example: false + peer_hostname_collision_rejected: + description: When enabled, registering or renaming a peer to a hostname already used by another peer in the account fails instead of receiving an IP-based suffix. Applies to ephemeral peers as well. + type: boolean + example: false agent_network_only: description: Limits the dashboard to the Agent Network surface for this account. Set for accounts created via netbird.ai signups and can be disabled later. Enabling this requires dashboard_features.agent_network to be true in the same request. type: boolean diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index a1f4bc530..1595553ed 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1693,6 +1693,9 @@ type AccountSettings struct { // PeerExposeGroups Limits which peer groups are allowed to expose services. If empty, all peers are allowed when peer expose is enabled. PeerExposeGroups []string `json:"peer_expose_groups"` + // PeerHostnameCollisionRejected When enabled, registering or renaming a peer to a hostname already used by another peer in the account fails instead of receiving an IP-based suffix. Applies to ephemeral peers as well. + PeerHostnameCollisionRejected *bool `json:"peer_hostname_collision_rejected,omitempty"` + // PeerInactivityExpiration Period of time of inactivity after which peer session expires (seconds). PeerInactivityExpiration int `json:"peer_inactivity_expiration"` From ef2292d822c4d61df9d1867fc3fa554daccbc77b Mon Sep 17 00:00:00 2001 From: Bruno Mercier Costa <94469565+emrcbrn@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:27:13 +0200 Subject: [PATCH 149/209] [client] Fix Windows UI exiting on first launch (#8138) * [client] Fix Windows UI exiting on first launch Bump the Wails fork to pick up netbirdio/wails#6, which stops SetBackgroundColour from dereferencing a WebView2 controller that does not exist yet. On first launch the theme service re-tints the Welcome window while it is still being created, and losing that race made the UI exit with no window. * [client] Point the Wails fork at the merged netbirdio/wails#6 Replace the temporary pin on emrcbrn/wails with the merge commit of netbirdio/wails#6 on the fork's integration branch. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index fa7047bd8..dcbc7d9d7 100644 --- a/go.mod +++ b/go.mod @@ -381,7 +381,7 @@ replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-2 replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0 -replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260928122122-d1db5fa35318 +replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d tool ( github.com/goreleaser/chglog/cmd/chglog diff --git a/go.sum b/go.sum index 55b3edf85..64197c736 100644 --- a/go.sum +++ b/go.sum @@ -539,8 +539,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ= -github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260928122122-d1db5fa35318 h1:Qv2jYeucRkuqkkM9aAVFcO9avmSfPEoB+gxKKKuI4vA= -github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260928122122-d1db5fa35318/go.mod h1:62UsqQRqanuCbFi6xElIw3/2X+yGF+TKlyiWImA5taU= +github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d h1:DV0FHFhNAaU69JHXKclYQWr/ZJoc2BA/ZpqlnX8l22U= +github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d/go.mod h1:62UsqQRqanuCbFi6xElIw3/2X+yGF+TKlyiWImA5taU= github.com/netbirdio/wireguard-go v0.0.0-20261008122457-d2b1ce9bfee9 h1:Mjt3tIBGa1/mJh1Xn8uZP30cd2VYd0NHANqTn+HBsaU= github.com/netbirdio/wireguard-go v0.0.0-20261008122457-d2b1ce9bfee9/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= From a6e154a9b367fc40532b04c3b33733cf283d0fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janek=20H=C3=A4rtter?= <108095150+janekhaertter@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:02:52 +0200 Subject: [PATCH 150/209] [misc] Extend the copyright year to 2026 (#8169) The Windows installer shows LICENSE on its license page, where the 2022 copyright line reads as outdated. Extend the range to 2022-2026 in LICENSE, its REUSE copy, and the installer's LegalCopyright, keeping 2022 as the year of first publication. --- LICENSE | 2 +- LICENSES/BSD-3-Clause.txt | 2 +- client/installer.nsis | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/LICENSE b/LICENSE index cea6f8f0b..4d0551de8 100644 --- a/LICENSE +++ b/LICENSE @@ -3,7 +3,7 @@ Those directories are licensed under the GNU Affero General Public License versi BSD 3-Clause License -Copyright (c) 2022 NetBird GmbH & AUTHORS +Copyright (c) 2022-2026 NetBird GmbH & AUTHORS Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/LICENSES/BSD-3-Clause.txt b/LICENSES/BSD-3-Clause.txt index 7cba76dfd..5e9a2539c 100644 --- a/LICENSES/BSD-3-Clause.txt +++ b/LICENSES/BSD-3-Clause.txt @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2022 NetBird GmbH & AUTHORS +Copyright (c) 2022-2026 NetBird GmbH & AUTHORS Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/client/installer.nsis b/client/installer.nsis index eb2d7d5bd..304d38864 100644 --- a/client/installer.nsis +++ b/client/installer.nsis @@ -2,7 +2,7 @@ !define COMP_NAME "Netbird" !define WEB_SITE "Netbird.io" !define VERSION $%APPVER% -!define COPYRIGHT "Netbird Authors, 2022" +!define COPYRIGHT "Netbird Authors, 2022-2026" !define DESCRIPTION "Connect your devices into a secure WireGuard-based overlay network with SSO, MFA, and granular access controls." !define INSTALLER_NAME "netbird-installer.exe" !define MAIN_APP_EXE "Netbird" From f0182dd645f5bdad07b7335b7302edbc33b58ce0 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Mon, 12 Oct 2026 15:09:45 +0200 Subject: [PATCH 151/209] [signal] Add a release-wired UBI image variant (#7458) * [signal] Add an explicit non-root UBI image variant Keep the existing signal image unchanged while making a separate UBI image available for local certification-readiness checks. Collect the linked Go dependency license terms as portable build inputs, and use SIGINT for the existing graceful stop handler. * [signal] Wire the UBI variant into GoReleaser Build the additive UBI signal variant through the existing release pipeline instead of relying on local Docker commands. Stage its licenses in the per-image build context and restrict the variant to validated AMD64 without changing existing image tags. * [signal] Align the UBI image with the other UBI variants Main now ships UBI images for the client, combined server and proxy with a shared license collector and a common shape, so the signal variant should look the same to reviewers and to Red Hat certification. Signal also listens on port 80 by default, which an arbitrary non-root UID cannot bind on OpenShift or Podman. Use release_files/collect-licenses.sh instead of a signal-only copy, build for amd64 and arm64 like the other UBI entries, and run as 1000:0 with a group-writable /var/lib/netbird that also holds Let's Encrypt data. Default NB_PORT to the legacy gRPC port 10000 so the image starts unprivileged and serves a single listener. * [infrastructure] Certify the signal UBI image on release The signal UBI image is published with every release, but only images listed in the certification workflow reach the Red Hat catalog, so it would otherwise need a manual run each time. Add signal to the dispatch options and the component list, using the same -ubi tag suffix as .goreleaser.yaml. The run reads its Partner Connect ID from REDHAT_CERT_ID_SIGNAL. * [signal] Answer Let's Encrypt challenges on the UBI image's port The challenge listener defaults to :443, which a non-root UID cannot bind on OpenShift or Podman, so Let's Encrypt could not work in the UBI image. Now that the listener address is configurable, default it to empty so the TLS listener on port 10000 answers the challenges itself when public port 443 is forwarded to it. --- .github/workflows/redhat-certify.yml | 2 ++ .goreleaser.yaml | 35 +++++++++++++++++++++++++ signal/Dockerfile.ubi | 38 ++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 signal/Dockerfile.ubi diff --git a/.github/workflows/redhat-certify.yml b/.github/workflows/redhat-certify.yml index 7c26c79fb..c6cd62bca 100644 --- a/.github/workflows/redhat-certify.yml +++ b/.github/workflows/redhat-certify.yml @@ -33,6 +33,7 @@ on: - client-rootless - reverse-proxy - netbird-server + - signal version: description: "Released version, e.g. v0.80.0" type: string @@ -68,6 +69,7 @@ jobs: "client-rootless ghcr.io/netbirdio/netbird -rootless-ubi" "reverse-proxy ghcr.io/netbirdio/reverse-proxy -ubi" "netbird-server ghcr.io/netbirdio/netbird-server -ubi" + "signal ghcr.io/netbirdio/signal -ubi" ) matrix="[]" missing=() diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ebb4a8525..1e7acbaa7 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -528,6 +528,41 @@ dockers_v2: "org.opencontainers.image.revision": "{{.FullCommit}}" "org.opencontainers.image.source": "{{.GitURL}}" "maintainer": "dev@netbird.io" + - id: signal-ubi + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-signal + images: + - netbirdio/signal + - ghcr.io/netbirdio/signal + tags: + - "{{ .Version }}-ubi" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}ubi-latest{{ end }}" + dockerfile: signal/Dockerfile.ubi + platforms: + - linux/amd64 + - linux/arm64 + build_args: + VERSION: "{{ .Version }}" + RELEASE: "{{ .Timestamp }}" + hooks: + pre: + - cmd: 'sh release_files/collect-licenses.sh -l signal/LICENSE "{{ .ContextDir }}/licenses" ./signal amd64 arm64' + env: + - GOOS=linux + - CGO_ENABLED=0 + labels: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" - id: management disable: "{{ .Env.SKIP_DOCKER_PUSH }}" ids: diff --git a/signal/Dockerfile.ubi b/signal/Dockerfile.ubi new file mode 100644 index 000000000..f0a6552da --- /dev/null +++ b/signal/Dockerfile.ubi @@ -0,0 +1,38 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 + +ARG TARGETPLATFORM +ARG VERSION=dev +ARG RELEASE=1 + +LABEL name="netbird-signal" \ + maintainer="NetBird " \ + vendor="NetBird GmbH" \ + version="${VERSION}" \ + release="${RELEASE}" \ + summary="NetBird Signal" \ + description="NetBird Signal brokers the connection handshakes between peers in NetBird networks." + +COPY --chmod=0555 ${TARGETPLATFORM}/netbird-signal /go/bin/netbird-signal +COPY licenses/ /licenses/ +# Only the data directory shares the root group for arbitrary non-root UIDs. +# Runtime-created Let's Encrypt keys retain the application's restrictive modes. +RUN mkdir -p /var/lib/netbird && \ + chown 1000:0 /var/lib/netbird && \ + chmod 0770 /var/lib/netbird && \ + chmod -R a+rX /licenses + +USER 1000:0 +ENV HOME=/var/lib/netbird +ENV NB_LETSENCRYPT_DATA_DIR="/var/lib/netbird" +# Unprivileged ports: runtimes such as OpenShift and Podman keep the kernel +# default that reserves ports below 1024 for root, so serve on the legacy +# gRPC port instead of 80/443. With Let's Encrypt, the empty listen address +# answers challenges on that port when public 443 is forwarded to it. +# 9090 is the metrics endpoint. +ENV NB_PORT="10000" +ENV NB_LETSENCRYPT_LISTEN_ADDRESS="" +EXPOSE 10000 9090 +# The signal server only handles SIGINT for a graceful stop. +STOPSIGNAL SIGINT +ENTRYPOINT ["/go/bin/netbird-signal", "run"] +CMD ["--log-file", "console"] From 763da1b9853c0de7e97054e69862531b16e50979 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Mon, 12 Oct 2026 15:09:45 +0200 Subject: [PATCH 152/209] [relay] Add a release-wired UBI image variant (#7457) * [relay] Add a release-wired UBI image variant * [relay] Align the UBI image with the shared UBI pattern Main now ships UBI variants of the client, combined server and proxy that share one license collector and one image layout, and the Red Hat certification workflow expects every certified image to carry both amd64 and arm64. The relay variant predated that, kept its own copy of the license script, built amd64 only, and ran as UID 65532 on the privileged default port :443, so it would not start under OpenShift or rootless Podman. Use release_files/collect-licenses.sh and build amd64 and arm64 like the other UBI images. Run as 1000:0 with a group-0 writable /var/lib/netbird for Let's Encrypt data, and default the listener to :8443 so an arbitrary non-root UID can bind it; the relay's Let's Encrypt flow uses TLS-ALPN on that same listener, so no separate challenge port is needed. * [infrastructure] Certify the relay UBI image on release Releases certify the UBI images through redhat-certify.yml, which only lists the client, proxy and combined server, so a released relay UBI image would never reach the Red Hat catalog. Add relay to the component list and the manual dispatch options. Its Partner Connect ID comes from REDHAT_CERT_ID_RELAY, which must exist before this merges: the resolve step fails the whole run when any component's variable is missing. --- .github/workflows/redhat-certify.yml | 2 ++ .goreleaser.yaml | 35 ++++++++++++++++++++++++++++ relay/Dockerfile.ubi | 33 ++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 relay/Dockerfile.ubi diff --git a/.github/workflows/redhat-certify.yml b/.github/workflows/redhat-certify.yml index c6cd62bca..763da745d 100644 --- a/.github/workflows/redhat-certify.yml +++ b/.github/workflows/redhat-certify.yml @@ -34,6 +34,7 @@ on: - reverse-proxy - netbird-server - signal + - relay version: description: "Released version, e.g. v0.80.0" type: string @@ -70,6 +71,7 @@ jobs: "reverse-proxy ghcr.io/netbirdio/reverse-proxy -ubi" "netbird-server ghcr.io/netbirdio/netbird-server -ubi" "signal ghcr.io/netbirdio/signal -ubi" + "relay ghcr.io/netbirdio/relay -ubi" ) matrix="[]" missing=() diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 1e7acbaa7..785bc452a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -506,6 +506,41 @@ dockers_v2: "org.opencontainers.image.revision": "{{.FullCommit}}" "org.opencontainers.image.source": "{{.GitURL}}" "maintainer": "dev@netbird.io" + - id: relay-ubi + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-relay + images: + - netbirdio/relay + - ghcr.io/netbirdio/relay + tags: + - "{{ .Version }}-ubi" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}ubi-latest{{ end }}" + dockerfile: relay/Dockerfile.ubi + platforms: + - linux/amd64 + - linux/arm64 + build_args: + VERSION: "{{ .Version }}" + RELEASE: "{{ .Timestamp }}" + hooks: + pre: + - cmd: 'sh release_files/collect-licenses.sh -l relay/LICENSE "{{ .ContextDir }}/licenses" ./relay amd64 arm64' + env: + - GOOS=linux + - CGO_ENABLED=0 + labels: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" - id: signal disable: "{{ .Env.SKIP_DOCKER_PUSH }}" ids: diff --git a/relay/Dockerfile.ubi b/relay/Dockerfile.ubi new file mode 100644 index 000000000..86b0cbc01 --- /dev/null +++ b/relay/Dockerfile.ubi @@ -0,0 +1,33 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 + +ARG TARGETPLATFORM +ARG VERSION=dev +ARG RELEASE=1 + +LABEL name="netbird-relay" \ + maintainer="NetBird " \ + vendor="NetBird GmbH" \ + version="${VERSION}" \ + release="${RELEASE}" \ + summary="NetBird Relay" \ + description="NetBird Relay carries traffic between peers when a direct connection is unavailable." + +COPY --chmod=0555 ${TARGETPLATFORM}/netbird-relay /go/bin/netbird-relay +COPY licenses/ /licenses/ +# Only the data directory shares the root group for arbitrary non-root UIDs. +# Runtime-created Let's Encrypt keys retain the application's restrictive modes. +RUN mkdir -p /var/lib/netbird && \ + chown 1000:0 /var/lib/netbird && \ + chmod 0770 /var/lib/netbird && \ + chmod -R a+rX /licenses + +USER 1000:0 +ENV HOME=/var/lib/netbird +ENV NB_LOG_FILE=console +# Unprivileged port: runtimes such as OpenShift and Podman keep the kernel +# default that reserves ports below 1024 for root. 8443/udp carries QUIC and +# 9000 is the health probe. +ENV NB_LISTEN_ADDRESS=":8443" +EXPOSE 8443 8443/udp +STOPSIGNAL SIGTERM +ENTRYPOINT ["/go/bin/netbird-relay"] From e4ddd9a3fc7055c4f4880f8e3275769c820cb6e1 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Mon, 12 Oct 2026 15:44:02 +0200 Subject: [PATCH 153/209] [management] Add a release-wired UBI image variant (#8167) * [management] Add a release-wired UBI image variant OpenShift and other Red Hat environments expect UBI-based images that run as an arbitrary non-root UID. The proxy, combined server and rootless client already ship -ubi variants; this adds the same for management, published as -ubi and ubi-latest for amd64 and arm64. The default --port of 80 cannot bind without root, so the image defaults to 8080. For Let's Encrypt, public 443 forwards to --port and --letsencrypt-listen-address is empty (#7706), so the main listener answers the TLS-ALPN challenges. * [management] Certify the management UBI image Adds management to the Red Hat certification components. Its Partner Connect component ID goes in the REDHAT_CERT_ID_MANAGEMENT repository variable. --- .github/workflows/redhat-certify.yml | 2 ++ .goreleaser.yaml | 35 ++++++++++++++++++++++++++++ management/Dockerfile.ubi | 33 ++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 management/Dockerfile.ubi diff --git a/.github/workflows/redhat-certify.yml b/.github/workflows/redhat-certify.yml index 763da745d..96d50de9f 100644 --- a/.github/workflows/redhat-certify.yml +++ b/.github/workflows/redhat-certify.yml @@ -33,6 +33,7 @@ on: - client-rootless - reverse-proxy - netbird-server + - management - signal - relay version: @@ -70,6 +71,7 @@ jobs: "client-rootless ghcr.io/netbirdio/netbird -rootless-ubi" "reverse-proxy ghcr.io/netbirdio/reverse-proxy -ubi" "netbird-server ghcr.io/netbirdio/netbird-server -ubi" + "management ghcr.io/netbirdio/management -ubi" "signal ghcr.io/netbirdio/signal -ubi" "relay ghcr.io/netbirdio/relay -ubi" ) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 785bc452a..de5f7419c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -620,6 +620,41 @@ dockers_v2: "org.opencontainers.image.revision": "{{.FullCommit}}" "org.opencontainers.image.source": "{{.GitURL}}" "maintainer": "dev@netbird.io" + - id: management-ubi + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-mgmt + images: + - netbirdio/management + - ghcr.io/netbirdio/management + tags: + - "{{ .Version }}-ubi" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}ubi-latest{{ end }}" + dockerfile: management/Dockerfile.ubi + platforms: + - linux/amd64 + - linux/arm64 + build_args: + VERSION: "{{ .Version }}" + RELEASE: "{{ .Timestamp }}" + hooks: + pre: + - cmd: 'sh release_files/collect-licenses.sh -l management/LICENSE "{{ .ContextDir }}/licenses" ./management amd64 arm64' + env: + - GOOS=linux + - CGO_ENABLED=1 + labels: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" - id: upload disable: "{{ .Env.SKIP_DOCKER_PUSH }}" ids: diff --git a/management/Dockerfile.ubi b/management/Dockerfile.ubi new file mode 100644 index 000000000..628d1a086 --- /dev/null +++ b/management/Dockerfile.ubi @@ -0,0 +1,33 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 + +ARG TARGETPLATFORM +ARG VERSION=dev +ARG RELEASE=1 + +LABEL name="netbird-management" \ + maintainer="NetBird " \ + vendor="NetBird GmbH" \ + version="${VERSION}" \ + release="${RELEASE}" \ + summary="NetBird Management" \ + description="NetBird Management is the control plane of a NetBird network and serves its REST and gRPC APIs." + +COPY --chmod=0555 ${TARGETPLATFORM}/netbird-mgmt /go/bin/netbird-mgmt +COPY licenses/ /licenses/ +# Only the data directory shares the root group for arbitrary non-root UIDs. +# Runtime-created keys and databases retain the application's restrictive modes. +RUN mkdir -p /var/lib/netbird /etc/netbird && \ + chown 1000:0 /var/lib/netbird && \ + chmod 0770 /var/lib/netbird && \ + chmod -R a+rX /licenses + +USER 1000:0 +ENV HOME=/var/lib/netbird +# Runtimes such as OpenShift and Podman reserve ports below 1024 for root, so +# --port defaults to 8080 here. With --letsencrypt-domain, forward public 443 +# to --port and set --letsencrypt-listen-address to empty, so the main listener +# answers the TLS-ALPN challenges. 33073 is the legacy gRPC port. +EXPOSE 8080 33073 +STOPSIGNAL SIGTERM +ENTRYPOINT ["/go/bin/netbird-mgmt", "management"] +CMD ["--port", "8080", "--log-file", "console"] From 8c70f9ec71e2a3b4ed01e8776a6a78b1f5a8118c Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:13:03 +0200 Subject: [PATCH 154/209] [management] fix peer ip allocation test (#8173) --- management/server/peer_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 93f38f25e..f6a7cc4c4 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -3252,12 +3252,12 @@ func TestAddPeer_ExhaustedIPAllocationFails(t *testing.T) { return err } require.NoError(t, addPeer("host-1")) - require.NoError(t, addPeer("host-2")) - err = addPeer("host-3") + err = addPeer("host-2") require.Error(t, err, "no free IP left, registration must fail") peers, err := manager.Store.GetAccountPeers(context.Background(), store.LockingStrengthNone, account.Id, "", "", "") require.NoError(t, err) - assert.Len(t, peers, 2) + require.Len(t, peers, 1) + assert.Equal(t, netip.MustParseAddr("100.64.0.1"), peers[0].IP) } From a3949581600790a7ac1640be43f824150b11dc83 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:36:04 +0200 Subject: [PATCH 155/209] [management] do in place expand of loaded snapshots (#8177) --- management/server/affectedpeers/resolver.go | 6 +- .../server/affectedpeers/resolver_test.go | 6 ++ management/server/group.go | 27 +++-- management/server/group_test.go | 98 +++++++++++++++++++ management/server/peer.go | 54 ++++------ management/server/user.go | 12 ++- 6 files changed, 148 insertions(+), 55 deletions(-) diff --git a/management/server/affectedpeers/resolver.go b/management/server/affectedpeers/resolver.go index 895e4fd36..f6946639d 100644 --- a/management/server/affectedpeers/resolver.go +++ b/management/server/affectedpeers/resolver.go @@ -267,10 +267,10 @@ func (c Change) isEmpty() bool { } // Expand returns the deduplicated affected peer IDs from the preloaded Snapshot, -// no store access. Run after the producing tx commits. Logs the full walk at -// trace level for diagnosing a miscalculation. +// no store access. Run after the producing tx commits. A nil Snapshot expands to +// nothing. Logs the full walk at trace level for diagnosing a miscalculation. func (snap *Snapshot) Expand(ctx context.Context, accountID string, c Change) []string { - if c.isEmpty() { + if snap == nil || c.isEmpty() { return nil } r := newResolver(ctx, snap, accountID, c) diff --git a/management/server/affectedpeers/resolver_test.go b/management/server/affectedpeers/resolver_test.go index b0c430c3c..d7858b82c 100644 --- a/management/server/affectedpeers/resolver_test.go +++ b/management/server/affectedpeers/resolver_test.go @@ -1,6 +1,7 @@ package affectedpeers import ( + "context" "testing" "github.com/stretchr/testify/assert" @@ -77,6 +78,11 @@ func TestPolicyGroupsAndPeers_NonPeerResource(t *testing.T) { assert.Empty(t, peers, "domain resource type should not produce direct peer IDs") } +func TestExpand_NilSnapshot(t *testing.T) { + var snap *Snapshot + assert.Nil(t, snap.Expand(context.Background(), "acc", Change{ChangedGroupIDs: []string{"g"}})) +} + func TestChangeIsEmpty(t *testing.T) { assert.True(t, Change{}.isEmpty()) assert.False(t, Change{ChangedGroupIDs: []string{"g"}}.isEmpty()) diff --git a/management/server/group.go b/management/server/group.go index 8d91df3ab..d0d6d2628 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -6,12 +6,12 @@ import ( "fmt" "slices" - agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" - "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/rs/xid" log "github.com/sirupsen/logrus" nbdns "github.com/netbirdio/netbird/dns" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/affectedpeers" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" @@ -249,8 +249,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us } var eventsToStore []func() - var snaps []*affectedpeers.Snapshot - var changes []affectedpeers.Change + affected := make(affectedPeerSet) var globalErr error createdCount := 0 @@ -281,7 +280,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us return err }) if err != nil { - log.WithContext(ctx).Errorf("failed to update group %s: %v", newGroup.ID, err) + log.WithContext(ctx).Errorf("failed to create group %s: %v", newGroup.ID, err) if createdCount == 0 { return err } @@ -290,15 +289,14 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us continue } createdCount++ - snaps = append(snaps, snap) - changes = append(changes, change) + affected.add(snap.Expand(ctx, accountID, change)) } for _, storeEvent := range eventsToStore { storeEvent() } - go am.dispatchAffected(ctx, accountID, snaps, changes) + go am.dispatchAffectedPeerIDs(ctx, accountID, affected.ids()) return globalErr } @@ -317,8 +315,7 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us } var eventsToStore []func() - var snaps []*affectedpeers.Snapshot - var changes []affectedpeers.Change + affected := make(affectedPeerSet) var globalErr error for _, newGroup := range groups { @@ -333,15 +330,14 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us continue } eventsToStore = append(eventsToStore, events...) - snaps = append(snaps, snap) - changes = append(changes, change) + affected.add(snap.Expand(ctx, accountID, change)) } for _, storeEvent := range eventsToStore { storeEvent() } - go am.dispatchAffected(ctx, accountID, snaps, changes) + go am.dispatchAffectedPeerIDs(ctx, accountID, affected.ids()) return globalErr } @@ -538,7 +534,10 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us am.StoreEvent(ctx, userID, group.ID, accountID, activity.GroupDeleted, group.EventMeta()) } - go am.dispatchAffected(ctx, accountID, []*affectedpeers.Snapshot{snap, ipv6Snap}, []affectedpeers.Change{change, ipv6Change}) + affected := make(affectedPeerSet) + affected.add(snap.Expand(ctx, accountID, change)) + affected.add(ipv6Snap.Expand(ctx, accountID, ipv6Change)) + go am.dispatchAffectedPeerIDs(ctx, accountID, affected.ids()) return allErrors } diff --git a/management/server/group_test.go b/management/server/group_test.go index f0ccd385c..61ba56c44 100644 --- a/management/server/group_test.go +++ b/management/server/group_test.go @@ -1386,3 +1386,101 @@ func TestDefaultAccountManager_GroupPeersMustBelongToAccount(t *testing.T) { assert.Equal(t, []string{peer1.ID}, stored.Peers, "rejected direct adds must not change membership") }) } + +func TestGroupsBatchAccountPeersUpdate(t *testing.T) { + runPeerUpdateTest(t, func(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + + for _, group := range []*types.Group{ + {ID: "groupA", Name: "GroupA", Peers: []string{peer1.ID, peer2.ID}}, + {ID: "groupB", Name: "GroupB", Peers: []string{peer3.ID}}, + } { + require.NoError(t, manager.CreateGroup(context.Background(), account.Id, userID, group)) + } + + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) + + step(t, "creating unlinked groups", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.CreateGroups(context.Background(), account.Id, userID, []*types.Group{ + {ID: "groupC", Name: "GroupC", Peers: []string{peer1.ID}}, + {ID: "groupD", Name: "GroupD"}, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + + _, err := manager.SavePolicy(context.Background(), account.Id, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + step(t, "updating a batch with a linked group", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.UpdateGroups(context.Background(), account.Id, userID, []*types.Group{ + {ID: "groupD", Name: "GroupD renamed"}, + {ID: "groupB", Name: "GroupB renamed"}, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + + step(t, "updating a batch where one item fails", func(t *testing.T) { + settleAffectedUpdates(updMsg) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg) + close(done) + }() + + err := manager.UpdateGroups(context.Background(), account.Id, userID, []*types.Group{ + {ID: "missing", Name: "Missing"}, + {ID: "groupB", Name: "GroupB"}, + }) + require.Error(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") + } + }) + }) +} diff --git a/management/server/peer.go b/management/server/peer.go index c60737535..1e3f72a3c 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -1596,30 +1596,20 @@ func (am *DefaultAccountManager) UpdateAccountPeers(ctx context.Context, account // ExpandAndUpdateAffected expands a Snapshot (loaded INSIDE the now-committed // transaction) into the affected peers and dispatches the network-map refresh. // Pure in-memory work plus dispatch, so it runs AFTER commit — the fan-out walk -// never holds the write lock, over the consistent in-tx snapshot. Exported so the -// networks sub-package managers (which hold only account.Manager) share it. +// never holds the write lock, over the consistent in-tx snapshot. The walk runs +// before the dispatch goroutine starts, so the snapshot is released on return. +// Exported so the networks sub-package managers (which hold only account.Manager) +// share it. func (am *DefaultAccountManager) ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) { - go am.dispatchAffected(ctx, accountID, []*affectedpeers.Snapshot{snap}, []affectedpeers.Change{change}) + go am.dispatchAffectedPeerIDs(ctx, accountID, snap.Expand(ctx, accountID, change)) } -// dispatchAffected expands one or more (snapshot, change) pairs — collected across -// one or several transactions — unions their affected peers, and dispatches a -// single network-map refresh. Each snapshot must already be loaded inside its -// transaction; this runs AFTER commit (pure in-memory + dispatch). It is spawned -// in a goroutine that outlives the request, so it detaches from the request -// context's cancellation up front. -func (am *DefaultAccountManager) dispatchAffected(ctx context.Context, accountID string, snaps []*affectedpeers.Snapshot, changes []affectedpeers.Change) { +// dispatchAffectedPeerIDs dispatches a single network-map refresh for already +// expanded affected peer IDs. It may run in a goroutine that outlives the +// request, so it detaches from the request context's cancellation up front. +func (am *DefaultAccountManager) dispatchAffectedPeerIDs(ctx context.Context, accountID string, affectedPeerIDs []string) { ctx = context.WithoutCancel(ctx) - var lists [][]string - for i, snap := range snaps { - if snap == nil { - continue - } - lists = append(lists, snap.Expand(ctx, accountID, changes[i])) - } - - affectedPeerIDs := unionStrings(lists...) if len(affectedPeerIDs) == 0 { log.WithContext(ctx).Tracef("no affected peers for account %s", accountID) return @@ -1629,21 +1619,19 @@ func (am *DefaultAccountManager) dispatchAffected(ctx context.Context, accountID _ = am.networkMapController.UpdateAffectedPeers(ctx, accountID, affectedPeerIDs) } -// unionStrings concatenates the given string lists into one deduplicated slice, -// preserving first-occurrence order. -func unionStrings(lists ...[]string) []string { - seen := make(map[string]struct{}) - var out []string - for _, list := range lists { - for _, id := range list { - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } +// affectedPeerSet collects the affected peer IDs of several snapshot expansions, +// deduplicating as they are added so it stays bounded by the account's peers +// however many items a batch has. +type affectedPeerSet map[string]struct{} + +func (s affectedPeerSet) add(peerIDs []string) { + for _, id := range peerIDs { + s[id] = struct{}{} } - return out +} + +func (s affectedPeerSet) ids() []string { + return maps.Keys(s) } // affectedPeerIDsFromNetworkMap returns the peer IDs referenced by a peer's diff --git a/management/server/user.go b/management/server/user.go index 5f29f4df7..e64320666 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -593,8 +593,7 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, return nil, err } - var snaps []*affectedpeers.Snapshot - var changes []affectedpeers.Change + affected := make(affectedPeerSet) var peersToExpire []*nbpeer.Peer var addUserEvents []func() var usersToSave = make([]*types.User, 0, len(updates)) @@ -629,6 +628,8 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, return nil, status.Errorf(status.InvalidArgument, "provided user update is nil") } + var itemSnap *affectedpeers.Snapshot + var itemChange affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { change, updatedUser, userPeersToExpire, userEvents, err := am.processUserUpdate( ctx, transaction, groupsMap, accountID, initiatorUserID, initiatorUser, update, addIfNotExists, settings, @@ -647,8 +648,7 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, return err } - snaps = append(snaps, snap) - changes = append(changes, change) + itemSnap, itemChange = snap, change usersToSave = append(usersToSave, updatedUser) addUserEvents = append(addUserEvents, userEvents...) peersToExpire = append(peersToExpire, userPeersToExpire...) @@ -662,7 +662,9 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, } globalErr = errors.Join(globalErr, err) // continue when updating multiple users + continue } + affected.add(itemSnap.Expand(ctx, accountID, itemChange)) } var updatedUsersInfo = make([]*types.UserInfo, 0, len(usersToSave)) @@ -693,7 +695,7 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, if err = am.Store.IncrementNetworkSerial(ctx, accountID); err != nil { return nil, fmt.Errorf("failed to increment network serial: %w", err) } - go am.dispatchAffected(ctx, accountID, snaps, changes) + go am.dispatchAffectedPeerIDs(ctx, accountID, affected.ids()) } return updatedUsersInfo, globalErr From 70ddb2a39380605c8b0d928f15736489a14d86c0 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:54:30 +0200 Subject: [PATCH 156/209] [client] Bound the Signal stream registration wait and track its state based on stream health (#8175) * [client] Move the Signal registration wait into its own helper Pure move, no behavior change. The wait for the registration header and the check on its value leave connect() for awaitRegistration(), so the next commit can bound that wait in one place. * [client] Split the Signal test server helper from its listener setup Pure move, no behavior change. serveTestSignalExchange serves any SignalExchangeServer on a local listener; startTestSignalServer keeps serving the real server through it. Lets a test wrap the real server. * [client] Bound the wait for the Signal registration header After an outage the Signal client could wait forever for the registration header of a new stream. Keepalive cannot end that wait when whatever answers the transport (a proxy in front of Signal, or Signal itself) accepts the stream and never answers it, and the receive watchdog only starts once registration succeeds. Signal then stayed down until the daemon was restarted, while Management and Relay recovered. Cancel the stream when the header does not arrive within 10 seconds, so connect() fails and the existing retry loop opens a new stream. A timer is used instead of a context deadline because the stream context must outlive registration. * [client] Report Signal unhealthy while no stream is registered IsHealthy judged Signal by the gRPC connection state alone and marked it connected whenever the connection was Ready or Idle. With the connection up but no stream registered, the client cannot receive offers or answers, yet netbird status showed Signal: Connected, hiding a stuck registration for as long as it lasted. Require a registered stream before reporting healthy. When there is none, return false without touching the recorder: the disconnect that dropped the stream already reported its reason. * [client] Name the server that reconnected in the SR watcher log Signal and Relay shared one reconnect callback, which logged "reconnected to Signal or Relay server" for either. A Relay reconnect while the Signal stream was down read as Signal having recovered, which misled diagnosis of a stuck Signal registration. Register one callback per server so the log names the one that actually reconnected. Which listeners get notified, and when, is unchanged. * [client] Trim comments in the Signal registration and health code Comment-only change, no behavior change. Drop comments that restate the code and keep only the reason a timer is used instead of a context deadline. * [client] Check Signal stream registration before the connection state IsHealthy returned true for a Connecting or Shutdown channel before the registration check ran, so a client with no registered stream could still be reported healthy while the channel was reconnecting. Check the stream first; the connection-state cases now only apply to a client that has a registered stream. * [client] Log a plain connect in the SR watcher The Signal and Relay callbacks also fire on the first connection, not only after a disconnect, so "reconnected" was wrong at startup. Log "connected to server" for both cases. * [client] Say what the SR watcher does when it logs The line was read as a per-server connect log, which Signal and Relay already emit themselves. What it marks is the peer guards being told to reset their reconnection backoff, which happens only once both are ready. Log that, naming the server whose event triggered it. --- client/internal/peer/guard/sr_watcher.go | 16 ++- shared/signal/client/grpc.go | 41 +++++-- shared/signal/client/health_test.go | 130 ++++++++++++++++++++++ shared/signal/client/registration_test.go | 86 ++++++++++++++ shared/signal/client/watchdog_test.go | 13 ++- 5 files changed, 270 insertions(+), 16 deletions(-) create mode 100644 shared/signal/client/health_test.go create mode 100644 shared/signal/client/registration_test.go diff --git a/client/internal/peer/guard/sr_watcher.go b/client/internal/peer/guard/sr_watcher.go index 0befd7438..8eac1a858 100644 --- a/client/internal/peer/guard/sr_watcher.go +++ b/client/internal/peer/guard/sr_watcher.go @@ -54,8 +54,8 @@ func (w *SRWatcher) Start(disableICEMonitor bool) { iceMonitor := NewICEMonitor(w.iFaceDiscover, w.iceConfig, GetICEMonitorPeriod()) go iceMonitor.Start(ctx, w.onICEChanged) } - w.signalClient.SetOnReconnectedListener(w.onReconnected) - w.relayManager.SetOnReconnectedListener(w.onReconnected) + w.signalClient.SetOnReconnectedListener(w.onSignalReconnected) + w.relayManager.SetOnReconnectedListener(w.onRelayReconnected) } @@ -96,7 +96,15 @@ func (w *SRWatcher) onICEChanged() { w.notify() } -func (w *SRWatcher) onReconnected() { +func (w *SRWatcher) onSignalReconnected() { + w.onReconnected("Signal") +} + +func (w *SRWatcher) onRelayReconnected() { + w.onReconnected("Relay") +} + +func (w *SRWatcher) onReconnected(server string) { if !w.signalClient.Ready() { return } @@ -104,7 +112,7 @@ func (w *SRWatcher) onReconnected() { return } - log.Infof("reconnected to Signal or Relay server") + log.Infof("Signal and Relay ready (triggered by %s), resetting peer reconnection backoff", server) w.notify() } diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go index 92be57b30..e489078a9 100644 --- a/shared/signal/client/grpc.go +++ b/shared/signal/client/grpc.go @@ -38,6 +38,8 @@ const ( receiveProbeTimeout = 10 * time.Second // receiveWatchdogInterval is how often the watchdog evaluates the stream. receiveWatchdogInterval = 10 * time.Second + // defaultRegistrationTimeout bounds the wait for a new stream's registration header. + defaultRegistrationTimeout = 10 * time.Second ) // errReceiveStreamStalled is reported when the receive stream is transport-alive @@ -92,6 +94,8 @@ type GrpcClient struct { // new stream feeds the same worker, and only triggers a reconnect storm. receiveHandoffBlocked atomic.Bool watchdogWg sync.WaitGroup + + registrationTimeout time.Duration } // Option configures optional GrpcClient behavior. @@ -112,6 +116,7 @@ func NewClient(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled boo mux: sync.Mutex{}, status: StreamDisconnected, connStateCallbackLock: sync.RWMutex{}, + registrationTimeout: defaultRegistrationTimeout, } for _, opt := range opts { opt(c) @@ -230,7 +235,7 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes // todo once the key rotation logic has been implemented, consider changing to some other identifier (received from management) streamCtx, cancelStream := context.WithCancel(ctx) defer cancelStream() - stream, err := c.connect(streamCtx, c.key.PublicKey().String()) + stream, err := c.connect(streamCtx, cancelStream, c.key.PublicKey().String()) if err != nil { log.Warnf("disconnected from the Signal Exchange due to an error: %v", err) return err @@ -308,7 +313,7 @@ func (c *GrpcClient) notifyStreamConnected() { } } -func (c *GrpcClient) connect(ctx context.Context, key string) (proto.SignalExchange_ConnectStreamClient, error) { +func (c *GrpcClient) connect(ctx context.Context, cancel context.CancelFunc, key string) (proto.SignalExchange_ConnectStreamClient, error) { c.stream = nil // add key fingerprint to the request header to be identified on the server side @@ -319,19 +324,30 @@ func (c *GrpcClient) connect(ctx context.Context, key string) (proto.SignalExcha if err != nil { return nil, err } - // blocks - header, err := c.stream.Header() - if err != nil { + if err := awaitRegistration(stream, c.registrationTimeout, cancel); err != nil { return nil, err } - registered := header.Get(proto.HeaderRegistered) - if len(registered) == 0 { - return nil, fmt.Errorf("didn't receive a registration header from the Signal server whille connecting to the streams") - } return stream, nil } +func awaitRegistration(stream proto.SignalExchange_ConnectStreamClient, timeout time.Duration, cancel context.CancelFunc) error { + // A timer rather than a context deadline: the stream context must outlive registration. + timer := time.AfterFunc(timeout, cancel) + header, err := stream.Header() + if !timer.Stop() { + return fmt.Errorf("no registration header from the Signal server within %s", timeout) + } + if err != nil { + return err + } + registered := header.Get(proto.HeaderRegistered) + if len(registered) == 0 { + return fmt.Errorf("didn't receive a registration header from the Signal server whille connecting to the streams") + } + return nil +} + // Ready indicates whether the client is okay and Ready to be used // for now it just checks whether gRPC connection to the service is in state Ready func (c *GrpcClient) Ready() bool { @@ -343,6 +359,13 @@ func (c *GrpcClient) Ready() bool { // recorder accordingly. It does not actively probe: the watchdog // (watchReceiveStream) owns probing the receive path and reconnecting. func (c *GrpcClient) IsHealthy() bool { + c.mux.Lock() + registered := c.status == StreamConnected + c.mux.Unlock() + if !registered { + return false + } + switch c.signalConn.GetState() { case connectivity.TransientFailure: return false diff --git a/shared/signal/client/health_test.go b/shared/signal/client/health_test.go new file mode 100644 index 000000000..d62ee2cc2 --- /dev/null +++ b/shared/signal/client/health_test.go @@ -0,0 +1,130 @@ +package client + +import ( + "context" + "net" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "google.golang.org/grpc" + "google.golang.org/grpc/connectivity" + "google.golang.org/grpc/credentials/insecure" + + sigProto "github.com/netbirdio/netbird/shared/signal/proto" +) + +type recordingNotifier struct { + mu sync.Mutex + connected bool +} + +func (n *recordingNotifier) MarkSignalDisconnected(error) { + n.mu.Lock() + defer n.mu.Unlock() + n.connected = false +} + +func (n *recordingNotifier) MarkSignalConnected() { + n.mu.Lock() + defer n.mu.Unlock() + n.connected = true +} + +func (n *recordingNotifier) isConnected() bool { + n.mu.Lock() + defer n.mu.Unlock() + return n.connected +} + +// TestIsHealthyRequiresRegisteredStream covers the status reporting half of a +// stuck registration: the gRPC connection to Signal is up but no stream is +// registered, so no peer can reach this client. The health check used to look +// at the connection only, reporting Signal as connected throughout. +func TestIsHealthyRequiresRegisteredStream(t *testing.T) { + addr := startTestSignalServer(t) + + key, err := wgtypes.GenerateKey() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + client, err := NewClient(ctx, addr, key, false) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + + notifier := &recordingNotifier{} + client.SetConnStateListener(notifier) + + assert.False(t, client.IsHealthy(), "no registered stream must not be reported healthy") + assert.False(t, notifier.isConnected(), "no registered stream must not mark Signal connected") + + // Stop Receive before Close runs (cleanups are LIFO), so the two never overlap. + receiveDone := make(chan struct{}) + go func() { + defer close(receiveDone) + _ = client.Receive(ctx, func(*sigProto.Message) error { return nil }) + }() + t.Cleanup(func() { + cancel() + <-receiveDone + }) + + streamReady := make(chan struct{}) + go func() { + client.WaitStreamConnected(ctx) + close(streamReady) + }() + select { + case <-streamReady: + case <-time.After(5 * time.Second): + t.Fatal("signal stream did not connect within timeout") + } + + assert.True(t, client.IsHealthy(), "a registered stream on a usable connection must be healthy") + assert.True(t, notifier.isConnected(), "a registered stream must mark Signal connected") +} + +// TestIsHealthyConnectingWithoutStream covers the window while the connection is +// still being established: a TCP listener that accepts but never speaks HTTP/2 +// keeps the channel in Connecting, and with no registered stream the client +// cannot receive anything yet, so it must not be reported healthy. +func TestIsHealthyConnectingWithoutStream(t *testing.T) { + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + var mu sync.Mutex + var accepted []net.Conn + t.Cleanup(func() { + _ = lis.Close() + mu.Lock() + defer mu.Unlock() + for _, c := range accepted { + _ = c.Close() + } + }) + go func() { + for { + c, err := lis.Accept() + if err != nil { + return + } + mu.Lock() + accepted = append(accepted, c) + mu.Unlock() + } + }() + + conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + conn.Connect() + require.Eventually(t, func() bool { return conn.GetState() == connectivity.Connecting }, 5*time.Second, 10*time.Millisecond, + "channel should be stuck connecting to a listener that never answers") + + client := &GrpcClient{signalConn: conn, status: StreamDisconnected} + assert.False(t, client.IsHealthy(), "a connecting channel with no registered stream must not be reported healthy") +} diff --git a/shared/signal/client/registration_test.go b/shared/signal/client/registration_test.go new file mode 100644 index 000000000..7703f843d --- /dev/null +++ b/shared/signal/client/registration_test.go @@ -0,0 +1,86 @@ +package client + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + sigProto "github.com/netbirdio/netbird/shared/signal/proto" + "github.com/netbirdio/netbird/signal/server" +) + +// stallingSignalServer holds the first ConnectStream open without ever sending +// the registration header, while the transport keeps answering keepalive pings. +// Later streams go to the real server. This is what a responder in front of or +// inside Signal looks like when it accepts a stream and never answers it. +type stallingSignalServer struct { + *server.Server + stalled atomic.Bool + held chan struct{} +} + +func (s *stallingSignalServer) ConnectStream(stream sigProto.SignalExchange_ConnectStreamServer) error { + if s.stalled.CompareAndSwap(false, true) { + close(s.held) + <-stream.Context().Done() + return stream.Context().Err() + } + return s.Server.ConnectStream(stream) +} + +// TestReceiveRetriesWhenRegistrationHeaderNeverArrives reproduces a client stuck +// forever waiting for the registration header of a stream the server side never +// answers: the receive watchdog only starts after registration, so nothing ended +// the wait and Signal never came back until the daemon was restarted. The client +// must give up on the silent stream and register on a new one. +func TestReceiveRetriesWhenRegistrationHeaderNeverArrives(t *testing.T) { + realServer, err := server.NewServer(context.Background(), otel.Meter("")) + require.NoError(t, err) + stalling := &stallingSignalServer{Server: realServer, held: make(chan struct{})} + addr := serveTestSignalExchange(t, stalling) + + key, err := wgtypes.GenerateKey() + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + client, err := NewClient(ctx, addr, key, false) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + client.registrationTimeout = 200 * time.Millisecond + + // Stop Receive before Close runs (cleanups are LIFO), so the two never overlap. + receiveDone := make(chan struct{}) + go func() { + defer close(receiveDone) + _ = client.Receive(ctx, func(*sigProto.Message) error { return nil }) + }() + t.Cleanup(func() { + cancel() + <-receiveDone + }) + + select { + case <-stalling.held: + case <-time.After(5 * time.Second): + t.Fatal("first stream did not reach the stalling server") + } + + streamReady := make(chan struct{}) + go func() { + client.WaitStreamConnected(ctx) + close(streamReady) + }() + select { + case <-streamReady: + case <-time.After(5 * time.Second): + t.Fatal("client never registered after the first stream went unanswered") + } + require.True(t, client.StreamConnected(), "client should report a registered stream") +} diff --git a/shared/signal/client/watchdog_test.go b/shared/signal/client/watchdog_test.go index a8bbafa29..ab0ab512e 100644 --- a/shared/signal/client/watchdog_test.go +++ b/shared/signal/client/watchdog_test.go @@ -19,13 +19,20 @@ import ( func startTestSignalServer(t *testing.T) string { t.Helper() + srv, err := server.NewServer(context.Background(), otel.Meter("")) + require.NoError(t, err) + + return serveTestSignalExchange(t, srv) +} + +func serveTestSignalExchange(t *testing.T, impl sigProto.SignalExchangeServer) string { + t.Helper() + lis, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) s := grpc.NewServer() - srv, err := server.NewServer(context.Background(), otel.Meter("")) - require.NoError(t, err) - sigProto.RegisterSignalExchangeServer(s, srv) + sigProto.RegisterSignalExchangeServer(s, impl) go func() { _ = s.Serve(lis) From 10e34090fa3f0a61f2366ce518955d3b9e168f03 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Tue, 13 Oct 2026 05:21:37 -0400 Subject: [PATCH 157/209] [client] Ensure onDisconnected is called on connection teardown (#8178) Conn.Close previously called onDisconnected only when evalStatus reported StatusConnected. When a connection drops or closes from a connecting or disconnecting state, the callback was skipped. This left registered listeners such as Rosenpass unaware of the teardown, keeping peer workers active and leaking sockets. Invoke onDisconnected unconditionally on Conn.Close if registered. --- client/internal/peer/conn.go | 2 +- client/internal/peer/conn_test.go | 43 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index 17823e043..b8a32b0cb 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -341,7 +341,7 @@ func (conn *Conn) Close(signalToRemote bool) { conn.Log.Errorf("failed to remove wg endpoint: %v", err) } - if conn.evalStatus() == StatusConnected && conn.onDisconnected != nil { + if conn.onDisconnected != nil { conn.onDisconnected(conn.config.WgConfig.RemoteKey) } diff --git a/client/internal/peer/conn_test.go b/client/internal/peer/conn_test.go index 8f3d6216d..b481c95ed 100644 --- a/client/internal/peer/conn_test.go +++ b/client/internal/peer/conn_test.go @@ -3,19 +3,26 @@ package peer import ( "context" "fmt" + "net" + "net/netip" "os" "testing" "time" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/configurer" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgproxy" "github.com/netbirdio/netbird/client/internal/metrics" "github.com/netbirdio/netbird/client/internal/peer/conntype" "github.com/netbirdio/netbird/client/internal/peer/dispatcher" "github.com/netbirdio/netbird/client/internal/peer/guard" "github.com/netbirdio/netbird/client/internal/peer/ice" + "github.com/netbirdio/netbird/client/internal/peer/worker" "github.com/netbirdio/netbird/client/internal/stdnet" "github.com/netbirdio/netbird/util" ) @@ -418,3 +425,39 @@ func TestMetricsConnType_RelayedMatchesIsRelayed(t *testing.T) { "priority %s: isRelayed and the %q metric tag must agree", priority, tag) } } + +type stubWGIface struct{} + +func (s *stubWGIface) UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error { + return nil +} +func (s *stubWGIface) RemovePeer(peerKey string) error { return nil } +func (s *stubWGIface) GetStats() (map[string]configurer.WGStats, error) { + return map[string]configurer.WGStats{}, nil +} +func (s *stubWGIface) GetProxy() wgproxy.Proxy { return nil } +func (s *stubWGIface) Address() wgaddr.Address { return wgaddr.Address{} } +func (s *stubWGIface) RemoveEndpointAddress(key string) error { return nil } + +func TestConn_Close_CallsOnDisconnected(t *testing.T) { + var disconnected []string + conn := newWGTimeoutTestConn(true, &disconnected) + conn.endpointUpdater = NewEndpointUpdater(conn.Log, conn.config.WgConfig, false) + conn.config.WgConfig.WgInterface = &stubWGIface{} + conn.endpointUpdater.wgConfig.WgInterface = &stubWGIface{} + conn.workerRelay = &WorkerRelay{} + ctx, cancel := context.WithCancel(context.Background()) + conn.ctx = ctx + conn.ctxCancel = cancel + conn.opened = true + conn.statusICE = worker.NewAtomicStatus() + conn.statusRelay = worker.NewAtomicStatus() + conn.statusRecorder = NewRecorder("https://mgm") + + // The connection is closed while in a disconnected or connecting state + // (e.g. peer dropped, ICE failed, or management disconnect). + conn.Close(false) + + assert.Equal(t, []string{conn.config.WgConfig.RemoteKey}, disconnected, + "Close must notify onDisconnected so Rosenpass cleans up its peer worker") +} From 49ec0e26999abec532c2de28b63f80c2e04c95c5 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Tue, 13 Oct 2026 13:04:10 +0300 Subject: [PATCH 158/209] [management] Emit activity events for batch group ops (#8180) --- management/server/group.go | 10 ++++----- management/server/group_test.go | 39 +++++++++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/management/server/group.go b/management/server/group.go index d0d6d2628..3cd31f152 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -256,6 +256,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us for _, newGroup := range groups { change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}} var snap *affectedpeers.Snapshot + var events []func() err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil { return err @@ -263,6 +264,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us newGroup.AccountID = accountID newGroup.PublicID = xid.New().String() + events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) if err = transaction.CreateGroup(ctx, newGroup); err != nil { return err @@ -273,9 +275,6 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us return err } - events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) - eventsToStore = append(eventsToStore, events...) - snap, err = affectedpeers.Load(ctx, transaction, accountID, change) return err }) @@ -290,6 +289,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us } createdCount++ affected.add(snap.Expand(ctx, accountID, change)) + eventsToStore = append(eventsToStore, events...) } for _, storeEvent := range eventsToStore { @@ -358,6 +358,8 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI } newGroup.PublicID = oldGroup.PublicID + events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) + if err := transaction.UpdateGroup(ctx, newGroup); err != nil { return err } @@ -372,8 +374,6 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI return err } - events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) - snap, err = affectedpeers.Load(ctx, transaction, accountID, change) return err }) diff --git a/management/server/group_test.go b/management/server/group_test.go index 61ba56c44..9bcebfd34 100644 --- a/management/server/group_test.go +++ b/management/server/group_test.go @@ -20,6 +20,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/networks" "github.com/netbirdio/netbird/management/server/networks/resources" @@ -77,6 +78,42 @@ func TestDefaultAccountManager_CreateGroup(t *testing.T) { } } +func TestDefaultAccountManager_BatchGroupChangesStoreEvents(t *testing.T) { + t.Setenv("NB_EVENT_ACTIVITY_LOG_ENABLED", "true") + + am, _, err := createManager(t) + require.NoError(t, err) + + _, account, err := initTestGroupAccount(am) + require.NoError(t, err) + + group := &types.Group{ID: "grp-batch", Name: "Batch group", Issued: types.GroupIssuedAPI} + err = am.CreateGroups(context.Background(), account.Id, groupAdminUserID, []*types.Group{group}) + require.NoError(t, err) + + renamed := &types.Group{ID: group.ID, Name: "Batch group renamed", Issued: types.GroupIssuedAPI} + err = am.UpdateGroups(context.Background(), account.Id, groupAdminUserID, []*types.Group{renamed}) + require.NoError(t, err) + + hasEvent := func(code activity.Activity) bool { + events, err := am.eventStore.Get(context.Background(), account.Id, 0, 100, true) + if err != nil { + return false + } + for _, event := range events { + if event.Activity == code && event.TargetID == group.ID { + return true + } + } + return false + } + + assert.Eventually(t, func() bool { return hasEvent(activity.GroupCreated) }, time.Second, 10*time.Millisecond, + "CreateGroups should store a group created event") + assert.Eventually(t, func() bool { return hasEvent(activity.GroupUpdated) }, time.Second, 10*time.Millisecond, + "UpdateGroups should store a group updated event on rename") +} + func TestDefaultAccountManager_DeleteGroup(t *testing.T) { am, _, err := createManager(t) if err != nil { @@ -1085,7 +1122,6 @@ func Test_AddPeerToGroup(t *testing.T) { errs <- fmt.Errorf("AddPeer failed for peer %d: %w", i, err) return } - }(i) } startTime := time.Now() @@ -1142,7 +1178,6 @@ func Test_AddPeerToAll(t *testing.T) { errs <- fmt.Errorf("AddPeer failed for peer %d: %w", i, err) return } - }(i) } startTime := time.Now() From f62949605e2b758bd5874e46432a932fdf5201cc Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:13:25 +0900 Subject: [PATCH 159/209] [client] Keep HA routing peer selection latency-aware and hold switches that reset connections (#7689) --- client/internal/peer/status.go | 173 +++- client/internal/peer/status_test.go | 178 +++++ client/internal/peer/worker_ice.go | 161 +++- client/internal/peer/worker_ice_test.go | 168 ++++ client/internal/routemanager/client/client.go | 755 ++++++++++++++---- .../routemanager/client/client_bench_test.go | 8 +- .../routemanager/client/client_test.go | 652 +++++++++++++++ client/internal/routemanager/client/env.go | 77 ++ .../client/relayed_switch_test.go | 358 +++++++++ 9 files changed, 2334 insertions(+), 196 deletions(-) create mode 100644 client/internal/peer/worker_ice_test.go create mode 100644 client/internal/routemanager/client/env.go create mode 100644 client/internal/routemanager/client/relayed_switch_test.go diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 6c44178e1..b695e3b22 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -28,6 +28,11 @@ import ( const eventQueueSize = 10 +// routerLatencyNotifyThreshold is the latency or noise change that notifies the route +// watchers subscribed to a peer. Latency is resampled continuously, so without +// a threshold every sample would wake every watcher of every routing peer. +const routerLatencyNotifyThreshold = 5 * time.Millisecond + type ResolvedDomainInfo struct { Prefixes []netip.Prefix ParentDomain domain.Domain @@ -41,11 +46,21 @@ type EventListener interface { OnEvent(event *proto.SystemEvent) } +// LatencySample is a peer's smoothed round trip time together with the +// uncertainty of that estimate, which is what the jitter of the path leaves +// behind after smoothing. Comparing two peers is only meaningful when their +// difference is larger than this. +type LatencySample struct { + Latency time.Duration + Noise time.Duration +} + // RouterState status for router peers. This contains relevant fields for route manager type RouterState struct { - Status ConnStatus - Relayed bool - Latency time.Duration + Status ConnStatus + Relayed bool + Latency time.Duration + LatencyNoise time.Duration } // State contains the latest state of a peer @@ -67,9 +82,11 @@ type State struct { BytesTx int64 BytesRx int64 Latency time.Duration - RosenpassEnabled bool - SSHHostKey []byte - routes map[string]struct{} + // LatencyNoise is the uncertainty of Latency on a jittery path + LatencyNoise time.Duration + RosenpassEnabled bool + SSHHostKey []byte + routes map[string]struct{} } // AddRoute add a single route to routes map @@ -103,6 +120,13 @@ func (s *State) GetRoutes() map[string]struct{} { return maps.Clone(s.routes) } +// clearLatency drops the latency estimate, which is only measured over a direct +// connection and says nothing about the path once that connection is gone. +func (s *State) clearLatency() { + s.Latency = 0 + s.LatencyNoise = 0 +} + // LocalPeerState contains the latest state of the local peer type LocalPeerState struct { IP string @@ -163,11 +187,25 @@ type FullStatus struct { Events []*proto.SystemEvent } +// routerSnapshot is a router-state map stamped with a sequence number taken +// under the status lock. Snapshots are built under the lock but delivered +// outside it, so two dispatchers can race on the way to a subscriber; the +// stamp lets delivery drop the older snapshot instead of letting it overwrite +// a newer one. +type routerSnapshot struct { + seq uint64 + states map[string]RouterState +} + type StatusChangeSubscription struct { peerID string id string eventsChan chan map[string]RouterState ctx context.Context + + // sendMu serializes deliveries and guards lastSeq + sendMu sync.Mutex + lastSeq uint64 } func newStatusChangeSubscription(ctx context.Context, peerID string) *StatusChangeSubscription { @@ -184,17 +222,54 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState { return s.eventsChan } +// deliver forwards a snapshot to the subscriber, dropping it when a newer one +// has already been delivered. A blocking deliver waits for buffer space, since +// peer state transitions must not be lost. A non-blocking one gives up when the +// subscriber is backlogged or another delivery is in flight; it is meant for +// latency refreshes, which are superseded by the next sample anyway and are +// also re-read from the recorder on the route watchers' periodic re-evaluation. +// Only a snapshot that reached the subscriber supersedes older ones. +func (s *StatusChangeSubscription) deliver(snapshot *routerSnapshot, block bool) { + if block { + s.sendMu.Lock() + } else if !s.sendMu.TryLock() { + return + } + defer s.sendMu.Unlock() + + if snapshot.seq <= s.lastSeq { + return + } + + if block { + select { + case s.eventsChan <- snapshot.states: + s.lastSeq = snapshot.seq + case <-s.ctx.Done(): + } + return + } + + select { + case s.eventsChan <- snapshot.states: + s.lastSeq = snapshot.seq + default: + } +} + // Status holds a state of peers, signal, management connections and relays. // mux is an RWMutex so hot read paths (notably PeerStateByIP, called for // every private-service request) don't contend against each other. // Pure read methods take RLock; anything that mutates state takes Lock. type Status struct { - mux sync.RWMutex - muxRelays sync.RWMutex - peers map[string]State - ipToKey map[string]string - activeRoutePeers map[route.HAUniqueID]string - changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription + mux sync.RWMutex + muxRelays sync.RWMutex + peers map[string]State + ipToKey map[string]string + activeRoutePeers map[route.HAUniqueID]string + changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription + // routerSeq stamps router-state snapshots, see routerSnapshot + routerSeq uint64 signalState bool signalError error managementState bool @@ -386,6 +461,7 @@ func (d *Status) UpdatePeerState(receivedState State) error { peerState.RemoteIceCandidateEndpoint = receivedState.RemoteIceCandidateEndpoint peerState.RelayServerAddress = receivedState.RelayServerAddress peerState.RosenpassEnabled = receivedState.RosenpassEnabled + peerState.clearLatency() } d.peers[receivedState.PubKey] = peerState @@ -402,7 +478,7 @@ func (d *Status) UpdatePeerState(receivedState State) error { d.notifier.peerListChanged(numPeers) } if notifyRouter { - d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot, true) } d.notifyStateChange() return nil @@ -522,7 +598,7 @@ func (d *Status) UpdatePeerICEState(receivedState State) error { d.notifier.peerListChanged(numPeers) } if notifyRouter { - d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot, true) } d.notifyStateChange() return nil @@ -559,7 +635,7 @@ func (d *Status) UpdatePeerRelayedState(receivedState State) error { d.notifier.peerListChanged(numPeers) } if notifyRouter { - d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot, true) } d.notifyStateChange() return nil @@ -595,7 +671,7 @@ func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error d.notifier.peerListChanged(numPeers) } if notifyRouter { - d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot, true) } d.notifyStateChange() return nil @@ -620,6 +696,7 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error { peerState.RemoteIceCandidateType = receivedState.RemoteIceCandidateType peerState.LocalIceCandidateEndpoint = receivedState.LocalIceCandidateEndpoint peerState.RemoteIceCandidateEndpoint = receivedState.RemoteIceCandidateEndpoint + peerState.clearLatency() d.peers[receivedState.PubKey] = peerState @@ -634,7 +711,7 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error { d.notifier.peerListChanged(numPeers) } if notifyRouter { - d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot) + d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot, true) } d.notifyStateChange() return nil @@ -714,7 +791,7 @@ func (d *Status) FinishPeerListModifications() { // snapshot per-peer router state to deliver after the lock is released type routerDispatch struct { peerID string - snapshot map[string]RouterState + snapshot *routerSnapshot } dispatches := make([]routerDispatch, 0, len(d.peers)) for key := range d.peers { @@ -728,7 +805,7 @@ func (d *Status) FinishPeerListModifications() { d.notifier.peerListChanged(numPeers) for _, rd := range dispatches { - d.dispatchRouterPeers(rd.peerID, rd.snapshot) + d.dispatchRouterPeers(rd.peerID, rd.snapshot, true) } d.notifyStateChange() } @@ -1057,22 +1134,42 @@ func (d *Status) GetManagementState() ManagementState { } } -func (d *Status) UpdateLatency(pubKey string, latency time.Duration) error { - if latency <= 0 { +func (d *Status) UpdateLatency(pubKey string, sample LatencySample) error { + if sample.Latency <= 0 { return nil } d.mux.Lock() - defer d.mux.Unlock() peerState, ok := d.peers[pubKey] if !ok { + d.mux.Unlock() return errors.New("peer doesn't exist") } - peerState.Latency = latency + + previous, previousNoise := peerState.Latency, peerState.LatencyNoise + peerState.Latency = sample.Latency + peerState.LatencyNoise = sample.Noise d.peers[pubKey] = peerState + + // the noise sets the switch margin, so it counts on its own + notifyRouter := absDuration(sample.Latency-previous) >= routerLatencyNotifyThreshold || + absDuration(sample.Noise-previousNoise) >= routerLatencyNotifyThreshold + routerSnapshot := d.snapshotRouterPeersLocked(pubKey, notifyRouter) + + d.mux.Unlock() + + d.dispatchRouterPeers(pubKey, routerSnapshot, false) + return nil } +func absDuration(d time.Duration) time.Duration { + if d < 0 { + return -d + } + return d +} + // IsLoginRequired determines if a peer's login has expired. func (d *Status) IsLoginRequired() bool { d.mux.RLock() @@ -1229,7 +1326,7 @@ func (d *Status) RemoveConnectionListener() { // Caller MUST hold d.mux. Returns nil when there are no subscribers for peerID // or when notify is false. The snapshot is consumed later by dispatchRouterPeers // outside the lock so the channel send cannot stall any d.mux holder. -func (d *Status) snapshotRouterPeersLocked(peerID string, notify bool) map[string]RouterState { +func (d *Status) snapshotRouterPeersLocked(peerID string, notify bool) *routerSnapshot { if !notify { return nil } @@ -1244,22 +1341,29 @@ func (d *Status) snapshotRouterPeersLocked(peerID string, notify bool) map[strin continue } routerPeers[pid] = RouterState{ - Status: s.ConnStatus, - Relayed: s.Relayed, - Latency: s.Latency, + Status: s.ConnStatus, + Relayed: s.Relayed, + Latency: s.Latency, + LatencyNoise: s.LatencyNoise, } } - return routerPeers + + d.routerSeq++ + + return &routerSnapshot{seq: d.routerSeq, states: routerPeers} } // dispatchRouterPeers delivers a previously snapshotted router-state map to // the peer's subscribers. Caller MUST NOT hold d.mux. The method takes a // fresh, short read of d.changeNotify under the lock to grab subscriber // channels, then sends outside the lock so a slow consumer cannot block other -// d.mux holders. The send itself stays blocking (only short-circuited by the -// subscriber's context) so peer state transitions are not silently dropped. -func (d *Status) dispatchRouterPeers(peerID string, routerPeers map[string]RouterState) { - if routerPeers == nil { +// d.mux holders. With block set, the send waits for buffer space (only +// short-circuited by the subscriber's context) so peer state transitions are +// not silently dropped; without it, a backlogged subscriber loses the snapshot +// instead of stalling the caller. Out-of-order deliveries are dropped either +// way, see StatusChangeSubscription.deliver. +func (d *Status) dispatchRouterPeers(peerID string, snapshot *routerSnapshot, block bool) { + if snapshot == nil { return } @@ -1274,10 +1378,7 @@ func (d *Status) dispatchRouterPeers(peerID string, routerPeers map[string]Route d.mux.Unlock() for _, sub := range subs { - select { - case sub.eventsChan <- routerPeers: - case <-sub.ctx.Done(): - } + sub.deliver(snapshot, block) } } diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go index b3f01b217..2d910ccf5 100644 --- a/client/internal/peer/status_test.go +++ b/client/internal/peer/status_test.go @@ -44,6 +44,184 @@ func TestGetPeer(t *testing.T) { assert.Error(t, err, "should return error when peer doesn't exist") } +func TestUpdateLatencyNotifiesRouteWatchers(t *testing.T) { + key := "abc" + status := NewRecorder("https://mgm") + require.NoError(t, status.AddPeer(key, "abc.netbird", "100.108.254.1", "")) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + sub := status.SubscribeToPeerStateChanges(ctx, key) + + require.NoError(t, status.UpdateLatency(key, LatencySample{Latency: 40 * time.Millisecond})) + select { + case states := <-sub.Events(): + assert.Equal(t, 40*time.Millisecond, states[key].Latency, "the notified state should carry the new latency") + default: + t.Fatal("expected a router state notification for the first latency sample") + } + + // below routerLatencyNotifyThreshold: recorded, but not worth waking the watchers + require.NoError(t, status.UpdateLatency(key, LatencySample{Latency: 42 * time.Millisecond})) + select { + case <-sub.Events(): + t.Fatal("a latency change below the threshold should not notify") + default: + } + + peerState, err := status.GetPeer(key) + require.NoError(t, err) + assert.Equal(t, 42*time.Millisecond, peerState.Latency, "the latency should be recorded even without a notification") + + require.NoError(t, status.UpdateLatency(key, LatencySample{Latency: 100 * time.Millisecond})) + select { + case states := <-sub.Events(): + assert.Equal(t, 100*time.Millisecond, states[key].Latency, "a significant latency change should notify") + default: + t.Fatal("expected a router state notification for a significant latency change") + } + + // the noise feeds the switch margin, so a large change in it matters on its + // own even when the latency stays put + require.NoError(t, status.UpdateLatency(key, LatencySample{Latency: 100 * time.Millisecond, Noise: 20 * time.Millisecond})) + select { + case states := <-sub.Events(): + assert.Equal(t, 20*time.Millisecond, states[key].LatencyNoise, "a significant noise change should notify") + default: + t.Fatal("expected a router state notification for a significant noise change") + } + + require.NoError(t, status.UpdateLatency(key, LatencySample{Latency: 100 * time.Millisecond, Noise: 21 * time.Millisecond})) + select { + case <-sub.Events(): + t.Fatal("a noise change below the threshold should not notify") + default: + } +} + +// TestSubscriptionDeliverOrdering covers the sequence stamping: snapshots are +// built under the status lock but delivered outside it, so a dispatcher that +// is descheduled between the two can deliver an outdated snapshot after a +// newer one. The older snapshot must be dropped, not delivered. +func TestSubscriptionDeliverOrdering(t *testing.T) { + sub := newStatusChangeSubscription(context.Background(), "peer") + + newer := &routerSnapshot{seq: 2, states: map[string]RouterState{"peer": {Status: StatusConnecting}}} + older := &routerSnapshot{seq: 1, states: map[string]RouterState{"peer": {Status: StatusConnected}}} + + sub.deliver(newer, true) + sub.deliver(older, true) + + select { + case states := <-sub.Events(): + assert.Equal(t, StatusConnecting, states["peer"].Status, "the newer snapshot should be delivered") + default: + t.Fatal("expected the newer snapshot to be delivered") + } + + select { + case <-sub.Events(): + t.Fatal("the outdated snapshot must be dropped, not delivered after the newer one") + default: + } +} + +// TestSubscriptionDeliverNonBlocking covers the latency dispatch path: a +// backlogged subscriber must not block the sender, and the dropped snapshot is +// recovered from the recorder by the route watchers' periodic re-evaluation. +func TestSubscriptionDeliverNonBlocking(t *testing.T) { + sub := newStatusChangeSubscription(context.Background(), "peer") + + // fill the subscription buffer with no consumer draining it + seq := uint64(1) + for { + before := len(sub.eventsChan) + sub.deliver(&routerSnapshot{seq: seq, states: map[string]RouterState{}}, false) + seq++ + if len(sub.eventsChan) == before { + break + } + } + + done := make(chan struct{}) + go func() { + sub.deliver(&routerSnapshot{seq: seq, states: map[string]RouterState{}}, false) + close(done) + }() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("a non-blocking deliver must return with the buffer full") + } +} + +// A snapshot the subscriber never received must not count as delivered: a +// state transition dispatched just before it, but delivered just after, would +// otherwise be dropped as outdated and never reach the subscriber. +func TestSubscriptionDroppedDeliveryDoesNotSupersede(t *testing.T) { + sub := newStatusChangeSubscription(context.Background(), "peer") + + for seq := uint64(1); seq <= uint64(cap(sub.eventsChan)); seq++ { + sub.deliver(&routerSnapshot{seq: seq, states: map[string]RouterState{}}, false) + } + require.Len(t, sub.eventsChan, cap(sub.eventsChan), "the buffer must be full") + + // a latency refresh stamped after the transition wins the race to the + // subscriber and is dropped on the full buffer + transition := &routerSnapshot{seq: 100, states: map[string]RouterState{"peer": {Status: StatusConnecting}}} + latency := &routerSnapshot{seq: 101, states: map[string]RouterState{"peer": {Status: StatusConnected}}} + sub.deliver(latency, false) + + for range cap(sub.eventsChan) { + <-sub.eventsChan + } + sub.deliver(transition, true) + + select { + case states := <-sub.Events(): + assert.Equal(t, StatusConnecting, states["peer"].Status, "the transition must be delivered") + default: + t.Fatal("a transition must not be dropped because a later snapshot was dropped") + } +} + +// Latency is only measured over a direct connection. Once it is gone the last +// sample describes a path the traffic no longer takes, and must not be compared +// against other routing peers as if it were current. +func TestLatencyClearedWithDirectConnection(t *testing.T) { + const key = "abc" + + newStatus := func(t *testing.T) *Status { + t.Helper() + status := NewRecorder("https://mgm") + require.NoError(t, status.AddPeer(key, "peer-a.netbird.local", "10.10.10.10", "")) + require.NoError(t, status.UpdatePeerICEState(State{PubKey: key, ConnStatus: StatusConnected})) + require.NoError(t, status.UpdateLatency(key, LatencySample{Latency: 20 * time.Millisecond, Noise: time.Millisecond})) + return status + } + + assertCleared := func(t *testing.T, status *Status) { + t.Helper() + state, err := status.GetPeer(key) + require.NoError(t, err) + assert.Zero(t, state.Latency, "the latency of the lost direct path must be cleared") + assert.Zero(t, state.LatencyNoise, "the noise of the lost direct path must be cleared") + } + + t.Run("ice disconnected, relayed", func(t *testing.T) { + status := newStatus(t) + require.NoError(t, status.UpdatePeerICEStateToDisconnected(State{PubKey: key, ConnStatus: StatusConnected, Relayed: true})) + assertCleared(t, status) + }) + + t.Run("connection closed", func(t *testing.T) { + status := newStatus(t) + require.NoError(t, status.UpdatePeerState(State{PubKey: key, ConnStatus: StatusIdle})) + assertCleared(t, status) + }) +} + func TestUpdatePeerState(t *testing.T) { key := "abc" ip := "10.10.10.10" diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go index 5979e9bdc..257164bbd 100644 --- a/client/internal/peer/worker_ice.go +++ b/client/internal/peer/worker_ice.go @@ -3,6 +3,7 @@ package peer import ( "context" "fmt" + "math" "net" "strconv" "sync" @@ -20,6 +21,26 @@ import ( "github.com/netbirdio/netbird/route" ) +const ( + // latencySampleInterval is how often the round trip time of the selected + // candidate pair is read. The ICE agent refreshes it on every consent + // binding request, so sampling is cheap and needs no extra traffic. + latencySampleInterval = 4 * time.Second + + // latencyEWMAAlpha is the weight of a new sample in the exponentially + // weighted moving average. At latencySampleInterval this yields a time + // constant of roughly 27 seconds: a sustained change is reflected within + // tens of seconds, while the residual movement on a jittery path stays + // below the switch margin that route selection applies, so noise alone + // cannot move a route. Raising it widens that residual movement. + latencyEWMAAlpha = 0.15 +) + +// latencySmoothedNoiseFactor converts the spread of the raw samples into the +// spread of the smoothed value: for an exponentially weighted moving average +// with weight a, the variance of the output is a/(2-a) of the input variance. +var latencySmoothedNoiseFactor = math.Sqrt(latencyEWMAAlpha / (2 - latencyEWMAAlpha)) + type ICEConnInfo struct { RemoteConn net.Conn RosenpassPubKey []byte @@ -137,7 +158,7 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) { w.log.Debugf("recreate ICE agent: %s / %s", w.sessionID, *remoteOfferAnswer.SessionID) } dialerCtx, dialerCancel := context.WithCancel(w.ctx) - agent, err := w.reCreateAgent(dialerCancel, preferredCandidateTypes) + agent, sampleNow, err := w.reCreateAgent(dialerCancel, preferredCandidateTypes) if err != nil { w.log.Errorf("failed to recreate ICE Agent: %s", err) return @@ -153,7 +174,7 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) { // Capture the cancel func at spawn time: connect reads it from the argument // instead of the field, which a newer OnNewOffer may already have replaced. - go w.connect(dialerCtx, dialerCancel, agent, remoteOfferAnswer) + go w.connect(dialerCtx, dialerCancel, agent, sampleNow, remoteOfferAnswer) } // OnRemoteCandidate Handles ICE connection Candidate provided by the remote peer. @@ -217,29 +238,33 @@ func (w *WorkerICE) Close() { w.abandonNegotiation() } -func (w *WorkerICE) reCreateAgent(dialerCancel context.CancelFunc, candidates []ice.CandidateType) (*icemaker.ThreadSafeAgent, error) { +// reCreateAgent creates a new ICE agent with its callbacks registered. The +// returned channel wakes that agent's latency sampler when its selected +// candidate pair changes. +func (w *WorkerICE) reCreateAgent(dialerCancel context.CancelFunc, candidates []ice.CandidateType) (*icemaker.ThreadSafeAgent, chan struct{}, error) { w.portForwardAttempted = false agent, err := icemaker.NewAgent(w.ctx, w.iFaceDiscover, w.config.ICEConfig, candidates, w.localUfrag, w.localPwd) if err != nil { - return nil, fmt.Errorf("create agent: %w", err) + return nil, nil, fmt.Errorf("create agent: %w", err) } if err := agent.OnCandidate(w.onICECandidate); err != nil { - return nil, err + return nil, nil, err } if err := agent.OnConnectionStateChange(w.onConnectionStateChange(agent, dialerCancel)); err != nil { - return nil, err + return nil, nil, err } - if err := agent.OnSelectedCandidatePairChange(func(c1, c2 ice.Candidate) { - w.onICESelectedCandidatePair(agent, c1, c2) - }); err != nil { - return nil, err + // one channel per agent, so the sampler of a replaced agent cannot take a + // wakeup meant for the new one + sampleNow := make(chan struct{}, 1) + if err := agent.OnSelectedCandidatePairChange(w.onICESelectedCandidatePair(sampleNow)); err != nil { + return nil, nil, err } - return agent, nil + return agent, sampleNow, nil } func (w *WorkerICE) SessionID() ICESessionID { @@ -252,7 +277,7 @@ func (w *WorkerICE) SessionID() ICESessionID { // will block until connection succeeded // but it won't release if ICE Agent went into Disconnected or Failed state, // so we have to cancel it with the provided context once agent detected a broken connection -func (w *WorkerICE) connect(ctx context.Context, dialerCancel context.CancelFunc, agent *icemaker.ThreadSafeAgent, remoteOfferAnswer *OfferAnswer) { +func (w *WorkerICE) connect(ctx context.Context, dialerCancel context.CancelFunc, agent *icemaker.ThreadSafeAgent, sampleNow <-chan struct{}, remoteOfferAnswer *OfferAnswer) { w.log.Debugf("gather candidates") if err := agent.GatherCandidates(); err != nil { w.log.Warnf("failed to gather candidates: %s", err) @@ -343,6 +368,9 @@ func (w *WorkerICE) connect(ctx context.Context, dialerCancel context.CancelFunc w.lastSuccess = time.Now() w.muxAgent.Unlock() + // ctx is the agent's dialer context, so sampling stops when the agent is closed + go w.sampleLatency(ctx, agent, sampleNow) + // todo: the potential problem is a race between the onConnectionStateChange // and the delivery below: after this unlock, a newer offer can replace // w.agent before onICEConnectionIsReady runs, delivering this (now stale) @@ -532,21 +560,63 @@ func (w *WorkerICE) createForwardedCandidate(srflxCandidate ice.Candidate, mappi return candidate, nil } -func (w *WorkerICE) onICESelectedCandidatePair(agent *icemaker.ThreadSafeAgent, c1, c2 ice.Candidate) { - w.log.Debugf("selected candidate pair [local <-> remote] -> [%s <-> %s], peer %s", c1.String(), c2.String(), - w.config.Key) +func (w *WorkerICE) onICESelectedCandidatePair(sampleNow chan<- struct{}) func(c1, c2 ice.Candidate) { + return func(c1, c2 ice.Candidate) { + w.log.Debugf("selected candidate pair [local <-> remote] -> [%s <-> %s], peer %s", c1.String(), c2.String(), + w.config.Key) + // refresh the recorded latency right away: it still describes the previous + // path, and route selection may run before the next sampling tick + select { + case sampleNow <- struct{}{}: + default: + } + } +} + +// sampleLatency records the smoothed round trip time of the agent's selected +// candidate pair until the agent is closed. Route selection compares the +// recorded latencies of high availability peers, so it has to keep following +// the path instead of freezing at the value observed when the pair was chosen. +func (w *WorkerICE) sampleLatency(ctx context.Context, agent *icemaker.ThreadSafeAgent, sampleNow <-chan struct{}) { + var sampler latencySampler + + ticker := time.NewTicker(latencySampleInterval) + defer ticker.Stop() + + for { + w.updateLatency(ctx, agent, &sampler) + + select { + case <-ctx.Done(): + return + case <-ticker.C: + case <-sampleNow: + } + } +} + +func (w *WorkerICE) updateLatency(ctx context.Context, agent *icemaker.ThreadSafeAgent, sampler *latencySampler) { pairStat, ok := agent.GetSelectedCandidatePairStats() if !ok { - w.log.Warnf("failed to get selected candidate pair stats") + w.log.Debugf("failed to get selected candidate pair stats") return } - duration := time.Duration(pairStat.CurrentRoundTripTime * float64(time.Second)) - if err := w.statusRecorder.UpdateLatency(w.config.Key, duration); err != nil { - w.log.Debugf("failed to update latency for peer: %s", err) + sample := sampler.observe(pairStat) + if sample.Latency <= 0 { return } + + // the agent may have been closed while sampling, and its connection's + // latency cleared: a late sample must not bring the stale value back + if ctx.Err() != nil { + return + } + + if err := w.statusRecorder.UpdateLatency(w.config.Key, sample); err != nil { + w.log.Debugf("failed to update latency for peer: %s", err) + } } func (w *WorkerICE) logSuccessfulPaths(agent *icemaker.ThreadSafeAgent) { @@ -612,6 +682,59 @@ func (w *WorkerICE) agentDial(ctx context.Context, agent *icemaker.ThreadSafeAge } } +// latencySampler smooths the round trip time samples of a single ICE agent and +// tracks how much they scatter, so consumers can tell a real difference between +// two paths from the noise of a jittery one. +type latencySampler struct { + smoothed time.Duration + deviation time.Duration + localID string + remoteID string +} + +// observe folds a candidate pair stat into the moving average and returns the +// smoothed latency with the uncertainty of that estimate. A different candidate +// pair starts over: the path changed, so the previous samples describe a route +// that no longer carries the traffic. +func (s *latencySampler) observe(stat ice.CandidatePairStats) LatencySample { + rtt := time.Duration(stat.CurrentRoundTripTime * float64(time.Second)) + if rtt <= 0 { + return LatencySample{} + } + + if s.smoothed <= 0 || s.localID != stat.LocalCandidateID || s.remoteID != stat.RemoteCandidateID { + s.localID = stat.LocalCandidateID + s.remoteID = stat.RemoteCandidateID + s.smoothed = rtt + // A fresh path is described by a single raw sample, which is exactly + // when the estimate deserves the least trust: a lucky outlier reported + // with no uncertainty could move a route on its own. Seed the deviation + // at half the observed value so the reported noise stays conservative + // until real samples accumulate, and keep a larger deviation carried + // over from the previous path, since the link's jitter usually outlives + // a candidate pair change. + if seed := rtt / 2; s.deviation < seed { + s.deviation = seed + } + return s.sample() + } + + // mean absolute deviation of the samples around the average, the same + // estimator TCP keeps next to its smoothed round trip time + delta := absDuration(rtt - s.smoothed) + s.deviation = time.Duration(latencyEWMAAlpha*float64(delta) + (1-latencyEWMAAlpha)*float64(s.deviation)) + s.smoothed = time.Duration(latencyEWMAAlpha*float64(rtt) + (1-latencyEWMAAlpha)*float64(s.smoothed)) + + return s.sample() +} + +func (s *latencySampler) sample() LatencySample { + return LatencySample{ + Latency: s.smoothed, + Noise: time.Duration(latencySmoothedNoiseFactor * float64(s.deviation)), + } +} + func shouldAddExtraCandidate(candidate ice.Candidate) bool { if candidate.Type() != ice.CandidateTypeServerReflexive { return false diff --git a/client/internal/peer/worker_ice_test.go b/client/internal/peer/worker_ice_test.go new file mode 100644 index 000000000..3089841c2 --- /dev/null +++ b/client/internal/peer/worker_ice_test.go @@ -0,0 +1,168 @@ +package peer + +import ( + "testing" + "time" + + "github.com/pion/ice/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func pairStat(local, remote string, rtt time.Duration) ice.CandidatePairStats { + return ice.CandidatePairStats{ + LocalCandidateID: local, + RemoteCandidateID: remote, + CurrentRoundTripTime: rtt.Seconds(), + } +} + +func TestLatencySamplerObserve(t *testing.T) { + t.Run("first sample is taken as is", func(t *testing.T) { + var s latencySampler + + got := s.observe(pairStat("local", "remote", 40*time.Millisecond)) + assert.Equal(t, 40*time.Millisecond, got.Latency, "the first sample has nothing to average with") + }) + + t.Run("zero round trip time is not recorded", func(t *testing.T) { + var s latencySampler + + require.Zero(t, s.observe(pairStat("local", "remote", 0)).Latency, "a pair without an RTT sample yields no latency") + + // the first real sample still seeds the average + assert.Equal(t, 20*time.Millisecond, s.observe(pairStat("local", "remote", 20*time.Millisecond)).Latency) + }) + + t.Run("single spike is damped", func(t *testing.T) { + var s latencySampler + + s.observe(pairStat("local", "remote", 20*time.Millisecond)) + spiked := s.observe(pairStat("local", "remote", 220*time.Millisecond)).Latency + + // a single sample only contributes its weight of the 200ms delta + assert.Equal(t, 50*time.Millisecond, spiked, "a single spike must not be reported in full") + + // and it decays again once the path is back to normal + for range 10 { + s.observe(pairStat("local", "remote", 20*time.Millisecond)) + } + assert.Less(t, s.observe(pairStat("local", "remote", 20*time.Millisecond)).Latency, 30*time.Millisecond, + "the average should return towards the steady state") + }) + + t.Run("sustained change is followed", func(t *testing.T) { + var s latencySampler + + s.observe(pairStat("local", "remote", 20*time.Millisecond)) + + // the route selection switch margin, see routemanager/client + const switchMargin = 20 * time.Millisecond + + // at a 4s sample interval, 3 samples are about 12 seconds + var latency time.Duration + for range 3 { + latency = s.observe(pairStat("local", "remote", 80*time.Millisecond)).Latency + } + assert.Greater(t, latency-20*time.Millisecond, switchMargin, + "a sustained 60ms degradation must clear the switch margin within seconds") + + // and 10 more samples, about 40 seconds in total, get most of the way there + for range 10 { + latency = s.observe(pairStat("local", "remote", 80*time.Millisecond)).Latency + } + assert.Greater(t, latency, 70*time.Millisecond, "a sustained degradation must be tracked within tens of seconds") + }) + + // Smoothing narrows the scatter of a jittery path but leaves a residue, and + // that residue is what route selection has to compare its margins against. + // The reported noise has to describe it, so it has to grow with the jitter + // and cover the movement of the smoothed value. + t.Run("reported noise reflects the jitter of the path", func(t *testing.T) { + // 100ms +/- 40ms, the same shape the netem jitter test uses + jittery := []int{ + 104, 138, 61, 96, 141, 72, 118, 65, 133, 88, + 59, 127, 92, 136, 68, 111, 143, 74, 99, 121, + } + steady := []int{ + 100, 101, 99, 100, 102, 99, 100, 101, 100, 99, 101, 100, 99, 100, 101, 100, 99, 101, 100, 100, + 101, 99, 100, 102, 99, 100, 101, 100, 99, 101, 100, 99, 100, 101, 100, 99, 101, 100, 100, 101, + } + + spreadAndNoise := func(raw []int) (spread, noise time.Duration) { + var s latencySampler + var minSmoothed, maxSmoothed time.Duration + + for i, ms := range raw { + got := s.observe(pairStat("local", "remote", time.Duration(ms)*time.Millisecond)) + noise = got.Noise + + // skip the warmup, the first sample is taken as is + if i < 5 { + continue + } + if minSmoothed == 0 || got.Latency < minSmoothed { + minSmoothed = got.Latency + } + if got.Latency > maxSmoothed { + maxSmoothed = got.Latency + } + } + + return maxSmoothed - minSmoothed, noise + } + + jitterySpread, jitteryNoise := spreadAndNoise(jittery) + steadySpread, steadyNoise := spreadAndNoise(steady) + + assert.Greater(t, jitteryNoise, steadyNoise, "a jittery path must report more noise than a steady one") + assert.Less(t, steadyNoise, time.Millisecond, "a steady path must report almost no noise, so the fixed margins decide") + + // the noise is a deviation, so a few of them have to cover the movement + // of the smoothed value, which is what the switch margin relies on + assert.Greater(t, 4*jitteryNoise, jitterySpread, + "reported noise %v must account for the smoothed spread %v", jitteryNoise, jitterySpread) + assert.Greater(t, 4*steadyNoise+time.Millisecond, steadySpread, + "reported noise %v must account for the smoothed spread %v", steadyNoise, steadySpread) + }) + + t.Run("new candidate pair discards the previous average", func(t *testing.T) { + var s latencySampler + + s.observe(pairStat("local", "remote", 200*time.Millisecond)) + got := s.observe(pairStat("local2", "remote2", 20*time.Millisecond)).Latency + + assert.Equal(t, 20*time.Millisecond, got, "samples of the previous path must not be carried over") + }) + + // A single raw sample is when the estimate deserves the least trust: with + // zero reported noise, a lucky outlier could clear the switch margins and + // move a route on its own. + t.Run("a fresh path reports conservative noise", func(t *testing.T) { + var s latencySampler + + first := s.observe(pairStat("local", "remote", 100*time.Millisecond)) + assert.Greater(t, first.Noise, 10*time.Millisecond, "a single-sample estimate must not be reported as noise-free") + + // steady samples shrink the uncertainty again + var got LatencySample + for range 40 { + got = s.observe(pairStat("local", "remote", 100*time.Millisecond)) + } + assert.Less(t, got.Noise, 2*time.Millisecond, "steady samples should earn the estimate its trust back") + }) + + t.Run("a pair change keeps the larger deviation of a jittery link", func(t *testing.T) { + var s latencySampler + + // build up a deviation well above the reseed floor of the new path + for _, ms := range []int{100, 180, 60, 170, 50, 190, 70, 160} { + s.observe(pairStat("local", "remote", time.Duration(ms)*time.Millisecond)) + } + before := s.deviation + require.Greater(t, before, 25*time.Millisecond, "the jittery path should have accumulated deviation") + + s.observe(pairStat("local2", "remote2", 50*time.Millisecond)) + assert.GreaterOrEqual(t, s.deviation, before, "a pair change must not shrink the known jitter of the link") + }) +} diff --git a/client/internal/routemanager/client/client.go b/client/internal/routemanager/client/client.go index 973cf1ab8..b882c4d4a 100644 --- a/client/internal/routemanager/client/client.go +++ b/client/internal/routemanager/client/client.go @@ -3,8 +3,10 @@ package client import ( "context" "fmt" + "math" "net/netip" "reflect" + "sync" "time" log "github.com/sirupsen/logrus" @@ -26,6 +28,48 @@ const ( handlerTypeStatic ) +const ( + // latencySwitchMinGain is the absolute latency improvement an alternative + // routing peer must show before an otherwise equivalent current peer is + // replaced. An absolute margin alone would be meaningless on slow paths, + // hence latencySwitchGainPercent on top. + latencySwitchMinGain = 20 * time.Millisecond + + // latencySwitchGainPercent is the relative latency improvement required in + // addition to latencySwitchMinGain. A relative margin alone would churn on + // fast paths, where a few milliseconds are a large fraction of the latency. + latencySwitchGainPercent = 20 + + // latencyNoiseSigmas is how many standard deviations of the measurement + // noise a latency gain has to exceed. Smoothing narrows the scatter of a + // jittery path but does not remove it, so on such a path two equivalent + // peers keep producing differences of tens of milliseconds. Requiring the + // gain to stand out from that scatter is what separates a peer that is + // really closer from one that only looks closer right now. + latencyNoiseSigmas = 4 + + // latencySwitchMinDwell is how long a routing peer is kept before latency + // can move the choice again. Changes in connection status, metric or relay + // state ignore it so failover is never delayed. + latencySwitchMinDwell = 30 * time.Second + + // routeReevalInterval is how often routes are re-evaluated without an + // incoming peer state change. A switch deferred by latencySwitchMinDwell, + // or a latency that drifts in steps too small to notify, would otherwise + // wait for an unrelated event to be reconsidered. + routeReevalInterval = latencySwitchMinDwell + + // unknownLatency ranks peers without a latency sample below any peer with a + // real measurement, without excluding them from selection. It doubles as the + // upper bound for samples, so a pathological value cannot dominate, and sits + // well above any usable path so that slow paths keep their order. + unknownLatency = 10 * time.Second + + // holdReportInterval is how long a routing peer held back by a switch rule + // is reported at debug level only, after being reported at info level once. + holdReportInterval = 10 * time.Minute +) + type reason int const ( @@ -36,10 +80,133 @@ const ( reasonHA ) +// switchReason describes why a routing peer replaced the current one. +type switchReason string + +const ( + switchReasonNone switchReason = "" + switchReasonUnavailable switchReason = "current routing peer unavailable" + switchReasonConnected switchReason = "connected peer preferred over idle" + switchReasonMetric switchReason = "lower route metric" + switchReasonDirect switchReason = "direct connection preferred over relayed" + switchReasonLatency switchReason = "lower latency" + switchReasonRouteChanged switchReason = "route assigned to a different routing peer" +) + +// holdKind is the rule that keeps the current routing peer over a better one. +type holdKind int + +const ( + holdLatencyMargin holdKind = iota + holdLatencyDwell + holdRelayedDelay +) + type routerPeerStatus struct { status peer.ConnStatus relayed bool latency time.Duration + // noise is the uncertainty of latency, see peer.LatencySample + noise time.Duration +} + +func (s routerPeerStatus) isDirect() bool { + return s.status == peer.StatusConnected && !s.relayed +} + +// routeCandidate is a route considered for selection, together with the +// selection criteria derived from the state of its routing peer. +type routeCandidate struct { + id route.ID + peer string + metric int + connected bool + relayed bool + latency time.Duration + noise time.Duration +} + +func newRouteCandidate(r *route.Route, status routerPeerStatus) routeCandidate { + latency := status.latency + if latency <= 0 || latency > unknownLatency { + latency = unknownLatency + } + + return routeCandidate{ + id: r.ID, + peer: r.Peer, + metric: r.Metric, + connected: status.status == peer.StatusConnected, + relayed: status.relayed, + latency: latency, + noise: status.noise, + } +} + +// tierCompare orders c against o on the categorical criteria: connection status, +// then route metric, then direct versus relayed. It returns a positive number +// when c outranks o, a negative one when o outranks c, and zero when the two are +// equivalent. Latency is excluded on purpose: it is the tie-break applied by +// betterThan and the only criterion the switch margins in shouldSwitchRoute +// apply to. +func (c routeCandidate) tierCompare(o routeCandidate) int { + if c.connected != o.connected { + if c.connected { + return 1 + } + return -1 + } + if c.metric != o.metric { + return o.metric - c.metric + } + if c.relayed != o.relayed { + if c.relayed { + return -1 + } + return 1 + } + + return 0 +} + +func (c routeCandidate) isDirect() bool { + return c.connected && !c.relayed +} + +func (c routeCandidate) tierBetterThan(o routeCandidate) bool { + return c.tierCompare(o) > 0 +} + +// winsOnlyOnRelay reports whether c outranks o solely by being direct while o +// is relayed, with connection status and metric equal. +func (c routeCandidate) winsOnlyOnRelay(o routeCandidate) bool { + return c.connected == o.connected && c.metric == o.metric && o.relayed && !c.relayed +} + +// switchReasonOver names the criterion on which c replaces the current o. +func (c routeCandidate) switchReasonOver(o routeCandidate) switchReason { + switch { + case c.connected != o.connected: + return switchReasonConnected + case c.metric != o.metric: + return switchReasonMetric + case c.relayed != o.relayed: + return switchReasonDirect + default: + return switchReasonLatency + } +} + +func (c routeCandidate) betterThan(o routeCandidate) bool { + if tier := c.tierCompare(o); tier != 0 { + return tier > 0 + } + + return c.latency < o.latency +} + +func (c routeCandidate) String() string { + return fmt.Sprintf("connected: %t, metric: %d, relayed: %t, latency: %v +/- %v", c.connected, c.metric, c.relayed, c.latency, c.noise) } type RoutesUpdate struct { @@ -69,18 +236,72 @@ type WatcherConfig struct { // Once stopped, it cannot be reused. // The methods are not thread-safe and should be synchronized externally. type Watcher struct { - ctx context.Context - cancel context.CancelFunc - statusRecorder *peer.Status - wgInterface iface.WGIface - routes map[route.ID]*route.Route - routeUpdate chan RoutesUpdate - peerStateUpdate chan map[string]peer.RouterState + ctx context.Context + cancel context.CancelFunc + statusRecorder *peer.Status + wgInterface iface.WGIface + routes map[route.ID]*route.Route + routeUpdate chan RoutesUpdate + // peerStateUpdate holds at most one pending routing peer state notification, + // see watchPeerStatusChanges + peerStateUpdate chan struct{} routePeersNotifiers map[string]chan struct{} // map of peer key to channel for peer state changes currentChosen *route.Route currentChosenStatus *routerPeerStatus - handler RouteHandler - updateSerial uint64 + // lastSwitch is when currentChosen was last replaced by a latency-driven + // switch, used to enforce latencySwitchMinDwell. Categorical switches + // (failover, metric, relay state) leave it untouched: the dwell exists to + // stop latency ping-pong, and arming it on a failover would delay the + // return to a recovered better peer. + lastSwitch time.Time + // latencySwitch marks the evaluation's switch decision as latency-driven; + // set by shouldSwitchRoute, consumed when the switch is applied + latencySwitch bool + // switchReason describes why the evaluation replaced the current routing + // peer; set by getBestRouteFromStatuses, consumed when the switch is applied + switchReason switchReason + // relayedSwitch is how long a current routing peer that dropped from direct + // to relayed is kept over an otherwise equivalent direct one. The zero value + // switches at once. + relayedSwitch relayedSwitchPolicy + // trackedCurrent, trackedPeer and trackedDirect are the current route, its + // routing peer and whether that peer was connected directly as of the + // previous evaluation, used to tell a peer that dropped to relay while + // carrying the route from one that was never direct + trackedCurrent route.ID + trackedPeer string + trackedDirect bool + // degradedSince is when the current routing peer dropped from direct to + // relayed, zero if it is direct or was already relayed when chosen + degradedSince time.Time + // relayedHold fires when a relayed switch being held back becomes due, so it + // is applied on time rather than on the next periodic re-evaluation + relayedHold *time.Timer + // heldCandidate and heldKind identify the alternative last held back by the + // switch rules and the rule holding it, and heldReported is when that was + // reported at info level. Routes are re-evaluated on every latency change, + // so a hold is reported once per episode instead of on every evaluation. + heldCandidate route.ID + heldKind holdKind + heldReported time.Time + // reportedUnassigned tracks that the current no-routing-peer episode has + // been reported, so the periodic re-evaluation does not repeat it + reportedUnassigned bool + // done is closed when the Start loop exits, so Stop can wait for it before + // removing the routes the loop might still be updating + done chan struct{} + // lifecycleMu orders Start against Stop: Start runs in its own goroutine + // and may begin after Stop, or never, so Stop only waits for done when the + // loop has actually started + lifecycleMu sync.Mutex + started bool + stopped bool + handler RouteHandler + updateSerial uint64 +} + +func newPeerStateUpdate() chan struct{} { + return make(chan struct{}, 1) } func NewWatcher(config WatcherConfig) *Watcher { @@ -94,9 +315,11 @@ func NewWatcher(config WatcherConfig) *Watcher { routes: make(map[route.ID]*route.Route), routePeersNotifiers: make(map[string]chan struct{}), routeUpdate: make(chan RoutesUpdate), - peerStateUpdate: make(chan map[string]peer.RouterState), + peerStateUpdate: newPeerStateUpdate(), + done: make(chan struct{}), handler: config.Handler, currentChosenStatus: nil, + relayedSwitch: relayedSwitch(), } return client } @@ -113,23 +336,7 @@ func (w *Watcher) getRouterPeerStatuses() map[route.ID]routerPeerStatus { status: peerStatus.ConnStatus, relayed: peerStatus.Relayed, latency: peerStatus.Latency, - } - } - return routePeerStatuses -} - -func (w *Watcher) convertRouterPeerStatuses(states map[string]peer.RouterState) map[route.ID]routerPeerStatus { - routePeerStatuses := make(map[route.ID]routerPeerStatus) - for _, r := range w.routes { - peerStatus, ok := states[r.Peer] - if !ok { - log.Warnf("couldn't fetch peer state: %v", r.Peer) - continue - } - routePeerStatuses[r.ID] = routerPeerStatus{ - status: peerStatus.Status, - relayed: peerStatus.Relayed, - latency: peerStatus.Latency, + noise: peerStatus.LatencyNoise, } } return routePeerStatuses @@ -139,122 +346,277 @@ func (w *Watcher) convertRouterPeerStatuses(states map[string]peer.RouterState) // within a Watcher, taking into account peer connection status, route metrics, and // preference for non-relayed and direct connections. // -// It follows these prioritization rules: +// Candidates are ranked by the categorical criteria first, in this order: // * Connection status: Both connected and idle peers are considered, but connected peers always take precedence. -// * Idle peer penalty: Idle peers receive a significant score penalty to ensure any connected peer is preferred. // * Metric: Routes with lower metrics (better) are prioritized. // * Non-relayed: Routes without relays are preferred. -// * Latency: Routes with lower latency are prioritized. -// * Allowed IPs: Idle peers can still receive allowed IPs to enable lazy connection triggering. -// * we compare the current score + 10ms to the chosen score to avoid flapping between routes -// * Stability: In case of equal scores, the currently active route (if any) is maintained. +// Peers that are equal on all of them are then ranked by latency, lowest first. +// +// Idle peers can still receive allowed IPs to enable lazy connection triggering. +// Connecting peers are skipped: they have no endpoint to assign allowed IPs to. +// +// Replacing the current route is subject to shouldSwitchRoute, so latency alone +// only moves the route when the gain is worth the interruption. In case of a +// tie, the currently active route (if any) is maintained. // // It returns the ID of the selected optimal route. func (w *Watcher) getBestRouteFromStatuses(routePeerStatuses map[route.ID]routerPeerStatus) (route.ID, routerPeerStatus) { - var chosen route.ID - chosenScore := float64(0) - currScore := float64(0) - var currID route.ID if w.currentChosen != nil { currID = w.currentChosen.ID } - var chosenStatus routerPeerStatus + var best, current routeCandidate + var haveBest, haveCurrent bool for _, r := range w.routes { - tempScore := float64(0) peerStatus, found := routePeerStatuses[r.ID] - // connecting status equals disconnected: no wireguard endpoint to assign allowed IPs to + // connecting status equals disconnected: no endpoint to assign allowed IPs to if !found || peerStatus.status == peer.StatusConnecting { continue } - if r.Metric < route.MaxMetric { - metricDiff := route.MaxMetric - r.Metric - tempScore = float64(metricDiff) * 10 - } - - // in some temporal cases, latency can be 0, so we set it to 999ms to not block but try to avoid this route - latency := 999 * time.Millisecond - if peerStatus.latency != 0 { - latency = peerStatus.latency - } else if !peerStatus.relayed && peerStatus.status != peer.StatusIdle { - log.Tracef("peer %s has 0 latency: [%v]", r.Peer, w.handler) - } - - // avoid negative tempScore on the higher latency calculation - if latency > 1*time.Second { - latency = 999 * time.Millisecond - } - - // higher latency is worse score - tempScore += 1 - latency.Seconds() - - // apply significant penalty for idle peers to ensure connected peers always take precedence - if peerStatus.status == peer.StatusConnected { - tempScore += 100_000 - } - - if !peerStatus.relayed { - tempScore++ - } - - if tempScore > chosenScore || (tempScore == chosenScore && chosen == "") { - chosen = r.ID - chosenStatus = peerStatus - chosenScore = tempScore - } - - if chosen == "" && currID == "" { - chosen = r.ID - chosenStatus = peerStatus - chosenScore = tempScore - } - + candidate := newRouteCandidate(r, peerStatus) if r.ID == currID { - currScore = tempScore + current, haveCurrent = candidate, true + } + if !haveBest || candidate.betterThan(best) { + best, haveBest = candidate, true } } - chosenID := chosen - if chosen == "" { - chosenID = "" - } - currentID := currID - if currID == "" { - currentID = "" + // Candidates that tie must not displace the current peer: the routes are + // held in a map, so the winner of a tie would otherwise depend on iteration + // order and change from one evaluation to the next. + if haveCurrent && !best.betterThan(current) { + best = current } - log.Debugf("chosen route: %s, chosen score: %f, current route: %s, current score: %f", chosenID, chosenScore, currentID, currScore) + w.trackRelayed(current, haveCurrent) + + if !haveBest { + w.reportUnassigned() + w.heldCandidate = "" + + return "", routerPeerStatus{} + } + w.reportedUnassigned = false + + if haveCurrent && current.id != best.id { + if log.IsLevelEnabled(log.DebugLevel) { + log.Debugf("best routing peer for [%v] is %s (%s), current is %s (%s)", + w.handler, best.peer, best, current.peer, current) + } + + if !w.shouldSwitchRoute(current, best) { + return current.id, routePeerStatuses[current.id] + } + } switch { - case chosen == "": - var peers []string - for _, r := range w.routes { - peers = append(peers, r.Peer) - } - - log.Infof("network [%v] has not been assigned a routing peer as no peers from the list %s are currently available", w.handler, peers) - case chosen != currID: - // we compare the current score + 10ms to the chosen score to avoid flapping between routes - if currScore != 0 && currScore+0.01 > chosenScore { - log.Debugf("keeping current routing peer %s for [%v]: the score difference with latency is less than 0.01(10ms): current: %f, new: %f", - w.currentChosen.Peer, w.handler, currScore, chosenScore) - return currID, chosenStatus - } - var p string - if rt := w.routes[chosen]; rt != nil { - p = rt.Peer - } - log.Infof("New chosen route is %s with peer %s with score %f for network [%v]", chosen, p, chosenScore, w.handler) + case haveCurrent && current.id == best.id: + case haveCurrent: + w.switchReason = best.switchReasonOver(current) + log.Warnf("switching routing peer for network [%v] from %s to %s: %s (%s)", + w.handler, current.peer, best.peer, w.switchReason, best) + case w.currentChosen != nil: + w.switchReason = switchReasonUnavailable + log.Warnf("switching routing peer for network [%v] from %s to %s: %s (%s)", + w.handler, w.currentChosen.Peer, best.peer, w.switchReason, best) + default: + log.Infof("new chosen route is %s with peer %s for network [%v]: %s", + best.id, best.peer, w.handler, best) } - return chosen, chosenStatus + return best.id, routePeerStatuses[best.id] } -func (w *Watcher) watchPeerStatusChanges(ctx context.Context, peerKey string, peerStateUpdate chan map[string]peer.RouterState, closer chan struct{}) { - subscription := w.statusRecorder.SubscribeToPeerStateChanges(ctx, peerKey) +// reportUnassigned reports that no routing peer is available for the network, +// once per episode: the periodic re-evaluation would otherwise repeat it for as +// long as the routing peers stay unavailable. +func (w *Watcher) reportUnassigned() { + if w.reportedUnassigned { + return + } + w.reportedUnassigned = true + + var peers []string + for _, r := range w.routes { + peers = append(peers, r.Peer) + } + log.Infof("network [%v] has not been assigned a routing peer as no peers from the list %s are currently available", w.handler, peers) +} + +// trackRelayed records when the current routing peer dropped from a direct to a +// relayed connection while carrying the route, which starts the relayedSwitch +// delay. A peer that was never direct, because it was relayed when chosen or +// came up over relay from idle, is not degraded: holding on to it would only +// keep the route on the slower path. +func (w *Watcher) trackRelayed(current routeCandidate, haveCurrent bool) { + if !haveCurrent { + w.trackedCurrent, w.trackedPeer, w.trackedDirect, w.degradedSince = "", "", false, time.Time{} + return + } + + switch { + case current.id != w.trackedCurrent, current.peer != w.trackedPeer, !current.relayed: + w.degradedSince = time.Time{} + case w.trackedDirect: + w.degradedSince = time.Now() + // a new degradation, reported as such even if an earlier one held + // back the same candidate + w.heldCandidate = "" + } + w.trackedCurrent, w.trackedPeer, w.trackedDirect = current.id, current.peer, current.isDirect() +} + +// shouldSwitchRoute reports whether the current routing peer should be replaced +// by the candidate, which outranks it. +// +// A candidate that wins on a categorical criterion (it is connected while the +// current peer is not, has a better metric, or is direct while the current peer +// is relayed) takes over immediately. The exception is a current peer that only +// just dropped from direct to relayed, see relayedSwitchDue. +// +// A candidate that only has lower latency has to beat the current peer by the +// switch margin, and the current peer has to have been in place for +// latencySwitchMinDwell. Every switch interrupts the traffic using the route, so +// a gain that is marginal, or that the measurement noise of the two paths can +// account for on its own, is not worth acting on. +// +// An approved latency-driven switch sets w.latencySwitch, which arms the dwell +// time once the switch is applied. +func (w *Watcher) shouldSwitchRoute(current, candidate routeCandidate) bool { + if candidate.winsOnlyOnRelay(current) { + return w.relayedSwitchDue(current, candidate) + } + if candidate.tierBetterThan(current) { + return true + } + + gain := current.latency - candidate.latency + if margin := switchMargin(current, candidate); gain < margin { + if level := w.holdLevel(candidate, holdLatencyMargin); log.IsLevelEnabled(level) { + log.StandardLogger().Logf(level, + "keeping routing peer %s for [%v] over %s: latency gain %v on %v is below the %v switch margin (%s)", + current.peer, w.handler, candidate.peer, gain, current.latency, margin, candidate) + } + return false + } + + if dwelled := time.Since(w.lastSwitch); dwelled < latencySwitchMinDwell { + if level := w.holdLevel(candidate, holdLatencyDwell); log.IsLevelEnabled(level) { + log.StandardLogger().Logf(level, + "keeping routing peer %s for [%v] over %s: chosen %v ago, below the %v minimum dwell time", + current.peer, w.handler, candidate.peer, dwelled.Round(time.Second), latencySwitchMinDwell) + } + return false + } + + w.latencySwitch = true + + return true +} + +// relayedSwitchDue reports whether a relayed current routing peer should give +// way to a candidate that is better only by being direct. +// +// A peer that dropped from direct to relayed while carrying the route is kept +// for the relayedSwitch delay: the relay still carries the traffic, staying +// keeps every flow through this routing peer alive, and a direct connection that +// dropped often recovers within seconds. A peer that was already relayed when +// chosen gives way at once, so the route is not stuck on the slower path when a +// direct one becomes available. A switch that is not due yet arms relayedHold so +// it is applied as soon as it becomes due. +func (w *Watcher) relayedSwitchDue(current, candidate routeCandidate) bool { + if w.degradedSince.IsZero() { + return true + } + + policy := w.relayedSwitch + if !policy.never { + remaining := policy.delay - time.Since(w.degradedSince) + if remaining <= 0 { + return true + } + w.armRelayedHold(remaining) + } + + if level := w.holdLevel(candidate, holdRelayedDelay); log.IsLevelEnabled(level) { + log.StandardLogger().Logf(level, + "keeping relayed routing peer %s for [%v] over direct %s: dropped to relay %v ago, switch delay %s", + current.peer, w.handler, candidate.peer, time.Since(w.degradedSince).Round(time.Second), policy) + } + + return false +} + +func (w *Watcher) armRelayedHold(d time.Duration) { + if w.relayedHold == nil { + w.relayedHold = time.NewTimer(d) + return + } + w.relayedHold.Reset(d) +} + +// relayedHoldC is the channel of relayedHold, nil while it was never armed so +// that selecting on it blocks. +func (w *Watcher) relayedHoldC() <-chan time.Time { + if w.relayedHold == nil { + return nil + } + return w.relayedHold.C +} + +// holdLevel is the level to report a decision to keep the current routing peer +// at. The first evaluation that holds a given candidate back under a given rule +// is reported at info level, because that is what someone wondering why a route +// did not move needs to see. The repeats stay at debug level for +// holdReportInterval, including when two near-equal peers keep trading places. +func (w *Watcher) holdLevel(candidate routeCandidate, kind holdKind) log.Level { + if w.heldCandidate == candidate.id && w.heldKind == kind && time.Since(w.heldReported) < holdReportInterval { + return log.DebugLevel + } + + w.heldCandidate, w.heldKind, w.heldReported = candidate.id, kind, time.Now() + + return log.InfoLevel +} + +// switchMargin is how much lower the candidate's latency has to be before the +// route moves: at least latencySwitchMinGain, at least latencySwitchGainPercent +// of the current latency so the margin scales with slow paths, and more than the +// combined measurement noise of the two paths. The noise term is what keeps a +// jittery path from moving the route on its own; it vanishes on a stable path, +// where the fixed margins decide. +func switchMargin(current, candidate routeCandidate) time.Duration { + margin := latencySwitchMinGain + + if relative := current.latency * latencySwitchGainPercent / 100; relative > margin { + margin = relative + } + + // the two paths vary independently, so their noise adds in quadrature + noise := time.Duration(latencyNoiseSigmas * math.Hypot(float64(current.noise), float64(candidate.noise))) + if noise > margin { + margin = noise + } + + return margin +} + +// watchPeerStatusChanges forwards the state changes of a routing peer to the +// watcher as a notification to re-evaluate. The watcher reads the current peer +// states when it gets to it, so a notification still pending covers any number +// of later changes, and the forwarder never waits for a busy watcher: that +// would stop it draining the subscription and in turn block the peer's updates. +func (w *Watcher) watchPeerStatusChanges(ctx context.Context, peerKey string, peerStateUpdate chan<- struct{}, closer chan struct{}) { + // the subscription gets its own context so a delivery blocked on it is + // released as soon as this forwarder exits, not only when the watcher stops + subCtx, cancel := context.WithCancel(ctx) + defer cancel() + + subscription := w.statusRecorder.SubscribeToPeerStateChanges(subCtx, peerKey) defer w.statusRecorder.UnsubscribePeerStateChanges(subscription) for { @@ -263,14 +625,11 @@ func (w *Watcher) watchPeerStatusChanges(ctx context.Context, peerKey string, pe return case <-closer: return - case routerStates := <-subscription.Events(): + case <-subscription.Events(): select { - case peerStateUpdate <- routerStates: + case peerStateUpdate <- struct{}{}: log.Debugf("triggered route state update for Peer: %s", peerKey) - case <-ctx.Done(): - return - case <-closer: - return + default: } } } @@ -337,6 +696,8 @@ func (w *Watcher) shouldSkipRecalculation(newChosenID route.ID, newStatus router } func (w *Watcher) recalculateRoutes(rsn reason, routerPeerStatuses map[route.ID]routerPeerStatus) error { + w.latencySwitch = false + w.switchReason = switchReasonNone newChosenID, newStatus := w.getBestRouteFromStatuses(routerPeerStatuses) // If no route is chosen, remove the route from the peer @@ -348,6 +709,9 @@ func (w *Watcher) recalculateRoutes(rsn reason, routerPeerStatuses map[route.ID] if err := w.removeAllowedIPs(w.currentChosen, rsn); err != nil { return fmt.Errorf("remove obsolete: %w", err) } + if rsn == reasonPeerUpdate { + w.routingPeerLostEvent(w.currentChosen) + } w.currentChosen = nil w.currentChosenStatus = nil @@ -360,6 +724,17 @@ func (w *Watcher) recalculateRoutes(rsn reason, routerPeerStatuses map[route.ID] return nil } + previous := w.currentChosen + newChosenRoute := w.routes[newChosenID] + // a route update can hand the same route to a different routing peer + peerReplaced := previous != nil && previous.ID == newChosenID && previous.Peer != newChosenRoute.Peer + switched := previous == nil || previous.ID != newChosenID || peerReplaced + if peerReplaced { + w.switchReason = switchReasonRouteChanged + log.Warnf("switching routing peer for network [%v] from %s to %s: %s", + w.handler, previous.Peer, newChosenRoute.Peer, w.switchReason) + } + // If the chosen route was assigned to a different peer, remove the allowed IPs first if isNew := w.currentChosen == nil; !isNew { if err := w.removeAllowedIPs(w.currentChosen, reasonHA); err != nil { @@ -367,7 +742,6 @@ func (w *Watcher) recalculateRoutes(rsn reason, routerPeerStatuses map[route.ID] } } - newChosenRoute := w.routes[newChosenID] if err := w.addAllowedIPs(newChosenRoute); err != nil { return fmt.Errorf("add new: %w", err) } @@ -377,20 +751,86 @@ func (w *Watcher) recalculateRoutes(rsn reason, routerPeerStatuses map[route.ID] w.currentChosen = newChosenRoute w.currentChosenStatus = &newStatus + if !switched { + return nil + } + + // start tracking the new routing peer from the state it was chosen in, so a + // drop to relay seen by the next evaluation counts as a degradation + w.trackedCurrent, w.trackedPeer = newChosenID, newChosenRoute.Peer + w.trackedDirect, w.degradedSince = newStatus.isDirect(), time.Time{} + if w.latencySwitch { + w.lastSwitch = time.Now() + } + if previous != nil { + w.routingPeerSwitchEvent(previous, newChosenRoute) + } return nil } -func (w *Watcher) connectEvent(route *route.Route) { - var defaultRoute bool - for _, r := range w.routes { - if r.Network.Bits() == 0 { - defaultRoute = true - break - } +// routingPeerSwitchEvent publishes a replaced routing peer as a system event, +// so it shows in the status output and debug bundles whatever the log level. +// Moving a masqueraded route resets the connections that used it. For default +// routes this adds the reason to the events of disconnectEvent and +// connectEvent, and carries no user message so it is not shown as a +// notification a second time. +func (w *Watcher) routingPeerSwitchEvent(previous, next *route.Route) { + if w.statusRecorder == nil { + return } - if !defaultRoute { + reason := w.switchReason + if reason == switchReasonNone { + reason = switchReasonUnavailable + } + + w.statusRecorder.PublishEvent( + proto.SystemEvent_WARNING, + proto.SystemEvent_NETWORK, + "Routing peer changed", + "", + map[string]string{ + "network": w.handler.String(), + "id": string(next.NetID), + "previous_peer": previous.Peer, + "peer": next.Peer, + "reason": string(reason), + }, + ) +} + +// routingPeerLostEvent publishes the loss of the last available routing peer of +// a network as a system event. Default routes are left to disconnectEvent. +func (w *Watcher) routingPeerLostEvent(previous *route.Route) { + if w.statusRecorder == nil || w.hasDefaultRoute() { + return + } + + w.statusRecorder.PublishEvent( + proto.SystemEvent_WARNING, + proto.SystemEvent_NETWORK, + "No routing peer available", + "", + map[string]string{ + "network": w.handler.String(), + "id": string(previous.NetID), + "previous_peer": previous.Peer, + }, + ) +} + +func (w *Watcher) hasDefaultRoute() bool { + for _, r := range w.routes { + if r.Network.Bits() == 0 { + return true + } + } + return false +} + +func (w *Watcher) connectEvent(route *route.Route) { + if !w.hasDefaultRoute() { return } @@ -411,15 +851,7 @@ func (w *Watcher) connectEvent(route *route.Route) { } func (w *Watcher) disconnectEvent(route *route.Route, rsn reason) { - var defaultRoute bool - for _, r := range w.routes { - if r.Network.Bits() == 0 { - defaultRoute = true - break - } - } - - if !defaultRoute { + if !w.hasDefaultRoute() { return } @@ -505,13 +937,41 @@ func (w *Watcher) classifyUpdate(update RoutesUpdate) bool { // Start is the main point of reacting on client network routing events. // All the processing related to the client network should be done here. Thread-safe. func (w *Watcher) Start() { + w.lifecycleMu.Lock() + if w.stopped || w.started { + w.lifecycleMu.Unlock() + return + } + w.started = true + w.lifecycleMu.Unlock() + + defer close(w.done) + + reeval := time.NewTicker(routeReevalInterval) + defer reeval.Stop() + defer func() { + if w.relayedHold != nil { + w.relayedHold.Stop() + } + }() + for { select { case <-w.ctx.Done(): return - case routersStates := <-w.peerStateUpdate: - routerPeerStatuses := w.convertRouterPeerStatuses(routersStates) - if err := w.recalculateRoutes(reasonPeerUpdate, routerPeerStatuses); err != nil { + case <-reeval.C: + if err := w.recalculateRoutes(reasonPeerUpdate, w.getRouterPeerStatuses()); err != nil { + log.Errorf("Failed to re-evaluate routes for network [%v]: %v", w.handler, err) + } + case <-w.relayedHoldC(): + if err := w.recalculateRoutes(reasonPeerUpdate, w.getRouterPeerStatuses()); err != nil { + log.Errorf("Failed to re-evaluate routes for network [%v]: %v", w.handler, err) + } + case <-w.peerStateUpdate: + // The notification only says that a routing peer changed. The states it + // carries can be older than ones already applied, since every routing + // peer has its own subscription, so the current states are read instead. + if err := w.recalculateRoutes(reasonPeerUpdate, w.getRouterPeerStatuses()); err != nil { log.Errorf("Failed to recalculate routes for network [%v]: %v", w.handler, err) } case update := <-w.routeUpdate: @@ -550,7 +1010,22 @@ func (w *Watcher) handleRouteUpdate(update RoutesUpdate) { func (w *Watcher) Stop() { log.Debugf("Stopping watcher for network [%v]", w.handler) + w.lifecycleMu.Lock() + if w.stopped { + w.lifecycleMu.Unlock() + return + } + w.stopped = true + started := w.started + w.lifecycleMu.Unlock() + w.cancel() + // wait for the Start loop to exit: it may be inside a recalculation, and + // cleaning up concurrently would race on currentChosen and could leave the + // allowed IPs of a route chosen after this point behind + if started { + <-w.done + } if w.currentChosen == nil { return diff --git a/client/internal/routemanager/client/client_bench_test.go b/client/internal/routemanager/client/client_bench_test.go index 668aec427..b788015db 100644 --- a/client/internal/routemanager/client/client_bench_test.go +++ b/client/internal/routemanager/client/client_bench_test.go @@ -23,6 +23,12 @@ var benchmarkTiers = []benchmarkTier{ {"Small", 100, 50, 4}, {"Medium", 1000, 200, 16}, {"Large", 5000, 500, 32}, + // the tiers below are deliberately past anything realistic: a watcher holds + // the routes of a single network, while these give one watcher every route + // in the account at once + {"ManyPeers", 20000, 500, 32}, + {"ManyRoutes", 1000, 5000, 16}, + {"ManyBoth", 20000, 5000, 32}, } type mockRouteHandler struct { @@ -136,7 +142,7 @@ func BenchmarkRecalculateRoutes(b *testing.B) { routes: routes, routePeersNotifiers: make(map[string]chan struct{}), routeUpdate: make(chan RoutesUpdate), - peerStateUpdate: make(chan map[string]peer.RouterState), + peerStateUpdate: newPeerStateUpdate(), handler: &mockRouteHandler{network: "benchmark"}, currentChosenStatus: nil, } diff --git a/client/internal/routemanager/client/client_test.go b/client/internal/routemanager/client/client_test.go index 850f6691f..764a2f74d 100644 --- a/client/internal/routemanager/client/client_test.go +++ b/client/internal/routemanager/client/client_test.go @@ -1,11 +1,17 @@ package client import ( + "context" "fmt" "net/netip" + "sync" "testing" "time" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/routemanager/common" "github.com/netbirdio/netbird/client/internal/routemanager/static" @@ -316,6 +322,145 @@ func TestGetBestrouteFromStatuses(t *testing.T) { currentRoute: "route1", expectedRouteID: "route2", }, + { + // 40ms gain clears latencySwitchMinGain but not the 20% of 300ms + name: "latency gain below the relative margin should not change the route", + statuses: map[route.ID]routerPeerStatus{ + "route1": { + status: peer.StatusConnected, + latency: 300 * time.Millisecond, + }, + "route2": { + status: peer.StatusConnected, + latency: 260 * time.Millisecond, + }, + }, + existingRoutes: map[route.ID]*route.Route{ + "route1": { + ID: "route1", + Metric: route.MaxMetric, + Peer: "peer1", + }, + "route2": { + ID: "route2", + Metric: route.MaxMetric, + Peer: "peer2", + }, + }, + currentRoute: "route1", + expectedRouteID: "route1", + }, + { + name: "latency gain above both margins should change the route", + statuses: map[route.ID]routerPeerStatus{ + "route1": { + status: peer.StatusConnected, + latency: 300 * time.Millisecond, + }, + "route2": { + status: peer.StatusConnected, + latency: 200 * time.Millisecond, + }, + }, + existingRoutes: map[route.ID]*route.Route{ + "route1": { + ID: "route1", + Metric: route.MaxMetric, + Peer: "peer1", + }, + "route2": { + ID: "route2", + Metric: route.MaxMetric, + Peer: "peer2", + }, + }, + currentRoute: "route1", + expectedRouteID: "route2", + }, + { + // 7ms gain is 58% of the current latency but below latencySwitchMinGain + name: "latency gain below the absolute margin should not change the route", + statuses: map[route.ID]routerPeerStatus{ + "route1": { + status: peer.StatusConnected, + latency: 12 * time.Millisecond, + }, + "route2": { + status: peer.StatusConnected, + latency: 5 * time.Millisecond, + }, + }, + existingRoutes: map[route.ID]*route.Route{ + "route1": { + ID: "route1", + Metric: route.MaxMetric, + Peer: "peer1", + }, + "route2": { + ID: "route2", + Metric: route.MaxMetric, + Peer: "peer2", + }, + }, + currentRoute: "route1", + expectedRouteID: "route1", + }, + { + name: "current relayed peer should be replaced by a direct peer with worse latency", + statuses: map[route.ID]routerPeerStatus{ + "route1": { + status: peer.StatusConnected, + relayed: true, + latency: 10 * time.Millisecond, + }, + "route2": { + status: peer.StatusConnected, + relayed: false, + latency: 200 * time.Millisecond, + }, + }, + existingRoutes: map[route.ID]*route.Route{ + "route1": { + ID: "route1", + Metric: route.MaxMetric, + Peer: "peer1", + }, + "route2": { + ID: "route2", + Metric: route.MaxMetric, + Peer: "peer2", + }, + }, + currentRoute: "route1", + expectedRouteID: "route2", + }, + { + name: "better metric should replace the current peer with better latency", + statuses: map[route.ID]routerPeerStatus{ + "route1": { + status: peer.StatusConnected, + latency: 10 * time.Millisecond, + }, + "route2": { + status: peer.StatusConnected, + latency: 300 * time.Millisecond, + }, + }, + existingRoutes: map[route.ID]*route.Route{ + "route1": { + ID: "route1", + Metric: route.MaxMetric, + Peer: "peer1", + }, + "route2": { + ID: "route2", + Metric: 5000, + Peer: "peer2", + }, + }, + currentRoute: "route1", + expectedRouteID: "route2", + }, { name: "current chosen route doesn't exist anymore", statuses: map[route.ID]routerPeerStatus{ @@ -828,3 +973,510 @@ func TestGetBestrouteFromStatuses(t *testing.T) { }) } } + +// newTestWatcher builds a watcher over two routes of equal metric, with route1 +// as the currently chosen one. +func newTestWatcher(t *testing.T, lastSwitch time.Time) *Watcher { + t.Helper() + + routes := map[route.ID]*route.Route{ + "route1": {ID: "route1", Metric: route.MaxMetric, Peer: "peer1"}, + "route2": {ID: "route2", Metric: route.MaxMetric, Peer: "peer2"}, + } + + params := common.HandlerParams{ + Route: &route.Route{Network: netip.MustParsePrefix("192.168.0.0/24")}, + } + + return &Watcher{ + handler: static.NewRoute(params), + routes: routes, + currentChosen: routes["route1"], + lastSwitch: lastSwitch, + } +} + +func TestLatencySwitchDwellTime(t *testing.T) { + // a gain large enough to clear both switch margins + statuses := map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 300 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 100 * time.Millisecond}, + } + + t.Run("latency switch is held during the dwell time", func(t *testing.T) { + w := newTestWatcher(t, time.Now()) + + chosen, _ := w.getBestRouteFromStatuses(statuses) + assert.Equal(t, route.ID("route1"), chosen, "route should be kept while the dwell time has not elapsed") + }) + + t.Run("latency switch is allowed after the dwell time", func(t *testing.T) { + w := newTestWatcher(t, time.Now().Add(-latencySwitchMinDwell-time.Second)) + + chosen, _ := w.getBestRouteFromStatuses(statuses) + assert.Equal(t, route.ID("route2"), chosen, "route should switch once the dwell time has elapsed") + }) + + t.Run("categorical improvement ignores the dwell time", func(t *testing.T) { + w := newTestWatcher(t, time.Now()) + + // route1 is no longer connected, so the dwell time must not delay failover + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusIdle, latency: 10 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 300 * time.Millisecond}, + }) + assert.Equal(t, route.ID("route2"), chosen, "failover must not be delayed by the dwell time") + }) +} + +// TestLatencySwitchNoiseMargin covers the noise aware margin: on a jittery path +// the smoothed latencies of two equivalent peers keep drifting apart by tens of +// milliseconds, and that difference must not move the route even though it +// clears the fixed margins. +// Measurements on slow paths must keep their order: a peer measured at 1.2s is +// better than one at 2.5s, and a peer without a sample still ranks below both. +func TestSlowPathLatenciesKeepTheirOrder(t *testing.T) { + w := newTestWatcher(t, time.Time{}) + + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 2500 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 1200 * time.Millisecond}, + }) + assert.Equal(t, route.ID("route2"), chosen, "a 1.3s gain on a 2.5s path must move the route") + + unsampled := newRouteCandidate(w.routes["route1"], routerPeerStatus{status: peer.StatusConnected}) + slow := newRouteCandidate(w.routes["route2"], routerPeerStatus{status: peer.StatusConnected, latency: 5 * time.Second}) + assert.True(t, slow.betterThan(unsampled), "a measured peer must rank above one without a sample") +} + +func TestLatencySwitchNoiseMargin(t *testing.T) { + t.Run("a gain within the measurement noise does not switch", func(t *testing.T) { + w := newTestWatcher(t, time.Time{}) + + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 120 * time.Millisecond, noise: 12 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 80 * time.Millisecond, noise: 12 * time.Millisecond}, + }) + assert.Equal(t, route.ID("route1"), chosen, "a 40ms gain between two paths jittering by 12ms is not a real difference") + }) + + t.Run("a gain beyond the measurement noise switches", func(t *testing.T) { + w := newTestWatcher(t, time.Time{}) + + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 300 * time.Millisecond, noise: 12 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 80 * time.Millisecond, noise: 12 * time.Millisecond}, + }) + assert.Equal(t, route.ID("route2"), chosen, "a 220ms gain stands out from 12ms of jitter") + }) + + // the held candidate is tracked so the decision is reported once per episode + // rather than on every re-evaluation + t.Run("holding a candidate back is tracked as one episode", func(t *testing.T) { + w := newTestWatcher(t, time.Time{}) + + within := map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 120 * time.Millisecond, noise: 12 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 80 * time.Millisecond, noise: 12 * time.Millisecond}, + } + + w.getBestRouteFromStatuses(within) + assert.Equal(t, route.ID("route2"), w.heldCandidate, "the held candidate should be recorded on the first hold") + + w.getBestRouteFromStatuses(within) + assert.Equal(t, route.ID("route2"), w.heldCandidate, "a repeated hold of the same candidate stays the same episode") + + // the smoothed latencies of two near-equal peers keep crossing: route2 + // falling behind for a moment must not start a new episode + w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 80 * time.Millisecond, noise: 12 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 120 * time.Millisecond, noise: 12 * time.Millisecond}, + }) + held := routeCandidate{id: "route2"} + assert.Equal(t, log.DebugLevel, w.holdLevel(held, holdLatencyMargin), "holding route2 back again after a crossing is a repeat") + + // a hold reported long ago is worth reporting again + w.heldReported = w.heldReported.Add(-holdReportInterval - time.Second) + assert.Equal(t, log.InfoLevel, w.holdLevel(held, holdLatencyMargin), "a hold is reported again once the report interval passed") + }) + + t.Run("a stable path is decided by the fixed margins", func(t *testing.T) { + w := newTestWatcher(t, time.Time{}) + + // the same 40ms gain as the first case, on paths that barely vary + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 120 * time.Millisecond, noise: 300 * time.Microsecond}, + "route2": {status: peer.StatusConnected, latency: 80 * time.Millisecond, noise: 300 * time.Microsecond}, + }) + assert.Equal(t, route.ID("route2"), chosen, "without jitter a 40ms gain on a 120ms path is a real improvement") + }) +} + +// recordingHandler records the allowed IPs calls a watcher makes. +type recordingHandler struct { + added []string + removed int +} + +func (h *recordingHandler) String() string { return "recording" } +func (h *recordingHandler) AddRoute(context.Context) error { return nil } +func (h *recordingHandler) RemoveRoute() error { return nil } +func (h *recordingHandler) AddAllowedIPs(peerKey string) error { + h.added = append(h.added, peerKey) + return nil +} + +func (h *recordingHandler) RemoveAllowedIPs() error { + h.removed++ + return nil +} + +func newRecalcWatcher(t *testing.T) (*Watcher, *recordingHandler) { + t.Helper() + + handler := &recordingHandler{} + routes := map[route.ID]*route.Route{ + "route1": {ID: "route1", Metric: route.MaxMetric, Peer: "peer1"}, + "route2": {ID: "route2", Metric: route.MaxMetric, Peer: "peer2"}, + } + + return &Watcher{ + statusRecorder: peer.NewRecorder("https://mgm"), + routes: routes, + handler: handler, + }, handler +} + +// TestRecalculateRoutesAssignsIdlePeer covers the lazy connection path: an idle +// routing peer has no established connection yet, but must still receive the +// allowed IPs so that traffic for the route can trigger the connection. +func TestRecalculateRoutesAssignsIdlePeer(t *testing.T) { + w, handler := newRecalcWatcher(t) + + err := w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusIdle}, + }) + require.NoError(t, err) + + assert.Equal(t, []string{"peer1"}, handler.added, "an idle routing peer should still get the allowed IPs") + require.NotNil(t, w.currentChosen, "an idle peer should be chosen when it is the only candidate") + assert.Equal(t, route.ID("route1"), w.currentChosen.ID) +} + +func TestRecalculateRoutesDwellTime(t *testing.T) { + w, handler := newRecalcWatcher(t) + + // route2 has no status yet, so route1 is the only candidate + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 300 * time.Millisecond}, + })) + require.Equal(t, []string{"peer1"}, handler.added) + require.True(t, w.lastSwitch.IsZero(), "the initial assignment is not latency-driven and must not arm the dwell time") + + betterRoute2 := map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 300 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 100 * time.Millisecond}, + } + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, betterRoute2)) + assert.Equal(t, []string{"peer1", "peer2"}, handler.added, "a latency switch with no recent switch should be allowed") + assert.Equal(t, 1, handler.removed) + require.False(t, w.lastSwitch.IsZero(), "a latency-driven switch must arm the dwell time") + + // the peers trade places: switching back is again latency-only, but now + // inside the dwell window + betterRoute1 := map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 100 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 300 * time.Millisecond}, + } + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, betterRoute1)) + assert.Equal(t, []string{"peer1", "peer2"}, handler.added, "the route should be held during the dwell time") + + w.lastSwitch = w.lastSwitch.Add(-latencySwitchMinDwell - time.Second) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, betterRoute1)) + assert.Equal(t, []string{"peer1", "peer2", "peer1"}, handler.added, "the route should switch once the dwell time elapsed") + assert.Equal(t, 2, handler.removed) + + // the switch re-armed the dwell time, which must not delay failover + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnecting, latency: 100 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 300 * time.Millisecond}, + })) + assert.Equal(t, []string{"peer1", "peer2", "peer1", "peer2"}, handler.added, "losing the routing peer must fail over regardless of the dwell time") + assert.Equal(t, 3, handler.removed) +} + +// TestFailoverDoesNotArmDwellTime covers the recovery path: a failover to a +// worse peer must not start the dwell time, or traffic would be stuck on the +// worse path for the full dwell after the better peer comes back. +func TestFailoverDoesNotArmDwellTime(t *testing.T) { + w, handler := newRecalcWatcher(t) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 20 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 200 * time.Millisecond}, + })) + require.Equal(t, []string{"peer1"}, handler.added) + + // peer1 blips into connecting: unguarded failover to the worse peer2 + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnecting, latency: 20 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 200 * time.Millisecond}, + })) + require.Equal(t, []string{"peer1", "peer2"}, handler.added, "failover to the remaining peer should happen") + require.True(t, w.lastSwitch.IsZero(), "a failover must not arm the dwell time") + + // peer1 recovers seconds later: the return is latency-only but must not be + // held back by a dwell the failover would have armed + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 20 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 200 * time.Millisecond}, + })) + assert.Equal(t, []string{"peer1", "peer2", "peer1"}, handler.added, "the recovered better peer should take the route back immediately") +} + +// blockingHandler records the allowed IPs calls in order and blocks the first +// AddAllowedIPs until released, to hold a recalculation in flight. +type blockingHandler struct { + mu sync.Mutex + calls []string + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (h *blockingHandler) String() string { return "blocking" } +func (h *blockingHandler) AddRoute(context.Context) error { return nil } +func (h *blockingHandler) RemoveRoute() error { return nil } + +func (h *blockingHandler) AddAllowedIPs(peerKey string) error { + h.once.Do(func() { + close(h.entered) + <-h.release + }) + h.record("add " + peerKey) + return nil +} + +func (h *blockingHandler) RemoveAllowedIPs() error { + h.record("remove") + return nil +} + +func (h *blockingHandler) record(call string) { + h.mu.Lock() + defer h.mu.Unlock() + h.calls = append(h.calls, call) +} + +// TestStopWaitsForStart covers the shutdown ordering: Stop must not clean up +// while the Start loop is still inside a recalculation, or it would remove the +// allowed IPs before the recalculation adds them and leave them behind. +func TestStopWaitsForStart(t *testing.T) { + w, _ := newRecalcWatcher(t) + handler := &blockingHandler{entered: make(chan struct{}), release: make(chan struct{})} + w.handler = handler + + require.NoError(t, w.statusRecorder.AddPeer("peer1", "peer1.netbird.cloud", "100.64.0.1", "")) + require.NoError(t, w.statusRecorder.UpdatePeerState(peer.State{PubKey: "peer1", ConnStatus: peer.StatusConnected})) + + w.ctx, w.cancel = context.WithCancel(context.Background()) + w.done = make(chan struct{}) + w.peerStateUpdate = make(chan struct{}) + go w.Start() + + // the unbuffered send returns once the loop took the notification, so the + // loop is running and about to recalculate + w.peerStateUpdate <- struct{}{} + select { + case <-handler.entered: + case <-time.After(2 * time.Second): + t.Fatal("the notification should start a recalculation") + } + + finished := make(chan struct{}) + go func() { + w.Stop() + close(finished) + }() + + select { + case <-finished: + t.Fatal("Stop must wait for the recalculation in flight") + case <-time.After(100 * time.Millisecond): + } + + close(handler.release) + select { + case <-finished: + case <-time.After(2 * time.Second): + t.Fatal("Stop should return once the Start loop has exited") + } + + handler.mu.Lock() + defer handler.mu.Unlock() + assert.Equal(t, []string{"add peer1", "remove"}, handler.calls, "Stop must clean up after the recalculation, not before it") +} + +// A notification carries no peer states: every routing peer has its own +// subscription and forwarder feeding the same channel, so a snapshot could +// arrive after a newer one. The watcher acts on the states in the recorder as +// of when it handles the notification. +func TestPeerStateNotificationUsesRecordedStates(t *testing.T) { + w, handler := newRecalcWatcher(t) + + recorder := w.statusRecorder + for _, key := range []string{"peer1", "peer2"} { + require.NoError(t, recorder.AddPeer(key, key+".netbird.cloud", "100.64.0.1", "")) + } + require.NoError(t, recorder.UpdatePeerICEState(peer.State{PubKey: "peer1", ConnStatus: peer.StatusConnected})) + require.NoError(t, recorder.UpdatePeerICEState(peer.State{PubKey: "peer2", ConnStatus: peer.StatusConnecting})) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, w.getRouterPeerStatuses())) + require.Equal(t, []string{"peer1"}, handler.added) + + w.ctx, w.cancel = context.WithCancel(context.Background()) + w.done = make(chan struct{}) + w.peerStateUpdate = make(chan struct{}) + go w.Start() + + require.NoError(t, recorder.UpdatePeerICEState(peer.State{PubKey: "peer1", ConnStatus: peer.StatusConnecting})) + require.NoError(t, recorder.UpdatePeerICEState(peer.State{PubKey: "peer2", ConnStatus: peer.StatusConnected})) + w.peerStateUpdate <- struct{}{} + w.Stop() + + assert.Equal(t, []string{"peer1", "peer2"}, handler.added, "the notification must move the route according to the recorded states") +} + +// While the watcher is busy, for instance inside a recalculation, the peer state +// forwarder must keep draining its subscription. Otherwise the subscription +// buffer fills up and the peer's next state update blocks its caller, the +// peer's connection handling, until the watcher is free again. +func TestPeerStateForwarderDoesNotBlockWhileWatcherIsBusy(t *testing.T) { + w, _ := newRecalcWatcher(t) + w.peerStateUpdate = newPeerStateUpdate() + + recorder := w.statusRecorder + require.NoError(t, recorder.AddPeer("peer1", "peer1.netbird.cloud", "100.64.0.1", "")) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + closer := make(chan struct{}) + go w.watchPeerStatusChanges(ctx, "peer1", w.peerStateUpdate, closer) + + // wait for the forwarder's subscription to exist, so the updates below reach it + require.Eventually(t, func() bool { + require.NoError(t, recorder.UpdatePeerICEState(peer.State{PubKey: "peer1", ConnStatus: peer.StatusConnected})) + require.NoError(t, recorder.UpdatePeerICEState(peer.State{PubKey: "peer1", ConnStatus: peer.StatusConnecting})) + select { + case <-w.peerStateUpdate: + return true + case <-time.After(50 * time.Millisecond): + return false + } + }, 2*time.Second, 10*time.Millisecond, "the forwarder must pass on a notification") + + // nobody reads peerStateUpdate: the watcher is busy + updated := make(chan struct{}) + go func() { + defer close(updated) + for i := 0; i < 50; i++ { + status := peer.StatusConnected + if i%2 == 1 { + status = peer.StatusConnecting + } + if err := recorder.UpdatePeerICEState(peer.State{PubKey: "peer1", ConnStatus: status}); err != nil { + t.Errorf("update peer state: %v", err) + return + } + } + }() + + select { + case <-updated: + case <-time.After(2 * time.Second): + t.Fatal("peer state updates must not block while the watcher is busy") + } +} + +// TestStopWithoutStart covers a watcher whose Start goroutine never ran, or ran +// only after Stop: Stop must return instead of waiting for a loop that does not +// exist, and a late Start must not begin working on a stopped watcher. +func TestStopWithoutStart(t *testing.T) { + w := NewWatcher(WatcherConfig{ + Context: context.Background(), + StatusRecorder: peer.NewRecorder("https://mgm"), + Handler: &recordingHandler{}, + }) + + finished := make(chan struct{}) + go func() { + w.Stop() + close(finished) + }() + + select { + case <-finished: + case <-time.After(2 * time.Second): + t.Fatal("Stop must return when Start was never called") + } + + started := make(chan struct{}) + go func() { + w.Start() + close(started) + }() + + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("Start after Stop must return immediately") + } +} + +// TestUnassignedNetworkReportedOncePerEpisode covers the no-routing-peer log: +// the periodic re-evaluation hits the same branch every tick, so the episode +// must be reported once, and again only after a route was assigned in between. +func TestUnassignedNetworkReportedOncePerEpisode(t *testing.T) { + w, _ := newRecalcWatcher(t) + + unassigned := map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnecting}, + } + assigned := map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 10 * time.Millisecond}, + } + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, unassigned)) + assert.True(t, w.reportedUnassigned, "the first unassigned evaluation should be reported") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, unassigned)) + assert.True(t, w.reportedUnassigned, "repeats stay within the same episode") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, assigned)) + assert.False(t, w.reportedUnassigned, "an assigned route ends the episode") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, unassigned)) + assert.True(t, w.reportedUnassigned, "losing the route again starts a new episode") +} + +// TestLatencyJitterDoesNotSwitchRoute walks two equivalent peers through jitter +// around the same mean. Neither peer is genuinely better, so the route must not +// move at any point. +func TestLatencyJitterDoesNotSwitchRoute(t *testing.T) { + w := newTestWatcher(t, time.Time{}) + + jitter := []int{-6, 3, -4, 8, -2, 5, -7, 1, 6, -3} + for i, offsetMs := range jitter { + offset := time.Duration(offsetMs) * time.Millisecond + statuses := map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 20*time.Millisecond + offset}, + "route2": {status: peer.StatusConnected, latency: 20*time.Millisecond - offset}, + } + + chosen, _ := w.getBestRouteFromStatuses(statuses) + require.Equal(t, route.ID("route1"), chosen, "route changed on jitter at sample %d", i) + } +} diff --git a/client/internal/routemanager/client/env.go b/client/internal/routemanager/client/env.go new file mode 100644 index 000000000..398667cdf --- /dev/null +++ b/client/internal/routemanager/client/env.go @@ -0,0 +1,77 @@ +package client + +import ( + "os" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + // envRelayedSwitchDelay sets how long a routing peer that dropped from a + // direct to a relayed connection while carrying the route is kept before the + // route moves to an equivalent peer with a direct connection. Takes a + // duration ("30s", "0" to switch at once) or "never" to only leave such a + // peer when it disconnects. A routing peer that was already relayed when + // chosen always gives way to a direct one at once. + envRelayedSwitchDelay = "NB_HA_RELAYED_SWITCH_DELAY" + + relayedSwitchDelayNever = "never" + + // defaultRelayedSwitchDelay covers the common case of a direct connection + // that drops to relay and recovers within seconds. Moving the route + // resets every masqueraded flow using it, staying on the relay does not. + defaultRelayedSwitchDelay = 30 * time.Second +) + +// relayedSwitchPolicy is the resolved envRelayedSwitchDelay setting. +type relayedSwitchPolicy struct { + delay time.Duration + // never keeps a connected relayed routing peer regardless of delay + never bool +} + +func (p relayedSwitchPolicy) String() string { + if p.never { + return relayedSwitchDelayNever + } + return p.delay.String() +} + +var ( + relayedSwitchOnce sync.Once + resolvedRelayedSwitch relayedSwitchPolicy +) + +// relayedSwitch returns the process-wide relayed switch policy, read from the +// environment on first use. +func relayedSwitch() relayedSwitchPolicy { + relayedSwitchOnce.Do(func() { + resolvedRelayedSwitch = parseRelayedSwitchPolicy(os.Getenv(envRelayedSwitchDelay)) + log.Infof("routing peer relayed switch delay: %s", resolvedRelayedSwitch) + }) + return resolvedRelayedSwitch +} + +func parseRelayedSwitchPolicy(raw string) relayedSwitchPolicy { + def := relayedSwitchPolicy{delay: defaultRelayedSwitchDelay} + + val := strings.TrimSpace(raw) + if val == "" { + return def + } + if strings.EqualFold(val, relayedSwitchDelayNever) { + return relayedSwitchPolicy{never: true} + } + + delay, err := time.ParseDuration(val) + if err != nil || delay < 0 { + log.Warnf("invalid %s value %q, using the default of %s: want a duration or %q", + envRelayedSwitchDelay, raw, def, relayedSwitchDelayNever) + return def + } + + return relayedSwitchPolicy{delay: delay} +} diff --git a/client/internal/routemanager/client/relayed_switch_test.go b/client/internal/routemanager/client/relayed_switch_test.go new file mode 100644 index 000000000..8f4a67bba --- /dev/null +++ b/client/internal/routemanager/client/relayed_switch_test.go @@ -0,0 +1,358 @@ +package client + +import ( + "context" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/route" +) + +func TestParseRelayedSwitchPolicy(t *testing.T) { + testCases := []struct { + raw string + want relayedSwitchPolicy + }{ + {raw: "", want: relayedSwitchPolicy{delay: defaultRelayedSwitchDelay}}, + {raw: " ", want: relayedSwitchPolicy{delay: defaultRelayedSwitchDelay}}, + {raw: "0", want: relayedSwitchPolicy{}}, + {raw: "10s", want: relayedSwitchPolicy{delay: 10 * time.Second}}, + {raw: " 2m ", want: relayedSwitchPolicy{delay: 2 * time.Minute}}, + {raw: "never", want: relayedSwitchPolicy{never: true}}, + {raw: "NEVER", want: relayedSwitchPolicy{never: true}}, + // unrecognised values keep the default rather than guessing + {raw: "-5s", want: relayedSwitchPolicy{delay: defaultRelayedSwitchDelay}}, + {raw: "soon", want: relayedSwitchPolicy{delay: defaultRelayedSwitchDelay}}, + {raw: "30", want: relayedSwitchPolicy{delay: defaultRelayedSwitchDelay}}, + } + + for _, tc := range testCases { + t.Run(tc.raw, func(t *testing.T) { + assert.Equal(t, tc.want, parseRelayedSwitchPolicy(tc.raw), "policy parsed from %q", tc.raw) + }) + } +} + +// newRelayedTestWatcher is a watcher over two equal-metric routes, currently on +// route1, for the given policy. +func newRelayedTestWatcher(t *testing.T, policy relayedSwitchPolicy) *Watcher { + t.Helper() + + w := newTestWatcher(t, time.Time{}) + w.relayedSwitch = policy + t.Cleanup(func() { + if w.relayedHold != nil { + w.relayedHold.Stop() + } + }) + return w +} + +// degrade walks the current routing peer route1 from direct to relayed, the way +// it is seen when a direct connection carrying the route drops to relay. +func degrade(t *testing.T, w *Watcher) { + t.Helper() + + chosen, _ := w.getBestRouteFromStatuses(currentDirect) + require.Equal(t, route.ID("route1"), chosen, "route1 must carry the route while direct") + + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + require.Equal(t, route.ID("route1"), chosen, "a routing peer that just dropped to relay must be kept") + require.False(t, w.degradedSince.IsZero(), "the drop to relay must be recorded") +} + +var ( + // route1 is relayed, route2 is an equivalent peer with a direct connection + currentRelayed = map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true, latency: 50 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 60 * time.Millisecond}, + } + currentDirect = map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 50 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 60 * time.Millisecond}, + } +) + +func TestDegradedRoutingPeerIsKeptDuringDelay(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) + + degrade(t, w) + require.NotNil(t, w.relayedHold, "the pending switch must arm a timer so it is applied when due") + + // the direct connection comes back within the delay: nothing moved, and the + // degraded period ends + chosen, _ := w.getBestRouteFromStatuses(currentDirect) + assert.Equal(t, route.ID("route1"), chosen, "the recovered routing peer must stay chosen") + assert.True(t, w.degradedSince.IsZero(), "the degraded period must end once the peer is direct again") +} + +func TestDegradedRoutingPeerIsReplacedAfterDelay(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) + + degrade(t, w) + w.degradedSince = w.degradedSince.Add(-31 * time.Second) + + chosen, _ := w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route2"), chosen, "a routing peer relayed for longer than the delay must give way to a direct one") + assert.Equal(t, switchReasonDirect, w.switchReason, "the switch reason must name the relayed connection") +} + +// A routing peer that was relayed when it was chosen is not degraded: when an +// equivalent direct peer shows up the route must upgrade at once instead of +// staying on the slower path for the delay. +func TestRelayedRoutingPeerUpgradesAtOnce(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{never: true}) + + // route1 was chosen while relayed and route2 was still connecting + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnecting}, + }) + require.Equal(t, route.ID("route1"), chosen) + require.True(t, w.degradedSince.IsZero(), "a peer relayed from the start is not degraded") + + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route2"), chosen, "a direct peer must take over from one that was relayed all along") + assert.Equal(t, switchReasonDirect, w.switchReason) + assert.Nil(t, w.relayedHold, "an upgrade must not schedule anything") +} + +// An idle routing peer woken by traffic whose relay connection comes up before +// the direct one was never direct: it is not degraded, and must give way to an +// equivalent direct peer at once. +func TestIdleRoutingPeerWokenOverRelayUpgradesAtOnce(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{never: true}) + + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusIdle}, + "route2": {status: peer.StatusIdle}, + }) + require.Equal(t, route.ID("route1"), chosen) + + chosen, _ = w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnecting}, + }) + require.Equal(t, route.ID("route1"), chosen) + require.True(t, w.degradedSince.IsZero(), "waking up over relay is not a drop from direct") + + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route2"), chosen, "a direct peer must take over from one that was never direct") + assert.Nil(t, w.relayedHold, "an upgrade must not schedule anything") +} + +// A routing peer that drops to relay again, after recovering from an earlier +// drop, is a new degradation and must be reported at info level again. +func TestRepeatedDegradationIsReportedAgain(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) + + degrade(t, w) + require.Equal(t, route.ID("route2"), w.heldCandidate, "the first drop must be reported") + + // the first report is well within the report interval + w.heldReported = w.heldReported.Add(-time.Minute) + firstReport := w.heldReported + + degrade(t, w) + assert.True(t, w.heldReported.After(firstReport), "a new drop to relay must be reported, not treated as a repeat") +} + +func TestDegradedRoutingPeerNeverPolicy(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{never: true}) + + degrade(t, w) + w.degradedSince = w.degradedSince.Add(-time.Hour) + + chosen, _ := w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route1"), chosen, "with never, a degraded routing peer is kept while connected") + assert.Nil(t, w.relayedHold, "with never there is nothing to schedule") +} + +func TestDegradedRoutingPeerZeroDelaySwitchesAtOnce(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{}) + + chosen, _ := w.getBestRouteFromStatuses(currentDirect) + require.Equal(t, route.ID("route1"), chosen) + + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route2"), chosen, "a zero delay must keep the immediate switch to a direct peer") +} + +// The delay only covers a candidate that is better solely by being direct. A +// better metric or a lost routing peer must move the route at once. +func TestRelayedDelayDoesNotHoldOtherSwitches(t *testing.T) { + t.Run("better metric", func(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{never: true}) + degrade(t, w) + w.routes["route2"].Metric = route.MaxMetric - 1 + + chosen, _ := w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route2"), chosen, "a better metric must win over a degraded peer immediately") + assert.Equal(t, switchReasonMetric, w.switchReason) + }) + + t.Run("current peer disconnected", func(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{never: true}) + degrade(t, w) + + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnecting, relayed: true}, + "route2": {status: peer.StatusConnected, latency: 60 * time.Millisecond}, + }) + assert.Equal(t, route.ID("route2"), chosen, "a routing peer that is gone must be replaced regardless of the delay") + assert.Equal(t, switchReasonUnavailable, w.switchReason) + }) + + t.Run("both relayed", func(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) + + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnected, relayed: true}, + }) + assert.Equal(t, route.ID("route1"), chosen, "equally relayed peers are a tie and the current one stays") + assert.Nil(t, w.relayedHold, "a tie must not schedule a switch") + }) +} + +// TestRelayedHoldTimerAppliesSwitch drives the Start loop: once the delay has +// passed, the held switch must be applied without waiting for a peer update or +// the periodic re-evaluation. +func TestRelayedHoldTimerAppliesSwitch(t *testing.T) { + w, handler := newRecalcWatcher(t) + w.relayedSwitch = relayedSwitchPolicy{delay: 50 * time.Millisecond} + + recorder := w.statusRecorder + for _, key := range []string{"peer1", "peer2"} { + require.NoError(t, recorder.AddPeer(key, key+".netbird.cloud", "100.64.0.1", "")) + } + require.NoError(t, recorder.UpdatePeerState(peer.State{PubKey: "peer1", ConnStatus: peer.StatusConnected, Relayed: true})) + require.NoError(t, recorder.UpdatePeerState(peer.State{PubKey: "peer2", ConnStatus: peer.StatusConnected})) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected}, + })) + require.Equal(t, []string{"peer1"}, handler.added) + + // route1 drops to relay while route2 is direct: held for the delay + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnected}, + })) + require.Equal(t, []string{"peer1"}, handler.added, "the switch must be held while the delay runs") + + w.ctx, w.cancel = context.WithCancel(context.Background()) + w.done = make(chan struct{}) + go w.Start() + t.Cleanup(w.Stop) + + // the loop owns the watcher state now, so observe the switch through the + // event it publishes rather than reading its fields + require.Eventually(t, func() bool { + for _, e := range recorder.GetEventHistory() { + if e.Message == "Routing peer changed" && e.Metadata["peer"] == "peer2" { + return true + } + } + return false + }, 2*time.Second, 10*time.Millisecond, "the held switch must be applied once the delay passed") +} + +// A route update can keep a route's ID and hand it to a different routing peer. +// That is a switch like any other: it is reported, and the new peer is tracked +// from its own state rather than inheriting the previous peer's. +func TestRouteReassignedToDifferentPeerIsASwitch(t *testing.T) { + w, handler := newRecalcWatcher(t) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected}, + })) + require.Equal(t, []string{"peer1"}, handler.added) + + reassigned := *w.routes["route1"] + reassigned.Peer = "peer3" + w.routes["route1"] = &reassigned + + require.NoError(t, w.recalculateRoutes(reasonRouteUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + })) + assert.Equal(t, []string{"peer1", "peer3"}, handler.added, "the allowed IPs must move to the new routing peer") + assert.Equal(t, "peer3", w.trackedPeer, "the new routing peer must be tracked") + assert.False(t, w.trackedDirect, "the new routing peer is tracked from its own state") + + events := w.statusRecorder.GetEventHistory() + require.Len(t, events, 1, "the reassignment must publish a switch event") + assert.Equal(t, "Routing peer changed", events[0].Message) + assert.Equal(t, "peer1", events[0].Metadata["previous_peer"]) + assert.Equal(t, "peer3", events[0].Metadata["peer"]) + assert.Equal(t, string(switchReasonRouteChanged), events[0].Metadata["reason"]) +} + +// Exit nodes report their own connect and disconnect events, but those carry +// no reason: the switch event is published for default routes too. +func TestDefaultRouteSwitchPublishesEvent(t *testing.T) { + w, _ := newRecalcWatcher(t) + for _, r := range w.routes { + r.Network = netip.MustParsePrefix("0.0.0.0/0") + } + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected}, + })) + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnecting}, + "route2": {status: peer.StatusConnected}, + })) + + var switchEvents []*proto.SystemEvent + for _, e := range w.statusRecorder.GetEventHistory() { + if e.Message == "Routing peer changed" { + switchEvents = append(switchEvents, e) + } + } + require.Len(t, switchEvents, 1, "an exit node switch must publish a switch event") + assert.Equal(t, string(switchReasonUnavailable), switchEvents[0].Metadata["reason"]) + assert.Empty(t, switchEvents[0].UserMessage, "the switch event must not notify the user a second time") +} + +func TestRoutingPeerSwitchPublishesEvent(t *testing.T) { + w, _ := newRecalcWatcher(t) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 20 * time.Millisecond}, + })) + assert.Empty(t, w.statusRecorder.GetEventHistory(), "the initial assignment is not a switch") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnecting}, + "route2": {status: peer.StatusConnected, latency: 20 * time.Millisecond}, + })) + + events := w.statusRecorder.GetEventHistory() + require.Len(t, events, 1, "a switch must publish exactly one event") + assert.Equal(t, proto.SystemEvent_WARNING, events[0].Severity) + assert.Equal(t, "Routing peer changed", events[0].Message) + assert.Equal(t, map[string]string{ + "network": "recording", + "id": "", + "previous_peer": "peer1", + "peer": "peer2", + "reason": string(switchReasonUnavailable), + }, events[0].Metadata, "the event must name both peers and the reason") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnecting}, + "route2": {status: peer.StatusConnecting}, + })) + + events = w.statusRecorder.GetEventHistory() + require.Len(t, events, 2, "losing the last routing peer must publish an event") + assert.Equal(t, "No routing peer available", events[1].Message) + assert.Equal(t, "peer2", events[1].Metadata["previous_peer"]) +} From dfa7bd78923c64ad5ca28a59a91546aadf911ed5 Mon Sep 17 00:00:00 2001 From: Daniele Casciani Date: Tue, 13 Oct 2026 18:03:47 +0200 Subject: [PATCH 160/209] [client] Migrate macOS cask template to Homebrew install steps (#7477) Homebrew deprecated the postflight and uninstall_preflight cask stanzas in favour of the declarative *_steps DSL, so every brew command that evaluates netbirdio/tap now prints deprecation warnings. Once the deprecation becomes a disable the generated cask stops loading and netbird-ui can no longer be installed or upgraded through Homebrew. The *_steps blocks take JSON-serialisable steps run in a sandbox rather than arbitrary Ruby, so system_command is re-expressed as run/remove. The two postflight blocks merge into one because a cask carries only a single instance, preserving the original order. set_permissions moves from a hardcoded /Applications to base: :appdir, matching what the installer invocation already did. The launchctl fallbacks keep their tolerant semantics through must_succeed: false, and remove is a no-op when the plist is absent. From 9efe59b9b2b2d4678e55ec1161be37479eb39314 Mon Sep 17 00:00:00 2001 From: Brandon Hopkins <76761586+TechHutTV@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:46:27 -0700 Subject: [PATCH 161/209] [infrastructure] Fix Traefik Versioning and Update Crowdsec (#8152) --- .../workflows/test-infrastructure-files.yml | 12 +++ .../getting-started-enterprise.sh | 82 ++++++++++++++-- infrastructure_files/getting-started.sh | 34 ++++++- infrastructure_files/migrate.sh | 10 +- .../tests/test-getting-started-enterprise.sh | 96 +++++++++++++++++++ .../tests/test-getting-started-networks.sh | 54 +++++++++++ .../tests/test-getting-started.sh | 82 ++++++++++++++++ 7 files changed, 357 insertions(+), 13 deletions(-) create mode 100644 infrastructure_files/tests/test-getting-started-enterprise.sh create mode 100644 infrastructure_files/tests/test-getting-started-networks.sh create mode 100644 infrastructure_files/tests/test-getting-started.sh diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml index 1313379ee..4875adc31 100644 --- a/.github/workflows/test-infrastructure-files.yml +++ b/.github/workflows/test-infrastructure-files.yml @@ -258,6 +258,18 @@ jobs: with: persist-credentials: false + - name: Verify enterprise Traefik tag validation + run: bash infrastructure_files/tests/test-getting-started-enterprise.sh + + - name: Verify community Traefik image validation + run: bash infrastructure_files/tests/test-getting-started.sh + + - name: Verify community Compose network scoping + run: bash infrastructure_files/tests/test-getting-started-networks.sh + + - name: Verify migration Compose network scoping + run: bash infrastructure_files/tests/test-getting-started-networks.sh --migration + - name: Verify fresh-install session cookie key hardening run: | grep -Fxq ' SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)' infrastructure_files/getting-started.sh diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index 344bd2fed..b6565bff1 100755 --- a/infrastructure_files/getting-started-enterprise.sh +++ b/infrastructure_files/getting-started-enterprise.sh @@ -1,8 +1,5 @@ #!/bin/bash -set -e -set -o pipefail - # NetBird Enterprise — Getting Started # Single-node bootstrap for a self-hosted NetBird Enterprise stack with the # embedded identity provider. Owner is created via first-login flow. @@ -23,6 +20,9 @@ PROXY_TOKEN_ID="" # server trusts X-Forwarded-* headers from this address only. TRAEFIK_IP="172.30.0.10" +TRAEFIK_HEADER_CONFIG=' - "--entrypoints.web.http.aliasHeadersStrategy=delete" + - "--entrypoints.websecure.http.aliasHeadersStrategy=delete"' + LICENSE_VERDICT="unknown" LICENSE_LOG_LINES="" @@ -50,6 +50,25 @@ check_openssl() { echo "openssl is not installed or not in PATH." > /dev/stderr exit 1 fi + return +} + +# Only parseable versions can guarantee that aliasHeadersStrategy and its +# CVE-2026-88004 fix (v3.7.13) are available in the fresh-install compose. +check_traefik_tag() { + local tag="${NETBIRD_TRAEFIK_TAG:-}" ver major minor patch + [[ -n "$tag" ]] || return 0 + ver="${tag#v}" + if [[ ! "$ver" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+))?$ ]]; then + die "Cannot verify NETBIRD_TRAEFIK_TAG=$tag. Use a numeric version tag such as v3.7.13 or newer, or unset NETBIRD_TRAEFIK_TAG for the default (v3.7.14)." + fi + major=$((10#${BASH_REMATCH[1]})) + minor=$((10#${BASH_REMATCH[2]})) + patch=$((10#${BASH_REMATCH[4]:-0})) + if (( major < 3 || (major == 3 && minor < 7) || (major == 3 && minor == 7 && patch < 13) )); then + die "NETBIRD_TRAEFIK_TAG=$tag predates aliasHeadersStrategy and its CVE-2026-88004 fix (added in v3.7.12, fixed in v3.7.13), which this installer always enables. Use v3.7.13 or newer, or unset NETBIRD_TRAEFIK_TAG for the default (v3.7.14)." + fi + return } die() { @@ -62,6 +81,7 @@ env_get() { local value value=$(sed -n "s/^$1=//p" .env | tail -n 1) echo "${value:-$2}" + return } # merge_env upserts the KEY=VALUE lines from stdin into .env, in place. @@ -72,14 +92,17 @@ merge_env() { { print } END { for (i = 1; i <= n; i++) if (o[i] in v) print v[o[i]] }' - .env) printf '%s\n' "$merged" > .env + return } rand_secret() { openssl rand -base64 32 | sed "$SED_STRIP_PADDING" + return } rand_b64_key() { openssl rand -base64 32 + return } check_nb_domain() { @@ -133,6 +156,7 @@ read_nb_domain() { fi fi echo "$value" + return } read_letsencrypt_email() { @@ -150,6 +174,7 @@ read_letsencrypt_email() { return fi echo "$value" + return } read_required() { @@ -163,6 +188,7 @@ read_required() { fi done echo "$value" + return } read_secret() { @@ -177,6 +203,7 @@ read_secret() { fi done echo "$value" + return } # read_yes_no "" [] @@ -199,6 +226,7 @@ read_yes_no() { [Yy] | [Yy][Ee][Ss]) echo "yes" ;; *) echo "no" ;; esac + return } read_crowdsec_option() { @@ -208,6 +236,7 @@ read_crowdsec_option() { echo " blocks known malicious sources before they reach services exposed through" echo " the proxy. Adds a CrowdSec container to the stack." NETBIRD_CROWDSEC=$(read_yes_no "Enable CrowdSec" "n") + return } # Gate the install on explicit acceptance of the NetBird On-Premise EULA. @@ -245,6 +274,7 @@ EOF exit 1 fi echo "" > /dev/stderr + return } wait_postgres() { @@ -267,6 +297,7 @@ wait_postgres() { done echo " done" set -e + return } wait_for_license_verdict() { @@ -359,6 +390,7 @@ up_all_but_proxy() { services=$($DOCKER_COMPOSE_COMMAND config --services | grep -vx proxy) # shellcheck disable=SC2086 $DOCKER_COMPOSE_COMMAND up -d $services + return } wait_crowdsec() { @@ -372,6 +404,7 @@ wait_crowdsec() { admin_token() { $DOCKER_COMPOSE_COMMAND run --rm --no-deps -T netbird-server admin token "$@" --config /etc/netbird/config.yaml + return } # revoke_proxy_token revokes the token this run minted if the proxy never started. @@ -384,6 +417,7 @@ revoke_proxy_token() { fi echo "Could not revoke the unused proxy token ${PROXY_TOKEN_ID}. Revoke it with:" > /dev/stderr echo " $DOCKER_COMPOSE_COMMAND run --rm netbird-server admin token revoke ${PROXY_TOKEN_ID} --config /etc/netbird/config.yaml" > /dev/stderr + return } # start_proxy mints the proxy token and CrowdSec bouncer key, then starts the proxy. @@ -419,6 +453,7 @@ start_proxy() { die "Could not start the proxy. Check the proxy logs, then re-run with --enable-proxy." fi PROXY_TOKEN_ID="" + return } print_proxy_notes() { @@ -433,10 +468,12 @@ print_proxy_notes() { if [[ "$NETBIRD_CROWDSEC" == "yes" ]]; then echo " CrowdSec is running. Enable it per service in the dashboard under Access Control." fi + return } init_environment() { check_openssl + check_traefik_tag DOCKER_COMPOSE_COMMAND=$(check_docker_compose) if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]]; then @@ -555,12 +592,14 @@ init_environment() { if [[ "$LICENSE_VERDICT" == "rejected" ]]; then exit 1 fi + return } # service_block NAME prints a service's definition from the compose file on stdin. service_block() { local name="$1" awk -v s=" ${name}:" '$0 == s { p = 1; print; next } p && (/^[^ ]/ || /^ [^ ]/) { exit } p' + return } # enable_features adds the proxy and/or traffic events to the install in the @@ -585,6 +624,9 @@ enable_features() { # so the re-render never removes a working Let's Encrypt setup. local traefik_block traefik_block=$(service_block traefik < docker-compose.yml) + # Enabling features does not upgrade Traefik. Preserve the existing header policy, + # including its absence on older releases that do not support these options. + TRAEFIK_HEADER_CONFIG=$(printf '%s\n' "$traefik_block" | grep -Ei -- '--entrypoints\.[^.]+\.http\.(alias|underscore)headersstrategy=' || true) if ! grep -q certificatesresolvers <<< "$traefik_block"; then CUSTOM_TLS_CERTS=$(awk '/:\/certs:ro$/ { sub(/^ *- /, ""); sub(/:\/certs:ro$/, ""); print; exit }' <<< "$traefik_block") fi @@ -684,6 +726,7 @@ enable_features() { if [[ "$want_proxy" == "yes" ]]; then print_proxy_notes fi + return } rollback() { @@ -697,6 +740,7 @@ rollback() { done $DOCKER_COMPOSE_COMMAND up -d --remove-orphans $DOCKER_COMPOSE_COMMAND restart netbird-server + return } # ------------------------------------------------------------------ @@ -723,7 +767,7 @@ NETBIRD_DOMAIN=${NETBIRD_DOMAIN} # Reverse proxy (Traefik) NETBIRD_LETSENCRYPT_EMAIL=${NETBIRD_LETSENCRYPT_EMAIL} -NETBIRD_TRAEFIK_TAG=${NETBIRD_TRAEFIK_TAG:-v3.6} +NETBIRD_TRAEFIK_TAG=${NETBIRD_TRAEFIK_TAG:-v3.7.14} NETBIRD_TRAEFIK_IP=${TRAEFIK_IP} # Image tags. Default to "latest" @@ -770,20 +814,23 @@ NETBIRD_ENCRYPTION_KEY=${NETBIRD_ENCRYPTION_KEY} # Dashboard OIDC scopes NETBIRD_AUTH_SUPPORTED_SCOPES=${NETBIRD_AUTH_SUPPORTED_SCOPES:-openid profile email groups} EOF + return } render_env_flow() { echo "NETBIRD_ENRICHER_TAG=${NETBIRD_ENRICHER_TAG:-latest}" echo "NETBIRD_RECEIVER_TAG=${NETBIRD_RECEIVER_TAG:-latest}" + return } render_env_proxy() { echo "NETBIRD_PROXY_TAG=${NETBIRD_PROXY_TAG:-latest}" echo "NETBIRD_PROXY_TOKEN=" if [[ "$NETBIRD_CROWDSEC" == "yes" ]]; then - echo "NETBIRD_CROWDSEC_TAG=${NETBIRD_CROWDSEC_TAG:-v1.7.7}" + echo "NETBIRD_CROWDSEC_TAG=${NETBIRD_CROWDSEC_TAG:-v1.8.1}" echo "NETBIRD_CROWDSEC_BOUNCER_KEY=" fi + return } render_docker_compose() { @@ -801,6 +848,7 @@ render_docker_compose() { render_compose_footer } | if [[ -n "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then cat; else sed '/NETBIRD_LICENSE_SERVER_BASE_URL/d'; fi \ | if [[ -n "$CUSTOM_TLS_CERTS" ]]; then sed -e '/certificatesresolvers/d' -e '/certresolver/d'; else cat; fi + return } render_compose_header() { @@ -815,6 +863,7 @@ x-default: &default services: EOF + return } render_compose_common() { @@ -840,6 +889,12 @@ render_compose_common() { - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--entrypoints.websecure.allowACMEByPass=true" +EOF + # Installs that predate these options have no header config; don't leave a blank line. + if [[ -n "$TRAEFIK_HEADER_CONFIG" ]]; then + printf '%s\n' "$TRAEFIK_HEADER_CONFIG" + fi + cat <<'EOF' # readTimeout bounds the whole request, and gRPC streams / relay WebSockets # never end one; idleTimeout would close the keep-alive connection they # are reused over. Entrypoint-wide is the only scope Traefik offers here. @@ -916,6 +971,7 @@ EOF - LETSENCRYPT_EMAIL= EOF + return } render_compose_server() { @@ -963,6 +1019,7 @@ render_compose_server() { - NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL} EOF + return } render_compose_flow() { @@ -1034,6 +1091,7 @@ render_compose_flow() { - traefik.http.services.netbird-flow-h2c.loadbalancer.server.scheme=h2c EOF + return } render_compose_proxy() { @@ -1097,6 +1155,7 @@ EOF EOF fi echo "" + return } render_compose_postgres() { @@ -1119,6 +1178,7 @@ render_compose_postgres() { - netbird_postgres:/var/lib/postgresql/data EOF + return } render_compose_footer() { @@ -1151,6 +1211,7 @@ networks: - subnet: 172.30.0.0/24 gateway: 172.30.0.1 EOF + return } render_config_yaml() { @@ -1215,6 +1276,7 @@ EOF if [[ "$NETBIRD_TRAFFIC_FLOW" == "yes" ]]; then render_config_flow fi + return } render_config_flow() { @@ -1225,6 +1287,7 @@ render_config_flow() { address: "https://${NETBIRD_DOMAIN}:443" interval: "60s" EOF + return } render_traefik_proxy() { @@ -1235,6 +1298,7 @@ tcp: proxyProtocol: version: 2 EOF + return } usage() { @@ -1249,6 +1313,7 @@ the current directory. --enable-traffic-events add traffic events logging (NATS, receiver, enricher) -h, --help show this help EOF + return } main() { @@ -1268,6 +1333,11 @@ main() { else enable_features "$enable_proxy" "$enable_flow" fi + return } -main "$@" +if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then + set -e + set -o pipefail + main "$@" +fi diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index d5c6d9dc9..902956199 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -87,6 +87,27 @@ check_jq() { return 0 } +check_traefik_image() { + local image="${TRAEFIK_IMAGE##*/}" major minor patch + + # A version tag cannot establish which release a digest points to. + if [[ "$TRAEFIK_IMAGE" == *@* || + ! "$image" =~ ^[^:@]+:v?([0-9]+)(\.([0-9]+)(\.([0-9]+))?)?$ ]]; then + echo "Cannot verify TRAEFIK_IMAGE=$TRAEFIK_IMAGE." >&2 + echo "Use a numeric Traefik version tag (v3.7.13 or newer) without a digest." >&2 + return 1 + fi + major=$((10#${BASH_REMATCH[1]})) + minor=$((10#${BASH_REMATCH[3]:-0})) + patch=$((10#${BASH_REMATCH[5]:-0})) + if (( major < 3 || (major == 3 && minor < 7) || (major == 3 && minor == 7 && patch < 13) )); then + echo "TRAEFIK_IMAGE=$TRAEFIK_IMAGE is incompatible with this configuration." >&2 + echo "Use Traefik v3.7.13 or newer for aliasHeadersStrategy and its header-sanitization fix." >&2 + return 1 + fi + return 0 +} + get_main_ip_address() { if [[ "$OSTYPE" == "darwin"* ]]; then interface=$(route -n get default | grep 'interface:' | awk '{print $2}') @@ -448,8 +469,8 @@ initialize_default_values() { # Combined server replaces separate signal, relay, and management containers NETBIRD_SERVER_IMAGE=${NETBIRD_SERVER_IMAGE:-"netbirdio/netbird-server:latest"} NETBIRD_PROXY_IMAGE=${NETBIRD_PROXY_IMAGE:-"netbirdio/reverse-proxy:latest"} - TRAEFIK_IMAGE=${TRAEFIK_IMAGE:-"traefik:v3.6"} - CROWDSEC_IMAGE=${CROWDSEC_IMAGE:-"crowdsecurity/crowdsec:v1.7.7"} + TRAEFIK_IMAGE=${TRAEFIK_IMAGE:-"traefik:v3.7.14"} + CROWDSEC_IMAGE=${CROWDSEC_IMAGE:-"crowdsecurity/crowdsec:v1.8.1"} # Reverse proxy configuration REVERSE_PROXY_TYPE="0" TRAEFIK_EXTERNAL_NETWORK="" @@ -613,6 +634,7 @@ generate_configuration_files() { # Render docker-compose and proxy config based on selection case "$REVERSE_PROXY_TYPE" in 0) + check_traefik_image || return 1 render_docker_compose_traefik_builtin > docker-compose.yml if [[ "$ENABLE_PROXY" == "true" ]]; then # Create placeholder proxy.env so docker-compose can validate @@ -912,11 +934,13 @@ services: # Docker provider - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - - "--providers.docker.network=netbird" + - "--providers.docker.network=\${COMPOSE_PROJECT_NAME:-}_netbird" # Entrypoints - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--entrypoints.websecure.allowACMEByPass=true" + - "--entrypoints.web.http.aliasHeadersStrategy=delete" + - "--entrypoints.websecure.http.aliasHeadersStrategy=delete" # Disable timeouts for long-lived gRPC streams - "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=0" - "--entrypoints.websecure.transport.respondingTimeouts.writeTimeout=0" @@ -1779,4 +1803,6 @@ print_post_setup_instructions() { return 0 } -init_environment +if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then + init_environment +fi diff --git a/infrastructure_files/migrate.sh b/infrastructure_files/migrate.sh index 67895fab6..46971a57a 100755 --- a/infrastructure_files/migrate.sh +++ b/infrastructure_files/migrate.sh @@ -838,7 +838,7 @@ generate_docker_compose_traefik() { services: # Traefik reverse proxy (automatic TLS via Let's Encrypt) traefik: - image: traefik:v3.6 + image: traefik:v3.7.14 container_name: netbird-traefik restart: unless-stopped networks: @@ -851,11 +851,13 @@ services: # Docker provider - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - - "--providers.docker.network=netbird" + - "--providers.docker.network=\${COMPOSE_PROJECT_NAME:-}_netbird" # Entrypoints - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--entrypoints.websecure.allowACMEByPass=true" + - "--entrypoints.web.http.aliasHeadersStrategy=delete" + - "--entrypoints.websecure.http.aliasHeadersStrategy=delete" # Disable timeouts for long-lived gRPC streams - "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=0" - "--entrypoints.websecure.transport.respondingTimeouts.writeTimeout=0" @@ -1283,4 +1285,6 @@ main() { return 0 } -main "$@" +if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then + main "$@" +fi diff --git a/infrastructure_files/tests/test-getting-started-enterprise.sh b/infrastructure_files/tests/test-getting-started-enterprise.sh new file mode 100644 index 000000000..9335caf2a --- /dev/null +++ b/infrastructure_files/tests/test-getting-started-enterprise.sh @@ -0,0 +1,96 @@ +#!/bin/bash + +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +source "$script_dir/../getting-started-enterprise.sh" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +cases=0 + +for errexit in +o -o; do + for pipefail in +o -o; do + bash -c ' + set "$1" errexit + set "$2" pipefail + before_options=$SHELLOPTS + source "$3" + if [[ "$SHELLOPTS" != "$before_options" ]]; then + echo "Sourcing changed shell options: $before_options -> $SHELLOPTS" >&2 + exit 1 + fi + ' bash "$errexit" "$pipefail" "$script_dir/../getting-started-enterprise.sh" + done +done + +assert_tag() { + local expected_status="$1" expected_message="$2" actual_status=0 + shift 2 + local label="${1-}" + + # Each call needs its own process because die exits rather than returning. + ( + unset NETBIRD_TRAEFIK_TAG + if [[ $# -gt 0 ]]; then + NETBIRD_TRAEFIK_TAG="$1" + fi + check_traefik_tag + ) >"$test_dir/stdout" 2>"$test_dir/stderr" || actual_status=$? + + if [[ "$actual_status" != "$expected_status" ]]; then + echo "Tag '$label': expected exit $expected_status, got $actual_status" >&2 + cat "$test_dir/stderr" >&2 + exit 1 + fi + if [[ -s "$test_dir/stdout" ]]; then + echo "Tag '$label': unexpected output on stdout" >&2 + cat "$test_dir/stdout" >&2 + exit 1 + fi + if [[ -n "$expected_message" ]]; then + if ! grep -Fq "$expected_message" "$test_dir/stderr"; then + echo "Tag '$label': expected diagnostic '$expected_message'" >&2 + cat "$test_dir/stderr" >&2 + exit 1 + fi + elif [[ -s "$test_dir/stderr" ]]; then + echo "Tag '$label': unexpected output on stderr" >&2 + cat "$test_dir/stderr" >&2 + exit 1 + fi + cases=$((cases + 1)) +} + +# Unset and empty overrides both select the installer's default image. +assert_tag 0 "" +assert_tag 0 "" "" + +for tag in v3.7.13 3.7.13 v3.7.14 v3.8 3.8 v4.0 v4.0.0 \ + v03.07.013 v3.08.0 v3.7.019; do + assert_tag 0 "" "$tag" +done + +for tag in v2.11.0 v3.6.20 v3.7 3.7 v3.7.11 v3.7.12 3.7.12 \ + v03.07.012 v3.7.08; do + assert_tag 1 "predates aliasHeadersStrategy" "$tag" +done + +for tag in latest custom-build v2 v3 sha256:abcdef v3.7.11-alpine \ + v3.7.13-alpine v3.7.13-rc1 v3.7.14+custom v3.7.14@sha256:abcdef \ + 3..14 v3.7.14.0 " 3.7.14" $'v3.7.14\n'; do + assert_tag 1 "Cannot verify NETBIRD_TRAEFIK_TAG=" "$tag" +done + +# Re-rendering an install that has no header config must not add a blank line +# to the Traefik command list. +for header_config in "" "$TRAEFIK_HEADER_CONFIG"; do + rendered=$(TRAEFIK_HEADER_CONFIG="$header_config" render_compose_common) + if [[ "$(printf '%s\n' "$rendered" | service_block traefik)" == *$'\n\n'* ]]; then + echo "Traefik command list has a blank line (header config: '${header_config:-}')" >&2 + exit 1 + fi + cases=$((cases + 1)) +done + +echo "Passed $cases Traefik tag validation cases." diff --git a/infrastructure_files/tests/test-getting-started-networks.sh b/infrastructure_files/tests/test-getting-started-networks.sh new file mode 100644 index 000000000..9283a80bc --- /dev/null +++ b/infrastructure_files/tests/test-getting-started-networks.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +set -eo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +source "$script_dir/../getting-started.sh" +compose_command=$(check_docker_compose) +read -r -a compose <<< "$compose_command" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +mkdir "$test_dir/deployment" +cd "$test_dir/deployment" + +unset COMPOSE_PROJECT_NAME COMPOSE_FILE COMPOSE_ENV_FILES +# The generator's environment must not bake a project name into the output. +if [[ "${1:-}" == "--migration" ]]; then + COMPOSE_PROJECT_NAME=render-only bash -s -- "$script_dir/../migrate.sh" >/dev/null <<'EOF' +source "$1" +INSTALL_DIR=$PWD +DOMAIN=netbird.test +generate_dashboard_env +generate_docker_compose_traefik +EOF +else + TRAEFIK_IMAGE=traefik:v3.7.14 + NETBIRD_DOMAIN=netbird.test + NETBIRD_AGENT_NETWORK=false + initialize_default_values + COMPOSE_PROJECT_NAME=render-only generate_configuration_files >/dev/null +fi + +assert_network() { + local expected="$1" actual provider + shift + "${compose[@]}" "$@" config --format json >config.json + actual=$(jq -r '.networks.netbird.name' config.json) + provider=$(jq -r '.services.traefik.command[] | select(startswith("--providers.docker.network="))' config.json) + if [[ "$actual" != "$expected" || "$provider" != "--providers.docker.network=$expected" ]]; then + echo "Expected network '$expected'; Compose chose '$actual', Traefik received '$provider'" >&2 + exit 1 + fi +} + +assert_network deployment_netbird +COMPOSE_PROJECT_NAME=env-project assert_network env-project_netbird +echo 'COMPOSE_PROJECT_NAME=file-project' >.env +assert_network file-project_netbird +COMPOSE_PROJECT_NAME=env-project assert_network cli-project_netbird -p cli-project +: >.env +echo 'name: config-project' >>docker-compose.yml +assert_network config-project_netbird + +echo "Passed 5 Compose network scoping cases." diff --git a/infrastructure_files/tests/test-getting-started.sh b/infrastructure_files/tests/test-getting-started.sh new file mode 100644 index 000000000..0826340e5 --- /dev/null +++ b/infrastructure_files/tests/test-getting-started.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +set -eo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +source "$script_dir/../getting-started.sh" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +cases=0 + +assert_image() { + local expected_status="$1" expected_message="$2" image="$3" actual_status=0 + local case_dir="$test_dir/$cases" + mkdir "$case_dir" + + ( + cd "$case_dir" + TRAEFIK_IMAGE="$image" + NETBIRD_DOMAIN=netbird.test + NETBIRD_AGENT_NETWORK=false + initialize_default_values + generate_configuration_files + ) >"$case_dir/stdout" 2>"$case_dir/stderr" || actual_status=$? + + if [[ "$actual_status" != "$expected_status" ]]; then + echo "Image '$image': expected exit $expected_status, got $actual_status" >&2 + cat "$case_dir/stderr" >&2 + exit 1 + fi + if [[ "$expected_status" == 0 ]]; then + if [[ -s "$case_dir/stderr" ]]; then + cat "$case_dir/stderr" >&2 + exit 1 + fi + for entrypoint in web websecure; do + if ! grep -Fq -- "--entrypoints.$entrypoint.http.aliasHeadersStrategy=delete" "$case_dir/docker-compose.yml"; then + echo "Image '$image': missing header protection for $entrypoint" >&2 + exit 1 + fi + done + if ! grep -Fxq " image: ${image:-traefik:v3.7.14}" "$case_dir/docker-compose.yml"; then + echo "Image '$image': unexpected rendered image" >&2 + exit 1 + fi + else + if ! grep -Fq "$expected_message" "$case_dir/stderr"; then + echo "Image '$image': expected diagnostic '$expected_message'" >&2 + cat "$case_dir/stderr" >&2 + exit 1 + fi + if [[ -e "$case_dir/docker-compose.yml" || -e "$case_dir/config.yaml" || -e "$case_dir/dashboard.env" ]]; then + echo "Image '$image': generated configuration despite failed validation" >&2 + exit 1 + fi + fi + cases=$((cases + 1)) +} + +# An empty override selects the default image. +assert_image 0 "" "" +for image in traefik:v3.7.13 traefik:3.7.13 traefik:v3.7.14 traefik:v3.8 \ + traefik:v4 traefik:v4.0.0 traefik:v03.07.013 traefik:v3.08.0 \ + registry.example:5000/team/traefik:3.7.13; do + assert_image 0 "" "$image" +done + +for tag in v2 v2.11.0 v3 v3.6.20 v3.7 v3.7.11 v3.7.12 v03.07.012 v3.7.08; do + assert_image 1 "incompatible with this configuration" "traefik:$tag" +done + +digest="sha256:$(printf '%064d' 0)" +for image in traefik:latest traefik:custom-build traefik registry.example:5000/traefik \ + "traefik@$digest" "registry.example:5000/team/traefik@$digest" \ + "traefik:v3.7.13@$digest" "traefik:latest@$digest" \ + traefik:v3.7.13-rc1 traefik:v3.7.13-alpine traefik:v3.7.14+custom \ + traefik:3..14 traefik:v3.7.14.0 traefik:custom:v3.7.14 :v3.7.14 \ + "traefik: v3.7.14" $'traefik:v3.7.14\n'; do + assert_image 1 "Cannot verify TRAEFIK_IMAGE=" "$image" +done + +echo "Passed $cases Traefik image configuration cases." From 2299e3d5a742ac5c8d28cf3fa36a8fcaacbb771d Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:46:35 +0200 Subject: [PATCH 162/209] [management] move is child account (#8195) --- management/internals/server/boot.go | 2 +- management/internals/server/modules.go | 4 ---- management/server/account.go | 18 ++---------------- management/server/auth/manager.go | 4 ++-- .../server/http/middleware/auth_middleware.go | 19 ------------------- .../http/middleware/auth_middleware_test.go | 12 +----------- .../http/middleware/middleware_factory.go | 3 +-- .../testing/testing_tools/channel/channel.go | 4 ++-- 8 files changed, 9 insertions(+), 57 deletions(-) diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 4ced64a16..13bbaa6fa 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -204,7 +204,7 @@ func (s *BaseServer) IDPHandler() http.Handler { func (s *BaseServer) Router() *mux.Router { return Create(s, func() *mux.Router { router := mux.NewRouter() - router.Use(middleware.BuildMiddleware(s.RateLimiter(), s.AuthManager(), s.AccountManager(), s.Metrics(), s.IsValidChildAccount)...) + router.Use(middleware.BuildMiddleware(s.RateLimiter(), s.AuthManager(), s.AccountManager(), s.Metrics())...) return router }) } diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index f548e9238..3379becf6 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -248,7 +248,3 @@ func (s *BaseServer) ReverseProxyDomainManager() *manager.Manager { return &m }) } - -func (s *BaseServer) IsValidChildAccount(_ context.Context, _, _, _ string) bool { - return false -} diff --git a/management/server/account.go b/management/server/account.go index 0a8ec9a59..90e384ac0 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -1637,10 +1637,6 @@ func (am *DefaultAccountManager) GetAccountIDFromUserAuth(ctx context.Context, u return "", "", status.Errorf(status.NotFound, "user %s not found", userAuth.UserId) } - if userAuth.IsChild { - return accountID, user.Id, nil - } - ctx, err = am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false) if err != nil { return "", "", err @@ -1692,10 +1688,10 @@ func (am *DefaultAccountManager) updateUserAuthWithSingleMode(ctx context.Contex // and propagates changes to peers if group propagation is enabled. // requires userAuth to have been ValidateAndParseToken and EnsureUserAccessByJWTGroups by the AuthManager func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth auth.UserAuth) error { - // Child accounts and PAT-authenticated requests do not sync JWT groups. + // PAT-authenticated requests do not sync JWT groups. // Embedded-Dex local users also skip sync because local password authentication // does not provide external IdP group claims. - if userAuth.IsChild || userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) { + if userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) { return nil } @@ -1874,8 +1870,6 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth // Existing user + Existing account + Existing Indexed Domain -> Nothing changes // // Existing user + Existing account + Existing domain reclassified Domain as private -> Nothing changes (index domain) -// -// UserAuth IsChild -> checks that account exists func (am *DefaultAccountManager) getAccountIDWithAuthorizationClaims(ctx context.Context, userAuth auth.UserAuth) (string, error) { log.WithContext(ctx).Tracef("getting account with authorization claims. User ID: \"%s\", Account ID: \"%s\", Domain: \"%s\", Domain Category: \"%s\"", userAuth.UserId, userAuth.AccountId, userAuth.Domain, userAuth.DomainCategory) @@ -1884,14 +1878,6 @@ func (am *DefaultAccountManager) getAccountIDWithAuthorizationClaims(ctx context return "", errors.New(emptyUserID) } - if userAuth.IsChild { - exists, err := am.Store.AccountExists(ctx, store.LockingStrengthNone, userAuth.AccountId) - if err != nil || !exists { - return "", err - } - return userAuth.AccountId, nil - } - if userAuth.DomainCategory != types.PrivateCategory || !isDomainValid(userAuth.Domain) { return am.GetAccountIDByUserID(ctx, userAuth) } diff --git a/management/server/auth/manager.go b/management/server/auth/manager.go index 9498789f2..7064c094f 100644 --- a/management/server/auth/manager.go +++ b/management/server/auth/manager.go @@ -75,10 +75,10 @@ func (m *manager) ValidateAndParseToken(ctx context.Context, value string) (auth } func (m *manager) EnsureUserAccessByJWTGroups(ctx context.Context, userAuth auth.UserAuth, token *jwt.Token) (auth.UserAuth, error) { - // Child accounts and PAT-authenticated requests do not use JWT group access checks. + // PAT-authenticated requests do not use JWT group access checks. // Embedded-Dex local users also skip them because local password authentication // does not provide external IdP group claims. - if userAuth.IsChild || userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) { + if userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) { return userAuth, nil } diff --git a/management/server/http/middleware/auth_middleware.go b/management/server/http/middleware/auth_middleware.go index 1e831e6c3..7c21b6a55 100644 --- a/management/server/http/middleware/auth_middleware.go +++ b/management/server/http/middleware/auth_middleware.go @@ -26,8 +26,6 @@ type SyncUserJWTGroupsFunc func(ctx context.Context, userAuth auth.UserAuth) err type GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) -type IsValidChildAccountFunc func(ctx context.Context, userID, accountID, childAccountID string) bool - // AuthMiddleware middleware to verify personal access tokens (PAT) and JWT tokens type AuthMiddleware struct { authManager serverauth.Manager @@ -36,7 +34,6 @@ type AuthMiddleware struct { syncUserJWTGroups SyncUserJWTGroupsFunc rateLimiter *ratelimit.APIRateLimiter patUsageTracker *PATUsageTracker - isValidChildAccount IsValidChildAccountFunc } // NewAuthMiddleware instance constructor @@ -47,7 +44,6 @@ func NewAuthMiddleware( getUserFromUserAuth GetUserFromUserAuthFunc, rateLimiter *ratelimit.APIRateLimiter, meter metric.Meter, - isValidChildAccount IsValidChildAccountFunc, ) *AuthMiddleware { var patUsageTracker *PATUsageTracker if meter != nil { @@ -65,7 +61,6 @@ func NewAuthMiddleware( getUserFromUserAuth: getUserFromUserAuth, rateLimiter: rateLimiter, patUsageTracker: patUsageTracker, - isValidChildAccount: isValidChildAccount, } } @@ -127,13 +122,6 @@ func (m *AuthMiddleware) checkJWTFromRequest(r *http.Request, authHeaderParts [] return err } - if impersonate, ok := r.URL.Query()["account"]; ok && len(impersonate) == 1 { - if m.isValidChildAccount(ctx, userAuth.UserId, userAuth.AccountId, impersonate[0]) { - userAuth.AccountId = impersonate[0] - userAuth.IsChild = true - } - } - // Email is now extracted in ToUserAuth (from claims or userinfo endpoint) // Available as userAuth.Email @@ -210,13 +198,6 @@ func (m *AuthMiddleware) checkPATFromRequest(r *http.Request, authHeaderParts [] IsPAT: true, } - if impersonate, ok := r.URL.Query()["account"]; ok && len(impersonate) == 1 { - if m.isValidChildAccount(r.Context(), userAuth.UserId, userAuth.AccountId, impersonate[0]) { - userAuth.AccountId = impersonate[0] - userAuth.IsChild = true - } - } - // propagates ctx change to upstream middleware *r = *nbcontext.SetUserAuthInRequest(r, userAuth) return nil diff --git a/management/server/http/middleware/auth_middleware_test.go b/management/server/http/middleware/auth_middleware_test.go index ceefc7c45..9e60af1fa 100644 --- a/management/server/http/middleware/auth_middleware_test.go +++ b/management/server/http/middleware/auth_middleware_test.go @@ -104,7 +104,7 @@ func mockMarkPATUsed(_ context.Context, token string) error { } func mockEnsureUserAccessByJWTGroups(_ context.Context, userAuth nbauth.UserAuth, token *jwt.Token) (nbauth.UserAuth, error) { - if userAuth.IsChild || userAuth.IsPAT { + if userAuth.IsPAT { return userAuth, nil } @@ -212,7 +212,6 @@ func TestAuthMiddleware_Handler(t *testing.T) { }, disabledLimiter, nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handlerToTest := authMiddleware.Handler(nextHandler) @@ -279,7 +278,6 @@ func TestAuthMiddleware_SyncUserJWTGroupsDetachedFromRequestCancellation(t *test }, disabledLimiter, nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handlerToTest := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) @@ -332,7 +330,6 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }, ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -385,7 +382,6 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }, ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -429,7 +425,6 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }, ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -474,7 +469,6 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }, ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -539,7 +533,6 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }, ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -599,7 +592,6 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }, ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -655,7 +647,6 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }, ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -756,7 +747,6 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) { }, disabledLimiter, nil, - func(_ context.Context, _, _, _ string) bool { return false }, ) for _, tc := range tt { diff --git a/management/server/http/middleware/middleware_factory.go b/management/server/http/middleware/middleware_factory.go index 3ae617774..5346301ea 100644 --- a/management/server/http/middleware/middleware_factory.go +++ b/management/server/http/middleware/middleware_factory.go @@ -10,7 +10,7 @@ import ( log "github.com/sirupsen/logrus" ) -func BuildMiddleware(rateLimiter *ratelimit.APIRateLimiter, authManager auth.Manager, accountManager account.Manager, metrics telemetry.AppMetrics, isValidChildAcctFunc IsValidChildAccountFunc) []mux.MiddlewareFunc { +func BuildMiddleware(rateLimiter *ratelimit.APIRateLimiter, authManager auth.Manager, accountManager account.Manager, metrics telemetry.AppMetrics) []mux.MiddlewareFunc { toret := make([]mux.MiddlewareFunc, 0) toret = append(toret, metrics.HTTPMiddleware().Handler) @@ -28,7 +28,6 @@ func BuildMiddleware(rateLimiter *ratelimit.APIRateLimiter, authManager auth.Man accountManager.GetUserFromUserAuth, rateLimiter, metrics.GetMeter(), - isValidChildAcctFunc, ).Handler) return toret diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index 1b15ba780..88ab70b9c 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -146,7 +146,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() - apiRouter.Use(middleware.BuildMiddleware(nil, authManagerMock, am, metrics, nil)...) + apiRouter.Use(middleware.BuildMiddleware(nil, authManagerMock, am, metrics)...) apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil) @@ -288,7 +288,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() - apiRouter.Use(middleware.BuildMiddleware(nil, authManagerMock, am, metrics, nil)...) + apiRouter.Use(middleware.BuildMiddleware(nil, authManagerMock, am, metrics)...) apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil) From 405bbb8997f3d58d34cf6138bb05f066c1686fd2 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 14 Oct 2026 15:50:16 +0200 Subject: [PATCH 163/209] [infrastructure] Restrict the FreeBSD port job to stable releases (#8204) * [infrastructure] Restrict the FreeBSD port job to stable releases The port scripts picked the target version by scraping the GitHub tags page and only dropped tags containing "rc". A canary tag such as v0.81.0-canary.pr-8098.1 sorts above the latest stable release, so the job tried to package a prerelease, and the page only lists ten tags, so a run of prereleases could leave no candidate at all. Both scripts now share one helper that reads the tags API, keeps MAJOR.MINOR.PATCH versions only, and rejects an explicitly requested prerelease. Go proxy downloads fail instead of hashing an error page. The job moves out of the release workflow into its own, so it runs on pushes to main, on release tags, and on pull requests that touch the scripts or the workflow itself. Prerelease tags skip the job, and a stable tag is packaged directly rather than whatever happens to be the latest release at the time. * [infrastructure] Keep the FreeBSD port job in the release workflow A separate workflow would rename the check and detach the job from the release run, so the job stays in release.yml behind a small trigger job that decides when it runs: pushes to main, stable release tags, and pull requests that touch the port scripts or the workflow. Prerelease tags and other branches skip it. Tags are now listed with git ls-remote, which returns every tag in one call and needs neither a token nor pagination, and the Go module proxy downloads are limited to HTTPS like the other fetches. * [infrastructure] Address Sonar findings in the FreeBSD port helper Read the version into a local and return explicitly from is_stable_version. * [infrastructure] Address review findings in the FreeBSD port job Only a full vMAJOR.MINOR.PATCH tag starts the port job, so a tag like v1.2 skips it instead of failing in the resolver. The diff detection steps use a glob array rather than parsing ls, and quote GITHUB_OUTPUT. A failed tag listing now stops the issue body script instead of writing an incomplete changelog, and a bare "v" argument is rejected rather than treated as a request for the latest release. --- .github/workflows/release.yml | 71 ++++++++++++++--- release_files/freebsd-port-diff.sh | 86 ++++----------------- release_files/freebsd-port-issue-body.sh | 88 +++++---------------- release_files/freebsd-port-lib.sh | 98 ++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 146 deletions(-) create mode 100644 release_files/freebsd-port-lib.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d133d87f1..b3ed4763c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,8 +23,50 @@ concurrency: cancel-in-progress: true jobs: + freebsd_port_trigger: + name: "FreeBSD Port / Trigger" + runs-on: ubuntu-22.04 + outputs: + run: ${{ steps.decide.outputs.run }} + version: ${{ steps.decide.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Decide whether the port job runs + id: decide + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + run=false + case "$GITHUB_EVENT_NAME" in + push) + if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then + run=true + elif [[ "$GITHUB_REF" == refs/tags/* && "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + # The port tracks stable releases only, so a canary or rc tag has nothing to submit. + run=true + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + fi + ;; + pull_request) + git fetch --depth=1 origin "$BASE_SHA" + if git diff --name-only "$BASE_SHA" HEAD | \ + grep -qE '^(release_files/freebsd-port-.*\.sh|\.github/workflows/release\.yml)$'; then + run=true + fi + ;; + esac + echo "run=$run" >> "$GITHUB_OUTPUT" + echo "FreeBSD port job runs: $run" + release_freebsd_port: name: "FreeBSD Port / Build & Test" + needs: freebsd_port_trigger + if: needs.freebsd_port_trigger.outputs.run == 'true' runs-on: ubuntu-22.04 steps: - name: Checkout @@ -33,18 +75,19 @@ jobs: persist-credentials: false - name: Generate FreeBSD port diff - run: bash -x release_files/freebsd-port-diff.sh - - - name: Generate FreeBSD port issue body - run: bash -x release_files/freebsd-port-issue-body.sh + env: + TARGET_VERSION: ${{ needs.freebsd_port_trigger.outputs.version }} + run: bash -x release_files/freebsd-port-diff.sh "$TARGET_VERSION" - name: Check if diff was generated id: check_diff run: | - if ls netbird-*.diff 1> /dev/null 2>&1; then - echo "diff_exists=true" >> $GITHUB_OUTPUT + shopt -s nullglob + diffs=(netbird-*.diff) + if [[ ${#diffs[@]} -gt 0 ]]; then + echo "diff_exists=true" >> "$GITHUB_OUTPUT" else - echo "diff_exists=false" >> $GITHUB_OUTPUT + echo "diff_exists=false" >> "$GITHUB_OUTPUT" echo "No diff file generated (port may already be up to date)" fi @@ -52,10 +95,18 @@ jobs: if: steps.check_diff.outputs.diff_exists == 'true' id: version run: | - VERSION=$(ls netbird-*.diff | sed 's/netbird-\(.*\)\.diff/\1/') - echo "version=$VERSION" >> $GITHUB_OUTPUT + diffs=(netbird-*.diff) + VERSION="${diffs[0]#netbird-}" + VERSION="${VERSION%.diff}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Generated files for version: $VERSION" - cat netbird-*.diff + cat "${diffs[0]}" + + - name: Generate FreeBSD port issue body + if: steps.check_diff.outputs.diff_exists == 'true' + env: + TARGET_VERSION: ${{ steps.version.outputs.version }} + run: bash -x release_files/freebsd-port-issue-body.sh "" "$TARGET_VERSION" - name: Read Go version from go.mod id: goversion diff --git a/release_files/freebsd-port-diff.sh b/release_files/freebsd-port-diff.sh index 77ea55520..cd3c341b9 100755 --- a/release_files/freebsd-port-diff.sh +++ b/release_files/freebsd-port-diff.sh @@ -9,62 +9,19 @@ # Usage: ./freebsd-port-diff.sh [new_version] # Example: ./freebsd-port-diff.sh 0.60.7 # -# If no version is provided, it fetches the latest from GitHub. +# If no version is provided, it uses the latest stable release from GitHub. +# Prerelease versions (canary, rc, ...) are rejected: the port only tracks +# stable releases. set -e -GITHUB_REPO="netbirdio/netbird" -PORTS_MIRROR_BASE="https://raw.githubusercontent.com/freebsd/freebsd-ports/main/security/netbird" +# shellcheck source=release_files/freebsd-port-lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/freebsd-port-lib.sh" + GO_PROXY="https://proxy.golang.org/github.com/netbirdio/netbird/@v" OUTPUT_DIR="${OUTPUT_DIR:-.}" AWK_FIRST_FIELD='{print $1}' -fetch_all_tags() { - curl -sL "https://github.com/${GITHUB_REPO}/tags" 2>/dev/null | \ - grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+([^"]+)?' | \ - grep -iv 'rc' | \ - sed 's/.*\/v//' | \ - sort -u -V - return 0 -} - -fetch_current_ports_version() { - echo "Fetching current version from FreeBSD ports..." >&2 - local makefile version - makefile=$(fetch_ports_file "Makefile") || return 1 - version=$(echo "$makefile" | \ - grep -E "^DISTVERSION=" | \ - sed 's/DISTVERSION=[[:space:]]*//' | \ - tr -d '\t ') - if [[ -z "$version" ]]; then - echo "Error: Could not extract DISTVERSION from ports Makefile" >&2 - return 1 - fi - echo "$version" - return 0 -} - -fetch_latest_github_release() { - echo "Fetching latest release from GitHub..." >&2 - fetch_all_tags | tail -1 - return 0 -} - -fetch_ports_file() { - local filename="$1" - local content - if ! content=$(curl -fsL --proto '=https' --proto-redir '=https' --retry 3 "${PORTS_MIRROR_BASE}/${filename}" 2>/dev/null); then - echo "Error: Could not fetch ${filename} from ${PORTS_MIRROR_BASE}" >&2 - return 1 - fi - if [[ "$content" == \<* ]]; then - echo "Error: Received HTML instead of ${filename} from ${PORTS_MIRROR_BASE}" >&2 - return 1 - fi - printf '%s' "$content" - return 0 -} - compute_checksums() { local version="$1" local tmpdir @@ -77,11 +34,15 @@ compute_checksums() { local mod_file="${tmpdir}/v${version}.mod" local zip_file="${tmpdir}/v${version}.zip" - curl -sL "${GO_PROXY}/v${version}.mod" -o "$mod_file" 2>/dev/null - curl -sL "${GO_PROXY}/v${version}.zip" -o "$zip_file" 2>/dev/null + local -a curl_opts=(-fsL --proto '=https' --proto-redir '=https' --retry 3) + if ! curl "${curl_opts[@]}" "${GO_PROXY}/v${version}.mod" -o "$mod_file" 2>/dev/null || \ + ! curl "${curl_opts[@]}" "${GO_PROXY}/v${version}.zip" -o "$zip_file" 2>/dev/null; then + echo "Error: Could not download v${version} from the Go module proxy" >&2 + return 1 + fi if [[ ! -s "$mod_file" ]] || [[ ! -s "$zip_file" ]]; then - echo "Error: Could not download files from Go module proxy" >&2 + echo "Error: Go module proxy returned empty files for v${version}" >&2 return 1 fi @@ -133,25 +94,12 @@ generate_new_makefile() { } # Parse arguments -NEW_VERSION="${1:-}" - -# Auto-detect versions if not provided -OLD_VERSION=$(fetch_current_ports_version) -if [[ -z "$OLD_VERSION" ]]; then - echo "Error: Could not fetch current version from FreeBSD ports" >&2 - exit 1 -fi -echo "Current FreeBSD ports version: ${OLD_VERSION}" >&2 - -if [[ -z "$NEW_VERSION" ]]; then - NEW_VERSION=$(fetch_latest_github_release) - if [[ -z "$NEW_VERSION" ]]; then - echo "Error: Could not fetch latest release from GitHub" >&2 - exit 1 - fi -fi +NEW_VERSION=$(resolve_target_version "${1:-}") echo "Target version: ${NEW_VERSION}" >&2 +OLD_VERSION=$(fetch_current_ports_version) +echo "Current FreeBSD ports version: ${OLD_VERSION}" >&2 + if [[ "$OLD_VERSION" = "$NEW_VERSION" ]]; then echo "Port is already at version ${NEW_VERSION}. Nothing to do." >&2 exit 0 diff --git a/release_files/freebsd-port-issue-body.sh b/release_files/freebsd-port-issue-body.sh index 1f0c8a567..16fe0e2b6 100755 --- a/release_files/freebsd-port-issue-body.sh +++ b/release_files/freebsd-port-issue-body.sh @@ -10,81 +10,29 @@ # # If no versions are provided, the script will: # - Fetch OLD version from the FreeBSD ports GitHub mirror (current version in ports tree) -# - Fetch NEW version from latest NetBird GitHub release tag +# - Fetch NEW version from the latest stable NetBird release tag on GitHub +# +# Prerelease versions (canary, rc, ...) are rejected: the port only tracks +# stable releases. set -e -GITHUB_REPO="netbirdio/netbird" -PORTS_MAKEFILE_URL="https://raw.githubusercontent.com/freebsd/freebsd-ports/main/security/netbird/Makefile" - -fetch_current_ports_version() { - echo "Fetching current version from FreeBSD ports..." >&2 - local makefile_content - makefile_content=$(curl -fsL --proto '=https' --proto-redir '=https' --retry 3 "$PORTS_MAKEFILE_URL" 2>/dev/null) || makefile_content="" - if [[ "$makefile_content" == \<* ]]; then - echo "Error: Received HTML instead of Makefile from ${PORTS_MAKEFILE_URL}" >&2 - return 1 - fi - if [[ -z "$makefile_content" ]]; then - echo "Error: Could not fetch Makefile from FreeBSD ports" >&2 - return 1 - fi - echo "$makefile_content" | grep -E "^DISTVERSION=" | sed 's/DISTVERSION=[[:space:]]*//' | tr -d '\t ' - return 0 -} - -fetch_all_tags() { - # Fetch tags from GitHub tags page (no rate limiting, no auth needed) - curl -sL "https://github.com/${GITHUB_REPO}/tags" 2>/dev/null | \ - grep -oE '/releases/tag/v[0-9]+\.[0-9]+\.[0-9]+([^"]+)?' | \ - grep -iv 'rc' | \ - sed 's/.*\/v//' | \ - sort -u -V - return 0 -} - -fetch_latest_github_release() { - echo "Fetching latest release from GitHub..." >&2 - local latest - - # Fetch from GitHub tags page - latest=$(fetch_all_tags | tail -1) - - if [[ -z "$latest" ]]; then - # Fallback to GitHub API - latest=$(curl -sL "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" 2>/dev/null | \ - grep '"tag_name"' | sed 's/.*"tag_name": *"v\([^"]*\)".*/\1/') - fi - - if [[ -z "$latest" ]]; then - echo "Error: Could not fetch latest release from GitHub" >&2 - return 1 - fi - echo "$latest" - return 0 -} +# shellcheck source=release_files/freebsd-port-lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/freebsd-port-lib.sh" OLD_VERSION="${1:-}" NEW_VERSION="${2:-}" if [[ -z "$OLD_VERSION" ]]; then OLD_VERSION=$(fetch_current_ports_version) - if [[ -z "$OLD_VERSION" ]]; then - echo "Error: Could not determine old version. Please provide it manually." >&2 - echo "Usage: $0 " >&2 - exit 1 - fi echo "Detected OLD version from FreeBSD ports: $OLD_VERSION" >&2 fi if [[ -z "$NEW_VERSION" ]]; then - NEW_VERSION=$(fetch_latest_github_release) - if [[ -z "$NEW_VERSION" ]]; then - echo "Error: Could not determine new version. Please provide it manually." >&2 - echo "Usage: $0 " >&2 - exit 1 - fi + NEW_VERSION=$(resolve_target_version "") echo "Detected NEW version from GitHub: $NEW_VERSION" >&2 +else + NEW_VERSION=$(resolve_target_version "$NEW_VERSION") fi if [[ "$OLD_VERSION" = "$NEW_VERSION" ]]; then @@ -98,8 +46,11 @@ OUTPUT_DIR="${OUTPUT_DIR:-.}" fetch_releases_between_versions() { echo "Fetching release history from GitHub..." >&2 - # Fetch all tags and filter to those between OLD and NEW versions - fetch_all_tags | \ + local tags + tags=$(fetch_stable_tags) || return 1 + + # List the stable releases after OLD up to and including NEW + echo "$tags" | \ while read -r ver; do if [[ "$(printf '%s\n' "$OLD_VERSION" "$ver" | sort -V | head -n1)" = "$OLD_VERSION" ]] && \ [[ "$(printf '%s\n' "$ver" "$NEW_VERSION" | sort -V | head -n1)" = "$ver" ]] && \ @@ -112,7 +63,7 @@ fetch_releases_between_versions() { generate_changelog_section() { local releases - releases=$(fetch_releases_between_versions) + releases=$(fetch_releases_between_versions) || return 1 echo "Changelogs:" if [[ -n "$releases" ]]; then @@ -127,7 +78,10 @@ generate_changelog_section() { OUTPUT_FILE="${OUTPUT_DIR}/netbird-${NEW_VERSION}-issue.txt" -cat << EOF > "$OUTPUT_FILE" +# Built before the heredoc: a failing substitution inside it would not stop the script. +CHANGELOG=$(generate_changelog_section) + +cat << EOT > "$OUTPUT_FILE" BUGZILLA ISSUE DETAILS ====================== @@ -139,11 +93,11 @@ Description: ------------ security/netbird: Update ${OLD_VERSION} => ${NEW_VERSION} -$(generate_changelog_section) +${CHANGELOG} Commit log: https://github.com/${GITHUB_REPO}/compare/v${OLD_VERSION}...v${NEW_VERSION} -EOF +EOT echo "=========================================" echo "Issue body saved to: ${OUTPUT_FILE}" diff --git a/release_files/freebsd-port-lib.sh b/release_files/freebsd-port-lib.sh new file mode 100644 index 000000000..20102be54 --- /dev/null +++ b/release_files/freebsd-port-lib.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# +# Shared helpers for the FreeBSD port scripts. Source this file, do not run it. +# +# The FreeBSD port tracks stable NetBird releases only, so every helper here +# works on plain MAJOR.MINOR.PATCH versions and ignores canary, release +# candidate and any other prerelease tag. + +GITHUB_REPO="netbirdio/netbird" +GITHUB_REPO_URL="https://github.com/${GITHUB_REPO}.git" +PORTS_MIRROR_BASE="https://raw.githubusercontent.com/freebsd/freebsd-ports/main/security/netbird" +STABLE_VERSION_RE='^[0-9]+\.[0-9]+\.[0-9]+$' + +# is_stable_version reports whether the version is a plain MAJOR.MINOR.PATCH release. +is_stable_version() { + local version="$1" + if [[ "$version" =~ $STABLE_VERSION_RE ]]; then + return 0 + fi + return 1 +} + +# fetch_stable_tags prints the stable release versions known to GitHub, oldest +# first. git ls-remote returns every tag in one round trip, with no API +# pagination or rate limit to work around. +fetch_stable_tags() { + local refs + if ! refs=$(git ls-remote --tags --refs "$GITHUB_REPO_URL" 2>/dev/null); then + echo "Error: Could not list tags of ${GITHUB_REPO_URL}" >&2 + return 1 + fi + + echo "$refs" | \ + sed -nE 's#.*refs/tags/v([0-9]+\.[0-9]+\.[0-9]+)$#\1#p' | \ + sort -u -V + return 0 +} + +# resolve_target_version validates an explicitly requested version, or picks the +# latest stable release from GitHub when none was given. A leading "v" is +# accepted so a git tag name can be passed as is. +resolve_target_version() { + local raw="$1" + local requested="${raw#v}" + + if [[ -n "$raw" ]]; then + if ! is_stable_version "$requested"; then + echo "Error: '${raw}' is not a stable release. The FreeBSD port only tracks MAJOR.MINOR.PATCH versions." >&2 + return 1 + fi + echo "$requested" + return 0 + fi + + echo "Fetching latest stable release from GitHub..." >&2 + local latest + latest=$(fetch_stable_tags | tail -1) + if [[ -z "$latest" ]]; then + echo "Error: Could not determine the latest stable release from GitHub" >&2 + return 1 + fi + echo "$latest" + return 0 +} + +# fetch_ports_file prints a file from the security/netbird port on the FreeBSD +# ports GitHub mirror. +fetch_ports_file() { + local filename="$1" + local content + if ! content=$(curl -fsL --proto '=https' --proto-redir '=https' --retry 3 "${PORTS_MIRROR_BASE}/${filename}" 2>/dev/null); then + echo "Error: Could not fetch ${filename} from ${PORTS_MIRROR_BASE}" >&2 + return 1 + fi + if [[ "$content" == \<* ]]; then + echo "Error: Received HTML instead of ${filename} from ${PORTS_MIRROR_BASE}" >&2 + return 1 + fi + printf '%s' "$content" + return 0 +} + +# fetch_current_ports_version prints the DISTVERSION currently in the ports tree. +fetch_current_ports_version() { + echo "Fetching current version from FreeBSD ports..." >&2 + local makefile version + makefile=$(fetch_ports_file "Makefile") || return 1 + version=$(echo "$makefile" | \ + grep -E "^DISTVERSION=" | \ + sed 's/DISTVERSION=[[:space:]]*//' | \ + tr -d '\t ') + if [[ -z "$version" ]]; then + echo "Error: Could not extract DISTVERSION from ports Makefile" >&2 + return 1 + fi + echo "$version" + return 0 +} From 85db35194ef437bc37e6f3cfe561378582c6c994 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:53:55 +0200 Subject: [PATCH 164/209] [client] Prolong HA routes on the current routing peer across relay and direct transitions (#8192) * [client] Do not move an HA route on latency before both peers have a sample A direct connection reports no latency until the first sample of its new ICE agent, and a drop to relay clears the previous one. Route selection ranked such a peer at unknownLatency, so a routing peer whose direct connection came back within the relayed switch delay lost the route to an equivalent peer on a "lower latency" switch right after recovering, resetting every masqueraded flow the delay was meant to keep alive. The sample usually lands before the evaluation, but nothing orders the two, and a pair with no round trip time yet postpones it to the next sampling tick. Latency-only switches now require a sample on both peers. Categorical criteria (connected, metric, direct versus relayed) are unaffected. * [client] Hold a never-direct HA routing peer before a direct peer takes over The peers of an HA group come up over relay first and turn direct one after the other, as when lazy connections wake the group or the client reconnects. A routing peer that was relayed when it carried the route gave way at once to the first equivalent peer that turned direct, so in a group of N peers the route moved right after the session started in roughly (N-1)/N of the cases, resetting every masqueraded flow, typically an SSH session opened on the wake-up traffic. Such a peer is now kept for the relayedSwitch delay, counted from the first evaluation that finds a direct contender. If it turns direct within the delay the route stays; otherwise it moves when the delay runs out, so the route does not stay on a relay for longer than the delay. A peer that dropped from direct keeps counting from the drop. * [client] Keep allowed IPs when only the chosen routing peer's status changes recalculateRoutes did not tell a status change of the chosen routing peer (idle to connected when traffic wakes it, connected to relayed and back) from a switch to another peer: it removed the allowed IPs and added them back on the same peer. In between the prefix had no owner and packets for it were dropped, and default routes published a spurious "disconnected due to high availability change" event followed by two "connected" ones. When the same route stays on the same peer with the same definition, only the recorded status is updated now. * [client] Limit the "never" relayed switch policy to peers that dropped from direct NB_HA_RELAYED_SWITCH_DELAY=never keeps a routing peer whose direct connection dropped to relay for as long as it stays connected. Once the relayed delay also covered peers that were never direct, "never" pinned those to the relay for good, although a direct equivalent was available and the peer showed no sign of turning direct. A peer that was never direct now uses the default delay under "never", which keeps the policy's original scope. * [client] Merge the unchanged-route checks of the HA route watcher shouldSkipRecalculation and the status-only fast path in recalculateRoutes both handled the same route staying on the same routing peer, one for an unchanged status and one for a changed one. They are now a single keepChosenRoute check. Pure refactor, no behavior change. * [client] Re-apply routed allowed IPs when a kept routing peer connects A peer update can recreate a routing peer: modifyPeers removes it from the interface, which drops its routed prefixes, and adds it back idle with its overlay addresses only. The remove and re-add that recalculateRoutes used to run on every status change of the chosen peer put the prefixes back as a side effect once the peer connected. Without it, a watcher that sees the same peer go idle and then connected leaves the routed subnet without an owner until a route update or an HA switch. When the chosen peer connects while keeping the route, the watcher now re-applies its routed allowed IPs through the route manager's reconcile, which is add-only and idempotent. Lazy connections already do the same when they arm an idle peer. * [client] Bound the HA route hold on a routing peer without a latency sample A latency-only switch waited for a sample on both peers. If the current peer never got one, because its candidate pair stats kept failing or reporting no round trip time, it kept the route indefinitely against a measured peer, however much faster that one was. The hold on an unsampled current peer now lasts unmeasuredLatencyHold (30s), long enough for a recovered direct connection to report its first sample. After that the peer is ranked at unknownLatency again and the usual switch margins apply. relayedHold is armed so the switch is reconsidered when the hold runs out. * [client] Restart the relayed delay of a never-direct peer when its contender goes For a routing peer that was never direct, the relayed delay runs from the first evaluation that finds a direct contender. It was only reset when the current peer changed or turned direct, so if that contender went away and another one showed up later, the delay counted from the first one and the new contender took over at once. The delay of such a peer now restarts once no direct contender is left. The delay of a peer that dropped from direct keeps running from the drop. * [client] Retrigger pull request synchronization * [client] Retry a failed routed allowed IPs reconcile on the next evaluation keepChosenRoute recorded the connected status before re-applying the routed allowed IPs and only logged a failed reconcile. Later evaluations then saw an unchanged status and never retried, so a transient AddAllowedIP failure left the recreated routing peer without its routed prefixes until another status transition or a route switch. The status is now recorded only once the reconcile succeeds. A failure leaves the previous status in place, so the next evaluation, at the latest the periodic re-evaluation, sees the change again and retries. --- client/internal/routemanager/client/client.go | 171 ++++++++++++--- .../routemanager/client/client_test.go | 82 +++++++ client/internal/routemanager/client/env.go | 9 +- .../client/relayed_switch_test.go | 204 +++++++++++++++--- client/internal/routemanager/manager.go | 26 +-- 5 files changed, 421 insertions(+), 71 deletions(-) diff --git a/client/internal/routemanager/client/client.go b/client/internal/routemanager/client/client.go index b882c4d4a..66c2ed491 100644 --- a/client/internal/routemanager/client/client.go +++ b/client/internal/routemanager/client/client.go @@ -65,6 +65,12 @@ const ( // well above any usable path so that slow paths keep their order. unknownLatency = 10 * time.Second + // unmeasuredLatencyHold is how long a current routing peer without a + // latency sample is kept against a measured one. A direct connection is + // sampled within seconds, so a peer still unsampled after this is ranked + // at unknownLatency rather than held indefinitely. + unmeasuredLatencyHold = 30 * time.Second + // holdReportInterval is how long a routing peer held back by a switch rule // is reported at debug level only, after being reported at info level once. holdReportInterval = 10 * time.Minute @@ -99,6 +105,7 @@ type holdKind int const ( holdLatencyMargin holdKind = iota holdLatencyDwell + holdLatencyUnmeasured holdRelayedDelay ) @@ -124,6 +131,9 @@ type routeCandidate struct { relayed bool latency time.Duration noise time.Duration + // measured is false while the peer has no latency sample, as right after a + // direct connection comes up: latency then holds unknownLatency + measured bool } func newRouteCandidate(r *route.Route, status routerPeerStatus) routeCandidate { @@ -140,6 +150,7 @@ func newRouteCandidate(r *route.Route, status routerPeerStatus) routeCandidate { relayed: status.relayed, latency: latency, noise: status.noise, + measured: status.latency > 0, } } @@ -230,6 +241,9 @@ type WatcherConfig struct { StatusRecorder *peer.Status Route *route.Route Handler RouteHandler + // ReconcileAllowedIPs re-applies the routed allowed IPs tracked for a + // routing peer onto the interface. Optional. + ReconcileAllowedIPs func(peerKey string) error } // Watcher watches route and peer changes and updates allowed IPs accordingly. @@ -271,11 +285,20 @@ type Watcher struct { trackedCurrent route.ID trackedPeer string trackedDirect bool - // degradedSince is when the current routing peer dropped from direct to - // relayed, zero if it is direct or was already relayed when chosen + // degradedSince is when the relayedSwitch delay of the current relayed + // routing peer started: its drop from direct, or the first direct contender + // if it was never direct. Zero while it is direct or uncontended. degradedSince time.Time - // relayedHold fires when a relayed switch being held back becomes due, so it - // is applied on time rather than on the next periodic re-evaluation + // unmeasuredRoute and unmeasuredSince track since when the current route's + // peer has had no latency sample, bounding the hold in shouldSwitchRoute + unmeasuredRoute route.ID + unmeasuredSince time.Time + // neverDirect marks a degradedSince started by a direct contender rather + // than by a drop from direct, which the "never" policy does not cover + neverDirect bool + // relayedHold fires when a switch held back by the relayed delay or the + // unmeasured latency hold becomes due, so it is applied on time rather than + // on the next periodic re-evaluation relayedHold *time.Timer // heldCandidate and heldKind identify the alternative last held back by the // switch rules and the rule holding it, and heldReported is when that was @@ -298,6 +321,9 @@ type Watcher struct { stopped bool handler RouteHandler updateSerial uint64 + // reconcileAllowedIPs re-applies a routing peer's routed allowed IPs when + // it connects while keeping the route, see keepChosenRoute. May be nil. + reconcileAllowedIPs func(peerKey string) error } func newPeerStateUpdate() chan struct{} { @@ -320,6 +346,7 @@ func NewWatcher(config WatcherConfig) *Watcher { handler: config.Handler, currentChosenStatus: nil, relayedSwitch: relayedSwitch(), + reconcileAllowedIPs: config.ReconcileAllowedIPs, } return client } @@ -393,6 +420,8 @@ func (w *Watcher) getBestRouteFromStatuses(routePeerStatuses map[route.ID]router } w.trackRelayed(current, haveCurrent) + w.trackUnmeasured(current, haveCurrent) + w.trackContender(current, best, haveCurrent && haveBest) if !haveBest { w.reportUnassigned() @@ -449,9 +478,8 @@ func (w *Watcher) reportUnassigned() { // trackRelayed records when the current routing peer dropped from a direct to a // relayed connection while carrying the route, which starts the relayedSwitch -// delay. A peer that was never direct, because it was relayed when chosen or -// came up over relay from idle, is not degraded: holding on to it would only -// keep the route on the slower path. +// delay at once. A peer that was never direct starts it later, when a direct +// peer first contends for the route, see relayedSwitchDue. func (w *Watcher) trackRelayed(current routeCandidate, haveCurrent bool) { if !haveCurrent { w.trackedCurrent, w.trackedPeer, w.trackedDirect, w.degradedSince = "", "", false, time.Time{} @@ -462,7 +490,7 @@ func (w *Watcher) trackRelayed(current routeCandidate, haveCurrent bool) { case current.id != w.trackedCurrent, current.peer != w.trackedPeer, !current.relayed: w.degradedSince = time.Time{} case w.trackedDirect: - w.degradedSince = time.Now() + w.degradedSince, w.neverDirect = time.Now(), false // a new degradation, reported as such even if an earlier one held // back the same candidate w.heldCandidate = "" @@ -494,6 +522,20 @@ func (w *Watcher) shouldSwitchRoute(current, candidate routeCandidate) bool { return true } + // Without a sample on both sides there is no gain to weigh. A direct + // connection reports no latency until its first sample, so comparing it as + // unknownLatency would move the route away from a peer that just recovered. + // The hold is bounded so a peer that never gets a sample does not keep the + // route for good. + if w.unmeasuredHoldActive(current, candidate) { + if level := w.holdLevel(candidate, holdLatencyUnmeasured); log.IsLevelEnabled(level) { + log.StandardLogger().Logf(level, + "keeping routing peer %s for [%v] over %s: latency not measured on both peers yet (%s)", + current.peer, w.handler, candidate.peer, candidate) + } + return false + } + gain := current.latency - candidate.latency if margin := switchMargin(current, candidate); gain < margin { if level := w.holdLevel(candidate, holdLatencyMargin); log.IsLevelEnabled(level) { @@ -518,22 +560,75 @@ func (w *Watcher) shouldSwitchRoute(current, candidate routeCandidate) bool { return true } +// trackContender restarts the relayed delay of a peer that was never direct +// once no direct contender is left. That delay runs from the first contender, +// so keeping it would let a later contender take over without waiting. The +// delay of a peer that dropped from direct keeps running from the drop. +func (w *Watcher) trackContender(current, best routeCandidate, have bool) { + if !w.neverDirect || w.degradedSince.IsZero() { + return + } + if have && best.winsOnlyOnRelay(current) { + return + } + w.degradedSince = time.Time{} +} + +// trackUnmeasured records since when the current route's peer has had no +// latency sample, and forgets it once the peer is measured or the route moves. +func (w *Watcher) trackUnmeasured(current routeCandidate, haveCurrent bool) { + if !haveCurrent || current.measured || current.id != w.unmeasuredRoute { + w.unmeasuredRoute, w.unmeasuredSince = "", time.Time{} + } + if haveCurrent && !current.measured && w.unmeasuredSince.IsZero() { + w.unmeasuredRoute, w.unmeasuredSince = current.id, time.Now() + } +} + +// unmeasuredHoldActive reports whether a latency comparison must wait because +// a side has no sample: always for an unsampled candidate, and for an unsampled +// current peer until unmeasuredLatencyHold runs out. A pending hold arms +// relayedHold so the switch is reconsidered when it runs out. +func (w *Watcher) unmeasuredHoldActive(current, candidate routeCandidate) bool { + if !candidate.measured { + return true + } + if current.measured { + return false + } + + remaining := unmeasuredLatencyHold - time.Since(w.unmeasuredSince) + if remaining <= 0 { + return false + } + w.armRelayedHold(remaining) + + return true +} + // relayedSwitchDue reports whether a relayed current routing peer should give // way to a candidate that is better only by being direct. // -// A peer that dropped from direct to relayed while carrying the route is kept -// for the relayedSwitch delay: the relay still carries the traffic, staying -// keeps every flow through this routing peer alive, and a direct connection that -// dropped often recovers within seconds. A peer that was already relayed when -// chosen gives way at once, so the route is not stuck on the slower path when a -// direct one becomes available. A switch that is not due yet arms relayedHold so -// it is applied as soon as it becomes due. +// A relayed peer is kept for the relayedSwitch delay: the relay still carries +// the traffic, and staying keeps every flow through this routing peer alive. +// For a peer that dropped from direct to relayed the delay runs from the drop, +// since a direct connection that dropped often recovers within seconds. For a +// peer that was never direct, as when the peers of a group come up over relay +// before their direct connections, it runs from the first evaluation that +// finds a direct contender: the current peer usually turns direct within +// seconds too. A switch that is not due yet arms relayedHold so it is applied +// as soon as it becomes due. func (w *Watcher) relayedSwitchDue(current, candidate routeCandidate) bool { if w.degradedSince.IsZero() { - return true + w.degradedSince, w.neverDirect = time.Now(), true } policy := w.relayedSwitch + // "never" keeps a peer whose direct connection dropped; one that was never + // direct still moves after the default delay instead of staying relayed + if policy.never && w.neverDirect { + policy = relayedSwitchPolicy{delay: defaultRelayedSwitchDelay} + } if !policy.never { remaining := policy.delay - time.Since(w.degradedSince) if remaining <= 0 { @@ -544,7 +639,7 @@ func (w *Watcher) relayedSwitchDue(current, candidate routeCandidate) bool { if level := w.holdLevel(candidate, holdRelayedDelay); log.IsLevelEnabled(level) { log.StandardLogger().Logf(level, - "keeping relayed routing peer %s for [%v] over direct %s: dropped to relay %v ago, switch delay %s", + "keeping relayed routing peer %s for [%v] over direct %s: held for %v, switch delay %s", current.peer, w.handler, candidate.peer, time.Since(w.degradedSince).Round(time.Second), policy) } @@ -677,21 +772,46 @@ func (w *Watcher) removeAllowedIPs(route *route.Route, rsn reason) error { return nil } -// shouldSkipRecalculation checks if we can skip route recalculation for the same route without status changes -func (w *Watcher) shouldSkipRecalculation(newChosenID route.ID, newStatus routerPeerStatus) bool { - if w.currentChosen == nil { +// keepChosenRoute reports whether the evaluation keeps the current route, with +// the same definition, on the same routing peer. A status change of that peer +// is recorded without removing and re-adding its allowed IPs, which would leave +// the prefix without an owner in between. When the peer connects, its routed +// allowed IPs are re-applied instead: a peer recreated by a peer update comes +// back with its overlay addresses only. +func (w *Watcher) keepChosenRoute(newChosenID route.ID, newStatus routerPeerStatus) bool { + if w.currentChosen == nil || w.currentChosen.ID != newChosenID { return false } - isSameRoute := w.currentChosen.ID == newChosenID && w.currentChosen.Equal(w.routes[newChosenID]) - if !isSameRoute { + newChosenRoute := w.routes[newChosenID] + if !w.currentChosen.Equal(newChosenRoute) { return false } - if w.currentChosenStatus != nil { - return w.currentChosenStatus.status == newStatus.status + if w.currentChosenStatus == nil || w.currentChosenStatus.status == newStatus.status { + return true } + // a failed reconcile leaves the previous status recorded, so the next + // evaluation sees the change again and retries + if newStatus.status == peer.StatusConnected && !w.reconcileRoutedAllowedIPs(newChosenRoute.Peer) { + return true + } + + w.currentChosen = newChosenRoute + w.currentChosenStatus = &newStatus + + return true +} + +func (w *Watcher) reconcileRoutedAllowedIPs(peerKey string) bool { + if w.reconcileAllowedIPs == nil { + return true + } + if err := w.reconcileAllowedIPs(peerKey); err != nil { + log.Warnf("failed to reconcile routed allowed IPs of routing peer %s for [%v], will retry: %v", peerKey, w.handler, err) + return false + } return true } @@ -719,8 +839,7 @@ func (w *Watcher) recalculateRoutes(rsn reason, routerPeerStatuses map[route.ID] return nil } - // If we can skip recalculation for the same route without changes, do nothing - if w.shouldSkipRecalculation(newChosenID, newStatus) { + if w.keepChosenRoute(newChosenID, newStatus) { return nil } diff --git a/client/internal/routemanager/client/client_test.go b/client/internal/routemanager/client/client_test.go index 764a2f74d..641383e90 100644 --- a/client/internal/routemanager/client/client_test.go +++ b/client/internal/routemanager/client/client_test.go @@ -2,6 +2,7 @@ package client import ( "context" + "errors" "fmt" "net/netip" "sync" @@ -1480,3 +1481,84 @@ func TestLatencyJitterDoesNotSwitchRoute(t *testing.T) { require.Equal(t, route.ID("route1"), chosen, "route changed on jitter at sample %d", i) } } + +// A routing peer that keeps the route while only its connection status changes, +// as when an idle peer is woken by traffic, must keep its allowed IPs in place: +// removing and re-adding them leaves the prefix without an owner in between. +func TestRecalculateRoutesStatusChangeKeepsAllowedIPs(t *testing.T) { + w, handler := newRecalcWatcher(t) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusIdle}, + })) + require.Equal(t, []string{"peer1"}, handler.added) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + })) + assert.Equal(t, []string{"peer1"}, handler.added, "a status change must not re-add the allowed IPs") + assert.Equal(t, 0, handler.removed, "a status change must not remove the allowed IPs") + require.NotNil(t, w.currentChosenStatus) + assert.Equal(t, peer.StatusConnected, w.currentChosenStatus.status, "the new status must be recorded") +} + +// A routing peer recreated by a peer update comes back idle with its overlay +// addresses only. When it connects while keeping the route, its routed allowed +// IPs must be re-applied, since nothing else puts them back on the new peer. +func TestRecalculateRoutesReconcilesAllowedIPsOnConnect(t *testing.T) { + w, handler := newRecalcWatcher(t) + var reconciled []string + w.reconcileAllowedIPs = func(peerKey string) error { + reconciled = append(reconciled, peerKey) + return nil + } + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected}, + })) + require.Equal(t, []string{"peer1"}, handler.added) + require.Empty(t, reconciled, "a fresh assignment adds the allowed IPs itself") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusIdle}, + })) + assert.Empty(t, reconciled, "going idle must not reconcile") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected}, + })) + assert.Equal(t, []string{"peer1"}, reconciled, "connecting while keeping the route must reconcile its allowed IPs") + assert.Equal(t, []string{"peer1"}, handler.added, "the reconcile must not go through the refcounted add") + assert.Equal(t, 0, handler.removed, "the allowed IPs must not be removed") +} + +// A failed reconcile must be retried: the new status is only recorded once the +// routed allowed IPs are back, so the next evaluation tries again. +func TestRecalculateRoutesRetriesFailedReconcile(t *testing.T) { + w, _ := newRecalcWatcher(t) + calls := 0 + w.reconcileAllowedIPs = func(string) error { + calls++ + if calls == 1 { + return errors.New("add allowed IP: transient failure") + } + return nil + } + + connected := map[route.ID]routerPeerStatus{"route1": {status: peer.StatusConnected}} + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, connected)) + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusIdle}, + })) + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, connected)) + require.Equal(t, 1, calls, "connecting must reconcile") + assert.Equal(t, peer.StatusIdle, w.currentChosenStatus.status, "a failed reconcile must not record the new status") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, connected)) + assert.Equal(t, 2, calls, "the next evaluation must retry the reconcile") + assert.Equal(t, peer.StatusConnected, w.currentChosenStatus.status, "a successful reconcile records the status") + + require.NoError(t, w.recalculateRoutes(reasonPeerUpdate, connected)) + assert.Equal(t, 2, calls, "nothing to retry once the reconcile succeeded") +} diff --git a/client/internal/routemanager/client/env.go b/client/internal/routemanager/client/env.go index 398667cdf..6a7c08a1f 100644 --- a/client/internal/routemanager/client/env.go +++ b/client/internal/routemanager/client/env.go @@ -12,10 +12,11 @@ import ( const ( // envRelayedSwitchDelay sets how long a routing peer that dropped from a // direct to a relayed connection while carrying the route is kept before the - // route moves to an equivalent peer with a direct connection. Takes a - // duration ("30s", "0" to switch at once) or "never" to only leave such a - // peer when it disconnects. A routing peer that was already relayed when - // chosen always gives way to a direct one at once. + // route moves to an equivalent peer with a direct connection. A routing peer + // that was never direct is kept for the same delay from when a direct peer + // first contends for the route. Takes a duration ("30s", "0" to switch at + // once) or "never" to only leave a peer that dropped from direct when it + // disconnects; a peer that was never direct then uses the default delay. envRelayedSwitchDelay = "NB_HA_RELAYED_SWITCH_DELAY" relayedSwitchDelayNever = "never" diff --git a/client/internal/routemanager/client/relayed_switch_test.go b/client/internal/routemanager/client/relayed_switch_test.go index 8f4a67bba..f70dfff50 100644 --- a/client/internal/routemanager/client/relayed_switch_test.go +++ b/client/internal/routemanager/client/relayed_switch_test.go @@ -103,11 +103,11 @@ func TestDegradedRoutingPeerIsReplacedAfterDelay(t *testing.T) { assert.Equal(t, switchReasonDirect, w.switchReason, "the switch reason must name the relayed connection") } -// A routing peer that was relayed when it was chosen is not degraded: when an -// equivalent direct peer shows up the route must upgrade at once instead of -// staying on the slower path for the delay. -func TestRelayedRoutingPeerUpgradesAtOnce(t *testing.T) { - w := newRelayedTestWatcher(t, relayedSwitchPolicy{never: true}) +// A routing peer that was relayed when it was chosen is held for the delay when +// an equivalent direct peer shows up, from that moment on: it usually turns +// direct within seconds, and moving the route resets its masqueraded flows. +func TestRelayedRoutingPeerIsKeptDuringDelay(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) // route1 was chosen while relayed and route2 was still connecting chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ @@ -115,36 +115,57 @@ func TestRelayedRoutingPeerUpgradesAtOnce(t *testing.T) { "route2": {status: peer.StatusConnecting}, }) require.Equal(t, route.ID("route1"), chosen) - require.True(t, w.degradedSince.IsZero(), "a peer relayed from the start is not degraded") + require.True(t, w.degradedSince.IsZero(), "the delay must not run before a direct peer contends") chosen, _ = w.getBestRouteFromStatuses(currentRelayed) - assert.Equal(t, route.ID("route2"), chosen, "a direct peer must take over from one that was relayed all along") - assert.Equal(t, switchReasonDirect, w.switchReason) - assert.Nil(t, w.relayedHold, "an upgrade must not schedule anything") + assert.Equal(t, route.ID("route1"), chosen, "a relayed peer must be kept when a direct one shows up") + assert.False(t, w.degradedSince.IsZero(), "the direct contender must start the delay") + assert.NotNil(t, w.relayedHold, "the pending switch must arm a timer so it is applied when due") + + w.degradedSince = w.degradedSince.Add(-31 * time.Second) + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route2"), chosen, "a peer still relayed after the delay must give way to a direct one") + assert.Equal(t, switchReasonDirect, w.switchReason, "the switch reason must name the relayed connection") } -// An idle routing peer woken by traffic whose relay connection comes up before -// the direct one was never direct: it is not degraded, and must give way to an -// equivalent direct peer at once. -func TestIdleRoutingPeerWokenOverRelayUpgradesAtOnce(t *testing.T) { - w := newRelayedTestWatcher(t, relayedSwitchPolicy{never: true}) +// The peers of an HA group woken from idle come up over relay first and turn +// direct one after the other. The route must stay on the peer it was on when +// the other one turns direct first and the current one follows within the +// delay, including before the current peer's first latency sample. +func TestIdleRoutingPeersWokenOverRelayKeepRoute(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) - chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ - "route1": {status: peer.StatusIdle}, - "route2": {status: peer.StatusIdle}, - }) - require.Equal(t, route.ID("route1"), chosen) + steps := []struct { + name string + statuses map[route.ID]routerPeerStatus + }{ + {"both idle", map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusIdle}, + "route2": {status: peer.StatusIdle}, + }}, + {"both relayed", map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnected, relayed: true}, + }}, + {"other direct first", map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnected, latency: 20 * time.Millisecond}, + }}, + {"current direct, not sampled", map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected}, + "route2": {status: peer.StatusConnected, latency: 20 * time.Millisecond}, + }}, + {"current direct, sampled", map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 38 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 20 * time.Millisecond}, + }}, + } - chosen, _ = w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ - "route1": {status: peer.StatusConnected, relayed: true}, - "route2": {status: peer.StatusConnecting}, - }) - require.Equal(t, route.ID("route1"), chosen) - require.True(t, w.degradedSince.IsZero(), "waking up over relay is not a drop from direct") - - chosen, _ = w.getBestRouteFromStatuses(currentRelayed) - assert.Equal(t, route.ID("route2"), chosen, "a direct peer must take over from one that was never direct") - assert.Nil(t, w.relayedHold, "an upgrade must not schedule anything") + for _, step := range steps { + chosen, _ := w.getBestRouteFromStatuses(step.statuses) + require.Equal(t, route.ID("route1"), chosen, "step %q must keep the route on route1", step.name) + } + assert.True(t, w.degradedSince.IsZero(), "the delay must end once the current peer is direct") } // A routing peer that drops to relay again, after recovering from an earlier @@ -356,3 +377,128 @@ func TestRoutingPeerSwitchPublishesEvent(t *testing.T) { assert.Equal(t, "No routing peer available", events[1].Message) assert.Equal(t, "peer2", events[1].Metadata["previous_peer"]) } + +// The statuses below follow the status recorder: a drop to relay clears the +// latency, and a recovered direct connection reports no latency until the new +// ICE agent's first sample lands. +var ( + flapDirect = map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, latency: 50 * time.Millisecond}, + "route2": {status: peer.StatusConnected, latency: 60 * time.Millisecond}, + } + flapRelayed = map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnected, latency: 60 * time.Millisecond}, + } + flapRecoveredUnsampled = map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected}, + "route2": {status: peer.StatusConnected, latency: 60 * time.Millisecond}, + } +) + +// A routing peer whose direct connection drops to relay and comes back within +// the delay must keep the route, including the window between the recovery and +// the first latency sample of the new connection. +func TestRecoveredRoutingPeerWithoutLatencySampleIsKept(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) + + chosen, _ := w.getBestRouteFromStatuses(flapDirect) + require.Equal(t, route.ID("route1"), chosen) + + chosen, _ = w.getBestRouteFromStatuses(flapRelayed) + require.Equal(t, route.ID("route1"), chosen, "a routing peer that just dropped to relay must be kept") + + chosen, _ = w.getBestRouteFromStatuses(flapRecoveredUnsampled) + assert.Equal(t, route.ID("route1"), chosen, "a recovered routing peer must not lose the route before its latency is sampled") +} + +// A routing peer that keeps dropping to relay for less than the delay, and +// staying direct for less than the delay in between, must keep the route on +// every cycle: each drop is a new degradation with its own delay. +func TestFlappingRoutingPeerIsKept(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) + + chosen, _ := w.getBestRouteFromStatuses(flapDirect) + require.Equal(t, route.ID("route1"), chosen) + + for cycle := 0; cycle < 3; cycle++ { + chosen, _ = w.getBestRouteFromStatuses(flapRelayed) + require.Equal(t, route.ID("route1"), chosen, "cycle %d: drop to relay", cycle) + + // relayed for 25s, still within the delay + w.degradedSince = w.degradedSince.Add(-25 * time.Second) + chosen, _ = w.getBestRouteFromStatuses(flapRelayed) + require.Equal(t, route.ID("route1"), chosen, "cycle %d: relayed within the delay", cycle) + + chosen, _ = w.getBestRouteFromStatuses(flapRecoveredUnsampled) + require.Equal(t, route.ID("route1"), chosen, "cycle %d: recovered, latency not sampled yet", cycle) + + chosen, _ = w.getBestRouteFromStatuses(flapDirect) + require.Equal(t, route.ID("route1"), chosen, "cycle %d: recovered and sampled", cycle) + } +} + +// "never" only keeps a routing peer that dropped from direct. A peer that was +// never direct must still move to a direct one after the default delay, so the +// route is not left on a relay for good. +func TestNeverPolicyDoesNotPinNeverDirectPeer(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{never: true}) + + chosen, _ := w.getBestRouteFromStatuses(map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnecting}, + }) + require.Equal(t, route.ID("route1"), chosen) + + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + require.Equal(t, route.ID("route1"), chosen, "a never-direct peer must be held for the default delay") + require.NotNil(t, w.relayedHold, "the default delay must arm the timer") + + w.degradedSince = w.degradedSince.Add(-defaultRelayedSwitchDelay - time.Second) + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route2"), chosen, "with never, a never-direct peer must still give way after the default delay") +} + +// A current peer that never gets a latency sample must not keep the route +// forever against a measured peer: once the hold runs out it is ranked as +// unknown latency again. +func TestUnmeasuredRoutingPeerHoldIsBounded(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) + + chosen, _ := w.getBestRouteFromStatuses(flapRecoveredUnsampled) + require.Equal(t, route.ID("route1"), chosen, "an unsampled current peer must be held at first") + require.False(t, w.unmeasuredSince.IsZero(), "the hold must record when it started") + + w.unmeasuredSince = w.unmeasuredSince.Add(-unmeasuredLatencyHold - time.Second) + chosen, _ = w.getBestRouteFromStatuses(flapRecoveredUnsampled) + assert.Equal(t, route.ID("route2"), chosen, "a peer still unsampled after the hold must give way to a measured one") + assert.Equal(t, switchReasonLatency, w.switchReason) +} + +// The delay of a peer that was never direct runs from the first direct +// contender. When that contender goes away the delay must start over, so a +// later contender is held for the full delay instead of taking over at once. +func TestNeverDirectDelayRestartsWhenContenderGoes(t *testing.T) { + w := newRelayedTestWatcher(t, relayedSwitchPolicy{delay: 30 * time.Second}) + + relayedAlone := map[route.ID]routerPeerStatus{ + "route1": {status: peer.StatusConnected, relayed: true}, + "route2": {status: peer.StatusConnecting}, + } + chosen, _ := w.getBestRouteFromStatuses(relayedAlone) + require.Equal(t, route.ID("route1"), chosen) + + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + require.Equal(t, route.ID("route1"), chosen, "the first direct contender must be held") + require.False(t, w.degradedSince.IsZero()) + + // the first contender was seen longer ago than the delay + w.degradedSince = w.degradedSince.Add(-31 * time.Second) + + chosen, _ = w.getBestRouteFromStatuses(relayedAlone) + require.Equal(t, route.ID("route1"), chosen) + assert.True(t, w.degradedSince.IsZero(), "the delay must start over once no direct contender is left") + + chosen, _ = w.getBestRouteFromStatuses(currentRelayed) + assert.Equal(t, route.ID("route1"), chosen, "a new direct contender must be held for the full delay") +} diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 981b0c987..7d699fbb4 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -578,12 +578,13 @@ func (m *DefaultManager) TriggerSelection(networks route.HAMap) { } config := client.WatcherConfig{ - Context: m.ctx, - DNSRouteInterval: m.dnsRouteInterval, - WGInterface: m.wgInterface, - StatusRecorder: m.statusRecorder, - Route: routes[0], - Handler: handler, + Context: m.ctx, + DNSRouteInterval: m.dnsRouteInterval, + WGInterface: m.wgInterface, + StatusRecorder: m.statusRecorder, + Route: routes[0], + Handler: handler, + ReconcileAllowedIPs: m.ReconcilePeerAllowedIPs, } clientNetworkWatcher := client.NewWatcher(config) m.clientNetworks[id] = clientNetworkWatcher @@ -628,12 +629,13 @@ func (m *DefaultManager) updateClientNetworks(updateSerial uint64, networks rout } config := client.WatcherConfig{ - Context: m.ctx, - DNSRouteInterval: m.dnsRouteInterval, - WGInterface: m.wgInterface, - StatusRecorder: m.statusRecorder, - Route: routes[0], - Handler: handler, + Context: m.ctx, + DNSRouteInterval: m.dnsRouteInterval, + WGInterface: m.wgInterface, + StatusRecorder: m.statusRecorder, + Route: routes[0], + Handler: handler, + ReconcileAllowedIPs: m.ReconcilePeerAllowedIPs, } clientNetworkWatcher = client.NewWatcher(config) m.clientNetworks[id] = clientNetworkWatcher From 9d837c6ae672c93fd23d40dafb615541d4bd6b2e Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:14:26 +0900 Subject: [PATCH 165/209] [client, management] Fix the edge cases left open in the certificate posture check (#8174) --- .github/workflows/release.yml | 2 + .goreleaser.yaml | 32 ++++- client/internal/certproof/collect.go | 14 ++- client/internal/certproof/collect_darwin.go | 8 +- client/internal/certproof/collect_test.go | 18 +-- client/internal/certproof/collect_windows.go | 6 +- .../internal/certproof/consoleuser_darwin.go | 4 +- .../certproof/consoleuser_darwin_test.go | 4 +- .../internal/certproof/desktopuser_windows.go | 12 +- .../certproof/desktopuser_windows_test.go | 8 +- client/internal/certproof/helper_spawn.go | 45 ------- .../internal/certproof/helper_spawn_test.go | 27 ---- client/internal/certproof/helper_test.go | 11 +- client/internal/certproof/keychain_darwin.go | 27 ++-- client/internal/certproof/keystore_test.go | 6 +- client/internal/certproof/merge.go | 50 ++++++++ client/internal/certproof/merge_test.go | 54 ++++++++ client/internal/certproof/pkcs11store.go | 51 ++++---- client/internal/certproof/pkcs11store_test.go | 33 +++-- client/internal/certproof/signattempts.go | 24 ++++ .../internal/certproof/signattempts_test.go | 41 +++++++ client/internal/certproof/store.go | 12 +- client/internal/certproof/store_other.go | 6 +- client/internal/certproof/store_other_test.go | 10 +- client/internal/certproof/store_tpm_test.go | 4 +- .../internal/certproof/storefile_unix_test.go | 2 +- .../internal/certproof/systemstore_windows.go | 22 ++-- client/internal/pkcs11/driver_linux.go | 69 ++++++++--- client/internal/pkcs11/driver_linux_test.go | 116 ++++++++++++++++++ client/internal/pkcs11/pkcs11.go | 1 + client/internal/pkcs11/publickey.go | 8 +- client/internal/pkcs11/publickey_test.go | 16 +++ client/internal/pkcs11/uri.go | 57 ++++++++- client/internal/pkcs11/uri_test.go | 56 +++++++++ .../network_map_db/shared_types_test.go | 22 ++++ .../certposture/certposture_test.go | 21 ++++ shared/management/client/grpc_test.go | 24 ++++ shared/management/http/api/openapi.yml | 1 + 38 files changed, 718 insertions(+), 206 deletions(-) create mode 100644 client/internal/certproof/merge.go create mode 100644 client/internal/certproof/merge_test.go create mode 100644 client/internal/certproof/signattempts.go create mode 100644 client/internal/certproof/signattempts_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3ed4763c..22ab7f566 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -417,6 +417,8 @@ jobs: path: | dist/netbird_linux** dist/netbird-pkcs11_linux** + dist/netbird-deb-32bit_linux** + dist/netbird-rpm-*_linux** retention-days: 7 - name: upload windows packages id: upload_windows_packages diff --git a/.goreleaser.yaml b/.goreleaser.yaml index de5f7419c..41c330a64 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -40,8 +40,9 @@ builds: tags: - load_wgnt_from_rsrc - # deb and rpm packages target glibc distributions, so they carry the PKCS#11 store, which - # links libc. Tarballs and the Alpine-based images keep the static build above. + # deb and rpm packages target glibc distributions, so on amd64 and arm64, the only + # architectures with a PKCS#11 driver, they carry the PKCS#11 store, which links libc. + # Tarballs, the Alpine-based images and the other architectures keep the static build. - id: netbird-pkcs11 dir: client binary: netbird @@ -49,10 +50,8 @@ builds: goos: - linux goarch: - - arm - amd64 - arm64 - - 386 ldflags: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" @@ -60,8 +59,24 @@ builds: - load_wgnt_from_rsrc - pkcs11 - # Single-arch builds: nfpm provides is not templated, so the RPM splits per arch. They - # carry the PKCS#11 store like the deb build above. + # The 32-bit deb architectures, which have no PKCS#11 driver. + - id: netbird-deb-32bit + dir: client + binary: netbird + env: [CGO_ENABLED=0] + goos: + - linux + goarch: + - arm + - 386 + ldflags: + - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser + mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - load_wgnt_from_rsrc + + # Single-arch builds: nfpm provides is not templated, so the RPM splits per arch. The + # ones with a PKCS#11 driver carry the store like the deb build above. - &netbird_rpm_build id: netbird-rpm-amd64 dir: client @@ -83,10 +98,14 @@ builds: - <<: *netbird_rpm_build id: netbird-rpm-arm goarch: [arm] + tags: + - load_wgnt_from_rsrc - <<: *netbird_rpm_build id: netbird-rpm-386 goarch: [386] + tags: + - load_wgnt_from_rsrc - id: netbird-static dir: client @@ -323,6 +342,7 @@ nfpms: bindir: /usr/bin builds: - netbird-pkcs11 + - netbird-deb-32bit formats: - deb scripts: diff --git a/client/internal/certproof/collect.go b/client/internal/certproof/collect.go index 65affca71..310fd2b19 100644 --- a/client/internal/certproof/collect.go +++ b/client/internal/certproof/collect.go @@ -149,9 +149,19 @@ func prove(signer crypto.Signer, chain []*x509.Certificate, nonce, peerKey []byt } func chainFingerprint(chain []*x509.Certificate) [sha256.Size]byte { - buf := make([]byte, 0, len(chain)*sha256.Size) + ders := make([][]byte, 0, len(chain)) for _, cert := range chain { - certHash := sha256.Sum256(cert.Raw) + ders = append(ders, cert.Raw) + } + return chainDERFingerprint(ders) +} + +// chainDERFingerprint identifies a chain of DER certificates by the hashes of all of them, +// in order. +func chainDERFingerprint(chain [][]byte) [sha256.Size]byte { + buf := make([]byte, 0, len(chain)*sha256.Size) + for _, der := range chain { + certHash := sha256.Sum256(der) buf = append(buf, certHash[:]...) } return sha256.Sum256(buf) diff --git a/client/internal/certproof/collect_darwin.go b/client/internal/certproof/collect_darwin.go index 92f0471e7..cc21563c1 100644 --- a/client/internal/certproof/collect_darwin.go +++ b/client/internal/certproof/collect_darwin.go @@ -60,7 +60,7 @@ func UserContext(cfg Config) string { if os.Geteuid() != 0 || cfg.OwnerUnknown { return "" } - user, ok := CurrentConsoleUser() + user, ok := currentConsoleUser() if !ok || !user.isOwner(cfg.ProfileOwner) { return "" } @@ -72,7 +72,7 @@ func UserContext(cfg Config) string { // per-session securityd, so the helper has to enter their Mach bootstrap namespace, // which is what launchctl asuser does. func collectAsConsoleUser(ctx context.Context, owner string, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) { - user, ok := CurrentConsoleUser() + user, ok := currentConsoleUser() if !ok { return nil, nil } @@ -126,12 +126,12 @@ func helperStore() Store { home, err := os.UserHomeDir() if err != nil { log.Debugf("certificate posture: no home directory, searching the default keychain list: %v", err) - return NewKeychainStore() + return newKeychainStore() } login := filepath.Join(home, "Library", "Keychains", "login.keychain-db") if _, err := os.Stat(login); err != nil { // Keychains created before macOS 10.12 keep the old file name. login = filepath.Join(home, "Library", "Keychains", "login.keychain") } - return NewKeychainStore(login) + return newKeychainStore(login) } diff --git a/client/internal/certproof/collect_test.go b/client/internal/certproof/collect_test.go index e16ee3668..aa033dea5 100644 --- a/client/internal/certproof/collect_test.go +++ b/client/internal/certproof/collect_test.go @@ -51,7 +51,7 @@ func TestCollect_ProvesOneMatchingCertificatePerChallenge(t *testing.T) { challenge("not a pem"), } - proofs := Collect(context.Background(), NewFileStore(dir), checks, peerKey) + proofs := Collect(context.Background(), newFileStore(dir), checks, peerKey) require.Len(t, proofs, 2) var subjects []string @@ -74,10 +74,10 @@ func TestCollect_NothingToProve(t *testing.T) { store Store checks []*proto.Checks }{ - {"no checks", NewFileStore(dir), nil}, - {"files only", NewFileStore(dir), []*proto.Checks{{Files: []string{"/bin/x"}}}}, - {"challenge without nonce", NewFileStore(dir), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{CaCertificates: []string{ca.PEM}}}}}, - {"missing store dir", NewFileStore(filepath.Join(dir, "missing")), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: make([]byte, certposture.NonceSize), CaCertificates: []string{ca.PEM}}}}}, + {"no checks", newFileStore(dir), nil}, + {"files only", newFileStore(dir), []*proto.Checks{{Files: []string{"/bin/x"}}}}, + {"challenge without nonce", newFileStore(dir), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{CaCertificates: []string{ca.PEM}}}}}, + {"missing store dir", newFileStore(filepath.Join(dir, "missing")), []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: make([]byte, certposture.NonceSize), CaCertificates: []string{ca.PEM}}}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -95,7 +95,7 @@ func TestFileStore_ChainWithIntermediate(t *testing.T) { dir := storeDir(t) writeFile(t, dir, "device.pem", certtest.CertPEM(leaf)+certtest.CertPEM(intermediate.Cert)+certtest.KeyPEM(t, key)) - candidates, err := NewFileStore(dir).Candidates(context.Background()) + candidates, err := newFileStore(dir).Candidates(context.Background()) require.NoError(t, err) require.Len(t, candidates, 1) require.Len(t, candidates[0].Chain, 2) @@ -124,7 +124,7 @@ func TestCollectChallenges_RefusesMalformedInput(t *testing.T) { dir := storeDir(t) key := certtest.ECDSAKey(t) writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key)) - store := NewFileStore(dir) + store := newFileStore(dir) nonce := certposture.NewChallenger([]byte("secret")).Nonce(peerKey, time.Now()) tests := []struct { @@ -159,7 +159,7 @@ func TestFileStore_SkipsKeyOfAnotherCertificate(t *testing.T) { good := ca.Issue(t, goodKey, "good") writeFile(t, dir, "b-good.pem", certtest.CertPEM(good)+certtest.KeyPEM(t, goodKey)) - candidates, err := NewFileStore(dir).Candidates(context.Background()) + candidates, err := newFileStore(dir).Candidates(context.Background()) require.NoError(t, err) require.Len(t, candidates, 1, "only the certificate whose key matches is a candidate") assert.True(t, good.Equal(candidates[0].Chain[0]), "the matching pair is kept") @@ -167,7 +167,7 @@ func TestFileStore_SkipsKeyOfAnotherCertificate(t *testing.T) { challenger := certposture.NewChallenger([]byte("secret")) now := time.Now() nonce := challenger.Nonce(peerKey, now) - proofs := CollectChallenges(context.Background(), NewFileStore(dir), []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{ca.PEM}}}, peerKey) + proofs := CollectChallenges(context.Background(), newFileStore(dir), []*proto.CertificateChallenge{{Nonce: nonce, CaCertificates: []string{ca.PEM}}}, peerKey) require.Len(t, proofs, 1) _, err = challenger.Verify(proofs[0], peerKey, now) assert.NoError(t, err, "the proof sent is one management accepts") diff --git a/client/internal/certproof/collect_windows.go b/client/internal/certproof/collect_windows.go index 8e5695539..3b21ec147 100644 --- a/client/internal/certproof/collect_windows.go +++ b/client/internal/certproof/collect_windows.go @@ -51,7 +51,7 @@ func UserContext(cfg Config) string { if !runningAsLocalSystem() || cfg.OwnerUnknown { return "" } - user, ok := CurrentDesktopUser(cfg.ProfileOwner) + user, ok := currentDesktopUser(cfg.ProfileOwner) if !ok { return "" } @@ -62,14 +62,14 @@ func UserContext(cfg Config) string { // helperStore is the store the helper reads. It runs as the signed-in user, so it wants // that user's store rather than the machine store the service already read. func helperStore() Store { - return NewUserStore() + return newUserStore() } // collectAsDesktopUser runs the helper inside the interactive session of the signed-in // user. Unlike a keychain on macOS, a Windows service can assume a user identity // directly, so the session token goes straight into the child process. func collectAsDesktopUser(ctx context.Context, owner string, challenges []*proto.CertificateChallenge, peerKey []byte) ([]certposture.Proof, error) { - user, ok := CurrentDesktopUser(owner) + user, ok := currentDesktopUser(owner) if !ok { return nil, nil } diff --git a/client/internal/certproof/consoleuser_darwin.go b/client/internal/certproof/consoleuser_darwin.go index a5182c7fd..1045bc014 100644 --- a/client/internal/certproof/consoleuser_darwin.go +++ b/client/internal/certproof/consoleuser_darwin.go @@ -35,10 +35,10 @@ type ConsoleUser struct { GID uint32 } -// CurrentConsoleUser reports the user sitting at the desktop. The second return value is +// currentConsoleUser reports the user sitting at the desktop. The second return value is // false when nobody is: at the login window macOS either reports no console user at all // or attributes the session to root, and neither has a login keychain to offer. -func CurrentConsoleUser() (ConsoleUser, bool) { +func currentConsoleUser() (ConsoleUser, bool) { if err := loadConsoleUser(); err != nil { log.Debugf("console user lookup unavailable: %v", err) return ConsoleUser{}, false diff --git a/client/internal/certproof/consoleuser_darwin_test.go b/client/internal/certproof/consoleuser_darwin_test.go index 9c1aaa22c..051cc4ab0 100644 --- a/client/internal/certproof/consoleuser_darwin_test.go +++ b/client/internal/certproof/consoleuser_darwin_test.go @@ -27,10 +27,10 @@ func TestConsoleUser_OnlyADesktopSessionCanBeValidated(t *testing.T) { } } -// CurrentConsoleUser runs against the real SystemConfiguration framework. A machine with +// currentConsoleUser runs against the real SystemConfiguration framework. A machine with // a desktop open must report a non-root user; a headless runner must report none. func TestCurrentConsoleUser_AgreesWithItself(t *testing.T) { - user, ok := CurrentConsoleUser() + user, ok := currentConsoleUser() if !ok { t.Log("no console user, running headless") return diff --git a/client/internal/certproof/desktopuser_windows.go b/client/internal/certproof/desktopuser_windows.go index 6f50aae4f..0a9d63b36 100644 --- a/client/internal/certproof/desktopuser_windows.go +++ b/client/internal/certproof/desktopuser_windows.go @@ -13,12 +13,8 @@ const ( noActiveSession = 0xFFFFFFFF servicesSession = 0 - // wtsCurrentServer is WTS_CURRENT_SERVER_HANDLE; wtsActive and wtsDisconnected are - // WTSActive and WTSDisconnected of WTS_CONNECTSTATE_CLASS. None is exported by - // x/sys/windows. + // wtsCurrentServer is WTS_CURRENT_SERVER_HANDLE, which x/sys/windows does not export. wtsCurrentServer = windows.Handle(0) - wtsActive = 0 - wtsDisconnected = 4 ) // DesktopUser is an interactive session and the account signed into it. The user's @@ -37,7 +33,7 @@ func (u DesktopUser) Close() { } } -// CurrentDesktopUser returns a token for the session whose certificate store should be +// currentDesktopUser returns a token for the session whose certificate store should be // asked: a session of owner, the account the active profile belongs to, with the // physical console preferred over remote sessions. With no owner only the console user // counts. Picking any signed-in user instead would let whoever else is logged in to a @@ -46,7 +42,7 @@ func (u DesktopUser) Close() { // // Obtaining the token needs SE_TCB_NAME, which the LocalSystem service has and an // ordinary process does not. -func CurrentDesktopUser(owner string) (DesktopUser, bool) { +func currentDesktopUser(owner string) (DesktopUser, bool) { console := windows.WTSGetActiveConsoleSessionId() if owner == "" { return consoleUser(console) @@ -163,7 +159,7 @@ func userSessions(console uint32) ([]uint32, error) { if console != noActiveSession && console != servicesSession { sessions = append(sessions, console) } - for _, state := range []uint32{wtsActive, wtsDisconnected} { + for _, state := range []uint32{windows.WTSActive, windows.WTSDisconnected} { for _, session := range unsafe.Slice(info, count) { if session.SessionID == servicesSession || session.SessionID == console || session.State != state { continue diff --git a/client/internal/certproof/desktopuser_windows_test.go b/client/internal/certproof/desktopuser_windows_test.go index 95d29eb5f..c3c4022e5 100644 --- a/client/internal/certproof/desktopuser_windows_test.go +++ b/client/internal/certproof/desktopuser_windows_test.go @@ -26,10 +26,10 @@ func TestSameAccountName(t *testing.T) { } } -// CurrentDesktopUser against the real session manager: an owner no session belongs to +// currentDesktopUser against the real session manager: an owner no session belongs to // must never yield a session, whoever else is signed in. func TestCurrentDesktopUser_UnknownOwnerHasNoSession(t *testing.T) { - user, ok := CurrentDesktopUser(`NO-SUCH-DOMAIN\no-such-user-netbird`) + user, ok := currentDesktopUser(`NO-SUCH-DOMAIN\no-such-user-netbird`) if ok { user.Close() } @@ -44,14 +44,14 @@ func TestCurrentDesktopUser_FindsTheOwnersSession(t *testing.T) { if owner == "" { t.Skip("set NB_TEST_DESKTOP_OWNER to a signed-in account and run as LocalSystem") } - user, ok := CurrentDesktopUser(owner) + user, ok := currentDesktopUser(owner) require.True(t, ok, "the signed-in owner %s must have a session", owner) defer user.Close() t.Logf("owner %s resolved to session %d as %s", owner, user.Session, user.Name) assert.True(t, sameAccountName(user.Name, owner), "the session found belongs to %s, not %s", owner, user.Name) assert.NotZero(t, user.Session, "session 0 hosts services and never belongs to the owner") - console, consoleOK := CurrentDesktopUser("") + console, consoleOK := currentDesktopUser("") if consoleOK { defer console.Close() t.Logf("without an owner the console user counts: session %d as %s", console.Session, console.Name) diff --git a/client/internal/certproof/helper_spawn.go b/client/internal/certproof/helper_spawn.go index 968cc3679..54ed265ec 100644 --- a/client/internal/certproof/helper_spawn.go +++ b/client/internal/certproof/helper_spawn.go @@ -3,12 +3,6 @@ package certproof import ( - "crypto/sha256" - "crypto/x509" - - log "github.com/sirupsen/logrus" - - "github.com/netbirdio/netbird/shared/management/certposture" "github.com/netbirdio/netbird/shared/management/proto" ) @@ -22,42 +16,3 @@ func helperRequest(challenges []*proto.CertificateChallenge, peerKey []byte) Hel } return req } - -// mergeProofs appends the user session proofs to the device proofs, dropping a leaf that -// both stores hold so the same certificate is proven once. -func mergeProofs(device, user []certposture.Proof) []certposture.Proof { - if len(user) == 0 { - return device - } - - seen := make(map[[sha256.Size]byte]struct{}, len(device)) - for _, proof := range device { - if len(proof.Chain) > 0 { - seen[sha256.Sum256(proof.Chain[0])] = struct{}{} - } - } - - merged := device - for _, proof := range user { - if len(proof.Chain) == 0 { - continue - } - fingerprint := sha256.Sum256(proof.Chain[0]) - if _, done := seen[fingerprint]; done { - continue - } - seen[fingerprint] = struct{}{} - merged = append(merged, proof) - logUserProof(proof) - } - return merged -} - -func logUserProof(proof certposture.Proof) { - leaf, err := x509.ParseCertificate(proof.Chain[0]) - if err != nil { - log.Debugf("certificate posture: user proof carries an unparsable leaf: %v", err) - return - } - log.Debugf("certificate posture: signed-in user proved %q issued by %q", leaf.Subject, leaf.Issuer) -} diff --git a/client/internal/certproof/helper_spawn_test.go b/client/internal/certproof/helper_spawn_test.go index 338a5f66a..74a792a49 100644 --- a/client/internal/certproof/helper_spawn_test.go +++ b/client/internal/certproof/helper_spawn_test.go @@ -3,42 +3,15 @@ package certproof import ( - "crypto/sha256" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/netbirdio/netbird/shared/management/certposture" "github.com/netbirdio/netbird/shared/management/certposture/certtest" "github.com/netbirdio/netbird/shared/management/proto" ) -func TestMergeProofs_ProvesACertificateHeldByBothStoresOnce(t *testing.T) { - ca := certtest.NewCA(t, "corp-root") - shared := ca.Issue(t, certtest.ECDSAKey(t), "shared") - userOnly := ca.Issue(t, certtest.ECDSAKey(t), "user-only") - - device := []certposture.Proof{{Chain: [][]byte{shared.Raw}}} - user := []certposture.Proof{{Chain: [][]byte{shared.Raw}}, {Chain: [][]byte{userOnly.Raw}}, {}} - - merged := mergeProofs(device, user) - - require.Len(t, merged, 2, "the shared leaf is proven once and the empty chain is dropped") - assert.Equal(t, shared.Raw, merged[0].Chain[0], "the device proof keeps its place") - assert.Equal(t, userOnly.Raw, merged[1].Chain[0], "the user-only certificate is appended") -} - -func TestMergeProofs_KeepsDeviceProofsWhenNoUserSession(t *testing.T) { - ca := certtest.NewCA(t, "corp-root") - device := []certposture.Proof{{Chain: [][]byte{ca.Issue(t, certtest.ECDSAKey(t), "device").Raw}}} - - merged := mergeProofs(device, nil) - - require.Len(t, merged, 1, "a machine with nobody signed in still sends its device proof") - assert.Equal(t, sha256.Sum256(device[0].Chain[0]), sha256.Sum256(merged[0].Chain[0]), "the device proof is unchanged") -} - func TestHelperRequest_CarriesEveryChallenge(t *testing.T) { ca := certtest.NewCA(t, "corp-root") challenges := []*proto.CertificateChallenge{ diff --git a/client/internal/certproof/helper_test.go b/client/internal/certproof/helper_test.go index 37dadead2..61fcc8532 100644 --- a/client/internal/certproof/helper_test.go +++ b/client/internal/certproof/helper_test.go @@ -14,7 +14,10 @@ import ( "github.com/netbirdio/netbird/shared/management/certposture/certtest" ) -func TestRunHelper_ProofSurvivesTheProcessBoundary(t *testing.T) { +// TestRunHelper_AnswersAChallengeFromItsStore runs the helper's request and response +// handling in-process: the proof it writes verifies as the daemon will verify it. The +// process boundary itself is covered by the runHelperCmd tests. +func TestRunHelper_AnswersAChallengeFromItsStore(t *testing.T) { ca := certtest.NewCA(t, "corp-root") dir := storeDir(t) key := certtest.ECDSAKey(t) @@ -29,7 +32,7 @@ func TestRunHelper_ProofSurvivesTheProcessBoundary(t *testing.T) { require.NoError(t, err, "request must encode") var stdout bytes.Buffer - require.NoError(t, runHelper(context.Background(), NewFileStore(dir), bytes.NewReader(request), &stdout)) + require.NoError(t, runHelper(context.Background(), newFileStore(dir), bytes.NewReader(request), &stdout)) var resp HelperResponse require.NoError(t, json.Unmarshal(stdout.Bytes(), &resp), "helper must emit decodable JSON") @@ -47,7 +50,7 @@ func TestRunHelper_NoChallengesYieldsEmptyResponse(t *testing.T) { require.NoError(t, err) var stdout bytes.Buffer - require.NoError(t, runHelper(context.Background(), NewFileStore(storeDir(t)), bytes.NewReader(request), &stdout)) + require.NoError(t, runHelper(context.Background(), newFileStore(storeDir(t)), bytes.NewReader(request), &stdout)) var resp HelperResponse require.NoError(t, json.Unmarshal(stdout.Bytes(), &resp), "an empty request must still emit valid JSON") @@ -56,7 +59,7 @@ func TestRunHelper_NoChallengesYieldsEmptyResponse(t *testing.T) { func TestRunHelper_RejectsMalformedRequest(t *testing.T) { var stdout bytes.Buffer - err := runHelper(context.Background(), NewFileStore(storeDir(t)), bytes.NewReader([]byte("not json")), &stdout) + err := runHelper(context.Background(), newFileStore(storeDir(t)), bytes.NewReader([]byte("not json")), &stdout) require.Error(t, err, "a malformed request must fail rather than emit an empty proof set") assert.Empty(t, stdout.String(), "nothing should be written to stdout on a decode failure") diff --git a/client/internal/certproof/keychain_darwin.go b/client/internal/certproof/keychain_darwin.go index 2bffdc81e..5b1426997 100644 --- a/client/internal/certproof/keychain_darwin.go +++ b/client/internal/certproof/keychain_darwin.go @@ -71,25 +71,25 @@ const systemKeychain = "/Library/Keychains/System.keychain" // by an ordinary user, it is that user's keychain search list. func DefaultStore() Store { if os.Geteuid() == 0 { - return NewKeychainStore(systemKeychain) + return newKeychainStore(systemKeychain) } - return NewKeychainStore() + return newKeychainStore() } -// KeychainStore yields the identities of a set of keychains, or of the process's keychain +// keychainStore yields the identities of a set of keychains, or of the process's keychain // search list when none is named, reached through purego so the client keeps building // with CGO_ENABLED=0. -type KeychainStore struct { +type keychainStore struct { keychains []string } -// NewKeychainStore returns a store that searches the keychain files at paths, or the +// newKeychainStore returns a store that searches the keychain files at paths, or the // process's keychain search list when no path is given. -func NewKeychainStore(paths ...string) *KeychainStore { - return &KeychainStore{keychains: paths} +func newKeychainStore(paths ...string) *keychainStore { + return &keychainStore{keychains: paths} } -func (s *KeychainStore) Candidates(_ context.Context) ([]Candidate, error) { +func (s *keychainStore) Candidates(_ context.Context) ([]Candidate, error) { if err := loadKeychain(); err != nil { return nil, err } @@ -163,17 +163,24 @@ func (s *keychainSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts algorithm := keychainAlgorithm(scheme) var signature []byte + var attempts signAttempts err = eachIdentity(searchList, func(identity uintptr, der []byte) (bool, error) { if !bytes.Equal(der, s.leaf.Raw) { return false, nil } - signature, err = signWithIdentity(identity, algorithm, digest) - return true, err + // The same certificate can sit in the keychain with more than one key, of which + // only one may be usable, so a failure moves on to the next match. + var ok bool + signature, ok = attempts.try(func() ([]byte, error) { return signWithIdentity(identity, algorithm, digest) }) + return ok, nil }) if err != nil { return nil, err } if signature == nil { + if err := attempts.err(); err != nil { + return nil, err + } return nil, errors.New("certificate is no longer in the keychain") } log.Debugf("keychain signed certificate posture challenge for %q, %d bytes", s.leaf.Subject, len(signature)) diff --git a/client/internal/certproof/keystore_test.go b/client/internal/certproof/keystore_test.go index 592d7414f..7d028c4fb 100644 --- a/client/internal/certproof/keystore_test.go +++ b/client/internal/certproof/keystore_test.go @@ -56,6 +56,8 @@ func TestSchemeFor(t *testing.T) { {"ecdsa sha384", ecKey.Public(), crypto.SHA384, schemeECDSASHA384}, {"rsa pss sha256", rsaKey.Public(), pss, schemeRSAPSSSHA256}, {"rsa pkcs1v15", rsaKey.Public(), crypto.SHA256, 0}, + {"ecdsa sha512", ecKey.Public(), crypto.SHA512, 0}, + {"rsa pss sha384", rsaKey.Public(), &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: crypto.SHA384}, 0}, {"ed25519", certtest.Ed25519Key(t).Public(), crypto.Hash(0), 0}, } for _, tc := range tests { @@ -83,5 +85,7 @@ func TestECDSASignatureASN1(t *testing.T) { assert.True(t, ecdsa.VerifyASN1(&key.PublicKey, digest[:], der)) _, err = ecdsaSignatureASN1(raw[:63]) - assert.Error(t, err) + assert.Error(t, err, "an odd length cannot split into r and s") + _, err = ecdsaSignatureASN1(nil) + assert.Error(t, err, "an empty signature is malformed") } diff --git a/client/internal/certproof/merge.go b/client/internal/certproof/merge.go new file mode 100644 index 000000000..d539ce927 --- /dev/null +++ b/client/internal/certproof/merge.go @@ -0,0 +1,50 @@ +package certproof + +import ( + "crypto/sha256" + "crypto/x509" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/shared/management/certposture" +) + +// mergeProofs appends the user session proofs to the device proofs, dropping one whose +// chain the device already proved. Management checks each chain against each check's CAs, +// so the same leaf proven through another chain is kept. +func mergeProofs(device, user []certposture.Proof) []certposture.Proof { + if len(user) == 0 { + return device + } + + seen := make(map[[sha256.Size]byte]struct{}, len(device)+len(user)) + for _, proof := range device { + if len(proof.Chain) > 0 { + seen[chainDERFingerprint(proof.Chain)] = struct{}{} + } + } + + merged := device + for _, proof := range user { + if len(proof.Chain) == 0 { + continue + } + fingerprint := chainDERFingerprint(proof.Chain) + if _, done := seen[fingerprint]; done { + continue + } + seen[fingerprint] = struct{}{} + merged = append(merged, proof) + logUserProof(proof) + } + return merged +} + +func logUserProof(proof certposture.Proof) { + leaf, err := x509.ParseCertificate(proof.Chain[0]) + if err != nil { + log.Debugf("certificate posture: user proof carries an unparsable leaf: %v", err) + return + } + log.Debugf("certificate posture: signed-in user proved %q issued by %q", leaf.Subject, leaf.Issuer) +} diff --git a/client/internal/certproof/merge_test.go b/client/internal/certproof/merge_test.go new file mode 100644 index 000000000..7db2dd421 --- /dev/null +++ b/client/internal/certproof/merge_test.go @@ -0,0 +1,54 @@ +package certproof + +import ( + "crypto/sha256" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/certposture" + "github.com/netbirdio/netbird/shared/management/certposture/certtest" +) + +func TestMergeProofs_ProvesACertificateHeldByBothStoresOnce(t *testing.T) { + ca := certtest.NewCA(t, "corp-root") + shared := ca.Issue(t, certtest.ECDSAKey(t), "shared") + userOnly := ca.Issue(t, certtest.ECDSAKey(t), "user-only") + + device := []certposture.Proof{{Chain: [][]byte{shared.Raw}}} + user := []certposture.Proof{{Chain: [][]byte{shared.Raw}}, {Chain: [][]byte{userOnly.Raw}}, {}} + + merged := mergeProofs(device, user) + + require.Len(t, merged, 2, "the shared leaf is proven once and the empty chain is dropped") + assert.Equal(t, shared.Raw, merged[0].Chain[0], "the device proof keeps its place") + assert.Equal(t, userOnly.Raw, merged[1].Chain[0], "the user-only certificate is appended") +} + +func TestMergeProofs_KeepsDeviceProofsWhenNoUserSession(t *testing.T) { + ca := certtest.NewCA(t, "corp-root") + device := []certposture.Proof{{Chain: [][]byte{ca.Issue(t, certtest.ECDSAKey(t), "device").Raw}}} + + merged := mergeProofs(device, nil) + + require.Len(t, merged, 1, "a machine with nobody signed in still sends its device proof") + assert.Equal(t, sha256.Sum256(device[0].Chain[0]), sha256.Sum256(merged[0].Chain[0]), "the device proof is unchanged") +} + +func TestMergeProofs_KeepsAnotherChainOfTheSameLeaf(t *testing.T) { + root := certtest.NewCA(t, "corp-root") + interA := root.Issue(t, certtest.ECDSAKey(t), "inter-a") + interB := root.Issue(t, certtest.ECDSAKey(t), "inter-b") + leaf := root.Issue(t, certtest.ECDSAKey(t), "leaf") + + // Management checks each chain against each check's CAs, so the same leaf proven + // through another chain is a different proof, not a duplicate. + device := []certposture.Proof{{Chain: [][]byte{leaf.Raw, interA.Raw}}} + user := []certposture.Proof{{Chain: [][]byte{leaf.Raw, interB.Raw}}, {Chain: [][]byte{leaf.Raw, interA.Raw}}} + + merged := mergeProofs(device, user) + + require.Len(t, merged, 2, "the chain only the user store holds is kept, the one both hold is not repeated") + assert.Equal(t, interB.Raw, merged[1].Chain[1], "the user's chain through the other intermediate is appended") +} diff --git a/client/internal/certproof/pkcs11store.go b/client/internal/certproof/pkcs11store.go index 1f88e7600..381f55799 100644 --- a/client/internal/certproof/pkcs11store.go +++ b/client/internal/certproof/pkcs11store.go @@ -38,24 +38,24 @@ func PKCS11FromEnv() PKCS11Config { return PKCS11Config{URI: os.Getenv(PKCS11URIEnv), PIN: os.Getenv(PINEnv)} } -// PKCS11Store yields the identities of a PKCS#11 token, which is how tpm2-pkcs11 exposes +// pkcs11Store yields the identities of a PKCS#11 token, which is how tpm2-pkcs11 exposes // TPM-held keys on Linux. Certificates on the token are paired with keys by CKA_ID, the // convention tpm2_ptool addcert and pkcs11-tool follow; certificate files in the PEM // directory by public key. Every signature happens on the token. -type PKCS11Store struct { +type pkcs11Store struct { uri *pkcs11.URI pin string certDir string } -// NewPKCS11Store parses cfg.URI, standing in the bare defaults when it is empty. Files in +// newPKCS11Store parses cfg.URI, standing in the bare defaults when it is empty. Files in // certDir without a key of their own are paired with the token's keys by public key. // // A PIN is only accepted together with a token label: without one the PIN would go to // whichever token the module lists first, which on a machine with a smartcard plugged // in may be the card, and every wrong PIN counts towards that card's lockout. -func NewPKCS11Store(cfg PKCS11Config, certDir string) (*PKCS11Store, error) { - store := &PKCS11Store{uri: &pkcs11.URI{}, pin: cfg.PIN, certDir: certDir} +func newPKCS11Store(cfg PKCS11Config, certDir string) (*pkcs11Store, error) { + store := &pkcs11Store{uri: &pkcs11.URI{}, pin: cfg.PIN, certDir: certDir} if cfg.URI != "" { parsed, err := pkcs11.ParseURI(cfg.URI) if err != nil { @@ -69,7 +69,7 @@ func NewPKCS11Store(cfg PKCS11Config, certDir string) (*PKCS11Store, error) { return store, nil } -func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) { +func (s *pkcs11Store) Candidates(_ context.Context) ([]Candidate, error) { session, err := s.open() if err != nil { return nil, err @@ -80,7 +80,7 @@ func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) { if err != nil { return nil, err } - fileChains, err := s.fileChains() + fileChains, fileIntermediates, err := s.fileChains() if err != nil { return nil, err } @@ -93,6 +93,7 @@ func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) { for _, chain := range fileChains { pool = append(pool, chain...) } + pool = append(pool, fileIntermediates...) var candidates []Candidate for _, cert := range certs { @@ -106,9 +107,12 @@ func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) { return candidates, nil } + // A token that cannot list its public keys still answers for the certificates it + // holds, so the identities found above are kept. keys, err := tokenPublicKeys(session) if err != nil { - return nil, err + log.Warnf("%s: cannot read its public keys, certificate files are not paired with them: %v", s, err) + return candidates, nil } for _, chain := range fileChains { leaf := chain[0] @@ -122,41 +126,45 @@ func (s *PKCS11Store) Candidates(_ context.Context) ([]Candidate, error) { return candidates, nil } -func (s *PKCS11Store) candidate(leaf *x509.Certificate, id []byte, pool []*x509.Certificate) Candidate { +func (s *pkcs11Store) candidate(leaf *x509.Certificate, id []byte, pool []*x509.Certificate) Candidate { chain := buildChain(leaf, pool) log.Debugf("%s candidate %q issued by %q built a chain of %d certificates", s, leaf.Subject, leaf.Issuer, len(chain)) return Candidate{Chain: chain, Signer: &pkcs11Signer{store: s, leaf: leaf, id: id}, Intermediates: pool} } // fileChains reads the certificate files in the PEM directory that carry no key of their -// own; the file store answers for the ones that do. -func (s *PKCS11Store) fileChains() ([][]*x509.Certificate, error) { +// own; the file store answers for the ones that do. The certificates of a file that does +// carry a key are returned too, since they can complete the chain of a token certificate. +func (s *pkcs11Store) fileChains() (chains [][]*x509.Certificate, intermediates []*x509.Certificate, err error) { if s.certDir == "" { - return nil, nil + return nil, nil, nil } paths, err := certFiles(s.certDir) if err != nil { - return nil, err + return nil, nil, err } - var chains [][]*x509.Certificate for _, path := range paths { chain, signer, err := loadPEM(path) - if err != nil || signer != nil { + if err != nil { + continue + } + if signer != nil { + intermediates = append(intermediates, chain...) continue } chains = append(chains, chain) } - return chains, nil + return chains, intermediates, nil } -func (s *PKCS11Store) String() string { +func (s *pkcs11Store) String() string { if s.uri.Token == "" { return "PKCS#11 token" } return fmt.Sprintf("PKCS#11 token %q", s.uri.Token) } -func (s *PKCS11Store) open() (*pkcs11.Session, error) { +func (s *pkcs11Store) open() (*pkcs11.Session, error) { module, err := pkcs11.Load(s.uri.Module()) if err != nil { return nil, err @@ -185,7 +193,7 @@ func (s *PKCS11Store) open() (*pkcs11.Session, error) { return session, err } -func (s *PKCS11Store) userPIN() ([]byte, error) { +func (s *pkcs11Store) userPIN() ([]byte, error) { if s.pin != "" { return []byte(s.pin), nil } @@ -208,7 +216,8 @@ func tokenPublicKeys(session *pkcs11.Session) (tokenKeys, error) { for _, object := range objects { id, err := session.Attribute(object, pkcs11.AttrID) if err != nil { - return nil, err + log.Debugf("skipping public key on PKCS#11 token: %v", err) + continue } public, err := session.PublicKey(object) if err != nil { @@ -287,7 +296,7 @@ func privateKey(session *pkcs11.Session, id []byte) (pkcs11.Object, error) { // pkcs11Signer holds only the certificate and its CKA_ID; the key is looked up in a fresh // session at signing time so no token handle outlives a call. type pkcs11Signer struct { - store *PKCS11Store + store *pkcs11Store leaf *x509.Certificate id []byte } diff --git a/client/internal/certproof/pkcs11store_test.go b/client/internal/certproof/pkcs11store_test.go index 485c84aae..746b54918 100644 --- a/client/internal/certproof/pkcs11store_test.go +++ b/client/internal/certproof/pkcs11store_test.go @@ -44,7 +44,7 @@ func TestStores_KeepsFileCertificatesWhenTokenFails(t *testing.T) { dir := storeDir(t) writeFile(t, dir, "device.pem", certtest.CertPEM(ca.Issue(t, key, "device"))+certtest.KeyPEM(t, key)) - candidates, err := Stores{failingStore{}, NewFileStore(dir)}.Candidates(context.Background()) + candidates, err := Stores{failingStore{}, newFileStore(dir)}.Candidates(context.Background()) require.NoError(t, err) assert.Len(t, candidates, 1, "the directory's certificate must survive a failing token") } @@ -241,13 +241,13 @@ func attr(typ uint, value []byte) pkcs11.Attribute { // pkcs11TestStore builds the store for the token NB_TEST_PKCS11_URI names, skipping when // no token is configured or this build lacks PKCS#11 support. -func pkcs11TestStore(t *testing.T, certDir string) (*PKCS11Store, string) { +func pkcs11TestStore(t *testing.T, certDir string) (*pkcs11Store, string) { t.Helper() uri := os.Getenv(testPKCS11URIEnv) if uri == "" { t.Skipf("set %s to a PKCS#11 URI with a PIN to run", testPKCS11URIEnv) } - store, err := NewPKCS11Store(PKCS11Config{URI: uri}, certDir) + store, err := newPKCS11Store(PKCS11Config{URI: uri}, certDir) require.NoError(t, err) if _, err := pkcs11.Load(store.uri.Module()); errors.Is(err, pkcs11.ErrUnsupported) { t.Skip(err) @@ -297,17 +297,30 @@ func TestPKCS11Store_FileChains(t *testing.T) { writeFile(t, dir, "sibling.key", certtest.KeyPEM(t, keyed)) writeFile(t, dir, "notes.txt", "not a certificate") - chains, err := (&PKCS11Store{uri: &pkcs11.URI{}, certDir: dir}).fileChains() + // An intermediate that only a keyed file carries still has to reach the pool a token + // certificate's chain is built from. + root := certtest.NewCA(t, "corp-root") + intermediateCA := certtest.NewIntermediate(t, root, "corp-intermediate") + chained := certtest.ECDSAKey(t) + writeFile(t, dir, "chained.pem", certtest.CertPEM(intermediateCA.Issue(t, chained, "chained"))+certtest.CertPEM(intermediateCA.Cert)+certtest.KeyPEM(t, chained)) + + chains, extra, err := (&pkcs11Store{uri: &pkcs11.URI{}, certDir: dir}).fileChains() require.NoError(t, err) + require.NotEmpty(t, extra, "the keyed file's certificates are offered for chain building") + tokenLeaf := intermediateCA.Issue(t, certtest.ECDSAKey(t), "token-leaf") + chain := buildChain(tokenLeaf, extra) + require.Len(t, chain, 2, "a token certificate is completed through the intermediate only the keyed file holds") + assert.True(t, intermediateCA.Cert.Equal(chain[1]), "the second certificate is that intermediate") var subjects []string for _, chain := range chains { subjects = append(subjects, chain[0].Subject.CommonName) } assert.ElementsMatch(t, []string{"device", "corp"}, subjects, "only key-less certificate files are left to the token") - chains, err = (&PKCS11Store{uri: &pkcs11.URI{}}).fileChains() + chains, extra, err = (&pkcs11Store{uri: &pkcs11.URI{}}).fileChains() require.NoError(t, err) assert.Empty(t, chains, "no directory configured means no file certificates") + assert.Empty(t, extra, "and no extra intermediates") } func TestNewPKCS11Store_PIN(t *testing.T) { @@ -324,7 +337,7 @@ func TestNewPKCS11Store_PIN(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - store, err := NewPKCS11Store(tt.cfg, "") + store, err := newPKCS11Store(tt.cfg, "") require.NoError(t, err) pin, err := store.userPIN() require.NoError(t, err) @@ -333,7 +346,7 @@ func TestNewPKCS11Store_PIN(t *testing.T) { }) } - _, err := NewPKCS11Store(PKCS11Config{URI: "not-a-pkcs11-uri", PIN: "1234"}, "") + _, err := newPKCS11Store(PKCS11Config{URI: "not-a-pkcs11-uri", PIN: "1234"}, "") assert.Error(t, err, "a malformed URI must not be silently replaced by the defaults") for name, cfg := range map[string]PKCS11Config{ @@ -342,7 +355,7 @@ func TestNewPKCS11Store_PIN(t *testing.T) { "inline pin-value only": {URI: "pkcs11:?pin-value=0000"}, "pin-source only": {URI: "pkcs11:?pin-source=file:/etc/netbird/pkcs11.pin"}, } { - _, err := NewPKCS11Store(cfg, "") + _, err := newPKCS11Store(cfg, "") assert.ErrorIs(t, err, errPINNeedsToken, "%s: a PIN must not go to whichever token is listed first", name) } } @@ -360,7 +373,7 @@ func TestPKCS11Store_WrongPINIsTriedOnce(t *testing.T) { _, uri := pkcs11TestStore(t, "") wrongPIN := "wrong-pin-" + t.Name() - store, err := NewPKCS11Store(PKCS11Config{URI: uri, PIN: wrongPIN}, "") + store, err := newPKCS11Store(PKCS11Config{URI: uri, PIN: wrongPIN}, "") require.NoError(t, err) _, err = store.Candidates(context.Background()) @@ -370,7 +383,7 @@ func TestPKCS11Store_WrongPINIsTriedOnce(t *testing.T) { _, err = store.Candidates(context.Background()) assert.ErrorIs(t, err, errPINRejectedBefore, "the rejected PIN is not sent to the token again") - good, err := NewPKCS11Store(PKCS11Config{URI: uri}, "") + good, err := newPKCS11Store(PKCS11Config{URI: uri}, "") require.NoError(t, err) _, err = good.Candidates(context.Background()) assert.NoError(t, err, "the correct PIN for the same token is unaffected") diff --git a/client/internal/certproof/signattempts.go b/client/internal/certproof/signattempts.go new file mode 100644 index 000000000..b056991bf --- /dev/null +++ b/client/internal/certproof/signattempts.go @@ -0,0 +1,24 @@ +package certproof + +import "errors" + +// signAttempts tracks the identities tried to sign with, for a certificate that can match +// more than one of them. +type signAttempts struct { + errs []error +} + +// try runs sign and reports its signature and whether it succeeded. A failure is kept. +func (a *signAttempts) try(sign func() ([]byte, error)) ([]byte, bool) { + signature, err := sign() + if err != nil { + a.errs = append(a.errs, err) + return nil, false + } + return signature, true +} + +// err joins the failures of the attempts, nil when there were none. +func (a *signAttempts) err() error { + return errors.Join(a.errs...) +} diff --git a/client/internal/certproof/signattempts_test.go b/client/internal/certproof/signattempts_test.go new file mode 100644 index 000000000..f5a2fef4f --- /dev/null +++ b/client/internal/certproof/signattempts_test.go @@ -0,0 +1,41 @@ +package certproof + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var errNeedsApprovalForTest = errors.New("key needs approval") + +// TestSignAttempts_FallsThroughToTheNextIdentity: a certificate can sit in a keychain with +// more than one key, of which only some can sign. A failure moves on, and an identity that +// signs ends the search without reporting the earlier failure. +func TestSignAttempts_FallsThroughToTheNextIdentity(t *testing.T) { + var attempts signAttempts + + signature, ok := attempts.try(func() ([]byte, error) { return nil, errNeedsApprovalForTest }) + assert.False(t, ok, "a failing identity does not end the search") + assert.Nil(t, signature) + + signature, ok = attempts.try(func() ([]byte, error) { return []byte("sig"), nil }) + require.True(t, ok, "the next identity signs") + assert.Equal(t, []byte("sig"), signature) +} + +// TestSignAttempts_KeepsEveryFailure: when no identity signs, the approval a user has to +// give must not be hidden behind the failure of a later identity. +func TestSignAttempts_KeepsEveryFailure(t *testing.T) { + var attempts signAttempts + assert.NoError(t, attempts.err(), "no attempts, no error") + + attempts.try(func() ([]byte, error) { return nil, errNeedsApprovalForTest }) + attempts.try(func() ([]byte, error) { return nil, errors.New("key is gone") }) + + err := attempts.err() + require.Error(t, err) + assert.ErrorIs(t, err, errNeedsApprovalForTest, "the earlier approval error survives the later failure") + assert.ErrorContains(t, err, "key is gone") +} diff --git a/client/internal/certproof/store.go b/client/internal/certproof/store.go index 295d6caa4..85ab7cda7 100644 --- a/client/internal/certproof/store.go +++ b/client/internal/certproof/store.go @@ -46,7 +46,7 @@ func (c Candidate) issuers() []*x509.Certificate { return append(slices.Clip(c.Chain[1:]), c.Intermediates...) } -// Store yields the certificates a peer may prove possession of. FileStore is the PEM +// Store yields the certificates a peer may prove possession of. fileStore is the PEM // directory implementation; OS keystores (CNG, Keychain, PKCS#11) slot in here. type Store interface { Candidates(ctx context.Context) ([]Candidate, error) @@ -68,15 +68,15 @@ type Config struct { OwnerUnknown bool } -// FileStore reads PEM files from a directory. A file holds the chain (leaf first) and +// fileStore reads PEM files from a directory. A file holds the chain (leaf first) and // either its private key or a sibling ".key" file holds it. The key is a plain // PKCS#8, EC or RSA key, or a TSS2 key the TPM signs with. -type FileStore struct { +type fileStore struct { dir string } -func NewFileStore(dir string) *FileStore { - return &FileStore{dir: dir} +func newFileStore(dir string) *fileStore { + return &fileStore{dir: dir} } func StoreDir() string { @@ -86,7 +86,7 @@ func StoreDir() string { return defaultStoreDir } -func (s *FileStore) Candidates(_ context.Context) ([]Candidate, error) { +func (s *fileStore) Candidates(_ context.Context) ([]Candidate, error) { paths, err := certFiles(s.dir) if err != nil { return nil, err diff --git a/client/internal/certproof/store_other.go b/client/internal/certproof/store_other.go index e6646732b..eeecf3c39 100644 --- a/client/internal/certproof/store_other.go +++ b/client/internal/certproof/store_other.go @@ -14,13 +14,13 @@ var unsupportedTokenOnce sync.Once // DefaultStore is the PEM directory named by NB_CERT_STORE_DIR, or /etc/netbird/certs. func DefaultStore() Store { - return NewFileStore(StoreDir()) + return newFileStore(StoreDir()) } // storeWithToken reads the PEM directory cfg names, joined by the PKCS#11 token when cfg // names one. The token pairs the directory's key-less certificates with its own keys. func storeWithToken(cfg Config) Store { - files := NewFileStore(StoreDir()) + files := newFileStore(StoreDir()) if cfg.PKCS11.URI == "" && cfg.PKCS11.PIN == "" { return files } @@ -30,7 +30,7 @@ func storeWithToken(cfg Config) Store { }) return files } - token, err := NewPKCS11Store(cfg.PKCS11, StoreDir()) + token, err := newPKCS11Store(cfg.PKCS11, StoreDir()) if err != nil { log.Warnf("ignoring PKCS#11 URI: %v", err) return files diff --git a/client/internal/certproof/store_other_test.go b/client/internal/certproof/store_other_test.go index 47b05f058..68b4df2a6 100644 --- a/client/internal/certproof/store_other_test.go +++ b/client/internal/certproof/store_other_test.go @@ -15,12 +15,12 @@ func TestStoreWithToken(t *testing.T) { dir := storeDir(t) t.Setenv(StoreDirEnv, dir) - files, ok := storeWithToken(Config{}).(*FileStore) + files, ok := storeWithToken(Config{}).(*fileStore) require.True(t, ok, "no token configured reads the PEM directory alone") assert.Equal(t, dir, files.dir, "the directory comes from NB_CERT_STORE_DIR") - assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{URI: "not-a-pkcs11-uri"}}), "an invalid URI must not hide the PEM directory") - assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{PIN: "1234"}}), "a PIN naming no token is refused and leaves the PEM directory") + assert.IsType(t, &fileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{URI: "not-a-pkcs11-uri"}}), "an invalid URI must not hide the PEM directory") + assert.IsType(t, &fileStore{}, storeWithToken(Config{PKCS11: PKCS11Config{PIN: "1234"}}), "a PIN naming no token is refused and leaves the PEM directory") configured := map[string]PKCS11Config{ "env pin with token uri": {URI: "pkcs11:token=netbird", PIN: "1234"}, @@ -28,7 +28,7 @@ func TestStoreWithToken(t *testing.T) { } if !pkcs11.Supported() { for name, cfg := range configured { - assert.IsType(t, &FileStore{}, storeWithToken(Config{PKCS11: cfg}), + assert.IsType(t, &fileStore{}, storeWithToken(Config{PKCS11: cfg}), "%s: a build without PKCS#11 support reads the PEM directory alone", name) } return @@ -37,7 +37,7 @@ func TestStoreWithToken(t *testing.T) { store, ok := storeWithToken(Config{PKCS11: cfg}).(Stores) require.True(t, ok, "%s joins the token to the PEM directory", name) require.Len(t, store, 2, name) - token, ok := store[1].(*PKCS11Store) + token, ok := store[1].(*pkcs11Store) require.True(t, ok, name) assert.Equal(t, dir, token.certDir, "%s: the token pairs certificates from the same directory", name) } diff --git a/client/internal/certproof/store_tpm_test.go b/client/internal/certproof/store_tpm_test.go index f8a7a0960..cce570891 100644 --- a/client/internal/certproof/store_tpm_test.go +++ b/client/internal/certproof/store_tpm_test.go @@ -36,7 +36,7 @@ func TestFileStore_TPMKeyFile(t *testing.T) { writeFile(t, dir, "locked.pem", certtest.CertPEM(ca.Issue(t, locked, "locked"))) writeFile(t, dir, "locked.key", tpmtest.KeyPEM(t, locked.Public().(*ecdsa.PublicKey), tpmtest.WithAuth())) - candidates, err := NewFileStore(dir).Candidates(context.Background()) + candidates, err := newFileStore(dir).Candidates(context.Background()) require.NoError(t, err) require.Len(t, candidates, 1, "only the key without an authorization value is usable") assert.True(t, leaf.Equal(candidates[0].Chain[0]), "candidate must carry the device certificate") @@ -73,7 +73,7 @@ func TestCollect_TPMKeyEndToEnd(t *testing.T) { nonce := challenger.Nonce(peerKey, now) checks := []*proto.Checks{{CertificateChallenge: &proto.CertificateChallenge{Nonce: nonce, CaCertificates: []string{ca.PEM}}}} - proofs := Collect(context.Background(), NewFileStore(dir), checks, peerKey) + proofs := Collect(context.Background(), newFileStore(dir), checks, peerKey) require.Len(t, proofs, 1, "the TPM-held key must prove the certificate") chain, err := challenger.Verify(proofs[0], peerKey, now) require.NoError(t, err) diff --git a/client/internal/certproof/storefile_unix_test.go b/client/internal/certproof/storefile_unix_test.go index d35a4e759..9932388b6 100644 --- a/client/internal/certproof/storefile_unix_test.go +++ b/client/internal/certproof/storefile_unix_test.go @@ -26,7 +26,7 @@ func devicePair(t *testing.T, dir string) { func candidates(t *testing.T, dir string) ([]Candidate, error) { t.Helper() - return NewFileStore(dir).Candidates(context.Background()) + return newFileStore(dir).Candidates(context.Background()) } func TestFileStore_AcceptsAPrivatePair(t *testing.T) { diff --git a/client/internal/certproof/systemstore_windows.go b/client/internal/certproof/systemstore_windows.go index 92aa03d4b..908899107 100644 --- a/client/internal/certproof/systemstore_windows.go +++ b/client/internal/certproof/systemstore_windows.go @@ -49,10 +49,10 @@ type bcryptPSSPaddingInfo struct { // DefaultStore is the local machine's personal certificate store, where device // certificates enrolled through AD or Intune are kept. func DefaultStore() Store { - return NewSystemStore() + return newSystemStore() } -// SystemStore yields the identities of a personal certificate store, completing their +// systemStore yields the identities of a personal certificate store, completing their // chains from the matching intermediate CA store. Keys are used through CNG and never // exported. // @@ -60,22 +60,22 @@ func DefaultStore() Store { // a service reads; the current user store lives in the signed-in user's registry hive // with keys protected against their profile, so it is only readable while running as // that user. -type SystemStore struct { +type systemStore struct { location uint32 } -// NewSystemStore reads the local machine store, which is what the daemon uses. -func NewSystemStore() *SystemStore { - return &SystemStore{location: windows.CERT_SYSTEM_STORE_LOCAL_MACHINE} +// newSystemStore reads the local machine store, which is what the daemon uses. +func newSystemStore() *systemStore { + return &systemStore{location: windows.CERT_SYSTEM_STORE_LOCAL_MACHINE} } -// NewUserStore reads the calling user's personal store. It is only useful in a process +// newUserStore reads the calling user's personal store. It is only useful in a process // already running as that user, which is what the posture helper is. -func NewUserStore() *SystemStore { - return &SystemStore{location: windows.CERT_SYSTEM_STORE_CURRENT_USER} +func newUserStore() *systemStore { + return &systemStore{location: windows.CERT_SYSTEM_STORE_CURRENT_USER} } -func (s *SystemStore) Candidates(_ context.Context) ([]Candidate, error) { +func (s *systemStore) Candidates(_ context.Context) ([]Candidate, error) { leaves, err := storeCertificates(s.location, personalStore) if err != nil { return nil, err @@ -100,7 +100,7 @@ func (s *SystemStore) Candidates(_ context.Context) ([]Candidate, error) { } // String names the store location the way the Windows documentation does. -func (s *SystemStore) String() string { +func (s *systemStore) String() string { if s.location == windows.CERT_SYSTEM_STORE_CURRENT_USER { return "CurrentUser" } diff --git a/client/internal/pkcs11/driver_linux.go b/client/internal/pkcs11/driver_linux.go index 93bf4a5ba..54a3863cb 100644 --- a/client/internal/pkcs11/driver_linux.go +++ b/client/internal/pkcs11/driver_linux.go @@ -32,6 +32,7 @@ const ( // signature is 2 KiB. maxAttributeLen = 1 << 20 maxSignatureLen = 1 << 16 + maxFoundObjects = 1 << 16 ) type version struct { @@ -103,6 +104,15 @@ const ( fnSign = 43 ) +// checkFunctionListVersion refuses a module whose function list is older than the layout +// functionList mirrors, which would otherwise be read past its end. +func checkFunctionListVersion(v version) error { + if v.major < 2 { + return fmt.Errorf("PKCS#11 function list version %d.%d is older than 2.0", v.major, v.minor) + } + return nil +} + // module holds the entry points of one loaded library, bound straight from its // CK_FUNCTION_LIST. type module struct { @@ -152,6 +162,10 @@ func load(path string) (_ driver, err error) { return nil, Error{Op: "C_GetFunctionList", Code: uint(rv)} } + if err := checkFunctionListVersion(list.version); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + m := &module{} for _, entry := range []struct { fn any @@ -185,21 +199,43 @@ func load(path string) (_ driver, err error) { return m, nil } +// slotListAttempts bounds how often the slot list is asked for again after it grew between +// the size query and the read. +const slotListAttempts = 3 + +// slotList returns the slots with a token present. A token plugged in between the size +// query and the read makes the module answer CKR_BUFFER_TOO_SMALL, in which case the list +// is asked for again. +func (m *module) slotList() ([]ulong, error) { + for range slotListAttempts { + var count ulong + if rv := m.cGetSlotList(1, nil, &count); rv != rvOK { + return nil, Error{Op: "C_GetSlotList", Code: uint(rv)} + } + if count == 0 { + return nil, nil + } + slots := make([]ulong, count) + rv := m.cGetSlotList(1, &slots[0], &count) + if rv == rvBufferTooSmall { + continue + } + if rv != rvOK { + return nil, Error{Op: "C_GetSlotList", Code: uint(rv)} + } + return slots[:count], nil + } + return nil, Error{Op: "C_GetSlotList", Code: rvBufferTooSmall} +} + func (m *module) tokens() ([]Token, error) { - var count ulong - if rv := m.cGetSlotList(1, nil, &count); rv != rvOK { - return nil, Error{Op: "C_GetSlotList", Code: uint(rv)} - } - if count == 0 { - return nil, nil - } - slots := make([]ulong, count) - if rv := m.cGetSlotList(1, &slots[0], &count); rv != rvOK { - return nil, Error{Op: "C_GetSlotList", Code: uint(rv)} + slots, err := m.slotList() + if err != nil { + return nil, err } - tokens := make([]Token, 0, count) - for _, slot := range slots[:count] { + tokens := make([]Token, 0, len(slots)) + for _, slot := range slots { var info tokenInfo if rv := m.cGetTokenInfo(slot, &info); rv != rvOK { continue @@ -262,11 +298,16 @@ func (m *module) findObjects(session uint, template []Attribute) ([]Object, erro if count > findBatch { return nil, fmt.Errorf("C_FindObjects reported %d handles for a batch of %d", count, findBatch) } + // Only an empty batch ends the search: a module may return fewer handles than + // asked for before it has returned them all. + if count == 0 { + return objects, nil + } for _, handle := range batch[:count] { objects = append(objects, Object(handle)) } - if count < findBatch { - return objects, nil + if len(objects) > maxFoundObjects { + return nil, fmt.Errorf("C_FindObjects returned more than %d objects", maxFoundObjects) } } } diff --git a/client/internal/pkcs11/driver_linux_test.go b/client/internal/pkcs11/driver_linux_test.go index 408a3fabc..2362ee18f 100644 --- a/client/internal/pkcs11/driver_linux_test.go +++ b/client/internal/pkcs11/driver_linux_test.go @@ -70,3 +70,119 @@ func loadFirst(t *testing.T, paths ...string) *Module { t.Skip("p11-kit trust module not installed") return nil } + +// TestSlotList_RetriesWhenTheListGrows: a token plugged in between the size query and the +// fill makes the module answer CKR_BUFFER_TOO_SMALL; the list has to be asked for again. +func TestSlotList_RetriesWhenTheListGrows(t *testing.T) { + calls := 0 + m := &module{cGetSlotList: func(_ byte, slots *ulong, count *ulong) ulong { + calls++ + switch { + case slots == nil && calls == 1: + *count = 1 + case slots != nil && calls == 2: + *count = 2 + return rvBufferTooSmall + case slots == nil: + *count = 2 + default: + *count = 2 + backing := unsafe.Slice(slots, 2) + backing[0], backing[1] = 7, 9 + } + return rvOK + }} + + slots, err := m.slotList() + + require.NoError(t, err) + assert.Equal(t, []ulong{7, 9}, slots, "the list is read again at its new size") +} + +func TestSlotList_GivesUpOnAListThatKeepsGrowing(t *testing.T) { + m := &module{cGetSlotList: func(_ byte, slots *ulong, count *ulong) ulong { + if slots == nil { + *count = 1 + return rvOK + } + return rvBufferTooSmall + }} + + _, err := m.slotList() + + assert.Error(t, err, "a module that never settles on a size is an error, not a loop") +} + +func TestCheckFunctionListVersion(t *testing.T) { + assert.NoError(t, checkFunctionListVersion(version{major: 2, minor: 40})) + assert.NoError(t, checkFunctionListVersion(version{major: 3, minor: 0})) + assert.Error(t, checkFunctionListVersion(version{major: 1, minor: 0}), "a pre-2.0 list has no entry points to index") + assert.Error(t, checkFunctionListVersion(version{}), "an empty version is not a function list") +} + +// fakeFindModule answers C_FindObjects from batches, a handle per entry, and reports how +// the search was torn down. +func fakeFindModule(batches [][]ulong, finalized *int) *module { + next := 0 + return &module{ + cFindObjectsInit: func(ulong, *attribute, ulong) ulong { return rvOK }, + cFindObjectsFinal: func(ulong) ulong { *finalized++; return rvOK }, + cFindObjects: func(_ ulong, objects *ulong, max ulong, count *ulong) ulong { + if next >= len(batches) { + *count = 0 + return rvOK + } + batch := batches[next] + next++ + copy(unsafe.Slice(objects, max), batch) + *count = ulong(len(batch)) + return rvOK + }, + } +} + +var findTemplate = []Attribute{{Type: AttrClass, Value: ULong(ClassPrivateKey)}} + +// TestFindObjects_ReadsPastAShortBatch: PKCS#11 ends a search with an empty batch, not with +// one shorter than asked for, so a module that hands out its handles in small batches must +// still have all of them returned. +func TestFindObjects_ReadsPastAShortBatch(t *testing.T) { + finalized := 0 + m := fakeFindModule([][]ulong{{1, 2}, {3}, {4, 5, 6}}, &finalized) + + objects, err := m.findObjects(1, findTemplate) + + require.NoError(t, err) + assert.Equal(t, []Object{1, 2, 3, 4, 5, 6}, objects, "every batch is read until the module returns an empty one") + assert.Equal(t, 1, finalized, "the search is finished once") +} + +func TestFindObjects_StopsAtAnEmptyBatch(t *testing.T) { + finalized := 0 + m := fakeFindModule(nil, &finalized) + + objects, err := m.findObjects(1, findTemplate) + + require.NoError(t, err) + assert.Empty(t, objects, "a search that finds nothing returns nothing") + assert.Equal(t, 1, finalized) +} + +// TestFindObjects_BoundsAModuleThatNeverEnds: a module that keeps returning handles must +// not make the daemon allocate without limit. +func TestFindObjects_BoundsAModuleThatNeverEnds(t *testing.T) { + finalized := 0 + endless := &module{ + cFindObjectsInit: func(ulong, *attribute, ulong) ulong { return rvOK }, + cFindObjectsFinal: func(ulong) ulong { finalized++; return rvOK }, + cFindObjects: func(_ ulong, _ *ulong, _ ulong, count *ulong) ulong { + *count = findBatch + return rvOK + }, + } + + _, err := endless.findObjects(1, findTemplate) + + assert.Error(t, err, "an endless search is cut off") + assert.Equal(t, 1, finalized, "and still finished") +} diff --git a/client/internal/pkcs11/pkcs11.go b/client/internal/pkcs11/pkcs11.go index da874fb62..4a34848d9 100644 --- a/client/internal/pkcs11/pkcs11.go +++ b/client/internal/pkcs11/pkcs11.go @@ -50,6 +50,7 @@ const ( rvPINExpired = 0xa3 rvPINLocked = 0xa4 rvUserAlreadyLoggedIn = 0x100 + rvBufferTooSmall = 0x150 rvAlreadyInitialized = 0x191 ) diff --git a/client/internal/pkcs11/publickey.go b/client/internal/pkcs11/publickey.go index 4d59598e6..aac78524a 100644 --- a/client/internal/pkcs11/publickey.go +++ b/client/internal/pkcs11/publickey.go @@ -70,8 +70,10 @@ func rsaPublicKey(modulus, exponent []byte) (*rsa.PublicKey, error) { // uncompressed point wrapped in a DER OCTET STRING, which some modules hand out bare. func ecPublicKey(params, point []byte) (*ecdsa.PublicKey, error) { var oid asn1.ObjectIdentifier - if _, err := asn1.Unmarshal(params, &oid); err != nil { + if rest, err := asn1.Unmarshal(params, &oid); err != nil { return nil, fmt.Errorf("CKA_EC_PARAMS: %w", err) + } else if len(rest) != 0 { + return nil, errors.New("CKA_EC_PARAMS has trailing data") } curve, ok := curvesByOID[oid.String()] if !ok { @@ -80,8 +82,10 @@ func ecPublicKey(params, point []byte) (*ecdsa.PublicKey, error) { size := (curve.Params().BitSize + 7) / 8 raw := point if len(raw) != 1+2*size { - if _, err := asn1.Unmarshal(point, &raw); err != nil { + if rest, err := asn1.Unmarshal(point, &raw); err != nil { return nil, fmt.Errorf("CKA_EC_POINT: %w", err) + } else if len(rest) != 0 { + return nil, errors.New("CKA_EC_POINT has trailing data") } } if len(raw) != 1+2*size || raw[0] != 4 { diff --git a/client/internal/pkcs11/publickey_test.go b/client/internal/pkcs11/publickey_test.go index 527d6db46..70c1c9a15 100644 --- a/client/internal/pkcs11/publickey_test.go +++ b/client/internal/pkcs11/publickey_test.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "crypto/rsa" "encoding/asn1" + "math" "math/big" "testing" @@ -21,6 +22,8 @@ func TestECPublicKey(t *testing.T) { }{ {"P-256", elliptic.P256(), asn1.ObjectIdentifier{1, 2, 840, 10045, 3, 1, 7}}, {"P-384", elliptic.P384(), asn1.ObjectIdentifier{1, 3, 132, 0, 34}}, + // The wrapped P-521 point is 133 bytes, which needs the long form of the DER length. + {"P-521", elliptic.P521(), asn1.ObjectIdentifier{1, 3, 132, 0, 35}}, } for _, tt := range curves { t.Run(tt.name, func(t *testing.T) { @@ -57,6 +60,13 @@ func TestECPublicKey_Rejections(t *testing.T) { assert.Error(t, err, "a truncated point must be rejected") _, err = ecPublicKey([]byte("junk"), point) assert.Error(t, err, "malformed parameters must be rejected") + + wrapped, err := asn1.Marshal(point) + require.NoError(t, err) + _, err = ecPublicKey(append(append([]byte{}, p256...), 0), point) + assert.Error(t, err, "bytes after the curve parameters must be rejected") + _, err = ecPublicKey(p256, append(append([]byte{}, wrapped...), 0)) + assert.Error(t, err, "bytes after the wrapped point must be rejected") } func TestRSAPublicKey(t *testing.T) { @@ -69,6 +79,12 @@ func TestRSAPublicKey(t *testing.T) { _, err = rsaPublicKey(key.N.Bytes(), nil) assert.Error(t, err, "a missing exponent must be rejected") + _, err = rsaPublicKey(key.N.Bytes(), big.NewInt(math.MaxInt32).Bytes()) + assert.NoError(t, err, "the largest exponent an int holds is accepted") + _, err = rsaPublicKey(key.N.Bytes(), big.NewInt(math.MaxInt32+1).Bytes()) + assert.Error(t, err, "an exponent past int32 must be rejected") + _, err = rsaPublicKey(key.N.Bytes(), []byte{0}) + assert.Error(t, err, "a zero exponent must be rejected") } func TestULongRoundTrip(t *testing.T) { diff --git a/client/internal/pkcs11/uri.go b/client/internal/pkcs11/uri.go index c7248c10e..fb5dc2765 100644 --- a/client/internal/pkcs11/uri.go +++ b/client/internal/pkcs11/uri.go @@ -29,7 +29,7 @@ type URI struct { // module-path must be absolute and a module-name must be a bare name. Unknown query // attributes are ignored, as the RFC asks. func ParseURI(raw string) (*URI, error) { - rest, ok := strings.CutPrefix(raw, "pkcs11:") + rest, ok := cutPrefixFold(raw, "pkcs11:") if !ok { return nil, errors.New("PKCS#11 URI does not start with the pkcs11 scheme") } @@ -72,11 +72,61 @@ func (u *URI) setQueryAttribute(name, value string) error { case "pin-value": u.pinValue = &value case "pin-source": - u.pinSource = value + path, err := pinSourcePath(value) + if err != nil { + return err + } + u.pinSource = path } return nil } +// pinSourcePath turns a pin-source value into the file to read. A plain path is used as +// it is; a file URI must name a local absolute path, with no host or localhost (RFC 8089 +// section 2), so a PIN is never read from somewhere the URI only appears to point to. +func pinSourcePath(value string) (string, error) { + if value == "" { + return "", errors.New("PKCS#11 URI pin-source is empty") + } + rest, isFile := cutPrefixFold(value, "file:") + if !isFile { + return value, nil + } + authorityAndPath, hasAuthority := strings.CutPrefix(rest, "//") + if !hasAuthority { + if !isAbsolutePath(rest) { + return "", fmt.Errorf("PKCS#11 URI pin-source %q must be an absolute path", value) + } + return rest, nil + } + if isAbsolutePath(authorityAndPath) { + return authorityAndPath, nil + } + host, path, _ := strings.Cut(authorityAndPath, "/") + if host != "" && !strings.EqualFold(host, "localhost") { + return "", fmt.Errorf("PKCS#11 URI pin-source host %q is not the local machine", host) + } + if path == "" { + return "", fmt.Errorf("PKCS#11 URI pin-source %q names no file", value) + } + return "/" + path, nil +} + +// isAbsolutePath reports whether p is absolute in a file URI. A path starting with a slash +// is absolute there on every platform, including Windows, where filepath.IsAbs wants a +// drive letter. +func isAbsolutePath(p string) bool { + return strings.HasPrefix(p, "/") || filepath.IsAbs(p) +} + +// cutPrefixFold is strings.CutPrefix with the prefix matched without regard to case. +func cutPrefixFold(s, prefix string) (string, bool) { + if len(s) < len(prefix) || !strings.EqualFold(s[:len(prefix)], prefix) { + return s, false + } + return s[len(prefix):], true +} + // Module is the library to load, DefaultModule when the URI names none. func (u *URI) Module() string { if u.ModulePath == "" { @@ -99,8 +149,7 @@ func (u *URI) PIN() ([]byte, error) { if u.pinSource == "" { return nil, nil } - path := strings.TrimPrefix(strings.TrimPrefix(u.pinSource, "file://"), "file:") - pin, err := os.ReadFile(path) + pin, err := os.ReadFile(u.pinSource) if err != nil { return nil, fmt.Errorf("read PIN: %w", err) } diff --git a/client/internal/pkcs11/uri_test.go b/client/internal/pkcs11/uri_test.go index 54e8a0d91..09aede6a7 100644 --- a/client/internal/pkcs11/uri_test.go +++ b/client/internal/pkcs11/uri_test.go @@ -84,6 +84,10 @@ func TestParseURI_Rejections(t *testing.T) { "module-name with dotdot": "pkcs11:token=a?module-name=..", "empty module-name": "pkcs11:token=a?module-name=", "pin-value and pin-source": "pkcs11:token=a?pin-value=1&pin-source=file:/etc/pin", + // Without a value the URI would silently mean "no PIN" and skip the login. + "empty pin-source": "pkcs11:token=a?pin-source=", + "remote pin-source": "pkcs11:token=a?pin-source=file://example.com/etc/pin", + "relative pin-source": "pkcs11:token=a?pin-source=file:pin", } for name, raw := range tests { _, err := ParseURI(raw) @@ -91,6 +95,31 @@ func TestParseURI_Rejections(t *testing.T) { } } +// TestParseURI_SchemeIsCaseInsensitive: RFC 3986 section 3.1 makes schemes case-insensitive. +func TestParseURI_SchemeIsCaseInsensitive(t *testing.T) { + for _, raw := range []string{"PKCS11:token=netbird", "Pkcs11:token=netbird"} { + uri, err := ParseURI(raw) + require.NoError(t, err, raw) + assert.Equal(t, "netbird", uri.Token, raw) + } +} + +func TestURI_PINFromLocalhostFileURI(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file URIs with a host name are written differently on Windows") + } + path := filepath.Join(t.TempDir(), "pin") + require.NoError(t, os.WriteFile(path, []byte("secret\n"), 0o600)) + + for _, host := range []string{"localhost", "LocalHost"} { + uri, err := ParseURI("pkcs11:token=netbird?pin-source=file://" + host + path) + require.NoError(t, err, host) + pin, err := uri.PIN() + require.NoError(t, err, host) + assert.Equal(t, []byte("secret"), pin, "RFC 8089 names the local machine as localhost") + } +} + func TestURI_PINFromFile(t *testing.T) { path := filepath.Join(t.TempDir(), "pin") require.NoError(t, os.WriteFile(path, []byte("secret\n"), 0o600)) @@ -117,3 +146,30 @@ func absModule(name string) string { } return "/lib/" + name } + +// TestPinSourcePath covers how a pin-source value becomes a file path, without touching the +// file system, so the same cases run on every platform. A path starting with a slash is +// absolute in a file URI everywhere, including Windows, where filepath.IsAbs would refuse it. +func TestPinSourcePath(t *testing.T) { + tests := []struct { + value string + want string + }{ + {"/etc/netbird/pkcs11.pin", "/etc/netbird/pkcs11.pin"}, + {"file:/etc/netbird/pkcs11.pin", "/etc/netbird/pkcs11.pin"}, + {"file:///etc/netbird/pkcs11.pin", "/etc/netbird/pkcs11.pin"}, + {"FILE:///etc/netbird/pkcs11.pin", "/etc/netbird/pkcs11.pin"}, + {"file://localhost/etc/netbird/pkcs11.pin", "/etc/netbird/pkcs11.pin"}, + {"file://LocalHost/etc/netbird/pkcs11.pin", "/etc/netbird/pkcs11.pin"}, + } + for _, tt := range tests { + got, err := pinSourcePath(tt.value) + require.NoError(t, err, tt.value) + assert.Equal(t, tt.want, got, tt.value) + } + + for _, value := range []string{"", "file:pin", "file://example.com/etc/pin", "file://localhost", "file://localhost/"} { + _, err := pinSourcePath(value) + assert.Error(t, err, "%q must be refused", value) + } +} diff --git a/management/internals/network_map_db/shared_types_test.go b/management/internals/network_map_db/shared_types_test.go index 8a1239e26..34b69d265 100644 --- a/management/internals/network_map_db/shared_types_test.go +++ b/management/internals/network_map_db/shared_types_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestRecordTypeAndRdata(t *testing.T) { @@ -36,3 +37,24 @@ func TestRecordTypeAndRdata(t *testing.T) { }) } } + +// TestConvertToNmdataPeers_CarriesTheVerifiedCertificates: the certificate posture check is +// evaluated on the chains stored in the peer meta, so a peer loaded through this path with +// its certificates dropped would silently fail every certificate check. +func TestConvertToNmdataPeers_CarriesTheVerifiedCertificates(t *testing.T) { + peers, _, err := ConvertToNmdataPeers([]Peer{ + {MetaCertificates: []byte(`["-----BEGIN CERTIFICATE-----\nleaf\n-----END CERTIFICATE-----\n","chain-2"]`)}, + {}, + }) + + require.NoError(t, err) + require.Len(t, peers, 2) + assert.Equal(t, []string{"-----BEGIN CERTIFICATE-----\nleaf\n-----END CERTIFICATE-----\n", "chain-2"}, peers[0].Meta.Certificates) + assert.Empty(t, peers[1].Meta.Certificates, "a peer that proved nothing has no certificates") +} + +func TestConvertToNmdataPeers_RejectsMalformedCertificates(t *testing.T) { + _, _, err := ConvertToNmdataPeers([]Peer{{MetaCertificates: []byte(`{"not":"a list"}`)}}) + + assert.Error(t, err, "a stored value that is not a list of chains must not load as no certificates") +} diff --git a/shared/management/certposture/certposture_test.go b/shared/management/certposture/certposture_test.go index ae31a2203..e8f3d20c1 100644 --- a/shared/management/certposture/certposture_test.go +++ b/shared/management/certposture/certposture_test.go @@ -2,6 +2,8 @@ package certposture import ( "crypto" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/sha256" @@ -86,6 +88,7 @@ func TestSignVerify_RoundTripPerKeyType(t *testing.T) { ca := certtest.NewCA(t, "root") keys := map[string]crypto.Signer{ SigAlgECDSASHA256: certtest.ECDSAKey(t), + SigAlgECDSASHA384: p384Key(t), SigAlgRSAPSSSHA256: certtest.RSAKey(t), SigAlgEd25519: certtest.Ed25519Key(t), } @@ -103,6 +106,24 @@ func TestSignVerify_RoundTripPerKeyType(t *testing.T) { } } +func p384Key(t *testing.T) crypto.Signer { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + require.NoError(t, err) + return key +} + +// TestSign_RefusesCurvesTheProofDoesNotName: a proof names its algorithm from the key, and +// the verifier only knows P-256 and P-384, so P-521 is refused before anything is signed. +func TestSign_RefusesCurvesTheProofDoesNotName(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + require.NoError(t, err) + + _, _, err = Sign(key, NewChallenger(secret).Nonce(peerKey, now), peerKey) + + assert.ErrorIs(t, err, ErrUnsupportedKey) +} + func TestVerify_Rejections(t *testing.T) { ca := certtest.NewCA(t, "root") c := NewChallenger(secret) diff --git a/shared/management/client/grpc_test.go b/shared/management/client/grpc_test.go index c947130fd..8c69cea1c 100644 --- a/shared/management/client/grpc_test.go +++ b/shared/management/client/grpc_test.go @@ -12,6 +12,8 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "github.com/netbirdio/netbird/client/system" + "github.com/netbirdio/netbird/shared/management/certposture" mgmtProto "github.com/netbirdio/netbird/shared/management/proto" ) @@ -93,3 +95,25 @@ func TestMaxRecvMsgSizeIntegration(t *testing.T) { assert.Len(t, resp.Key, payloadSize) }) } + +// TestInfoToMetaData_CarriesCertificateProofs: management verifies the proofs it receives +// in the peer meta, so a field dropped by the mapping fails the certificate check without +// any error on either side. +func TestInfoToMetaData_CarriesCertificateProofs(t *testing.T) { + info := &system.Info{CertificateProofs: []certposture.Proof{ + {Nonce: []byte("nonce-1"), Chain: [][]byte{[]byte("leaf"), []byte("intermediate")}, SigAlg: "ecdsa-sha256", Signature: []byte("sig-1")}, + {Nonce: []byte("nonce-2"), Chain: [][]byte{[]byte("other")}, SigAlg: "rsa-pss-sha256", Signature: []byte("sig-2")}, + }} + + meta := infoToMetaData(info) + + require.Len(t, meta.GetCertificateProofs(), 2, "every proof reaches the meta") + first := meta.GetCertificateProofs()[0] + assert.Equal(t, []byte("nonce-1"), first.GetNonce()) + assert.Equal(t, [][]byte{[]byte("leaf"), []byte("intermediate")}, first.GetChain(), "the whole chain is carried, in order") + assert.Equal(t, "ecdsa-sha256", first.GetSigAlg()) + assert.Equal(t, []byte("sig-1"), first.GetSignature()) + assert.Equal(t, "rsa-pss-sha256", meta.GetCertificateProofs()[1].GetSigAlg()) + + assert.Empty(t, infoToMetaData(&system.Info{}).GetCertificateProofs(), "a device without proofs sends none") +} diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 8b97d5f47..fbf8bf5cd 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -1821,6 +1821,7 @@ components: ca_certificates: description: PEM encoded CA certificates the peer's certificate must chain to type: array + minItems: 1 items: type: string example: ["-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"] From 547dd1f177bc1190558a680488f085550b1f39a4 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:19:57 +0200 Subject: [PATCH 166/209] [client] Detect default route changes in the Linux network monitor (#8216) The Linux network monitor skipped every route update whose Dst was not nil, treating a nil Dst as the default route. Since the bump to vishvananda/netlink v1.3 (v0.43.0) the default route arrives as an explicit 0.0.0.0/0 or ::/0, so every default route change was filtered out and the monitor never fired on Linux, no matter how the underlay moved. Treat both a nil Dst and a zero-length prefix as the default route. --- .../networkmonitor/check_change_linux.go | 13 +++++- .../networkmonitor/check_change_linux_test.go | 43 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 client/internal/networkmonitor/check_change_linux_test.go diff --git a/client/internal/networkmonitor/check_change_linux.go b/client/internal/networkmonitor/check_change_linux.go index efd8b5884..4fbff4448 100644 --- a/client/internal/networkmonitor/check_change_linux.go +++ b/client/internal/networkmonitor/check_change_linux.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "net" "syscall" log "github.com/sirupsen/logrus" @@ -35,7 +36,7 @@ func checkChange(ctx context.Context, nexthopv4, nexthopv6 systemops.Nexthop) er // handle route changes case route := <-routeChan: // default route and main table - if route.Dst != nil || route.Table != syscall.RT_TABLE_MAIN { + if !isDefaultRoute(route.Dst) || route.Table != syscall.RT_TABLE_MAIN { continue } switch route.Type { @@ -52,3 +53,13 @@ func checkChange(ctx context.Context, nexthopv4, nexthopv6 systemops.Nexthop) er } } } + +// isDefaultRoute reports whether dst is a default route. netlink v1.3 reports +// the default as 0.0.0.0/0 or ::/0, older versions as a nil Dst. +func isDefaultRoute(dst *net.IPNet) bool { + if dst == nil { + return true + } + ones, _ := dst.Mask.Size() + return ones == 0 +} diff --git a/client/internal/networkmonitor/check_change_linux_test.go b/client/internal/networkmonitor/check_change_linux_test.go new file mode 100644 index 000000000..13e1c9e6d --- /dev/null +++ b/client/internal/networkmonitor/check_change_linux_test.go @@ -0,0 +1,43 @@ +//go:build !android + +package networkmonitor + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsDefaultRoute(t *testing.T) { + mustCIDR := func(s string) *net.IPNet { + t.Helper() + _, n, err := net.ParseCIDR(s) + if err != nil { + t.Fatalf("parse %s: %v", s, err) + } + return n + } + + tests := []struct { + name string + dst *net.IPNet + want bool + }{ + // netlink before v1.3 left Dst nil for the default route. + {name: "nil dst", dst: nil, want: true}, + // netlink v1.3 reports the default route with an explicit zero-length prefix. + {name: "v4 default", dst: mustCIDR("0.0.0.0/0"), want: true}, + {name: "v6 default", dst: mustCIDR("::/0"), want: true}, + // The exit node split routes and ordinary prefixes are not defaults. + {name: "v4 split half", dst: mustCIDR("0.0.0.0/1"), want: false}, + {name: "v4 subnet", dst: mustCIDR("10.0.0.0/8"), want: false}, + {name: "v4 host", dst: mustCIDR("1.1.1.1/32"), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isDefaultRoute(tt.dst), "default route detection") + }) + } +} From 357d8a2a7c565605e0e10cd79d1b46c5ea958555 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Thu, 15 Oct 2026 14:26:50 +0200 Subject: [PATCH 167/209] [proxy,client] Add --external-ip-map flag for the embedded client (#8185) * [proxy] Add --external-ip-map flag for the embedded client The proxy behind a NAT with a forwarded fixed --wg-port could not advertise its public address as an ICE candidate, so peers fell back to relay. Expose the agent's external IP map through embed.Options and a proxy flag (NB_PROXY_EXTERNAL_IP_MAP). * [proxy] Validate --external-ip-map at startup The engine drops every external IP mapping when one fails to parse, so a typo or a space after a comma silently disabled the feature. Move the agent's validator to shared/externalipmap, run it from the proxy at startup, and trim entries first. * [client,proxy] Require an external IP first in --external-ip-map The engine reads the first component as the external IP, so a mapping like iface/10.0.0.5 passed validation and then dropped every mapping. --- client/cmd/up.go | 73 +-------------------------- client/embed/embed.go | 4 ++ proxy/cmd/proxy/cmd/root.go | 20 ++++++++ proxy/internal/roundtrip/netbird.go | 4 ++ proxy/lifecycle.go | 4 ++ proxy/server.go | 12 +++-- shared/externalipmap/validate.go | 70 +++++++++++++++++++++++++ shared/externalipmap/validate_test.go | 48 ++++++++++++++++++ 8 files changed, 160 insertions(+), 75 deletions(-) create mode 100644 shared/externalipmap/validate.go create mode 100644 shared/externalipmap/validate_test.go diff --git a/client/cmd/up.go b/client/cmd/up.go index 120a25595..e336ba257 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "net" "net/netip" "runtime" "strings" @@ -26,16 +25,11 @@ import ( "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/server" "github.com/netbirdio/netbird/client/system" + "github.com/netbirdio/netbird/shared/externalipmap" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/util" ) -const ( - invalidInputType int = iota - ipInputType - interfaceInputType -) - const ( dnsLabelsFlag = "extra-dns-labels" @@ -106,7 +100,7 @@ func upFunc(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed initializing log %v", err) } - err = validateNATExternalIPs(natExternalIPs) + err = externalipmap.Validate(natExternalIPs) if err != nil { return err } @@ -836,36 +830,6 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte return &loginRequest, nil } -func validateNATExternalIPs(list []string) error { - for _, element := range list { - if element == "" { - return fmt.Errorf("empty string is not a valid input for %s", externalIPMapFlag) - } - - subElements := strings.Split(element, "/") - if len(subElements) > 2 { - return fmt.Errorf("%s is not a valid input for %s. it should be formatted as \"String\" or \"String/String\"", element, externalIPMapFlag) - } - - if len(subElements) == 1 && !isValidIP(subElements[0]) { - return fmt.Errorf("%s is not a valid input for %s. it should be formatted as \"IP\" or \"IP/IP\", or \"IP/Interface Name\"", element, externalIPMapFlag) - } - - last := 0 - for _, singleElement := range subElements { - inputType, err := validateElement(singleElement) - if err != nil { - return fmt.Errorf("%s is not a valid input for %s. it should be an IP string or a network name", singleElement, externalIPMapFlag) - } - if last == interfaceInputType && inputType == interfaceInputType { - return fmt.Errorf("%s is not a valid input for %s. it should not contain two interface names", element, externalIPMapFlag) - } - last = inputType - } - } - return nil -} - func parseInterfaceName(name string) error { if runtime.GOOS != "darwin" { return nil @@ -878,39 +842,6 @@ func parseInterfaceName(name string) error { return fmt.Errorf("invalid interface name %s. Please use the prefix utun followed by a number on MacOS. e.g., utun1 or utun199", name) } -func validateElement(element string) (int, error) { - if isValidIP(element) { - return ipInputType, nil - } - validIface, err := isValidInterface(element) - if err != nil { - return invalidInputType, fmt.Errorf("unable to validate the network interface name, error: %s", err) - } - - if validIface { - return interfaceInputType, nil - } - - return interfaceInputType, fmt.Errorf("invalid IP or network interface name not found") -} - -func isValidIP(ip string) bool { - return net.ParseIP(ip) != nil -} - -func isValidInterface(name string) (bool, error) { - netInterfaces, err := net.Interfaces() - if err != nil { - return false, err - } - for _, iface := range netInterfaces { - if iface.Name == name { - return true, nil - } - } - return false, nil -} - func parseCustomDNSAddress(modified bool) ([]byte, error) { var parsed []byte if modified { diff --git a/client/embed/embed.go b/client/embed/embed.go index 6fdddab48..208e71817 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -118,6 +118,9 @@ type Options struct { MTU *uint16 // DNSLabels defines additional DNS labels configured in the peer. DNSLabels []string + // NATExternalIPs maps external IPs to internal IPs or interfaces for ICE + // candidates, same format as the agent's --external-ip-map flag. + NATExternalIPs []string // Performance configures the tunnel's buffer pool cap and batch size. Performance Performance } @@ -224,6 +227,7 @@ func New(opts Options) (*Client, error) { WireguardPort: opts.WireguardPort, MTU: opts.MTU, DNSLabels: parsedLabels, + NATExternalIPs: opts.NATExternalIPs, } if opts.ConfigPath != "" { config, err = profilemanager.UpdateOrCreateConfig(input) diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go index 765d5c05a..5734efe4e 100644 --- a/proxy/cmd/proxy/cmd/root.go +++ b/proxy/cmd/proxy/cmd/root.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" "strconv" + "strings" "syscall" "time" @@ -13,6 +14,7 @@ import ( "github.com/spf13/cobra" "golang.org/x/crypto/acme" + "github.com/netbirdio/netbird/shared/externalipmap" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/profiling" @@ -74,6 +76,7 @@ var ( certLockMethod string wildcardCertDir string wgPort uint16 + externalIPMap []string proxyProtocol bool preSharedKey string supportsCustomPorts bool @@ -117,6 +120,7 @@ func init() { rootCmd.Flags().StringVar(&certLockMethod, "cert-lock-method", envStringOrDefault("NB_PROXY_CERT_LOCK_METHOD", "auto"), "Certificate lock method for cross-replica coordination: auto, flock, or k8s-lease") rootCmd.Flags().StringVar(&wildcardCertDir, "wildcard-cert-dir", envStringOrDefault("NB_PROXY_WILDCARD_CERT_DIR", ""), "Directory containing wildcard certificate pairs (.crt/.key). Wildcard patterns are extracted from SANs automatically") rootCmd.Flags().Uint16Var(&wgPort, "wg-port", envUint16OrDefault("NB_PROXY_WG_PORT", 0), "WireGuard listen port (0 = random). Fixed port only works with single-account deployments") + rootCmd.Flags().StringSliceVar(&externalIPMap, "external-ip-map", envStringSliceOrDefault("NB_PROXY_EXTERNAL_IP_MAP", nil), "Comma-separated external IPs to advertise as ICE candidates, as IP or IP/internal-IP-or-interface (e.g. '203.0.113.5/10.0.0.5'). Requires a fixed --wg-port forwarded on the NAT") rootCmd.Flags().BoolVar(&proxyProtocol, "proxy-protocol", envBoolOrDefault("NB_PROXY_PROXY_PROTOCOL", false), "Enable PROXY protocol on TCP listeners to preserve client IPs behind L4 proxies") rootCmd.Flags().StringVar(&preSharedKey, "preshared-key", envStringOrDefault("NB_PROXY_PRESHARED_KEY", ""), "Define a pre-shared key for the tunnel between proxy and peers") rootCmd.Flags().BoolVar(&supportsCustomPorts, "supports-custom-ports", envBoolOrDefault("NB_PROXY_SUPPORTS_CUSTOM_PORTS", true), "Whether the proxy can bind arbitrary ports for UDP/TCP passthrough") @@ -221,6 +225,13 @@ func runServer(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid --trusted-proxies: %w", err) } + for i := range externalIPMap { + externalIPMap[i] = strings.TrimSpace(externalIPMap[i]) + } + if err := externalipmap.Validate(externalIPMap); err != nil { + return fmt.Errorf("invalid --external-ip-map: %w", err) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() @@ -248,6 +259,7 @@ func runServer(cmd *cobra.Command, args []string) error { CertLockMethod: nbacme.CertLockMethod(certLockMethod), WildcardCertDir: wildcardCertDir, WireguardPort: wgPort, + NATExternalIPs: externalIPMap, Performance: perf, ProxyProtocol: proxyProtocol, PreSharedKey: preSharedKey, @@ -286,6 +298,14 @@ func envStringOrDefault(key string, def string) string { return v } +func envStringSliceOrDefault(key string, def []string) []string { + v, exists := os.LookupEnv(key) + if !exists || v == "" { + return def + } + return strings.Split(v, ",") +} + func envUint16OrDefault(key string, def uint16) uint16 { v, exists := os.LookupEnv(key) if !exists { diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go index 07b497c46..21feb59b7 100644 --- a/proxy/internal/roundtrip/netbird.go +++ b/proxy/internal/roundtrip/netbird.go @@ -144,6 +144,8 @@ type ClientConfig struct { WGPort uint16 PreSharedKey string Performance embed.Performance + // NATExternalIPs mirrors embed.Options.NATExternalIPs. + NATExternalIPs []string // BlockInbound mirrors embed.Options.BlockInbound. Set to true on the // standalone proxy where the embedded client never accepts inbound; // set to false on the private/embedded proxy so the engine creates @@ -402,6 +404,7 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account WireguardPort: &wgPort, PreSharedKey: n.clientCfg.PreSharedKey, Performance: n.clientCfg.Performance, + NATExternalIPs: n.clientCfg.NATExternalIPs, } logEmbedOptions(n.logger, accountID, serviceID, publicKey.String(), embedOpts) client, err := embed.New(embedOpts) @@ -914,6 +917,7 @@ func logEmbedOptions(logger *log.Logger, accountID types.AccountID, serviceID ty "management_url": opts.ManagementURL, "log_level": opts.LogLevel, "wg_port": wgPort, + "nat_external_ips": opts.NATExternalIPs, "mtu": mtu, "block_inbound": opts.BlockInbound, "block_lan_access": opts.BlockLANAccess, diff --git a/proxy/lifecycle.go b/proxy/lifecycle.go index f8c74d8b5..206c296ac 100644 --- a/proxy/lifecycle.go +++ b/proxy/lifecycle.go @@ -89,6 +89,9 @@ type Config struct { // WireguardPort is the UDP port for the embedded NetBird tunnel. // Zero asks the OS for a random port. WireguardPort uint16 + // NATExternalIPs are external IPs advertised as ICE candidates by the + // embedded tunnel. Requires a fixed WireguardPort forwarded on the NAT. + NATExternalIPs []string // ProxyProtocol enables PROXY protocol (v1/v2) on TCP listeners. ProxyProtocol bool // PreSharedKey is the WireGuard pre-shared key used between the @@ -160,6 +163,7 @@ func New(ctx context.Context, cfg Config) *Server { ForwardedProto: cfg.ForwardedProto, TrustedProxies: cfg.TrustedProxies, WireguardPort: cfg.WireguardPort, + NATExternalIPs: cfg.NATExternalIPs, ProxyProtocol: cfg.ProxyProtocol, PreSharedKey: cfg.PreSharedKey, Performance: cfg.Performance, diff --git a/proxy/server.go b/proxy/server.go index 083449962..9ad81b50f 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -203,6 +203,9 @@ type Server struct { // single-account deployments; multiple accounts will fail to bind // the same port. WireguardPort uint16 + // NATExternalIPs maps external IPs advertised as ICE candidates by every + // embedded client. Requires a fixed WireguardPort forwarded on the NAT. + NATExternalIPs []string // Performance configures the tunnel pool/batch sizes for every // embedded client this proxy spawns. Performance embed.Performance @@ -581,10 +584,11 @@ func (s *Server) initManagementClient() error { // inbound listeners. func (s *Server) initNetBirdClient() error { s.netbird = roundtrip.NewNetBird(s.ctx, s.ID, s.ProxyURL, roundtrip.ClientConfig{ - MgmtAddr: s.ManagementAddress, - WGPort: s.WireguardPort, - PreSharedKey: s.PreSharedKey, - Performance: s.Performance, + MgmtAddr: s.ManagementAddress, + WGPort: s.WireguardPort, + PreSharedKey: s.PreSharedKey, + Performance: s.Performance, + NATExternalIPs: s.NATExternalIPs, // On --private the embedded client serves per-account inbound // listeners and must apply management's ACL: keep BlockInbound off // so the engine creates the ACL manager. On the standalone proxy diff --git a/shared/externalipmap/validate.go b/shared/externalipmap/validate.go new file mode 100644 index 000000000..30517007c --- /dev/null +++ b/shared/externalipmap/validate.go @@ -0,0 +1,70 @@ +// Package externalipmap validates the --external-ip-map flag shared by the +// agent and the proxy. +package externalipmap + +import ( + "fmt" + "net" + "strings" +) + +const flagName = "external-ip-map" + +// Validate checks each element is formatted as "IP", "IP/IP" or +// "IP/interface". The engine drops every mapping when one element fails to +// parse, so callers should reject bad input up front. +func Validate(list []string) error { + for _, element := range list { + if element == "" { + return fmt.Errorf("empty string is not a valid input for %s", flagName) + } + + subElements := strings.Split(element, "/") + if len(subElements) > 2 { + return fmt.Errorf("%s is not a valid input for %s. it should be formatted as \"String\" or \"String/String\"", element, flagName) + } + + if !isValidIP(subElements[0]) { + return fmt.Errorf("%s is not a valid input for %s. it should be formatted as \"IP\" or \"IP/IP\", or \"IP/Interface Name\"", element, flagName) + } + + if len(subElements) == 2 { + if err := validateInternal(subElements[1]); err != nil { + return fmt.Errorf("%s is not a valid input for %s. it should be an IP string or a network name: %w", subElements[1], flagName, err) + } + } + } + return nil +} + +func validateInternal(element string) error { + if isValidIP(element) { + return nil + } + validIface, err := isValidInterface(element) + if err != nil { + return fmt.Errorf("validate network interface name: %w", err) + } + if !validIface { + return fmt.Errorf("invalid IP or network interface name not found") + } + return nil +} + +// isValidIP uses net.ParseIP to match the engine's parser exactly. +func isValidIP(ip string) bool { + return net.ParseIP(ip) != nil +} + +func isValidInterface(name string) (bool, error) { + netInterfaces, err := net.Interfaces() + if err != nil { + return false, err + } + for _, iface := range netInterfaces { + if iface.Name == name { + return true, nil + } + } + return false, nil +} diff --git a/shared/externalipmap/validate_test.go b/shared/externalipmap/validate_test.go new file mode 100644 index 000000000..baf3cf787 --- /dev/null +++ b/shared/externalipmap/validate_test.go @@ -0,0 +1,48 @@ +package externalipmap + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidate(t *testing.T) { + ifaces, err := net.Interfaces() + require.NoError(t, err) + require.NotEmpty(t, ifaces, "host must have at least one interface") + iface := ifaces[0].Name + + tests := []struct { + name string + input []string + wantErr bool + }{ + {name: "nil", input: nil}, + {name: "single IP", input: []string{"203.0.113.5"}}, + {name: "IP to IP", input: []string{"203.0.113.5/10.0.0.5"}}, + {name: "IP to interface", input: []string{"203.0.113.5/" + iface}}, + {name: "several", input: []string{"203.0.113.5", "203.0.113.6/10.0.0.6"}}, + {name: "empty element", input: []string{""}, wantErr: true}, + // The engine rejects padded values, so validation must too. + {name: "space padded", input: []string{"203.0.113.5", " 203.0.113.6"}, wantErr: true}, + {name: "not an IP", input: []string{"not-an-ip"}, wantErr: true}, + {name: "too many slashes", input: []string{"203.0.113.5/10.0.0.5/10.0.0.6"}, wantErr: true}, + {name: "unknown interface", input: []string{"203.0.113.5/nb-no-such-iface0"}, wantErr: true}, + {name: "external interface", input: []string{iface}, wantErr: true}, + {name: "interface to IP", input: []string{iface + "/10.0.0.5"}, wantErr: true}, + {name: "two interfaces", input: []string{iface + "/" + iface}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Validate(tt.input) + if tt.wantErr { + assert.Error(t, err, "input %q should be rejected", tt.input) + return + } + assert.NoError(t, err, "input %q should be accepted", tt.input) + }) + } +} From 89c1b47e9ca73d7bc037f431760674ff919435eb Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:23:29 +0200 Subject: [PATCH 168/209] [management] cancel peer expiration for account if account is gone (#8218) --- management/server/account.go | 21 +++++++++++++++++++-- management/server/account_test.go | 24 ++++++++++++++++++++++++ management/server/peer.go | 6 ++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/management/server/account.go b/management/server/account.go index 90e384ac0..59991a4e4 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -770,7 +770,12 @@ func (am *DefaultAccountManager) peerLoginExpirationJob(ctx context.Context, acc ctx = context.WithValue(ctx, hook.ExecutionContextKey, fmt.Sprintf("%s-PEER-EXPIRATION", hook.SystemSource)) expiredPeers, err := am.getExpiredPeers(ctx, accountID) + if isNotFound(err) { + log.WithContext(ctx).Debugf("account %s not found, stopping peer login expiration job", accountID) + return 0, false + } if err != nil { + log.WithContext(ctx).Errorf("failed getting expired peers for account %s: %v", accountID, err) return peerSchedulerRetryInterval, true } @@ -806,8 +811,12 @@ func (am *DefaultAccountManager) schedulePeerLoginExpiration(ctx context.Context func (am *DefaultAccountManager) peerInactivityExpirationJob(ctx context.Context, accountID string) func() (time.Duration, bool) { return func() (time.Duration, bool) { inactivePeers, err := am.getInactivePeers(ctx, accountID) + if isNotFound(err) { + log.WithContext(ctx).Debugf("account %s not found, stopping peer inactivity expiration job", accountID) + return 0, false + } if err != nil { - log.WithContext(ctx).Errorf("failed getting inactive peers for account %s", accountID) + log.WithContext(ctx).Errorf("failed getting inactive peers for account %s: %v", accountID, err) return peerSchedulerRetryInterval, true } @@ -827,6 +836,14 @@ func (am *DefaultAccountManager) peerInactivityExpirationJob(ctx context.Context } } +func isNotFound(err error) bool { + if err == nil { + return false + } + sErr, ok := status.FromError(err) + return ok && sErr.Type() == status.NotFound +} + // checkAndSchedulePeerInactivityExpiration periodically checks for inactive peers to end their sessions func (am *DefaultAccountManager) checkAndSchedulePeerInactivityExpiration(ctx context.Context, accountID string) { am.peerInactivityExpiry.Cancel(ctx, []string{accountID}) @@ -978,8 +995,8 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u log.WithContext(ctx).Errorf("failed deleting account %s. error: %s", accountID, err) return err } - // cancel peer login expiry job am.peerLoginExpiry.Cancel(ctx, []string{account.Id}) + am.peerInactivityExpiry.Cancel(ctx, []string{account.Id}) am.certChallenges.Forget(account.Id) meta := map[string]any{"account_id": account.Id, "domain": account.Domain, "created_at": account.CreatedAt} diff --git a/management/server/account_test.go b/management/server/account_test.go index 7aa88ee64..ef7525961 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -4990,3 +4990,27 @@ func TestUpdateUserAuthWithSingleMode(t *testing.T) { assert.Contains(t, err.Error(), "query failed") }) } + +func TestDefaultAccountManager_ExpirationJobsStopForDeletedAccount(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + ctx := context.Background() + accountID, err := manager.GetAccountIDByUserID(ctx, auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + account, err := manager.Store.GetAccount(ctx, accountID) + require.NoError(t, err, "unable to get account") + require.NoError(t, manager.Store.DeleteAccount(ctx, account), "unable to delete account") + + jobs := map[string]func() (time.Duration, bool){ + "login": manager.peerLoginExpirationJob(ctx, accountID), + "inactivity": manager.peerInactivityExpirationJob(ctx, accountID), + } + for name, job := range jobs { + t.Run(name, func(t *testing.T) { + _, reschedule := job() + assert.False(t, reschedule, "job must not reschedule for a deleted account") + }) + } +} diff --git a/management/server/peer.go b/management/server/peer.go index 1e3f72a3c..f21e5ebc9 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -1702,6 +1702,9 @@ func (am *DefaultAccountManager) getNextPeerExpiration(ctx context.Context, acco } settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if isNotFound(err) { + return 0, false + } if err != nil { log.WithContext(ctx).Errorf("failed to get account settings: %v", err) return peerSchedulerRetryInterval, true @@ -1745,6 +1748,9 @@ func (am *DefaultAccountManager) getNextInactivePeerExpiration(ctx context.Conte } settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if isNotFound(err) { + return 0, false + } if err != nil { log.WithContext(ctx).Errorf("failed to get account settings: %v", err) return peerSchedulerRetryInterval, true From dd830f089086790e988b345e5a5f9415ce8e3e15 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:26:04 +0200 Subject: [PATCH 169/209] [management] clean nul byte strings in peer meta (#8219) --- .../internals/shared/grpc/peer_meta_test.go | 45 +++++++++++++++++++ management/internals/shared/grpc/server.go | 41 +++++++++-------- 2 files changed, 68 insertions(+), 18 deletions(-) create mode 100644 management/internals/shared/grpc/peer_meta_test.go diff --git a/management/internals/shared/grpc/peer_meta_test.go b/management/internals/shared/grpc/peer_meta_test.go new file mode 100644 index 000000000..c6336ff1b --- /dev/null +++ b/management/internals/shared/grpc/peer_meta_test.go @@ -0,0 +1,45 @@ +package grpc + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestExtractPeerMeta_RemovesNulAndInvalidUTF8(t *testing.T) { + meta := extractPeerMeta(context.Background(), &proto.PeerSystemMeta{ + Hostname: "host\x00name", + OS: "\x00\x00\x00\x00\x00\x00s", + OSVersion: "11\xff", + SysSerialNumber: "SN\x00\x00", + Environment: &proto.Environment{Cloud: "\x00aws"}, + NetworkAddresses: []*proto.NetworkAddress{ + {NetIP: "192.168.0.1/24", Mac: "e0:d5\x00"}, + }, + Files: []*proto.File{{Path: "C:\\x\x00.exe"}}, + }) + + assert.Equal(t, "hostname", meta.Hostname) + assert.Equal(t, "s", meta.OS) + assert.Equal(t, "11", meta.OSVersion) + assert.Equal(t, "SN", meta.SystemSerialNumber) + assert.Equal(t, "aws", meta.Environment.Cloud) + assert.Equal(t, "e0:d5", meta.NetworkAddresses[0].Mac) + assert.Equal(t, "C:\\x.exe", meta.Files[0].Path) +} + +func TestCleanClientString(t *testing.T) { + tests := map[string]string{ + "": "", + "\x00\x00": "", + "Wïndows": "Wïndows", + "ssh-ed25519 AAA\x00": "ssh-ed25519 AAA", + "bad\xc3\x28utf8": "bad(utf8", + } + for in, want := range tests { + assert.Equal(t, want, cleanClientString(in), "input %q", in) + } +} diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 51023c34d..b48a86d55 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -553,9 +553,9 @@ func extractPeerMeta(ctx context.Context, meta *proto.PeerSystemMeta) nbpeer.Pee return nbpeer.PeerSystemMeta{} } - osVersion := meta.GetOSVersion() + osVersion := cleanClientString(meta.GetOSVersion()) if osVersion == "" { - osVersion = meta.GetCore() + osVersion = cleanClientString(meta.GetCore()) } networkAddresses := make([]nbpeer.NetworkAddress, 0, len(meta.GetNetworkAddresses())) @@ -567,36 +567,36 @@ func extractPeerMeta(ctx context.Context, meta *proto.PeerSystemMeta) nbpeer.Pee } networkAddresses = append(networkAddresses, nbpeer.NetworkAddress{ NetIP: netAddr, - Mac: addr.GetMac(), + Mac: cleanClientString(addr.GetMac()), }) } files := make([]nbpeer.File, 0, len(meta.GetFiles())) for _, file := range meta.GetFiles() { files = append(files, nbpeer.File{ - Path: file.GetPath(), + Path: cleanClientString(file.GetPath()), Exist: file.GetExist(), ProcessIsRunning: file.GetProcessIsRunning(), }) } return nbpeer.PeerSystemMeta{ - Hostname: meta.GetHostname(), - GoOS: meta.GetGoOS(), - Kernel: meta.GetKernel(), - Platform: meta.GetPlatform(), - OS: meta.GetOS(), + Hostname: cleanClientString(meta.GetHostname()), + GoOS: cleanClientString(meta.GetGoOS()), + Kernel: cleanClientString(meta.GetKernel()), + Platform: cleanClientString(meta.GetPlatform()), + OS: cleanClientString(meta.GetOS()), OSVersion: osVersion, - WtVersion: meta.GetNetbirdVersion(), - UIVersion: meta.GetUiVersion(), - KernelVersion: meta.GetKernelVersion(), + WtVersion: cleanClientString(meta.GetNetbirdVersion()), + UIVersion: cleanClientString(meta.GetUiVersion()), + KernelVersion: cleanClientString(meta.GetKernelVersion()), NetworkAddresses: networkAddresses, - SystemSerialNumber: meta.GetSysSerialNumber(), - SystemProductName: meta.GetSysProductName(), - SystemManufacturer: meta.GetSysManufacturer(), + SystemSerialNumber: cleanClientString(meta.GetSysSerialNumber()), + SystemProductName: cleanClientString(meta.GetSysProductName()), + SystemManufacturer: cleanClientString(meta.GetSysManufacturer()), Environment: nbpeer.Environment{ - Cloud: meta.GetEnvironment().GetCloud(), - Platform: meta.GetEnvironment().GetPlatform(), + Cloud: cleanClientString(meta.GetEnvironment().GetCloud()), + Platform: cleanClientString(meta.GetEnvironment().GetPlatform()), }, Flags: nbpeer.Flags{ RosenpassEnabled: meta.GetFlags().GetRosenpassEnabled(), @@ -618,6 +618,11 @@ func extractPeerMeta(ctx context.Context, meta *proto.PeerSystemMeta) nbpeer.Pee } } +// cleanClientString removes NUL bytes and invalid UTF-8 from a client-reported string, which Postgres rejects in text columns. +func cleanClientString(s string) string { + return strings.ToValidUTF8(strings.ReplaceAll(s, "\x00", ""), "") +} + func capabilitiesToInt32(caps []proto.PeerCapability) []int32 { result := make([]int32, len(caps)) for i, c := range caps { @@ -711,7 +716,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto peer, network, postureChecks, enableSSH, err := s.accountManager.LoginPeer(ctx, types.PeerLogin{ WireGuardPubKey: peerKey.String(), - SSHKey: string(sshKey), + SSHKey: cleanClientString(string(sshKey)), Meta: peerMeta, UserID: userID, SetupKey: loginReq.GetSetupKey(), From afd5c6df6a26eec3c0a81df2374dfe1ce9983ea8 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:27:36 +0200 Subject: [PATCH 170/209] [management] fix proxy conn lifecycle during acks (#8220) --- management/internals/shared/grpc/proxy.go | 57 ++++++++++++--- .../shared/grpc/sync_mappings_test.go | 72 +++++++++++++++++++ proxy/server.go | 8 +++ 3 files changed, 127 insertions(+), 10 deletions(-) diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index 8e93f77b0..439ff1dba 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -152,6 +152,10 @@ type ProxyServiceServer struct { // initial snapshot delivery. Configurable via NB_PROXY_SNAPSHOT_BATCH_SIZE. snapshotBatchSize int + // snapshotAckTimeout bounds the wait for each snapshot batch ack. + // Defaults to defaultSnapshotAckTimeout when zero. + snapshotAckTimeout time.Duration + cancel context.CancelFunc } @@ -168,6 +172,8 @@ const defaultProxyTokenTTL = 5 * time.Minute const defaultSnapshotBatchSize = 500 +const defaultSnapshotAckTimeout = 5 * time.Minute + func snapshotBatchSizeFromEnv() int { if v := os.Getenv("NB_PROXY_SNAPSHOT_BATCH_SIZE"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { @@ -185,6 +191,14 @@ func (s *ProxyServiceServer) proxyTokenTTL() time.Duration { return defaultProxyTokenTTL } +// snapshotAckWait returns the configured snapshot ack timeout or the default when unset. +func (s *ProxyServiceServer) snapshotAckWait() time.Duration { + if s.snapshotAckTimeout > 0 { + return s.snapshotAckTimeout + } + return defaultSnapshotAckTimeout +} + // proxyConnection represents a connected proxy type proxyConnection struct { proxyID string @@ -484,7 +498,7 @@ func (s *ProxyServiceServer) SyncMappings(stream proto.ProxyService_SyncMappings return err } - if err := s.sendSnapshotSync(stream.Context(), conn, stream); err != nil { + if err := s.sendSnapshotSync(conn.ctx, conn, stream); err != nil { s.cleanupFailedSnapshot(stream.Context(), conn) return fmt.Errorf("send snapshot to proxy %s: %w", params.proxyID, err) } @@ -728,7 +742,7 @@ func (s *ProxyServiceServer) sendSnapshotSync(ctx context.Context, conn *proxyCo return fmt.Errorf("send snapshot batch: %w", err) } - if err := waitForAck(stream); err != nil { + if err := waitForAck(ctx, stream, s.snapshotAckWait()); err != nil { return err } } @@ -740,7 +754,7 @@ func (s *ProxyServiceServer) sendSnapshotSync(ctx context.Context, conn *proxyCo return fmt.Errorf("send snapshot completion: %w", err) } - if err := waitForAck(stream); err != nil { + if err := waitForAck(ctx, stream, s.snapshotAckWait()); err != nil { return err } } @@ -748,15 +762,38 @@ func (s *ProxyServiceServer) sendSnapshotSync(ctx context.Context, conn *proxyCo return nil } -func waitForAck(stream proto.ProxyService_SyncMappingsServer) error { - msg, err := stream.Recv() - if err != nil { - return fmt.Errorf("receive ack: %w", err) +// waitForAck waits for the proxy to ack a snapshot batch. It gives up when ctx +// is canceled or no ack arrives within timeout, since a proxy that abandoned the +// stream without closing it would otherwise block the handler indefinitely. +func waitForAck(ctx context.Context, stream proto.ProxyService_SyncMappingsServer, timeout time.Duration) error { + type recvResult struct { + msg *proto.SyncMappingsRequest + err error } - if msg.GetAck() == nil { - return fmt.Errorf("expected ack, got %T", msg.GetMsg()) + // Recv returns once the handler exits and gRPC tears the stream down. + result := make(chan recvResult, 1) + go func() { + msg, err := stream.Recv() + result <- recvResult{msg: msg, err: err} + }() + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case r := <-result: + if r.err != nil { + return fmt.Errorf("receive ack: %w", r.err) + } + if r.msg.GetAck() == nil { + return fmt.Errorf("expected ack, got %T", r.msg.GetMsg()) + } + return nil + case <-ctx.Done(): + return fmt.Errorf("wait for ack: %w", ctx.Err()) + case <-timer.C: + return fmt.Errorf("no ack within %s", timeout) } - return nil } // heartbeat updates the proxy's last_seen timestamp every minute and diff --git a/management/internals/shared/grpc/sync_mappings_test.go b/management/internals/shared/grpc/sync_mappings_test.go index 6949b79e9..753142ea0 100644 --- a/management/internals/shared/grpc/sync_mappings_test.go +++ b/management/internals/shared/grpc/sync_mappings_test.go @@ -409,3 +409,75 @@ func TestConnectionSendResponse_RoutesToLegacyStream(t *testing.T) { require.Len(t, stream.messages, 1) assert.Equal(t, "svc-2", stream.messages[0].Mapping[0].Id) } + +// blockingSyncStream is a ProxyService_SyncMappingsServer whose Recv blocks +// until the test ends, like a proxy that stopped acking without closing the stream. +type blockingSyncStream struct { + grpc.ServerStream + done chan struct{} +} + +func (s *blockingSyncStream) Recv() (*proto.SyncMappingsRequest, error) { + <-s.done + return nil, context.Canceled +} + +func (s *blockingSyncStream) Send(*proto.SyncMappingsResponse) error { return nil } + +func newBlockingSyncStream(t *testing.T) *blockingSyncStream { + t.Helper() + s := &blockingSyncStream{done: make(chan struct{})} + t.Cleanup(func() { close(s.done) }) + return s +} + +func TestWaitForAck_Ack(t *testing.T) { + stream := &syncRecordingStream{recvMsgs: []*proto.SyncMappingsRequest{ackMsg()}} + + require.NoError(t, waitForAck(context.Background(), stream, time.Second)) +} + +func TestWaitForAck_UnexpectedMessage(t *testing.T) { + stream := &syncRecordingStream{recvMsgs: []*proto.SyncMappingsRequest{ + {Msg: &proto.SyncMappingsRequest_Init{Init: &proto.SyncMappingsInit{}}}, + }} + + assert.ErrorContains(t, waitForAck(context.Background(), stream, time.Second), "expected ack") +} + +func TestWaitForAck_TimesOutWhenProxyStopsAcking(t *testing.T) { + stream := newBlockingSyncStream(t) + + start := time.Now() + err := waitForAck(context.Background(), stream, 50*time.Millisecond) + + assert.ErrorContains(t, err, "no ack within") + assert.Less(t, time.Since(start), time.Second) +} + +func TestWaitForAck_ReturnsWhenConnectionCanceled(t *testing.T) { + stream := newBlockingSyncStream(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + assert.ErrorIs(t, waitForAck(ctx, stream, time.Minute), context.Canceled) +} + +func TestSendSnapshotSync_GivesUpWhenProxyStopsAcking(t *testing.T) { + const cluster = "cluster.example.com" + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(1, cluster), nil) + + s := newSnapshotTestServer(t, 10) + s.serviceManager = mgr + s.snapshotAckTimeout = 50 * time.Millisecond + + stream := newBlockingSyncStream(t) + conn := &proxyConnection{proxyID: "proxy-a", address: cluster, syncStream: stream} + + err := s.sendSnapshotSync(context.Background(), conn, stream) + + assert.ErrorContains(t, err, "no ack within") +} diff --git a/proxy/server.go b/proxy/server.go index 9ad81b50f..3bba38644 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -1303,6 +1303,10 @@ func (s *Server) proxyCapabilities() *proto.ProxyCapabilities { } func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool, onConnected func()) error { + // Canceling closes the stream on management; a bare return leaves it open. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + connectTime := time.Now() mappingClient, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ ProxyId: s.ID, @@ -1325,6 +1329,10 @@ func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServ } func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool, onConnected func()) error { + // Canceling closes the stream on management; a bare return leaves it open. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + connectTime := time.Now() stream, err := client.SyncMappings(ctx) if err != nil { From 28657607c14d4b5135c48cb79fcb9f4fd0600b42 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 16 Oct 2026 02:16:45 +0200 Subject: [PATCH 171/209] [client] Keep interface discovery out of the bind open path (#8226) * [client] Keep interface discovery out of the bind open path Since 0.81.0 a userspace-mode agent whose interface name is not covered by the interface blacklist deadlocks while bringing the device up, and the daemon, the GUI and the service stop all hang. wireguard-go holds the device net lock across bind.Open, our bind builds the UDP mux there, the mux enumerated interfaces in its constructor, and the interface filter probed every unlisted name through wgctrl. For the agent's own interface that probe connected to the daemon's own UAPI endpoint and waited, with no deadline, for a reply the handler could only give after taking the same lock. #7346 moved the first enumeration from the stdnet constructor, which ran before the device existed, to this call site. The mux no longer discovers interfaces when it is constructed. It records whether its socket is bound to an unspecified address and GetConn uses that instead of the length of the discovered list, while GetListenAddresses keeps refreshing on demand, which pion calls during candidate gathering outside the device locks. The WireGuard detector also answers for the agent's own interface without probing, so no filter asks the daemon about itself. * [client] Take the agent's own interface name in the detector constructor Every device implementation reports the configured name, so there is no later name to follow and the setter was state without a reader that needed it. The detector takes the name once and compares without a lock. * [client] Create the default mux Net in the constructor With discovery moved to GetListenAddresses, the nil Net fallback ran there as well, and the first concurrent callers raced on writing it. stdnet.NewNet does no discovery, so the constructor can create it before the mux is shared and the address refresh only reads it. * [client] Follow the created interface name in the detector Android ignores the configured interface name and keeps the TUN name returned by CreateUnmonitoredTUNFromFD, so the detector built from the configuration would still probe the agent's real interface. The engine updates the detector with wgInterface.Name() once the interface exists. * [client] Keep the detector's own interface name immutable On Android the kernel names the TUN and the name can change again on renew, while the UAPI socket cannot be opened there at all, so neither the probe nor a name set after creation can identify the agent's own interface reliably. Excluding that interface on Android belongs to the interface discovery the app provides. Everywhere else the configured name is the created name, so the constructor argument is enough and the detector needs no setter and no lock on the hot path. --- client/iface/bind/ice_bind_test.go | 88 ++++++++++++++++++++ client/iface/udpmux/mux.go | 20 ++--- client/iface/udpmux/mux_test.go | 99 +++++++++++++++++++++++ client/internal/engine.go | 2 +- client/internal/stdnet/wgdetector.go | 19 ++++- client/internal/stdnet/wgdetector_test.go | 36 +++++++++ 6 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 client/iface/udpmux/mux_test.go diff --git a/client/iface/bind/ice_bind_test.go b/client/iface/bind/ice_bind_test.go index 0b8db7640..8dd82d7df 100644 --- a/client/iface/bind/ice_bind_test.go +++ b/client/iface/bind/ice_bind_test.go @@ -2,17 +2,22 @@ package bind import ( "fmt" + "io" "net" "net/netip" "sync" + "sync/atomic" "testing" "time" + "github.com/pion/transport/v3" "github.com/pion/transport/v3/stdnet" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" + "golang.zx2c4.com/wireguard/device" + "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface/wgaddr" ) @@ -326,3 +331,86 @@ func listenUDP(t *testing.T, network, addr string) *net.UDPConn { require.NoError(t, err) return conn } + +type countingNet struct { + transport.Net + calls atomic.Int64 + ifaces []*transport.Interface + before func() +} + +func (n *countingNet) Interfaces() ([]*transport.Interface, error) { + n.calls.Add(1) + if n.before != nil { + n.before() + } + return n.ifaces, nil +} + +func TestICEBind_OpenDoesNotDiscoverInterfaces(t *testing.T) { + n := newCountingNet() + iceBind := NewICEBind(n, testAddress(), 1280) + + for i := 0; i < 2; i++ { + _, port, err := iceBind.Open(0) + require.NoError(t, err) + require.NotZero(t, port, "the bind listens on a port") + assert.Equal(t, int64(0), n.calls.Load(), "opening the bind must not enumerate interfaces, attempt %d", i+1) + require.NoError(t, iceBind.Close()) + } + + _, _, err := iceBind.Open(0) + require.NoError(t, err) + t.Cleanup(func() { _ = iceBind.Close() }) + + mux, err := iceBind.GetICEMux() + require.NoError(t, err) + assert.NotEmpty(t, mux.SingleSocketUDPMux.GetListenAddresses(), "listen addresses come from the discovered interfaces") + assert.Equal(t, int64(1), n.calls.Load(), "interfaces are discovered when the listen addresses are asked for") +} + +func TestICEBind_DeviceUpDoesNotWaitOnInterfaceDiscovery(t *testing.T) { + tunDev, _, err := netstack.CreateNetTUN([]netip.Addr{netip.MustParseAddr("100.64.0.1")}, nil, 1280) + require.NoError(t, err) + + var dev *device.Device + n := newCountingNet() + n.before = func() { + _ = dev.IpcGetOperation(io.Discard) + } + iceBind := NewICEBind(n, testAddress(), 1280) + dev = device.NewDevice(tunDev, iceBind, device.NewLogger(device.LogLevelSilent, "")) + + upDone := make(chan error, 1) + go func() { + upDone <- dev.Up() + }() + + select { + case err := <-upDone: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("device Up did not return: interface discovery ran under the device lock and waited on the device itself") + } + t.Cleanup(dev.Close) + + assert.Equal(t, int64(0), n.calls.Load(), "bringing the device up must not enumerate interfaces") + + mux, err := iceBind.GetICEMux() + require.NoError(t, err) + assert.NotEmpty(t, mux.SingleSocketUDPMux.GetListenAddresses(), "discovery that talks to the device succeeds once the device is up") + assert.Equal(t, int64(1), n.calls.Load(), "interfaces are discovered outside the device lock") +} + +func newCountingNet() *countingNet { + ifc := transport.NewInterface(net.Interface{Index: 1, MTU: 1500, Name: "eth0", Flags: net.FlagUp | net.FlagMulticast}) + ifc.AddAddress(&net.IPNet{IP: net.IPv4(192, 168, 1, 10), Mask: net.CIDRMask(24, 32)}) + return &countingNet{ifaces: []*transport.Interface{ifc}} +} + +func testAddress() wgaddr.Address { + return wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/10"), + } +} diff --git a/client/iface/udpmux/mux.go b/client/iface/udpmux/mux.go index 3aa0b4d88..2f7287b53 100644 --- a/client/iface/udpmux/mux.go +++ b/client/iface/udpmux/mux.go @@ -47,6 +47,7 @@ type SingleSocketUDPMux struct { // for UDP connection listen at unspecified address localAddrsForUnspecified []net.Addr + unspecified bool } const maxAddrSize = 512 @@ -157,6 +158,9 @@ func NewSingleSocketUDPMux(params Params) *SingleSocketUDPMux { if params.Logger == nil { params.Logger = getLogger() } + if params.Net == nil { + params.Net = stdnet.NewNet(context.Background(), nil, nil) + } mux := &SingleSocketUDPMux{ addressMap: map[string][]*udpMuxedConn{}, @@ -165,6 +169,7 @@ func NewSingleSocketUDPMux(params Params) *SingleSocketUDPMux { connsIPv6: make(map[string]*udpMuxedConn), candidateConnMap: make(map[string]*udpMuxedConn), closedChan: make(chan struct{}, 1), + unspecified: isUnspecified(params.UDPConn.LocalAddr()), pool: &sync.Pool{ New: func() interface{} { // big enough buffer to fit both packet and address @@ -173,7 +178,6 @@ func NewSingleSocketUDPMux(params Params) *SingleSocketUDPMux { }, } - mux.updateLocalAddresses() return mux } @@ -199,10 +203,6 @@ func (m *SingleSocketUDPMux) updateLocalAddresses() { m.params.Logger.Errorf("LocalAddr expected IPV4 or IPV6, got %T", m.params.UDPConn.LocalAddr()) } if len(networks) > 0 { - if m.params.Net == nil { - m.params.Net = stdnet.NewNet(context.Background(), nil, nil) - } - ips, err := localInterfaces(m.params.Net, m.params.InterfaceFilter, nil, networks, true) if err == nil { for _, ip := range ips { @@ -241,10 +241,7 @@ func (m *SingleSocketUDPMux) GetListenAddresses() []net.Addr { // creates the connection if an existing one can't be found func (m *SingleSocketUDPMux) GetConn(ufrag string, addr net.Addr, candidateID string) (net.PacketConn, error) { // don't check addr for mux using unspecified address - m.mu.Lock() - lenLocalAddrs := len(m.localAddrsForUnspecified) - m.mu.Unlock() - if lenLocalAddrs == 0 && m.params.UDPConn.LocalAddr().String() != addr.String() { + if !m.unspecified && m.params.UDPConn.LocalAddr().String() != addr.String() { return nil, fmt.Errorf("invalid address %s", addr.String()) } @@ -508,3 +505,8 @@ func getLogger() logging.LeveledLogger { //fac.Writer = log.StandardLogger().Writer() return fac.NewLogger("ice") } + +func isUnspecified(addr net.Addr) bool { + udpAddr, ok := addr.(*net.UDPAddr) + return ok && udpAddr.IP.IsUnspecified() +} diff --git a/client/iface/udpmux/mux_test.go b/client/iface/udpmux/mux_test.go new file mode 100644 index 000000000..2e2770282 --- /dev/null +++ b/client/iface/udpmux/mux_test.go @@ -0,0 +1,99 @@ +package udpmux + +import ( + "fmt" + "net" + "sync" + "sync/atomic" + "testing" + + "github.com/pion/transport/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type countingNet struct { + transport.Net + calls atomic.Int64 + ifaces []*transport.Interface +} + +func (n *countingNet) Interfaces() ([]*transport.Interface, error) { + n.calls.Add(1) + return n.ifaces, nil +} + +func TestNewSingleSocketUDPMux_DoesNotDiscoverInterfaces(t *testing.T) { + conn := listenUDP(t, net.IPv4zero) + n := &countingNet{ifaces: []*transport.Interface{testInterface("eth0", net.IPv4(192, 168, 1, 10))}} + + mux := NewSingleSocketUDPMux(Params{UDPConn: conn, Net: n}) + t.Cleanup(func() { _ = mux.Close() }) + + assert.Equal(t, int64(0), n.calls.Load(), "constructing the mux must not enumerate interfaces") + + addrs := mux.GetListenAddresses() + assert.Equal(t, int64(1), n.calls.Load(), "listen addresses are discovered when they are asked for") + + port := conn.LocalAddr().(*net.UDPAddr).Port + require.Len(t, addrs, 1, "one address per discovered interface IP") + assert.Equal(t, fmt.Sprintf("192.168.1.10:%d", port), addrs[0].String(), "the discovered IP is paired with the socket port") +} + +func TestSingleSocketUDPMux_GetConnWithoutListenAddresses(t *testing.T) { + t.Run("unspecified socket accepts any address", func(t *testing.T) { + conn := listenUDP(t, net.IPv4zero) + n := &countingNet{} + mux := NewSingleSocketUDPMux(Params{UDPConn: conn, Net: n}) + t.Cleanup(func() { _ = mux.Close() }) + + port := conn.LocalAddr().(*net.UDPAddr).Port + pc, err := mux.GetConn("ufrag", &net.UDPAddr{IP: net.IPv4(192, 168, 1, 10), Port: port}, "candidate") + require.NoError(t, err) + assert.NotNil(t, pc, "a connection is created for a local address of an unspecified socket") + assert.Equal(t, int64(0), n.calls.Load(), "GetConn must not depend on a prior discovery") + }) + + t.Run("specific socket rejects a foreign address", func(t *testing.T) { + conn := listenUDP(t, net.IPv4(127, 0, 0, 1)) + mux := NewSingleSocketUDPMux(Params{UDPConn: conn}) + t.Cleanup(func() { _ = mux.Close() }) + + _, err := mux.GetConn("ufrag", &net.UDPAddr{IP: net.IPv4(192, 168, 1, 10), Port: 1234}, "candidate") + assert.Error(t, err, "an address the socket is not bound to must be rejected") + + pc, err := mux.GetConn("ufrag", conn.LocalAddr(), "candidate") + require.NoError(t, err) + assert.NotNil(t, pc, "the bound address is accepted") + }) +} + +func listenUDP(t *testing.T, ip net.IP) *net.UDPConn { + t.Helper() + conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: 0}) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + +func testInterface(name string, ip net.IP) *transport.Interface { + ifc := transport.NewInterface(net.Interface{Index: 1, MTU: 1500, Name: name, Flags: net.FlagUp | net.FlagMulticast}) + ifc.AddAddress(&net.IPNet{IP: ip, Mask: net.CIDRMask(24, 32)}) + return ifc +} + +func TestSingleSocketUDPMux_ConcurrentListenAddressesWithDefaultNet(t *testing.T) { + conn := listenUDP(t, net.IPv4zero) + mux := NewSingleSocketUDPMux(Params{UDPConn: conn}) + t.Cleanup(func() { _ = mux.Close() }) + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + assert.NotEmpty(t, mux.GetListenAddresses(), "every caller gets at least the socket address") + }() + } + wg.Wait() +} diff --git a/client/internal/engine.go b/client/internal/engine.go index fdf89ae06..c4bdf5121 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -387,7 +387,7 @@ func NewEngine( mgmClient: services.MgmClient, relayManager: services.RelayManager, peerStore: peerstore.NewConnStore(), - wgDetector: stdnet.NewWGDetector(), + wgDetector: stdnet.NewWGDetector(config.WgIfaceName), syncMsgMux: &sync.Mutex{}, config: config, mobileDep: mobileDep, diff --git a/client/internal/stdnet/wgdetector.go b/client/internal/stdnet/wgdetector.go index f34e469e1..8ed290190 100644 --- a/client/internal/stdnet/wgdetector.go +++ b/client/internal/stdnet/wgdetector.go @@ -34,6 +34,8 @@ type WGDetector struct { // probe is replaced in tests; it is the call this type exists to avoid repeating. probe func(string) bool + ownIface string + mu sync.RWMutex cache map[string]wgDetectorEntry @@ -41,11 +43,12 @@ type WGDetector struct { } // NewWGDetector returns a detector with the default time to live. -func NewWGDetector() *WGDetector { +func NewWGDetector(ownIface string) *WGDetector { return &WGDetector{ - ttl: wgDetectorTTL, - probe: probeWireGuard, - cache: make(map[string]wgDetectorEntry), + ttl: wgDetectorTTL, + probe: probeWireGuard, + ownIface: ownIface, + cache: make(map[string]wgDetectorEntry), } } @@ -57,6 +60,10 @@ func (d *WGDetector) IsWireGuard(iFace string) bool { return probeWireGuard(iFace) } + if d.isOwnInterface(iFace) { + return true + } + if isWireGuard, ok := d.cached(iFace); ok { return isWireGuard } @@ -104,6 +111,10 @@ func (d *WGDetector) store(iFace string, isWireGuard bool) { d.cache[iFace] = wgDetectorEntry{isWireGuard: isWireGuard, expireAt: now.Add(d.ttl)} } +func (d *WGDetector) isOwnInterface(iFace string) bool { + return d.ownIface != "" && iFace == d.ownIface +} + func probeWireGuard(iFace string) bool { wg, err := wgctrl.New() if err != nil { diff --git a/client/internal/stdnet/wgdetector_test.go b/client/internal/stdnet/wgdetector_test.go index 68260ae59..28cf85f6a 100644 --- a/client/internal/stdnet/wgdetector_test.go +++ b/client/internal/stdnet/wgdetector_test.go @@ -150,3 +150,39 @@ func TestWGDetectorDropsExpiredEntries(t *testing.T) { assert.Len(t, d.cache, 1, "expired entries of vanished interfaces must be dropped") assert.Contains(t, d.cache, "eth0", "the fresh answer must be kept") } + +func TestWGDetectorOwnInterfaceIsWireGuardWithoutProbing(t *testing.T) { + d, calls := newCountingDetector(t, time.Minute, false) + d.ownIface = "NetBird" + + assert.True(t, d.IsWireGuard("NetBird"), "the agent's own interface is a WireGuard device") + assert.Equal(t, int64(0), calls.Load(), "the own interface must never reach the probe") + + assert.False(t, d.IsWireGuard("NetBird2"), "only the exact own name is answered without probing") + assert.Equal(t, int64(1), calls.Load(), "a different interface is probed") +} + +func TestWGDetectorWithoutOwnInterfaceProbesEmptyName(t *testing.T) { + d, calls := newCountingDetector(t, time.Minute, false) + + assert.False(t, d.IsWireGuard(""), "an unnamed interface is not mistaken for the own one") + assert.Equal(t, int64(1), calls.Load(), "with no own interface set every name is probed") +} + +func TestInterfaceFilterRejectsOwnInterfaceRegardlessOfDisallowList(t *testing.T) { + d, calls := newCountingDetector(t, time.Minute, false) + d.ownIface = "NetBird" + filter := InterfaceFilter([]string{"wt"}, d) + + assert.False(t, filter("NetBird"), "the own interface is rejected even when the disallow list does not cover it") + assert.Equal(t, int64(0), calls.Load(), "the own interface is not probed") + + assert.True(t, filter("eth0"), "an ordinary interface stays available") + assert.Equal(t, int64(1), calls.Load(), "an ordinary interface is still probed") +} + +func TestNewWGDetectorKeepsTheOwnInterface(t *testing.T) { + d := NewWGDetector("NetbirdWT") + + assert.True(t, d.IsWireGuard("NetbirdWT"), "the constructor records the own interface") +} From cf017119711184a0661062a0c8ac2043e4e8d411 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 16 Oct 2026 20:52:43 +0200 Subject: [PATCH 172/209] [client] Bump wireguard-go to bd0cb75a3df0 (#8229) StdNetBind.Send reused pooled *net.UDPAddr objects and copied the destination into ua.IP before resizing the slice. After an IPv4 send the slice was 4 bytes long, so the next IPv6 send copied only 4 bytes and extended the slice with stale data, misdirecting datagrams to a truncated or foreign IPv6 address. Userspace clients with both IPv4 and IPv6 P2P peers saw intermittent loss and restarting handshakes. The fork now carries the upstream fix (WireGuard/wireguard-go#135, netbirdio/wireguard-go#24). Pick it up. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index dcbc7d9d7..79dcd1867 100644 --- a/go.mod +++ b/go.mod @@ -369,7 +369,7 @@ replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-2024 replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-20231030152038-ef1ed2a27949 -replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20261008122457-d2b1ce9bfee9 +replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20261016113635-bd0cb75a3df0 replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 diff --git a/go.sum b/go.sum index 64197c736..5b9cd5a11 100644 --- a/go.sum +++ b/go.sum @@ -541,8 +541,8 @@ github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470 github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ= github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d h1:DV0FHFhNAaU69JHXKclYQWr/ZJoc2BA/ZpqlnX8l22U= github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d/go.mod h1:62UsqQRqanuCbFi6xElIw3/2X+yGF+TKlyiWImA5taU= -github.com/netbirdio/wireguard-go v0.0.0-20261008122457-d2b1ce9bfee9 h1:Mjt3tIBGa1/mJh1Xn8uZP30cd2VYd0NHANqTn+HBsaU= -github.com/netbirdio/wireguard-go v0.0.0-20261008122457-d2b1ce9bfee9/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +github.com/netbirdio/wireguard-go v0.0.0-20261016113635-bd0cb75a3df0 h1:nCK1YzDybjbAfrx+C2QPmcQ+7jYj4Rcb3FskqTNZEXo= +github.com/netbirdio/wireguard-go v0.0.0-20261016113635-bd0cb75a3df0/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= From 279adb8fdd5c5eb6d0f960fb6b65ab543ebb1f9d Mon Sep 17 00:00:00 2001 From: Edward <43848523+thomashacker@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:58:14 +0200 Subject: [PATCH 173/209] [management] Return the reason when custom domain validation fails (#8122) * [management] Return the reason when custom domain validation fails The validate endpoint returned an empty 202 and ran the check in a goroutine, so a failed verification only reached the management log. Run the check in the request and return 204 or 412 with a classified reason. Resolver errors are not forwarded. * [management] Address review feedback on custom domain validation Pass the request context to the lookup and store calls now that the check runs in the request, compare the self-resolved name case-insensitively, document the 412 like the other entries, and check the returned error in the existing tests. * [management] Match custom domain CNAME targets case-insensitively DNS names are case-insensitive, so a CNAME pointing at the cluster in different casing should validate instead of failing as a mismatch. * [management] Add validation timeout --------- Co-authored-by: DevBot NetBird <91386968+netbirddev@users.noreply.github.com> --- .../modules/reverseproxy/domain/interface.go | 2 +- .../reverseproxy/domain/manager/api.go | 7 +- .../domain/manager/expiration_test.go | 8 +- .../reverseproxy/domain/manager/manager.go | 87 +++++----- .../domain/manager/manager_realstore_test.go | 4 +- .../domain/manager/normalization_test.go | 2 +- .../domain/manager/validation_test.go | 151 ++++++++++++++++++ .../modules/reverseproxy/domain/validator.go | 59 ++++++- .../reverseproxy/domain/validator_test.go | 66 ++++++++ shared/management/http/api/openapi.yml | 9 +- 10 files changed, 331 insertions(+), 64 deletions(-) create mode 100644 management/internals/modules/reverseproxy/domain/manager/validation_test.go diff --git a/management/internals/modules/reverseproxy/domain/interface.go b/management/internals/modules/reverseproxy/domain/interface.go index a4bba5841..c729fb7b0 100644 --- a/management/internals/modules/reverseproxy/domain/interface.go +++ b/management/internals/modules/reverseproxy/domain/interface.go @@ -8,6 +8,6 @@ type Manager interface { GetDomains(ctx context.Context, accountID, userID string) ([]*Domain, error) CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*Domain, error) DeleteDomain(ctx context.Context, accountID, userID, domainID string) error - ValidateDomain(ctx context.Context, accountID, userID, domainID string) + ValidateDomain(ctx context.Context, accountID, userID, domainID string) error GetClusterDomains() []string } diff --git a/management/internals/modules/reverseproxy/domain/manager/api.go b/management/internals/modules/reverseproxy/domain/manager/api.go index f01329010..ec5daf7d6 100644 --- a/management/internals/modules/reverseproxy/domain/manager/api.go +++ b/management/internals/modules/reverseproxy/domain/manager/api.go @@ -134,7 +134,10 @@ func (h *handler) triggerCustomDomainValidation(w http.ResponseWriter, r *http.R return } - go h.manager.ValidateDomain(r.Context(), userAuth.AccountId, userAuth.UserId, domainID) + if err := h.manager.ValidateDomain(r.Context(), userAuth.AccountId, userAuth.UserId, domainID); err != nil { + util.WriteError(r.Context(), err, w) + return + } - w.WriteHeader(http.StatusAccepted) + w.WriteHeader(http.StatusNoContent) } diff --git a/management/internals/modules/reverseproxy/domain/manager/expiration_test.go b/management/internals/modules/reverseproxy/domain/manager/expiration_test.go index e3b05efbb..3a3bd6e92 100644 --- a/management/internals/modules/reverseproxy/domain/manager/expiration_test.go +++ b/management/internals/modules/reverseproxy/domain/manager/expiration_test.go @@ -29,7 +29,7 @@ func TestValidateDomain_ExpiredRegistration(t *testing.T) { Update("validation_expires_at", expiresAt).Error) env.resolver.set("validation.expired.example.com", testCluster) - env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID) + assert.Error(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID), "an expired registration must be refused") stored := storedDomain(t, env.store, accountA, d.Domain) require.NotNil(t, stored) @@ -47,7 +47,7 @@ func TestCreateDomain_ValidationDeadline(t *testing.T) { assert.Equal(t, createdAt.Add(48*time.Hour), *d.ValidationExpiresAt, "new registrations get 48 hours") time.Sleep(time.Hour) - env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID) + assert.Error(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID), "a missing CNAME must fail validation") stored := storedDomain(t, env.store, accountA, d.Domain) require.NotNil(t, stored) require.NotNil(t, stored.ValidationExpiresAt) @@ -198,9 +198,10 @@ func TestValidateDomain_DeadlinePassesDuringLookup(t *testing.T) { resolver := blockingDomainResolver{started: make(chan struct{}), release: make(chan struct{})} env.manager.validator.Resolver = resolver done := make(chan struct{}) + var validateErr error go func() { defer close(done) - env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID) + validateErr = env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID) }() <-resolver.started time.Sleep(48 * time.Hour) @@ -211,6 +212,7 @@ func TestValidateDomain_DeadlinePassesDuringLookup(t *testing.T) { } close(resolver.release) <-done + assert.Error(t, validateErr, "a validation finishing after the deadline must be refused") owner := accountA if cleanup { assert.Nil(t, storedDomain(t, env.store, accountA, d.Domain), "late validation must not restore the old claim") diff --git a/management/internals/modules/reverseproxy/domain/manager/manager.go b/management/internals/modules/reverseproxy/domain/manager/manager.go index 1e9ecab43..f92ed8201 100644 --- a/management/internals/modules/reverseproxy/domain/manager/manager.go +++ b/management/internals/modules/reverseproxy/domain/manager/manager.go @@ -227,22 +227,19 @@ func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID s return nil } -func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID string) { - ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create) +// validationTimeout bounds the DNS check so it ends, and reports why, before a proxy in +// front of management times the request out. +const validationTimeout = 10 * time.Second + +// ValidateDomain checks the domain's validation CNAME and marks it validated. A failure +// the user can act on is returned as PreconditionFailed carrying the reason. +func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID string) error { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create) if err != nil { - log.WithFields(log.Fields{ - "accountID": accountID, - "domainID": domainID, - }).WithError(err).Error("validate domain") - return + return status.NewPermissionValidationError(err) } if !ok { - log.WithFields(log.Fields{ - "accountID": accountID, - "domainID": domainID, - "userID": userID, - }).Error("validate domain: permission denied") - return + return status.NewPermissionDeniedError() } log.WithFields(log.Fields{ @@ -250,32 +247,23 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID "domainID": domainID, }).Info("starting domain validation") - d, err := m.store.GetCustomDomain(context.Background(), accountID, domainID) + d, err := m.store.GetCustomDomain(ctx, accountID, domainID) if err != nil { - log.WithFields(log.Fields{ - "accountID": accountID, - "domainID": domainID, - }).WithError(err).Error("get custom domain from store") - return + return fmt.Errorf("get domain from store: %w", err) } if d.Validated { - return + return nil } if d.ValidationExpiresAt == nil || !time.Now().Before(*d.ValidationExpiresAt) { - log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}). - Debug("custom domain validation window has expired") - return + return validationFailed(domain.NewValidationError(domain.ValidationReasonExpired, + "custom domain %s validation window has expired; delete and add the domain again", d.Domain)) } // Validate only against the domain's target cluster targetCluster := d.TargetCluster if targetCluster == "" { - log.WithFields(log.Fields{ - "accountID": accountID, - "domainID": domainID, - "domain": d.Domain, - }).Warn("domain has no target cluster set, skipping validation") - return + return validationFailed(domain.NewValidationError(domain.ValidationReasonNoTargetCluster, + "custom domain %s has no target cluster", d.Domain)) } log.WithFields(log.Fields{ @@ -285,32 +273,35 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID "targetCluster": targetCluster, }).Info("validating domain against target cluster") - if m.validator.IsValid(context.Background(), d.Domain, []string{targetCluster}) { + lookupCtx, cancel := context.WithTimeout(ctx, validationTimeout) + _, err = m.validator.Validate(lookupCtx, d.Domain, []string{targetCluster}) + cancel() + if err == nil { d.Validated = true - if _, err := m.store.UpdateCustomDomain(context.Background(), accountID, d); err != nil { - entry := log.WithFields(log.Fields{ - "accountID": accountID, - "domainID": domainID, - }).WithError(err) - if sErr, ok := status.FromError(err); ok && sErr.Type() == status.PreconditionFailed { - entry.Debug("custom domain registration is no longer pending validation") - return + if _, err := m.store.UpdateCustomDomain(ctx, accountID, d); err != nil { + // A concurrent request may have validated the domain first. + if current, getErr := m.store.GetCustomDomain(ctx, accountID, domainID); getErr == nil && current.Validated { + return nil } - entry.Error("update custom domain in store") - return + return fmt.Errorf("update domain in store: %w", err) } log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}). Info("custom domain validated successfully") - m.accountManager.StoreEvent(context.Background(), userID, domainID, accountID, activity.DomainValidated, d.EventMeta()) - } else { - log.WithFields(log.Fields{ - "accountID": accountID, - "domainID": domainID, - "domain": d.Domain, - "targetCluster": targetCluster, - }).Warn("domain validation failed - CNAME does not match target cluster") + m.accountManager.StoreEvent(ctx, userID, domainID, accountID, activity.DomainValidated, d.EventMeta()) + return nil } + + return validationFailed(err) +} + +// validationFailed shows a classified failure to the user and keeps anything else internal. +func validationFailed(err error) error { + var vErr *domain.ValidationError + if errors.As(err, &vErr) { + return status.Errorf(status.PreconditionFailed, "%s", vErr.Message) + } + return fmt.Errorf("validate domain: %w", err) } // GetClusterDomains returns a list of proxy cluster domains. diff --git a/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go b/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go index fed402498..dfb999fbd 100644 --- a/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go +++ b/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go @@ -229,7 +229,7 @@ func TestValidateDomain_UnlocksClusterDerivation(t *testing.T) { require.Error(t, err) env.resolver.set("validation.later.example.com", testCluster) - env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID) + require.NoError(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID)) require.True(t, storedDomain(t, env.store, accountA, "later.example.com").Validated) @@ -286,7 +286,7 @@ func TestValidateDomain_PermissionDeniedDoesNotValidate(t *testing.T) { // a validated domain is the permission check. env.resolver.set("validation.guarded.example.com", testCluster) - env.manager.ValidateDomain(ctx, accountA, accountAMember, created.ID) + assert.Error(t, env.manager.ValidateDomain(ctx, accountA, accountAMember, created.ID), "a caller without permission must be refused") stored := storedDomain(t, env.store, accountA, "guarded.example.com") require.NotNil(t, stored) diff --git a/management/internals/modules/reverseproxy/domain/manager/normalization_test.go b/management/internals/modules/reverseproxy/domain/manager/normalization_test.go index da5e4bdd1..810454c57 100644 --- a/management/internals/modules/reverseproxy/domain/manager/normalization_test.go +++ b/management/internals/modules/reverseproxy/domain/manager/normalization_test.go @@ -52,7 +52,7 @@ func TestCreateDomain_NormalizedNameCanValidateLater(t *testing.T) { require.False(t, created.Validated, "a missing CNAME must leave the normalized registration pending") env.resolver.set("validation.apps.example.com", testCluster) - env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID) + require.NoError(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID)) stored, err := env.store.GetCustomDomain(ctx, accountA, created.ID) require.NoError(t, err) assert.Equal(t, "apps.example.com", stored.Domain, "retrying validation must retain the normalized name") diff --git a/management/internals/modules/reverseproxy/domain/manager/validation_test.go b/management/internals/modules/reverseproxy/domain/manager/validation_test.go new file mode 100644 index 000000000..b7800c6f5 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/manager/validation_test.go @@ -0,0 +1,151 @@ +package manager + +import ( + "context" + "testing" + "testing/synctest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + nbstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" +) + +// The validator tests cover each DNS outcome. These cover what the manager adds on top: +// the status type the API turns into a response code, and the checks before the lookup. +func TestValidateDomain_ReturnsReason(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + setup func(t *testing.T, env *domainTestEnv, d *domain.Domain) + user string + want status.Type + message string + }{ + { + name: "cname mismatch", + setup: func(_ *testing.T, env *domainTestEnv, d *domain.Domain) { + env.resolver.set("validation."+d.Domain, "other.example.net") + }, + want: status.PreconditionFailed, + message: "CNAME record validation.test.example.com points to other.example.net; point it to " + testCluster, + }, + { + name: "expired window", + setup: func(t *testing.T, env *domainTestEnv, d *domain.Domain) { + setDomainColumn(t, env, d, "validation_expires_at", time.Now().Add(-time.Second)) + }, + want: status.PreconditionFailed, + message: "custom domain test.example.com validation window has expired; delete and add the domain again", + }, + { + name: "no target cluster", + setup: func(t *testing.T, env *domainTestEnv, d *domain.Domain) { + setDomainColumn(t, env, d, "target_cluster", "") + }, + want: status.PreconditionFailed, + message: "custom domain test.example.com has no target cluster", + }, + { + name: "permission denied", + setup: func(_ *testing.T, env *domainTestEnv, d *domain.Domain) { + env.resolver.set("validation."+d.Domain, testCluster) + }, + user: accountAMember, + want: status.PermissionDenied, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env := setupDomainTest(t) + d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "test.example.com", testCluster) + require.NoError(t, err) + tt.setup(t, env, d) + user := accountAUser + if tt.user != "" { + user = tt.user + } + + err = env.manager.ValidateDomain(ctx, accountA, user, d.ID) + sErr, ok := status.FromError(err) + require.True(t, ok, "expected a status error, got %v", err) + assert.Equal(t, tt.want, sErr.Type(), "status type") + if tt.message != "" { + assert.Equal(t, tt.message, sErr.Message, "user-facing message") + } + assert.False(t, storedDomain(t, env.store, accountA, d.Domain).Validated, "a failed check must not validate the domain") + }) + } +} + +func TestValidateDomain_SuccessIsIdempotent(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "ok.example.com", testCluster) + require.NoError(t, err) + env.resolver.set("validation.ok.example.com", testCluster) + + require.NoError(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)) + assert.True(t, storedDomain(t, env.store, accountA, d.Domain).Validated, "domain should be validated") + assert.NoError(t, env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID), "an already validated domain is a success") +} + +func setDomainColumn(t *testing.T, env *domainTestEnv, d *domain.Domain, column string, value any) { + t.Helper() + db := env.store.(*nbstore.SqlStore).GetDB() + require.NoError(t, db.Model(&domain.Domain{}).Where("id = ?", d.ID).Update(column, value).Error) +} + +// Two verify requests can both read the domain as pending. The one that loses the store +// update must still report success when the other one validated the domain. +func TestValidateDomain_ConcurrentValidationSucceeds(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "twice.example.com", testCluster) + require.NoError(t, err) + resolver := blockingDomainResolver{started: make(chan struct{}), release: make(chan struct{})} + env.manager.validator.Resolver = resolver + + done := make(chan error, 1) + go func() { + done <- env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID) + }() + <-resolver.started + // The other request completes while this one is still looking up the CNAME. + setDomainColumn(t, env, d, "validated", true) + close(resolver.release) + + assert.NoError(t, <-done, "a domain validated by a concurrent request is a success") +} + +// hangingResolver never answers, so only a deadline on the context ends the lookup. +type hangingResolver struct{} + +func (hangingResolver) LookupCNAME(ctx context.Context, _ string) (string, error) { + <-ctx.Done() + return "", ctx.Err() +} + +// A lookup that hangs must end on the validation timeout and still report the reason, +// rather than leaving the request to whatever timeout sits in front of management. +func TestValidateDomain_LookupTimeout(t *testing.T) { + env := setupClockDomainTest(t) + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() + d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "slow.example.com", testCluster) + require.NoError(t, err) + env.manager.validator.Resolver = hangingResolver{} + + start := time.Now() + err = env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID) + sErr, ok := status.FromError(err) + require.True(t, ok, "expected a status error, got %v", err) + assert.Equal(t, status.PreconditionFailed, sErr.Type(), "status type") + assert.Equal(t, "DNS lookup for validation.slow.example.com failed; retry the validation", sErr.Message, "user-facing message") + assert.Equal(t, validationTimeout, time.Since(start), "the lookup must end on the validation timeout") + }) +} diff --git a/management/internals/modules/reverseproxy/domain/validator.go b/management/internals/modules/reverseproxy/domain/validator.go index 9c23c1192..e8d1ea3d0 100644 --- a/management/internals/modules/reverseproxy/domain/validator.go +++ b/management/internals/modules/reverseproxy/domain/validator.go @@ -2,6 +2,8 @@ package domain import ( "context" + "errors" + "fmt" "net" "strings" @@ -16,6 +18,33 @@ type Validator struct { Resolver resolver } +// ValidationReason classifies why a custom domain failed validation. +type ValidationReason string + +const ( + ValidationReasonCNAMENotFound ValidationReason = "cname_not_found" + ValidationReasonCNAMEMismatch ValidationReason = "cname_mismatch" + ValidationReasonLookupFailed ValidationReason = "lookup_failed" + ValidationReasonExpired ValidationReason = "validation_expired" + ValidationReasonNoTargetCluster ValidationReason = "no_target_cluster" +) + +// ValidationError is a validation failure whose message is safe to show to the user. +type ValidationError struct { + Reason ValidationReason + Message string +} + +// Error returns the user-facing message. +func (e *ValidationError) Error() string { + return e.Message +} + +// NewValidationError returns a ValidationError with a formatted message. +func NewValidationError(reason ValidationReason, format string, args ...any) *ValidationError { + return &ValidationError{Reason: reason, Message: fmt.Sprintf(format, args...)} +} + // NewValidator initializes a validator with a specific DNS Resolver. // If a Validator is used without specifying a Resolver, then it will // use the net.DefaultResolver. @@ -39,6 +68,13 @@ func (v *Validator) IsValid(ctx context.Context, domain string, accept []string) // ValidateWithCluster validates a custom domain and returns the matched cluster address. // Returns the cluster address and true if valid, or empty string and false if invalid. func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, accept []string) (string, bool) { + cluster, err := v.Validate(ctx, domain, accept) + return cluster, err == nil +} + +// Validate validates a custom domain and returns the matched cluster address. +// On failure it returns a *ValidationError whose message is safe to show to the user. +func (v *Validator) Validate(ctx context.Context, domain string, accept []string) (string, error) { if v.Resolver == nil { v.Resolver = net.DefaultResolver } @@ -56,10 +92,19 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce "domain": domain, "lookupDomain": lookupDomain, }).WithError(err).Warn("CNAME lookup failed for domain validation") - return "", false + // Never forward the resolver error: its text includes the resolver address. + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + return "", cnameNotFound(lookupDomain, accept) + } + return "", NewValidationError(ValidationReasonLookupFailed, "DNS lookup for %s failed; retry the validation", lookupDomain) } nakedCNAME := strings.TrimSuffix(cname, ".") + // A name without a CNAME record resolves to itself. + if strings.EqualFold(nakedCNAME, lookupDomain) { + return "", cnameNotFound(lookupDomain, accept) + } log.WithFields(log.Fields{ "domain": domain, "cname": cname, @@ -69,13 +114,13 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce for _, acceptDomain := range accept { normalizedAccept := strings.TrimSuffix(acceptDomain, ".") - if nakedCNAME == normalizedAccept { + if strings.EqualFold(nakedCNAME, normalizedAccept) { log.WithFields(log.Fields{ "domain": domain, "cname": nakedCNAME, "cluster": acceptDomain, }).Info("domain CNAME matched cluster") - return acceptDomain, true + return acceptDomain, nil } } @@ -84,5 +129,11 @@ func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, acce "cname": nakedCNAME, "acceptList": accept, }).Warn("domain CNAME does not match any accepted cluster") - return "", false + return "", NewValidationError(ValidationReasonCNAMEMismatch, "CNAME record %s points to %s; point it to %s", + lookupDomain, nakedCNAME, strings.Join(accept, ", ")) +} + +func cnameNotFound(lookupDomain string, accept []string) *ValidationError { + return NewValidationError(ValidationReasonCNAMENotFound, "no CNAME record found for %s; point it to %s", + lookupDomain, strings.Join(accept, ", ")) } diff --git a/management/internals/modules/reverseproxy/domain/validator_test.go b/management/internals/modules/reverseproxy/domain/validator_test.go index 1f9583728..8d47bca32 100644 --- a/management/internals/modules/reverseproxy/domain/validator_test.go +++ b/management/internals/modules/reverseproxy/domain/validator_test.go @@ -2,6 +2,8 @@ package domain_test import ( "context" + "errors" + "net" "testing" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" @@ -36,6 +38,12 @@ func TestIsValid(t *testing.T) { accept: []string{"bar.example.com"}, expect: false, }, + "match in other case": { + resolver: resolver{"Bar.Example.COM."}, + domain: "foo.example.com", + accept: []string{"bar.example.com"}, + expect: true, + }, "accept trailing dot": { resolver: resolver{"bar.example.com."}, domain: "foo.example.com", @@ -54,3 +62,61 @@ func TestIsValid(t *testing.T) { }) } } + +type errResolver struct { + err error +} + +func (r errResolver) LookupCNAME(context.Context, string) (string, error) { + return "", r.err +} + +func TestValidate_Reason(t *testing.T) { + const notFound = "no CNAME record found for validation.foo.example.com; point it to eu.proxy.example.com" + tests := map[string]struct { + resolver interface { + LookupCNAME(context.Context, string) (string, error) + } + reason domain.ValidationReason + message string + }{ + "mismatch": { + resolver: resolver{"other.example.net."}, + reason: domain.ValidationReasonCNAMEMismatch, + message: "CNAME record validation.foo.example.com points to other.example.net; point it to eu.proxy.example.com", + }, + "no cname resolves to itself": { + resolver: resolver{"validation.foo.example.com."}, + reason: domain.ValidationReasonCNAMENotFound, + message: notFound, + }, + "no cname resolves to itself in other case": { + resolver: resolver{"Validation.Foo.example.com."}, + reason: domain.ValidationReasonCNAMENotFound, + message: notFound, + }, + "not found": { + resolver: errResolver{&net.DNSError{Err: "no such host", Name: "validation.foo.example.com", Server: "10.0.0.2:53", IsNotFound: true}}, + reason: domain.ValidationReasonCNAMENotFound, + message: notFound, + }, + "other error is not forwarded": { + resolver: errResolver{errors.New("dial udp 10.0.0.2:53: connect: network is unreachable")}, + reason: domain.ValidationReasonLookupFailed, + message: "DNS lookup for validation.foo.example.com failed; retry the validation", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := domain.NewValidator(test.resolver).Validate(t.Context(), "foo.example.com", []string{"eu.proxy.example.com"}) + var vErr *domain.ValidationError + if !errors.As(err, &vErr) { + t.Fatalf("expected a *domain.ValidationError, got %v", err) + } + if vErr.Reason != test.reason || vErr.Message != test.message { + t.Errorf("expect %q %q, actual %q %q", test.reason, test.message, vErr.Reason, vErr.Message) + } + }) + } +} diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index fbf8bf5cd..ca8c1339b 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -12895,7 +12895,7 @@ paths: /api/reverse-proxies/domains/{domainId}/validate: get: summary: Validate a custom domain - description: Trigger domain ownership validation for a custom domain + description: Validate domain ownership for a custom domain tags: [ Services ] security: - BearerAuth: [ ] @@ -12908,8 +12908,8 @@ paths: type: string description: The custom domain ID responses: - '202': - description: Reverse proxy custom domain validation triggered + '204': + description: Reverse proxy custom domain validated '400': "$ref": "#/components/responses/bad_request" '401': @@ -12918,6 +12918,9 @@ paths: "$ref": "#/components/responses/forbidden" '404': "$ref": "#/components/responses/not_found" + '412': + description: Precondition failed - the custom domain could not be validated + content: { } '500': "$ref": "#/components/responses/internal_error" /api/integrations/notifications/types: From f8f335720de0d7f5e4842fd24c42b78bfa042859 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:08:15 +0200 Subject: [PATCH 174/209] [client,relay] Bump quic-go to v0.64.0 (#8249) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 79dcd1867..bc8e5e287 100644 --- a/go.mod +++ b/go.mod @@ -105,7 +105,7 @@ require ( github.com/pkg/sftp v1.13.9 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 - github.com/quic-go/quic-go v0.62.0 + github.com/quic-go/quic-go v0.64.0 github.com/redis/go-redis/v9 v9.7.3 github.com/rs/xid v1.3.0 github.com/shirou/gopsutil/v4 v4.25.8 diff --git a/go.sum b/go.sum index 5b9cd5a11..9af59daf5 100644 --- a/go.sum +++ b/go.sum @@ -647,8 +647,8 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4 github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= -github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8= -github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= +github.com/quic-go/quic-go v0.64.0 h1:dlUpiyp9+7UrbbuhLvV7CJ9F8uoZxXf/vi2h8tkuuoA= +github.com/quic-go/quic-go v0.64.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= From b1f4efb160071bb04052bfbb736ca0227ff151e3 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:16:59 +0900 Subject: [PATCH 175/209] [client] Fix errcheck findings from the latest golangci-lint (#8251) --- client/iface/iface_close_test.go | 24 ++++++++++++++++-------- client/ssh/client/client.go | 6 ++++-- management/internals/server/server.go | 6 +++++- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/client/iface/iface_close_test.go b/client/iface/iface_close_test.go index ea3115ec0..ecbbf4866 100644 --- a/client/iface/iface_close_test.go +++ b/client/iface/iface_close_test.go @@ -12,6 +12,7 @@ import ( "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface/device" + nbnetstack "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/iface/wgproxy" @@ -34,14 +35,14 @@ func (f *fakeTunDevice) Create() (device.WGConfigurer, error) { func (f *fakeTunDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) { return nil, errors.New("not implemented") } -func (f *fakeTunDevice) UpdateAddr(wgaddr.Address) error { return nil } -func (f *fakeTunDevice) WgAddress() wgaddr.Address { return wgaddr.Address{} } -func (f *fakeTunDevice) MTU() uint16 { return DefaultMTU } -func (f *fakeTunDevice) DeviceName() string { return "nb-close-test" } +func (f *fakeTunDevice) UpdateAddr(wgaddr.Address) error { return nil } +func (f *fakeTunDevice) WgAddress() wgaddr.Address { return wgaddr.Address{} } +func (f *fakeTunDevice) MTU() uint16 { return DefaultMTU } +func (f *fakeTunDevice) DeviceName() string { return "nb-close-test" } func (f *fakeTunDevice) FilteredDevice() *device.FilteredDevice { return nil } -func (f *fakeTunDevice) Device() *wgdevice.Device { return nil } -func (f *fakeTunDevice) GetNet() *netstack.Net { return nil } -func (f *fakeTunDevice) GetICEBind() device.EndpointManager { return nil } +func (f *fakeTunDevice) Device() *wgdevice.Device { return nil } +func (f *fakeTunDevice) GetNet() *netstack.Net { return nil } +func (f *fakeTunDevice) GetICEBind() device.EndpointManager { return nil } func (f *fakeTunDevice) Close() error { close(f.closeStarted) @@ -62,6 +63,10 @@ func (fakeProxyFactory) Free() error { return nil } // WGIface.GetDevice() which needs the same mutex. The fix is to drop // the lock before tun.Close() returns control. func TestWGIface_CloseReleasesMutexBeforeTunClose(t *testing.T) { + // Netstack mode skips the platform interface cleanup in Close, so only + // the mutex and tun close behavior is exercised. + t.Setenv(nbnetstack.EnvUseNetstackMode, "true") + tun := &fakeTunDevice{ closeStarted: make(chan struct{}), unblockClose: make(chan struct{}), @@ -105,7 +110,10 @@ func TestWGIface_CloseReleasesMutexBeforeTunClose(t *testing.T) { close(tun.unblockClose) select { - case <-closeDone: + case err := <-closeDone: + if err != nil { + t.Errorf("WGIface.Close() returned an error: %v", err) + } case <-time.After(2 * time.Second): t.Fatal("WGIface.Close() never returned after the tun was unblocked") } diff --git a/client/ssh/client/client.go b/client/ssh/client/client.go index 31143a4f4..dc94c33f4 100644 --- a/client/ssh/client/client.go +++ b/client/ssh/client/client.go @@ -166,7 +166,8 @@ func (c *Client) ExecuteCommandWithIO(ctx context.Context, command string) error case <-ctx.Done(): _ = session.Signal(ssh.SIGTERM) select { - case <-done: + case err := <-done: + log.Debugf("session ended after SIGTERM: %v", err) return ctx.Err() case <-time.After(100 * time.Millisecond): return ctx.Err() @@ -205,7 +206,8 @@ func (c *Client) ExecuteCommandWithPTY(ctx context.Context, command string) erro case <-ctx.Done(): _ = session.Signal(ssh.SIGTERM) select { - case <-done: + case err := <-done: + log.Debugf("session ended after SIGTERM: %v", err) return ctx.Err() case <-time.After(100 * time.Millisecond): return ctx.Err() diff --git a/management/internals/server/server.go b/management/internals/server/server.go index bbf0f389d..c95092d70 100644 --- a/management/internals/server/server.go +++ b/management/internals/server/server.go @@ -335,7 +335,11 @@ func (s *BaseServer) Stop() error { } select { - case <-s.Errors(): + case err := <-s.Errors(): + if err != nil { + log.WithContext(ctx).Infof("stopped Management Service: %v", err) + return nil + } log.WithContext(ctx).Infof("stopped Management Service") return nil case <-ctx.Done(): From 6e1065882a453a199a7c3ed7831fe6b6011b9b80 Mon Sep 17 00:00:00 2001 From: DevBot NetBird <91386968+netbirddev@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:24:07 +0200 Subject: [PATCH 176/209] New Crowdin updates (#8101) * New translations common.json (French) [ci skip] * New translations common.json (Spanish) [ci skip] * New translations common.json (German) [ci skip] * New translations common.json (Hebrew) [ci skip] * New translations common.json (Hungarian) [ci skip] * New translations common.json (Italian) [ci skip] * New translations common.json (Japanese) [ci skip] * New translations common.json (Russian) [ci skip] * New translations common.json (Ukrainian) [ci skip] * New translations common.json (Chinese Simplified) [ci skip] * New translations common.json (Portuguese, Brazilian) [ci skip] * New translations common.json (Galician) [ci skip] * Update client/ui/i18n/locales/he/common.json Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * New translations common.json (French) [ci skip] * New translations common.json (Spanish) [ci skip] * New translations common.json (German) [ci skip] * New translations common.json (Hebrew) [ci skip] * New translations common.json (Hungarian) [ci skip] * New translations common.json (Italian) [ci skip] * New translations common.json (Japanese) [ci skip] * New translations common.json (Russian) [ci skip] * New translations common.json (Ukrainian) [ci skip] * New translations common.json (Chinese Simplified) [ci skip] * New translations common.json (Portuguese, Brazilian) [ci skip] * New translations common.json (Galician) [ci skip] --------- Co-authored-by: Edward <43848523+thomashacker@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- client/ui/i18n/locales/de/common.json | 6 +- client/ui/i18n/locales/es/common.json | 6 +- client/ui/i18n/locales/fr/common.json | 6 +- client/ui/i18n/locales/he/common.json | 1397 ++++++++++++++++++++++ client/ui/i18n/locales/hu/common.json | 6 +- client/ui/i18n/locales/it/common.json | 6 +- client/ui/i18n/locales/ja/common.json | 6 +- client/ui/i18n/locales/pt/common.json | 6 +- client/ui/i18n/locales/ru/common.json | 6 +- client/ui/i18n/locales/zh-CN/common.json | 6 +- 10 files changed, 1415 insertions(+), 36 deletions(-) create mode 100644 client/ui/i18n/locales/he/common.json diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index dcce2f908..105c8c0b3 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -552,12 +552,10 @@ "message": "Ändern des Autostarts fehlgeschlagen" }, "settings.general.keepConnectedOnQuit.label": { - "message": "Nach dem Beenden verbunden bleiben", - "description": "Toggle label: keep the VPN connection up after quitting the UI." + "message": "Nach dem Beenden verbunden bleiben" }, "settings.general.keepConnectedOnQuit.help": { - "message": "Die Verbindung bleibt im Hintergrund bestehen, nachdem Sie NetBird schließen. Sie endet erst, wenn Sie sie selbst trennen.", - "description": "Helper text for the stay-connected-after-quitting toggle." + "message": "Die Verbindung bleibt im Hintergrund bestehen, nachdem Sie NetBird schließen. Sie endet erst, wenn Sie sie selbst trennen." }, "settings.general.language.label": { "message": "Anzeigesprache" diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index 979879680..f786592a7 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -552,12 +552,10 @@ "message": "Error al cambiar el inicio automático" }, "settings.general.keepConnectedOnQuit.label": { - "message": "Permanecer conectado al salir", - "description": "Toggle label: keep the VPN connection up after quitting the UI." + "message": "Permanecer conectado al salir" }, "settings.general.keepConnectedOnQuit.help": { - "message": "La conexión sigue activa en segundo plano después de cerrar NetBird. Solo se detiene cuando la desconectas tú.", - "description": "Helper text for the stay-connected-after-quitting toggle." + "message": "La conexión sigue activa en segundo plano después de cerrar NetBird. Solo se detiene cuando la desconectas tú." }, "settings.general.language.label": { "message": "Idioma de la interfaz" diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index f9961d864..87e833e3f 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -552,12 +552,10 @@ "message": "Échec de la modification du démarrage automatique" }, "settings.general.keepConnectedOnQuit.label": { - "message": "Rester connecté après la fermeture", - "description": "Toggle label: keep the VPN connection up after quitting the UI." + "message": "Rester connecté après la fermeture" }, "settings.general.keepConnectedOnQuit.help": { - "message": "La connexion reste active en arrière-plan après la fermeture de NetBird. Elle ne s'arrête que si vous la coupez vous-même.", - "description": "Helper text for the stay-connected-after-quitting toggle." + "message": "La connexion reste active en arrière-plan après la fermeture de NetBird. Elle ne s'arrête que si vous la coupez vous-même." }, "settings.general.language.label": { "message": "Langue d’affichage" diff --git a/client/ui/i18n/locales/he/common.json b/client/ui/i18n/locales/he/common.json new file mode 100644 index 000000000..640f6b3da --- /dev/null +++ b/client/ui/i18n/locales/he/common.json @@ -0,0 +1,1397 @@ +{ + "tray.tooltip": { + "message": "NetBird" + }, + "tray.status.disconnected": { + "message": "מנותק" + }, + "tray.status.daemonUnavailable": { + "message": "לא פעיל" + }, + "tray.status.error": { + "message": "שגיאה" + }, + "tray.status.connected": { + "message": "מחובר" + }, + "tray.status.connecting": { + "message": "מתחבר" + }, + "tray.status.needsLogin": { + "message": "צריך להיכנס" + }, + "tray.status.loginFailed": { + "message": "הכניסה נכשלה" + }, + "tray.status.sessionExpired": { + "message": "תוקף ההפעלה פג" + }, + "tray.session.expiresIn": { + "message": "תוקף ההפעלה יפוג בעוד {remaining}" + }, + "tray.session.unit.lessThanMinute": { + "message": "פחות מדקה" + }, + "tray.session.unit.minute": { + "message": "דקה" + }, + "tray.session.unit.minutes": { + "message": "{count} דקות" + }, + "tray.session.unit.hour": { + "message": "שעה" + }, + "tray.session.unit.hours": { + "message": "{count} שעות" + }, + "tray.session.unit.day": { + "message": "יום" + }, + "tray.session.unit.days": { + "message": "{count} ימים" + }, + "tray.menu.open": { + "message": "פתיחת NetBird" + }, + "tray.menu.connect": { + "message": "התחברות" + }, + "tray.menu.disconnect": { + "message": "התנתקות" + }, + "tray.menu.exitNode": { + "message": "נקודת יציאה" + }, + "tray.menu.networks": { + "message": "משאבים" + }, + "tray.menu.profiles": { + "message": "פרופילים" + }, + "tray.menu.manageProfiles": { + "message": "ניהול פרופילים" + }, + "tray.menu.settings": { + "message": "הגדרות…" + }, + "tray.menu.debugBundle": { + "message": "יצירת מאגד ניפוי שגיאות" + }, + "tray.menu.about": { + "message": "עזרה ותמיכה" + }, + "tray.menu.github": { + "message": "GitHub" + }, + "tray.menu.documentation": { + "message": "תיעוד" + }, + "tray.menu.troubleshoot": { + "message": "פתרון בעיות" + }, + "tray.menu.downloadLatest": { + "message": "הורדת הגרסה העדכנית ביותר" + }, + "tray.menu.installVersion": { + "message": "התקנת גרסה {version}" + }, + "tray.menu.guiVersion": { + "message": "ממשק משתמש: {version}" + }, + "tray.menu.daemonVersion": { + "message": "סוכן: {version}" + }, + "tray.menu.versionUnknown": { + "message": "—" + }, + "tray.menu.quit": { + "message": "יציאה מ־NetBird" + }, + "notify.daemonOutdated.title": { + "message": "שירות NetBird אינו עדכני" + }, + "notify.daemonOutdated.body": { + "message": "יש לעדכן את שירות NetBird כדי להשתמש ביישום הזה." + }, + "notify.update.title": { + "message": "יש עדכון ל־NetBird" + }, + "notify.update.body": { + "message": "יצא NetBird {version}." + }, + "notify.update.enforcedSuffix": { + "message": " הנהלת המערכת דורשת ממך לעדכן לגרסה הזאת." + }, + "notify.error.title": { + "message": "שגיאה" + }, + "notify.error.connect": { + "message": "החיבור נכשל" + }, + "notify.error.disconnect": { + "message": "הניתוק נכשל" + }, + "notify.error.switchProfile": { + "message": "המעבר לפרופיל {profile} נכשל" + }, + "notify.error.exitNode": { + "message": "עדכון נקודת היציאה {name} נכשל" + }, + "notify.sessionExpired.title": { + "message": "תוקף הפעלת NetBird פג" + }, + "notify.sessionExpired.body": { + "message": "תוקף הפעלת ה־NetBird שלך פג. נא להיכנס למערכת שוב." + }, + "notify.sessionWarning.title": { + "message": "תוקף ההפעלה יפוג בקרוב" + }, + "notify.sessionWarning.body": { + "message": "הפעלת ה־NetBird שלך תפוג בעוד {remaining}. לחיצה על להאריך כעת תחדש אותה." + }, + "notify.sessionWarning.bodyGeneric": { + "message": "תוקף הפעלת ה־NetBird שלך עומד לפוג. לחיצה על להאריך כעת תחדש אותו." + }, + "notify.sessionWarning.extend": { + "message": "להאריך כעת" + }, + "notify.sessionWarning.dismiss": { + "message": "התעלמות" + }, + "notify.sessionWarning.failed": { + "message": "הארכת ההפעלה של NetBird נכשלה" + }, + "notify.sessionWarning.successTitle": { + "message": "ההפעלה של NetBird הוארכה" + }, + "notify.sessionWarning.successBody": { + "message": "ההפעלה שלך התרעננה." + }, + "notify.sessionDeadlineRejected.title": { + "message": "מועד הסיום של ההפעלה נדחה" + }, + "notify.sessionDeadlineRejected.body": { + "message": "השרת שלח מועד סיום שגוי להפעלה. נא להיכנס שוב למערכת." + }, + "notify.mdm.policyApplied.title": { + "message": "ההגדרות של NetBird עודכנו" + }, + "notify.mdm.policyApplied.body": { + "message": "הגדרות ה־NetBird שלך עודכנו על ידי מדיניות צוות המחשוב שלך." + }, + "common.cancel": { + "message": "ביטול" + }, + "common.save": { + "message": "שמירה" + }, + "common.saveChanges": { + "message": "שמירת השינויים" + }, + "common.saving": { + "message": "שמירה…" + }, + "common.close": { + "message": "סגירה" + }, + "common.copy": { + "message": "העתקה" + }, + "common.togglePasswordVisibility": { + "message": "הסתרת/חשיפת סיסמה" + }, + "common.increase": { + "message": "הגדלה" + }, + "common.decrease": { + "message": "הקטנה" + }, + "common.delete": { + "message": "מחיקה" + }, + "common.create": { + "message": "יצירה" + }, + "common.add": { + "message": "הוספה" + }, + "common.remove": { + "message": "הסרה" + }, + "common.refresh": { + "message": "ריענון" + }, + "common.loading": { + "message": "בטעינה…" + }, + "common.netbird": { + "message": "NetBird" + }, + "common.noResults.title": { + "message": "לא ניתן למצוא תוצאות כלשהן." + }, + "common.noResults.description": { + "message": "לא הצלחנו למצוא תוצאות כלשהן. נא לנסות ביטוי חיפוש אחר או לשנות את המסננים שלך." + }, + "notConnected.title": { + "message": "מנותק" + }, + "notConnected.description": { + "message": "יש להתחבר ל־NetBird תחילה כדי לצפות במידע מפורט על העמיתים, משאבי הרשת ונקודות היציאה שלך." + }, + "connect.status.disconnected": { + "message": "מנותק" + }, + "connect.status.connecting": { + "message": "מתחבר…" + }, + "connect.status.connected": { + "message": "מחובר" + }, + "connect.status.disconnecting": { + "message": "מתנתק…" + }, + "connect.status.daemonUnavailable": { + "message": "הסוכן לא זמין" + }, + "connect.status.loginRequired": { + "message": "נדרשת כניסה" + }, + "connect.error.loginTitle": { + "message": "הכניסה נכשלה" + }, + "connect.error.connectTitle": { + "message": "התחברות נכשלה" + }, + "connect.error.disconnectTitle": { + "message": "התנתקות נכשלה" + }, + "nav.peers.title": { + "message": "עמיתים" + }, + "nav.peers.description": { + "message": "{connected} מתוך {total} מחוברים" + }, + "nav.resources.title": { + "message": "משאבים" + }, + "nav.resources.description": { + "message": "{active} מתוך {total} פעילים" + }, + "nav.exitNode.title": { + "message": "נקודות יציאה" + }, + "nav.exitNode.none": { + "message": "לא פעיל" + }, + "nav.exitNode.using": { + "message": "דרך {name}" + }, + "header.openSettings": { + "message": "פתיחת הגדרות" + }, + "header.togglePanel": { + "message": "הצגת/הסתרת לוח צד" + }, + "profile.selector.loading": { + "message": "בטעינה…" + }, + "profile.selector.noProfile": { + "message": "אין פרופיל" + }, + "profile.selector.searchPlaceholder": { + "message": "חיפוש פרופיל לפי שם…" + }, + "profile.selector.emptyTitle": { + "message": "לא נמצאו פרופילים" + }, + "profile.selector.emptyDescription": { + "message": "כדאי לנסות ביטוי אחר לחיפוש או ליצור פרופיל חדש." + }, + "profile.selector.newProfile": { + "message": "פרופיל חדש" + }, + "profile.selector.moreOptions": { + "message": "אפשרויות נוספות" + }, + "profile.selector.deregister": { + "message": "ביטול רישום" + }, + "profile.selector.delete": { + "message": "מחיקה" + }, + "profile.selector.switchTo": { + "message": "החלפה לפרופיל הזה" + }, + "profile.selector.edit": { + "message": "עריכה" + }, + "profile.edit.title": { + "message": "עריכת פרופיל" + }, + "profile.edit.submit": { + "message": "שמירת השינויים" + }, + "profile.dialog.title": { + "message": "נא למלא שם פרופיל" + }, + "profile.dialog.nameLabel": { + "message": "שם פרופיל" + }, + "profile.dialog.description": { + "message": "הגדרת שם שניתן לזהות בקלות לפרופיל שלך." + }, + "profile.dialog.placeholder": { + "message": "למשל: עבודה" + }, + "profile.dialog.submit": { + "message": "הוספת פרופיל" + }, + "profile.dialog.required": { + "message": "נא למלא שם לפרופיל, למשל: עבודה, בית" + }, + "profile.dialog.managementHelp": { + "message": "אפשר להשתמש בענן של NetBird או בשרת משלך." + }, + "profile.dialog.urlUnreachable": { + "message": "לא ניתן להגיע לשרת. נא לבדוק שהכתובת נכונה או להוסיף את הפרופיל בכל זאת אם ברור לך שהיא נכונה." + }, + "header.menu.settings": { + "message": "הגדרות…" + }, + "header.menu.defaultView": { + "message": "תצוגת ברירת מחדל" + }, + "header.menu.advancedView": { + "message": "תצוגה מתקדמת" + }, + "header.menu.updateAvailable": { + "message": "יש עדכון" + }, + "header.menu.open": { + "message": "פתיחת התפריט" + }, + "header.profile.switch": { + "message": "החלפת פרופיל" + }, + "connect.toggle.label": { + "message": "החלפת מצב חיבור NetBird" + }, + "connect.localIp.label": { + "message": "כתובות IP מקומיות" + }, + "common.search": { + "message": "חיפוש" + }, + "common.filter": { + "message": "סינון" + }, + "exitNodes.dropdown.trigger": { + "message": "בחירת נקודת יציאה" + }, + "peers.row.label": { + "message": "פתיחת פרטים עבור {name}, {status}" + }, + "peers.dialog.title": { + "message": "פרטי עמית" + }, + "networks.row.toggle": { + "message": "להחליף את המצב של {name}" + }, + "networks.bulk.label": { + "message": "להחליף את המצב של כל המשאבים הגלויים" + }, + "profile.switch.title": { + "message": "להחליף את הפרופיל כך שיהיה „{name}”?" + }, + "profile.switch.message": { + "message": "להחליף פרופילים?\nהפרופיל הנוכחי שלך יתנתק." + }, + "profile.switch.confirm": { + "message": "אישור" + }, + "profile.deregister.title": { + "message": "לבטל את רישום הפרופיל „{name}”?" + }, + "profile.deregister.message": { + "message": "לבטל את רישום הפרופיל הזה?\nצריך להיכנס שוב כדי להשתמש בו." + }, + "profile.deregister.confirm": { + "message": "לבטל רישום" + }, + "profile.delete.title": { + "message": "למחוק את הפרופיל „{name}”?" + }, + "profile.delete.message": { + "message": "למחוק את הפרופיל הזה?\nזאת פעולה בלתי הפיכה." + }, + "profile.delete.disabledActive": { + "message": "לא ניתן למחוק פרופילים פעילים. יש להחליף לפרופיל אחר בטרם מחיקת הפרופיל הזה." + }, + "profile.delete.disabledDefault": { + "message": "לא ניתן למחוק את פרופיל ברירת המחדל." + }, + "profile.error.switchTitle": { + "message": "החלפת פרופיל נכשלה" + }, + "profile.error.deregisterTitle": { + "message": "ביטול רישום פרופיל נכשל" + }, + "profile.error.deleteTitle": { + "message": "מחיקת פרופיל נכשלה" + }, + "profile.error.createTitle": { + "message": "יצירת פרופיל נכשלה" + }, + "profile.error.editTitle": { + "message": "עריכת פרופיל נכשלה" + }, + "profile.error.loadTitle": { + "message": "טעינת הפרופילים נכשלה" + }, + "profile.dropdown.activeProfile": { + "message": "הפרופיל הפעיל" + }, + "profile.dropdown.switchProfile": { + "message": "החלפת פרופיל" + }, + "profile.dropdown.noEmail": { + "message": "אחר" + }, + "profile.dropdown.addProfile": { + "message": "הוספת פרופיל" + }, + "profile.dropdown.manageProfiles": { + "message": "ניהול פרופילים" + }, + "profile.dropdown.settings": { + "message": "הגדרות" + }, + "settings.profiles.section.profiles": { + "message": "פרופילים" + }, + "settings.profiles.intro": { + "message": "ניתן להחזיק את זהויות ה־NetBird הנפרדות זו לצד זו, למשל חשבון עבודה וחשבון פרטי או שרתי ניהול שונים. אפשר להוסיף, לבטל רישום או למחוק פרופילים להלן." + }, + "settings.profiles.addProfile": { + "message": "הוספת פרופיל" + }, + "settings.profiles.active": { + "message": "פעיל" + }, + "settings.profiles.emptyTitle": { + "message": "אין פרופילים" + }, + "settings.profiles.emptyDescription": { + "message": "יש ליצור פרופיל כדי להתחבר לשרת ניהול של NetBird." + }, + "settings.error.loadTitle": { + "message": "טעינת ההגדרות נכשלה" + }, + "settings.error.saveTitle": { + "message": "שמירת ההגדרות נכשלה" + }, + "settings.error.debugBundleTitle": { + "message": "מאגד ניפוי השגיאות נכשל" + }, + "settings.nav.label": { + "message": "סעיפי הגדרות" + }, + "settings.tabs.general": { + "message": "כללי" + }, + "settings.tabs.network": { + "message": "רשת" + }, + "settings.tabs.security": { + "message": "אבטחה" + }, + "settings.tabs.profiles": { + "message": "פרופילים" + }, + "settings.tabs.ssh": { + "message": "SSH" + }, + "settings.tabs.advanced": { + "message": "מתקדם" + }, + "settings.tabs.troubleshooting": { + "message": "פתרון בעיות" + }, + "settings.tabs.about": { + "message": "על אודות" + }, + "settings.tabs.updateAvailable": { + "message": "יש עדכון" + }, + "settings.general.section.general": { + "message": "כללי" + }, + "settings.general.section.connection": { + "message": "חיבור" + }, + "settings.general.connectOnStartup.label": { + "message": "להתחבר עם העלייה" + }, + "settings.general.connectOnStartup.help": { + "message": "להקים חיבור אוטומטית כשהשירות מופעל." + }, + "settings.general.notifications.label": { + "message": "התראות שולחן עבודה" + }, + "settings.general.notifications.help": { + "message": "להציג התראות שולחן עבודה על עדכונים חדשים ואירועי חיבור." + }, + "settings.general.autostart.label": { + "message": "להפעיל את ממשק המשתמש של NetBird עם הכניסה למערכת" + }, + "settings.general.autostart.help": { + "message": "להפעיל את הממשק של NetBird אוטומטית עם הכניסה. הפעולה הזאת משפיעה על הממשק הגרפי בלבד, לא על שירות הרקע." + }, + "settings.general.autostart.errorTitle": { + "message": "שינוי בהפעלה האוטומטית נכשל" + }, + "settings.general.keepConnectedOnQuit.label": { + "message": "להשאיר את החיבור לאחר היציאה" + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "החיבור נשאר פעיל ברקע לאחר סגירת NetBird. הוא נקטע רק כשבחרת לנתק אותו בעצמך." + }, + "settings.general.language.label": { + "message": "שפת התצוגה" + }, + "settings.general.language.help": { + "message": "בחירת שפת הממשק של NetBird." + }, + "settings.general.language.search": { + "message": "חיפוש שפה…" + }, + "settings.general.language.empty": { + "message": "אין שפות תואמות." + }, + "settings.general.theme.label": { + "message": "ערכת עיצוב" + }, + "settings.general.theme.help": { + "message": "נא לבחור בהירה או כהה או להתאים למערכת." + }, + "settings.general.theme.system": { + "message": "מערכת" + }, + "settings.general.theme.light": { + "message": "בהירה" + }, + "settings.general.theme.dark": { + "message": "כהה" + }, + "settings.general.management.label": { + "message": "שרת ניהול" + }, + "settings.general.management.help": { + "message": "אפשר להתחבר לענן של NetBird או לשרת ניהול באירוח שלך. שינויים יחברו את הלקוח מחדש." + }, + "settings.general.management.cloud": { + "message": "ענן" + }, + "settings.general.management.selfHosted": { + "message": "אירוח עצמי" + }, + "settings.general.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlError": { + "message": "נא למלא כתובת תקפה, למשל, https://netbird.selfhosted.com:443" + }, + "settings.general.management.urlUnreachable": { + "message": "לא ניתן להגיע לשרת הזה. נא לבדוק את הכתובת או לשמור בכל זאת אם ברור לך שהיא נכונה." + }, + "settings.general.management.switchCloudTitle": { + "message": "לעבור לענן NetBird?" + }, + "settings.general.management.switchCloudMessage": { + "message": "מנתק אותך מהשרת שלך באירוח עצמי.\nיתכן שיהיה עליך להיכנס למערכת שוב." + }, + "settings.general.management.switchCloudConfirm": { + "message": "מעבר לענן" + }, + "settings.network.section.connectivity": { + "message": "קישוריות" + }, + "settings.network.section.routingDns": { + "message": "ניתוב ו־DNS" + }, + "settings.network.monitor.label": { + "message": "להתחבר מחדש כשחל שינוי ברשת" + }, + "settings.network.monitor.help": { + "message": "מעקב אחר הרשת כדי להתחבר מחדש עקב שינויים כגון מעבר רשת אלחוטית, שינויים בחיבור הקווי או התעוררות משינה." + }, + "settings.network.dns.label": { + "message": "הפעלת DNS" + }, + "settings.network.dns.help": { + "message": "החלת הגדרות DNS בניהול NetBird לפותר שמות המארחים." + }, + "settings.network.clientRoutes.label": { + "message": "הפעלת ניתובי לקוח" + }, + "settings.network.clientRoutes.help": { + "message": "קבלת ניתובים מעמיתים אחרים כדי להגיע לרשתות שלהם." + }, + "settings.network.serverRoutes.label": { + "message": "הפעלת ניתובי שרת" + }, + "settings.network.serverRoutes.help": { + "message": "לפרסם את הניתובים המקומיים של המארח הזה לעמיתים אחרים." + }, + "settings.network.ipv6.label": { + "message": "הפעלת IPv6" + }, + "settings.network.ipv6.help": { + "message": "להשתמש בחלוקת כתובות IPv6 לרשת שכבת העל של NetBird." + }, + "settings.security.section.firewall": { + "message": "חומת אש" + }, + "settings.security.section.encryption": { + "message": "הצפנה" + }, + "settings.security.blockInbound.label": { + "message": "חסימת תעבורה נכנסת" + }, + "settings.security.blockInbound.help": { + "message": "לדחות חיבורים שלא התבקשו מעמיתים למכשיר הזה ולרשתות כלשהן שהוא מנתב אליהן. אין השפעה על התקשורת היוצאת." + }, + "settings.security.blockLan.label": { + "message": "חסימת גישה לרשת המקומית" + }, + "settings.security.blockLan.help": { + "message": "למנוע מעמיתים להגיע לרשת המקומית שלך או המכשירים שבה כאשר המכשיר הזה מנתב את התעבורה שלהם." + }, + "settings.security.rosenpass.label": { + "message": "הפעלת עמידות לפיענוח צפנים קוונטי" + }, + "settings.security.rosenpass.help": { + "message": "הוספת החלפת מפתחות עמידה לפיענוח צפנים קוונטי דרך Rosenpass על גבי WireGuard®." + }, + "settings.security.rosenpassPermissive.label": { + "message": "הפעלת מצב מתירני" + }, + "settings.security.rosenpassPermissive.help": { + "message": "לאפשר חיבורים לעמיתים ללא תמיכה בעמידות לפיענוח צפנים קוונטי." + }, + "settings.ssh.section.server": { + "message": "שרת" + }, + "settings.ssh.section.capabilities": { + "message": "יכולות" + }, + "settings.ssh.section.authentication": { + "message": "אימות" + }, + "settings.ssh.server.label": { + "message": "הפעלת שרת SSH" + }, + "settings.ssh.server.help": { + "message": "להריץ את שרת ה־SSH של NetBird על המארח הזה כדי שעמיתים אחרים יוכלו להתחבר אליו." + }, + "settings.ssh.root.label": { + "message": "לאפשר כניסת ניהול (root)" + }, + "settings.ssh.root.help": { + "message": "לאפשר לעמיתים להיכנס בתור משתמש הניהול. אפשר להשבית כדי לדרוש שזה יהיה חשבון ללא הרשאות מופלגות." + }, + "settings.ssh.sftp.label": { + "message": "לאפשר SFTP" + }, + "settings.ssh.sftp.help": { + "message": "העברת קבצים בבטחה עם לקוחות מובנים של SFTP או SCP." + }, + "settings.ssh.localForward.label": { + "message": "העברת פתחה מקומית" + }, + "settings.ssh.localForward.help": { + "message": "לאפשר לעמיתים שמתחברים לתעל פתחות מקומיות לשירותים שנגישים למארח הזה." + }, + "settings.ssh.remoteForward.label": { + "message": "העברת פתחה מרוחקת" + }, + "settings.ssh.remoteForward.help": { + "message": "לאפשר לעמיתים שמתחברים לחשוף בפני המארח הזה פתחות שחוזרות למכונות שלהם." + }, + "settings.ssh.jwt.label": { + "message": "הפעלת אימות JWT" + }, + "settings.ssh.jwt.help": { + "message": "לאמת כל הפעלת SSH מול ספק הזהות (IdP) שלך לזהות המשתמש ולפיקוח. יש להשבית כדי לסמוך רק על מדיניויות ה־ACL (רשימת בקרת גישה) של הרשתות בלבד, שימושי כשאין ספק זהות (IdP) זמין." + }, + "settings.ssh.jwtTtl.label": { + "message": "זמן חיי JWT במטמון (TTL)" + }, + "settings.ssh.jwtTtl.help": { + "message": "כמה זמן הלקוח הזה שומר JWT במטמון בטרם הצגת בקשה חוזרת לחיבורי SSH יוצאים. יש להגדיר ל־0 כדי להשבית את המטמון ולעבור אימות עם כל חיבור מחדש." + }, + "settings.ssh.jwtTtl.suffix": { + "message": "שנייה/ות" + }, + "settings.advanced.section.interface": { + "message": "ממשק" + }, + "settings.advanced.section.security": { + "message": "אבטחה" + }, + "settings.advanced.interfaceName.label": { + "message": "שם" + }, + "settings.advanced.interfaceName.error": { + "message": "יש להשתמש ב־1-15 אותיות, ספרות, נקודות, מינוסים או קווים תחתיים." + }, + "settings.advanced.interfaceName.errorMac": { + "message": "חייב להתחיל ב־„utun” ולאחריו מספר (למשל: utun100)." + }, + "settings.advanced.port.label": { + "message": "פתחה" + }, + "settings.advanced.port.error": { + "message": "נא למלא פתחה בין {min} ל־{max}." + }, + "settings.advanced.port.help": { + "message": "אם מוגדר ל־0, ייעשה שימוש בפתחה פנויה אקראית." + }, + "settings.advanced.mtu.label": { + "message": "MTU" + }, + "settings.advanced.mtu.error": { + "message": "נא למלא ערך MTU בין {min} לבין {max}." + }, + "settings.advanced.psk.label": { + "message": "מפתח ששותף מראש" + }, + "settings.advanced.psk.help": { + "message": "מפתח ששותף מראש (PSK) של WireGuard כרשות להצפנה סימטרית בנוסף. לא זהה למפתח התקנה של NetBird. תהיה לך תקשורת רק מול עמיתים שמשתמשים באותו המפתח ששותף מראש." + }, + "settings.troubleshooting.section.title": { + "message": "מאגד ניפוי שגיאות" + }, + "settings.troubleshooting.anonymize.label": { + "message": "הסוואת מידע רגיש עם מידע אלמוני" + }, + "settings.troubleshooting.anonymize.help": { + "message": "הסתרת כתובות IP ציבוריות ושמות תחום שאינם של NetBird מהיומנים." + }, + "settings.troubleshooting.anonymize.info": { + "message": "ברירת המחדל משאירה את כתובות ה־IPv4 הפנימיות ואת שמות העמיתים קריאים למטרות תמיכה. מצב מחמיר מסתיר באלמוניות כתובות פרטיות (RFC 1918), CGNAT ו־link-local (קישור מקומי), שמות עמיתים ומפתחות ציבוריים של WireGuard. ערכים חוזרים ממופים לאותו ממלא המקום, כך שהעמיתים נשארים בלתי ניתנים להבדלה. כדאי להשתמש במחמיר בעת שיתוף המאגד מחוץ לארגון שלך." + }, + "settings.troubleshooting.anonymize.none": { + "message": "ללא" + }, + "settings.troubleshooting.anonymize.default": { + "message": "ברירת מחדל" + }, + "settings.troubleshooting.anonymize.strict": { + "message": "מחמיר" + }, + "settings.troubleshooting.systemInfo.label": { + "message": "לכלול את פרטי המערכת" + }, + "settings.troubleshooting.systemInfo.help": { + "message": "לכלול את מערכת ההפעלה, הליבה, ממשקי הרשת וטבלאות הניתוב." + }, + "settings.troubleshooting.upload.label": { + "message": "להעלות את המאגד לשרתים של NetBird" + }, + "settings.troubleshooting.upload.help": { + "message": "החזרת מפתח שליחה לשיתוף עם התמיכה של NetBird." + }, + "settings.troubleshooting.trace.label": { + "message": "הפעלת יומני מעקב" + }, + "settings.troubleshooting.trace.help": { + "message": "הגברת רמת התיעוד ביומן ל־TRACE (מעקב) ומשחזרת אותה לאחר מכן." + }, + "settings.troubleshooting.capture.label": { + "message": "הפעלת לכידה" + }, + "settings.troubleshooting.capture.help": { + "message": "חיבור מחדש והמתנה כדי לאפשר ליצור את התקלה מחדש." + }, + "settings.troubleshooting.packets.label": { + "message": "לכידת מנות נתונים מהרשת" + }, + "settings.troubleshooting.packets.help": { + "message": "שמירת ‎.pcap של תעבורת רשת בזמן חלון האיסוף." + }, + "settings.troubleshooting.duration.label": { + "message": "משך לכידה" + }, + "settings.troubleshooting.duration.help": { + "message": "למשך כמה זמן הפעלת הלכידה רצה." + }, + "settings.troubleshooting.duration.suffix": { + "message": "דקה/ות" + }, + "settings.troubleshooting.create": { + "message": "יצירת מאגד" + }, + "settings.troubleshooting.progress.description": { + "message": "היומנים, פרטי המערכת ומצב החיבור נאספים. האיסוף בדרך כלל לוקח כמה רגעים. אפשר להמשיך להשתמש ב־NetBird או לסגור את ההגדרות בזמן שהתהליך מסתיים." + }, + "settings.troubleshooting.cancelling": { + "message": "מבוטל…" + }, + "settings.troubleshooting.done.uploadedTitle": { + "message": "מאגד ניפוי השגיאות נשלח בהצלחה!" + }, + "settings.troubleshooting.done.savedTitle": { + "message": "המאגד נשמר" + }, + "settings.troubleshooting.done.uploadedDescription": { + "message": "יש לשתף את מפתח השליחה להלן עם התמיכה של NetBird. עותק מקומי נשמר בנוסף על המכונה שלך." + }, + "settings.troubleshooting.done.savedDescription": { + "message": "מאגד ניפוי השגיאות שלך נשמר מקומית." + }, + "settings.troubleshooting.done.copyKey": { + "message": "העתקת מפתח" + }, + "settings.troubleshooting.done.openFolder": { + "message": "פתיחת תיקייה" + }, + "settings.troubleshooting.done.openFileLocation": { + "message": "פתיחת מקום הקובץ" + }, + "settings.troubleshooting.uploadFailedWithReason": { + "message": "ההעלאה נכשלה: {reason}, המאגד עדיין שמור מקומית." + }, + "settings.troubleshooting.uploadFailed": { + "message": "ההעלאה נכשלה. המאגד עדיין שמור מקומית." + }, + "settings.troubleshooting.stage.reconnecting": { + "message": "NetBird מתחבר מחדש…" + }, + "settings.troubleshooting.stage.capturing": { + "message": "יומני ניפוי השגיאות נלכדים" + }, + "settings.troubleshooting.stage.bundling": { + "message": "מאגד ניפוי שגיאות נוצר…" + }, + "settings.troubleshooting.stage.uploading": { + "message": "נשלח ל־NetBird…" + }, + "settings.troubleshooting.stage.cancelling": { + "message": "מבטל…" + }, + "settings.about.client": { + "message": "לקוח NetBird v{version}" + }, + "settings.about.clientName": { + "message": "לקוח NetBird" + }, + "settings.about.development": { + "message": "[פיתוח]" + }, + "settings.about.gui": { + "message": "ממשק משתמש v{version}" + }, + "settings.about.guiName": { + "message": "ממשק משתמש" + }, + "settings.about.copyright": { + "message": "© {year} NetBird. כל הזכויות שמורות." + }, + "settings.about.links.imprint": { + "message": "הצהרה משפטית" + }, + "settings.about.links.privacy": { + "message": "פרטיות" + }, + "settings.about.links.cla": { + "message": "CLA" + }, + "settings.about.links.terms": { + "message": "תנאי השירות" + }, + "settings.about.community.github": { + "message": "GitHub" + }, + "settings.about.community.slack": { + "message": "Slack" + }, + "settings.about.community.forum": { + "message": "פורום" + }, + "settings.about.community.documentation": { + "message": "תיעוד" + }, + "settings.about.community.feedback": { + "message": "משוב" + }, + "update.banner.message": { + "message": "ההתקנה של NetBird {version} מוכנה." + }, + "update.banner.later": { + "message": "אחר כך" + }, + "update.banner.installNow": { + "message": "להתקין עכשיו" + }, + "update.card.versionAvailableDownload": { + "message": "גרסה {version} זמינה להורדה." + }, + "update.card.versionAvailableInstall": { + "message": "גרסה {version} זמינה להתקנה." + }, + "update.card.whatsNew": { + "message": "מה חדש?" + }, + "update.card.installNow": { + "message": "להתקין עכשיו" + }, + "update.card.getInstaller": { + "message": "הורדה" + }, + "update.card.autoCheckInterval": { + "message": "NetBird בודק ברקע אם יש עדכונים." + }, + "update.card.changelog": { + "message": "יומן שינויים" + }, + "update.card.onLatestVersion": { + "message": "הגרסה המותקנת היא העדכנית ביותר" + }, + "update.header.tooltip": { + "message": "יש עדכון" + }, + "update.overlay.updatingVersion": { + "message": "NetBird מתעדכן ל־v{version}" + }, + "update.overlay.updating": { + "message": "NetBird מתעדכן" + }, + "update.overlay.description": { + "message": "גרסה חדשה יותר זמינה ומותקנת כרגע. NetBird יופעל מחדש אוטומטית לאחר סיום העדכון." + }, + "update.overlay.error.timeoutTitle": { + "message": "העדכון אורך זמן רב מדי" + }, + "update.overlay.error.timeoutDescription": { + "message": "התקנת {target} ארכה זמן רב מדי ולא הסתיימה." + }, + "update.overlay.error.canceledTitle": { + "message": "העדכון נעצר" + }, + "update.overlay.error.canceledDescription": { + "message": "העדכון לגרסה {target} בוטל לפני שהספיק להסתיים." + }, + "update.overlay.error.failTitle": { + "message": "לא ניתן להתקין את העדכון" + }, + "update.overlay.error.failDescription": { + "message": "לא ניתן להתקין את {target}." + }, + "update.overlay.error.unknownMessage": { + "message": "שגיאה לא ידועה" + }, + "update.overlay.error.targetVersion": { + "message": "v{version}" + }, + "update.overlay.error.targetFallback": { + "message": "הגרסה החדשה" + }, + "update.error.loadStateTitle": { + "message": "טעינת מצב העדכון נכשלה" + }, + "update.error.triggerTitle": { + "message": "התחלת העדכון נכשלה" + }, + "update.page.versionLine": { + "message": "הלקוח מתעדכן לגרסה: {version}." + }, + "update.page.versionLineGeneric": { + "message": "הלקוח מתעדכן." + }, + "update.page.outdated": { + "message": "גרסת הלקוח שלך ישנה מגרסת העדכון האוטומטי שהוגדרה בניהול." + }, + "update.page.status.running": { + "message": "מתעדכן" + }, + "update.page.status.timeout": { + "message": "הזמן שהוקצב לעדכון הסתיים. נא לנסות שוב." + }, + "update.page.status.canceled": { + "message": "העדכון בוטל." + }, + "update.page.status.failed": { + "message": "העדכון נכשל: {message}" + }, + "update.page.status.unknownError": { + "message": "שגיאת עדכון לא ידועה" + }, + "update.page.failedTitle": { + "message": "העדכון נכשל" + }, + "update.page.timeoutMessage": { + "message": "הזמן שהוקצב לעדכון תם." + }, + "update.page.dontClose": { + "message": "נא לא לסגור את החלון הזה" + }, + "update.page.updating": { + "message": "מתעדכן…" + }, + "update.page.complete": { + "message": "העדכון הושלם" + }, + "update.page.failed": { + "message": "העדכון נכשל" + }, + "window.title.settings": { + "message": "הגדרות" + }, + "window.title.signIn": { + "message": "כניסה" + }, + "window.title.sessionExpiration": { + "message": "תוקף ההפעלה פג" + }, + "window.title.updating": { + "message": "מתעדכן" + }, + "window.title.welcome": { + "message": "ברוך בואך ל־NetBird" + }, + "window.title.error": { + "message": "שגיאה" + }, + "welcome.title": { + "message": "כדאי לחפש את NetBird בשורת המשימות שלך" + }, + "welcome.titleMac": { + "message": "כדאי לחפש את NetBird בשורת התפריטים שלך" + }, + "welcome.description": { + "message": "NetBird שוכן בשורת המשימות שלך. יש ללחוץ על הסמל כדי להתחבר, להחליף פרופילים או כדי לפתוח את ההגדרות." + }, + "welcome.descriptionMac": { + "message": "NetBird שוכן בסרגל התפריטים שלך. יש ללחוץ על הסמל כדי להתחבר, להחליף פרופילים או כדי לפתוח את ההגדרות." + }, + "welcome.continue": { + "message": "המשך" + }, + "welcome.back": { + "message": "חזרה" + }, + "welcome.management.title": { + "message": "הגדרת NetBird" + }, + "welcome.management.description": { + "message": "יש ללחוץ על המשך כדי להתחיל או לבחור באירוח עצמי אם יש לך שרת NetBird משלך." + }, + "welcome.management.cloud.title": { + "message": "ענן NetBird" + }, + "welcome.management.cloud.description": { + "message": "אפשר להשתמש בשירות באירוח שלנו. לא צריך הגדרות." + }, + "welcome.management.selfHosted.title": { + "message": "אירוח עצמי" + }, + "welcome.management.selfHosted.description": { + "message": "התחברות לשרת ניהול משלך." + }, + "welcome.management.urlLabel": { + "message": "כתובת שרת ניהול" + }, + "welcome.management.urlPlaceholder": { + "message": "https://netbird.selfhosted.com:443" + }, + "welcome.management.urlInvalid": { + "message": "נא למלא כתובת שרת ניהול תקפה, כגון, https://netbird.selfhosted.com:443" + }, + "welcome.management.urlUnreachable": { + "message": "לא ניתן להגיע לשרת הזה. נא לבדוק את הכתובת או את הרשת שלך, לאחר מכן להמשיך אם היא נכונה בוודאות." + }, + "welcome.management.checking": { + "message": "מתבצעת בדיקה…" + }, + "browserLogin.title": { + "message": "יש להשלים את הכניסה בדפדפן שלך" + }, + "browserLogin.notSeeing": { + "message": "פתחנו לשונית בדפדפן כדי לאפשר לך לסיים את הכניסה למערכת. לא רואה אותה?" + }, + "browserLogin.tryAgain": { + "message": "לנסות שוב" + }, + "browserLogin.openFailedTitle": { + "message": "פתיחת הדפדפן נכשלה" + }, + "sessionExpiration.title": { + "message": "תוקף ההפעלה עומד לפוג בקרוב" + }, + "sessionExpiration.titleLater": { + "message": "תוקף ההפעלה שלך יפוג" + }, + "sessionExpiration.description": { + "message": "המכשיר הזה ינותק בהקדם. יש לחדש את הכניסה דרך הדפדפן." + }, + "sessionExpiration.descriptionLater": { + "message": "כניסה עם דפדפן משאירה את המכשיר הזה מחובר לרשת שלך." + }, + "sessionExpiration.stay": { + "message": "חידוש הפעלה" + }, + "sessionExpiration.authenticate": { + "message": "אימות" + }, + "sessionExpiration.logout": { + "message": "יציאה" + }, + "sessionExpiration.expired": { + "message": "תוקף ההפעלה פג" + }, + "sessionExpiration.expiredDescription": { + "message": "המכשיר נותק. יש לעבור אימות עם כניסת דפדפן כדי להתחבר מחדש." + }, + "sessionExpiration.close": { + "message": "סגירה" + }, + "sessionExpiration.extendFailedTitle": { + "message": "הארכת ההפעלה נכשלה" + }, + "sessionExpiration.logoutFailedTitle": { + "message": "היציאה מהחשבון נכשלה" + }, + "peers.search.placeholder": { + "message": "חיפוש לפי שם או IP" + }, + "peers.filter.all": { + "message": "כולם" + }, + "peers.filter.online": { + "message": "מחוברים" + }, + "peers.filter.offline": { + "message": "מנותקים" + }, + "peers.empty.title": { + "message": "אין עמיתים זמינים" + }, + "peers.empty.description": { + "message": "או שאין לך עמיתים זמינים או שאין לך גישה לאף אחד מהם." + }, + "peers.details.domain": { + "message": "שם תחום" + }, + "peers.details.netbirdIp": { + "message": "IP של NetBird" + }, + "peers.details.netbirdIpv6": { + "message": "IPv6 של NetBird" + }, + "peers.details.publicKey": { + "message": "מפתח ציבורי" + }, + "peers.details.connection": { + "message": "חיבור" + }, + "peers.details.latency": { + "message": "עיכוב" + }, + "peers.details.lastHandshake": { + "message": "לחיצת יד אחרונה" + }, + "peers.details.statusSince": { + "message": "עדכון אחרון על החיבור" + }, + "peers.details.bytes": { + "message": "בתים" + }, + "peers.details.bytesSent": { + "message": "נשלחו" + }, + "peers.details.bytesReceived": { + "message": "התקבלו" + }, + "peers.details.localIce": { + "message": "ICE מקומי" + }, + "peers.details.remoteIce": { + "message": "ICE מרוחק" + }, + "peers.details.never": { + "message": "אף פעם לא" + }, + "peers.details.justNow": { + "message": "כרגע" + }, + "peers.details.refresh": { + "message": "ריענון" + }, + "peers.status.connected": { + "message": "מחובר" + }, + "peers.status.connecting": { + "message": "מתחבר" + }, + "peers.status.disconnected": { + "message": "מנותק" + }, + "peers.details.relayAddress": { + "message": "ממסר" + }, + "peers.details.networks": { + "message": "משאבים" + }, + "peers.details.relayed": { + "message": "דרך ממסר" + }, + "peers.details.p2p": { + "message": "P2P" + }, + "peers.details.rosenpass": { + "message": "Rosenpass הופעל" + }, + "networks.search.placeholder": { + "message": "חיפוש לפי רשת או שם תחום" + }, + "networks.filter.all": { + "message": "כולן" + }, + "networks.filter.active": { + "message": "פעילות" + }, + "networks.filter.overlapping": { + "message": "חופפות" + }, + "networks.empty.title": { + "message": "אין משאבים זמינים" + }, + "networks.empty.description": { + "message": "או שאין לך משאבי רשת זמינים או שאין לך גישה לאף אחד מהם." + }, + "networks.selected": { + "message": "נבחרה" + }, + "networks.unselected": { + "message": "לא נבחרה" + }, + "networks.ips.heading": { + "message": "כתובות IP שנפתרו" + }, + "networks.bulk.selectionCount": { + "message": "{selected} מתוך {total} פעילות" + }, + "networks.bulk.enableAll": { + "message": "להפעיל הכול" + }, + "networks.bulk.disableAll": { + "message": "לכבות הכול" + }, + "exitNodes.search.placeholder": { + "message": "חיפוש נקודות יציאה" + }, + "exitNodes.none": { + "message": "אין" + }, + "exitNodes.empty.title": { + "message": "אין נקודות יציאה זמינות" + }, + "exitNodes.empty.description": { + "message": "לא שותפו נקודות יציאה עם העמית הזה." + }, + "exitNodes.card.title": { + "message": "נקודת יציאה" + }, + "exitNodes.card.statusActive": { + "message": "פעילה" + }, + "exitNodes.card.statusInactive": { + "message": "כבויה" + }, + "exitNodes.dropdown.noneTitle": { + "message": "אין" + }, + "exitNodes.dropdown.noneDescription": { + "message": "חיבור ישיר ללא נקודת יציאה" + }, + "quickActions.connect": { + "message": "התחברות" + }, + "quickActions.disconnect": { + "message": "התנתקות" + }, + "daemon.unavailable.title": { + "message": "שירות NetBird לא פעיל" + }, + "daemon.unavailable.description": { + "message": "היישום יתחבר אוטומטית לאחר שהשירות יופעל." + }, + "daemon.unavailable.docsLink": { + "message": "תיעוד" + }, + "daemon.outdated.title": { + "message": "שירות NetBird אינו עדכני" + }, + "daemon.outdated.description": { + "message": "ממשק המשתמש החדש לא תואם ללקוח ישן יותר של NetBird. יש לעדכן את הלקוח שלך כדי להשתמש ביישום החדש" + }, + "daemon.outdated.download": { + "message": "להוריד את העדכני ביותר" + }, + "error.jwt_clock_skew": { + "message": "הכניסה נכשלה: השעון של המכשיר הזה לא תואם במדויק את זה של השרת. נא לכוון את שעון המערכת שלך ולנסות שוב." + }, + "error.jwt_expired": { + "message": "תוקף אסימון הכניסה שלך פג. נא להיכנס שוב." + }, + "error.jwt_signature_invalid": { + "message": "הכניסה נכשלה: חתימת האסימון שגויה. נא ליצור קשר עם הנהלת המערכת שלך." + }, + "error.session_expired": { + "message": "תוקף ההפעלה שלך פג. נא להיכנס שוב." + }, + "error.invalid_setup_key": { + "message": "מפתח ההתקנה חסר או שגוי." + }, + "error.permission_denied": { + "message": "בקשת הכניסה נדחתה על ידי השרת." + }, + "error.daemon_unreachable": { + "message": "הסוכן של NetBird לא מגיב. נא לבדוק שהשירות פעיל." + }, + "error.settings_locked": { + "message": "אי אפשר לשנות את ההגדרות במכשיר הזה: הן ננעלו על ידי ההנהלה." + }, + "error.settings_managed_by_mdm": { + "message": "ההגדרה הזאת מנוהלת על ידי הארגון שלך ואי אפשר לשנות אותה." + }, + "error.unknown": { + "message": "הפעולה נכשלה." + }, + "error.elevation_unavailable": { + "message": "NetBird לא הצליח לבקש מהמערכת הזאת את ההרשאות שהשינוי הזה צריך. יש להריץ את זה במקום:" + }, + "error.elevation_failed": { + "message": "אי אפשר להחיל את השינויים עם הרשאות גבוהות יותר. יש להריץ את זה במקום:" + }, + "settings.ssh.privilege.actorRoot": { + "message": "משתמש על/root" + }, + "settings.ssh.privilege.actorAdministrator": { + "message": "הרשאות ניהול" + }, + "settings.ssh.privilege.hint": { + "message": "דורש {actor}. יש להריץ את זה במקום:" + }, + "settings.ssh.privilege.oneWay": { + "message": "אפשר לכבות את זה, אבל ההפעלה דורשת {actor}." + }, + "settings.ssh.privilege.oneWayInverted": { + "message": "אפשר להפעיל את זה, אבל הכיבוי דורש {actor}." + }, + "settings.ssh.privilege.authorizePending": { + "message": "בהמתנה לאימות…" + } +} diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 94dcb578c..36705afbc 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -552,12 +552,10 @@ "message": "Az automatikus indítás módosítása sikertelen" }, "settings.general.keepConnectedOnQuit.label": { - "message": "Kapcsolat megtartása kilépéskor", - "description": "Toggle label: keep the VPN connection up after quitting the UI." + "message": "Kapcsolat megtartása kilépéskor" }, "settings.general.keepConnectedOnQuit.help": { - "message": "A kapcsolat a háttérben megmarad, miután bezárod a NetBirdöt. Csak akkor szakad meg, ha te magad bontod.", - "description": "Helper text for the stay-connected-after-quitting toggle." + "message": "A kapcsolat a háttérben megmarad, miután bezárod a NetBirdöt. Csak akkor szakad meg, ha te magad bontod." }, "settings.general.language.label": { "message": "Megjelenítési nyelv" diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 8c1312535..6590a01fe 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -552,12 +552,10 @@ "message": "Modifica avvio automatico non riuscita" }, "settings.general.keepConnectedOnQuit.label": { - "message": "Resta connesso dopo la chiusura", - "description": "Toggle label: keep the VPN connection up after quitting the UI." + "message": "Resta connesso dopo la chiusura" }, "settings.general.keepConnectedOnQuit.help": { - "message": "La connessione resta attiva in background dopo la chiusura di NetBird. Si interrompe solo quando la disconnetti tu.", - "description": "Helper text for the stay-connected-after-quitting toggle." + "message": "La connessione resta attiva in background dopo la chiusura di NetBird. Si interrompe solo quando la disconnetti tu." }, "settings.general.language.label": { "message": "Lingua dell'interfaccia" diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index a3138de6c..c8918e4ae 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -552,12 +552,10 @@ "message": "自動起動の変更に失敗しました" }, "settings.general.keepConnectedOnQuit.label": { - "message": "終了後も接続を維持", - "description": "Toggle label: keep the VPN connection up after quitting the UI." + "message": "終了後も接続を維持" }, "settings.general.keepConnectedOnQuit.help": { - "message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。", - "description": "Helper text for the stay-connected-after-quitting toggle." + "message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。" }, "settings.general.language.label": { "message": "表示言語" diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index a75ae3dfc..aca458a4d 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -552,12 +552,10 @@ "message": "Falha ao alterar o início automático" }, "settings.general.keepConnectedOnQuit.label": { - "message": "Permanecer conectado ao sair", - "description": "Toggle label: keep the VPN connection up after quitting the UI." + "message": "Permanecer conectado ao sair" }, "settings.general.keepConnectedOnQuit.help": { - "message": "A conexão continua ativa em segundo plano depois de fechar o NetBird. Ela só para quando você mesmo a desconecta.", - "description": "Helper text for the stay-connected-after-quitting toggle." + "message": "A conexão continua ativa em segundo plano depois de fechar o NetBird. Ela só para quando você mesmo a desconecta." }, "settings.general.language.label": { "message": "Idioma de exibição" diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index e11ed26a4..a0795c1da 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -552,12 +552,10 @@ "message": "Не удалось изменить автозапуск" }, "settings.general.keepConnectedOnQuit.label": { - "message": "Оставаться подключённым после выхода", - "description": "Toggle label: keep the VPN connection up after quitting the UI." + "message": "Оставаться подключённым после выхода" }, "settings.general.keepConnectedOnQuit.help": { - "message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами.", - "description": "Helper text for the stay-connected-after-quitting toggle." + "message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами." }, "settings.general.language.label": { "message": "Язык интерфейса" diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 64725a69f..9f4713899 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -552,12 +552,10 @@ "message": "更改自启动设置失败" }, "settings.general.keepConnectedOnQuit.label": { - "message": "退出后保持连接", - "description": "Toggle label: keep the VPN connection up after quitting the UI." + "message": "退出后保持连接" }, "settings.general.keepConnectedOnQuit.help": { - "message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。", - "description": "Helper text for the stay-connected-after-quitting toggle." + "message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。" }, "settings.general.language.label": { "message": "显示语言" From 0bb98547042986f02a47a07ac70557a8b2893dcf Mon Sep 17 00:00:00 2001 From: Edward <43848523+thomashacker@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:39:17 +0200 Subject: [PATCH 177/209] [client] Add Hebrew to the language picker (#8102) The Hebrew translation is complete and synced from Crowdin, and RTL layout support is in, so list it in _index.json. --- client/ui/i18n/locales/_index.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ui/i18n/locales/_index.json b/client/ui/i18n/locales/_index.json index a52ff5929..4e9b632b5 100644 --- a/client/ui/i18n/locales/_index.json +++ b/client/ui/i18n/locales/_index.json @@ -11,6 +11,7 @@ {"code": "pt", "displayName": "Português", "englishName": "Portuguese"}, {"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"}, {"code": "ja", "displayName": "日本語", "englishName": "Japanese"}, - {"code": "gl", "displayName": "Galego", "englishName": "Galician"} + {"code": "gl", "displayName": "Galego", "englishName": "Galician"}, + {"code": "he", "displayName": "עברית", "englishName": "Hebrew"} ] } From 36fe467aba4239cb3df9d00b2f3feb75d86bbc91 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 19 Oct 2026 18:47:25 +0200 Subject: [PATCH 178/209] [client] Avoid Signal reconnect listener shutdown deadlock (#8257) * [client] Reproduce Signal reconnect listener shutdown deadlock * [client] Invoke Signal reconnect listeners outside the client mutex Capture the listener with the registration state, then release the mutex before calling it. Watcher shutdown can now unregister a listener while an in-flight callback waits for the watcher lock. Keep callback invocation synchronous and document that unregistration does not drain an already captured callback. Record callback completion inside the regression test's external lock. --- shared/signal/client/grpc.go | 10 ++- shared/signal/client/reconnect_test.go | 98 ++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 shared/signal/client/reconnect_test.go diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go index e489078a9..22239047b 100644 --- a/shared/signal/client/grpc.go +++ b/shared/signal/client/grpc.go @@ -299,7 +299,6 @@ func (c *GrpcClient) notifyStreamDisconnected() { func (c *GrpcClient) notifyStreamConnected() { c.mux.Lock() - defer c.mux.Unlock() c.status = StreamConnected if c.connectedCh != nil { @@ -308,8 +307,12 @@ func (c *GrpcClient) notifyStreamConnected() { c.connectedCh = nil } - if c.onReconnectedListenerFn != nil { - c.onReconnectedListenerFn() + listener := c.onReconnectedListenerFn + c.mux.Unlock() + + // Listeners can hold their own locks while unregistering from this client. + if listener != nil { + listener() } } @@ -409,6 +412,7 @@ func (c *GrpcClient) WaitStreamConnected(ctx context.Context) { } } +// SetOnReconnectedListener replaces the listener without waiting for an in-flight callback. func (c *GrpcClient) SetOnReconnectedListener(fn func()) { c.mux.Lock() defer c.mux.Unlock() diff --git a/shared/signal/client/reconnect_test.go b/shared/signal/client/reconnect_test.go new file mode 100644 index 000000000..bf4e2cb87 --- /dev/null +++ b/shared/signal/client/reconnect_test.go @@ -0,0 +1,98 @@ +package client + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestReconnectListenerCanBeClearedWhileCallbackIsBlocked(t *testing.T) { + client := &GrpcClient{} + var listenerMu sync.Mutex + callbackStarted := make(chan struct{}) + callbackFinished := make(chan struct{}) + callbackCalls := 0 + client.SetOnReconnectedListener(func() { + callbackCalls++ + close(callbackStarted) + listenerMu.Lock() + close(callbackFinished) + listenerMu.Unlock() + }) + + // SRWatcher.Close holds its own mutex while removing the listener. A + // reconnect callback waiting for that mutex must not block its removal. + listenerMu.Lock() + notificationDone := make(chan struct{}) + go func() { + defer close(notificationDone) + client.notifyStreamConnected() + }() + var unregisterDone chan struct{} + t.Cleanup(func() { + // Release the callback even when the regression assertion fails, so + // the unfixed implementation does not leave deadlocked goroutines. + listenerMu.Unlock() + select { + case <-notificationDone: + select { + case <-callbackFinished: + default: + t.Error("in-flight callback did not finish after listener removal") + } + case <-time.After(5 * time.Second): + t.Error("reconnect notification did not finish after releasing the listener") + } + if unregisterDone != nil { + select { + case <-unregisterDone: + case <-time.After(5 * time.Second): + t.Error("listener removal did not finish after releasing the listener") + } + } + }) + + select { + case <-callbackStarted: + case <-time.After(5 * time.Second): + t.Fatal("reconnect callback did not start") + } + + unregisterDone = make(chan struct{}) + go func() { + client.SetOnReconnectedListener(nil) + close(unregisterDone) + }() + select { + case <-unregisterDone: + case <-time.After(5 * time.Second): + t.Fatal("listener removal blocked behind the reconnect callback") + } + + // Registration still becomes visible, and future notifications do not + // invoke the removed listener even while its first callback is in flight. + assert.True(t, client.StreamConnected(), "listener removal must preserve stream registration") + client.notifyStreamConnected() + assert.Equal(t, 1, callbackCalls, "future notifications must not invoke the removed listener") +} + +func TestReconnectListenerRunsAfterRegistrationWaitersAreReleased(t *testing.T) { + connected := make(chan struct{}) + client := &GrpcClient{connectedCh: connected} + called := false + client.SetOnReconnectedListener(func() { + called = true + select { + case <-connected: + default: + t.Error("reconnect callback ran before registration waiters were released") + } + }) + + client.notifyStreamConnected() + + assert.True(t, called, "reconnect notification must invoke its listener synchronously") + assert.True(t, client.StreamConnected(), "stream must remain registered after the callback") +} From 8fef487e4256e592c1ff916fd9cd155f1849b881 Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Mon, 19 Oct 2026 22:19:39 +0200 Subject: [PATCH 179/209] [management] Add support for pagination in GetAllPeers call (#8190) * use libopenapi OpenAPI generator Signed-off-by: Dmitri Dolguikh * added a handler for v1alpha1/peers Signed-off-by: Dmitri Dolguikh * wire up request validator Signed-off-by: Dmitri Dolguikh * wired up spec-based validation Signed-off-by: Dmitri Dolguikh * use sync validation; set base url to v1alpha1 Signed-off-by: Dmitri Dolguikh * moved stuff around, extracted runtime libopenapu deps into runtime_tooling Signed-off-by: Dmitri Dolguikh * testing /peers path params Signed-off-by: Dmitri Dolguikh * fixed tests Signed-off-by: Dmitri Dolguikh * added user schemas and paths Signed-off-by: Dmitri Dolguikh * use api validation in tests Signed-off-by: Dmitri Dolguikh * post-merge fixes Signed-off-by: Dmitri Dolguikh * deleted tmp command used to test validator integration Signed-off-by: Dmitri Dolguikh * require request body in post/put requests in order to force validation Signed-off-by: Dmitri Dolguikh * making linter happy Signed-off-by: Dmitri Dolguikh * switch to api/v1alpha1 Signed-off-by: Dmitri Dolguikh * go mod tidy Signed-off-by: Dmitri Dolguikh * keep the original order of middleware: metrics, cors, then auth Signed-off-by: Dmitri Dolguikh * return 422 on validation errors Signed-off-by: Dmitri Dolguikh * cleanups Signed-off-by: Dmitri Dolguikh * more cleanups Signed-off-by: Dmitri Dolguikh * v1alpha1 spec cleanups Signed-off-by: Dmitri Dolguikh * no need for a double-pointer Signed-off-by: Dmitri Dolguikh * more linter fixes Signed-off-by: Dmitri Dolguikh * removed more double pointers Signed-off-by: Dmitri Dolguikh * use di to inject api_v0 and api_v1 http routers Signed-off-by: Dmitri Dolguikh * do not re-add middleware on repeated call to ApiHandler Signed-off-by: Dmitri Dolguikh * support for pagination and filtering in /peers call Signed-off-by: Dmitri Dolguikh * calling ApiRouter() now also calls ApiV1Router() Signed-off-by: Dmitri Dolguikh * adding peer filters Signed-off-by: Dmitri Dolguikh * introduced paginated GetAllPeers call Signed-off-by: Dmitri Dolguikh * wire up pagination Signed-off-by: Dmitri Dolguikh * convert pagination state to query scope Signed-off-by: Dmitri Dolguikh * peer filter wip Signed-off-by: Dmitri Dolguikh * implemented peer filter Signed-off-by: Dmitri Dolguikh * test for filtering by os Signed-off-by: Dmitri Dolguikh * support for sorting of peers Signed-off-by: Dmitri Dolguikh * support for ordering + tests Signed-off-by: Dmitri Dolguikh * more tests Signed-off-by: Dmitri Dolguikh * wire up total number of records in paginated GetAllPeers call Signed-off-by: Dmitri Dolguikh * cleaned up GetAllPeers response Signed-off-by: Dmitri Dolguikh * pacify linter Signed-off-by: Dmitri Dolguikh * cleanups and fixes Signed-off-by: Dmitri Dolguikh * moar cleanups Signed-off-by: Dmitri Dolguikh * pacify linter Signed-off-by: Dmitri Dolguikh * remove duplicated test Signed-off-by: Dmitri Dolguikh * make linter happy Signed-off-by: Dmitri Dolguikh * moving GetAllPeersPaginated to peer manager, p. 1 Signed-off-by: Dmitri Dolguikh * moving GetAllPeersPaginated to peer manager, p. 2 Signed-off-by: Dmitri Dolguikh * added peer manager tests for GetPeersPaginated Signed-off-by: Dmitri Dolguikh * order peers in GetAccountPeersPaginated by their ids by default Signed-off-by: Dmitri Dolguikh * always order by peer_id, filter out proxies Signed-off-by: Dmitri Dolguikh * fixed broken tests Signed-off-by: Dmitri Dolguikh * support for filtering of peers by peer_id in GetAllPeers() Signed-off-by: Dmitri Dolguikh * filtering by peer_ids Signed-off-by: Dmitri Dolguikh * use lowercase in 'like' queries, drop ordering by ip Signed-off-by: Dmitri Dolguikh * drop sort_by ip key Signed-off-by: Dmitri Dolguikh * drop forced_approve filter Signed-off-by: Dmitri Dolguikh * replace mac with darwin in peer os filter Signed-off-by: Dmitri Dolguikh * fix peers query parameter: use peer_ids Signed-off-by: Dmitri Dolguikh * fixed broken peer_test Signed-off-by: Dmitri Dolguikh * responded to feedback Signed-off-by: Dmitri Dolguikh --------- Signed-off-by: Dmitri Dolguikh --- management/internals/modules/peers/manager.go | 47 ++-- .../internals/modules/peers/manager_mock.go | 32 +-- .../internals/modules/peers/manager_test.go | 197 ++++++++++++++ management/internals/server/boot.go | 2 +- management/server/api/v1alpha1/handler.go | 4 +- .../api/v1alpha1/peers/peers_handler.go | 224 ++++++++++------ .../peers/peers_handler_pagination_test.go | 191 +++++++++++++ .../api/v1alpha1/peers/peers_handler_test.go | 162 +++++++++--- .../handlers/groups/groups_handler_test.go | 4 +- .../http/handlers/peers/peers_handler_test.go | 4 +- management/server/mock_server/account_mock.go | 5 +- management/server/peer.go | 4 +- management/server/peer_test.go | 1 - management/server/store/sql_store_peer.go | 159 ++++++++++- .../server/store/sql_store_peer_test.go | 4 +- .../store/sql_store_peers_filter_test.go | 250 ++++++++++++++++++ management/server/store/sql_store_test.go | 16 ++ management/server/store/store.go | 117 ++++++-- management/server/store/store_mock.go | 16 ++ .../server/testdata/peers_pagination.sql | 112 ++++++++ .../management/http/apiv1alpha1/bundle.yaml | 116 ++++++-- .../http/apiv1alpha1/peer/components.yaml | 40 +-- .../http/apiv1alpha1/peer/peers.yaml | 76 +++++- .../management/http/apiv1alpha1/types.gen.go | 18 +- 24 files changed, 1571 insertions(+), 230 deletions(-) create mode 100644 management/internals/modules/peers/manager_test.go create mode 100644 management/server/api/v1alpha1/peers/peers_handler_pagination_test.go create mode 100644 management/server/store/sql_store_peers_filter_test.go create mode 100644 management/server/testdata/peers_pagination.sql diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index e944be291..6f9705efe 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -28,8 +28,8 @@ import ( type Manager interface { GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) GetPeerAccountID(ctx context.Context, peerID string) (string, error) - GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) GetPeersByGroupIDs(ctx context.Context, accountID string, groupsIDs []string) ([]*peer.Peer, error) + GetPeersPaginated(ctx context.Context, accountID, userID string, pagination store.PaginationState, filters store.PeerFilters, sorting store.PeerSorting) ([]*peer.Peer, int, error) DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error SetNetworkMapController(networkMapController network_map.Controller) SetIntegratedPeerValidator(integratedPeerValidator integrated_validator.IntegratedValidator) @@ -51,8 +51,7 @@ type managerImpl struct { permissionsManager permissions.Manager integratedPeerValidator integrated_validator.IntegratedValidator accountManager account.Manager - - networkMapController network_map.Controller + networkMapController network_map.Controller } func NewManager(store store.Store, permissionsManager permissions.Manager) Manager { @@ -87,19 +86,6 @@ func (m *managerImpl) GetPeer(ctx context.Context, accountID, userID, peerID str return m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) } -func (m *managerImpl) GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) { - allowed, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read) - if err != nil { - return nil, fmt.Errorf("failed to validate user permissions: %w", err) - } - - if !allowed { - return m.store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID) - } - - return m.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "", "") -} - func (m *managerImpl) GetPeerAccountID(ctx context.Context, peerID string) (string, error) { return m.store.GetAccountIDByPeerID(ctx, store.LockingStrengthNone, peerID) } @@ -108,6 +94,35 @@ func (m *managerImpl) GetPeersByGroupIDs(ctx context.Context, accountID string, return m.store.GetPeersByGroupIDs(ctx, accountID, groupsIDs) } +func (m *managerImpl) GetPeersPaginated(ctx context.Context, accountID, userID string, pagination store.PaginationState, filters store.PeerFilters, sorting store.PeerSorting) ([]*peer.Peer, int, error) { + user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + if err != nil { + return nil, 0, err + } + + allowed, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read) + if err != nil { + return nil, 0, status.NewPermissionValidationError(err) + } + + if allowed { + return m.store.GetAccountPeersPaginated(ctx, store.LockingStrengthNone, accountID, pagination, filters, sorting) + } + + settings, err := m.store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, 0, fmt.Errorf("failed to get account settings: %w", err) + } + + if user.IsRestrictable() && settings.RegularUsersViewBlocked { + return []*peer.Peer{}, 0, nil + } + + peers, err := m.store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID) + + return peers, len(peers), err +} + // GetPeerByTunnelIP delegates to the store's indexed lookup. func (m *managerImpl) GetPeerByTunnelIP(ctx context.Context, accountID string, ip net.IP) (*peer.Peer, error) { return m.store.GetPeerByIP(ctx, store.LockingStrengthNone, accountID, ip) diff --git a/management/internals/modules/peers/manager_mock.go b/management/internals/modules/peers/manager_mock.go index 8c26d43b1..ee97ba308 100644 --- a/management/internals/modules/peers/manager_mock.go +++ b/management/internals/modules/peers/manager_mock.go @@ -18,6 +18,7 @@ import ( account "github.com/netbirdio/netbird/management/server/account" integrated_validator "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" peer "github.com/netbirdio/netbird/management/server/peer" + store "github.com/netbirdio/netbird/management/server/store" types "github.com/netbirdio/netbird/management/server/types" gomock "go.uber.org/mock/gomock" ) @@ -74,21 +75,6 @@ func (mr *MockManagerMockRecorder) DeletePeers(ctx, accountID, peerIDs, userID, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePeers", reflect.TypeOf((*MockManager)(nil).DeletePeers), ctx, accountID, peerIDs, userID, checkConnected) } -// GetAllPeers mocks base method. -func (m *MockManager) GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAllPeers", ctx, accountID, userID) - ret0, _ := ret[0].([]*peer.Peer) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAllPeers indicates an expected call of GetAllPeers. -func (mr *MockManagerMockRecorder) GetAllPeers(ctx, accountID, userID any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllPeers", reflect.TypeOf((*MockManager)(nil).GetAllPeers), ctx, accountID, userID) -} - // GetPeer mocks base method. func (m *MockManager) GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) { m.ctrl.T.Helper() @@ -180,6 +166,22 @@ func (mr *MockManagerMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupsIDs return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockManager)(nil).GetPeersByGroupIDs), ctx, accountID, groupsIDs) } +// GetPeersPaginated mocks base method. +func (m *MockManager) GetPeersPaginated(ctx context.Context, accountID, userID string, pagination store.PaginationState, filters store.PeerFilters, sorting store.PeerSorting) ([]*peer.Peer, int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeersPaginated", ctx, accountID, userID, pagination, filters, sorting) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(int) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetPeersPaginated indicates an expected call of GetPeersPaginated. +func (mr *MockManagerMockRecorder) GetPeersPaginated(ctx, accountID, userID, pagination, filters, sorting any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersPaginated", reflect.TypeOf((*MockManager)(nil).GetPeersPaginated), ctx, accountID, userID, pagination, filters, sorting) +} + // SetAccountManager mocks base method. func (m *MockManager) SetAccountManager(accountManager account.Manager) { m.ctrl.T.Helper() diff --git a/management/internals/modules/peers/manager_test.go b/management/internals/modules/peers/manager_test.go new file mode 100644 index 000000000..2645276b7 --- /dev/null +++ b/management/internals/modules/peers/manager_test.go @@ -0,0 +1,197 @@ +package peers + +import ( + "context" + "fmt" + "testing" + + "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/stretchr/testify/assert" + + "go.uber.org/mock/gomock" +) + +func TestManager_GetPeersPaginated_HappyPath(t *testing.T) { + ctrl := gomock.NewController(t) + // GetUserByUserID + // GetAccountPeersPaginated + // GetAccountSettings + // GetUserPeers + mockStore := store.NewMockStore(ctrl) + // ValidateUserPermissions + permissionsManager := permissions.NewMockManager(ctrl) + + m := NewManager(mockStore, permissionsManager) + + mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1). + Return(&types.User{ + Id: "test-user", + }, nil) + permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1). + Return(true, context.Background(), nil) + mockStore.EXPECT().GetAccountPeersPaginated(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account"), + gomock.Eq(store.PaginationState{Page: 11, PageSize: 111}), gomock.Eq(store.PeerFilters{UserId: "test-user"}), + gomock.Eq(store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}})).Times(1). + Return([]*peer.Peer{{ID: "test-peer"}}, 10, nil) + + peers, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user", + store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"}, + store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}}) + + assert.Equal(t, []*peer.Peer{{ID: "test-peer"}}, peers) + assert.Equal(t, 10, total) + assert.NoError(t, err) +} + +func TestManager_GetPeersPaginated_GetUserWithError(t *testing.T) { + ctrl := gomock.NewController(t) + mockStore := store.NewMockStore(ctrl) + permissionsManager := permissions.NewMockManager(ctrl) + + m := NewManager(mockStore, permissionsManager) + + mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1). + Return(nil, fmt.Errorf("boom!")) + + _, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user", + store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"}, + store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}}) + + assert.Equal(t, 0, total) + assert.ErrorContains(t, err, "boom!") +} + +func TestManager_GetPeersPaginated_NoPermission(t *testing.T) { + ctrl := gomock.NewController(t) + mockStore := store.NewMockStore(ctrl) + permissionsManager := permissions.NewMockManager(ctrl) + + m := NewManager(mockStore, permissionsManager) + + mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1). + Return(&types.User{ + Id: "test-user", + }, nil) + permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1). + Return(false, context.Background(), nil) + mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1). + Return(&types.Settings{RegularUsersViewBlocked: false}, nil) + mockStore.EXPECT().GetUserPeers(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account"), gomock.Eq("test-user")).Times(1). + Return([]*peer.Peer{{ID: "test-peer"}}, nil) + + peers, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user", + store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"}, + store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}}) + + assert.Equal(t, []*peer.Peer{{ID: "test-peer"}}, peers) + assert.Equal(t, 1, total) + assert.NoError(t, err) +} + +func TestManager_GetPeersPaginated_NoPermission_GetUserPeers_WithError(t *testing.T) { + ctrl := gomock.NewController(t) + mockStore := store.NewMockStore(ctrl) + permissionsManager := permissions.NewMockManager(ctrl) + + m := NewManager(mockStore, permissionsManager) + + mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1). + Return(&types.User{ + Id: "test-user", + }, nil) + permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1). + Return(false, context.Background(), nil) + mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1). + Return(&types.Settings{RegularUsersViewBlocked: false}, nil) + mockStore.EXPECT().GetUserPeers(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account"), gomock.Eq("test-user")).Times(1). + Return(nil, fmt.Errorf("boom!")) + + _, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user", + store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"}, + store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}}) + + assert.Equal(t, 0, total) + assert.ErrorContains(t, err, "boom!") +} + +func TestManager_GetPeersPaginated_GetAccountSettings_WithError(t *testing.T) { + ctrl := gomock.NewController(t) + mockStore := store.NewMockStore(ctrl) + permissionsManager := permissions.NewMockManager(ctrl) + + m := NewManager(mockStore, permissionsManager) + + mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1). + Return(&types.User{ + Id: "test-user", + }, nil) + permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1). + Return(false, context.Background(), nil) + mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1). + Return(nil, fmt.Errorf("boom!")) + + _, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user", + store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"}, + store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}}) + + assert.Equal(t, 0, total) + assert.ErrorContains(t, err, "boom!") +} +func TestManager_GetPeersPaginated_NoPermission_User_IsRestrictable_ViewUnblocked(t *testing.T) { + ctrl := gomock.NewController(t) + mockStore := store.NewMockStore(ctrl) + permissionsManager := permissions.NewMockManager(ctrl) + + m := NewManager(mockStore, permissionsManager) + + mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1). + Return(&types.User{ + Id: "test-user", + Role: types.UserRoleUser, + }, nil) + permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1). + Return(false, context.Background(), nil) + mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1). + Return(&types.Settings{RegularUsersViewBlocked: false}, nil) + mockStore.EXPECT().GetUserPeers(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account"), gomock.Eq("test-user")).Times(1). + Return([]*peer.Peer{{ID: "test-peer"}}, nil) + + peers, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user", + store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"}, + store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}}) + + assert.Equal(t, []*peer.Peer{{ID: "test-peer"}}, peers) + assert.Equal(t, 1, total) + assert.NoError(t, err) +} + +func TestManager_GetPeersPaginated_NoPermission_User_IsRestrictable_ViewBlocked(t *testing.T) { + ctrl := gomock.NewController(t) + mockStore := store.NewMockStore(ctrl) + permissionsManager := permissions.NewMockManager(ctrl) + + m := NewManager(mockStore, permissionsManager) + + mockStore.EXPECT().GetUserByUserID(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-user")).Times(1). + Return(&types.User{ + Id: "test-user", + Role: types.UserRoleUser, + }, nil) + permissionsManager.EXPECT().ValidateUserPermissions(gomock.Any(), gomock.Eq("test-account"), gomock.Eq("test-user"), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)).Times(1). + Return(false, context.Background(), nil) + mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Eq(store.LockingStrengthNone), gomock.Eq("test-account")).Times(1). + Return(&types.Settings{RegularUsersViewBlocked: true}, nil) + + peers, total, err := m.GetPeersPaginated(context.Background(), "test-account", "test-user", + store.PaginationState{Page: 11, PageSize: 111}, store.PeerFilters{UserId: "test-user"}, + store.PeerSorting{Fields: []store.PeerSortingKey{{Field: store.PeerSortByName, Order: store.DescOrderBy}}}) + + assert.Equal(t, make([]*peer.Peer, 0), peers) + assert.Equal(t, 0, total) + assert.NoError(t, err) +} diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 13bbaa6fa..e466005fe 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -165,7 +165,7 @@ func (s *BaseServer) EventStore() activity.Store { func (s *BaseServer) APIHandler() http.Handler { _ = CreateNamed(s, "http_v1api", func() http.Handler { apiv1Router := s.ApiV1Router() - _, err := v1alpha1.NewAPIV1Handler(context.Background(), apiv1Router, s.AccountManager(), s.NetworkMapController(), s.PermissionsManager()) + _, err := v1alpha1.NewAPIV1Handler(context.Background(), apiv1Router, s.AccountManager(), s.PeersManager(), s.NetworkMapController(), s.PermissionsManager()) if err != nil { log.Fatalf("failed to create API handler: %v", err) } diff --git a/management/server/api/v1alpha1/handler.go b/management/server/api/v1alpha1/handler.go index 3af8f4871..21b9bbcec 100644 --- a/management/server/api/v1alpha1/handler.go +++ b/management/server/api/v1alpha1/handler.go @@ -12,6 +12,7 @@ import ( "github.com/netbirdio/netbird/management/server/permissions" + peersmodule "github.com/netbirdio/netbird/management/internals/modules/peers" "github.com/netbirdio/netbird/management/server/api/v1alpha1/peers" "github.com/netbirdio/netbird/management/server/api/v1alpha1/users" ) @@ -20,6 +21,7 @@ func NewAPIV1Handler( ctx context.Context, router *mux.Router, accountManager account.Manager, + peerManager peersmodule.Manager, networkMapController network_map.Controller, permissionsManager permissions.Manager) (http.Handler, error) { @@ -30,7 +32,7 @@ func NewAPIV1Handler( router.Use(v1validatorMiddleware.Handler) - peersHandler := peers.NewHandler(accountManager, networkMapController, permissionsManager) + peersHandler := peers.NewHandler(accountManager, peerManager, networkMapController, permissionsManager) _ = peersHandler.WithEndpointsForRouter(router) usersHandler := users.NewHandler(accountManager) diff --git a/management/server/api/v1alpha1/peers/peers_handler.go b/management/server/api/v1alpha1/peers/peers_handler.go index 8e0593723..50841858c 100644 --- a/management/server/api/v1alpha1/peers/peers_handler.go +++ b/management/server/api/v1alpha1/peers/peers_handler.go @@ -6,17 +6,22 @@ import ( "fmt" "net/http" "net/netip" + "net/url" + "strconv" + "strings" "github.com/gorilla/mux" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/internals/modules/peers" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/api/v1alpha1/groups" nbcontext "github.com/netbirdio/netbird/management/server/context" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/shared/management/http/apiv1alpha1" "github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/shared/management/status" @@ -26,12 +31,14 @@ type Handler struct { accountManager account.Manager permissionsManager permissions.Manager networkMapController network_map.Controller + peersManager peers.Manager } // NewHandler creates a new peers Handler -func NewHandler(accountManager account.Manager, networkMapController network_map.Controller, permissionsManager permissions.Manager) *Handler { +func NewHandler(accountManager account.Manager, peerManager peers.Manager, networkMapController network_map.Controller, permissionsManager permissions.Manager) *Handler { return &Handler{ accountManager: accountManager, + peersManager: peerManager, networkMapController: networkMapController, permissionsManager: permissionsManager, } @@ -210,14 +217,32 @@ func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) { return } - nameFilter := r.URL.Query().Get("name") - ipFilter := r.URL.Query().Get("ip") - macFilter := r.URL.Query().Get("mac") + pagination, err := paginationFromQuery(r.URL.Query()) + if err != nil { + log.WithContext(r.Context()).Errorf("error parsing query: %v", err) + util.WriteError(r.Context(), &status.Error{ErrorType: status.InvalidArgument, Message: err.Error()}, w) + return + } + + filters, err := filtersFromQuery(r.URL.Query()) + if err != nil { + log.WithContext(r.Context()).Errorf("error parsing query: %v", err) + util.WriteError(r.Context(), &status.Error{ErrorType: status.InvalidArgument, Message: err.Error()}, w) + return + } + + sorting, err := sortingOrderFromQuery(r.URL.Query()) + if err != nil { + log.WithContext(r.Context()).Errorf("error parsing query: %v", err) + util.WriteError(r.Context(), &status.Error{ErrorType: status.InvalidArgument, Message: err.Error()}, w) + return + } accountID, userID := userAuth.AccountId, userAuth.UserId - peers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, nameFilter, ipFilter, macFilter) + peers, peerCount, err := h.peersManager.GetPeersPaginated(r.Context(), accountID, userID, pagination, filters, sorting) if err != nil { + log.WithContext(r.Context()).Errorf("error getting peers: %v", err) util.WriteError(r.Context(), err, w) return } @@ -232,35 +257,135 @@ func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) { grps, _ := h.accountManager.GetAllGroups(r.Context(), accountID, userID) grpsInfoMap := groups.ToGroupsInfoMap(grps, len(peers)) - respBody := make([]*apiv1alpha1.PeerBatch, 0, len(peers)) - for _, peer := range peers { - if peer.ProxyMeta.Embedded { - continue - } - respBody = append(respBody, toPeerListItemResponse(peer, grpsInfoMap[peer.ID], dnsDomain, 0)) - } - - validPeersMap, invalidPeersMap, err := h.accountManager.GetValidatedPeers(r.Context(), accountID) + validPeers, invalidPeers, err := h.accountManager.GetValidatedPeers(r.Context(), accountID) if err != nil { log.WithContext(r.Context()).Errorf("failed to get validated peers: %v", err) util.WriteError(r.Context(), fmt.Errorf("internal error"), w) return } - h.setApprovalRequiredFlag(respBody, validPeersMap, invalidPeersMap) + + respBody := apiv1alpha1.PeerBatch{Peers: make([]apiv1alpha1.Peer, 0, len(peers))} + for _, peer := range peers { + _, valid := validPeers[peer.ID] + reason := invalidPeers[peer.ID] + + respBody.Peers = append(respBody.Peers, *toSinglePeerResponse(peer, grpsInfoMap[peer.ID], dnsDomain, valid, reason)) + } + respBody.Page = pagination.Page + respBody.PageSize = pagination.PageSize + respBody.PeerCount = peerCount util.WriteJSONObject(r.Context(), w, respBody) } -func (h *Handler) setApprovalRequiredFlag(respBody []*apiv1alpha1.PeerBatch, validPeersMap map[string]struct{}, invalidPeersMap map[string]string) { - for _, peer := range respBody { - _, ok := validPeersMap[peer.Id] - if !ok { - peer.ApprovalRequired = true +func filtersFromQuery(v url.Values) (store.PeerFilters, error) { + connected, err := parseBool(v.Get("connected")) + if err != nil { + return store.PeerFilters{}, err + } - reason := invalidPeersMap[peer.Id] - peer.DisapprovalReason = &reason + approvalRequired, err := parseBool(v.Get("approval_required")) + if err != nil { + return store.PeerFilters{}, err + } + + var isServer *bool + if v.Get("kind") != "" { + isServer = new(v.Get("kind") == "server") + if *isServer && v.Get("user_id") != "" { + return store.PeerFilters{}, fmt.Errorf("incompatible filters: kind == server and a non-empty user_id") } } + return store.PeerFilters{ + UserId: v.Get("user_id"), + GroupIds: v["group_ids"], + Connected: connected, + ApprovalRequired: approvalRequired, + Os: v["os"], + IP: v.Get("ip"), + MAC: v.Get("mac"), + Name: v.Get("name"), + Hostname: v.Get("hostname"), + IsServer: isServer, + PeersIds: v["peer_ids"], + }, nil +} + +func parseBool(maybeBool string) (*bool, error) { + if maybeBool == "" { + return nil, nil //nolint:nilnil + } + + parsed, err := strconv.ParseBool(maybeBool) + if err != nil { + return nil, err + } + + return new(parsed), nil +} + +func paginationFromQuery(v url.Values) (store.PaginationState, error) { + page, err := strconv.Atoi(v.Get("page")) + if err != nil { + return store.PaginationState{}, err + } + + pageSize, err := strconv.Atoi(v.Get("page_size")) + if err != nil { + return store.PaginationState{}, err + } + + return store.PaginationState{ + Page: page, + PageSize: pageSize, + }, nil +} + +func sortingOrderFromQuery(v url.Values) (store.PeerSorting, error) { + toret := store.PeerSorting{} + sortby, ok := v["sort_by"] + if !ok { + return toret, nil + } + + for i := 0; i < len(sortby); { + // ascending sorting order by default + sortkey := store.PeerSortingKey{Order: store.AscOrderBy} + field, order := parseFieldAndOrder(sortby[i]) + + switch field { + case "name": + sortkey.Field = store.PeerSortByName + case "last_seen": + sortkey.Field = store.PeerSortByLastSeen + case "version": + sortkey.Field = store.PeerSortByVersion + default: + return toret, fmt.Errorf("unable to parse sorting order '%s'", sortby) + } + + switch order { + case "asc", "": + // nop, already set + case "desc": + sortkey.Order = store.DescOrderBy + default: + return store.PeerSorting{}, fmt.Errorf("unable to parse sorting order '%s'", sortby) + } + + toret.Fields = append(toret.Fields, sortkey) + i++ + } + + return toret, nil +} + +func parseFieldAndOrder(fieldOrder string) (string, string) { + maybeFieldAndOrder := strings.SplitN(fieldOrder, ",", 2) + if len(maybeFieldAndOrder) == 2 { + return strings.TrimSpace(maybeFieldAndOrder[0]), strings.TrimSpace(maybeFieldAndOrder[1]) + } + return strings.TrimSpace(maybeFieldAndOrder[0]), "" } func parseIPv6(s *string) (netip.Addr, error) { @@ -337,61 +462,6 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []apiv1alpha1.GroupMinim return apiPeer } -func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []apiv1alpha1.GroupMinimum, dnsDomain string, accessiblePeersCount int) *apiv1alpha1.PeerBatch { - osVersion := peer.Meta.OSVersion - if osVersion == "" { - osVersion = peer.Meta.Core - } - - return &apiv1alpha1.PeerBatch{ - CreatedAt: peer.CreatedAt, - AccessiblePeersCount: accessiblePeersCount, - Peer: apiv1alpha1.Peer{ - PeerMinimum: apiv1alpha1.PeerMinimum{ - Id: peer.ID, - Name: peer.Name, - }, - Ip: peer.IP.String(), - Ipv6: peerIPv6String(peer), - ConnectionIp: peer.Location.ConnectionIP.String(), - Connected: peer.Status.Connected, - LastSeen: peer.Status.LastSeen, - Os: fmt.Sprintf("%s %s", peer.Meta.OS, osVersion), - KernelVersion: peer.Meta.KernelVersion, - GeonameId: int(peer.Location.GeoNameID), - Version: peer.Meta.WtVersion, - Groups: groupsInfo, - SshEnabled: peer.SSHEnabled, - Hostname: peer.Meta.Hostname, - UserId: peer.UserID, - UiVersion: peer.Meta.UIVersion, - DnsLabel: fqdn(peer, dnsDomain), - ExtraDnsLabels: fqdnList(peer.ExtraDNSLabels, dnsDomain), - LoginExpirationEnabled: peer.LoginExpirationEnabled, - LastLogin: peer.GetLastLogin(), - LoginExpired: peer.Status.LoginExpired, - CountryCode: apiv1alpha1.CountryCode(peer.Location.CountryCode), - CityName: apiv1alpha1.CityName(peer.Location.CityName), - SerialNumber: peer.Meta.SystemSerialNumber, - InactivityExpirationEnabled: peer.InactivityExpirationEnabled, - Ephemeral: peer.Ephemeral, - LocalFlags: &apiv1alpha1.PeerLocalFlags{ - BlockInbound: &peer.Meta.Flags.BlockInbound, - BlockLanAccess: &peer.Meta.Flags.BlockLANAccess, - DisableClientRoutes: &peer.Meta.Flags.DisableClientRoutes, - DisableDns: &peer.Meta.Flags.DisableDNS, - DisableFirewall: &peer.Meta.Flags.DisableFirewall, - DisableServerRoutes: &peer.Meta.Flags.DisableServerRoutes, - LazyConnectionEnabled: &peer.Meta.Flags.LazyConnectionEnabled, - RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled, - RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive, - ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed, - RemoteJobsAllowed: &peer.Meta.Flags.RemoteJobsAllowed, - }, - }, - } -} - func fqdn(peer *nbpeer.Peer, dnsDomain string) string { fqdn := peer.FQDN(dnsDomain) if fqdn == "" { diff --git a/management/server/api/v1alpha1/peers/peers_handler_pagination_test.go b/management/server/api/v1alpha1/peers/peers_handler_pagination_test.go new file mode 100644 index 000000000..4d1449a14 --- /dev/null +++ b/management/server/api/v1alpha1/peers/peers_handler_pagination_test.go @@ -0,0 +1,191 @@ +package peers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + peersmodule "github.com/netbirdio/netbird/management/internals/modules/peers" + nbcontext "github.com/netbirdio/netbird/management/server/context" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/apiv1alpha1" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func TestGetPeersPagination(t *testing.T) { + peer := &nbpeer.Peer{ + ID: testPeerID, + Status: &nbpeer.PeerStatus{Connected: true}, + Meta: nbpeer.PeerSystemMeta{}, + } + + tt := []struct { + name string + expectedStatus int + requestPath string + setExpectations func(p *Handler) + }{ + { + name: "GetPeers default paging, filtering, and sorting", + requestPath: "/peers", + expectedStatus: http.StatusOK, + setExpectations: func(p *Handler) { + p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(), + gomock.Eq(store.PaginationState{Page: 1, PageSize: 100}), gomock.Any(), gomock.Any()).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes() + }, + }, + { + name: "GetPeers paging", + requestPath: "/peers?page=10&page_size=250", + expectedStatus: http.StatusOK, + setExpectations: func(p *Handler) { + p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(), + gomock.Eq(store.PaginationState{Page: 10, PageSize: 250}), + gomock.Any(), gomock.Any()).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes() + }, + }, + { + name: "GetPeers paging with invalid page", + requestPath: "/peers?page=aaa&page_size=250", + expectedStatus: http.StatusUnprocessableEntity, + setExpectations: func(p *Handler) {}, + }, + { + name: "GetPeers paging with invalid page size", + requestPath: "/peers?page=10&page_size=aaa", + expectedStatus: http.StatusUnprocessableEntity, + setExpectations: func(p *Handler) {}, + }, + { + name: "GetPeers filtering all valid params", + requestPath: "/peers?user_id=test-user&group_ids=1&group_ids=2&group_ids=3&group_ids=4&peer_ids=peer-1&peer_ids=peer-2&connected=true&approval_required=false&os=linux&os=windows&os=darwin&os=android&os=ios&os=js&ip=10.10.10.10&mac=00:11:22:33:44:55&name=test&hostname=test-host&kind=user_device", + expectedStatus: http.StatusOK, + setExpectations: func(p *Handler) { + p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(), + gomock.Eq(store.PaginationState{Page: 1, PageSize: 100}), + gomock.Eq(store.PeerFilters{ + UserId: "test-user", + GroupIds: []string{"1", "2", "3", "4"}, + Connected: new(true), + ApprovalRequired: new(false), + Os: []string{"linux", "windows", "darwin", "android", "ios", "js"}, + IP: "10.10.10.10", + MAC: "00:11:22:33:44:55", + Name: "test", + Hostname: "test-host", + IsServer: new(false), + PeersIds: []string{"peer-1", "peer-2"}, + }), + gomock.Any()).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes() + }, + }, + { + name: "GetPeers filtering with kind server", + requestPath: "/peers?kind=server", + expectedStatus: http.StatusOK, + setExpectations: func(p *Handler) { + p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(), + gomock.Eq(store.PaginationState{Page: 1, PageSize: 100}), + gomock.Eq(store.PeerFilters{IsServer: new(true)}), + gomock.Any()).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes() + }, + }, + { + name: "GetPeers filtering with invalid connected value", + requestPath: "/peers?connected=aaa", + expectedStatus: http.StatusUnprocessableEntity, + setExpectations: func(p *Handler) {}, + }, + { + name: "GetPeers filtering with invalid approval_required value", + requestPath: "/peers?approval_required=aaa", + expectedStatus: http.StatusUnprocessableEntity, + setExpectations: func(p *Handler) {}, + }, + { + name: "GetPeers filtering with invalid os value", + requestPath: "/peers?os=nosuchos", + expectedStatus: http.StatusUnprocessableEntity, + setExpectations: func(p *Handler) {}, + }, + { + name: "GetPeers filtering with invalid kind value", + requestPath: "/peers?kind=nosuchkind", + expectedStatus: http.StatusUnprocessableEntity, + setExpectations: func(p *Handler) {}, + }, + { + name: "GetPeers filtering with conflicting user_id and kind", + requestPath: "/peers?user_id=test-user&kind=server", + expectedStatus: http.StatusUnprocessableEntity, + setExpectations: func(p *Handler) {}, + }, + { + name: "GetPeers sorting preserves field ordering", + requestPath: "/peers?sort_by=last_seen&sort_by=name&sort_by=version", + expectedStatus: http.StatusOK, + setExpectations: func(p *Handler) { + p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(), + gomock.Eq(store.PaginationState{Page: 1, PageSize: 100}), + gomock.Any(), + gomock.Eq(store.PeerSorting{ + Fields: []store.PeerSortingKey{ + {Field: store.PeerSortByLastSeen, Order: store.AscOrderBy}, + {Field: store.PeerSortByName, Order: store.AscOrderBy}, + {Field: store.PeerSortByVersion, Order: store.AscOrderBy}, + }, + })).Return(make([]*nbpeer.Peer, 0), 0, nil).AnyTimes() + }, + }, + { + name: "GetPeers sorting with invalid field", + requestPath: "/peers?sort_by=nosuchfield,asc", + expectedStatus: http.StatusUnprocessableEntity, + setExpectations: func(p *Handler) {}, + }, + { + name: "GetPeers sorting with invalid field", + requestPath: "/peers?sort_by=nosuchfield,asc", + expectedStatus: http.StatusUnprocessableEntity, + setExpectations: func(p *Handler) {}, + }, + { + name: "GetPeers sorting with invalid sorting order", + requestPath: "/peers?sort_by=name,nosuchorder&sort_by=version", + expectedStatus: http.StatusUnprocessableEntity, + setExpectations: func(p *Handler) {}, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + p := initTestMetaData(t, peer) + + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, tc.requestPath, nil) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: "admin_user", + Domain: "hotmail.com", + AccountId: "test_id", + }) + + v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware() + assert.NoError(t, err) + router := mux.NewRouter() + router.Use(v1validator.Handler) + router = p.WithEndpointsForRouter(router) + + tc.setExpectations(p) + router.ServeHTTP(recorder, req) + + res := recorder.Result() + defer res.Body.Close() + + assert.Equal(t, tc.expectedStatus, recorder.Code) + }) + } +} diff --git a/management/server/api/v1alpha1/peers/peers_handler_test.go b/management/server/api/v1alpha1/peers/peers_handler_test.go index 492d0e12d..af7b31d10 100644 --- a/management/server/api/v1alpha1/peers/peers_handler_test.go +++ b/management/server/api/v1alpha1/peers/peers_handler_test.go @@ -18,6 +18,7 @@ import ( "golang.org/x/exp/maps" "github.com/netbirdio/netbird/management/internals/controllers/network_map" + peersmodule "github.com/netbirdio/netbird/management/internals/modules/peers" nbcontext "github.com/netbirdio/netbird/management/server/context" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" @@ -127,7 +128,10 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { }). AnyTimes() + peerManager := peersmodule.NewMockManager(ctrl2) + return &Handler{ + peersManager: peerManager, accountManager: &mock_server.MockAccountManager{ UpdatePeerFunc: func(_ context.Context, accountID, userID string, update *nbpeer.Peer) (*nbpeer.Peer, error) { var p *nbpeer.Peer @@ -173,8 +177,8 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { return nil, fmt.Errorf("user not found") } }, - GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { - return peers, nil + GetPeersFunc: func(_ context.Context, accountID, userID string, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error) { + return peers, len(peers), nil }, GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) { peersID := make([]string, len(peers)) @@ -220,9 +224,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { } } -// Tests the GetAllPeers endpoint reachable in the route /api/peers -// Use the metadata generated by initTestMetaData() to check for values -func TestGetPeers(t *testing.T) { +func TestGetPeer(t *testing.T) { peer := &nbpeer.Peer{ ID: testPeerID, @@ -262,23 +264,13 @@ func TestGetPeers(t *testing.T) { requestPath string contentType string requestBody io.Reader - expectedArray bool expectedPeer *nbpeer.Peer }{ - { - name: "GetPeersMetaData", - requestType: http.MethodGet, - requestPath: "/peers", - expectedStatus: http.StatusOK, - expectedArray: true, - expectedPeer: peer, - }, { name: "GetPeer with update channel", requestType: http.MethodGet, requestPath: "/peers/" + testPeerID, expectedStatus: http.StatusOK, - expectedArray: false, expectedPeer: peer, }, { @@ -287,7 +279,6 @@ func TestGetPeers(t *testing.T) { requestPath: "/peers/" + testPeerID, contentType: "application/json", expectedStatus: http.StatusOK, - expectedArray: false, requestBody: bytes.NewBufferString("{\"login_expiration_enabled\":true,\"name\":\"New Name\",\"ssh_enabled\":true, \"inactivity_expiration_enabled\":true}"), expectedPeer: expectedUpdatedPeer, }, @@ -330,32 +321,123 @@ func TestGetPeers(t *testing.T) { t.Fatalf("I don't know what I expected; %v", err) } - var got *apiv1alpha1.Peer - if tc.expectedArray { - respBody := []*apiv1alpha1.Peer{} - err = json.Unmarshal(content, &respBody) - if err != nil { - t.Fatalf("Sent content is not in correct json format; %v", err) - } - - // hardcode this check for now as we only have two peers in this suite - assert.Equal(t, len(respBody), 2) - - for _, peer := range respBody { - if peer.Id == testPeerID { - got = peer - } - } - - } else { - got = &apiv1alpha1.Peer{} - err = json.Unmarshal(content, got) - if err != nil { - t.Fatalf("Sent content is not in correct json format; %v", err) - } + var got apiv1alpha1.Peer + err = json.Unmarshal(content, &got) + if err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) } - t.Log(got) + assert.Equal(t, tc.expectedPeer.Name, got.Name) + assert.Equal(t, tc.expectedPeer.Meta.WtVersion, got.Version) + assert.Equal(t, tc.expectedPeer.IP.String(), got.Ip) + assert.Equal(t, "OS core", got.Os) + assert.Equal(t, tc.expectedPeer.LoginExpirationEnabled, got.LoginExpirationEnabled) + assert.Equal(t, tc.expectedPeer.SSHEnabled, got.SshEnabled) + assert.Equal(t, tc.expectedPeer.Status.Connected, got.Connected) + assert.Equal(t, tc.expectedPeer.Meta.SystemSerialNumber, got.SerialNumber) + }) + } +} + +func TestGetPeers(t *testing.T) { + peer := &nbpeer.Peer{ + ID: testPeerID, + Key: "key", + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + Status: &nbpeer.PeerStatus{Connected: true}, + Name: "PeerName", + LoginExpirationEnabled: false, + Meta: nbpeer.PeerSystemMeta{ + Hostname: "hostname", + GoOS: "GoOS", + Kernel: "kernel", + Core: "core", + Platform: "platform", + OS: "OS", + WtVersion: "development", + SystemSerialNumber: "C02XJ0J0JGH7", + }, + } + + peer1 := peer.Copy() + peer1.ID = noUpdateChannelTestPeerID + + tt := []struct { + name string + expectedStatus int + requestType string + requestPath string + contentType string + expectedPeer *nbpeer.Peer + }{ + { + name: "GetPeersMetaData", + requestType: http.MethodGet, + requestPath: "/peers", + expectedStatus: http.StatusOK, + expectedPeer: peer, + }, + } + + p := initTestMetaData(t, peer, peer1) + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + + recorder := httptest.NewRecorder() + req := httptest.NewRequest(tc.requestType, tc.requestPath, nil) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: "admin_user", + Domain: "hotmail.com", + AccountId: "test_id", + }) + if tc.contentType != "" { + req.Header.Set("Content-Type", tc.contentType) + } + + v1validator, err := apiv1alpha1.CreateV1ApiValidatingMiddleware() + assert.NoError(t, err) + router := mux.NewRouter() + router.Use(v1validator.Handler) + + router = p.WithEndpointsForRouter(router) + + p.peersManager.(*peersmodule.MockManager).EXPECT().GetPeersPaginated(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*nbpeer.Peer{peer, peer1}, 2, nil) + + router.ServeHTTP(recorder, req) + + res := recorder.Result() + defer res.Body.Close() + + if status := recorder.Code; status != tc.expectedStatus { + t.Fatalf("handler returned wrong status code: got %v want %v", + status, http.StatusOK) + } + + content, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("I don't know what I expected; %v", err) + } + + var got *apiv1alpha1.Peer + respBody := apiv1alpha1.PeerBatch{} + err = json.Unmarshal(content, &respBody) + if err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, respBody.Page, 1) + assert.Equal(t, respBody.PageSize, 100) + assert.Equal(t, respBody.PeerCount, 2) + assert.Equal(t, len(respBody.Peers), 2) + + for _, peer := range respBody.Peers { + if peer.Id == testPeerID { + got = &peer + } + } assert.Equal(t, tc.expectedPeer.Name, got.Name) assert.Equal(t, tc.expectedPeer.Meta.WtVersion, got.Version) diff --git a/management/server/http/handlers/groups/groups_handler_test.go b/management/server/http/handlers/groups/groups_handler_test.go index 3e322db4e..f61131da0 100644 --- a/management/server/http/handlers/groups/groups_handler_test.go +++ b/management/server/http/handlers/groups/groups_handler_test.go @@ -78,8 +78,8 @@ func initGroupTestData(initGroups ...*types.Group) *handler { return nil, status.Errorf(status.NotFound, "unknown group name") }, - GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { - return maps.Values(TestPeers), nil + GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error) { + return maps.Values(TestPeers), len(maps.Values(TestPeers)), nil }, DeleteGroupFunc: func(_ context.Context, accountID, userId, groupID string) error { if groupID == "linked-grp" { diff --git a/management/server/http/handlers/peers/peers_handler_test.go b/management/server/http/handlers/peers/peers_handler_test.go index 7054082cc..558a6d1bd 100644 --- a/management/server/http/handlers/peers/peers_handler_test.go +++ b/management/server/http/handlers/peers/peers_handler_test.go @@ -173,8 +173,8 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { return nil, fmt.Errorf("user not found") } }, - GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { - return peers, nil + GetPeersFunc: func(_ context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error) { + return peers, len(peers), nil }, GetPeerGroupsFunc: func(ctx context.Context, accountID, peerID string) ([]*types.Group, error) { peersID := make([]string, len(peers)) diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index 079e60548..50d18ca9b 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -39,7 +39,7 @@ type MockAccountManager struct { GetAccountIDByUserIdFunc func(ctx context.Context, userAuth auth.UserAuth) (string, error) GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) ListUsersFunc func(ctx context.Context, accountID string) ([]*types.User, error) - GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) + GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, int, error) MarkPeerConnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error MarkPeerDisconnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*nmdata.PostureChecks, int64, error) @@ -820,7 +820,8 @@ func (am *MockAccountManager) GetAccountIDFromUserAuth(ctx context.Context, user // GetPeers mocks GetPeers of the AccountManager interface func (am *MockAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { if am.GetPeersFunc != nil { - return am.GetPeersFunc(ctx, accountID, userID, nameFilter, ipFilter, macFilter) + peers, _, err := am.GetPeersFunc(ctx, accountID, userID, nameFilter, ipFilter, macFilter) + return peers, err } return nil, status.Errorf(codes.Unimplemented, "method GetPeers is not implemented") } diff --git a/management/server/peer.go b/management/server/peer.go index f21e5ebc9..aa3abcb86 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -73,7 +73,9 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID return []*nbpeer.Peer{}, nil } - return am.Store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID) + peers, err := am.Store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID) + + return peers, err } // MarkPeerConnected marks a peer as connected with optimistic-locked diff --git a/management/server/peer_test.go b/management/server/peer_test.go index f6a7cc4c4..691630a3b 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -730,7 +730,6 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) { return } assert.NotNil(t, peers) - assert.Len(t, peers, testCase.expectedPeerCount) }) diff --git a/management/server/store/sql_store_peer.go b/management/server/store/sql_store_peer.go index ec5e1993c..657d2a6e0 100644 --- a/management/server/store/sql_store_peer.go +++ b/management/server/store/sql_store_peer.go @@ -8,6 +8,7 @@ import ( "fmt" "net" "net/netip" + "strings" "time" "github.com/jackc/pgx/v5" @@ -494,7 +495,6 @@ func (s *SqlStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength Locking return &peer, nil } -// GetAccountPeers retrieves peers for an account. func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) { var peers []*nbpeer.Peer tx := s.db @@ -514,7 +514,6 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre if macFilter != "" { query = query.Where("meta_network_addresses LIKE ?", "%"+macFilter+"%") } - if err := query.Find(&peers).Error; err != nil { log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err) return nil, status.Errorf(status.Internal, "failed to get peers from store") @@ -523,6 +522,162 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre return peers, nil } +// GetAccountPeers retrieves peers for an account. +func (s *SqlStore) GetAccountPeersPaginated(ctx context.Context, lockStrength LockingStrength, accountID string, pagination PaginationState, filters PeerFilters, sorting PeerSorting) ([]*nbpeer.Peer, int, error) { + // main query + var peers []*nbpeer.Peer + { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + query := tx.Where("peers.account_id=?", accountID) + query = query.Where("(peers.proxy_meta_embedded=false or peers.proxy_meta_embedded is null)") + query = filters.AsConditions(query) + query = pagination.AsScope(query) + query = sorting.AsOrderBy(ctx, query) + + if err := query.Distinct().Find(&peers).Error; err != nil { + log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err) + return nil, 0, status.Errorf(status.Internal, "failed to get peers from store") + } + } + + // total number of records + var peerCount int64 + { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + query := tx.Where("peers.account_id=?", accountID) + query = query.Where("(peers.proxy_meta_embedded=false or peers.proxy_meta_embedded is null)") + query = filters.AsConditions(query) + + if err := query.Table("peers").Select("count(distinct(peers.id))").Count(&peerCount).Error; err != nil { + log.WithContext(ctx).Errorf("failed to get total number of peers from the store: %s", err) + return nil, 0, status.Errorf(status.Internal, "failed to get total number of peers from store") + } + } + + return peers, int(peerCount), nil +} + +type PaginationState struct { + Page int + PageSize int +} + +func (p *PaginationState) AsScope(db *gorm.DB) *gorm.DB { + // empty pagination state + if p.PageSize == 0 { + return db + } + offset := max(p.Page-1, 0) * p.PageSize + return db.Offset(offset).Limit(p.PageSize) +} + +type PeerFilters struct { + UserId string + GroupIds []string + Connected *bool + ApprovalRequired *bool + Os []string + IP string + MAC string + Hostname string + Name string + IsServer *bool + PeersIds []string +} + +func (f *PeerFilters) AsConditions(db *gorm.DB) *gorm.DB { + if len(f.GroupIds) > 0 { + db = db.Joins("join group_peers on peers.id=group_peers.peer_id and peers.account_id=group_peers.account_id").Where("group_peers.group_id in (?)", f.GroupIds) + } + if f.UserId != "" { + db = db.Where("user_id=?", f.UserId) + } + if f.Connected != nil { + db = db.Where("peer_status_connected=?", *f.Connected) + } + if f.ApprovalRequired != nil { + db = db.Where("peer_status_requires_approval=?", *f.ApprovalRequired) + } + if len(f.Os) > 0 { + db = db.Where("meta_go_os in (?)", f.Os) + } + if f.IP != "" { + db = db.Where("ip like ? or lower(ipv6) like ?", fmt.Sprintf("%%%s%%", f.IP), fmt.Sprintf("%%%s%%", strings.ToLower(f.IP))) + } + if f.MAC != "" { + db = db.Where("lower(meta_network_addresses) like ?", fmt.Sprintf("%%%s%%", strings.ToLower(f.MAC))) + } + if f.Hostname != "" { + db = db.Where("lower(meta_hostname) like ?", fmt.Sprintf("%%%s%%", strings.ToLower(f.Hostname))) + } + if f.Name != "" { + db = db.Where("lower(name) like ?", fmt.Sprintf("%%%s%%", strings.ToLower(f.Name))) + } + if f.IsServer != nil { + if *f.IsServer { + db = db.Where("(user_id='' or user_id is null)") + } else { + db = db.Where("user_id<>'' and user_id is not null") + } + } + if len(f.PeersIds) > 0 { + db = db.Where("id in (?)", f.PeersIds) + } + + return db +} + +type PeerSortByField int + +const ( + PeerSortByName PeerSortByField = iota + PeerSortByLastSeen + PeerSortByVersion +) + +type OrderBy int + +const ( + AscOrderBy OrderBy = iota + DescOrderBy +) + +type PeerSortingKey struct { + Field PeerSortByField + Order OrderBy +} + +type PeerSorting struct { + Fields []PeerSortingKey +} + +func (s *PeerSorting) AsOrderBy(ctx context.Context, db *gorm.DB) *gorm.DB { + order := make([]clause.OrderByColumn, 0) + + for _, key := range s.Fields { + switch key.Field { + case PeerSortByName: + order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "peers.name"}, Desc: key.Order == DescOrderBy}) + case PeerSortByLastSeen: + order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "peers.peer_status_last_seen"}, Desc: key.Order == DescOrderBy}) + case PeerSortByVersion: + order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "meta_wt_version"}, Desc: key.Order == DescOrderBy}) + default: + log.WithContext(ctx).Warnf("invalid order by column '%v', skipping", key.Field) + } + } + + // always order/break ties by id asc order + order = append(order, clause.OrderByColumn{Column: clause.Column{Name: "peers.id"}, Desc: false}) + return db.Order(clause.OrderBy{Columns: order}) +} + // GetUserPeers retrieves peers for a user. func (s *SqlStore) GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*nbpeer.Peer, error) { tx := s.db diff --git a/management/server/store/sql_store_peer_test.go b/management/server/store/sql_store_peer_test.go index 41dc9edcb..0b467e37b 100644 --- a/management/server/store/sql_store_peer_test.go +++ b/management/server/store/sql_store_peer_test.go @@ -512,7 +512,7 @@ func TestSqlStore_GetAccountPeers(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - peers, err := store.GetAccountPeers(context.Background(), LockingStrengthNone, tt.accountID, tt.nameFilter, tt.ipFilter, "") + peers, _, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, tt.accountID, PaginationState{}, PeerFilters{Hostname: tt.nameFilter, IP: tt.ipFilter}, PeerSorting{}) require.NoError(t, err) require.Len(t, peers, tt.expectedCount) }) @@ -555,7 +555,7 @@ func TestSqlStore_GetAccountPeers_FilterByMac(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - peers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "", tt.macFilter) + peers, _, err := store.GetAccountPeersPaginated(ctx, LockingStrengthNone, accountID, PaginationState{}, PeerFilters{MAC: tt.macFilter}, PeerSorting{}) require.NoError(t, err) require.Len(t, peers, tt.expectedCount) }) diff --git a/management/server/store/sql_store_peers_filter_test.go b/management/server/store/sql_store_peers_filter_test.go new file mode 100644 index 000000000..dabfcf2ae --- /dev/null +++ b/management/server/store/sql_store_peers_filter_test.go @@ -0,0 +1,250 @@ +package store + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSqlStore_FilterPeers(t *testing.T) { + runTestForAllEnginesNoSqliteSeed(t, "../testdata/peers_pagination.sql", func(t *testing.T, engine string, store Store) { + var tests = []struct { + description string + filters PeerFilters + expectedLength int + expectedPeers []string + }{ + { + description: "filter by user_id", + filters: PeerFilters{UserId: "user-id-1"}, + expectedLength: 1, + expectedPeers: []string{"Peer-1-name"}, + }, + { + description: "filter by connected status", + filters: PeerFilters{Connected: new(true)}, + expectedLength: 2, + expectedPeers: []string{"Peer-1-name", "peer-2-name"}, + }, + { + description: "filter by approval required status", + filters: PeerFilters{ApprovalRequired: new(true)}, + expectedLength: 2, + expectedPeers: []string{"peer-2-name", "peer-3-name"}, + }, + { + description: "filter by ipv4", + filters: PeerFilters{IP: "10.10.10"}, + expectedLength: 2, + expectedPeers: []string{"Peer-1-name", "peer-2-name"}, + }, + { + description: "filter by ipv6", + filters: PeerFilters{IP: "ba80:6aa5:89f1:44d7:8701:869"}, + expectedLength: 2, + expectedPeers: []string{"peer-2-name", "peer-3-name"}, + }, + { + description: "filter by ipv6 (uppercase)", + filters: PeerFilters{IP: "BA80:6AA5:89f1:44d7:8701:869"}, + expectedLength: 2, + expectedPeers: []string{"peer-2-name", "peer-3-name"}, + }, + { + description: "filter by mac", + filters: PeerFilters{MAC: ":15:5d:24:0c"}, + expectedLength: 3, + expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name"}, + }, + { + description: "filter by mac (uppercase)", + filters: PeerFilters{MAC: ":15:5D:24:0C"}, + expectedLength: 3, + expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name"}, + }, + { + description: "filter by kind (server)", + filters: PeerFilters{IsServer: new(true), Os: []string{"linux"}}, + expectedLength: 3, + expectedPeers: []string{"peer-4-name", "peer-5-name", "peer-6-name"}, + }, + { + description: "filter by kind (user_device)", + filters: PeerFilters{IsServer: new(false)}, + expectedLength: 3, + expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name"}, + }, + { + description: "filter by group_ids", + filters: PeerFilters{GroupIds: []string{"group-one-resource-id", "group-two-resources-id"}}, + expectedLength: 2, + expectedPeers: []string{"Peer-1-name", "peer-2-name"}, + }, + { + description: "filter by os", + filters: PeerFilters{Os: []string{"linux", "ios"}}, + expectedLength: 5, + expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-4-name", "peer-5-name", "peer-6-name"}, + }, + { + description: "filter by peer id", + filters: PeerFilters{PeersIds: []string{"peer-id-one", "peer-id-five", "peer-id-three"}}, + expectedLength: 3, + expectedPeers: []string{"Peer-1-name", "peer-3-name", "peer-5-name"}, + }, + { + description: "filter by name", + filters: PeerFilters{Name: "peer-1"}, + expectedLength: 1, + expectedPeers: []string{"Peer-1-name"}, + }, + { + description: "filter by name (upper-case)", + filters: PeerFilters{Name: "PEER-1"}, + expectedLength: 1, + expectedPeers: []string{"Peer-1-name"}, + }, + { + description: "filter by hostname", + filters: PeerFilters{Hostname: "one-name.local"}, + expectedLength: 1, + expectedPeers: []string{"Peer-1-name"}, + }, + { + description: "filter by hostname (upper-case)", + filters: PeerFilters{Hostname: "ONE-NAME.LOCAL"}, + expectedLength: 1, + expectedPeers: []string{"Peer-1-name"}, + }, + } + for _, tt := range tests { + t.Run(tt.description+" "+engine, func(t *testing.T) { + t.Helper() + peers, count, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, "account-1", + PaginationState{}, tt.filters, PeerSorting{}) + require.NoError(t, err) + require.Len(t, peers, tt.expectedLength) + require.Equal(t, tt.expectedLength, count) + actualPeerNames := make([]string, 0, len(peers)) + for _, p := range peers { + actualPeerNames = append(actualPeerNames, p.Name) + } + require.ElementsMatch(t, actualPeerNames, tt.expectedPeers) + }) + } + }) +} + +func TestSqlStore_PeersPaging(t *testing.T) { + runTestForAllEnginesNoSqliteSeed(t, "../testdata/peers_pagination.sql", func(t *testing.T, engine string, store Store) { + var tests = []struct { + description string + page int + pageSize int + expectedLength int + expectedCount int + expectedPeers []string + }{ + { + description: "page 1, size 4", + page: 1, + pageSize: 4, + expectedLength: 4, + expectedCount: 6, + expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name", "peer-4-name"}, + }, + { + description: "page 2, size 4", + page: 2, + pageSize: 4, + expectedLength: 2, + expectedCount: 6, + expectedPeers: []string{"peer-5-name", "peer-6-name"}, + }, + } + for _, tt := range tests { + t.Run(tt.description+" "+engine, func(t *testing.T) { + t.Helper() + peers, count, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, "account-1", + PaginationState{Page: tt.page, PageSize: tt.pageSize}, PeerFilters{}, PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByName}}}) + require.NoError(t, err) + require.Len(t, peers, tt.expectedLength) + require.Equal(t, tt.expectedCount, count) + actualPeerNames := make([]string, 0, len(peers)) + for _, p := range peers { + actualPeerNames = append(actualPeerNames, p.Name) + } + require.ElementsMatch(t, actualPeerNames, tt.expectedPeers) + }) + } + }) +} + +func TestSqlStore_PeersSorting(t *testing.T) { + runTestForAllEnginesNoSqliteSeed(t, "../testdata/peers_pagination.sql", func(t *testing.T, engine string, store Store) { + var tests = []struct { + description string + sorting PeerSorting + expectedLength int + expectedPeers []string + }{ + { + description: "default order (by id ascending)", + sorting: PeerSorting{Fields: []PeerSortingKey{}}, + expectedLength: 6, + expectedPeers: []string{"peer-5-name", "peer-4-name", "Peer-1-name", "peer-6-name", "peer-3-name", "peer-2-name"}, + }, + { + description: "by name ascending", + sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByName}}}, + expectedLength: 6, + expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name", "peer-4-name", "peer-5-name", "peer-6-name"}, + }, + { + description: "by name descending", + sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByName, Order: DescOrderBy}}}, + expectedLength: 6, + expectedPeers: []string{"peer-6-name", "peer-5-name", "peer-4-name", "peer-3-name", "peer-2-name", "Peer-1-name"}, + }, + { + description: "by last_seen ascending", + sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByLastSeen}}}, + expectedLength: 6, + expectedPeers: []string{"peer-6-name", "peer-4-name", "peer-5-name", "peer-3-name", "peer-2-name", "Peer-1-name"}, + }, + { + description: "by last_seen descending", + sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByLastSeen, Order: DescOrderBy}}}, + expectedLength: 6, + expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-3-name", "peer-5-name", "peer-4-name", "peer-6-name"}, + }, + { + description: "by version ascending", + sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByVersion}}}, + expectedLength: 6, + expectedPeers: []string{"Peer-1-name", "peer-2-name", "peer-4-name", "peer-3-name", "peer-6-name", "peer-5-name"}, + }, + { + description: "by version descending", + sorting: PeerSorting{Fields: []PeerSortingKey{{Field: PeerSortByVersion, Order: DescOrderBy}}}, + expectedLength: 6, + expectedPeers: []string{"peer-5-name", "peer-6-name", "peer-3-name", "peer-4-name", "peer-2-name", "Peer-1-name"}, + }, + } + for _, tt := range tests { + t.Run(tt.description+" "+engine, func(t *testing.T) { + t.Helper() + peers, _, err := store.GetAccountPeersPaginated(context.Background(), LockingStrengthNone, "account-1", + PaginationState{}, PeerFilters{}, tt.sorting) + require.NoError(t, err) + require.Len(t, peers, tt.expectedLength) + actualPeerNames := make([]string, 0, len(peers)) + for _, p := range peers { + actualPeerNames = append(actualPeerNames, p.Name) + } + require.Equal(t, tt.expectedPeers, actualPeerNames) + }) + } + }) +} diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index f695a150d..24f9a410d 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -42,6 +42,22 @@ func runTestForAllEngines(t *testing.T, testDataFile string, f func(t *testing.T } } +func runTestForAllEnginesNoSqliteSeed(t *testing.T, testDataFile string, f func(t *testing.T, engine string, store Store)) { + t.Helper() + for _, engine := range supportedEngines { + if os.Getenv("NETBIRD_STORE_ENGINE") != "" && os.Getenv("NETBIRD_STORE_ENGINE") != string(engine) { + continue + } + t.Setenv("NETBIRD_STORE_ENGINE", string(engine)) + store, cleanUp, err := NewTestStore(context.Background(), testDataFile, t.TempDir()) + assert.NoError(t, err, "engine: ", string(engine)) + t.Cleanup(cleanUp) + assert.NoError(t, err) + f(t, string(engine), store) + os.Unsetenv("NETBIRD_STORE_ENGINE") + } +} + func Test_NewStore(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("The SQLite store is not properly supported by Windows yet") diff --git a/management/server/store/store.go b/management/server/store/store.go index 01aaf4892..174e97b48 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -23,6 +23,7 @@ import ( "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/gorm" + "gorm.io/gorm/logger" "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" @@ -161,6 +162,7 @@ type Store interface { AddPeerToAccount(ctx context.Context, peer *nbpeer.Peer) error GetPeerByPeerPubKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (*nbpeer.Peer, error) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter, macFilter string) ([]*nbpeer.Peer, error) + GetAccountPeersPaginated(ctx context.Context, lockStrength LockingStrength, accountID string, pagination PaginationState, filters PeerFilters, sorting PeerSorting) ([]*nbpeer.Peer, int, error) GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*nbpeer.Peer, error) GetPeerByID(ctx context.Context, lockStrength LockingStrength, accountID string, peerID string) (*nbpeer.Peer, error) GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*nbpeer.Peer, error) @@ -723,7 +725,7 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) ( } if filename != "" { - err = LoadSQL(conn.DB(nil), filename) + err = LoadSQL(conn.DB(nil), filename, translaterForSql(kind)) if err != nil { _ = conn.Close() return nil, nil, fmt.Errorf("failed to load SQL file: %v", err) @@ -747,7 +749,7 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) ( maxRetries := 2 for i := 0; i < maxRetries; i++ { - sqlStore, cleanup, err = getSqlStoreEngine(ctx, store, kind) + sqlStore, cleanup, err = getSqlStoreEngine(ctx, store, kind, dataDir) if err == nil { return sqlStore, cleanup, nil } @@ -759,6 +761,59 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) ( return nil, nil, fmt.Errorf("failed to create test store after %d attempts: %v", maxRetries, err) } +func NewTestStore(ctx context.Context, filename string, dataDir string) (Store, func(), error) { + kind := getStoreEngineFromEnv() + if kind == "" { + kind = types.SqliteStoreEngine + } + + var sqlStore *SqlStore + var cleanup func() + var err error + + maxRetries := 2 + for i := 0; i < maxRetries; i++ { + sqlStore, cleanup, err = getSqlStoreEngine(ctx, nil, kind, dataDir) + if err == nil { + break + } + if i < maxRetries-1 { + time.Sleep(100 * time.Millisecond) + } + } + if err != nil { + return nil, nil, fmt.Errorf("failed to create test store after %d attempts: %v", maxRetries, err) + } + + if filename != "" { + err = LoadSQL(sqlStore.db, filename, translaterForSql(kind)) + if err != nil { + _ = sqlStore.Close(ctx) + return nil, nil, fmt.Errorf("failed to load SQL file: %v", err) + } + } + + err = addAllGroupToAccount(ctx, sqlStore) + if err != nil { + _ = sqlStore.Close(ctx) + return nil, nil, fmt.Errorf("failed to add all group to account: %v", err) + } + + return sqlStore, cleanup, nil +} + +// use to translate double-quote escaped reserved names (ansi) +// to mysql tick-escaped ones +func translaterForSql(kind types.Engine) *strings.Replacer { + if kind != types.MysqlStoreEngine { + return strings.NewReplacer() + } + return strings.NewReplacer( + "\"key\"", "`key`", + "\"groups\"", "`groups`", + ) +} + func addAllGroupToAccount(ctx context.Context, store Store) error { allAccounts := store.GetAllAccounts(ctx) for _, account := range allAccounts { @@ -782,8 +837,8 @@ func addAllGroupToAccount(ctx context.Context, store Store) error { return nil } -func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.Engine) (Store, func(), error) { - store := sqliteStore +func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.Engine, dataDir string) (*SqlStore, func(), error) { + var store *SqlStore var cleanup func() var err error switch kind { @@ -795,6 +850,15 @@ func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.En cleanup = func() { // sqlite doesn't need to be cleaned up } + store = sqliteStore + var conn *db.Conn + if store == nil { + conn, err = db.OpenSqliteFile(ctx, dataDir, db.SqliteFileName) + if err != nil { + return nil, nil, fmt.Errorf("failed to create test store: %v", err) + } + store, err = NewSqlStore(ctx, conn, nil, false) + } } if err != nil { return nil, cleanup, fmt.Errorf("failed to create test store: %v", err) @@ -803,7 +867,7 @@ func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.En closeConnection := func() { cleanup() store.Close(ctx) - if store != sqliteStore { + if sqliteStore != nil && store != sqliteStore { // The sqlite store only seeded the engine under test; without this // every test leaks its connection and the opener goroutines. sqliteStore.Close(ctx) @@ -813,7 +877,7 @@ func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.En return store, closeConnection, nil } -func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Engine) (*SqlStore, func(), error) { +func newReusedPostgresStore(ctx context.Context, sqliteStore *SqlStore, kind types.Engine) (*SqlStore, func(), error) { dsn, ok := lookupDSNEnv(PostgresDsnEnv, PostgresDsnEnvLegacy) if !ok || dsn == "" { var err error @@ -846,16 +910,25 @@ func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Eng return nil, nil, err } - store, err = newPostgresqlStoreFromSqlStore(ctx, store, dsn, nil, true) - if err != nil { - cleanup() - return nil, nil, err + var store *SqlStore + if sqliteStore != nil { + store, err = newPostgresqlStoreFromSqlStore(ctx, sqliteStore, dsn, nil, true) + if err != nil { + cleanup() + return nil, nil, err + } + } else { + store, err = NewPostgresqlStoreForTests(ctx, dsn, nil, false) + if err != nil { + cleanup() + return nil, nil, err + } } return store, cleanup, nil } -func newReusedMysqlStore(ctx context.Context, store *SqlStore, kind types.Engine) (*SqlStore, func(), error) { +func newReusedMysqlStore(ctx context.Context, sqliteStore *SqlStore, kind types.Engine) (*SqlStore, func(), error) { dsn, ok := lookupDSNEnv(mysqlDsnEnv, mysqlDsnEnvLegacy) if !ok || dsn == "" { var err error @@ -900,10 +973,19 @@ func newReusedMysqlStore(ctx context.Context, store *SqlStore, kind types.Engine return nil, nil, err } - store, err = newMysqlStoreFromSqlStore(ctx, store, dsn, nil, true) - if err != nil { - cleanup() - return nil, nil, err + var store *SqlStore + if sqliteStore != nil { + store, err = newMysqlStoreFromSqlStore(ctx, sqliteStore, dsn, nil, true) + if err != nil { + cleanup() + return nil, nil, err + } + } else { + store, err = NewMysqlStore(ctx, dsn, nil, false) + if err != nil { + cleanup() + return nil, nil, err + } } return store, cleanup, nil @@ -1135,6 +1217,7 @@ func createRandomDB(dsn string, admin *gorm.DB, engine types.Engine, template st dropDB, err = gorm.Open(postgres.Open(originalDSN), &gorm.Config{ SkipDefaultTransaction: true, PrepareStmt: false, + Logger: logger.Default.LogMode(logger.Info), }) if err != nil { log.Errorf("failed to connect for dropping database %s: %v", dbName, err) @@ -1205,7 +1288,7 @@ func replaceDBName(dsn, newDBName string) string { return re.ReplaceAllString(dsn, `${pre}`+newDBName+`${post}`) } -func LoadSQL(db *gorm.DB, filepath string) error { +func LoadSQL(db *gorm.DB, filepath string, translator *strings.Replacer) error { sqlContent, err := os.ReadFile(filepath) if err != nil { return err @@ -1214,7 +1297,7 @@ func LoadSQL(db *gorm.DB, filepath string) error { queries := strings.Split(string(sqlContent), ";") for _, query := range queries { - query = strings.TrimSpace(query) + query = translator.Replace(strings.TrimSpace(query)) if query != "" { err := db.Exec(query).Error if err != nil { diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 956cac4b8..8b89219e5 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -1344,6 +1344,22 @@ func (mr *MockStoreMockRecorder) GetAccountPeers(ctx, lockStrength, accountID, n return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockStore)(nil).GetAccountPeers), ctx, lockStrength, accountID, nameFilter, ipFilter, macFilter) } +// GetAccountPeersPaginated mocks base method. +func (m *MockStore) GetAccountPeersPaginated(ctx context.Context, lockStrength LockingStrength, accountID string, pagination PaginationState, filters PeerFilters, sorting PeerSorting) ([]*peer.Peer, int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountPeersPaginated", ctx, lockStrength, accountID, pagination, filters, sorting) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(int) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAccountPeersPaginated indicates an expected call of GetAccountPeersPaginated. +func (mr *MockStoreMockRecorder) GetAccountPeersPaginated(ctx, lockStrength, accountID, pagination, filters, sorting any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeersPaginated", reflect.TypeOf((*MockStore)(nil).GetAccountPeersPaginated), ctx, lockStrength, accountID, pagination, filters, sorting) +} + // GetAccountPeersWithExpiration mocks base method. func (m *MockStore) GetAccountPeersWithExpiration(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*peer.Peer, error) { m.ctrl.T.Helper() diff --git a/management/server/testdata/peers_pagination.sql b/management/server/testdata/peers_pagination.sql new file mode 100644 index 000000000..12e7d9d40 --- /dev/null +++ b/management/server/testdata/peers_pagination.sql @@ -0,0 +1,112 @@ +insert into accounts (id, domain, network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups, + settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) +VALUES('account-1','account-1-test.com','network-1','{"IP":"100.103.0.0","Mask":"//8AAA=="}','{"IP":"fdde:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',1,'["disabled-group-1","disabled-group-2"]', + true, 86400000000000, false, + 86400000000000, null, '["group-one-resource-id"]', false, + false, 'disabled', false, false); +insert into accounts (id, domain,network_identifier, network_net, network_net_v6, network_dns, network_serial,dns_settings_disabled_management_groups, + settings_peer_login_expiration_enabled, settings_peer_login_expiration, settings_peer_inactivity_expiration_enabled, + settings_peer_inactivity_expiration, settings_dns_domain, settings_ipv6_enabled_groups, settings_routing_peer_dns_resolution_enabled, + settings_lazy_connection_enabled, settings_auto_update_version, settings_auto_update_always, settings_metrics_push_enabled) +VALUES('account-2','account-2-test.com','network-2','{"IP":"110.0.0.0","Mask":"//8AAA=="}','{"IP":"fddf:e995:fd38:a465::","Mask":"//////////8AAAAAAAAAAA=="}','',2,null, + true, 86400000000000, false, + 86400000000000, null, '["group-two-resources-id"]', false, + false, 'disabled', false, false); +insert into "groups" (id, account_id, name, resources, public_id) VALUES('group-one-resource-id','account-1','group-1-name', '[{"ID":"host-id-1","Type":"host"}]','group-one-resource-id-public'); +insert into "groups" (id, account_id, name, resources, public_id) VALUES('group-two-resources-id','account-1','group-2-name', '[{"ID":"subnet-id-1","Type":"subnet"}, {"ID":"host-id-2","Type":"host"}]','group-two-resources-id-public'); +insert into "groups" (id, account_id, name, resources, public_id) VALUES('group-no-resources-id','account-1','group-3-name', null,'group-no-resources-id-public'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-one','group-one-resource-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-one','group-two-resources-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-two','group-two-resources-id'); +insert into group_peers (account_id, peer_id, group_id) VALUES('account-1','peer-id-three','group-no-resources-id'); +insert into peers (id, account_id, "key", ssh_key, name, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, peer_status_last_seen, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + meta_hostname, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-one','account-1','key-1','ssh-key-1','Peer-1-name','peer-1','["extra-peer-1"]','user-id-1',true,true,'2026-08-06 13:25:59.12999','"10.10.10.1"','"fdf4:ba80:6aa5:89f1:44d7:8701:8677:4940"', + false,true,false,'2026-08-06 13:25:59.12999','cluster-1.netbird.services', + '0.76.0','linux','26.4.1','6.8.0-134-generic','[{"NetIP":"fe80::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ac"},{"NetIP":"192.168.16.1/20","Mac":"00:15:5d:24:0c:ac"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'peer-one-name.local', 'DE','Berlin','"46.201.148.187"'); +insert into peers (id,account_id,"key", ssh_key, name, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, peer_status_last_seen, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + meta_hostname, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-two','account-1','key-2','ssh-key-2','peer-2-name','peer-2','["extra-peer-2"]','user-id-2',true,true,'2026-08-06 14:25:59.12999','"10.10.100.1"','"fdf5:ba80:6aa5:89f1:44d7:8701:8698:4940"', + true,true,false,'2026-08-06 12:25:59.12999','cluster-2.netbird.services', + '0.76.1','ios','26.4.2','6.8.0-135-generic','[{"NetIP":"fe81::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ad"},{"NetIP":"192.168.17.1/20","Mac":"00:15:5d:24:0c:ad"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',0, + 'peer-two-name.local', 'DE','Berlin','"46.201.149.187"'); +insert into peers (id,account_id,"key", ssh_key, name, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, peer_status_last_seen, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, + meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, meta_hostname, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-three','account-1','key-3','ssh-key-3','peer-3-name','peer-3','["extra-peer-3"]','user-id-3',true,true,'2026-08-06 12:25:59.12999','"10.10.200.1"','"fdf6:ba80:6aa5:89f1:44d7:8701:8699:4940"', + true,false,false,'2026-08-06 11:25:59.12999','cluster-3.netbird.services', + '0.76.3','windows','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:5d:24:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:5d:24:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'peer-three-name.local','DE','Berlin','"46.201.150.187"'); +insert into peers (id,account_id,"key", ssh_key, name, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, peer_status_last_seen, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + meta_hostname, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-four','account-1','key-4','ssh-key-4','peer-4-name','peer-4','["extra-peer-4"]','',true,true,'2026-08-06 12:25:59.12999','"10.10.20.1"','"fdf6:ba80:6aa5:89f1:44d7:8701:8669:4940"', + false,false,false,'2026-08-06 07:25:59.12999','cluster-4.netbird.services', + '0.76.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:7d:44:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:6d:34:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'peer-four-name.local','DE','Berlin','"46.201.150.187"'); +insert into peers (id,account_id,"key", ssh_key, name, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, peer_status_last_seen, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + meta_hostname, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-five','account-1','key-5','ssh-key-5','peer-5-name','peer-5','["extra-peer-5"]','',true,true,'2026-08-06 12:25:59.12999','"10.10.9.2"','"fdf6:ba80:6aa5:89f1:44d7:8701:8669:4941"', + false,false,false,'2026-08-06 09:25:59.12999','cluster-4.netbird.services', + '0.78.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:7d:44:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:6d:34:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'peer-five-name.local','DE','Berlin','"46.201.150.187"'); +insert into peers (id,account_id,"key", ssh_key, name, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, peer_status_last_seen, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + meta_hostname, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-six','account-1','key-6','ssh-key-6','peer-6-name','peer-6','["extra-peer-6"]','',true,true,'2026-08-06 12:25:59.12999','"10.10.8.3"','"fdf6:ba80:6aa5:89f1:44d7:8701:8669:4942"', + false,false,false,'2026-08-06 03:25:59.12999','cluster-4.netbird.services', + '0.77.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:7d:44:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:6d:34:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'peer-six-name.local','DE','Berlin','"46.201.150.187"'); + -- peer-id-8 should never appear in any of the results, as we are filtering out proxy peers +insert into peers (id,account_id,"key", ssh_key, name, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, peer_status_last_seen, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + meta_hostname, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-8','account-1','key-8','ssh-key-8','peer-8-name','peer-8','["extra-peer-8"]','',true,true,'2026-08-06 12:25:59.12999','"10.10.88.3"','"fdf6:ba80:6aa5:89f1:44d7:8701:8669:4943"', + false,false,true,'2026-08-06 03:25:59.12999','cluster-4.netbird.services', + '0.77.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:7d:44:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:6d:34:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'peer-eight-name.local','DE','Berlin','"46.201.150.187"'); +insert into peers (id,account_id,"key", ssh_key, name, dns_label, extra_dns_labels, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6, + peer_status_requires_approval, peer_status_connected, proxy_meta_embedded, peer_status_last_seen, proxy_meta_cluster, + meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, + meta_capabilities, meta_flags, meta_sync_message_version, + meta_hostname, + location_country_code, location_city_name, location_connection_ip) + values('peer-id-7','account-2','key-7','ssh-key-7','peer-7-name','peer-7','["extra-peer-7"]','',true,true,'2026-08-06 12:25:59.12999','"10.10.8.3"','"fdf6:ba80:6aa5:89f1:44d7:8701:8669:4942"', + false,false,false,'2026-08-06 03:25:59.12999','cluster-4.netbird.services', + '0.77.2','linux','26.4.3','6.8.0-136-generic','[{"NetIP":"fe82::8b4c:973f:a76b:3771/64","Mac":"00:15:7d:44:0c:ae"},{"NetIP":"192.168.18.1/20","Mac":"00:15:6d:34:0c:ae"}]','[{"Path":"/usr/bin/netbird","Exist":false,"ProcessIsRunning":false}]', + '[1,2]','{"RosenpassEnabled":false,"RosenpassPermissive":false,"ServerSSHAllowed":true,"DisableClientRoutes":false,"DisableServerRoutes":false,"DisableDNS":false,"DisableFirewall":false,"BlockLANAccess":false,"BlockInbound":false,"DisableIPv6":false,"LazyConnectionEnabled":false}',1, + 'peer-seven-name.local','DE','Berlin','"46.201.150.187"'); diff --git a/shared/management/http/apiv1alpha1/bundle.yaml b/shared/management/http/apiv1alpha1/bundle.yaml index c8245ab49..5ac8011ea 100644 --- a/shared/management/http/apiv1alpha1/bundle.yaml +++ b/shared/management/http/apiv1alpha1/bundle.yaml @@ -150,22 +150,30 @@ components: description: A human-readable error message. example: "couldn't parse JSON request" PeerBatch: - allOf: - - $ref: '#/components/schemas/Peer' - - type: object - properties: - created_at: - description: Peer creation date (UTC) - type: string - format: date-time - example: "2023-05-05T09:00:35.477782Z" - accessible_peers_count: - description: Number of accessible peers - type: integer - example: 5 - required: - - created_at - - accessible_peers_count + type: object + properties: + peer_count: + description: Total number of peers + type: integer + example: 5 + page: + description: Current page number + type: integer + example: 5 + page_size: + description: Page size + type: integer + example: 5 + peers: + description: List of peers + type: array + items: + $ref: '#/components/schemas/Peer' + required: + - peer_count + - page + - page_size + - peers PeerMinimum: type: object properties: @@ -701,6 +709,30 @@ components: minimum: 1 maximum: 250 default: 100 + - name: user_id + in: query + description: filter peers by their user_id + required: false + schema: + type: string + - name: group_ids + in: query + description: filter peers by group_ids + required: false + schema: + type: array + items: + type: string + - name: peer_ids + in: query + description: filter peers by their ids + required: false + schema: + type: array + minItems: 1 + maxItems: 50 + items: + type: string - name: connected in: query description: Filter by peer connected status @@ -724,16 +756,58 @@ components: enum: - linux - windows - - mac + - darwin - android - ios - js - - name: search + - name: ip in: query - description: filter peers by name, dns_label, ip, os, version, serial_number, owner name, owner email, group names, mac + description: filter peers by ip required: false schema: type: string + - name: mac + in: query + description: filter peers by mac address + required: false + schema: + type: string + - name: name + in: query + description: filter peers by hostname + required: false + schema: + type: string + - name: hostname + in: query + description: filter peers by hostname + required: false + schema: + type: string + - name: kind + in: query + description: filter peers by host kind + required: false + schema: + type: string + enum: + - server + - user_device + - name: sort_by + in: query + description: sorting of peers + required: false + example: sort_by=name,asc&sort_by=last_seen,desc&sort_by=version + schema: + type: array + items: + type: string + enum: + - name + - last_seen + - version + - asc + - desc security: - BearerAuth: [] - TokenAuth: [] @@ -743,9 +817,7 @@ components: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/PeerBatch' + $ref: '#/components/schemas/PeerBatch' '400': "$ref": "#/components/responses/bad_request" '401': diff --git a/shared/management/http/apiv1alpha1/peer/components.yaml b/shared/management/http/apiv1alpha1/peer/components.yaml index e2a7b9240..8c197d8e0 100644 --- a/shared/management/http/apiv1alpha1/peer/components.yaml +++ b/shared/management/http/apiv1alpha1/peer/components.yaml @@ -239,19 +239,27 @@ components: type: boolean example: false PeerBatch: - allOf: - - $ref: '#/components/schemas/Peer' - - type: object - properties: - created_at: - description: Peer creation date (UTC) - type: string - format: date-time - example: "2023-05-05T09:00:35.477782Z" - accessible_peers_count: - description: Number of accessible peers - type: integer - example: 5 - required: - - created_at - - accessible_peers_count \ No newline at end of file + type: object + properties: + peer_count: + description: Total number of peers + type: integer + example: 5 + page: + description: Current page number + type: integer + example: 5 + page_size: + description: Page size + type: integer + example: 5 + peers: + description: List of peers + type: array + items: + $ref: '#/components/schemas/Peer' + required: + - peer_count + - page + - page_size + - peers \ No newline at end of file diff --git a/shared/management/http/apiv1alpha1/peer/peers.yaml b/shared/management/http/apiv1alpha1/peer/peers.yaml index c360018fc..5a32260ad 100644 --- a/shared/management/http/apiv1alpha1/peer/peers.yaml +++ b/shared/management/http/apiv1alpha1/peer/peers.yaml @@ -20,6 +20,30 @@ get: minimum: 1 maximum: 250 default: 100 + - name: user_id + in: query + description: filter peers by their user_id + required: false + schema: + type: string + - name: group_ids + in: query + description: filter peers by group_ids + required: false + schema: + type: array + items: + type: string + - name: peer_ids + in: query + description: filter peers by their ids + required: false + schema: + type: array + minItems: 1 + maxItems: 50 + items: + type: string - name: connected in: query description: Filter by peer connected status @@ -43,16 +67,58 @@ get: enum: - linux - windows - - mac + - darwin - android - ios - js - - name: search + - name: ip in: query - description: filter peers by name, dns_label, ip, os, version, serial_number, owner name, owner email, group names, mac + description: filter peers by ip required: false schema: type: string + - name: mac + in: query + description: filter peers by mac address + required: false + schema: + type: string + - name: name + in: query + description: filter peers by hostname + required: false + schema: + type: string + - name: hostname + in: query + description: filter peers by hostname + required: false + schema: + type: string + - name: kind + in: query + description: filter peers by host kind + required: false + schema: + type: string + enum: + - server + - user_device + - name: sort_by + in: query + description: sorting of peers + required: false + example: sort_by=name,asc&sort_by=last_seen,desc&sort_by=version + schema: + type: array + items: + type: string + enum: + - name + - last_seen + - version + - asc + - desc security: - BearerAuth: [ ] - TokenAuth: [ ] @@ -62,9 +128,7 @@ get: content: application/json: schema: - type: array - items: - $ref: './components.yaml#/components/schemas/PeerBatch' + $ref: './components.yaml#/components/schemas/PeerBatch' '400': "$ref": "../openapi.yaml#/components/responses/bad_request" '401': diff --git a/shared/management/http/apiv1alpha1/types.gen.go b/shared/management/http/apiv1alpha1/types.gen.go index 3e9ede3e4..14ac4e7f4 100644 --- a/shared/management/http/apiv1alpha1/types.gen.go +++ b/shared/management/http/apiv1alpha1/types.gen.go @@ -41,13 +41,17 @@ type ErrorResponse struct { } type PeerBatch struct { - Peer - // CreatedAt Peer creation date (UTC). - // CreatedAt example value is defined in the OpenAPI schema. - CreatedAt time.Time `json:"created_at"` - // AccessiblePeersCount Number of accessible peers. - // AccessiblePeersCount example value is defined in the OpenAPI schema. - AccessiblePeersCount int `json:"accessible_peers_count"` + // PeerCount Total number of peers. + // PeerCount example value is defined in the OpenAPI schema. + PeerCount int `json:"peer_count"` + // Page Current page number. + // Page example value is defined in the OpenAPI schema. + Page int `json:"page"` + // PageSize Page size. + // PageSize example value is defined in the OpenAPI schema. + PageSize int `json:"page_size"` + // Peers List of peers. + Peers []Peer `json:"peers"` } type PeerMinimum struct { From 6989d689cf2afe906546f7128835a33160266e53 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:42:34 +0200 Subject: [PATCH 180/209] [misc] Announce tag-pushed releases on the forum (#8265) * [misc] Announce tag-pushed releases on the forum Since 0.77 a release is cut by pushing a tag, and goreleaser creates the GitHub release with GITHUB_TOKEN. Events raised by that token start no workflow, so the release:published trigger never fires for them and 0.78.0, 0.79.0, 0.80.0, 0.81.0 and 0.81.3 got no forum topic, while releases published from the UI still did. Post from the completion of the Release workflow instead, for tag runs that succeeded and whose release was created by the bot. Release candidates stay off the forum, and an existing topic with the same title is left alone so re-running a green Release run does not post twice. * [misc] Point an empty forum post at the release notes goreleaser creates the release with an empty body and the notes are written on the release page afterwards, so the topic would otherwise hold only its title link. Say where the notes and downloads are. * [misc] Fail closed in the forum post lookups Announce stable tags only: every pre-release tag carries a hyphen, so canaries and release candidates pushed as tags stay off the forum. A failed release lookup now fails the step instead of passing as "no release"; only a 404 skips. The duplicate check searches by title rather than reading the first page of the category, and a failed search stops the step instead of falling through to a post. Discourse requests refuse any non-HTTPS redirect and retry transient errors. --- .github/workflows/forum.yml | 82 +++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/.github/workflows/forum.yml b/.github/workflows/forum.yml index 75543ef8b..44582c78e 100644 --- a/.github/workflows/forum.yml +++ b/.github/workflows/forum.yml @@ -3,9 +3,16 @@ name: Post release topic on Discourse on: release: types: [published] + workflow_run: + workflows: [Release] + types: [completed] + +permissions: + contents: read jobs: post: + if: github.event_name == 'release' runs-on: ubuntu-latest steps: - uses: roots/discourse-topic-github-release-action@557d74ea05b6cc0c47f555c1d5d28a89d904005b # v1.1.0 @@ -15,3 +22,78 @@ jobs: discourse-author-username: NetBird discourse-category: 17 discourse-tags: releases + + # goreleaser publishes the release of a pushed tag with GITHUB_TOKEN, and events + # raised by that token start no workflow, so the release event above never fires. + post-tag-release: + if: >- + github.event_name == 'workflow_run' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.conclusion == 'success' && + startsWith(github.event.workflow_run.head_branch, 'v') && + !contains(github.event.workflow_run.head_branch, '-') + runs-on: ubuntu-latest + steps: + - name: Post the release topic + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.workflow_run.head_branch }} + DISCOURSE_API_KEY: ${{ secrets.DISCOURSE_RELEASES_API_KEY }} + DISCOURSE_URL: https://forum.netbird.io + DISCOURSE_USER: NetBird + DISCOURSE_CATEGORY: "17" + run: | + set -euo pipefail + + if ! release=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" 2>gh.err); then + if grep -q 'HTTP 404' gh.err; then + echo "no release for ${TAG}, nothing to announce" + exit 0 + fi + cat gh.err >&2 + exit 1 + fi + + # A release a person published already fired the release event above. + author=$(jq -r '.author.login' <<<"$release") + if [ "$author" != "github-actions[bot]" ] || [ "$(jq -r '.draft' <<<"$release")" = "true" ]; then + echo "${TAG} published by ${author}, announced by the release event" + exit 0 + fi + + # Same title and body as the action above, so both paths read alike. + title="${GITHUB_REPOSITORY#*/} ${TAG} released" + + # A re-run of a successful Release run fires this event again. A failed lookup + # stops the step rather than risk a duplicate topic. + search=$(curl -fsS --retry 3 --proto '=https' --proto-redir '=https' -G \ + -H "Api-Key: ${DISCOURSE_API_KEY}" \ + -H "Api-Username: ${DISCOURSE_USER}" \ + "${DISCOURSE_URL}/search.json" \ + --data-urlencode "q=\"${title}\" in:title") + existing=$(jq -r --arg t "$title" --argjson c "$DISCOURSE_CATEGORY" \ + '[(.topics // [])[] | select(.category_id == $c and (.title | ascii_downcase) == ($t | ascii_downcase))] | length' \ + <<<"$search") + if [ "$existing" != "0" ]; then + echo "topic '${title}' already exists" + exit 0 + fi + + # goreleaser leaves the body empty; the notes are written on the release page afterwards. + url=$(jq -r '.html_url' <<<"$release") + body=$(jq -r '.body // ""' <<<"$release") + if [ -z "${body//[[:space:]]/}" ]; then + body="Release notes and downloads: ${url}" + fi + + jq -n \ + --arg title "$title" \ + --arg raw "$(printf '[%s](%s)\n\n%s' "$title" "$url" "$body")" \ + --argjson category "$DISCOURSE_CATEGORY" \ + '{title: $title, raw: $raw, category: $category, tags: ["releases"]}' \ + | curl -fsS -X POST "${DISCOURSE_URL}/posts.json" \ + -H "Api-Key: ${DISCOURSE_API_KEY}" \ + -H "Api-Username: ${DISCOURSE_USER}" \ + -H "Content-Type: application/json" \ + --data @- \ + | jq -r '"posted \(env.DISCOURSE_URL)/t/\(.topic_slug)/\(.topic_id)"' From 30ea8270d690f03eac77ea3dc1fe4dd5bcc7d5da Mon Sep 17 00:00:00 2001 From: Alireza Heydari <109125333+alireza-hdri@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:48:41 +0330 Subject: [PATCH 181/209] [client] Fix grammatical error in post-install message (#8242) --- release_files/post_install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release_files/post_install.sh b/release_files/post_install.sh index c763b7447..bd7e7feb8 100644 --- a/release_files/post_install.sh +++ b/release_files/post_install.sh @@ -19,7 +19,7 @@ restrict_env_file() { } cleanInstall() { - printf "\033[32m Post Install of an clean install\033[0m\n" + printf "\033[32m Post Install of a clean install\033[0m\n" restrict_env_file # Step 3 (clean install), enable the service in the proper way for this platform /usr/bin/netbird service install From 1f709707929da3fdbce27928e0c9618b8dc2953f Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Tue, 20 Oct 2026 13:33:46 +0300 Subject: [PATCH 182/209] [management] Mask credentials in logged URLs (#8250) --- combined/cmd/root.go | 8 ++++---- management/server/cache/redis.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/combined/cmd/root.go b/combined/cmd/root.go index 26d6ceedb..fa65f7b6d 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -758,7 +758,7 @@ func logAgentNetworkConfig(cfg *CombinedConfig) { // logEnvVars logs all NB_ environment variables that are currently set func logEnvVars() { - log.Info("=== Environment Variables ===") + log.Debug("=== Environment Variables ===") found := false for _, env := range os.Environ() { if strings.HasPrefix(env, "NB_") { @@ -770,14 +770,14 @@ func logEnvVars() { } else if strings.Contains(keyLower, "dsn") { value = maskDSNPassword(value) } - log.Infof(" %s=%s", key, value) + log.Debugf(" %s=%s", key, value) found = true } } if !found { - log.Info(" (none set)") + log.Debug(" (none set)") } - log.Info("=== End Environment Variables ===") + log.Debug("=== End Environment Variables ===") } // maskDSNPassword masks the password in a DSN string. diff --git a/management/server/cache/redis.go b/management/server/cache/redis.go index 0cd921c92..7aa3fb8e8 100644 --- a/management/server/cache/redis.go +++ b/management/server/cache/redis.go @@ -39,7 +39,7 @@ func getRedisStore(ctx context.Context, redisEnvAddr string, maxConn int) (Store return nil, err } - log.WithContext(subCtx).Infof("using redis cache at %s", redisEnvAddr) + log.WithContext(subCtx).Infof("using redis cache at %s", options.Addr) return &redisStore{ StoreInterface: redisstore.NewRedis(redisClient), From 7420c979303b0b0540356545ec87c1ec2bf37f12 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:35:15 +0200 Subject: [PATCH 183/209] [client] Keep the resolv.conf watch armed while repairing DNS in file mode (#8207) * [client] Split resolv.conf parsing from file reading Pure refactor, no behavior change. parseResolvConf parses content that was already read, so a caller can compare and parse the same snapshot of the file instead of reading it twice. * [client] Return the written content from the resolv.conf update function Pure refactor, no behavior change. The repair watcher needs to know what NetBird wrote so it can tell its own write apart from a third party's. Callers ignore the content for now. * [client] Keep the resolv.conf watch armed while repairing In file mode the repair watcher removed its inotify watch, rewrote resolv.conf and re-added the watch, without looking at the file again. A second third-party write landing in that window raised no event, so the file stayed on the upstream resolver until something else touched it. systemd-resolved with the stub listener off, the Amazon Linux 2023 default, writes the file twice in a row on restart and does not rewrite it on an unchanged DHCP renewal, so the host stayed off NetBird DNS while the client reported connected. The watch now stays armed during the repair. NetBird's own write is recognized by comparing the file with the content it wrote, which also covers a write whose search list was truncated to the resolv.conf limits and would otherwise fail the parameter check forever. The watcher checks the file once when it is armed, since a change made before that produced no event. * [client] Consume inotify errors in the resolv.conf watcher fsnotify blocks its reader until an error is received. The watcher never read the Errors channel, so a single error, such as an event queue overflow, stopped all further events and silently disabled the repair. The watcher now consumes errors and checks the file, since an overflow means events were dropped. * [client] Parse resolv.conf one line at a time Pure refactor, no behavior change. The per-line handling moves into parseResolvConfLine, which keeps parseResolvConf under the cognitive complexity limit. * [client] Drop the comment restating parseResolvConfLine --- client/internal/dns/file_parser_unix.go | 114 +++++++------ client/internal/dns/file_repair_unix.go | 107 +++++++----- client/internal/dns/file_repair_unix_test.go | 165 ++++++++++++++++++- client/internal/dns/file_unix.go | 10 +- 4 files changed, 297 insertions(+), 99 deletions(-) diff --git a/client/internal/dns/file_parser_unix.go b/client/internal/dns/file_parser_unix.go index 50ba74c0c..2ef05e3fd 100644 --- a/client/internal/dns/file_parser_unix.go +++ b/client/internal/dns/file_parser_unix.go @@ -35,11 +35,7 @@ func parseBackupResolvConf() (*resolvConf, error) { } func parseResolvConfFile(resolvConfFile string) (*resolvConf, error) { - rconf := &resolvConf{ - searchDomains: make([]string, 0), - nameServers: make([]netip.Addr, 0), - others: make([]string, 0), - } + rconf := newResolvConf() file, err := os.Open(resolvConfFile) if err != nil { @@ -56,56 +52,74 @@ func parseResolvConfFile(resolvConfFile string) (*resolvConf, error) { return rconf, fmt.Errorf("failed to read %s file: %w", resolvConfFile, err) } + return parseResolvConf(cur) +} + +// parseResolvConf parses resolv.conf content. The returned config is never nil, so callers can +// fall back to its empty lists on error. +func parseResolvConf(cur []byte) (*resolvConf, error) { + rconf := newResolvConf() + if len(cur) == 0 { return rconf, fmt.Errorf("file is empty") } for _, line := range strings.Split(string(cur), "\n") { - line = strings.TrimSpace(line) - - if strings.HasPrefix(line, "#") { - continue - } - - if strings.HasPrefix(line, "domain") { - continue - } - - if strings.HasPrefix(line, "options") && strings.Contains(line, "rotate") { - line = strings.ReplaceAll(line, "rotate", "") - splitLines := strings.Fields(line) - if len(splitLines) == 1 { - continue - } - line = strings.Join(splitLines, " ") - } - - if strings.HasPrefix(line, "search") { - splitLines := strings.Fields(line) - if len(splitLines) < 2 { - continue - } - - rconf.searchDomains = splitLines[1:] - continue - } - - if strings.HasPrefix(line, "nameserver") { - splitLines := strings.Fields(line) - if len(splitLines) != 2 { - continue - } - if addr, err := netip.ParseAddr(splitLines[1]); err == nil { - rconf.nameServers = append(rconf.nameServers, addr.Unmap()) - } else { - log.Warnf("invalid nameserver address in resolv.conf: %s, skipping", splitLines[1]) - } - continue - } - - if line != "" { - rconf.others = append(rconf.others, line) - } + parseResolvConfLine(rconf, strings.TrimSpace(line)) } return rconf, nil } + +func parseResolvConfLine(rconf *resolvConf, line string) { + if strings.HasPrefix(line, "#") { + return + } + + if strings.HasPrefix(line, "domain") { + return + } + + if strings.HasPrefix(line, "options") && strings.Contains(line, "rotate") { + line = strings.ReplaceAll(line, "rotate", "") + splitLines := strings.Fields(line) + if len(splitLines) == 1 { + return + } + line = strings.Join(splitLines, " ") + } + + if strings.HasPrefix(line, "search") { + splitLines := strings.Fields(line) + if len(splitLines) < 2 { + return + } + + rconf.searchDomains = splitLines[1:] + return + } + + if strings.HasPrefix(line, "nameserver") { + splitLines := strings.Fields(line) + if len(splitLines) != 2 { + return + } + if addr, err := netip.ParseAddr(splitLines[1]); err == nil { + rconf.nameServers = append(rconf.nameServers, addr.Unmap()) + } else { + log.Warnf("invalid nameserver address in resolv.conf: %s, skipping", splitLines[1]) + } + return + } + + if line != "" { + rconf.others = append(rconf.others, line) + } +} + +func newResolvConf() *resolvConf { + return &resolvConf{ + searchDomains: make([]string, 0), + nameServers: make([]netip.Addr, 0), + others: make([]string, 0), + } +} diff --git a/client/internal/dns/file_repair_unix.go b/client/internal/dns/file_repair_unix.go index 0846dbf38..57255fb56 100644 --- a/client/internal/dns/file_repair_unix.go +++ b/client/internal/dns/file_repair_unix.go @@ -3,7 +3,9 @@ package dns import ( + "bytes" "net/netip" + "os" "path" "path/filepath" "sync" @@ -23,7 +25,8 @@ var ( } ) -type repairConfFn func([]string, netip.Addr, *resolvConf, *statemanager.Manager) error +// repairConfFn writes the NetBird-managed resolv.conf and returns the exact content it wrote. +type repairConfFn func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) type repair struct { operationFile string @@ -43,7 +46,9 @@ func newRepair(operationFile string, updateFn repairConfFn) *repair { } } -func (f *repair) watchFileChanges(nbSearchDomains []string, nbNameserverIP netip.Addr, stateManager *statemanager.Manager) { +// watchFileChanges keeps resolv.conf pointing at NetBird. written is the content NetBird last +// wrote to the file, used to recognize the echo of its own writes. +func (f *repair) watchFileChanges(nbSearchDomains []string, nbNameserverIP netip.Addr, written []byte, stateManager *statemanager.Manager) { if f.inotify != nil { return } @@ -54,54 +59,80 @@ func (f *repair) watchFileChanges(nbSearchDomains []string, nbNameserverIP netip log.Errorf("failed to start inotify watcher for resolv.conf: %s", err) return } + + if err := inotify.Add(f.watchDir); err != nil { + log.Errorf("failed to add inotify watch for resolv.conf: %s", err) + if err := inotify.Close(); err != nil { + log.Debugf("failed to close resolv.conf inotify: %v", err) + } + return + } f.inotify = inotify f.inotifyWg.Add(1) go func() { defer f.inotifyWg.Done() - for event := range f.inotify.Events { - if !f.isEventRelevant(event) { - continue - } - log.Tracef("%s changed, check if it is broken", f.operationFile) - - rConf, err := parseResolvConfFile(f.operationFile) - if err != nil { - log.Warnf("failed to parse resolv conf: %s", err) - continue - } - - log.Debugf("check resolv.conf parameters: %s", rConf) - if !isNbParamsMissing(nbSearchDomains, nbNameserverIP, rConf) { - log.Tracef("resolv.conf still correct, skip the update") - continue - } - log.Info("broken params in resolv.conf, repairing it...") - - err = f.inotify.Remove(f.watchDir) - if err != nil { - log.Errorf("failed to rm inotify watch for resolv.conf: %s", err) - } - - err = f.updateFn(nbSearchDomains, nbNameserverIP, rConf, stateManager) - if err != nil { - log.Errorf("failed to repair resolv.conf: %v", err) - } - - err = f.inotify.Add(f.watchDir) - if err != nil { - log.Errorf("failed to re-add inotify watch for resolv.conf: %s", err) - return + // A change that landed before the watch was armed produced no event. + lastWritten := f.checkAndRepair(nbSearchDomains, nbNameserverIP, written, stateManager) + for { + select { + case event, ok := <-inotify.Events: + if !ok { + return + } + if !f.isEventRelevant(event) { + continue + } + case err, ok := <-inotify.Errors: + if !ok { + return + } + // fsnotify stops delivering events until an error is received, and an + // error such as a queue overflow means events were dropped. + log.Warnf("resolv.conf inotify error, checking the file: %v", err) } + lastWritten = f.checkAndRepair(nbSearchDomains, nbNameserverIP, lastWritten, stateManager) } }() +} - err = f.inotify.Add(f.watchDir) +// checkAndRepair rewrites resolv.conf when NetBird's parameters are missing from it and returns +// the content NetBird last wrote. The watch stays armed while repairing, so a third-party write +// racing the repair still raises an event; the echo of NetBird's own write is skipped by content. +func (f *repair) checkAndRepair(nbSearchDomains []string, nbNameserverIP netip.Addr, lastWritten []byte, stateManager *statemanager.Manager) []byte { + content, err := os.ReadFile(f.operationFile) if err != nil { - log.Errorf("failed to add inotify watch for resolv.conf: %s", err) - return + log.Warnf("failed to read resolv.conf: %s", err) + return lastWritten } + + if bytes.Equal(content, lastWritten) { + log.Tracef("%s still holds the NetBird managed content, skip the check", f.operationFile) + return lastWritten + } + + log.Tracef("%s changed, check if it is broken", f.operationFile) + + rConf, err := parseResolvConf(content) + if err != nil { + log.Warnf("failed to parse resolv conf: %s", err) + return lastWritten + } + + log.Debugf("check resolv.conf parameters: %s", rConf) + if !isNbParamsMissing(nbSearchDomains, nbNameserverIP, rConf) { + log.Tracef("resolv.conf still correct, skip the update") + return lastWritten + } + log.Info("broken params in resolv.conf, repairing it...") + + written, err := f.updateFn(nbSearchDomains, nbNameserverIP, rConf, stateManager) + if err != nil { + log.Errorf("failed to repair resolv.conf: %v", err) + return lastWritten + } + return written } func (f *repair) stopWatchFileChanges() { diff --git a/client/internal/dns/file_repair_unix_test.go b/client/internal/dns/file_repair_unix_test.go index f22081307..c771ff641 100644 --- a/client/internal/dns/file_repair_unix_test.go +++ b/client/internal/dns/file_repair_unix_test.go @@ -4,12 +4,19 @@ package dns import ( "context" + "fmt" "net/netip" "os" "path/filepath" + "strings" + "sync/atomic" "testing" "time" + "github.com/fsnotify/fsnotify" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/client/internal/statemanager" "github.com/netbirdio/netbird/util" ) @@ -106,14 +113,14 @@ nameserver 8.8.8.8`, var changed bool ctx, cancel := context.WithTimeout(context.Background(), time.Second) - updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) error { + updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) { changed = true cancel() - return nil + return nil, nil } r := newRepair(operationFile, updateFn) - r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), nil) + r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), nil, nil) err = os.WriteFile(operationFile, []byte(tt.touchedConfContent), 0755) if err != nil { @@ -153,14 +160,14 @@ searchdomain netbird.cloud something` var changed bool ctx, cancel := context.WithTimeout(context.Background(), time.Second) - updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) error { + updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) { changed = true cancel() - return nil + return nil, nil } r := newRepair(tmpLink, updateFn) - r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), nil) + r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), nil, nil) err = os.WriteFile(tmpLink, []byte(modifyContent), 0755) if err != nil { @@ -175,3 +182,149 @@ searchdomain netbird.cloud something` t.Errorf("unexpected result: want: %v, got: %v", true, false) } } + +// systemd-resolved can rewrite resolv.conf twice in a row, e.g. on restart. When the second write +// lands right after NetBird's repair, the watcher must still see it and repair again; otherwise the +// host silently stays on the upstream resolver until the next unrelated change of the file. +func Test_repairCatchesWriteRacingRepair(t *testing.T) { + good := "nameserver 10.0.0.1\nsearch netbird.cloud\n" + upstream := "nameserver 8.8.8.8\nsearch .\n" + operationFile := filepath.Join(t.TempDir(), "resolv.conf") + require.NoError(t, os.WriteFile(operationFile, []byte(good), 0644)) + + var calls atomic.Int32 + updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) { + n := calls.Add(1) + if err := os.WriteFile(operationFile, []byte(good), 0644); err != nil { + return nil, err + } + if n == 1 { + // the third party's second write lands right after the repair + if err := writeFileAtomic(operationFile, upstream); err != nil { + return nil, err + } + } + return []byte(good), nil + } + + r := newRepair(operationFile, updateFn) + r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), []byte(good), nil) + t.Cleanup(r.stopWatchFileChanges) + + require.NoError(t, writeFileAtomic(operationFile, upstream)) + + assert.Eventually(t, func() bool { + content, err := os.ReadFile(operationFile) + return err == nil && string(content) == good + }, 2*time.Second, 10*time.Millisecond, "resolv.conf should be repaired after the racing write") + assert.GreaterOrEqual(t, calls.Load(), int32(2), "the racing write should trigger a second repair") +} + +// A change made between NetBird's write and the arming of the watch produces no event, so the +// watcher must check the file as soon as it starts. +func Test_repairChecksFileWhenArmed(t *testing.T) { + good := "nameserver 10.0.0.1\nsearch netbird.cloud\n" + operationFile := filepath.Join(t.TempDir(), "resolv.conf") + require.NoError(t, os.WriteFile(operationFile, []byte("nameserver 8.8.8.8\n"), 0644)) + + repaired := make(chan struct{}, 1) + updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) { + select { + case repaired <- struct{}{}: + default: + } + return []byte(good), os.WriteFile(operationFile, []byte(good), 0644) + } + + r := newRepair(operationFile, updateFn) + r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), []byte(good), nil) + t.Cleanup(r.stopWatchFileChanges) + + select { + case <-repaired: + case <-time.After(2 * time.Second): + t.Fatal("watcher should repair a file changed before it was armed") + } +} + +// When NetBird has more search domains than resolv.conf can hold, its own write lacks some of them +// and fails the parameter check. The watcher must recognize that write as its own and not repair +// it again in a loop. +func Test_repairNoLoopOnTruncatedSearchDomains(t *testing.T) { + nbDomains := make([]string, 0, fileMaxNumberOfSearchDomains+2) + for i := range fileMaxNumberOfSearchDomains + 2 { + nbDomains = append(nbDomains, fmt.Sprintf("d%d.netbird.cloud", i)) + } + truncated := fmt.Sprintf("nameserver 10.0.0.1\nsearch %s\n", strings.Join(nbDomains[:fileMaxNumberOfSearchDomains], " ")) + + operationFile := filepath.Join(t.TempDir(), "resolv.conf") + require.NoError(t, os.WriteFile(operationFile, []byte(truncated), 0644)) + + var calls atomic.Int32 + updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) { + calls.Add(1) + return []byte(truncated), os.WriteFile(operationFile, []byte(truncated), 0644) + } + + r := newRepair(operationFile, updateFn) + r.watchFileChanges(nbDomains, netip.MustParseAddr("10.0.0.1"), []byte(truncated), nil) + t.Cleanup(r.stopWatchFileChanges) + + require.NoError(t, writeFileAtomic(operationFile, "nameserver 8.8.8.8\n")) + + require.Eventually(t, func() bool { return calls.Load() >= 1 }, 2*time.Second, 10*time.Millisecond, + "the third-party write should be repaired") + time.Sleep(500 * time.Millisecond) + assert.Equal(t, int32(1), calls.Load(), "NetBird's own truncated write should not trigger another repair") +} + +// fsnotify blocks its reader until an error is received, and an error such as a queue overflow +// means events were dropped. The watcher must consume errors and check the file, so it keeps working +// and catches a change whose event was lost. +func Test_repairChecksFileOnWatcherError(t *testing.T) { + good := "nameserver 10.0.0.1\nsearch netbird.cloud\n" + operationFile := filepath.Join(t.TempDir(), "resolv.conf") + require.NoError(t, os.WriteFile(operationFile, []byte(good), 0644)) + + // A hard link in another directory changes the file without an event in the watched one, + // standing in for an event the kernel dropped. + alias := filepath.Join(t.TempDir(), "alias") + require.NoError(t, os.Link(operationFile, alias)) + + repaired := make(chan struct{}, 1) + updateFn := func([]string, netip.Addr, *resolvConf, *statemanager.Manager) ([]byte, error) { + select { + case repaired <- struct{}{}: + default: + } + return []byte(good), os.WriteFile(operationFile, []byte(good), 0644) + } + + r := newRepair(operationFile, updateFn) + r.watchFileChanges([]string{"netbird.cloud"}, netip.MustParseAddr("10.0.0.1"), []byte(good), nil) + t.Cleanup(r.stopWatchFileChanges) + + require.NoError(t, os.WriteFile(alias, []byte("nameserver 8.8.8.8\n"), 0644)) + + select { + case r.inotify.Errors <- fsnotify.ErrEventOverflow: + case <-time.After(2 * time.Second): + t.Fatal("watcher should consume inotify errors") + } + + select { + case <-repaired: + case <-time.After(2 * time.Second): + t.Fatal("watcher should check the file after an inotify error") + } +} + +// writeFileAtomic replaces the file the way systemd-resolved does: write a temporary file in the same +// directory, then rename it over the target. +func writeFileAtomic(target, content string) error { + tmp := target + ".tmp" + if err := os.WriteFile(tmp, []byte(content), 0644); err != nil { + return err + } + return os.Rename(tmp, target) +} diff --git a/client/internal/dns/file_unix.go b/client/internal/dns/file_unix.go index 45e621443..52f221d10 100644 --- a/client/internal/dns/file_unix.go +++ b/client/internal/dns/file_unix.go @@ -61,11 +61,11 @@ func (f *fileConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *st f.repair.stopWatchFileChanges() - err = f.updateConfig(nbSearchDomains, f.nbNameserverIP, resolvConf, stateManager) + written, err := f.updateConfig(nbSearchDomains, f.nbNameserverIP, resolvConf, stateManager) if err != nil { return err } - f.repair.watchFileChanges(nbSearchDomains, f.nbNameserverIP, stateManager) + f.repair.watchFileChanges(nbSearchDomains, f.nbNameserverIP, written, stateManager) return nil } @@ -74,7 +74,7 @@ func (f *fileConfigurator) getOriginalNameservers() []netip.Addr { return f.originalNameservers } -func (f *fileConfigurator) updateConfig(nbSearchDomains []string, nbNameserverIP netip.Addr, cfg *resolvConf, stateManager *statemanager.Manager) error { +func (f *fileConfigurator) updateConfig(nbSearchDomains []string, nbNameserverIP netip.Addr, cfg *resolvConf, stateManager *statemanager.Manager) ([]byte, error) { searchDomainList := mergeSearchDomains(nbSearchDomains, cfg.searchDomains) buf := prepareResolvConfContent( @@ -90,7 +90,7 @@ func (f *fileConfigurator) updateConfig(nbSearchDomains []string, nbNameserverIP if restoreErr != nil { log.Errorf("attempt to restore default file failed with error: %s", err) } - return fmt.Errorf("creating resolver file %s. Error: %w", defaultResolvConfPath, err) + return nil, fmt.Errorf("creating resolver file %s. Error: %w", defaultResolvConfPath, err) } log.Infof("created a NetBird managed %s file with the DNS settings. Added %d search domains. Search list: %s", defaultResolvConfPath, len(searchDomainList), searchDomainList) @@ -100,7 +100,7 @@ func (f *fileConfigurator) updateConfig(nbSearchDomains []string, nbNameserverIP log.Errorf("failed to create unclean shutdown resolv.conf backup: %s", err) } - return nil + return buf.Bytes(), nil } func (f *fileConfigurator) restoreHostDNS() error { From b866d1584c9989f0ad8b4ccc319e189aead66a6e Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Tue, 20 Oct 2026 14:07:00 +0200 Subject: [PATCH 184/209] [client] Drop the non-functional admin URL (#8267) * [client] Drop the non-functional admin URL #4218 deprecated --admin-url, but #3980 merged right after it and restored the flag and its wiring. The value has been stored and echoed back ever since, without anything reading it. Stop sending it from the CLI, ignore it in the daemon and drop it from the profile config, the UI settings service and the debug bundle. The proto fields stay for older clients, and the mobile getters and setters keep their signatures as deprecated stubs. * [client] Drop adminUrl from the Wails API reference --- client/android/preferences.go | 22 +++----- client/android/preferences_test.go | 5 +- client/cmd/root.go | 3 +- client/cmd/up.go | 1 - client/internal/debug/debug.go | 7 --- client/internal/debug/debug_test.go | 10 +--- client/internal/profilemanager/config.go | 50 ++----------------- client/internal/profilemanager/config_test.go | 6 --- .../config_would_change_test.go | 50 ++++++++----------- client/ios/NetBirdSDK/preferences.go | 22 +++----- client/ios/NetBirdSDK/preferences_test.go | 5 +- client/server/server.go | 5 -- client/server/setconfig_test.go | 10 ++-- client/ui/frontend/WAILS-API.md | 6 +-- client/ui/services/settings.go | 4 -- 15 files changed, 60 insertions(+), 146 deletions(-) diff --git a/client/android/preferences.go b/client/android/preferences.go index 3623de23f..af37682c5 100644 --- a/client/android/preferences.go +++ b/client/android/preferences.go @@ -58,23 +58,17 @@ func (p *Preferences) SetManagementURL(url string) { p.configInput.ManagementURL = url } -// GetAdminURL reads URL from config file +// GetAdminURL returns the default admin panel URL. +// +// Deprecated: the admin URL is no longer stored or used by the client. func (p *Preferences) GetAdminURL() (string, error) { - if p.configInput.AdminURL != "" { - return p.configInput.AdminURL, nil - } - - cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) - if err != nil { - return "", err - } - return cfg.AdminURL.String(), err + return profilemanager.DefaultAdminURL, nil } -// SetAdminURL stores the given URL and waits for commit -func (p *Preferences) SetAdminURL(url string) { - p.configInput.AdminURL = url -} +// SetAdminURL is a no-op kept for API compatibility. +// +// Deprecated: the admin URL is no longer stored or used by the client. +func (p *Preferences) SetAdminURL(_ string) {} // HasPreSharedKey reports whether a pre-shared key is staged, persisted, or // enforced by MDM; the key itself is never handed to the native layer. diff --git a/client/android/preferences_test.go b/client/android/preferences_test.go index d9f5b1918..98670afe0 100644 --- a/client/android/preferences_test.go +++ b/client/android/preferences_test.go @@ -43,13 +43,14 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) { cfgFile := filepath.Join(t.TempDir(), "netbird.json") p := NewPreferences(cfgFile) + // The admin URL is deprecated: setting it is a no-op. p.SetAdminURL(exampleString) resp, err := p.GetAdminURL() if err != nil { t.Fatalf("failed to read admin url: %s", err) } - if resp != exampleString { + if resp != profilemanager.DefaultAdminURL { t.Errorf("unexpected admin url: %s", resp) } @@ -95,7 +96,7 @@ func TestPreferences_Commit(t *testing.T) { t.Fatalf("failed to read admin url: %s", err) } - if resp != exampleURL { + if resp != profilemanager.DefaultAdminURL { t.Errorf("unexpected admin url: %s", resp) } diff --git a/client/cmd/root.go b/client/cmd/root.go index 51179f77c..f976ca895 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -157,7 +157,8 @@ func init() { rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]") rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL)) - rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL)) + rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", "(DEPRECATED) Admin Panel URL - This flag is no longer functional") + _ = rootCmd.PersistentFlags().MarkDeprecated("admin-url", "the admin-url flag is no longer functional and will be removed in a future version") rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level") rootCmd.PersistentFlags().StringSliceVar(&logFiles, "log-file", []string{defaultLogFile}, "sets NetBird log paths written to simultaneously. If `console` is specified the log will be output to stdout. If `syslog` is specified the log will be sent to syslog daemon. You can pass the flag multiple times or separate entries by `,` character") rootCmd.PersistentFlags().StringVarP(&setupKey, "setup-key", "k", "", "Setup key obtained from the Management Service Dashboard (used to register peer)") diff --git a/client/cmd/up.go b/client/cmd/up.go index e336ba257..1b5a4a6cd 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -493,7 +493,6 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro req.Username = username req.ManagementUrl = managementURL - req.AdminURL = adminURL req.NatExternalIPs = natExternalIPs req.CustomDNSAddress = customDNSAddressConverted req.ExtraIFaceBlacklist = extraIFaceBlackList diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 95419b0e9..5e8badfc5 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -174,7 +174,6 @@ The IP addresses in the interfaces file are anonymized using the same process as Configuration The config.txt file contains anonymized configuration information of the NetBird client. Sensitive information such as private keys and SSH keys are excluded. The following fields are anonymized: - ManagementURL -- AdminURL - NATExternalIPs - CustomDNSAddress @@ -602,9 +601,6 @@ func (g *BundleGenerator) addConfig() error { if g.internalConfig.ManagementURL != nil { configContent.WriteString(fmt.Sprintf("ManagementURL: %s\n", g.anonymizer.AnonymizeURI(g.internalConfig.ManagementURL.String()))) } - if g.internalConfig.AdminURL != nil { - configContent.WriteString(fmt.Sprintf("AdminURL: %s\n", g.anonymizer.AnonymizeURI(g.internalConfig.AdminURL.String()))) - } configContent.WriteString(fmt.Sprintf("NATExternalIPs: %v\n", anonymizeNATExternalIPs(g.internalConfig.NATExternalIPs, g.anonymizer))) if g.internalConfig.CustomDNSAddress != "" { configContent.WriteString(fmt.Sprintf("CustomDNSAddress: %s\n", g.anonymizer.AnonymizeString(g.internalConfig.CustomDNSAddress))) @@ -613,9 +609,6 @@ func (g *BundleGenerator) addConfig() error { if g.internalConfig.ManagementURL != nil { configContent.WriteString(fmt.Sprintf("ManagementURL: %s\n", g.internalConfig.ManagementURL.String())) } - if g.internalConfig.AdminURL != nil { - configContent.WriteString(fmt.Sprintf("AdminURL: %s\n", g.internalConfig.AdminURL.String())) - } configContent.WriteString(fmt.Sprintf("NATExternalIPs: %v\n", g.internalConfig.NATExternalIPs)) if g.internalConfig.CustomDNSAddress != "" { configContent.WriteString(fmt.Sprintf("CustomDNSAddress: %s\n", g.internalConfig.CustomDNSAddress)) diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index a9cda5a46..93886c5e7 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -856,14 +856,12 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { } mURL, _ := url.Parse("https://api.example.com:443") - aURL, _ := url.Parse("https://admin.example.com:443") bTrue := true iVal := 42 cfg := &profilemanager.Config{ PrivateKey: "priv", PreSharedKey: "psk", ManagementURL: mURL, - AdminURL: aURL, WgIface: "wt0", WgPort: 51820, NetworkMonitor: &bTrue, @@ -947,7 +945,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { } // renderAddConfigSpecific renders the fields handled by the anonymize/non-anonymize -// branches in addConfig (ManagementURL, AdminURL, NATExternalIPs, CustomDNSAddress). +// branches in addConfig (ManagementURL, NATExternalIPs, CustomDNSAddress). // addCommonConfigFields covers the rest. Keeping this in the test mirrors the // production shape without needing to write an actual zip. func renderAddConfigSpecific(g *BundleGenerator) string { @@ -956,9 +954,6 @@ func renderAddConfigSpecific(g *BundleGenerator) string { if g.internalConfig.ManagementURL != nil { sb.WriteString("ManagementURL: " + g.anonymizer.AnonymizeURI(g.internalConfig.ManagementURL.String()) + "\n") } - if g.internalConfig.AdminURL != nil { - sb.WriteString("AdminURL: " + g.anonymizer.AnonymizeURI(g.internalConfig.AdminURL.String()) + "\n") - } sb.WriteString("NATExternalIPs: x\n") if g.internalConfig.CustomDNSAddress != "" { sb.WriteString("CustomDNSAddress: " + g.anonymizer.AnonymizeString(g.internalConfig.CustomDNSAddress) + "\n") @@ -967,9 +962,6 @@ func renderAddConfigSpecific(g *BundleGenerator) string { if g.internalConfig.ManagementURL != nil { sb.WriteString("ManagementURL: " + g.internalConfig.ManagementURL.String() + "\n") } - if g.internalConfig.AdminURL != nil { - sb.WriteString("AdminURL: " + g.internalConfig.AdminURL.String() + "\n") - } sb.WriteString("NATExternalIPs: x\n") if g.internalConfig.CustomDNSAddress != "" { sb.WriteString("CustomDNSAddress: " + g.internalConfig.CustomDNSAddress + "\n") diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index 3d378a9e6..6c797a184 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -36,7 +36,8 @@ const ( DefaultManagementURL = "https://api.netbird.io:443" // oldDefaultManagementURL points to the NetBird's old cloud management endpoint oldDefaultManagementURL = "https://api.wiretrustee.com:443" - // DefaultAdminURL points to NetBird's cloud management console + // DefaultAdminURL points to NetBird's cloud management console. It is only + // reported by the deprecated mobile admin URL getters. DefaultAdminURL = "https://app.netbird.io:443" ) @@ -60,7 +61,6 @@ var DefaultInterfaceBlacklist = []string{ // ConfigInput carries configuration changes to the client type ConfigInput struct { ManagementURL string - AdminURL string ConfigPath string StateFilePath string PreSharedKey *string @@ -114,7 +114,6 @@ type Config struct { PrivateKey string PreSharedKey string ManagementURL *url.URL - AdminURL *url.URL WgIface string WgPort int NetworkMonitor *bool @@ -508,29 +507,6 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { } } - if config.AdminURL == nil { - log.Infof("using default Admin URL %s", DefaultAdminURL) - config.AdminURL, err = parseURL("Admin URL", DefaultAdminURL) - if err != nil { - return false, err - } - } - // The admin panel is opened, not dialed, so unlike the Management URL its - // path is part of what identifies it: a panel served under /netbird is not - // the one served at the root. - if input.AdminURL != "" { - newURL, err := parseURL("Admin Panel URL", input.AdminURL) - if err != nil { - return updated, err - } - if !SameServiceURLIncludingPath(newURL, config.AdminURL) { - log.Infof("new Admin Panel URL provided, updated to %#v (old value %#v)", - newURL.String(), config.AdminURL.String()) - config.AdminURL = newURL - updated = true - } - } - if input.WireguardPort != nil && *input.WireguardPort != config.WgPort { log.Infof("updating Wireguard port %d (old value %d)", *input.WireguardPort, config.WgPort) @@ -1027,8 +1003,7 @@ func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) { // matches only another nil one. // // The path plays no part: a management URL is dialed, and only its host and -// port are. util.SameServiceURL is this comparison plus the path, which is -// what SameServiceURLIncludingPath needs and delegates to. +// port are. util.SameServiceURL is this comparison plus the path. func SameServiceURL(a, b *url.URL) bool { if a == nil || b == nil { return a == b @@ -1039,25 +1014,6 @@ func SameServiceURL(a, b *url.URL) bool { util.ServiceURLPort(a) == util.ServiceURLPort(b) } -// SameServiceURLIncludingPath is SameServiceURL plus everything a URL carries -// past its endpoint: path, query, fragment and userinfo. -// -// Use it for a URL that gets opened rather than dialed. The admin panel can -// live under a path, so two URLs with the same endpoint and different paths are -// two different panels — where for a URL the client dials over gRPC only the -// endpoint is ever used. Equivalent spellings still compare equal: a missing -// path and "/" are the same root, and so is a trailing slash on any path. -func SameServiceURLIncludingPath(a, b *url.URL) bool { - if a == nil || b == nil { - return a == b - } - - return util.SameServiceURL(a, b) && - a.RawQuery == b.RawQuery && - a.Fragment == b.Fragment && - a.User.String() == b.User.String() -} - func parseURL(serviceName, serviceURL string) (*url.URL, error) { parsedMgmtURL, err := url.ParseRequestURI(serviceURL) if err != nil { diff --git a/client/internal/profilemanager/config_test.go b/client/internal/profilemanager/config_test.go index a461aa71f..192fc8ec8 100644 --- a/client/internal/profilemanager/config_test.go +++ b/client/internal/profilemanager/config_test.go @@ -36,17 +36,14 @@ func TestGetConfig(t *testing.T) { } assert.Equal(t, config.ManagementURL.String(), DefaultManagementURL) - assert.Equal(t, config.AdminURL.String(), DefaultAdminURL) managementURL := "https://test.management.url:33071" - adminURL := "https://app.admin.url:443" path := filepath.Join(t.TempDir(), "config.json") preSharedKey := "preSharedKey" // case 2: new config has to be generated config, err = UpdateOrCreateConfig(ConfigInput{ ManagementURL: managementURL, - AdminURL: adminURL, ConfigPath: path, PreSharedKey: &preSharedKey, }) @@ -64,7 +61,6 @@ func TestGetConfig(t *testing.T) { // case 3: existing config -> fetch it config, err = UpdateOrCreateConfig(ConfigInput{ ManagementURL: managementURL, - AdminURL: adminURL, ConfigPath: path, PreSharedKey: &preSharedKey, }) @@ -79,7 +75,6 @@ func TestGetConfig(t *testing.T) { newManagementURL := "https://test.newManagement.url:33071" config, err = UpdateOrCreateConfig(ConfigInput{ ManagementURL: newManagementURL, - AdminURL: adminURL, ConfigPath: path, PreSharedKey: &preSharedKey, }) @@ -164,7 +159,6 @@ func TestNewProfileDefaults(t *testing.T) { require.NoError(t, err, "should create new config") assert.Equal(t, DefaultManagementURL, config.ManagementURL.String(), "ManagementURL should have default") - assert.Equal(t, DefaultAdminURL, config.AdminURL.String(), "AdminURL should have default") assert.NotEmpty(t, config.PrivateKey, "PrivateKey should be generated") assert.NotEmpty(t, config.SSHKey, "SSHKey should be generated") assert.Equal(t, iface.WgInterfaceDefault, config.WgIface, "WgIface should have default") diff --git a/client/internal/profilemanager/config_would_change_test.go b/client/internal/profilemanager/config_would_change_test.go index 6b140030f..3efa2fb05 100644 --- a/client/internal/profilemanager/config_would_change_test.go +++ b/client/internal/profilemanager/config_would_change_test.go @@ -334,40 +334,34 @@ func TestCreateInMemoryConfigCarriesAnIdentity(t *testing.T) { require.NotEmpty(t, cfg.SSHKey) } -// The admin panel is opened, not dialed, so its path identifies it. Comparing -// it as a bare endpoint left a custom panel URL unable to change. -func TestAdminURLPathIsPartOfTheIdentity(t *testing.T) { - path := filepath.Join(t.TempDir(), "panel.json") - _, err := UpdateOrCreateConfig(ConfigInput{ - ConfigPath: path, - AdminURL: "https://app.example.com/netbird", - }) +// Profiles written before the admin URL was dropped still carry an AdminURL key. +// It must not break loading, and the next write must drop it. +func TestLegacyAdminURLIsIgnored(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.json") + _, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: path}) require.NoError(t, err) - cfg, err := GetExistingConfig(path) + raw, err := os.ReadFile(path) require.NoError(t, err) - require.Equal(t, "https://app.example.com:443/netbird", cfg.AdminURL.String()) - - // Equivalent spellings of the same panel are still not a change. - for _, same := range []string{ - "https://app.example.com/netbird", - "https://app.example.com:443/netbird", - "https://app.example.com/netbird/", - "https://APP.example.com/netbird", - } { - changed, err := cfg.WouldChange(ConfigInput{AdminURL: same}) - require.NoError(t, err) - require.False(t, changed, "%q is the stored panel written differently", same) - } - - // A different path is a different panel, and it must be persisted. - changed, err := cfg.WouldChange(ConfigInput{AdminURL: "https://app.example.com/other"}) + var stored map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &stored)) + stored["AdminURL"] = json.RawMessage(`{"Scheme":"https","Host":"panel.example.com:443","Path":"/netbird"}`) + rewritten, err := json.Marshal(stored) require.NoError(t, err) - require.True(t, changed, "a different panel path is a change") + require.NoError(t, os.WriteFile(path, rewritten, 0600)) - updated, err := UpdateConfig(ConfigInput{ConfigPath: path, AdminURL: "https://app.example.com/other"}) + _, err = GetExistingConfig(path) + require.NoError(t, err, "a legacy AdminURL key must not prevent loading the profile") + + port := 51999 + _, err = UpdateConfig(ConfigInput{ConfigPath: path, WireguardPort: &port}) require.NoError(t, err) - require.Equal(t, "https://app.example.com:443/other", updated.AdminURL.String(), "the new panel path was not persisted") + + raw, err = os.ReadFile(path) + require.NoError(t, err) + stored = nil + require.NoError(t, json.Unmarshal(raw, &stored)) + require.NotContains(t, stored, "AdminURL", "the legacy AdminURL key should be dropped on write") } // unsetOnDisk rewrites the stored config so the named fields carry a JSON null, diff --git a/client/ios/NetBirdSDK/preferences.go b/client/ios/NetBirdSDK/preferences.go index 642f9e160..4209a5654 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -61,23 +61,17 @@ func (p *Preferences) SetManagementURL(url string) { p.configInput.ManagementURL = url } -// GetAdminURL read url from config file +// GetAdminURL returns the default admin panel URL. +// +// Deprecated: the admin URL is no longer stored or used by the client. func (p *Preferences) GetAdminURL() (string, error) { - if p.configInput.AdminURL != "" { - return p.configInput.AdminURL, nil - } - - cfg, err := profilemanager.ReadConfigOrDefault(p.configInput.ConfigPath) - if err != nil { - return "", err - } - return cfg.AdminURL.String(), err + return profilemanager.DefaultAdminURL, nil } -// SetAdminURL store the given url and wait for commit -func (p *Preferences) SetAdminURL(url string) { - p.configInput.AdminURL = url -} +// SetAdminURL is a no-op kept for API compatibility. +// +// Deprecated: the admin URL is no longer stored or used by the client. +func (p *Preferences) SetAdminURL(_ string) {} // HasPreSharedKey reports whether a pre-shared key is staged, persisted, or // enforced by MDM; the key itself is never handed to the native layer. diff --git a/client/ios/NetBirdSDK/preferences_test.go b/client/ios/NetBirdSDK/preferences_test.go index 2382e123c..2deb996b2 100644 --- a/client/ios/NetBirdSDK/preferences_test.go +++ b/client/ios/NetBirdSDK/preferences_test.go @@ -47,13 +47,14 @@ func TestPreferences_ReadUncommitedValues(t *testing.T) { stateFile := filepath.Join(t.TempDir(), "state.json") p := NewPreferences(cfgFile, stateFile) + // The admin URL is deprecated: setting it is a no-op. p.SetAdminURL(exampleString) resp, err := p.GetAdminURL() if err != nil { t.Fatalf("failed to read admin url: %s", err) } - if resp != exampleString { + if resp != profilemanager.DefaultAdminURL { t.Errorf("unexpected admin url: %s", resp) } @@ -100,7 +101,7 @@ func TestPreferences_Commit(t *testing.T) { t.Fatalf("failed to read admin url: %s", err) } - if resp != exampleURL { + if resp != profilemanager.DefaultAdminURL { t.Errorf("unexpected admin url: %s", resp) } diff --git a/client/server/server.go b/client/server/server.go index 4ce2a5c82..94469c683 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -593,9 +593,6 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile if msg.ManagementUrl != "" { config.ManagementURL = msg.ManagementUrl } - if msg.AdminURL != "" { - config.AdminURL = msg.AdminURL - } if msg.InterfaceName != nil { config.InterfaceName = msg.InterfaceName } @@ -2409,7 +2406,6 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p // overlay (Loader lives outside profilemanager). cfg.ApplyMDMPolicy(s.mdmLoader.Load()) managementURL := cfg.ManagementURL - adminURL := cfg.AdminURL var preSharedKey = cfg.PreSharedKey if preSharedKey != "" { @@ -2465,7 +2461,6 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p return &proto.GetConfigResponse{ ManagementUrl: managementURL.String(), PreSharedKey: preSharedKey, - AdminURL: adminURL.String(), InterfaceName: cfg.WgIface, WireguardPort: int64(cfg.WgPort), Mtu: int64(cfg.MTU), diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index d7f7b2bd5..454bfe423 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -1,6 +1,7 @@ package server import ( + "os" "os/user" "path/filepath" "reflect" @@ -129,7 +130,10 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.NoError(t, err) require.Equal(t, "https://new-api.netbird.io:443", cfg.ManagementURL.String()) - require.Equal(t, "https://new-admin.netbird.io:443", cfg.AdminURL.String()) + // Older clients still send the deprecated admin URL; it must be ignored. + raw, err := os.ReadFile(cfgPath) + require.NoError(t, err) + require.NotContains(t, string(raw), "new-admin.netbird.io", "deprecated AdminURL must not be persisted") require.Equal(t, rosenpassEnabled, cfg.RosenpassEnabled) require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive) require.NotNil(t, cfg.ServerSSHAllowed) @@ -182,11 +186,11 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "CleanNATExternalIPs": true, // control flag for clearing "CleanDNSLabels": true, // control flag for clearing "LazyConnectionEnabled": true, // deprecated: proto field retained for compat, no longer applied + "AdminURL": true, // deprecated: proto field retained for compat, no longer applied } expectedFields := map[string]bool{ "ManagementUrl": true, - "AdminURL": true, "RosenpassEnabled": true, "RosenpassPermissive": true, "ServerSSHAllowed": true, @@ -249,7 +253,6 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { // This map must be updated when adding new config-related CLI flags. flagToField := map[string]string{ "management-url": "ManagementUrl", - "admin-url": "AdminURL", "enable-rosenpass": "RosenpassEnabled", "rosenpass-permissive": "RosenpassPermissive", "allow-server-ssh": "ServerSSHAllowed", @@ -286,6 +289,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { fieldsWithoutCLIFlags := map[string]bool{ "DisableNotifications": true, // Only settable via UI "LazyConnectionEnabled": true, // deprecated: no longer settable (managed by server + NB_LAZY_CONN) + "AdminURL": true, // deprecated: the admin-url flag is no longer functional } // Get all SetConfigRequest fields to verify our map is complete. diff --git a/client/ui/frontend/WAILS-API.md b/client/ui/frontend/WAILS-API.md index 6cc1dd79c..ad0866b65 100644 --- a/client/ui/frontend/WAILS-API.md +++ b/client/ui/frontend/WAILS-API.md @@ -110,7 +110,7 @@ Settings.SetConfig(p: SetConfigParams): Promise // partial update Settings.GetFeatures(): Promise // operator-disabled UI sections ``` -`SetConfig` is a partial update: only fields you set are pushed to the daemon. `profileName` + `username` are always required; the typed fields in `SetConfigParams` are optional (`field?: T | null`). `managementUrl` and `adminUrl` are always-string for historical reasons. +`SetConfig` is a partial update: only fields you set are pushed to the daemon. `profileName` + `username` are always required; the typed fields in `SetConfigParams` are optional (`field?: T | null`). `managementUrl` is always-string for historical reasons. **PSK mask quirk:** `GetConfig` returns existing pre-shared keys as `"**********"`. If you send the mask back, `wgtypes.ParseKey` fails on the next connect. `SettingsContext.save` drops the field when it equals `"**********"`. See `modules/settings/SettingsContext.tsx`. @@ -242,7 +242,7 @@ The tray also reads a tray-only synthetic `"Error"` for icon purposes; the front `Config` (read-only mirror, all required): ```ts -{ managementUrl, adminUrl, configFile, logFile, preSharedKey, interfaceName: string; +{ managementUrl, configFile, logFile, preSharedKey, interfaceName: string; wireguardPort, mtu, sshJwtCacheTtl: number; disableAutoConnect, serverSshAllowed, rosenpassEnabled, rosenpassPermissive, @@ -254,7 +254,7 @@ The tray also reads a tray-only synthetic `"Error"` for icon purposes; the front disableSshAuth: boolean; } ``` -`SetConfigParams` has all `Config` fields as `field?: T | null` (partial update), plus the write-only `disableFirewall?: boolean | null`, plus `profileName` / `username` / `managementUrl` / `adminUrl` as required strings. +`SetConfigParams` has all `Config` fields as `field?: T | null` (partial update), plus the write-only `disableFirewall?: boolean | null`, plus `profileName` / `username` / `managementUrl` as required strings. `Features`: `{ disableProfiles, disableUpdateSettings, disableNetworks: boolean }`. diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index 7c20184bd..c3628fbdf 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -51,7 +51,6 @@ type ConfigParams struct { type Config struct { ManagementURL string `json:"managementUrl"` - AdminURL string `json:"adminUrl"` ConfigFile string `json:"configFile"` LogFile string `json:"logFile"` PreSharedKeySet bool `json:"preSharedKeySet"` @@ -84,7 +83,6 @@ type SetConfigParams struct { ProfileName string `json:"profileName"` Username string `json:"username"` ManagementURL string `json:"managementUrl"` - AdminURL string `json:"adminUrl"` InterfaceName *string `json:"interfaceName,omitempty"` WireguardPort *int64 `json:"wireguardPort,omitempty"` MTU *int64 `json:"mtu,omitempty"` @@ -144,7 +142,6 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error } return Config{ ManagementURL: resp.GetManagementUrl(), - AdminURL: resp.GetAdminURL(), ConfigFile: resp.GetConfigFile(), LogFile: resp.GetLogFile(), PreSharedKeySet: resp.GetPreSharedKey() != "", @@ -181,7 +178,6 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcom ProfileName: p.ProfileName, Username: p.Username, ManagementUrl: p.ManagementURL, - AdminURL: p.AdminURL, InterfaceName: p.InterfaceName, WireguardPort: p.WireguardPort, Mtu: p.MTU, From 569b9aa169c01ddb89885f188811da2f21f085d0 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Tue, 20 Oct 2026 14:21:05 +0200 Subject: [PATCH 185/209] [misc] Let suites outside the module reach the e2e harness network (#8260) The harness hands peers the management address netbird.local:8080, which only resolves on the deployment's own Docker network, and keeps that network, the client image and the client container private. A suite that runs agents somewhere else, such as the Kubernetes operator's Kind cluster, has no way to attach to the deployment, run the same client build, or check routed access from a harness peer. Expose the network name, resolve the client image on its own, and let a caller exec into a client container. --- e2e/harness/client.go | 34 +++++++++++++++++++++++++++++----- e2e/harness/combined.go | 8 ++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/e2e/harness/client.go b/e2e/harness/client.go index 9843c25b2..da27d3bc5 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -65,11 +65,7 @@ func StartClient(ctx context.Context, c *Combined, setupKey string, opts ...Clie opt(&o) } - root, err := repoRoot(ctx) - if err != nil { - return nil, err - } - clientImage, err := resolveImage(ctx, root, "NB_E2E_CLIENT_IMAGE", defaultClientImage, clientDockerfile) + clientImage, err := ClientImage(ctx) if err != nil { return nil, err } @@ -105,6 +101,34 @@ func StartClient(ctx context.Context, c *Combined, setupKey string, opts ...Clie return &Client{container: ctr, name: o.name}, nil } +// ClientImage returns the client image StartClient runs, building it from this +// module's source unless NB_E2E_CLIENT_IMAGE names a published one. A suite that +// runs the agent outside a harness container, such as in a Kubernetes cluster, +// uses it to get the same build. +func ClientImage(ctx context.Context) (string, error) { + root, err := repoRoot(ctx) + if err != nil { + return "", err + } + return resolveImage(ctx, root, "NB_E2E_CLIENT_IMAGE", defaultClientImage, clientDockerfile) +} + +// Exec runs a command inside the client container and returns its exit code and +// combined output. It reaches whatever the agent routes, so a suite can check +// access the request helpers here do not model, such as plain HTTP to a routed +// address. +func (cl *Client) Exec(ctx context.Context, cmd ...string) (int, string, error) { + code, reader, err := cl.container.Exec(ctx, cmd, tcexec.Multiplexed()) + if err != nil { + return 0, "", fmt.Errorf("exec %v: %w", cmd, err) + } + out, err := io.ReadAll(reader) + if err != nil { + return code, string(out), fmt.Errorf("read output of %v: %w", cmd, err) + } + return code, string(out), nil +} + // Hostname returns the container hostname the agent reports to management — // the name the registered peer appears under in the peers API. func (cl *Client) Hostname() string { diff --git a/e2e/harness/combined.go b/e2e/harness/combined.go index ea451e3ad..d08db8bf9 100644 --- a/e2e/harness/combined.go +++ b/e2e/harness/combined.go @@ -332,6 +332,14 @@ func (c *Combined) Restart(ctx context.Context) error { return nil } +// NetworkName returns the Docker network the deployment runs on. Peers reach +// management, signal and relay at the combined server's alias on this network +// only, so a suite running agents outside the harness, such as in a Kind +// cluster, attaches them here. +func (c *Combined) NetworkName() string { + return c.network.Name +} + // Logs returns the combined server container logs, for diagnostics. func (c *Combined) Logs(ctx context.Context) string { return containerLogs(ctx, c.container) From aa0f569677a687dac0c9fe7e329ac4229579a699 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Tue, 20 Oct 2026 18:50:32 +0300 Subject: [PATCH 186/209] [management] Remove the MySQL store engine (#7914) --- .../workflows/check-license-dependencies.yml | 4 +- .github/workflows/golang-test-linux.yml | 8 +- .github/workflows/golang-test-windows.yml | 2 +- .../workflows/test-infrastructure-files.yml | 32 +- .goreleaser.yaml | 26 - combined/cmd/admin_config_test.go | 4 - combined/cmd/config.go | 2 +- combined/cmd/root.go | 9 +- combined/config.yaml.example | 4 +- .../modules/20-management-store.md | 8 +- go.mod | 2 - go.sum | 6 - infrastructure_files/configure.sh | 12 - infrastructure_files/docker-compose.yml.tmpl | 1 - .../docker-compose.yml.tmpl.traefik | 1 - infrastructure_files/migrate-to-enterprise.sh | 2 +- infrastructure_files/migrate.sh | 12 - .../agent_config_realstore_test.go | 2 +- .../internals/modules/agentnetwork/manager.go | 1 - .../agentnetwork/provider_redaction_test.go | 5 +- .../modules/agentnetwork/types/settings.go | 3 +- .../agentnetwork/types/usageoverview.go | 2 +- management/internals/shared/db/engine.go | 1 - management/internals/shared/db/open.go | 25 - management/internals/shared/db/open_test.go | 12 - management/internals/shared/db/transaction.go | 30 +- management/server/account.go | 1 - management/server/account_test.go | 2 +- management/server/management_proto_test.go | 4 - management/server/migration/migration.go | 54 +- .../migration/migration_agentnetwork.go | 20 +- management/server/migration/migration_test.go | 54 +- management/server/peer_test.go | 7 +- management/server/store/sql_store.go | 36 - management/server/store/sql_store_account.go | 6 +- .../server/store/sql_store_group_test.go | 5 - .../server/store/sql_store_idp_migration.go | 1 - management/server/store/sql_store_peer.go | 10 +- .../server/store/sql_store_pgx_parity_test.go | 2 +- management/server/store/sql_store_proxy.go | 6 +- .../server/store/sql_store_setup_key.go | 6 +- management/server/store/sql_store_test.go | 48 -- management/server/store/store.go | 307 +------- management/server/testutil/store.go | 64 +- management/server/testutil/store_ios.go | 6 - management/server/types/store.go | 2 +- .../management/client/rest/accounts_test.go | 2 +- tools/mysql-migrate/LICENSE | 661 ------------------ tools/mysql-migrate/copy.go | 357 ---------- tools/mysql-migrate/main.go | 242 ------- tools/mysql-migrate/main_test.go | 225 ------ 51 files changed, 103 insertions(+), 2241 deletions(-) delete mode 100644 management/internals/shared/db/open_test.go delete mode 100644 tools/mysql-migrate/LICENSE delete mode 100644 tools/mysql-migrate/copy.go delete mode 100644 tools/mysql-migrate/main.go delete mode 100644 tools/mysql-migrate/main_test.go diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml index 27c59f8d6..81d293e4f 100644 --- a/.github/workflows/check-license-dependencies.yml +++ b/.github/workflows/check-license-dependencies.yml @@ -34,7 +34,7 @@ jobs: while IFS= read -r dir; do echo "=== Checking $dir ===" # Search for problematic imports, excluding test files - RESULTS=$(grep -r "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\)" "$dir" --include="*.go" 2>/dev/null | grep -v "_test.go" | grep -v "test_" | grep -v "/test/" | grep -v "tools/idp-migrate/" | grep -v "tools/mysql-migrate/" || true) + RESULTS=$(grep -r "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\)" "$dir" --include="*.go" 2>/dev/null | grep -v "_test.go" | grep -v "test_" | grep -v "/test/" | grep -v "tools/idp-migrate/" || true) if [ -n "$RESULTS" ]; then echo "❌ Found problematic dependencies:" echo "$RESULTS" @@ -93,7 +93,7 @@ jobs: IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath") # Check if any importer is NOT in management/signal/relay - BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\|tools/mysql-migrate\)" | head -1) + BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1) if [ -n "$BSD_IMPORTER" ]; then echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER" diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index 880370ede..b3e4dd4f7 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -307,7 +307,7 @@ jobs: sh -c ' \ apk update; apk add --no-cache \ ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \ - go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged -e tools/mysql-migrate) + go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged) ' test_relay: @@ -498,7 +498,7 @@ jobs: fail-fast: false matrix: arch: ["amd64"] - store: ["sqlite", "postgres", "mysql"] + store: ["sqlite", "postgres"] runs-on: ubuntu-22.04 steps: - name: Checkout code @@ -547,10 +547,6 @@ jobs: DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin - - name: download mysql image - if: matrix.store == 'mysql' - run: docker pull mlsmaycon/warmed-mysql:8 - # The -json stream goes through tools/gotestsummary so the log shows one # line per test, the output of failed tests, the head of a timeout panic # with the still-running tests, and the slowest tests per package. diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml index 49be3af58..ca300f7df 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -76,7 +76,7 @@ jobs: # resolve; the Where-Object pipeline then drops the broken package by # path. Without -e, go list aborts with empty stdout. run: | - $packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui$' } | Where-Object { $_ -notmatch '/tools/mysql-migrate' } + $packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui$' } $goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe" $cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1" Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml index 4875adc31..68cf1b639 100644 --- a/.github/workflows/test-infrastructure-files.yml +++ b/.github/workflows/test-infrastructure-files.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - store: ["sqlite", "postgres", "mysql"] + store: ["sqlite", "postgres"] services: postgres: image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }} @@ -35,19 +35,6 @@ jobs: --health-timeout 5s ports: - 5432:5432 - mysql: - image: ${{ (matrix.store == 'mysql') && 'mysql' || '' }} - env: - MYSQL_USER: netbird - MYSQL_PASSWORD: mysql - MYSQL_ROOT_PASSWORD: mysqlroot - MYSQL_DATABASE: netbird - options: >- - --health-cmd "mysqladmin ping --silent" - --health-interval 10s - --health-timeout 5s - ports: - - 3306:3306 steps: - name: Set Database Connection String run: | @@ -56,11 +43,6 @@ jobs: else echo "NETBIRD_STORE_ENGINE_POSTGRES_DSN==" >> $GITHUB_ENV fi - if [ "${{ matrix.store }}" == "mysql" ]; then - echo "NETBIRD_STORE_ENGINE_MYSQL_DSN=netbird:mysql@tcp($(hostname -I | awk '{print $1}'):3306)/netbird" >> $GITHUB_ENV - else - echo "NETBIRD_STORE_ENGINE_MYSQL_DSN==" >> $GITHUB_ENV - fi - name: Install jq run: sudo apt-get install -y jq @@ -86,15 +68,6 @@ jobs: restore-keys: | ${{ runner.os }}-go- - - name: Setup MySQL privileges - if: matrix.store == 'mysql' - run: | - sleep 10 - mysql -h 127.0.0.1 -u root -pmysqlroot -e " - GRANT SYSTEM_VARIABLES_ADMIN ON *.* TO 'netbird'@'%'; - FLUSH PRIVILEGES; - " - - name: cp setup.env run: cp infrastructure_files/tests/setup.env infrastructure_files/ @@ -114,7 +87,6 @@ jobs: CI_NETBIRD_AUTH_SUPPORTED_SCOPES: "openid profile email offline_access api email_verified" CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }} NETBIRD_STORE_ENGINE_POSTGRES_DSN: ${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }} - NETBIRD_STORE_ENGINE_MYSQL_DSN: ${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }} CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false - name: check values @@ -143,7 +115,6 @@ jobs: CI_NETBIRD_SIGNAL_PORT: 12345 CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }} NETBIRD_STORE_ENGINE_POSTGRES_DSN: "${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$" - NETBIRD_STORE_ENGINE_MYSQL_DSN: "${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$" CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4" CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false @@ -182,7 +153,6 @@ jobs: grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep Scope | grep "$CI_NETBIRD_AUTH_SUPPORTED_SCOPES" grep -A 10 PKCEAuthorizationFlow management.json | grep -A 10 ProviderConfig | grep -A 3 RedirectURLs | grep "http://localhost:53000" grep "external-ip" turnserver.conf | grep $CI_NETBIRD_TURN_EXTERNAL_IP - grep "NETBIRD_STORE_ENGINE_MYSQL_DSN=$NETBIRD_STORE_ENGINE_MYSQL_DSN" docker-compose.yml grep NETBIRD_STORE_ENGINE_POSTGRES_DSN docker-compose.yml | egrep "$NETBIRD_STORE_ENGINE_POSTGRES_DSN" # check relay values grep "NB_EXPOSED_ADDRESS=rels://$CI_NETBIRD_DOMAIN:33445" docker-compose.yml diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 41c330a64..62b64717e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -280,28 +280,6 @@ builds: - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser mod_timestamp: "{{ .CommitTimestamp }}" - - id: netbird-mysql-migrate - dir: tools/mysql-migrate - env: - - CGO_ENABLED=1 - - >- - {{- if eq .Runtime.Goos "linux" }} - {{- if eq .Arch "arm64"}}CC=aarch64-linux-gnu-gcc{{- end }} - {{- if eq .Arch "arm"}}CC=arm-linux-gnueabihf-gcc{{- end }} - {{- end }} - binary: netbird-mysql-migrate - goos: - - linux - goarch: - - amd64 - - arm64 - - arm - goarm: - - 7 - ldflags: - - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser - mod_timestamp: "{{ .CommitTimestamp }}" - universal_binaries: - id: netbird @@ -318,10 +296,6 @@ archives: builds: - netbird-idp-migrate name_template: "netbird-idp-migrate_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - - id: netbird-mysql-migrate - builds: - - netbird-mysql-migrate - name_template: "netbird-mysql-migrate_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - id: nblink builds: - nblink diff --git a/combined/cmd/admin_config_test.go b/combined/cmd/admin_config_test.go index ff7045d38..5cec26552 100644 --- a/combined/cmd/admin_config_test.go +++ b/combined/cmd/admin_config_test.go @@ -35,13 +35,9 @@ func TestOpenAdminEventStoreMissingEncryptionKeyReturnsNilInterface(t *testing.T func TestApplyServerStoreEnv(t *testing.T) { t.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", "") - t.Setenv("NB_STORE_ENGINE_MYSQL_DSN", "") t.Setenv("NB_STORE_ENGINE_SQLITE_FILE", "") applyServerStoreEnv(StoreConfig{Engine: "postgres", DSN: "postgres-dsn", File: "store.db"}) require.Equal(t, "postgres-dsn", os.Getenv("NB_STORE_ENGINE_POSTGRES_DSN")) require.Equal(t, "store.db", os.Getenv("NB_STORE_ENGINE_SQLITE_FILE")) - - applyServerStoreEnv(StoreConfig{Engine: "mysql", DSN: "mysql-dsn"}) - require.Equal(t, "mysql-dsn", os.Getenv("NB_STORE_ENGINE_MYSQL_DSN")) } diff --git a/combined/cmd/config.go b/combined/cmd/config.go index 890c86876..ca3b25617 100644 --- a/combined/cmd/config.go +++ b/combined/cmd/config.go @@ -188,7 +188,7 @@ type RelaysConfig struct { type StoreConfig struct { Engine string `yaml:"engine"` EncryptionKey string `yaml:"encryptionKey"` - DSN string `yaml:"dsn"` // Connection string for postgres or mysql engines + DSN string `yaml:"dsn"` // Connection string for the postgres engine File string `yaml:"file"` // SQLite database file path (optional, defaults to dataDir) } diff --git a/combined/cmd/root.go b/combined/cmd/root.go index fa65f7b6d..fedb0e1cc 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -127,13 +127,8 @@ func execute(cmd *cobra.Command, _ []string) error { // initializeConfig loads and validates the configuration, then initializes logging. func applyServerStoreEnv(storeConfig StoreConfig) { - if dsn := storeConfig.DSN; dsn != "" { - switch strings.ToLower(storeConfig.Engine) { - case "postgres": - os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn) - case "mysql": - os.Setenv("NB_STORE_ENGINE_MYSQL_DSN", dsn) - } + if dsn := storeConfig.DSN; dsn != "" && strings.EqualFold(storeConfig.Engine, "postgres") { + os.Setenv("NB_STORE_ENGINE_POSTGRES_DSN", dsn) } if file := storeConfig.File; file != "" { os.Setenv("NB_STORE_ENGINE_SQLITE_FILE", file) diff --git a/combined/config.yaml.example b/combined/config.yaml.example index 085e4344f..68dbf701e 100644 --- a/combined/config.yaml.example +++ b/combined/config.yaml.example @@ -110,8 +110,8 @@ server: # Store configuration store: - engine: "sqlite" # sqlite, postgres, or mysql - dsn: "" # Connection string for postgres or mysql + engine: "sqlite" # sqlite or postgres + dsn: "" # Connection string for postgres encryptionKey: "" # file: "" # Custom SQLite file path (optional, defaults to {dataDir}/store.db) diff --git a/docs/agent-networks/modules/20-management-store.md b/docs/agent-networks/modules/20-management-store.md index 1acc12611..658b1fb4f 100644 --- a/docs/agent-networks/modules/20-management-store.md +++ b/docs/agent-networks/modules/20-management-store.md @@ -64,17 +64,17 @@ Reads decrypt provider secrets in-place; writes do `provider.Copy().EncryptSensi ### Correctness - `SaveAgentNetworkProvider` saves the copy (sql_store_agentnetwork.go:95). The caller's in-memory pointer therefore keeps plaintext `api_key` and any `CreatedAt`/`UpdatedAt` gorm autofills land on the copy, not the original. Callers that need synced timestamps must re-fetch. -- `IncrementAgentNetworkConsumption`'s `Create` provides initial counter values (`TokensInput: tokensIn`, etc.) in the row, and on conflict the assignments add the same deltas to the existing values. The insert-vs-update arithmetic is consistent. Cross-check that no engine in use (sqlite, postgres, mysql) silently rejects the `OnConflict` clause — GORM emits engine-specific SQL but `ON DUPLICATE KEY UPDATE` (mysql) vs `ON CONFLICT (...)` (sqlite/postgres) need their unique constraint to match the composite PK on `agent_network_consumption`; it does, by construction. +- `IncrementAgentNetworkConsumption`'s `Create` provides initial counter values (`TokensInput: tokensIn`, etc.) in the row, and on conflict the assignments add the same deltas to the existing values. The insert-vs-update arithmetic is consistent. Cross-check that no engine in use (sqlite, postgres) silently rejects the `OnConflict` clause — GORM emits `ON CONFLICT (...)`, which needs its unique constraint to match the composite PK on `agent_network_consumption`; it does, by construction. - `IncrementAgentNetworkConsumption` writes `updated_at: time.Now().UTC()` literally inside the assignments map (sql_store_agentnetwork.go:333) — fine, but it's a Go-side timestamp captured at call time, not a DB-side `now()`. Acceptable for an audit field. - `GetAgentNetworkConsumption` returns a zero-valued non-nil row on `ErrRecordNotFound` (sql_store_agentnetwork.go:364-371). Document or rename — a typed sentinel error would be more orthodox; callers must know not to error-check. ### Concurrency / transactions -- Hot-path `IncrementAgentNetworkConsumption` runs outside any explicit transaction; concurrency safety relies entirely on the DB serialising the `ON CONFLICT` upsert against the composite PK. This is correct for postgres and mysql; for sqlite it serialises behind the single writer. +- Hot-path `IncrementAgentNetworkConsumption` runs outside any explicit transaction; concurrency safety relies entirely on the DB serialising the `ON CONFLICT` upsert against the composite PK. This is correct for postgres; for sqlite it serialises behind the single writer. - `SaveAgentNetworkSettings` is a blind upsert with no version/etag — concurrent writes from two operators last-write-wins on the collection-toggle flags (settings.go:23-25). Acceptable for admin-curated state but worth flagging. - `Save*Provider` uses `db.Save` on a struct with a PK already set — GORM emits UPDATE or INSERT based on row existence. No upsert clause is attached, so a race between two creates with the same generated `xid` (vanishingly unlikely) would surface as a PK violation. ### Migration safety -- All six tables ride `AutoMigrate` (sql_store.go:141-142). AutoMigrate is additive: new columns get added, but it never drops columns nor narrows types. Three `bool` columns on `agent_network_settings` (`EnableLogCollection`, `EnablePromptCollection`, `RedactPii`) default to false at the GORM/DDL layer for existing rows; the test at sql_store_agentnetwork_budgetrule_test.go:83-112 locks that down on a fresh sqlite. Verify postgres/mysql produce the same default. +- All six tables ride `AutoMigrate` (sql_store.go:141-142). AutoMigrate is additive: new columns get added, but it never drops columns nor narrows types. Three `bool` columns on `agent_network_settings` (`EnableLogCollection`, `EnablePromptCollection`, `RedactPii`) default to false at the GORM/DDL layer for existing rows; the test at sql_store_agentnetwork_budgetrule_test.go:83-112 locks that down on a fresh sqlite. Verify postgres produces the same default. - The named index `idx_agent_network_settings_cluster_subdomain` on settings.go:15 is declared on only `subdomain`. Either the cluster column also needs `gorm:"index:idx_agent_network_settings_cluster_subdomain"` to make it composite, or the name is misleading. - The named index `idx_agent_network_provider` on `Provider.ProviderID` (provider.go:30) is *not* unique and not scoped to account — two providers in the same account with the same `provider_id` are permitted at the DB layer; uniqueness, if any, must live above the store. @@ -101,7 +101,7 @@ Gap: there is no store-level test for providers (encryption round-trip), policie - No retention / GC for `agent_network_consumption`. - No `Delete` for `Settings` (one row per account, cleared with the account). -- No DB-engine-specific tuning — the same struct tags drive sqlite, mysql, postgres. +- No DB-engine-specific tuning — the same struct tags drive sqlite and postgres. - Provider `extra_values` and `models` are JSON blobs; querying inside them is not supported by design. - `GetAgentNetworkConsumption` "not-found = zero row" contract is convenient but unconventional. diff --git a/go.mod b/go.mod index bc8e5e287..beb612a72 100644 --- a/go.mod +++ b/go.mod @@ -113,7 +113,6 @@ require ( github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 github.com/stretchr/testify v1.12.1 github.com/testcontainers/testcontainers-go v0.37.0 - github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 github.com/testcontainers/testcontainers-go/modules/redis v0.37.0 github.com/things-go/go-socks5 v0.0.4 @@ -142,7 +141,6 @@ require ( google.golang.org/api v0.276.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 gopkg.in/yaml.v3 v3.0.1 - gorm.io/driver/mysql v1.5.7 gorm.io/driver/postgres v1.5.7 gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 diff --git a/go.sum b/go.sum index 9af59daf5..82e2daf78 100644 --- a/go.sum +++ b/go.sum @@ -252,7 +252,6 @@ github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3yg github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s= github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= -github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= @@ -721,8 +720,6 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= -github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 h1:LqUos1oR5iuuzorFnSvxsHNdYdCHB/DfI82CuT58wbI= -github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0/go.mod h1:vHEEHx5Kf+uq5hveaVAMrTzPY8eeRZcKcl23MRw5Tkc= github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 h1:hsVwFkS6s+79MbKEO+W7A1wNIw1fmkMtF4fg83m6kbc= github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0/go.mod h1:Qj/eGbRbO/rEYdcRLmN+bEojzatP/+NS1y8ojl2PQsc= github.com/testcontainers/testcontainers-go/modules/redis v0.37.0 h1:9HIY28I9ME/Zmb+zey1p/I1mto5+5ch0wLX+nJdOsQ4= @@ -1025,13 +1022,10 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= -gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= -gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= diff --git a/infrastructure_files/configure.sh b/infrastructure_files/configure.sh index ce1a041e6..2d9fe5ee8 100755 --- a/infrastructure_files/configure.sh +++ b/infrastructure_files/configure.sh @@ -53,18 +53,6 @@ if [[ "$NETBIRD_STORE_CONFIG_ENGINE" == "postgres" ]]; then export NETBIRD_STORE_ENGINE_POSTGRES_DSN fi -# Check if MySQL is set as the store engine -if [[ "$NETBIRD_STORE_CONFIG_ENGINE" == "mysql" ]]; then - # Exit if 'NETBIRD_STORE_ENGINE_MYSQL_DSN' is not set - if [[ -z "$NETBIRD_STORE_ENGINE_MYSQL_DSN" ]]; then - echo "Warning: NETBIRD_STORE_CONFIG_ENGINE=mysql but NETBIRD_STORE_ENGINE_MYSQL_DSN is not set." - echo "Please add the following line to your setup.env file:" - echo 'NETBIRD_STORE_ENGINE_MYSQL_DSN=":@tcp(127.0.0.1:3306)/"' - exit 1 - fi - export NETBIRD_STORE_ENGINE_MYSQL_DSN -fi - # local development or tests if [[ $NETBIRD_DOMAIN == "localhost" || $NETBIRD_DOMAIN == "127.0.0.1" ]]; then export NETBIRD_MGMT_SINGLE_ACCOUNT_MODE_DOMAIN="netbird.selfhosted" diff --git a/infrastructure_files/docker-compose.yml.tmpl b/infrastructure_files/docker-compose.yml.tmpl index 1c9c63f78..0ffb20a72 100644 --- a/infrastructure_files/docker-compose.yml.tmpl +++ b/infrastructure_files/docker-compose.yml.tmpl @@ -94,7 +94,6 @@ services: ] environment: - NETBIRD_STORE_ENGINE_POSTGRES_DSN=$NETBIRD_STORE_ENGINE_POSTGRES_DSN - - NETBIRD_STORE_ENGINE_MYSQL_DSN=$NETBIRD_STORE_ENGINE_MYSQL_DSN # Coturn coturn: diff --git a/infrastructure_files/docker-compose.yml.tmpl.traefik b/infrastructure_files/docker-compose.yml.tmpl.traefik index 0010974c5..7fcf1cdde 100644 --- a/infrastructure_files/docker-compose.yml.tmpl.traefik +++ b/infrastructure_files/docker-compose.yml.tmpl.traefik @@ -100,7 +100,6 @@ services: - traefik.http.services.netbird-management.loadbalancer.server.scheme=h2c environment: - NETBIRD_STORE_ENGINE_POSTGRES_DSN=$NETBIRD_STORE_ENGINE_POSTGRES_DSN - - NETBIRD_STORE_ENGINE_MYSQL_DSN=$NETBIRD_STORE_ENGINE_MYSQL_DSN # Coturn coturn: diff --git a/infrastructure_files/migrate-to-enterprise.sh b/infrastructure_files/migrate-to-enterprise.sh index 8abde2f68..5b2cc2f79 100755 --- a/infrastructure_files/migrate-to-enterprise.sh +++ b/infrastructure_files/migrate-to-enterprise.sh @@ -855,7 +855,7 @@ configure_sqlite_store() { POSTGRES_DSN="host=postgres user=netbird password=${POSTGRES_PASSWORD} dbname=netbird port=5432 sslmode=disable" } -# mysql, or something this script has never seen. Swapping the images is still +# An engine this script has never seen. Swapping the images is still # valid; touching the store is not. configure_unsupported_store() { MIGRATE_POSTGRES="no" diff --git a/infrastructure_files/migrate.sh b/infrastructure_files/migrate.sh index 46971a57a..2b018ab54 100755 --- a/infrastructure_files/migrate.sh +++ b/infrastructure_files/migrate.sh @@ -472,12 +472,6 @@ detect_store_config() { if [[ -n "$pg_dsn" ]]; then STORE_DSN="$pg_dsn" fi - - local mysql_dsn - mysql_dsn=$(grep '^NETBIRD_STORE_ENGINE_MYSQL_DSN=' "$INSTALL_DIR/setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_MYSQL_DSN=//' | tr -d '"' || echo "") - if [[ -n "$mysql_dsn" ]]; then - STORE_DSN="$mysql_dsn" - fi fi # Also check base.setup.env @@ -487,12 +481,6 @@ detect_store_config() { if [[ -n "$pg_dsn" ]]; then STORE_DSN="$pg_dsn" fi - - local mysql_dsn - mysql_dsn=$(grep '^NETBIRD_STORE_ENGINE_MYSQL_DSN=' "$INSTALL_DIR/base.setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_MYSQL_DSN=//' | tr -d '"' || echo "") - if [[ -n "$mysql_dsn" ]]; then - STORE_DSN="$mysql_dsn" - fi fi log_success "Store engine: $STORE_ENGINE" diff --git a/management/internals/modules/agentnetwork/agent_config_realstore_test.go b/management/internals/modules/agentnetwork/agent_config_realstore_test.go index 2fd541fad..e69cec59a 100644 --- a/management/internals/modules/agentnetwork/agent_config_realstore_test.go +++ b/management/internals/modules/agentnetwork/agent_config_realstore_test.go @@ -341,7 +341,7 @@ func TestGetAgentConfigForUser_RealStore(t *testing.T) { require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) // users.account_id is a foreign key into accounts, enforced on - // MySQL/Postgres, so the account row must exist before its users. + // Postgres, so the account row must exist before its users. require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID})) require.NoError(t, s.SaveUser(ctx, &nbtypes.User{ Id: "user-in", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"}, diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go index 71ff53214..69ae4b432 100644 --- a/management/internals/modules/agentnetwork/manager.go +++ b/management/internals/modules/agentnetwork/manager.go @@ -1277,7 +1277,6 @@ func isUniqueConstraintError(err error) bool { } msg := err.Error() return strings.Contains(msg, "(SQLSTATE 23505)") || // postgres - strings.Contains(msg, "Error 1062 (23000)") || // mysql strings.Contains(msg, "UNIQUE constraint failed") // sqlite } diff --git a/management/internals/modules/agentnetwork/provider_redaction_test.go b/management/internals/modules/agentnetwork/provider_redaction_test.go index d6a749fb9..1503f4121 100644 --- a/management/internals/modules/agentnetwork/provider_redaction_test.go +++ b/management/internals/modules/agentnetwork/provider_redaction_test.go @@ -98,9 +98,8 @@ func TestGetProvider_RedactsForReadOnlyViewer(t *testing.T) { // keeps the account-wide view with full config. // newSelfScopeStore seeds the account and its users only, so each test -// declares exactly the providers and policies it asserts on — the store -// rejects re-saving a policy id on MySQL, so tests never overwrite each -// other's rows. +// declares exactly the providers and policies it asserts on, so tests never +// overwrite each other's rows. func newSelfScopeStore(t *testing.T) (*managerImpl, store.Store) { t.Helper() mgr, s := newAgentConfigTestMgr(t) diff --git a/management/internals/modules/agentnetwork/types/settings.go b/management/internals/modules/agentnetwork/types/settings.go index 70ecc5f70..6a28e6a5c 100644 --- a/management/internals/modules/agentnetwork/types/settings.go +++ b/management/internals/modules/agentnetwork/types/settings.go @@ -21,8 +21,7 @@ type Settings struct { AccountID string `gorm:"primaryKey"` // Domain is the gateway endpoint hostname agents call. Globally unique - // across accounts. Sized explicitly because MySQL cannot index an - // unbounded TEXT column; 255 covers the RFC 1035 253-octet bound. + // across accounts. 255 covers the RFC 1035 253-octet bound. Domain string `gorm:"type:varchar(255);uniqueIndex:idx_agent_network_settings_domain"` // ProxyAddress is the declared cluster address of the proxy serving this diff --git a/management/internals/modules/agentnetwork/types/usageoverview.go b/management/internals/modules/agentnetwork/types/usageoverview.go index 81e02c6b8..d46aec9c5 100644 --- a/management/internals/modules/agentnetwork/types/usageoverview.go +++ b/management/internals/modules/agentnetwork/types/usageoverview.go @@ -95,7 +95,7 @@ func bucketStart(t time.Time, g UsageGranularity) time.Time { // AggregateUsageByGranularity buckets the usage rows by the requested // granularity and returns the buckets ordered oldest-first. Aggregation is done // in Go (rather than per-engine SQL date_trunc) so granularities stay portable -// across SQLite/Postgres/MySQL and easy to extend. +// across SQLite and Postgres and easy to extend. func AggregateUsageByGranularity(rows []*AgentNetworkUsage, g UsageGranularity) []*AgentNetworkUsageBucket { byPeriod := make(map[string]*AgentNetworkUsageBucket) for _, r := range rows { diff --git a/management/internals/shared/db/engine.go b/management/internals/shared/db/engine.go index 02a3f5570..6cf03e239 100644 --- a/management/internals/shared/db/engine.go +++ b/management/internals/shared/db/engine.go @@ -6,5 +6,4 @@ type Engine string const ( SqliteStoreEngine Engine = "sqlite" PostgresStoreEngine Engine = "postgres" - MysqlStoreEngine Engine = "mysql" ) diff --git a/management/internals/shared/db/open.go b/management/internals/shared/db/open.go index 48a7bf330..c573e8c08 100644 --- a/management/internals/shared/db/open.go +++ b/management/internals/shared/db/open.go @@ -11,7 +11,6 @@ import ( "time" "github.com/jackc/pgx/v5/pgxpool" - "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -121,30 +120,6 @@ func OpenPostgres(ctx context.Context, dsn string, pool PoolConfig) (*Conn, erro return conn, nil } -// MysqlDSN adds the connection parameters every MySQL handle needs, keeping -// the options already present in dsn. -func MysqlDSN(dsn string) string { - separator := "?" - if strings.Contains(dsn, "?") { - separator = "&" - } - return dsn + separator + "charset=utf8&parseTime=True&loc=Local" -} - -// OpenMysql opens a MySQL database through gorm. -func OpenMysql(ctx context.Context, dsn string) (*Conn, error) { - gormDB, err := gorm.Open(mysql.Open(MysqlDSN(dsn)), GormConfig()) - if err != nil { - return nil, err - } - conn, err := NewConn(ctx, gormDB, MysqlStoreEngine, nil) - if err != nil { - closeGorm(gormDB) - return nil, err - } - return conn, nil -} - func newPgxPool(ctx context.Context, dsn string, cfg PoolConfig) (*pgxpool.Pool, error) { config, err := pgxpool.ParseConfig(dsn) if err != nil { diff --git a/management/internals/shared/db/open_test.go b/management/internals/shared/db/open_test.go deleted file mode 100644 index e6b506b78..000000000 --- a/management/internals/shared/db/open_test.go +++ /dev/null @@ -1,12 +0,0 @@ -package db - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestMysqlDSN(t *testing.T) { - assert.Equal(t, "user:pw@tcp(host:3306)/db?charset=utf8&parseTime=True&loc=Local", MysqlDSN("user:pw@tcp(host:3306)/db")) - assert.Equal(t, "user:pw@tcp(host:3306)/db?tls=true&charset=utf8&parseTime=True&loc=Local", MysqlDSN("user:pw@tcp(host:3306)/db?tls=true")) -} diff --git a/management/internals/shared/db/transaction.go b/management/internals/shared/db/transaction.go index 9699aaee6..3930033d5 100644 --- a/management/internals/shared/db/transaction.go +++ b/management/internals/shared/db/transaction.go @@ -39,10 +39,7 @@ func (c *Conn) RunInTx(ctx context.Context, fn func(tx *Tx) error) error { return err } - err := c.withForeignKeyChecksDisabled(tx, func() error { - return fn(&Tx{db: tx}) - }) - if err != nil { + if err := fn(&Tx{db: tx}); err != nil { tx.Rollback() c.logIfTimedOut(ctx, timeoutCtx, err, "transaction", startTime) return err @@ -73,31 +70,6 @@ func (c *Conn) applyStatementTimeouts(tx *gorm.DB) error { return nil } -// withForeignKeyChecksDisabled runs fn with MySQL's FK checks off, which avoids -// deadlocks on MySQL and Aurora without needing SUPER privilege. The setting is -// session-scoped and survives a rollback, so it is turned back on whenever fn -// returns or panics; otherwise the pooled connection would keep it disabled. -func (c *Conn) withForeignKeyChecksDisabled(tx *gorm.DB, fn func() error) (err error) { - if c.engine != MysqlStoreEngine { - return fn() - } - if err := tx.Exec("SET FOREIGN_KEY_CHECKS = 0").Error; err != nil { - return fmt.Errorf("failed to disable FK checks: %w", err) - } - defer func() { - restoreErr := tx.Exec("SET FOREIGN_KEY_CHECKS = 1").Error - if restoreErr == nil { - return - } - if err == nil { - err = fmt.Errorf("failed to re-enable FK checks: %w", restoreErr) - return - } - log.WithContext(tx.Statement.Context).Warnf("failed to re-enable FK checks after failed transaction: %v", restoreErr) - }() - return fn() -} - func (c *Conn) logIfTimedOut(ctx, timeoutCtx context.Context, err error, phase string, startTime time.Time) { if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) { log.WithContext(ctx).Warnf("%s exceeded %s timeout after %v, stack: %s", phase, c.txTimeout, time.Since(startTime), debug.Stack()) diff --git a/management/server/account.go b/management/server/account.go index 59991a4e4..68150a947 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -152,7 +152,6 @@ func (am *DefaultAccountManager) runAccountDeletionHooks(ctx context.Context, ac func isUniqueConstraintError(err error) bool { switch { case strings.Contains(err.Error(), "(SQLSTATE 23505)"), - strings.Contains(err.Error(), "Error 1062 (23000)"), strings.Contains(err.Error(), "UNIQUE constraint failed"): return true diff --git a/management/server/account_test.go b/management/server/account_test.go index ef7525961..8f7b1f18b 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -3759,7 +3759,7 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel. // peerUpdateTimeout bounds how long peerShouldReceiveUpdate and its outer // wrappers wait for an expected update message. Sized for slow CI runners -// (MySQL, FreeBSD, loaded sqlite) where the channel publish can take +// (FreeBSD, loaded sqlite) where the channel publish can take // seconds. Only runs down on failure; passing tests return immediately // when the channel delivers. const peerUpdateTimeout = 5 * time.Second diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go index 4e21ea7a3..9ca94d2e6 100644 --- a/management/server/management_proto_test.go +++ b/management/server/management_proto_test.go @@ -429,10 +429,6 @@ func Test_SyncStatusRace(t *testing.T) { if os.Getenv("NETBIRD_STORE_ENGINE") == "postgres" { t.Skip("Skipping on CI and Postgres store") } - - if os.Getenv("NETBIRD_STORE_ENGINE") == "mysql" { - t.Skip("Skipping on CI and MySQL store") - } } for i := 0; i < 500; i++ { t.Run(fmt.Sprintf("TestRun-%d", i), func(t *testing.T) { diff --git a/management/server/migration/migration.go b/management/server/migration/migration.go index 6d8ed90cc..239166c99 100644 --- a/management/server/migration/migration.go +++ b/management/server/migration/migration.go @@ -19,19 +19,11 @@ import ( "gorm.io/gorm/clause" ) -func GetColumnName(db *gorm.DB, column string) string { - if db.Name() == "mysql" { - return fmt.Sprintf("`%s`", column) - } - return column -} - // MigrateFieldFromGobToJSON migrates a column from Gob encoding to JSON encoding. // T is the type of the model that contains the field to be migrated. // S is the type of the field to be migrated. func MigrateFieldFromGobToJSON[T any, S any](ctx context.Context, db *gorm.DB, fieldName string) error { - orgColumnName := fieldName - oldColumnName := GetColumnName(db, orgColumnName) + oldColumnName := fieldName newColumnName := fieldName + "_tmp" var model T @@ -86,7 +78,7 @@ func MigrateFieldFromGobToJSON[T any, S any](ctx context.Context, db *gorm.DB, f for _, row := range rows { var field S - str, ok := row[orgColumnName].(string) + str, ok := row[oldColumnName].(string) if !ok { return fmt.Errorf("type assertion failed") } @@ -125,8 +117,7 @@ func MigrateFieldFromGobToJSON[T any, S any](ctx context.Context, db *gorm.DB, f // MigrateNetIPFieldFromBlobToJSON migrates a Net IP column from Blob encoding to JSON encoding. // T is the type of the model that contains the field to be migrated. func MigrateNetIPFieldFromBlobToJSON[T any](ctx context.Context, db *gorm.DB, fieldName string, indexName string) error { - orgColumnName := fieldName - oldColumnName := GetColumnName(db, orgColumnName) + oldColumnName := fieldName newColumnName := fieldName + "_tmp" var model T @@ -178,7 +169,7 @@ func MigrateNetIPFieldFromBlobToJSON[T any](ctx context.Context, db *gorm.DB, fi for _, row := range rows { var blobValue string - if columnValue := row[orgColumnName]; columnValue != nil { + if columnValue := row[oldColumnName]; columnValue != nil { value, ok := columnValue.(string) if !ok { return fmt.Errorf("type assertion failed") @@ -225,8 +216,7 @@ func MigrateNetIPFieldFromBlobToJSON[T any](ctx context.Context, db *gorm.DB, fi } func MigrateSetupKeyToHashedSetupKey[T any](ctx context.Context, db *gorm.DB) error { - orgColumnName := "key" - oldColumnName := GetColumnName(db, orgColumnName) + oldColumnName := "key" newColumnName := "key_secret" var model T @@ -268,7 +258,7 @@ func MigrateSetupKeyToHashedSetupKey[T any](ctx context.Context, db *gorm.DB) er for _, row := range rows { var plainKey string - if columnValue := row[orgColumnName]; columnValue != nil { + if columnValue := row[oldColumnName]; columnValue != nil { value, ok := columnValue.(string) if !ok { return fmt.Errorf("type assertion failed") @@ -394,33 +384,13 @@ func CreateIndexIfNotExists[T any](ctx context.Context, db *gorm.DB, indexName s return fmt.Errorf("failed to parse model schema: %w", err) } tableName := stmt.Schema.Table - dialect := db.Name() if db.Migrator().HasIndex(&model, indexName) { log.WithContext(ctx).Infof("index %s already exists on table %s", indexName, tableName) return nil } - var columnClause string - if dialect == "mysql" { - var withLength []string - for _, col := range columns { - quotedCol := fmt.Sprintf("`%s`", col) - if col == "ip" || col == "dns_label" || col == "key" { - withLength = append(withLength, fmt.Sprintf("%s(64)", quotedCol)) - } else { - withLength = append(withLength, quotedCol) - } - } - columnClause = strings.Join(withLength, ", ") - } else { - columnClause = strings.Join(columns, ", ") - } - - createStmt := fmt.Sprintf("CREATE UNIQUE INDEX %s ON %s (%s)", indexName, tableName, columnClause) - if dialect == "postgres" || dialect == "sqlite" { - createStmt = strings.Replace(createStmt, "CREATE UNIQUE INDEX", "CREATE UNIQUE INDEX IF NOT EXISTS", 1) - } + createStmt := fmt.Sprintf("CREATE UNIQUE INDEX IF NOT EXISTS %s ON %s (%s)", indexName, tableName, strings.Join(columns, ", ")) log.WithContext(ctx).Infof("executing index creation: %s", createStmt) if err := db.Exec(createStmt).Error; err != nil { @@ -505,14 +475,6 @@ func hasForeignKey(db *gorm.DB, table, column string) bool { AND kcu.table_name = ? AND kcu.column_name = ? `, table, column).Scan(&count) - case "mysql": - db.Raw(` - SELECT COUNT(*) FROM information_schema.key_column_usage - WHERE table_schema = DATABASE() - AND table_name = ? - AND column_name = ? - AND referenced_table_name IS NOT NULL - `, table, column).Scan(&count) default: // sqlite type fkInfo struct { From string @@ -592,7 +554,7 @@ func RemoveDuplicatePeerKeys(ctx context.Context, db *gorm.DB) error { return nil } - keyColumn := GetColumnName(db, "key") + keyColumn := "key" var duplicates []struct { Key string diff --git a/management/server/migration/migration_agentnetwork.go b/management/server/migration/migration_agentnetwork.go index c6cda56c2..7e19c0298 100644 --- a/management/server/migration/migration_agentnetwork.go +++ b/management/server/migration/migration_agentnetwork.go @@ -33,15 +33,6 @@ func (agentNetworkSettingsMigration) TableName() string { return "agent_network_ // bootstrap always wrote both, so such a row indicates corruption and the // migration fails loudly rather than leaving an empty domain to collide with // the unique index confusingly. -// -// The transaction is real only on sqlite and postgres, where DDL is -// transactional. MySQL implicitly commits around every ALTER TABLE, so there -// each step stands alone; what makes an interrupted run resumable on MySQL is -// that every step is guarded by the schema state it changes — the entry check -// fires while either legacy column remains, the adds skip existing columns, -// the backfill and its loud-failure check run only while the legacy cluster -// column exists (they provably completed before any drop), and each drop -// skips what is already gone. func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error { model := &agentNetworkSettingsMigration{} migrator := db.Migrator() @@ -66,14 +57,9 @@ func MigrateAgentNetworkSettingsToDomain(ctx context.Context, db *gorm.DB) error } if hasCluster { - concat := "subdomain || '.' || cluster" - if tx.Name() == "mysql" { - concat = "CONCAT(subdomain, '.', cluster)" - } - res := tx.Exec(fmt.Sprintf( - "UPDATE agent_network_settings SET domain = %s, proxy_address = cluster WHERE (domain IS NULL OR domain = '') AND cluster <> '' AND subdomain <> ''", - concat, - )) + res := tx.Exec( + "UPDATE agent_network_settings SET domain = subdomain || '.' || cluster, proxy_address = cluster WHERE (domain IS NULL OR domain = '') AND cluster <> '' AND subdomain <> ''", + ) if res.Error != nil { return fmt.Errorf("backfill agent_network_settings domain: %w", res.Error) } diff --git a/management/server/migration/migration_test.go b/management/server/migration/migration_test.go index 868332fdf..54c587a8a 100644 --- a/management/server/migration/migration_test.go +++ b/management/server/migration/migration_test.go @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -32,17 +31,6 @@ func setupDatabase(t *testing.T) *gorm.DB { var dsn string var cleanup func() switch os.Getenv("NETBIRD_STORE_ENGINE") { - case "mysql": - cleanup, dsn, err = testutil.CreateMysqlTestContainer() - if err != nil { - t.Fatalf("Failed to create MySQL test container: %v", err) - } - - if dsn == "" { - t.Fatal("MySQL connection string is empty, ensure the test container is running") - } - - db, err = gorm.Open(mysql.Open(dsn+"?charset=utf8&parseTime=True&loc=Local"), &gorm.Config{}) case "postgres": cleanup, dsn, err = testutil.CreatePostgresTestContainer() if err != nil { @@ -597,9 +585,8 @@ func TestCleanupOrphanedResources_Idempotent(t *testing.T) { } func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) { - engine := os.Getenv("NETBIRD_STORE_ENGINE") - if engine != "postgres" && engine != "mysql" { - t.Skip("FK constraint early-exit test requires postgres or mysql") + if os.Getenv("NETBIRD_STORE_ENGINE") != "postgres" { + t.Skip("FK constraint early-exit test requires postgres") } db := setupDatabase(t) @@ -614,24 +601,12 @@ func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) { require.NoError(t, db.Create(&testChildWithFK{ID: "c1", ParentID: "p1"}).Error) require.NoError(t, db.Create(&testChildWithFK{ID: "c2", ParentID: "p2"}).Error) - switch engine { - case "postgres": - require.NoError(t, db.Exec("ALTER TABLE test_children DROP CONSTRAINT fk_test_children_parent").Error) - require.NoError(t, db.Exec("DELETE FROM test_parents WHERE id = ?", "p2").Error) - require.NoError(t, db.Exec( - "ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent "+ - "FOREIGN KEY (parent_id) REFERENCES test_parents(id) NOT VALID", - ).Error) - case "mysql": - require.NoError(t, db.Exec("SET FOREIGN_KEY_CHECKS = 0").Error) - require.NoError(t, db.Exec("ALTER TABLE test_children DROP FOREIGN KEY fk_test_children_parent").Error) - require.NoError(t, db.Exec("DELETE FROM test_parents WHERE id = ?", "p2").Error) - require.NoError(t, db.Exec( - "ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent "+ - "FOREIGN KEY (parent_id) REFERENCES test_parents(id)", - ).Error) - require.NoError(t, db.Exec("SET FOREIGN_KEY_CHECKS = 1").Error) - } + require.NoError(t, db.Exec("ALTER TABLE test_children DROP CONSTRAINT fk_test_children_parent").Error) + require.NoError(t, db.Exec("DELETE FROM test_parents WHERE id = ?", "p2").Error) + require.NoError(t, db.Exec( + "ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent "+ + "FOREIGN KEY (parent_id) REFERENCES test_parents(id) NOT VALID", + ).Error) err = migration.CleanupOrphanedResources[testChildWithFK, testParent](context.Background(), db, "parent_id") require.NoError(t, err) @@ -717,8 +692,6 @@ func TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated(t *testing.T) { require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{})) require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{})) - // Timestamp must be set explicitly: a zero time.Time serialises as - // '0000-00-00 00:00:00', which MySQL rejects under strict mode. require.NoError(t, db.Create(&agentNetworkTypes.AgentNetworkUsage{ ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6", Timestamp: time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC), @@ -819,10 +792,9 @@ func TestMigrateAgentNetworkSettingsToDomain_FailsOnUnmigratableRow(t *testing.T assert.Contains(t, err.Error(), "resolve them manually", "the error must tell the operator what to do") } -// partialAgentNetworkSettings models the one non-atomic state a MySQL run can -// be interrupted in: DDL auto-commits there, so a crash between the two legacy -// column drops leaves subdomain behind while cluster (and the completed -// backfill) are already committed. +// partialAgentNetworkSettings models a schema left between the two legacy +// column drops: subdomain is still present while cluster is gone and the +// backfill has already completed. type partialAgentNetworkSettings struct { AccountID string `gorm:"primaryKey"` Subdomain string @@ -832,8 +804,8 @@ type partialAgentNetworkSettings struct { func (partialAgentNetworkSettings) TableName() string { return "agent_network_settings" } -// TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop pins MySQL -// resumability: a rerun over the interrupted state must remove the leftover +// TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop pins +// resumability: a rerun over the partial state must remove the leftover // subdomain column without re-running the backfill (the cluster column that // feeds it is gone) and without touching the migrated values. func TestMigrateAgentNetworkSettingsToDomain_ResumesAfterPartialDrop(t *testing.T) { diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 691630a3b..b75dd1b72 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -1566,8 +1566,7 @@ func Test_RegisterPeerBySetupKey(t *testing.T) { func Test_RegisterPeerRollbackOnFailure(t *testing.T) { engine := os.Getenv("NETBIRD_STORE_ENGINE") - if engine == "sqlite" || engine == "mysql" || engine == "" { - // we intentionally disabled foreign keys in mysql + if engine == "sqlite" || engine == "" { t.Skip("Skipping test because store is not respecting foreign keys") } if runtime.GOOS == "windows" { @@ -2219,10 +2218,6 @@ func Test_IsUniqueConstraintError(t *testing.T) { name: "PostgreSQL uniqueness error", engine: types.PostgresStoreEngine, }, - { - name: "MySQL uniqueness error", - engine: types.MysqlStoreEngine, - }, { name: "SQLite uniqueness error", engine: types.SqliteStoreEngine, diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index b705ba0a2..1734129c3 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -33,7 +33,6 @@ import ( const ( idQueryCondition = "id = ?" keyQueryCondition = "key = ?" - mysqlKeyQueryCondition = "`key` = ?" accountAndIDQueryCondition = "account_id = ? and id = ?" accountAndAnyIDQueryCondition = "account_id = ? and (id = ? or public_id = ?)" accountAndPeerIDQueryCondition = "account_id = ? and peer_id = ?" @@ -119,13 +118,6 @@ func (s *SqlStore) pgxPool() *pgxpool.Pool { return s.conn.Pool(s.tx) } -func GetKeyQueryCondition(s *SqlStore) string { - if s.conn.Engine() == db.MysqlStoreEngine { - return mysqlKeyQueryCondition - } - return keyQueryCondition -} - // AcquireGlobalLock acquires global lock across all the accounts and returns a function that releases the lock func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) { log.WithContext(ctx).Tracef("acquiring global lock") @@ -174,15 +166,6 @@ func NewPostgresqlStore(ctx context.Context, dsn string, metrics telemetry.AppMe return newStore(ctx, conn, metrics, skipMigration) } -// NewMysqlStore creates a new MySQL store. -func NewMysqlStore(ctx context.Context, dsn string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { - conn, err := db.OpenMysql(ctx, dsn) - if err != nil { - return nil, err - } - return newStore(ctx, conn, metrics, skipMigration) -} - // NewSqliteStoreFromFileStore restores a store from FileStore and stores SQLite DB in the file located in datadir. func NewSqliteStoreFromFileStore(ctx context.Context, fileStore *FileStore, dataDir string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { store, err := NewSqliteStore(ctx, dataDir, metrics, skipMigration) @@ -240,11 +223,6 @@ func NewPostgresqlStoreForTests(ctx context.Context, dsn string, metrics telemet return newStore(ctx, conn, metrics, skipMigration) } -// NewMysqlStoreFromSqlStore restores a store from SqlStore and stores MySQL DB. -func NewMysqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, dsn string, metrics telemetry.AppMetrics) (*SqlStore, error) { - return newMysqlStoreFromSqlStore(ctx, sqliteStore, dsn, metrics, false) -} - // seedFromSqliteStore copies the installation ID and the accounts of the // sqlite seed store into a freshly created engine store. func seedFromSqliteStore(ctx context.Context, store, sqliteStore *SqlStore) error { @@ -259,20 +237,6 @@ func seedFromSqliteStore(ctx context.Context, store, sqliteStore *SqlStore) erro return nil } -func newMysqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, dsn string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { - store, err := NewMysqlStore(ctx, dsn, metrics, skipMigration) - if err != nil { - return nil, err - } - - if err := seedFromSqliteStore(ctx, store, sqliteStore); err != nil { - _ = store.Close(ctx) - return nil, err - } - - return store, nil -} - // ExecuteInTransaction runs operation in a transaction. A store that is already // bound to one joins it instead of opening a second, independent transaction. func (s *SqlStore) ExecuteInTransaction(ctx context.Context, operation func(store Store) error) error { diff --git a/management/server/store/sql_store_account.go b/management/server/store/sql_store_account.go index 3780de77d..5c488ece8 100644 --- a/management/server/store/sql_store_account.go +++ b/management/server/store/sql_store_account.go @@ -1084,10 +1084,8 @@ func (s *SqlStore) SaveAccountSettings(ctx context.Context, accountID string, se return status.Errorf(status.Internal, "failed to save account settings to store") } - // MySQL reports RowsAffected=0 for no-op updates where values don't change, - // unlike SQLite/Postgres which report matched rows. Skip the check since the - // caller (UpdateAccountSettings) already verified the account exists via - // GetAccountSettings with LockingStrengthUpdate. + // No RowsAffected check: the caller (UpdateAccountSettings) already verified + // the account exists via GetAccountSettings with LockingStrengthUpdate. return nil } diff --git a/management/server/store/sql_store_group_test.go b/management/server/store/sql_store_group_test.go index 58291ab44..abb7fa8de 100644 --- a/management/server/store/sql_store_group_test.go +++ b/management/server/store/sql_store_group_test.go @@ -3,7 +3,6 @@ package store import ( "context" "fmt" - "os" "testing" "github.com/stretchr/testify/require" @@ -69,10 +68,6 @@ func TestSqlStore_GetGroupsByIDs(t *testing.T) { } func TestSqlStore_CreateGroup(t *testing.T) { - if os.Getenv("CI") == "true" { - t.Log("Skipping MySQL test on CI") - } - t.Setenv("NETBIRD_STORE_ENGINE", string(types.MysqlStoreEngine)) store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) t.Cleanup(cleanup) require.NoError(t, err) diff --git a/management/server/store/sql_store_idp_migration.go b/management/server/store/sql_store_idp_migration.go index 760d2967c..46ccd7603 100644 --- a/management/server/store/sql_store_idp_migration.go +++ b/management/server/store/sql_store_idp_migration.go @@ -55,7 +55,6 @@ func (s *SqlStore) ListUsers(ctx context.Context) ([]*types.User, error) { } // txDeferFKConstraints defers foreign key constraint checks for the duration of the transaction. -// MySQL is already handled by s.transaction (SET FOREIGN_KEY_CHECKS = 0). func (s *SqlStore) txDeferFKConstraints(tx *gorm.DB) error { if s.conn.Engine() == types.SqliteStoreEngine { return tx.Exec("PRAGMA defer_foreign_keys = ON").Error diff --git a/management/server/store/sql_store_peer.go b/management/server/store/sql_store_peer.go index 657d2a6e0..77642eea7 100644 --- a/management/server/store/sql_store_peer.go +++ b/management/server/store/sql_store_peer.go @@ -375,7 +375,7 @@ func (s *SqlStore) GetAccountByPeerID(ctx context.Context, peerID string) (*type func (s *SqlStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) (*types.Account, error) { var peer nbpeer.Peer - result := s.db.Select("account_id").Take(&peer, GetKeyQueryCondition(s), peerKey) + result := s.db.Select("account_id").Take(&peer, keyQueryCondition, peerKey) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { @@ -394,7 +394,7 @@ func (s *SqlStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) ( func (s *SqlStore) GetAccountIDByPeerPubKey(ctx context.Context, peerKey string) (string, error) { var peer nbpeer.Peer var accountID string - result := s.db.Model(&peer).Select("account_id").Where(GetKeyQueryCondition(s), peerKey).Take(&accountID) + result := s.db.Model(&peer).Select("account_id").Where(keyQueryCondition, peerKey).Take(&accountID) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return "", status.Errorf(status.NotFound, "account not found: index lookup failed") @@ -483,7 +483,7 @@ func (s *SqlStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength Locking } var peer nbpeer.Peer - result := tx.Take(&peer, GetKeyQueryCondition(s), peerKey) + result := tx.Take(&peer, keyQueryCondition, peerKey) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { @@ -914,7 +914,7 @@ func (s *SqlStore) GetUserIDByPeerKey(ctx context.Context, lockStrength LockingS var userID string result := tx.Model(&nbpeer.Peer{}). Select("user_id"). - Take(&userID, GetKeyQueryCondition(s), peerKey) + Take(&userID, keyQueryCondition, peerKey) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { @@ -935,7 +935,7 @@ func (s *SqlStore) GetPeerIDByKey(ctx context.Context, lockStrength LockingStren var peerID string result := tx.Model(&nbpeer.Peer{}). Select("id"). - Where(GetKeyQueryCondition(s), key). + Where(keyQueryCondition, key). Limit(1). Scan(&peerID) if result.Error != nil { diff --git a/management/server/store/sql_store_pgx_parity_test.go b/management/server/store/sql_store_pgx_parity_test.go index 1f17817d0..4af0b5540 100644 --- a/management/server/store/sql_store_pgx_parity_test.go +++ b/management/server/store/sql_store_pgx_parity_test.go @@ -13,7 +13,7 @@ import ( ) // TestPgxServiceColumnsMatchGorm guards the Postgres pgx read path against -// drifting from the gorm model. The SQLite/MySQL gorm path loads rows by struct, +// drifting from the gorm model. The SQLite gorm path loads rows by struct, // so a new column on a model is picked up automatically, but the hand-written // pgx SELECT in sql_store.go must be updated by hand. This test fails when a // gorm column is missing from the pgx column list, which otherwise silently diff --git a/management/server/store/sql_store_proxy.go b/management/server/store/sql_store_proxy.go index bdccd282c..c6a08b508 100644 --- a/management/server/store/sql_store_proxy.go +++ b/management/server/store/sql_store_proxy.go @@ -21,8 +21,8 @@ func (s *SqlStore) GetProxyMetrics(ctx context.Context) (ProxyMetrics, error) { var m ProxyMetrics activeCutoff := time.Now().Add(-proxyActiveThreshold) - // COUNT(DISTINCT ... CASE WHEN ...) is portable across sqlite/postgres - // (MySQL too) and keeps the round-trip to one. proxy.StatusConnected + // COUNT(DISTINCT ... CASE WHEN ...) is portable across sqlite and postgres + // and keeps the round-trip to one. proxy.StatusConnected // is the same string the cluster-capability queries use; the active // window matches the cluster-capability semantics (only proxies // heartbeating within ~2 * heartbeat interval count as connected). @@ -292,7 +292,7 @@ func (s *SqlStore) GetProxyClusters(ctx context.Context, accountID string) ([]pr "cluster_address AS address, "+ // COUNT(CASE WHEN ... THEN 1 END) counts only non-NULL — i.e. only // rows that satisfy the predicate — so it works portably across - // sqlite/postgres/mysql without dialect-specific FILTER syntax. + // sqlite and postgres without dialect-specific FILTER syntax. "COUNT(CASE WHEN status = ? AND last_seen > ? THEN 1 END) AS connected_proxies, "+ // MAX(CASE …) > 0 expresses BOOL_OR in a way Postgres tolerates // (Postgres can't MAX a boolean column). diff --git a/management/server/store/sql_store_setup_key.go b/management/server/store/sql_store_setup_key.go index ac42ef725..ef63b2fca 100644 --- a/management/server/store/sql_store_setup_key.go +++ b/management/server/store/sql_store_setup_key.go @@ -18,7 +18,7 @@ import ( func (s *SqlStore) GetAccountBySetupKey(ctx context.Context, setupKey string) (*types.Account, error) { var key types.SetupKey - result := s.db.Select("account_id").Take(&key, GetKeyQueryCondition(s), setupKey) + result := s.db.Select("account_id").Take(&key, keyQueryCondition, setupKey) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, status.NewSetupKeyNotFoundError(setupKey) @@ -99,7 +99,7 @@ func (s *SqlStore) getSetupKeys(ctx context.Context, accountID string) ([]types. func (s *SqlStore) GetAccountIDBySetupKey(ctx context.Context, setupKey string) (string, error) { var accountID string - result := s.db.Model(&types.SetupKey{}).Select("account_id").Where(GetKeyQueryCondition(s), setupKey).Take(&accountID) + result := s.db.Model(&types.SetupKey{}).Select("account_id").Where(keyQueryCondition, setupKey).Take(&accountID) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return "", status.NewSetupKeyNotFoundError(setupKey) @@ -123,7 +123,7 @@ func (s *SqlStore) GetSetupKeyBySecret(ctx context.Context, lockStrength Locking var setupKey types.SetupKey result := tx. - Take(&setupKey, GetKeyQueryCondition(s), key) + Take(&setupKey, keyQueryCondition, key) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 24f9a410d..867657d8b 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -164,7 +164,6 @@ func TestMigrate(t *testing.T) { err = migratePreAuto(context.Background(), store.(*SqlStore).db) require.NoError(t, err, "Migration should not fail on migrated db") - } func newAccount(store Store, id int) error { @@ -352,10 +351,6 @@ func TestSqlStore_DatabaseBlocking(t *testing.T) { } func TestSqlStore_ExecuteInTransaction_Timeout(t *testing.T) { - if os.Getenv("NETBIRD_STORE_ENGINE") == "mysql" { - t.Skip("Skipping timeout test for MySQL") - } - t.Setenv("NB_STORE_TRANSACTION_TIMEOUT", "1s") store, cleanup, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir()) @@ -429,49 +424,6 @@ func TestNewSqliteStore_BusyTimeoutRespectsUserOverride(t *testing.T) { } } -func TestSqlStore_ExecuteInTransaction_RestoresForeignKeyChecksOnMysql(t *testing.T) { - runTestForAllEngines(t, "", func(t *testing.T, store Store) { - sqlStore := store.(*SqlStore) - if sqlStore.conn.Engine() != types.MysqlStoreEngine { - t.Skip("FOREIGN_KEY_CHECKS is MySQL specific") - } - sqlDB, err := sqlStore.GetDB().DB() - require.NoError(t, err) - sqlDB.SetMaxOpenConns(1) - ctx := context.Background() - - foreignKeyChecks := func() int { - var enabled int - require.NoError(t, sqlStore.GetDB().Raw("SELECT @@SESSION.foreign_key_checks").Scan(&enabled).Error) - return enabled - } - - err = store.ExecuteInTransaction(ctx, func(Store) error { return assert.AnError }) - require.ErrorIs(t, err, assert.AnError) - assert.Equal(t, 1, foreignKeyChecks()) - - require.Panics(t, func() { - _ = store.ExecuteInTransaction(ctx, func(Store) error { panic("boom") }) - }) - assert.Equal(t, 1, foreignKeyChecks()) - - err = sqlStore.transaction(ctx, func(*gorm.DB) error { return assert.AnError }) - require.ErrorIs(t, err, assert.AnError) - assert.Equal(t, 1, foreignKeyChecks()) - - err = store.ExecuteInTransaction(ctx, func(transaction Store) error { - bound := transaction.(*SqlStore) - require.NoError(t, bound.transaction(ctx, func(*gorm.DB) error { return nil })) - var enabled int - require.NoError(t, bound.GetDB().Raw("SELECT @@SESSION.foreign_key_checks").Scan(&enabled).Error) - assert.Equal(t, 0, enabled, "a savepoint must not re-enable FK checks for the rest of the transaction") - return nil - }) - require.NoError(t, err) - assert.Equal(t, 1, foreignKeyChecks()) - }) -} - func TestSqlStore_Transaction_RollsBackOnError(t *testing.T) { runTestForAllEngines(t, "../testdata/extended-store.sql", func(t *testing.T, store Store) { ctx := context.Background() diff --git a/management/server/store/store.go b/management/server/store/store.go index 174e97b48..6d20c1e2b 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -4,7 +4,6 @@ package store import ( "context" - "database/sql" "errors" "fmt" "net" @@ -20,7 +19,6 @@ import ( "github.com/google/uuid" log "github.com/sirupsen/logrus" - "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -452,8 +450,6 @@ type AgentNetworkMetrics struct { const ( PostgresDsnEnv = "NB_STORE_ENGINE_POSTGRES_DSN" PostgresDsnEnvLegacy = "NETBIRD_STORE_ENGINE_POSTGRES_DSN" - mysqlDsnEnv = "NB_STORE_ENGINE_MYSQL_DSN" - mysqlDsnEnvLegacy = "NETBIRD_STORE_ENGINE_MYSQL_DSN" ) // lookupDSNEnv checks the NB_ env var first, then falls back to the legacy NETBIRD_ env var. @@ -464,7 +460,7 @@ func lookupDSNEnv(nbKey, legacyKey string) (string, bool) { return os.LookupEnv(legacyKey) } -var supportedEngines = []types.Engine{types.SqliteStoreEngine, types.PostgresStoreEngine, types.MysqlStoreEngine} +var supportedEngines = []types.Engine{types.SqliteStoreEngine, types.PostgresStoreEngine} func getStoreEngineFromEnv() types.Engine { // NETBIRD_STORE_ENGINE supposed to be used in tests. Otherwise, rely on the config file. @@ -474,7 +470,7 @@ func getStoreEngineFromEnv() types.Engine { } value := types.Engine(strings.ToLower(kind)) - if slices.Contains(supportedEngines, value) { + if slices.Contains(supportedEngines, value) || value == types.MysqlStoreEngine { return value } @@ -540,12 +536,8 @@ func OpenConn(ctx context.Context, kind types.Engine, dataDir string) (*db.Conn, } return db.OpenPostgres(ctx, dsn, db.DefaultPoolConfig) case types.MysqlStoreEngine: - log.WithContext(ctx).Info("using MySQL store engine") - dsn, ok := lookupDSNEnv(mysqlDsnEnv, mysqlDsnEnvLegacy) - if !ok { - return nil, fmt.Errorf("%s is not set", mysqlDsnEnv) - } - return db.OpenMysql(ctx, dsn) + return nil, fmt.Errorf("the MySQL store engine is no longer supported. Please refer to the documentation for " + + "migrating to PostgreSQL: https://docs.netbird.io/selfhosted/migration/mysql-to-postgresql") default: return nil, fmt.Errorf("unsupported kind of store: %s", kind) } @@ -725,7 +717,7 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) ( } if filename != "" { - err = LoadSQL(conn.DB(nil), filename, translaterForSql(kind)) + err = LoadSQL(conn.DB(nil), filename) if err != nil { _ = conn.Close() return nil, nil, fmt.Errorf("failed to load SQL file: %v", err) @@ -761,6 +753,7 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) ( return nil, nil, fmt.Errorf("failed to create test store after %d attempts: %v", maxRetries, err) } +// NewTestStore creates a test store and optionally loads SQL after migrating its schema. func NewTestStore(ctx context.Context, filename string, dataDir string) (Store, func(), error) { kind := getStoreEngineFromEnv() if kind == "" { @@ -786,7 +779,7 @@ func NewTestStore(ctx context.Context, filename string, dataDir string) (Store, } if filename != "" { - err = LoadSQL(sqlStore.db, filename, translaterForSql(kind)) + err = LoadSQL(sqlStore.db, filename) if err != nil { _ = sqlStore.Close(ctx) return nil, nil, fmt.Errorf("failed to load SQL file: %v", err) @@ -802,18 +795,6 @@ func NewTestStore(ctx context.Context, filename string, dataDir string) (Store, return sqlStore, cleanup, nil } -// use to translate double-quote escaped reserved names (ansi) -// to mysql tick-escaped ones -func translaterForSql(kind types.Engine) *strings.Replacer { - if kind != types.MysqlStoreEngine { - return strings.NewReplacer() - } - return strings.NewReplacer( - "\"key\"", "`key`", - "\"groups\"", "`groups`", - ) -} - func addAllGroupToAccount(ctx context.Context, store Store) error { allAccounts := store.GetAllAccounts(ctx) for _, account := range allAccounts { @@ -843,9 +824,7 @@ func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.En var err error switch kind { case types.PostgresStoreEngine: - store, cleanup, err = newReusedPostgresStore(ctx, sqliteStore, kind) - case types.MysqlStoreEngine: - store, cleanup, err = newReusedMysqlStore(ctx, sqliteStore, kind) + store, cleanup, err = newReusedPostgresStore(ctx, sqliteStore) default: cleanup = func() { // sqlite doesn't need to be cleaned up @@ -877,7 +856,7 @@ func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.En return store, closeConnection, nil } -func newReusedPostgresStore(ctx context.Context, sqliteStore *SqlStore, kind types.Engine) (*SqlStore, func(), error) { +func newReusedPostgresStore(ctx context.Context, sqliteStore *SqlStore) (*SqlStore, func(), error) { dsn, ok := lookupDSNEnv(PostgresDsnEnv, PostgresDsnEnvLegacy) if !ok || dsn == "" { var err error @@ -891,7 +870,7 @@ func newReusedPostgresStore(ctx context.Context, sqliteStore *SqlStore, kind typ return nil, nil, fmt.Errorf("%s is not set", PostgresDsnEnv) } - db, err := openDBWithRetry(dsn, kind, 5) + db, err := openDBWithRetry(dsn, 5) if err != nil { return nil, nil, fmt.Errorf("failed to open postgres connection: %v", err) } @@ -902,7 +881,7 @@ func newReusedPostgresStore(ctx context.Context, sqliteStore *SqlStore, kind typ return nil, nil, err } - dsn, cleanup, err := createRandomDB(dsn, db, kind, template) + dsn, cleanup, err := createRandomDB(dsn, db, template) closeGormDB(db) @@ -928,74 +907,10 @@ func newReusedPostgresStore(ctx context.Context, sqliteStore *SqlStore, kind typ return store, cleanup, nil } -func newReusedMysqlStore(ctx context.Context, sqliteStore *SqlStore, kind types.Engine) (*SqlStore, func(), error) { - dsn, ok := lookupDSNEnv(mysqlDsnEnv, mysqlDsnEnvLegacy) - if !ok || dsn == "" { - var err error - _, dsn, err = testutil.CreateMysqlTestContainer() - if err != nil { - return nil, nil, err - } - } - - if dsn == "" { - return nil, nil, fmt.Errorf("%s is not set", mysqlDsnEnv) - } - - db, err := openDBWithRetry(dsn, kind, 5) - if err != nil { - return nil, nil, fmt.Errorf("failed to open mysql connection: %v", err) - } - - sqlDB, err := db.DB() - if err != nil { - return nil, nil, fmt.Errorf("failed to get underlying sql.DB: %v", err) - } - sqlDB.SetMaxOpenConns(1) - sqlDB.SetMaxIdleConns(1) - - tableDDL, err := mysqlSchemaTemplate(ctx, dsn, db) - if err != nil { - sqlDB.Close() - return nil, nil, err - } - - dsn, cleanup, err := createRandomDB(dsn, db, kind, "") - - sqlDB.Close() - - if err != nil { - return nil, nil, err - } - - if err := cloneMysqlSchema(ctx, dsn, tableDDL); err != nil { - cleanup() - return nil, nil, err - } - - var store *SqlStore - if sqliteStore != nil { - store, err = newMysqlStoreFromSqlStore(ctx, sqliteStore, dsn, nil, true) - if err != nil { - cleanup() - return nil, nil, err - } - } else { - store, err = NewMysqlStore(ctx, dsn, nil, false) - if err != nil { - cleanup() - return nil, nil, err - } - } - - return store, cleanup, nil -} - // schemaTemplates remembers, per engine and server, a database that went // through the full migration once in this process. Every later test database // is cloned from it, so a test pays for CREATE DATABASE and a schema copy -// instead of the 40-table AutoMigrate plus every pre and post migration, which -// is what made each MySQL test store cost well over a second in CI. +// instead of the 40-table AutoMigrate plus every pre and post migration. var ( schemaTemplatesMu sync.Mutex schemaTemplates = map[string]*schemaTemplate{} @@ -1003,10 +918,6 @@ var ( type schemaTemplate struct { dbName string - // tableDDL holds the CREATE TABLE statements of the template. MySQL has no - // server-side database template, so the schema is replayed statement by - // statement into each test database. - tableDDL []string } func schemaTemplateKey(engine types.Engine, dsn string) string { @@ -1046,112 +957,6 @@ func postgresSchemaTemplate(ctx context.Context, baseDSN string, admin *gorm.DB) return name, nil } -// mysqlSchemaTemplate returns the CREATE TABLE statements of a fully migrated -// database, migrating one on first use. -func mysqlSchemaTemplate(ctx context.Context, baseDSN string, admin *gorm.DB) ([]string, error) { - schemaTemplatesMu.Lock() - defer schemaTemplatesMu.Unlock() - - key := schemaTemplateKey(types.MysqlStoreEngine, baseDSN) - if tpl, ok := schemaTemplates[key]; ok { - return tpl.tableDDL, nil - } - - name := newTestDBName("test_template") - if err := admin.Exec(fmt.Sprintf("CREATE DATABASE %s", name)).Error; err != nil { - return nil, fmt.Errorf("create mysql template database: %w", err) - } - - tplStore, err := NewMysqlStore(ctx, replaceDBName(baseDSN, name), nil, false) - if err != nil { - dropDatabase(admin, name) - return nil, fmt.Errorf("migrate mysql template database: %w", err) - } - tableDDL, err := mysqlTableDDL(ctx, tplStore.db, name) - tplStore.Close(ctx) - if err != nil { - dropDatabase(admin, name) - return nil, err - } - - schemaTemplates[key] = &schemaTemplate{dbName: name, tableDDL: tableDDL} - return tableDDL, nil -} - -func mysqlTableDDL(ctx context.Context, db *gorm.DB, dbName string) ([]string, error) { - sqlDB, err := db.DB() - if err != nil { - return nil, err - } - - tables, err := mysqlTableNames(ctx, sqlDB, dbName) - if err != nil { - return nil, err - } - - tableDDL := make([]string, 0, len(tables)) - for _, table := range tables { - var name, createStmt string - row := sqlDB.QueryRowContext(ctx, fmt.Sprintf("SHOW CREATE TABLE %s.%s", dbName, table)) - if err := row.Scan(&name, &createStmt); err != nil { - return nil, fmt.Errorf("read create statement of %s: %w", table, err) - } - tableDDL = append(tableDDL, createStmt) - } - return tableDDL, nil -} - -func mysqlTableNames(ctx context.Context, sqlDB *sql.DB, dbName string) ([]string, error) { - rows, err := sqlDB.QueryContext(ctx, fmt.Sprintf("SHOW TABLES FROM %s", dbName)) - if err != nil { - return nil, fmt.Errorf("list template tables: %w", err) - } - defer rows.Close() - - var tables []string - for rows.Next() { - var table string - if err := rows.Scan(&table); err != nil { - return nil, fmt.Errorf("scan template table name: %w", err) - } - tables = append(tables, table) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("list template tables: %w", err) - } - return tables, nil -} - -// cloneMysqlSchema replays the template's CREATE TABLE statements into the -// database the DSN points at. -func cloneMysqlSchema(ctx context.Context, dsn string, tableDDL []string) error { - gormDB, err := gorm.Open(mysql.Open(db.MysqlDSN(dsn)), db.GormConfig()) - if err != nil { - return fmt.Errorf("connect to test database: %w", err) - } - sqlDB, err := gormDB.DB() - if err != nil { - return err - } - defer sqlDB.Close() - - // The statements come out of SHOW TABLES in name order, not dependency - // order, and their foreign keys reference tables of the session's default - // database. Pin a single connection so the session setting below covers - // every statement, and connect straight to the new database so unqualified - // references land there. - sqlDB.SetMaxOpenConns(1) - if _, err := sqlDB.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS = 0"); err != nil { - return fmt.Errorf("disable foreign key checks: %w", err) - } - for _, stmt := range tableDDL { - if _, err := sqlDB.ExecContext(ctx, stmt); err != nil { - return fmt.Errorf("replay table definition: %w", err) - } - } - return nil -} - // dropDatabase removes a template that never became usable, so a failed setup // does not leave it behind on a shared server. The server may still be tearing // down the sessions the failed migration held, so the drop retries while @@ -1168,18 +973,12 @@ func closeGormDB(db *gorm.DB) { } } -func openDBWithRetry(dsn string, engine types.Engine, maxRetries int) (*gorm.DB, error) { +func openDBWithRetry(dsn string, maxRetries int) (*gorm.DB, error) { var gormDB *gorm.DB var err error for i := range maxRetries { - switch engine { - case types.PostgresStoreEngine: - gormDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) - case types.MysqlStoreEngine: - gormDB, err = gorm.Open(mysql.Open(db.MysqlDSN(dsn)), &gorm.Config{}) - } - + gormDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) if err == nil { return gormDB, nil } @@ -1193,13 +992,13 @@ func openDBWithRetry(dsn string, engine types.Engine, maxRetries int) (*gorm.DB, return nil, err } -// createRandomDB creates a uniquely named database for one test. On postgres a -// non-empty template is copied server-side with CREATE DATABASE ... TEMPLATE. -func createRandomDB(dsn string, admin *gorm.DB, engine types.Engine, template string) (string, func(), error) { +// createRandomDB creates a uniquely named database for one test. A non-empty +// template is copied server-side with CREATE DATABASE ... TEMPLATE. +func createRandomDB(dsn string, admin *gorm.DB, template string) (string, func(), error) { dbName := newTestDBName("test_db") createStmt := fmt.Sprintf("CREATE DATABASE %s", dbName) - if template != "" && engine == types.PostgresStoreEngine { + if template != "" { createStmt = fmt.Sprintf("CREATE DATABASE %s TEMPLATE %s", dbName, template) } if err := execWithTemplateRetry(admin, createStmt); err != nil { @@ -1209,59 +1008,28 @@ func createRandomDB(dsn string, admin *gorm.DB, engine types.Engine, template st originalDSN := dsn cleanup := func() { - var dropDB *gorm.DB - var err error - - switch engine { - case types.PostgresStoreEngine: - dropDB, err = gorm.Open(postgres.Open(originalDSN), &gorm.Config{ - SkipDefaultTransaction: true, - PrepareStmt: false, - Logger: logger.Default.LogMode(logger.Info), - }) - if err != nil { - log.Errorf("failed to connect for dropping database %s: %v", dbName, err) - return - } - defer func() { - if sqlDB, _ := dropDB.DB(); sqlDB != nil { - sqlDB.Close() - } - }() - + dropDB, err := gorm.Open(postgres.Open(originalDSN), &gorm.Config{ + SkipDefaultTransaction: true, + PrepareStmt: false, + Logger: logger.Default.LogMode(logger.Info), + }) + if err != nil { + log.Errorf("failed to connect for dropping database %s: %v", dbName, err) + return + } + defer func() { if sqlDB, _ := dropDB.DB(); sqlDB != nil { - sqlDB.SetMaxOpenConns(1) - sqlDB.SetMaxIdleConns(0) - sqlDB.SetConnMaxLifetime(time.Second) + sqlDB.Close() } + }() - err = dropDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", dbName)).Error - - case types.MysqlStoreEngine: - dropDB, err = gorm.Open(mysql.Open(db.MysqlDSN(originalDSN)), &gorm.Config{ - SkipDefaultTransaction: true, - PrepareStmt: false, - }) - if err != nil { - log.Errorf("failed to connect for dropping database %s: %v", dbName, err) - return - } - defer func() { - if sqlDB, _ := dropDB.DB(); sqlDB != nil { - sqlDB.Close() - } - }() - - if sqlDB, _ := dropDB.DB(); sqlDB != nil { - sqlDB.SetMaxOpenConns(1) - sqlDB.SetMaxIdleConns(0) - sqlDB.SetConnMaxLifetime(time.Second) - } - - err = dropDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", dbName)).Error + if sqlDB, _ := dropDB.DB(); sqlDB != nil { + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(0) + sqlDB.SetConnMaxLifetime(time.Second) } - if err != nil { + if err := dropDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", dbName)).Error; err != nil { log.Errorf("failed to drop database %s: %v", dbName, err) } } @@ -1288,7 +1056,8 @@ func replaceDBName(dsn, newDBName string) string { return re.ReplaceAllString(dsn, `${pre}`+newDBName+`${post}`) } -func LoadSQL(db *gorm.DB, filepath string, translator *strings.Replacer) error { +// LoadSQL executes the SQL statements from the given file. +func LoadSQL(db *gorm.DB, filepath string) error { sqlContent, err := os.ReadFile(filepath) if err != nil { return err @@ -1297,7 +1066,7 @@ func LoadSQL(db *gorm.DB, filepath string, translator *strings.Replacer) error { queries := strings.Split(string(sqlContent), ";") for _, query := range queries { - query = translator.Replace(strings.TrimSpace(query)) + query = strings.TrimSpace(query) if query != "" { err := db.Exec(query).Error if err != nil { diff --git a/management/server/testutil/store.go b/management/server/testutil/store.go index 3854c1173..ddb905d3e 100644 --- a/management/server/testutil/store.go +++ b/management/server/testutil/store.go @@ -8,74 +8,12 @@ import ( log "github.com/sirupsen/logrus" "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/modules/mysql" "github.com/testcontainers/testcontainers-go/modules/postgres" testcontainersredis "github.com/testcontainers/testcontainers-go/modules/redis" "github.com/testcontainers/testcontainers-go/wait" ) -var ( - pgContainer *postgres.PostgresContainer - mysqlContainer *mysql.MySQLContainer -) - -// CreateMysqlTestContainer creates a new MySQL container for testing. -func CreateMysqlTestContainer() (func(), string, error) { - ctx := context.Background() - - if mysqlContainer != nil { - connStr, err := mysqlContainer.ConnectionString(ctx) - if err != nil { - return nil, "", err - } - return noOpCleanup, connStr, nil - } - - var err error - mysqlContainer, err = mysql.Run(ctx, - "mlsmaycon/warmed-mysql:8", - mysql.WithDatabase("testing"), - mysql.WithUsername("root"), - mysql.WithPassword("testing"), - // Every test creates and drops a database with about 40 tables, so with - // the server defaults the run is dominated by durability work: each - // CREATE TABLE fsyncs the redo log, the binary log and the doublewrite - // buffer. None of it protects anything in a container that is discarded - // after the run. Tables stay in per-table files on purpose: in the shared - // system tablespace the cost of every CREATE and DROP grew with the number - // of databases the run had already created. - testcontainers.WithCmd("mysqld", - "--innodb-flush-log-at-trx-commit=0", - "--innodb-doublewrite=OFF", - "--skip-log-bin", - ), - testcontainers.WithWaitStrategy( - wait.ForLog("/usr/sbin/mysqld: ready for connections"). - WithOccurrence(1).WithStartupTimeout(15*time.Second).WithPollInterval(100*time.Millisecond), - ), - ) - if err != nil { - return nil, "", err - } - - cleanup := func() { - if mysqlContainer != nil { - timeoutCtx, cancelFunc := context.WithTimeout(ctx, 1*time.Second) - defer cancelFunc() - if err = mysqlContainer.Terminate(timeoutCtx); err != nil { - log.WithContext(ctx).Warnf("failed to stop mysql container %s: %s", mysqlContainer.GetContainerID(), err) - } - mysqlContainer = nil // reset the container to allow recreation - } - } - - talksConn, err := mysqlContainer.ConnectionString(ctx) - if err != nil { - return nil, "", err - } - - return cleanup, talksConn, nil -} +var pgContainer *postgres.PostgresContainer // CreatePostgresTestContainer creates a new PostgreSQL container for testing. func CreatePostgresTestContainer() (func(), string, error) { diff --git a/management/server/testutil/store_ios.go b/management/server/testutil/store_ios.go index 9e3b5ce4a..2a484724b 100644 --- a/management/server/testutil/store_ios.go +++ b/management/server/testutil/store_ios.go @@ -8,12 +8,6 @@ func CreatePostgresTestContainer() (func(), string, error) { }, "", nil } -func CreateMysqlTestContainer() (func(), string, error) { - return func() { - // Empty function for MySQL - }, "", nil -} - func CreateRedisTestContainer() (func(), string, error) { return func() { // Empty function for Redis diff --git a/management/server/types/store.go b/management/server/types/store.go index a13d52f91..726ca4c89 100644 --- a/management/server/types/store.go +++ b/management/server/types/store.go @@ -8,5 +8,5 @@ const ( PostgresStoreEngine = db.PostgresStoreEngine FileStoreEngine Engine = "jsonfile" SqliteStoreEngine = db.SqliteStoreEngine - MysqlStoreEngine = db.MysqlStoreEngine + MysqlStoreEngine Engine = "mysql" ) diff --git a/shared/management/client/rest/accounts_test.go b/shared/management/client/rest/accounts_test.go index e44ada298..9de802000 100644 --- a/shared/management/client/rest/accounts_test.go +++ b/shared/management/client/rest/accounts_test.go @@ -168,7 +168,7 @@ func TestAccounts_Integration_Update(t *testing.T) { }) } -// Account deletion on MySQL and PostgreSQL databases causes unknown errors +// Account deletion on PostgreSQL databases causes unknown errors // func TestAccounts_Integration_Delete(t *testing.T) { // withBlackBoxServer(t, func(c *rest.Client) { // accounts, err := c.Accounts.List(context.Background()) diff --git a/tools/mysql-migrate/LICENSE b/tools/mysql-migrate/LICENSE deleted file mode 100644 index be3f7b28e..000000000 --- a/tools/mysql-migrate/LICENSE +++ /dev/null @@ -1,661 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/tools/mysql-migrate/copy.go b/tools/mysql-migrate/copy.go deleted file mode 100644 index 85e0c5e91..000000000 --- a/tools/mysql-migrate/copy.go +++ /dev/null @@ -1,357 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "fmt" - "math" - "slices" - "strconv" - "strings" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -type dialect struct { - quote func(string) string - tablesQuery string - columnsQuery string -} - -var mysqlDialect = dialect{ - quote: func(name string) string { return "`" + name + "`" }, - tablesQuery: `SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'`, - columnsQuery: `SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ?`, -} - -var sqliteDialect = dialect{ - quote: func(name string) string { return `"` + name + `"` }, - tablesQuery: `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`, - columnsQuery: `SELECT name FROM pragma_table_info(?)`, -} - -type column struct { - name string - pgType string -} - -// copyStore copies the tables the source shares with the target schema; -// source-only tables are legacy and skipped. -func copyStore(ctx context.Context, src *sql.DB, d dialect, tx pgx.Tx, skip map[string]bool) (int, int64, error) { - srcTables, err := queryStrings(ctx, src, d.tablesQuery) - if err != nil { - return 0, 0, fmt.Errorf("list source tables: %w", err) - } - dstTables, err := pgStrings(ctx, tx, `SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE'`) - if err != nil { - return 0, 0, fmt.Errorf("list target tables: %w", err) - } - - var tables []string - for _, t := range srcTables { - if !skip[t] && slices.Contains(dstTables, t) { - tables = append(tables, t) - } - } - if len(tables) == 0 { - return 0, 0, fmt.Errorf("no NetBird tables found in the source") - } - - ordered, err := parentsFirst(ctx, tx, tables) - if err != nil { - return 0, 0, err - } - - var total int64 - for _, table := range ordered { - n, err := copyTable(ctx, src, d, tx, table) - if err != nil { - return 0, 0, fmt.Errorf("table %s: %w", table, err) - } - total += n - } - - if err := resetSequences(ctx, tx, tables); err != nil { - return 0, 0, err - } - return len(tables), total, nil -} - -func copyTable(ctx context.Context, src *sql.DB, d dialect, tx pgx.Tx, table string) (int64, error) { - cols, err := targetColumns(ctx, tx, table) - if err != nil { - return 0, err - } - srcCols, err := queryStrings(ctx, src, d.columnsQuery, table) - if err != nil { - return 0, fmt.Errorf("list source columns: %w", err) - } - - names := make([]string, len(cols)) - quoted := make([]string, len(cols)) - var missing []string - for i, c := range cols { - names[i] = c.name - quoted[i] = d.quote(c.name) - if !slices.Contains(srcCols, c.name) { - missing = append(missing, c.name) - } - } - if len(missing) > 0 { - return 0, fmt.Errorf("source lacks columns %v; start the management server once with this release before migrating", missing) - } - - var hasRows bool - if err := tx.QueryRow(ctx, fmt.Sprintf("SELECT EXISTS (SELECT 1 FROM %s)", pgx.Identifier{table}.Sanitize())).Scan(&hasRows); err != nil { - return 0, err - } - if hasRows { - return 0, fmt.Errorf("target already holds data") - } - - rows, err := src.QueryContext(ctx, fmt.Sprintf("SELECT %s FROM %s", strings.Join(quoted, ", "), d.quote(table))) - if err != nil { - return 0, fmt.Errorf("read source: %w", err) - } - defer rows.Close() - - return tx.CopyFrom(ctx, pgx.Identifier{table}, names, newRowSource(rows, cols)) -} - -func targetColumns(ctx context.Context, tx pgx.Tx, table string) ([]column, error) { - rows, err := tx.Query(ctx, `SELECT column_name, data_type FROM information_schema.columns - WHERE table_schema = current_schema() AND table_name = $1 ORDER BY ordinal_position`, table) - if err != nil { - return nil, err - } - return pgx.CollectRows(rows, func(row pgx.CollectableRow) (column, error) { - var c column - err := row.Scan(&c.name, &c.pgType) - return c, err - }) -} - -func parentsFirst(ctx context.Context, tx pgx.Tx, tables []string) ([]string, error) { - rows, err := tx.Query(ctx, `SELECT child.relname, parent.relname FROM pg_constraint c - JOIN pg_class child ON child.oid = c.conrelid - JOIN pg_class parent ON parent.oid = c.confrelid - JOIN pg_namespace n ON n.oid = child.relnamespace - WHERE c.contype = 'f' AND n.nspname = current_schema()`) - if err != nil { - return nil, fmt.Errorf("read foreign keys: %w", err) - } - parents := map[string][]string{} - var child, parent string - _, err = pgx.ForEachRow(rows, []any{&child, &parent}, func() error { - if child != parent { - parents[child] = append(parents[child], parent) - } - return nil - }) - if err != nil { - return nil, fmt.Errorf("read foreign keys: %w", err) - } - - pending := slices.Sorted(slices.Values(tables)) - done := make(map[string]bool, len(tables)) - ordered := make([]string, 0, len(tables)) - for len(pending) > 0 { - var next []string - for _, t := range pending { - if ready(parents[t], tables, done) { - ordered = append(ordered, t) - done[t] = true - } else { - next = append(next, t) - } - } - if len(next) == len(pending) { - return nil, fmt.Errorf("foreign keys form a cycle between %v", next) - } - pending = next - } - return ordered, nil -} - -func ready(parents, tables []string, done map[string]bool) bool { - for _, p := range parents { - if slices.Contains(tables, p) && !done[p] { - return false - } - } - return true -} - -// resetSequences moves serial sequences past the ids COPY inserted explicitly. -func resetSequences(ctx context.Context, tx pgx.Tx, tables []string) error { - rows, err := tx.Query(ctx, `SELECT table_name, column_name, pg_get_serial_sequence(quote_ident(table_name), column_name) - FROM information_schema.columns - WHERE table_schema = current_schema() AND table_name = ANY($1) - AND pg_get_serial_sequence(quote_ident(table_name), column_name) IS NOT NULL`, tables) - if err != nil { - return fmt.Errorf("list sequences: %w", err) - } - type sequence struct{ table, column, name string } - seqs, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (sequence, error) { - var s sequence - err := row.Scan(&s.table, &s.column, &s.name) - return s, err - }) - if err != nil { - return fmt.Errorf("list sequences: %w", err) - } - - for _, s := range seqs { - q := fmt.Sprintf("SELECT setval('%s', COALESCE(MAX(%s), 0) + 1, false) FROM %s", - strings.ReplaceAll(s.name, "'", "''"), pgx.Identifier{s.column}.Sanitize(), pgx.Identifier{s.table}.Sanitize()) - if _, err := tx.Exec(ctx, q); err != nil { - return fmt.Errorf("reset sequence %s: %w", s.name, err) - } - } - return nil -} - -type rowSource struct { - rows *sql.Rows - cols []column - vals []any - ptrs []any -} - -func newRowSource(rows *sql.Rows, cols []column) *rowSource { - r := &rowSource{rows: rows, cols: cols, vals: make([]any, len(cols)), ptrs: make([]any, len(cols))} - for i := range r.vals { - r.ptrs[i] = &r.vals[i] - } - return r -} - -func (r *rowSource) Next() bool { return r.rows.Next() } - -func (r *rowSource) Err() error { return r.rows.Err() } - -func (r *rowSource) Values() ([]any, error) { - if err := r.rows.Scan(r.ptrs...); err != nil { - return nil, err - } - out := make([]any, len(r.vals)) - for i, v := range r.vals { - if v == nil { - continue - } - converted, err := convert(v, r.cols[i].pgType) - if err != nil { - return nil, fmt.Errorf("column %s: %w", r.cols[i].name, err) - } - out[i] = converted - } - return out, nil -} - -// convert adapts a non-NULL value to the target column type: MySQL and SQLite -// return text and numbers as bytes and booleans as integers. -func convert(v any, pgType string) (any, error) { - switch pgType { - case "boolean": - return toBool(v) - case "bytea": - return toBytes(v), nil - case "smallint", "integer", "bigint": - return toInt64(v) - case "real", "double precision": - return toFloat64(v) - case "numeric": - return toNumeric(v) - default: - return toText(v), nil - } -} - -func toBool(v any) (any, error) { - switch b := v.(type) { - case int64: - return b != 0, nil - case []byte: - return strconv.ParseBool(string(b)) - case string: - return strconv.ParseBool(b) - } - return v, nil -} - -func toBytes(v any) any { - if s, ok := v.(string); ok { - return []byte(s) - } - return v -} - -func toInt64(v any) (any, error) { - switch n := v.(type) { - case uint64: - if n > math.MaxInt64 { - return nil, fmt.Errorf("value %d overflows bigint", n) - } - return int64(n), nil - case []byte: - return strconv.ParseInt(string(n), 10, 64) - } - return v, nil -} - -func toFloat64(v any) (any, error) { - if b, ok := v.([]byte); ok { - return strconv.ParseFloat(string(b), 64) - } - return v, nil -} - -func toNumeric(v any) (any, error) { - var s string - switch x := v.(type) { - case []byte: - s = string(x) - case string: - s = x - default: - return v, nil - } - var n pgtype.Numeric - if err := n.Scan(s); err != nil { - return nil, err - } - return n, nil -} - -func toText(v any) any { - if b, ok := v.([]byte); ok { - return string(b) - } - return v -} - -func queryStrings(ctx context.Context, db *sql.DB, query string, args ...any) ([]string, error) { - rows, err := db.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - var out []string - for rows.Next() { - var s string - if err := rows.Scan(&s); err != nil { - return nil, err - } - out = append(out, s) - } - return out, rows.Err() -} - -func pgStrings(ctx context.Context, tx pgx.Tx, query string) ([]string, error) { - rows, err := tx.Query(ctx, query) - if err != nil { - return nil, err - } - return pgx.CollectRows(rows, pgx.RowTo[string]) -} diff --git a/tools/mysql-migrate/main.go b/tools/mysql-migrate/main.go deleted file mode 100644 index f9531c66a..000000000 --- a/tools/mysql-migrate/main.go +++ /dev/null @@ -1,242 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "errors" - "flag" - "fmt" - "log/slog" - "net/url" - "os" - "strings" - "time" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" - log "github.com/sirupsen/logrus" - "gorm.io/driver/mysql" - "gorm.io/driver/postgres" - "gorm.io/driver/sqlite" - "gorm.io/gorm" - "gorm.io/gorm/logger" - - "github.com/netbirdio/netbird/idp/dex" - "github.com/netbirdio/netbird/management/internals/shared/db" - "github.com/netbirdio/netbird/management/server/activity" - "github.com/netbirdio/netbird/management/server/store" -) - -type config struct { - mysqlDSN string - postgresDSN string - eventsPostgresDSN string - authPostgresDSN string - eventsDB string - authDB string - mysqlTimezone string -} - -type storeMigration struct { - name string - source *sql.DB - dialect dialect - targetDSN string - schema func(ctx context.Context, dsn string) error - skipTables map[string]bool -} - -func main() { - cfg, err := parseFlags(os.Args[1:]) - if errors.Is(err, flag.ErrHelp) { - return - } - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(2) - } - - // Silence the store constructors' migration logs. - log.SetLevel(log.WarnLevel) - - if err := run(context.Background(), cfg); err != nil { - fmt.Fprintf(os.Stderr, "migration failed: %v\n", err) - os.Exit(1) - } -} - -func parseFlags(args []string) (*config, error) { - cfg := &config{} - fs := flag.NewFlagSet("netbird-mysql-migrate", flag.ContinueOnError) - fs.StringVar(&cfg.mysqlDSN, "mysql-dsn", "", "MySQL DSN of the management store (required)") - fs.StringVar(&cfg.postgresDSN, "postgres-dsn", "", "Postgres DSN to migrate all stores to (required)") - fs.StringVar(&cfg.eventsPostgresDSN, "events-postgres-dsn", "", "Postgres DSN for activity events (default --postgres-dsn)") - fs.StringVar(&cfg.authPostgresDSN, "auth-postgres-dsn", "", "Postgres DSN for the embedded IdP (default --postgres-dsn)") - fs.StringVar(&cfg.eventsDB, "events-db", "/var/lib/netbird/events.db", "SQLite file of the activity events, skipped when missing") - fs.StringVar(&cfg.authDB, "auth-db", "/var/lib/netbird/idp.db", "SQLite file of the embedded IdP, skipped when missing") - fs.StringVar(&cfg.mysqlTimezone, "mysql-timezone", "UTC", "time zone the management server ran in, e.g. Europe/Berlin") - if err := fs.Parse(args); err != nil { - return nil, err - } - if _, err := time.LoadLocation(cfg.mysqlTimezone); err != nil { - return nil, fmt.Errorf("--mysql-timezone: %w", err) - } - - if cfg.mysqlDSN == "" || cfg.postgresDSN == "" { - return nil, errors.New("--mysql-dsn and --postgres-dsn are required") - } - if cfg.eventsPostgresDSN == "" { - cfg.eventsPostgresDSN = cfg.postgresDSN - } - if cfg.authPostgresDSN == "" { - cfg.authPostgresDSN = cfg.postgresDSN - } - return cfg, nil -} - -func run(ctx context.Context, cfg *config) error { - migrations, closeSources, err := openSources(cfg) - if err != nil { - return err - } - defer closeSources() - - for _, m := range migrations { - if err := m.schema(ctx, m.targetDSN); err != nil { - return fmt.Errorf("create %s schema: %w", m.name, err) - } - } - - var ( - pools []*pgxpool.Pool - txs []pgx.Tx - ) - // Roll back first: pool Close waits for the connections open transactions hold. - defer func() { - for _, tx := range txs { - _ = tx.Rollback(ctx) - } - for _, pool := range pools { - pool.Close() - } - }() - - results := make([]string, 0, len(migrations)) - for _, m := range migrations { - pool, err := pgxpool.New(ctx, m.targetDSN) - if err != nil { - return fmt.Errorf("connect %s target: %w", m.name, err) - } - pools = append(pools, pool) - - tx, err := pool.Begin(ctx) - if err != nil { - return fmt.Errorf("begin %s transaction: %w", m.name, err) - } - txs = append(txs, tx) - - tables, rows, err := copyStore(ctx, m.source, m.dialect, tx, m.skipTables) - if err != nil { - return fmt.Errorf("%s: %w", m.name, err) - } - results = append(results, fmt.Sprintf("%s: %d rows from %d tables", m.name, rows, tables)) - } - - // Commit last so a failure in any store leaves every target empty. - for i, tx := range txs { - if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("commit %s: %w", migrations[i].name, err) - } - } - - for _, r := range results { - fmt.Fprintln(os.Stdout, r) - } - fmt.Fprintln(os.Stdout, "Done. Point the store, activity and auth store settings at Postgres before starting the server.") - return nil -} - -func openSources(cfg *config) ([]storeMigration, func(), error) { - var opened []*gorm.DB - closeAll := func() { - for _, g := range opened { - if sqlDB, err := g.DB(); err == nil { - _ = sqlDB.Close() - } - } - } - - open := func(dialector gorm.Dialector) (*sql.DB, error) { - g, err := gorm.Open(dialector, &gorm.Config{Logger: logger.Discard}) - if err != nil { - return nil, err - } - opened = append(opened, g) - return g.DB() - } - - mysqlDB, err := open(mysql.Open(mysqlDSN(cfg.mysqlDSN, cfg.mysqlTimezone))) - if err != nil { - closeAll() - return nil, nil, fmt.Errorf("open MySQL: %w", err) - } - migrations := []storeMigration{{ - name: "management", source: mysqlDB, dialect: mysqlDialect, - targetDSN: cfg.postgresDSN, schema: createManagementSchema, - }} - - sqliteStores := []storeMigration{ - {name: "activity", targetDSN: cfg.eventsPostgresDSN, schema: createActivitySchema}, - {name: "auth", targetDSN: cfg.authPostgresDSN, schema: createAuthSchema, skipTables: map[string]bool{"migrations": true}}, - } - for i, file := range []string{cfg.eventsDB, cfg.authDB} { - m := sqliteStores[i] - if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) { - fmt.Fprintf(os.Stdout, "%s: %s not found, skipping\n", m.name, file) - continue - } - m.source, err = open(sqlite.Open("file:" + file + "?mode=ro")) - if err != nil { - closeAll() - return nil, nil, fmt.Errorf("open %s: %w", file, err) - } - m.dialect = sqliteDialect - migrations = append(migrations, m) - } - - return migrations, closeAll, nil -} - -func mysqlDSN(dsn, timezone string) string { - return strings.TrimSuffix(db.MysqlDSN(dsn), "&loc=Local") + "&loc=" + url.QueryEscape(timezone) -} - -func createManagementSchema(ctx context.Context, dsn string) error { - s, err := store.NewPostgresqlStore(ctx, dsn, nil, false) - if err != nil { - return err - } - return s.Close(ctx) -} - -func createActivitySchema(_ context.Context, dsn string) error { - g, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Discard}) - if err != nil { - return err - } - defer func() { - if sqlDB, err := g.DB(); err == nil { - _ = sqlDB.Close() - } - }() - return g.AutoMigrate(&activity.Event{}, &activity.DeletedUser{}) -} - -func createAuthSchema(_ context.Context, dsn string) error { - cfg := dex.Storage{Type: "postgres", Config: map[string]any{"dsn": dsn}} - s, err := cfg.OpenStorage(slog.New(slog.DiscardHandler)) - if err != nil { - return err - } - return s.Close() -} diff --git a/tools/mysql-migrate/main_test.go b/tools/mysql-migrate/main_test.go deleted file mode 100644 index 59d444440..000000000 --- a/tools/mysql-migrate/main_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "log/slog" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - dexstorage "github.com/dexidp/dex/storage" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/rs/xid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/driver/mysql" - "gorm.io/gorm" - - "github.com/netbirdio/netbird/idp/dex" - "github.com/netbirdio/netbird/management/internals/shared/db" - "github.com/netbirdio/netbird/management/server/activity" - activitystore "github.com/netbirdio/netbird/management/server/activity/store" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/testutil" - "github.com/netbirdio/netbird/management/server/types" - "github.com/netbirdio/netbird/util/crypt" -) - -const testAccountID = "bf1c8084-ba50-4ce7-9439-34653001fc3b" - -func TestRun(t *testing.T) { - if runtime.GOOS == "windows" || (os.Getenv("CI") == "true" && runtime.GOOS != "linux") { - t.Skip("needs Docker with Linux containers for MySQL and Postgres") - } - // Seed in UTC to match the --mysql-timezone default. - time.Local = time.UTC - ctx := context.Background() - dir := t.TempDir() - key, err := crypt.GenerateKey() - require.NoError(t, err) - - mysqlDSN := newMySQLDatabase(t, "run_src") - pgDSN := newPostgresDatabase(t, ctx, "run_dst") - mysqlStore := seedMySQL(t, ctx, mysqlDSN) - eventID := seedEvents(t, ctx, dir, key) - seedIdP(t, ctx, filepath.Join(dir, "idp.db")) - - cfg, err := parseFlags([]string{ - "--mysql-dsn", mysqlDSN, - "--postgres-dsn", pgDSN, - "--events-db", filepath.Join(dir, "events.db"), - "--auth-db", filepath.Join(dir, "idp.db"), - }) - require.NoError(t, err) - require.NoError(t, run(ctx, cfg)) - - t.Run("management", func(t *testing.T) { - pgStore, err := store.NewPostgresqlStore(ctx, pgDSN, nil, true) - require.NoError(t, err) - t.Cleanup(func() { _ = pgStore.Close(ctx) }) - - assert.JSONEq(t, accountJSON(t, ctx, mysqlStore), accountJSON(t, ctx, pgStore), - "the account read from Postgres must match the one in MySQL") - }) - - t.Run("activity", func(t *testing.T) { - t.Setenv("NB_ACTIVITY_EVENT_STORE_ENGINE", "postgres") - t.Setenv("NB_ACTIVITY_EVENT_POSTGRES_DSN", pgDSN) - events, err := activitystore.NewSqlStore(ctx, dir, key) - require.NoError(t, err) - t.Cleanup(func() { _ = events.Close(ctx) }) - - got, err := events.Get(ctx, testAccountID, 0, 10, false) - require.NoError(t, err) - require.Len(t, got, 1, "the migrated event must be readable") - assert.Equal(t, eventID, got[0].ID, "the event must keep its id") - - next, err := events.Save(ctx, &activity.Event{Timestamp: time.Now(), AccountID: testAccountID, InitiatorID: "u", TargetID: "p"}) - require.NoError(t, err) - assert.Greater(t, next.ID, eventID, "new events must continue after the migrated ids") - }) - - t.Run("auth", func(t *testing.T) { - idp := dex.Storage{Type: "postgres", Config: map[string]any{"dsn": pgDSN}} - s, err := idp.OpenStorage(slog.New(slog.DiscardHandler)) - require.NoError(t, err) - t.Cleanup(func() { _ = s.Close() }) - - pw, err := s.GetPassword(ctx, "admin@example.com") - require.NoError(t, err) - assert.Equal(t, []byte("hash"), pw.Hash, "the IdP password must survive the migration") - }) - - t.Run("refuses a target that holds data", func(t *testing.T) { - assert.ErrorContains(t, run(ctx, cfg), "target already holds data") - }) -} - -func seedMySQL(t *testing.T, ctx context.Context, dsn string) *store.SqlStore { - t.Helper() - t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine)) - seed, cleanup, err := store.NewTestStoreFromSQL(ctx, "../../management/server/testdata/extended-store.sql", t.TempDir()) - require.NoError(t, err) - t.Cleanup(cleanup) - - mysqlStore, err := store.NewMysqlStoreFromSqlStore(ctx, seed.(*store.SqlStore), dsn, nil) - require.NoError(t, err) - t.Cleanup(func() { _ = mysqlStore.Close(ctx) }) - return mysqlStore -} - -func seedEvents(t *testing.T, ctx context.Context, dir, key string) uint64 { - t.Helper() - events, err := activitystore.NewSqlStore(ctx, dir, key) - require.NoError(t, err) - defer events.Close(ctx) - - event, err := events.Save(ctx, &activity.Event{ - Timestamp: time.Now(), Activity: activity.PeerAddedByUser, - AccountID: testAccountID, InitiatorID: "user", TargetID: "peer", - Meta: map[string]any{"name": "peer"}, - }) - require.NoError(t, err) - return event.ID -} - -func seedIdP(t *testing.T, ctx context.Context, file string) { - t.Helper() - idp := dex.Storage{Type: "sqlite3", Config: map[string]any{"file": file}} - s, err := idp.OpenStorage(slog.New(slog.DiscardHandler)) - require.NoError(t, err) - defer s.Close() - - require.NoError(t, s.CreatePassword(ctx, dexstorage.Password{ - Email: "admin@example.com", Hash: []byte("hash"), Username: "admin", UserID: "admin-id", - })) -} - -func accountJSON(t *testing.T, ctx context.Context, s store.Store) string { - t.Helper() - var account *types.Account - require.NoError(t, s.ExecuteInTransaction(ctx, func(tx store.Store) error { - var err error - account, err = tx.GetAccount(ctx, testAccountID) - return err - })) - out, err := json.Marshal(account) - require.NoError(t, err) - return string(out) -} - -func TestMySQLTimezone(t *testing.T) { - if runtime.GOOS == "windows" || (os.Getenv("CI") == "true" && runtime.GOOS != "linux") { - t.Skip("needs Docker with Linux containers for MySQL and Postgres") - } - ctx := context.Background() - mysqlDSN := newMySQLDatabase(t, "tz_src") - pgDSN := newPostgresDatabase(t, ctx, "tz_dst") - ms := seedMySQL(t, ctx, mysqlDSN) - require.NoError(t, ms.GetDB().Exec("UPDATE accounts SET created_at = '2026-01-02 03:04:05' WHERE id = ?", testAccountID).Error) - - cfg, err := parseFlags([]string{ - "--mysql-dsn", mysqlDSN, - "--postgres-dsn", pgDSN, - "--mysql-timezone", "Asia/Tokyo", - "--events-db", filepath.Join(t.TempDir(), "none.db"), - "--auth-db", filepath.Join(t.TempDir(), "none.db"), - }) - require.NoError(t, err) - require.NoError(t, run(ctx, cfg)) - - pool, err := pgxpool.New(ctx, pgDSN) - require.NoError(t, err) - t.Cleanup(pool.Close) - var got time.Time - require.NoError(t, pool.QueryRow(ctx, "SELECT created_at FROM accounts WHERE id = $1", testAccountID).Scan(&got)) - want := time.Date(2026, 1, 1, 18, 4, 5, 0, time.UTC) - assert.True(t, want.Equal(got), "created_at must keep the instant the server wrote, got %s", got.UTC()) -} - -func TestParseFlagsRejectsUnknownTimezone(t *testing.T) { - _, err := parseFlags([]string{"--mysql-dsn", "m", "--postgres-dsn", "p", "--mysql-timezone", "Mars/Base"}) - assert.ErrorContains(t, err, "--mysql-timezone") -} - -// newMySQLDatabase creates a database unique to this run in the shared -// container, so repeated runs (-count) don't collide, and drops it afterwards. -func newMySQLDatabase(t *testing.T, prefix string) string { - t.Helper() - _, base, err := testutil.CreateMysqlTestContainer() - require.NoError(t, err) - admin, err := gorm.Open(mysql.Open(db.MysqlDSN(base)), &gorm.Config{}) - require.NoError(t, err) - name := prefix + "_" + xid.New().String() - require.NoError(t, admin.Exec("CREATE DATABASE "+name).Error) - t.Cleanup(func() { - _ = admin.Exec("DROP DATABASE IF EXISTS " + name).Error - if sqlDB, err := admin.DB(); err == nil { - _ = sqlDB.Close() - } - }) - return base[:strings.LastIndex(base, "/")] + "/" + name -} - -// newPostgresDatabase is the Postgres counterpart of newMySQLDatabase. -func newPostgresDatabase(t *testing.T, ctx context.Context, prefix string) string { - t.Helper() - _, base, err := testutil.CreatePostgresTestContainer() - require.NoError(t, err) - // Dex connects through lib/pq, which requires TLS unless told otherwise. - base = strings.TrimSuffix(base, "?") - admin, err := pgxpool.New(ctx, base+"?sslmode=disable") - require.NoError(t, err) - name := prefix + "_" + xid.New().String() - _, err = admin.Exec(ctx, "CREATE DATABASE "+name) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = admin.Exec(context.Background(), "DROP DATABASE IF EXISTS "+name+" WITH (FORCE)") - admin.Close() - }) - return base[:strings.LastIndex(base, "/")] + "/" + name + "?sslmode=disable" -} From 1e63b68c4dc243a32fef823df1bc024c997a9c60 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 21 Oct 2026 12:27:44 +0200 Subject: [PATCH 187/209] [relay] Negotiate the protocol version on the server side (#8231) --- relay/metrics/realy.go | 54 ++-- relay/server/handshake.go | 59 ++-- relay/server/handshake_test.go | 378 ++++++++++++++++++++++++++ relay/server/peer.go | 34 ++- relay/server/peer_test.go | 99 +++++++ relay/server/relay.go | 20 +- relay/server/ws_version_test.go | 97 +++++++ shared/relay/messages/message.go | 220 +++++++++++++-- shared/relay/messages/message_test.go | 246 +++++++++++++++++ shared/relay/messages/peer_state.go | 12 +- 10 files changed, 1128 insertions(+), 91 deletions(-) create mode 100644 relay/server/handshake_test.go create mode 100644 relay/server/peer_test.go create mode 100644 relay/server/ws_version_test.go diff --git a/relay/metrics/realy.go b/relay/metrics/realy.go index 49a357557..1af7a1ca2 100644 --- a/relay/metrics/realy.go +++ b/relay/metrics/realy.go @@ -8,6 +8,8 @@ import ( log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" + + "github.com/netbirdio/netbird/shared/relay/messages" ) const ( @@ -17,16 +19,17 @@ const ( type Metrics struct { metric.Meter - TransferBytesSent metric.Int64Counter - TransferBytesRecv metric.Int64Counter - AuthenticationTime metric.Float64Histogram - PeerStoreTime metric.Float64Histogram - peerReconnections metric.Int64Counter - peers metric.Int64UpDownCounter - peerActivityChan chan string - peerLastActive map[string]time.Time - mutexActivity sync.Mutex - ctx context.Context + TransferBytesSent metric.Int64Counter + TransferBytesRecv metric.Int64Counter + AuthenticationTime metric.Float64Histogram + PeerStoreTime metric.Float64Histogram + peerReconnections metric.Int64Counter + peerProtocolVersions metric.Int64Counter + peers metric.Int64UpDownCounter + peerActivityChan chan string + peerLastActive map[string]time.Time + mutexActivity sync.Mutex + ctx context.Context } func NewMetrics(ctx context.Context, meter metric.Meter) (*Metrics, error) { @@ -88,14 +91,22 @@ func NewMetrics(ctx context.Context, meter metric.Meter) (*Metrics, error) { return nil, err } + peerProtocolVersions, err := meter.Int64Counter("relay_peer_protocol_versions_total", + metric.WithDescription("Total number of peer connections by advertised and selected protocol version"), + ) + if err != nil { + return nil, err + } + m := &Metrics{ - Meter: meter, - TransferBytesSent: bytesSent, - TransferBytesRecv: bytesRecv, - AuthenticationTime: authTime, - PeerStoreTime: peerStoreTime, - peers: peers, - peerReconnections: peerReconnections, + Meter: meter, + TransferBytesSent: bytesSent, + TransferBytesRecv: bytesRecv, + AuthenticationTime: authTime, + PeerStoreTime: peerStoreTime, + peers: peers, + peerReconnections: peerReconnections, + peerProtocolVersions: peerProtocolVersions, ctx: ctx, peerActivityChan: make(chan string, 10), @@ -151,6 +162,15 @@ func (m *Metrics) RecordPeerReconnection() { m.peerReconnections.Add(m.ctx, 1) } +// RecordProtocolVersion counts a new peer connection by the protocol version the client advertised +// and the version the server selected for it +func (m *Metrics) RecordProtocolVersion(advertised, selected messages.ProtocolVersion) { + m.peerProtocolVersions.Add(m.ctx, 1, metric.WithAttributes( + attribute.Int("advertised", int(advertised)), + attribute.Int("selected", int(selected)), + )) +} + // PeerActivity increases the active connections func (m *Metrics) PeerActivity(peerID string) { select { diff --git a/relay/server/handshake.go b/relay/server/handshake.go index f064b3501..c5ec39283 100644 --- a/relay/server/handshake.go +++ b/relay/server/handshake.go @@ -19,20 +19,9 @@ type Validator interface { Validate(any) error } -// preparedMsg contains the marshalled success response message +// preparedMsg contains the marshalled success response message for every supported protocol version type preparedMsg struct { - responseAuthMsg []byte -} - -func newPreparedMsg(instanceURL string) (*preparedMsg, error) { - ram, err := messages.MarshalAuthResponse(instanceURL) - if err != nil { - return nil, fmt.Errorf("failed to marshal auth response msg: %w", err) - } - - return &preparedMsg{ - responseAuthMsg: ram, - }, nil + responseAuthMsg map[messages.ProtocolVersion][]byte } type handshake struct { @@ -40,7 +29,35 @@ type handshake struct { validator Validator preparedMsg *preparedMsg - peerID *messages.PeerID + peerID *messages.PeerID + advertisedVersion messages.ProtocolVersion + selectedVersion messages.ProtocolVersion +} + +func newPreparedMsg(instanceURL string) (*preparedMsg, error) { + responses := make(map[messages.ProtocolVersion][]byte, messages.CurrentProtocolVersion) + for version := messages.ProtocolV1; ; version++ { + ram, err := messages.NewEncoder(version).AuthResponse(instanceURL) + if err != nil { + return nil, fmt.Errorf("marshal auth response for version %d: %w", version, err) + } + responses[version] = ram + if version == messages.CurrentProtocolVersion { + break + } + } + + return &preparedMsg{ + responseAuthMsg: responses, + }, nil +} + +func (p *preparedMsg) authResponse(version messages.ProtocolVersion) ([]byte, error) { + msg, ok := p.responseAuthMsg[version] + if !ok { + return nil, fmt.Errorf("no prepared auth response for protocol version %d", version) + } + return msg, nil } func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, error) { @@ -52,9 +69,9 @@ func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, err buf = buf[:n] - _, err = messages.ValidateVersion(buf) + advertised, err := messages.ReadVersion(buf) if err != nil { - return nil, fmt.Errorf("validate version from %s: %w", h.conn.RemoteAddr(), err) + return nil, fmt.Errorf("read version from %s: %w", h.conn.RemoteAddr(), err) } msgType, err := messages.DetermineClientMessageType(buf) @@ -70,12 +87,20 @@ func (h *handshake) handshakeReceive(ctx context.Context) (*messages.PeerID, err if err != nil { return peerID, err } + h.peerID = peerID + h.advertisedVersion = advertised + h.selectedVersion = messages.SelectVersion(advertised, messages.CurrentProtocolVersion) return peerID, nil } func (h *handshake) handshakeResponse(ctx context.Context) error { - if _, err := h.conn.Write(ctx, h.preparedMsg.responseAuthMsg); err != nil { + msg, err := h.preparedMsg.authResponse(h.selectedVersion) + if err != nil { + return err + } + + if _, err := h.conn.Write(ctx, msg); err != nil { return fmt.Errorf("handshake response write to %s (%s): %w", h.peerID, h.conn.RemoteAddr(), err) } diff --git a/relay/server/handshake_test.go b/relay/server/handshake_test.go new file mode 100644 index 000000000..5cdcaa8ea --- /dev/null +++ b/relay/server/handshake_test.go @@ -0,0 +1,378 @@ +package server + +import ( + "context" + "encoding/binary" + "errors" + "net" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/relay/auth/allow" + "github.com/netbirdio/netbird/shared/relay/messages" +) + +const ( + testWaitTimeout = 2 * time.Second + + // authTokenLenOffset is where the envelope's token length starts: header(2) + magic(4) + peer ID(36). + authTokenLenOffset = 2 + 4 + 36 +) + +// scriptedConn is a listener.Conn whose inbound frames are queued by the test and whose +// outbound frames are recorded. +type scriptedConn struct { + reads chan []byte + closed chan struct{} + closeOnce sync.Once + onWrite func([]byte) + + mu sync.Mutex + writes [][]byte +} + +type recordingValidator struct { + err error + + mu sync.Mutex + payloads [][]byte +} + +func newScriptedConn() *scriptedConn { + return &scriptedConn{ + reads: make(chan []byte, 16), + closed: make(chan struct{}), + } +} + +func (c *scriptedConn) Read(ctx context.Context, b []byte) (int, error) { + select { + case msg := <-c.reads: + return copy(b, msg), nil + case <-c.closed: + return 0, net.ErrClosed + case <-ctx.Done(): + return 0, ctx.Err() + } +} + +func (c *scriptedConn) Write(_ context.Context, b []byte) (int, error) { + select { + case <-c.closed: + return 0, net.ErrClosed + default: + } + + frame := append([]byte(nil), b...) + c.mu.Lock() + c.writes = append(c.writes, frame) + c.mu.Unlock() + + if c.onWrite != nil { + c.onWrite(frame) + } + return len(b), nil +} + +func (c *scriptedConn) RemoteAddr() net.Addr { + return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1} +} + +func (c *scriptedConn) Close() error { + c.closeOnce.Do(func() { close(c.closed) }) + return nil +} + +func (c *scriptedConn) Protocol() string { + return "test" +} + +func (c *scriptedConn) deliver(t *testing.T, frame []byte) { + t.Helper() + select { + case c.reads <- frame: + case <-time.After(testWaitTimeout): + t.Fatal("scripted connection read queue is full") + } +} + +func (c *scriptedConn) isClosed() bool { + select { + case <-c.closed: + return true + default: + return false + } +} + +func (c *scriptedConn) frames() [][]byte { + c.mu.Lock() + defer c.mu.Unlock() + return append([][]byte(nil), c.writes...) +} + +func (c *scriptedConn) waitFrames(t *testing.T, n int) [][]byte { + t.Helper() + deadline := time.Now().Add(testWaitTimeout) + for { + frames := c.frames() + if len(frames) >= n { + return frames + } + if time.Now().After(deadline) { + t.Fatalf("expected %d written frames, got %d", n, len(frames)) + } + time.Sleep(5 * time.Millisecond) + } +} + +func (c *scriptedConn) waitClosed(t *testing.T) { + t.Helper() + select { + case <-c.closed: + case <-time.After(testWaitTimeout): + t.Fatal("connection was not closed") + } +} + +func (v *recordingValidator) Validate(credentials any) error { + v.mu.Lock() + defer v.mu.Unlock() + + payload, _ := credentials.([]byte) + v.payloads = append(v.payloads, append([]byte(nil), payload...)) + return v.err +} + +func (v *recordingValidator) calls() [][]byte { + v.mu.Lock() + defer v.mu.Unlock() + return append([][]byte(nil), v.payloads...) +} + +func TestRelay_AuthV1ResponseIsUnchanged(t *testing.T) { + validator := &recordingValidator{} + relay := newTestRelay(t, validator) + peerID := messages.HashID("v1-client") + token := []byte("v1-token") + auth, err := messages.MarshalAuthMsg(peerID, token) + require.NoError(t, err) + + conn := newScriptedConn() + conn.deliver(t, auth) + relay.Accept(conn) + + frames := conn.waitFrames(t, 1) + want, err := messages.MarshalAuthResponse(relay.instanceURL.String()) + require.NoError(t, err) + assert.Equal(t, want, frames[0], "a v1 client must receive the same response bytes as today") + assert.Equal(t, [][]byte{token}, validator.calls(), "the validator must receive the v1 token bytes") + assert.False(t, conn.isClosed(), "an authenticated v1 connection must stay open") + + item, ok := relay.store.Peer(peerID) + require.True(t, ok, "the peer must be stored after the response") + assert.Equal(t, messages.ProtocolV1, item.(*Peer).Version(), "a v1 client selects v1") +} + +func TestRelay_AuthEnvelopeSelectsV1(t *testing.T) { + cases := []struct { + name string + version messages.ProtocolVersion + ext []byte + }{ + {"v2 without extension", 2, nil}, + {"v2 with json extension", 2, []byte(`{"cred2":"value"}`)}, + {"v255 with opaque extension", 255, []byte("opaque-extension-bytes")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + validator := &recordingValidator{} + relay := newTestRelay(t, validator) + peerID := messages.HashID("future-client") + token := []byte("future-token") + auth, err := messages.MarshalAuthEnvelope(tc.version, peerID, token, tc.ext) + require.NoError(t, err) + + conn := newScriptedConn() + conn.deliver(t, auth) + relay.Accept(conn) + + frames := conn.waitFrames(t, 1) + want, err := messages.MarshalAuthResponse(relay.instanceURL.String()) + require.NoError(t, err) + assert.Equal(t, want, frames[0], "the response must carry the selected v1 header") + assert.Equal(t, [][]byte{token}, validator.calls(), "the validator must receive the token without the extension") + assert.False(t, conn.isClosed(), "a higher advertised version must not be rejected") + + item, ok := relay.store.Peer(peerID) + require.True(t, ok, "the peer must be stored after the response") + assert.Equal(t, messages.ProtocolV1, item.(*Peer).Version(), "the connection must use the selected v1") + }) + } +} + +func TestRelay_AuthRejectsInvalidFirstFrame(t *testing.T) { + peerID := messages.HashID("client") + validEnvelope, err := messages.MarshalAuthEnvelope(2, peerID, []byte("token"), []byte("ext")) + require.NoError(t, err) + validV1, err := messages.MarshalAuthMsg(peerID, []byte("token")) + require.NoError(t, err) + transportFirst, err := messages.MarshalTransportMsg(peerID, []byte("payload")) + require.NoError(t, err) + + versionZero := append([]byte(nil), validV1...) + versionZero[0] = 0 + + badMagic := append([]byte(nil), validEnvelope...) + badMagic[2] ^= 0xFF + + zeroTokenLen := append([]byte(nil), validEnvelope...) + binary.BigEndian.PutUint16(zeroTokenLen[authTokenLenOffset:], 0) + + tokenLenPastEnd := append([]byte(nil), validEnvelope...) + binary.BigEndian.PutUint16(tokenLenPastEnd[authTokenLenOffset:], uint16(len(validEnvelope))) + + cases := []struct { + name string + frame []byte + }{ + {"version zero", versionZero}, + {"reserved hello type", []byte{byte(messages.ProtocolV1), byte(messages.MsgTypeHello)}}, + {"unknown type before auth", []byte{byte(messages.ProtocolV1), 200}}, + {"transport before auth", transportFirst}, + {"bad magic in envelope", badMagic}, + {"zero token length", zeroTokenLen}, + {"token length past the frame", tokenLenPastEnd}, + {"envelope truncated at the length field", validEnvelope[:authTokenLenOffset+1]}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + validator := &recordingValidator{} + relay := newTestRelay(t, validator) + + conn := newScriptedConn() + conn.deliver(t, tc.frame) + relay.Accept(conn) + + assert.True(t, conn.isClosed(), "the connection must be closed") + assert.Empty(t, conn.frames(), "no response may be written") + assert.Empty(t, validator.calls(), "the validator must not run") + _, ok := relay.store.Peer(peerID) + assert.False(t, ok, "the peer must not be stored") + }) + } +} + +func TestRelay_AuthRejectsInvalidToken(t *testing.T) { + peerID := messages.HashID("client") + v1Auth, err := messages.MarshalAuthMsg(peerID, []byte("token")) + require.NoError(t, err) + envelopeAuth, err := messages.MarshalAuthEnvelope(2, peerID, []byte("token"), []byte(`{"ok":true}`)) + require.NoError(t, err) + + cases := []struct { + name string + frame []byte + }{ + {"v1 layout", v1Auth}, + {"envelope with well-formed extension", envelopeAuth}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + validator := &recordingValidator{err: errors.New("invalid token")} + relay := newTestRelay(t, validator) + + conn := newScriptedConn() + conn.deliver(t, tc.frame) + relay.Accept(conn) + + assert.True(t, conn.isClosed(), "the connection must be closed") + assert.Empty(t, conn.frames(), "no response may be written") + assert.Len(t, validator.calls(), 1, "the validator must run exactly once") + _, ok := relay.store.Peer(peerID) + assert.False(t, ok, "the peer must not be stored") + }) + } +} + +func TestRelay_AuthResponsePrecedesPublish(t *testing.T) { + relay := newTestRelay(t, &allow.Auth{}) + peerID := messages.HashID("client") + + onlineEvents := make(chan []messages.PeerID, 1) + watcher := relay.notifier.NewListener(func(ids []messages.PeerID) { onlineEvents <- ids }, func([]messages.PeerID) {}) + t.Cleanup(func() { relay.notifier.RemoveListener(watcher) }) + watcher.AddInterestedPeers([]messages.PeerID{peerID}) + + var storedAtResponse, notifiedAtResponse bool + conn := newScriptedConn() + conn.onWrite = func(frame []byte) { + if frame[1] != byte(messages.MsgTypeAuthResponse) { + return + } + _, storedAtResponse = relay.store.Peer(peerID) + notifiedAtResponse = len(onlineEvents) > 0 + } + + auth, err := messages.MarshalAuthMsg(peerID, []byte("token")) + require.NoError(t, err) + conn.deliver(t, auth) + relay.Accept(conn) + + conn.waitFrames(t, 1) + assert.False(t, storedAtResponse, "the peer must not be in the store when the response is written") + assert.False(t, notifiedAtResponse, "no online notification may precede the response") + + select { + case ids := <-onlineEvents: + assert.Equal(t, []messages.PeerID{peerID}, ids, "the peer must be published after the response") + case <-time.After(testWaitTimeout): + t.Fatal("the peer was not published after the response") + } +} + +func TestRelay_ReconnectClosesPreviousConnection(t *testing.T) { + relay := newTestRelay(t, &allow.Auth{}) + first, peerID := acceptPeer(t, relay, "client") + + auth, err := messages.MarshalAuthMsg(peerID, []byte("token")) + require.NoError(t, err) + second := newScriptedConn() + second.deliver(t, auth) + relay.Accept(second) + + second.waitFrames(t, 1) + first.waitClosed(t) + assert.False(t, second.isClosed(), "the reconnecting client must keep its new connection") + item, ok := relay.store.Peer(peerID) + require.True(t, ok, "the peer must stay stored after a reconnect") + assert.Same(t, second, item.(*Peer).conn, "the store must hold the new connection") +} + +func newTestRelay(t *testing.T, validator Validator) *Relay { + t.Helper() + relay, err := NewRelay(Config{ + ExposedAddress: "rel://127.0.0.1:1234", + AuthValidator: validator, + }) + require.NoError(t, err) + t.Cleanup(func() { relay.Shutdown(context.Background()) }) + return relay +} + +func acceptPeer(t *testing.T, relay *Relay, name string) (*scriptedConn, messages.PeerID) { + t.Helper() + peerID := messages.HashID(name) + auth, err := messages.MarshalAuthMsg(peerID, []byte("token")) + require.NoError(t, err) + + conn := newScriptedConn() + conn.deliver(t, auth) + relay.Accept(conn) + conn.waitFrames(t, 1) + return conn, peerID +} diff --git a/relay/server/peer.go b/relay/server/peer.go index 8376cdfa7..887695d94 100644 --- a/relay/server/peer.go +++ b/relay/server/peer.go @@ -27,6 +27,8 @@ type Peer struct { metrics *metrics.Metrics log *log.Entry id messages.PeerID + version messages.ProtocolVersion + enc messages.Encoder conn listener.Conn connMu sync.RWMutex store *store.Store @@ -42,12 +44,14 @@ type Peer struct { } // NewPeer creates a new Peer instance and prepare custom logging -func NewPeer(metrics *metrics.Metrics, id messages.PeerID, conn listener.Conn, store *store.Store, notifier *store.PeerNotifier) *Peer { +func NewPeer(metrics *metrics.Metrics, id messages.PeerID, version messages.ProtocolVersion, conn listener.Conn, store *store.Store, notifier *store.PeerNotifier) *Peer { ctx, cancel := context.WithCancel(context.Background()) p := &Peer{ metrics: metrics, log: log.WithField("peer_id", id.String()), id: id, + version: version, + enc: messages.NewEncoder(version), conn: conn, store: store, notifier: notifier, @@ -95,16 +99,15 @@ func (p *Peer) Work() { msg := buf[:n] - _, err = messages.ValidateVersion(msg) - if err != nil { - p.log.Warnf("failed to validate protocol version: %s", err) - return + if err := messages.ValidateConnectionVersion(msg, p.version); err != nil { + p.log.Tracef("drop message: %s", err) + continue } msgType, err := messages.DetermineClientMessageType(msg) if err != nil { - p.log.Errorf("failed to determine message type: %s", err) - return + p.log.Tracef("drop message: %s", err) + continue } p.handleMsgType(ctx, msgType, hc, n, msg) @@ -115,6 +118,11 @@ func (p *Peer) ID() messages.PeerID { return p.id } +// Version returns the protocol version selected for this connection +func (p *Peer) Version() messages.ProtocolVersion { + return p.version +} + func (p *Peer) handleMsgType(ctx context.Context, msgType messages.MsgType, hc *healthcheck.Sender, n int, msg []byte) { switch msgType { case messages.MsgTypeHealthCheck: @@ -133,7 +141,7 @@ func (p *Peer) handleMsgType(ctx context.Context, msgType messages.MsgType, hc * case messages.MsgTypeUnsubscribePeerState: p.handleUnsubscribePeerState(msg) default: - p.log.Warnf("received unexpected message type: %s", msgType) + p.log.Tracef("drop %s message: not allowed after authentication", msgType) } } @@ -149,7 +157,7 @@ func (p *Peer) Write(ctx context.Context, b []byte) (int, error) { func (p *Peer) CloseGracefully(ctx context.Context) { p.connMu.Lock() defer p.connMu.Unlock() - err := p.writeWithTimeout(ctx, messages.MarshalCloseMsg()) + err := p.writeWithTimeout(ctx, p.enc.CloseMsg()) if err != nil { p.log.Errorf("failed to send close message to peer: %s", p.String()) } @@ -187,7 +195,7 @@ func (p *Peer) handleHealthcheckEvents(ctx context.Context, hc *healthcheck.Send for { select { case <-hc.HealthCheck: - _, err := p.Write(ctx, messages.MarshalHealthcheck()) + _, err := p.Write(ctx, p.enc.Healthcheck()) if err != nil { p.log.Errorf("failed to send healthcheck message: %s", err) return @@ -220,7 +228,7 @@ func (p *Peer) handleTransportMsg(msg []byte) { } dp := item.(*Peer) - err = messages.UpdateTransportMsg(msg, p.id) + err = messages.UpdateTransportMsg(msg, dp.version, p.id) if err != nil { p.log.Errorf("failed to update transport message: %s", err) return @@ -267,7 +275,7 @@ func (p *Peer) handleUnsubscribePeerState(msg []byte) { } func (p *Peer) sendPeersOnline(peers []messages.PeerID) { - msgs, err := messages.MarshalPeersOnline(peers) + msgs, err := p.enc.PeersOnline(peers) if err != nil { p.log.Errorf("failed to marshal peer location message: %s", err) return @@ -284,7 +292,7 @@ func (p *Peer) sendPeersWentOffline(peers []messages.PeerID) { p.notificationMutex.Lock() defer p.notificationMutex.Unlock() - msgs, err := messages.MarshalPeersWentOffline(peers) + msgs, err := p.enc.PeersWentOffline(peers) if err != nil { p.log.Errorf("failed to marshal peer location message: %s", err) return diff --git a/relay/server/peer_test.go b/relay/server/peer_test.go new file mode 100644 index 000000000..3b6a8de41 --- /dev/null +++ b/relay/server/peer_test.go @@ -0,0 +1,99 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/relay/auth/allow" + "github.com/netbirdio/netbird/shared/relay/messages" +) + +func TestPeer_DropsFramesItCannotHandle(t *testing.T) { + secondAuth, err := messages.MarshalAuthMsg(messages.HashID("alice"), []byte("token")) + require.NoError(t, err) + + cases := []struct { + name string + frame []byte + }{ + {"unknown message type", []byte{byte(messages.ProtocolV1), 200, 0xDE, 0xAD}}, + {"version above the selected one", []byte{2, byte(messages.MsgTypeHealthCheck)}}, + {"version zero", []byte{0, byte(messages.MsgTypeHealthCheck)}}, + {"header shorter than two bytes", []byte{byte(messages.ProtocolV1)}}, + {"reserved hello type", []byte{byte(messages.ProtocolV1), byte(messages.MsgTypeHello)}}, + {"server to client peers online type", []byte{byte(messages.ProtocolV1), byte(messages.MsgTypePeersOnline)}}, + {"auth after authentication", secondAuth}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + relay := newTestRelay(t, &allow.Auth{}) + alice, _ := acceptPeer(t, relay, "alice") + _, bobID := acceptPeer(t, relay, "bob") + + alice.deliver(t, tc.frame) + subscribe, err := messages.MarshalSubPeerStateMsg([]messages.PeerID{bobID}) + require.NoError(t, err) + alice.deliver(t, subscribe[0]) + + // The subscription answer proves the loop survived the dropped frame before it. + frames := alice.waitFrames(t, 2) + assert.False(t, alice.isClosed(), "a dropped frame must not close the connection") + online := frames[1] + assert.Equal(t, byte(messages.ProtocolV1), online[0], "notifications must carry the connection version") + assert.Equal(t, byte(messages.MsgTypePeersOnline), online[1], "the subscription must be answered") + ids, err := messages.UnmarshalPeersOnlineMsg(online) + require.NoError(t, err) + assert.Equal(t, []messages.PeerID{bobID}, ids, "the online peer must be reported") + }) + } +} + +func TestPeer_ClosesOnClientCloseMessage(t *testing.T) { + relay := newTestRelay(t, &allow.Auth{}) + conn, peerID := acceptPeer(t, relay, "alice") + + conn.deliver(t, messages.MarshalCloseMsg()) + + conn.waitClosed(t) + assert.Len(t, conn.frames(), 1, "only the auth response may be written") + assert.Eventually(t, func() bool { + _, ok := relay.store.Peer(peerID) + return !ok + }, testWaitTimeout, 5*time.Millisecond, "the peer must be removed from the store") +} + +func TestPeer_ForwardsTransportWithSenderAndDestinationVersion(t *testing.T) { + relay := newTestRelay(t, &allow.Auth{}) + alice, aliceID := acceptPeer(t, relay, "alice") + bob, bobID := acceptPeer(t, relay, "bob") + + payload := []byte("hello bob") + transport, err := messages.MarshalTransportMsg(bobID, payload) + require.NoError(t, err) + alice.deliver(t, transport) + + frames := bob.waitFrames(t, 2) + forwarded := frames[1] + assert.Equal(t, byte(messages.ProtocolV1), forwarded[0], "the forwarded frame must carry the destination's version") + assert.Equal(t, byte(messages.MsgTypeTransport), forwarded[1], "the forwarded frame must stay a transport message") + senderID, gotPayload, err := messages.UnmarshalTransportMsg(forwarded) + require.NoError(t, err) + assert.Equal(t, aliceID, *senderID, "the peer ID must be rewritten to the sender") + assert.Equal(t, payload, gotPayload, "the payload must be preserved") + assert.False(t, alice.isClosed(), "the sender must stay connected") +} + +func TestPeer_GracefulCloseCarriesConnectionVersion(t *testing.T) { + relay := newTestRelay(t, &allow.Auth{}) + conn, _ := acceptPeer(t, relay, "alice") + + relay.Shutdown(context.Background()) + + frames := conn.waitFrames(t, 2) + assert.Equal(t, messages.NewEncoder(messages.ProtocolV1).CloseMsg(), frames[1], "the close message must carry the connection version") + assert.True(t, conn.isClosed(), "the connection must be closed after the close message") +} diff --git a/relay/server/relay.go b/relay/server/relay.go index 58f9db840..bd580efd0 100644 --- a/relay/server/relay.go +++ b/relay/server/relay.go @@ -150,8 +150,17 @@ func (r *Relay) Accept(conn listener.Conn) { return } - peer := NewPeer(r.metrics, *peerID, conn, r.store, r.notifier) - peer.log.Infof("peer connected from: %s", conn.RemoteAddr()) + if err := h.handshakeResponse(hsCtx); err != nil { + log.Errorf("failed to send handshake response, close connection: %s", err) + if cErr := conn.Close(); cErr != nil { + log.Errorf("failed to close connection, %s: %s", conn.RemoteAddr(), cErr) + } + return + } + r.metrics.RecordAuthenticationTime(time.Since(acceptTime)) + + peer := NewPeer(r.metrics, *peerID, h.selectedVersion, conn, r.store, r.notifier) + peer.log.Infof("peer connected from: %s, advertised protocol version: %d, selected: %d", conn.RemoteAddr(), h.advertisedVersion, h.selectedVersion) storeTime := time.Now() if isReconnection := r.store.AddPeer(peer); isReconnection { r.metrics.RecordPeerReconnection() @@ -161,6 +170,7 @@ func (r *Relay) Accept(conn listener.Conn) { transport := conn.Protocol() r.metrics.RecordPeerStoreTime(time.Since(storeTime)) r.metrics.PeerConnected(peer.String(), transport) + r.metrics.RecordProtocolVersion(h.advertisedVersion, h.selectedVersion) go func() { peer.Work() if deleted := r.store.DeletePeer(peer); deleted { @@ -169,12 +179,6 @@ func (r *Relay) Accept(conn listener.Conn) { peer.log.Debugf("relay connection closed") r.metrics.PeerDisconnected(peer.String(), transport) }() - - if err := h.handshakeResponse(hsCtx); err != nil { - log.Errorf("failed to send handshake response, close peer: %s", err) - peer.Close() - } - r.metrics.RecordAuthenticationTime(time.Since(acceptTime)) } // Shutdown closes the relay server diff --git a/relay/server/ws_version_test.go b/relay/server/ws_version_test.go new file mode 100644 index 000000000..bd5fa7303 --- /dev/null +++ b/relay/server/ws_version_test.go @@ -0,0 +1,97 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/relay" + "github.com/netbirdio/netbird/shared/relay/messages" +) + +func TestServer_FutureClientOverWebSocket(t *testing.T) { + addr := freeAddress(t) + srv := newTestServer(t, addr) + errChan := make(chan error, 1) + go func() { + errChan <- srv.Listen(ListenerConfig{Address: addr}) + }() + waitForListeners(t, srv, errChan) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + instanceURL := srv.InstanceURL() + want, err := messages.MarshalAuthResponse(instanceURL.String()) + require.NoError(t, err) + + // A synthetic client advertising v2 with an extension area that a v1 server does not read. + future := dialRelayWebSocket(t, ctx, addr) + futureID := messages.HashID("future-client") + futureAuth, err := messages.MarshalAuthEnvelope(2, futureID, []byte("future-token"), []byte(`{"cred2":"value"}`)) + require.NoError(t, err) + require.NoError(t, future.Write(ctx, websocket.MessageBinary, futureAuth)) + assert.Equal(t, want, readFrame(t, ctx, future), "a v2 client must receive the v1 response") + + legacy := dialRelayWebSocket(t, ctx, addr) + legacyID := messages.HashID("legacy-client") + legacyAuth, err := messages.MarshalAuthMsg(legacyID, []byte("legacy-token")) + require.NoError(t, err) + require.NoError(t, legacy.Write(ctx, websocket.MessageBinary, legacyAuth)) + assert.Equal(t, want, readFrame(t, ctx, legacy), "a v1 client must receive the response it gets today") + + toLegacy, err := messages.MarshalTransportMsg(legacyID, []byte("from future")) + require.NoError(t, err) + require.NoError(t, future.Write(ctx, websocket.MessageBinary, toLegacy)) + assertTransportFrame(t, readFrame(t, ctx, legacy), futureID, "from future") + + toFuture, err := messages.MarshalTransportMsg(futureID, []byte("from legacy")) + require.NoError(t, err) + require.NoError(t, legacy.Write(ctx, websocket.MessageBinary, toFuture)) + assertTransportFrame(t, readFrame(t, ctx, future), legacyID, "from legacy") + + // After the response the connection is v1. A frame with another version is dropped and the + // connection stays usable, which the transport message right after it proves. + require.NoError(t, future.Write(ctx, websocket.MessageBinary, []byte{2, byte(messages.MsgTypeHealthCheck)})) + toLegacyAgain, err := messages.MarshalTransportMsg(legacyID, []byte("after the dropped frame")) + require.NoError(t, err) + require.NoError(t, future.Write(ctx, websocket.MessageBinary, toLegacyAgain)) + assertTransportFrame(t, readFrame(t, ctx, legacy), futureID, "after the dropped frame") + + require.NoError(t, future.Write(ctx, websocket.MessageBinary, messages.MarshalCloseMsg())) + require.NoError(t, legacy.Write(ctx, websocket.MessageBinary, messages.MarshalCloseMsg())) +} + +func dialRelayWebSocket(t *testing.T, ctx context.Context, addr string) *websocket.Conn { + t.Helper() + conn, resp, err := websocket.Dial(ctx, "ws://"+addr+relay.WebSocketURLPath, nil) //nolint:bodyclose + require.NoError(t, err) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + t.Cleanup(func() { _ = conn.CloseNow() }) + return conn +} + +func readFrame(t *testing.T, ctx context.Context, conn *websocket.Conn) []byte { + t.Helper() + msgType, frame, err := conn.Read(ctx) + require.NoError(t, err) + require.Equal(t, websocket.MessageBinary, msgType, "relay frames are binary messages") + return frame +} + +func assertTransportFrame(t *testing.T, frame []byte, sender messages.PeerID, payload string) { + t.Helper() + require.GreaterOrEqual(t, len(frame), 2, "a frame must carry the protocol header") + assert.Equal(t, byte(messages.ProtocolV1), frame[0], "the forwarded frame must carry the destination's version") + assert.Equal(t, byte(messages.MsgTypeTransport), frame[1], "the forwarded frame must be a transport message") + id, got, err := messages.UnmarshalTransportMsg(frame) + require.NoError(t, err) + assert.Equal(t, sender, *id, "the peer ID must be rewritten to the sender") + assert.Equal(t, payload, string(got), "the payload must be preserved") +} diff --git a/shared/relay/messages/message.go b/shared/relay/messages/message.go index fdbf2a6e7..e25571edd 100644 --- a/shared/relay/messages/message.go +++ b/shared/relay/messages/message.go @@ -2,16 +2,27 @@ package messages import ( "bytes" + "encoding/binary" "errors" "fmt" + "math" ) const ( - MaxHandshakeSize = 212 + // TODO: MaxHandshakeSize is the buffer size of the first read on every connection before + // authentication. Weigh it against memory use under many unauthenticated connections; a + // smaller limit may still fit the extension area. + MaxHandshakeSize = 8192 MaxHandshakeRespSize = 8192 MaxMessageSize = 8820 - CurrentProtocolVersion = 1 + ProtocolV1 ProtocolVersion = 1 + // CurrentProtocolVersion is the highest protocol version this build supports. + CurrentProtocolVersion = ProtocolV1 + + // authEnvelopeMinVersion is the first advertised version whose Auth message carries a + // length-prefixed token followed by an extension area. + authEnvelopeMinVersion ProtocolVersion = 2 MsgTypeUnknown MsgType = 0 // MsgTypeHello and MsgTypeHelloResponse are the removed legacy handshake @@ -38,10 +49,13 @@ const ( // auth message sizeOfMagicByte = 4 + sizeOfTokenLen = 2 headerSizeAuth = sizeOfMagicByte + peerIDSize offsetMagicByte = sizeOfProtoHeader offsetAuthPeerID = sizeOfProtoHeader + sizeOfMagicByte headerTotalSizeAuth = sizeOfProtoHeader + headerSizeAuth + offsetAuthTokenLen = headerTotalSizeAuth + offsetAuthToken = offsetAuthTokenLen + sizeOfTokenLen // transport headerSizeTransport = peerIDSize @@ -52,6 +66,10 @@ const ( var ( ErrInvalidMessageLength = errors.New("invalid message length") ErrUnsupportedVersion = errors.New("unsupported version") + ErrInvalidTokenLength = errors.New("invalid token length") + ErrReservedMsgType = errors.New("reserved message type") + ErrMsgTypeDirection = errors.New("message type not valid from client") + ErrUnknownMsgType = errors.New("unknown message type") magicHeader = []byte{0x21, 0x12, 0xA4, 0x42} @@ -60,6 +78,19 @@ var ( type MsgType byte +// ProtocolVersion is the relay wire protocol version carried in the first byte of every frame. +type ProtocolVersion uint8 + +// Encoder marshals server-originated messages with the protocol version selected for one connection. +type Encoder struct { + version ProtocolVersion +} + +// NewEncoder returns an Encoder that stamps every message with the given protocol version. +func NewEncoder(version ProtocolVersion) Encoder { + return Encoder{version: version} +} + func (m MsgType) String() string { switch m { case MsgTypeHello: @@ -89,18 +120,99 @@ func (m MsgType) String() string { } } -// ValidateVersion checks if the given version is supported by the protocol -func ValidateVersion(msg []byte) (int, error) { +// Version returns the protocol version the Encoder stamps on its messages. +func (e Encoder) Version() ProtocolVersion { + return e.version +} + +// AuthResponse creates the response to a successful Auth message. The payload is the instance URL +// of the server, which the clients use to choose a common Relay server. +func (e Encoder) AuthResponse(address string) ([]byte, error) { + ab := []byte(address) + msg := make([]byte, sizeOfProtoHeader, sizeOfProtoHeader+len(ab)) + + msg[0] = byte(e.version) + msg[1] = byte(MsgTypeAuthResponse) + + msg = append(msg, ab...) + + if len(msg) > MaxHandshakeRespSize { + return nil, fmt.Errorf("invalid message length: %d", len(msg)) + } + + return msg, nil +} + +// CloseMsg creates a close message for a graceful disconnect. +func (e Encoder) CloseMsg() []byte { + return []byte{ + byte(e.version), + byte(MsgTypeClose), + } +} + +// Healthcheck creates a health check message. +func (e Encoder) Healthcheck() []byte { + return []byte{ + byte(e.version), + byte(MsgTypeHealthCheck), + } +} + +// PeersOnline creates the peers online notification messages. +func (e Encoder) PeersOnline(ids []PeerID) ([][]byte, error) { + return marshalPeerIDs(e.version, ids, byte(MsgTypePeersOnline)) +} + +// PeersWentOffline creates the peers went offline notification messages. +func (e Encoder) PeersWentOffline(ids []PeerID) ([][]byte, error) { + return marshalPeerIDs(e.version, ids, byte(MsgTypePeersWentOffline)) +} + +// ReadVersion returns the protocol version carried in the frame header. Zero is invalid. +func ReadVersion(msg []byte) (ProtocolVersion, error) { if len(msg) < sizeOfProtoHeader { return 0, ErrInvalidMessageLength } - version := int(msg[0]) - if version != CurrentProtocolVersion { + version := ProtocolVersion(msg[0]) + if version == 0 { return 0, fmt.Errorf("%d: %w", version, ErrUnsupportedVersion) } return version, nil } +// SelectVersion returns the highest protocol version both sides support. +func SelectVersion(advertised, supported ProtocolVersion) ProtocolVersion { + if advertised < supported { + return advertised + } + return supported +} + +// ValidateVersion checks that the frame carries the protocol version of this build. +func ValidateVersion(msg []byte) (int, error) { + version, err := ReadVersion(msg) + if err != nil { + return 0, err + } + if version != CurrentProtocolVersion { + return 0, fmt.Errorf("%d: %w", version, ErrUnsupportedVersion) + } + return int(version), nil +} + +// ValidateConnectionVersion checks that the frame carries the version selected for the connection. +func ValidateConnectionVersion(msg []byte, selected ProtocolVersion) error { + version, err := ReadVersion(msg) + if err != nil { + return err + } + if version != selected { + return fmt.Errorf("%d instead of selected %d: %w", version, selected, ErrUnsupportedVersion) + } + return nil +} + // DetermineClientMessageType determines the message type from the first the message func DetermineClientMessageType(msg []byte) (MsgType, error) { if len(msg) < sizeOfProtoHeader { @@ -117,8 +229,12 @@ func DetermineClientMessageType(msg []byte) (MsgType, error) { MsgTypeSubscribePeerState, MsgTypeUnsubscribePeerState: return msgType, nil + case MsgTypeHello, MsgTypeHelloResponse: + return MsgTypeUnknown, fmt.Errorf("%d: %w", msgType, ErrReservedMsgType) + case MsgTypeAuthResponse, MsgTypePeersOnline, MsgTypePeersWentOffline: + return MsgTypeUnknown, fmt.Errorf("%d: %w", msgType, ErrMsgTypeDirection) default: - return MsgTypeUnknown, fmt.Errorf("invalid msg type %d", msgType) + return MsgTypeUnknown, fmt.Errorf("%d: %w", msgType, ErrUnknownMsgType) } } @@ -162,8 +278,41 @@ func MarshalAuthMsg(peerID PeerID, authPayload []byte) ([]byte, error) { return msg, nil } -// UnmarshalAuthMsg extracts peerID and the auth payload from the message +// MarshalAuthEnvelope creates the Auth message layout used by clients advertising protocol +// version 2 or higher: the token is length-prefixed and followed by an extension area that +// servers without support for it ignore. +func MarshalAuthEnvelope(version ProtocolVersion, peerID PeerID, token, ext []byte) ([]byte, error) { + if version < authEnvelopeMinVersion { + return nil, fmt.Errorf("protocol version %d does not use the auth envelope", version) + } + if len(token) == 0 || len(token) > math.MaxUint16 { + return nil, fmt.Errorf("%d: %w", len(token), ErrInvalidTokenLength) + } + + size := offsetAuthToken + len(token) + len(ext) + if size > MaxHandshakeSize { + return nil, fmt.Errorf("too large auth message: %d", size) + } + + msg := make([]byte, size) + msg[0] = byte(version) + msg[1] = byte(MsgTypeAuth) + copy(msg[offsetMagicByte:], magicHeader) + copy(msg[offsetAuthPeerID:], peerID[:]) + binary.BigEndian.PutUint16(msg[offsetAuthTokenLen:], uint16(len(token))) + copy(msg[offsetAuthToken:], token) + copy(msg[offsetAuthToken+len(token):], ext) + return msg, nil +} + +// UnmarshalAuthMsg extracts the peer ID and the auth token from an Auth message of any +// protocol version. The extension area of the envelope layout is not returned. func UnmarshalAuthMsg(msg []byte) (*PeerID, []byte, error) { + version, err := ReadVersion(msg) + if err != nil { + return nil, nil, err + } + if len(msg) < headerTotalSizeAuth { return nil, nil, ErrInvalidMessageLength } @@ -174,7 +323,15 @@ func UnmarshalAuthMsg(msg []byte) (*PeerID, []byte, error) { } peerID := PeerID(msg[offsetAuthPeerID:headerTotalSizeAuth]) - return &peerID, msg[headerTotalSizeAuth:], nil + if version < authEnvelopeMinVersion { + return &peerID, msg[headerTotalSizeAuth:], nil + } + + token, err := unmarshalAuthEnvelopeToken(msg) + if err != nil { + return nil, nil, err + } + return &peerID, token, nil } // MarshalAuthResponse creates a response message to the auth. @@ -182,19 +339,7 @@ func UnmarshalAuthMsg(msg []byte) (*PeerID, []byte, error) { // instance URL. This URL will be used by choose the common Relay server in case if the peers are in different Relay // servers. func MarshalAuthResponse(address string) ([]byte, error) { - ab := []byte(address) - msg := make([]byte, sizeOfProtoHeader, sizeOfProtoHeader+len(ab)) - - msg[0] = byte(CurrentProtocolVersion) - msg[1] = byte(MsgTypeAuthResponse) - - msg = append(msg, ab...) - - if len(msg) > MaxHandshakeRespSize { - return nil, fmt.Errorf("invalid message length: %d", len(msg)) - } - - return msg, nil + return NewEncoder(CurrentProtocolVersion).AuthResponse(address) } // UnmarshalAuthResponse it is a confirmation message to auth success @@ -209,10 +354,7 @@ func UnmarshalAuthResponse(msg []byte) (string, error) { // The close message is used to close the connection gracefully between the client and the server. The server and the // client can send this message. After receiving this message, the server or client will close the connection. func MarshalCloseMsg() []byte { - return []byte{ - byte(CurrentProtocolVersion), - byte(MsgTypeClose), - } + return NewEncoder(CurrentProtocolVersion).CloseMsg() } // MarshalTransportMsg creates a transport message. @@ -252,13 +394,14 @@ func UnmarshalTransportID(buf []byte) (*PeerID, error) { return &id, nil } -// UpdateTransportMsg updates the peerID in the transport message. -// With this function the server can reuse the given byte slice to update the peerID in the transport message. So do -// need to allocate a new byte slice. -func UpdateTransportMsg(msg []byte, peerID PeerID) error { +// UpdateTransportMsg rewrites the transport message in place for forwarding: the header carries +// the protocol version of the destination connection and the peer ID field the sender's ID. +// The payload bytes are left untouched, so no new byte slice is allocated. +func UpdateTransportMsg(msg []byte, version ProtocolVersion, peerID PeerID) error { if len(msg) < offsetTransportID+peerIDSize { return ErrInvalidMessageLength } + msg[0] = byte(version) copy(msg[offsetTransportID:], peerID[:]) return nil } @@ -269,3 +412,20 @@ func UpdateTransportMsg(msg []byte, peerID PeerID) error { func MarshalHealthcheck() []byte { return healthCheckMsg } + +func unmarshalAuthEnvelopeToken(msg []byte) ([]byte, error) { + if len(msg) < offsetAuthToken { + return nil, ErrInvalidMessageLength + } + + tokenLen := int(binary.BigEndian.Uint16(msg[offsetAuthTokenLen:offsetAuthToken])) + if tokenLen == 0 { + return nil, fmt.Errorf("%d: %w", tokenLen, ErrInvalidTokenLength) + } + + end := offsetAuthToken + tokenLen + if end > len(msg) { + return nil, fmt.Errorf("%d exceeds the message: %w", tokenLen, ErrInvalidTokenLength) + } + return msg[offsetAuthToken:end], nil +} diff --git a/shared/relay/messages/message_test.go b/shared/relay/messages/message_test.go index 26a504063..b3727c86a 100644 --- a/shared/relay/messages/message_test.go +++ b/shared/relay/messages/message_test.go @@ -1,7 +1,11 @@ package messages import ( + "encoding/binary" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestDetermineClientMessageTypeRejectsHello(t *testing.T) { @@ -119,3 +123,245 @@ func TestMarshalHealthcheck(t *testing.T) { t.Errorf("expected %d, got %d", MsgTypeHealthCheck, msgType) } } + +func TestReadVersion(t *testing.T) { + _, err := ReadVersion([]byte{byte(ProtocolV1)}) + assert.ErrorIs(t, err, ErrInvalidMessageLength, "a header needs two bytes") + + _, err = ReadVersion([]byte{0, byte(MsgTypeAuth)}) + assert.ErrorIs(t, err, ErrUnsupportedVersion, "version zero is invalid") + + for _, version := range []ProtocolVersion{1, 2, 128, 255} { + got, err := ReadVersion([]byte{byte(version), byte(MsgTypeAuth)}) + require.NoError(t, err) + assert.Equal(t, version, got, "the advertised version must be returned unchanged") + } +} + +func TestSelectVersion(t *testing.T) { + assert.Equal(t, ProtocolV1, SelectVersion(1, 1), "equal versions select themselves") + assert.Equal(t, ProtocolV1, SelectVersion(2, 1), "a higher advertised version selects the server maximum") + assert.Equal(t, ProtocolV1, SelectVersion(255, 1), "the highest advertised version selects the server maximum") + assert.Equal(t, ProtocolVersion(2), SelectVersion(2, 3), "a lower advertised version selects itself") + assert.Equal(t, ProtocolVersion(2), SelectVersion(3, 2), "a higher advertised version selects the server maximum") +} + +func TestValidateVersionAcceptsOnlyCurrent(t *testing.T) { + _, err := ValidateVersion([]byte{byte(CurrentProtocolVersion), byte(MsgTypeHealthCheck)}) + require.NoError(t, err) + + _, err = ValidateVersion([]byte{byte(CurrentProtocolVersion) + 1, byte(MsgTypeHealthCheck)}) + assert.ErrorIs(t, err, ErrUnsupportedVersion, "the client build accepts only its own version") + + _, err = ValidateVersion([]byte{0, byte(MsgTypeHealthCheck)}) + assert.ErrorIs(t, err, ErrUnsupportedVersion, "version zero is invalid") +} + +func TestValidateConnectionVersion(t *testing.T) { + require.NoError(t, ValidateConnectionVersion([]byte{1, byte(MsgTypeHealthCheck)}, ProtocolV1)) + assert.ErrorIs(t, ValidateConnectionVersion([]byte{2, byte(MsgTypeHealthCheck)}, ProtocolV1), ErrUnsupportedVersion, "a frame must carry the selected version") + assert.ErrorIs(t, ValidateConnectionVersion([]byte{1, byte(MsgTypeHealthCheck)}, 2), ErrUnsupportedVersion, "a frame below the selected version is rejected too") + assert.ErrorIs(t, ValidateConnectionVersion([]byte{0, byte(MsgTypeHealthCheck)}, ProtocolV1), ErrUnsupportedVersion, "version zero is invalid") + assert.ErrorIs(t, ValidateConnectionVersion([]byte{1}, ProtocolV1), ErrInvalidMessageLength, "a header needs two bytes") +} + +func TestDetermineClientMessageTypeClassification(t *testing.T) { + rejected := []struct { + name string + msgType MsgType + wantErr error + }{ + {"hello is reserved", MsgTypeHello, ErrReservedMsgType}, + {"hello response is reserved", MsgTypeHelloResponse, ErrReservedMsgType}, + {"auth response is server to client only", MsgTypeAuthResponse, ErrMsgTypeDirection}, + {"peers online is server to client only", MsgTypePeersOnline, ErrMsgTypeDirection}, + {"peers went offline is server to client only", MsgTypePeersWentOffline, ErrMsgTypeDirection}, + {"zero is unknown", MsgTypeUnknown, ErrUnknownMsgType}, + {"a future type is unknown", MsgType(200), ErrUnknownMsgType}, + } + for _, tc := range rejected { + t.Run(tc.name, func(t *testing.T) { + got, err := DetermineClientMessageType([]byte{byte(ProtocolV1), byte(tc.msgType)}) + assert.ErrorIs(t, err, tc.wantErr) + assert.Equal(t, MsgTypeUnknown, got, "a rejected type must not be returned") + }) + } + + accepted := []MsgType{MsgTypeAuth, MsgTypeTransport, MsgTypeClose, MsgTypeHealthCheck, MsgTypeSubscribePeerState, MsgTypeUnsubscribePeerState} + for _, msgType := range accepted { + got, err := DetermineClientMessageType([]byte{byte(ProtocolV1), byte(msgType)}) + require.NoError(t, err) + assert.Equal(t, msgType, got, "a client message type must be returned") + } +} + +func TestAuthEnvelopeRoundTrip(t *testing.T) { + peerID := HashID("future-client") + token := []byte("0123456789abcdef") + cases := []struct { + name string + version ProtocolVersion + ext []byte + }{ + {"v2 without extension", 2, nil}, + {"v2 with json extension", 2, []byte(`{"cred2":"value"}`)}, + {"v255 with opaque extension", 255, []byte("opaque-extension-bytes")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + msg, err := MarshalAuthEnvelope(tc.version, peerID, token, tc.ext) + require.NoError(t, err) + + version, err := ReadVersion(msg) + require.NoError(t, err) + assert.Equal(t, tc.version, version, "the header must carry the advertised version") + + msgType, err := DetermineClientMessageType(msg) + require.NoError(t, err) + assert.Equal(t, MsgType(MsgTypeAuth), msgType, "the envelope is an auth message") + + gotID, gotToken, err := UnmarshalAuthMsg(msg) + require.NoError(t, err) + assert.Equal(t, peerID, *gotID, "the peer ID must be preserved") + assert.Equal(t, token, gotToken, "the token must be returned without the extension bytes") + }) + } +} + +func TestMarshalAuthEnvelopeRejectsInvalidInput(t *testing.T) { + peerID := HashID("future-client") + + _, err := MarshalAuthEnvelope(ProtocolV1, peerID, []byte("token"), nil) + assert.Error(t, err, "v1 does not use the envelope layout") + + _, err = MarshalAuthEnvelope(2, peerID, nil, nil) + assert.ErrorIs(t, err, ErrInvalidTokenLength, "the token is mandatory") + + _, err = MarshalAuthEnvelope(2, peerID, []byte("token"), make([]byte, MaxHandshakeSize)) + assert.Error(t, err, "the envelope must fit the handshake size limit") +} + +func TestUnmarshalAuthMsgV1LayoutUnchanged(t *testing.T) { + peerID := HashID("legacy-client") + // In the v1 layout the token runs to the end of the frame, so bytes that would be a + // length prefix in the envelope belong to the token here. + payload := []byte{0x00, 0x05, 'a', 'b', 'c', 'd', 'e', 'f', 'g'} + msg, err := MarshalAuthMsg(peerID, payload) + require.NoError(t, err) + + gotID, gotToken, err := UnmarshalAuthMsg(msg) + require.NoError(t, err) + assert.Equal(t, peerID, *gotID, "the peer ID must be preserved") + assert.Equal(t, payload, gotToken, "the v1 token must be returned whole") +} + +func TestUnmarshalAuthMsgRejectsMalformedFrames(t *testing.T) { + peerID := HashID("future-client") + valid, err := MarshalAuthEnvelope(2, peerID, []byte("token"), []byte("ext")) + require.NoError(t, err) + + t.Run("zero token length", func(t *testing.T) { + msg := append([]byte(nil), valid...) + binary.BigEndian.PutUint16(msg[offsetAuthTokenLen:], 0) + _, _, err := UnmarshalAuthMsg(msg) + assert.ErrorIs(t, err, ErrInvalidTokenLength) + }) + + t.Run("token length past the end", func(t *testing.T) { + msg := append([]byte(nil), valid...) + binary.BigEndian.PutUint16(msg[offsetAuthTokenLen:], uint16(len(msg))) + _, _, err := UnmarshalAuthMsg(msg) + assert.ErrorIs(t, err, ErrInvalidTokenLength) + }) + + t.Run("truncated before the length field", func(t *testing.T) { + _, _, err := UnmarshalAuthMsg(valid[:headerTotalSizeAuth+1]) + assert.ErrorIs(t, err, ErrInvalidMessageLength) + }) + + t.Run("version zero", func(t *testing.T) { + msg := append([]byte(nil), valid...) + msg[0] = 0 + _, _, err := UnmarshalAuthMsg(msg) + assert.ErrorIs(t, err, ErrUnsupportedVersion) + }) + + t.Run("invalid magic", func(t *testing.T) { + msg := append([]byte(nil), valid...) + msg[offsetMagicByte] ^= 0xFF + _, _, err := UnmarshalAuthMsg(msg) + assert.Error(t, err) + }) + + t.Run("shorter than the fixed header", func(t *testing.T) { + _, _, err := UnmarshalAuthMsg(valid[:headerTotalSizeAuth-1]) + assert.ErrorIs(t, err, ErrInvalidMessageLength) + }) +} + +func TestEncoderStampsVersion(t *testing.T) { + for _, version := range []ProtocolVersion{1, 2, 7} { + enc := NewEncoder(version) + assert.Equal(t, version, enc.Version()) + assert.Equal(t, []byte{byte(version), byte(MsgTypeHealthCheck)}, enc.Healthcheck(), "healthcheck must carry the version") + assert.Equal(t, []byte{byte(version), byte(MsgTypeClose)}, enc.CloseMsg(), "close must carry the version") + + resp, err := enc.AuthResponse("rel://relay.example") + require.NoError(t, err) + assert.Equal(t, byte(version), resp[0], "auth response must carry the version") + assert.Equal(t, byte(MsgTypeAuthResponse), resp[1]) + addr, err := UnmarshalAuthResponse(resp) + require.NoError(t, err) + assert.Equal(t, "rel://relay.example", addr, "the auth response payload must be the address") + + online, err := enc.PeersOnline([]PeerID{HashID("a")}) + require.NoError(t, err) + require.Len(t, online, 1) + assert.Equal(t, byte(version), online[0][0], "peers online must carry the version") + assert.Equal(t, byte(MsgTypePeersOnline), online[0][1]) + + offline, err := enc.PeersWentOffline([]PeerID{HashID("a")}) + require.NoError(t, err) + require.Len(t, offline, 1) + assert.Equal(t, byte(version), offline[0][0], "peers went offline must carry the version") + assert.Equal(t, byte(MsgTypePeersWentOffline), offline[0][1]) + } +} + +func TestPackageEncodersUseCurrentVersion(t *testing.T) { + current := NewEncoder(CurrentProtocolVersion) + assert.Equal(t, current.CloseMsg(), MarshalCloseMsg()) + assert.Equal(t, current.Healthcheck(), MarshalHealthcheck()) + + want, err := current.AuthResponse("addr") + require.NoError(t, err) + got, err := MarshalAuthResponse("addr") + require.NoError(t, err) + assert.Equal(t, want, got) + + ids := []PeerID{HashID("a"), HashID("b")} + wantOnline, err := current.PeersOnline(ids) + require.NoError(t, err) + gotOnline, err := MarshalPeersOnline(ids) + require.NoError(t, err) + assert.Equal(t, wantOnline, gotOnline) +} + +func TestUpdateTransportMsgRewritesVersionAndSender(t *testing.T) { + dst := HashID("dst") + sender := HashID("sender") + payload := []byte("payload") + msg, err := MarshalTransportMsg(dst, payload) + require.NoError(t, err) + + require.NoError(t, UpdateTransportMsg(msg, ProtocolVersion(2), sender)) + assert.Equal(t, byte(2), msg[0], "the header must carry the destination version") + assert.Equal(t, byte(MsgTypeTransport), msg[1], "the message type must be preserved") + + gotID, gotPayload, err := UnmarshalTransportMsg(msg) + require.NoError(t, err) + assert.Equal(t, sender, *gotID, "the peer ID must be rewritten to the sender") + assert.Equal(t, payload, gotPayload, "the payload must be preserved") + + assert.ErrorIs(t, UpdateTransportMsg(msg[:headerTotalSizeTransport-1], ProtocolV1, sender), ErrInvalidMessageLength) +} diff --git a/shared/relay/messages/peer_state.go b/shared/relay/messages/peer_state.go index f10bc7bdf..42b1762b5 100644 --- a/shared/relay/messages/peer_state.go +++ b/shared/relay/messages/peer_state.go @@ -5,7 +5,7 @@ import ( ) func MarshalSubPeerStateMsg(ids []PeerID) ([][]byte, error) { - return marshalPeerIDs(ids, byte(MsgTypeSubscribePeerState)) + return marshalPeerIDs(CurrentProtocolVersion, ids, byte(MsgTypeSubscribePeerState)) } func UnmarshalSubPeerStateMsg(buf []byte) ([]PeerID, error) { @@ -13,7 +13,7 @@ func UnmarshalSubPeerStateMsg(buf []byte) ([]PeerID, error) { } func MarshalUnsubPeerStateMsg(ids []PeerID) ([][]byte, error) { - return marshalPeerIDs(ids, byte(MsgTypeUnsubscribePeerState)) + return marshalPeerIDs(CurrentProtocolVersion, ids, byte(MsgTypeUnsubscribePeerState)) } func UnmarshalUnsubPeerStateMsg(buf []byte) ([]PeerID, error) { @@ -21,7 +21,7 @@ func UnmarshalUnsubPeerStateMsg(buf []byte) ([]PeerID, error) { } func MarshalPeersOnline(ids []PeerID) ([][]byte, error) { - return marshalPeerIDs(ids, byte(MsgTypePeersOnline)) + return marshalPeerIDs(CurrentProtocolVersion, ids, byte(MsgTypePeersOnline)) } func UnmarshalPeersOnlineMsg(buf []byte) ([]PeerID, error) { @@ -29,7 +29,7 @@ func UnmarshalPeersOnlineMsg(buf []byte) ([]PeerID, error) { } func MarshalPeersWentOffline(ids []PeerID) ([][]byte, error) { - return marshalPeerIDs(ids, byte(MsgTypePeersWentOffline)) + return marshalPeerIDs(CurrentProtocolVersion, ids, byte(MsgTypePeersWentOffline)) } func UnMarshalPeersWentOffline(buf []byte) ([]PeerID, error) { @@ -37,7 +37,7 @@ func UnMarshalPeersWentOffline(buf []byte) ([]PeerID, error) { } // marshalPeerIDs is a generic function to marshal peer IDs with a specific message type -func marshalPeerIDs(ids []PeerID, msgType byte) ([][]byte, error) { +func marshalPeerIDs(version ProtocolVersion, ids []PeerID, msgType byte) ([][]byte, error) { if len(ids) == 0 { return nil, fmt.Errorf("no list of peer ids provided") } @@ -54,7 +54,7 @@ func marshalPeerIDs(ids []PeerID, msgType byte) ([][]byte, error) { totalSize := sizeOfProtoHeader + len(chunk)*peerIDSize buf := make([]byte, totalSize) - buf[0] = byte(CurrentProtocolVersion) + buf[0] = byte(version) buf[1] = msgType offset := sizeOfProtoHeader From 031a4bd70de40ec286197a7c6de58613a0692293 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Wed, 21 Oct 2026 15:53:05 +0200 Subject: [PATCH 188/209] [client] Migrate the desktop UI to Tailwind CSS 4 and fix frontend npm audit findings (#8277) * [proxy] Fix npm audit findings in the web UI npm audit reported 10 vulnerabilities (7 high), all in build tooling: Vite/launch-editor, PostCSS, Babel, browserslist, js-yaml, nanoid, brace-expansion and source-map-js. All are resolved by the lockfile update; Vite is pinned exactly, so it is bumped from 7.3.2 to 7.3.7. * [client] Migrate the desktop UI to Tailwind CSS 4 and fix npm audit findings pnpm audit reported 36 vulnerabilities in the desktop UI. All but two (braces and postcss-selector-parser, both pulled in by Tailwind 3 with no patched version in range) are fixed by updating within the existing ranges; react-router moves to 7.18.4, which also fixes an open redirect and an XSS in its error handler. The remaining two go away by moving to Tailwind CSS 4, which the proxy web UI already uses: - The config moves into an @theme block in globals.css; the official upgrade tool applied the v4 utility renames across the components. - @tailwindcss/vite replaces the PostCSS plugin and autoprefixer. - tw-animate-css replaces tailwindcss-animate with the same classes. - The Prettier plugin reads the theme from the stylesheet. A screenshot comparison of every screen against the Tailwind 3 build found three regressions caused by v4's changed utility and variant ordering, fixed here so the UI renders as before: - Label combined inline-block and flex; v4 let inline-block win, which made every settings row taller. The dead inline-block is removed. - The active settings tab lost its highlight in dark mode because dark: now outranks group-data-[state=active]:. The active color is set for dark mode explicitly. - v4 rebuilt its default palette in OKLCH, shifting colors the theme does not override such as sky-400. The Tailwind 3 values for the neutral, zinc, sky, orange and amber families are pinned in @theme. The only remaining visible difference is 2px less height per row in the profiles list, because v4's preflight resets the browser's default table-cell padding. --- client/ui/frontend/.prettierrc | 1 + client/ui/frontend/package.json | 8 +- client/ui/frontend/pnpm-lock.yaml | 2755 ++++++++--------- client/ui/frontend/postcss.config.js | 6 - client/ui/frontend/src/components/Badge.tsx | 2 +- .../src/components/CopyToClipboard.tsx | 12 +- .../frontend/src/components/DropdownMenu.tsx | 28 +- .../src/components/LanguagePicker.tsx | 12 +- .../src/components/ManagementServerSwitch.tsx | 2 +- client/ui/frontend/src/components/Tooltip.tsx | 4 +- .../frontend/src/components/VerticalTabs.tsx | 10 +- .../src/components/buttons/Button.tsx | 22 +- .../src/components/buttons/IconButton.tsx | 2 +- .../src/components/dialog/ConfirmDialog.tsx | 4 +- .../frontend/src/components/dialog/Dialog.tsx | 12 +- .../components/dialog/DialogDescription.tsx | 2 +- .../src/components/dialog/DialogHeading.tsx | 2 +- .../empty-state/DaemonOutdatedOverlay.tsx | 2 +- .../empty-state/DaemonUnavailableOverlay.tsx | 2 +- .../src/components/empty-state/NoResults.tsx | 2 +- .../empty-state/NotConnectedState.tsx | 2 +- .../frontend/src/components/inputs/Input.tsx | 22 +- .../src/components/inputs/SearchInput.tsx | 4 +- .../frontend/src/components/inputs/Select.tsx | 4 +- .../components/switches/FancyToggleSwitch.tsx | 4 +- .../src/components/switches/SwitchItem.tsx | 4 +- .../src/components/switches/ToggleSwitch.tsx | 8 +- .../src/components/typography/Label.tsx | 4 +- client/ui/frontend/src/globals.css | 286 +- .../src/modules/error/ErrorDialog.tsx | 4 +- .../main/MainConnectionStatusSwitch.tsx | 18 +- .../src/modules/main/MainExitNodeSwitcher.tsx | 20 +- .../frontend/src/modules/main/MainHeader.tsx | 6 +- .../src/modules/main/advanced/Navigation.tsx | 4 +- .../main/advanced/networks/NetworkFilters.tsx | 4 +- .../main/advanced/networks/Networks.tsx | 20 +- .../main/advanced/peers/PeerDetailPanel.tsx | 30 +- .../main/advanced/peers/PeerFilters.tsx | 4 +- .../src/modules/main/advanced/peers/Peers.tsx | 8 +- .../src/modules/profiles/ProfileAvatar.tsx | 2 +- .../src/modules/profiles/ProfileDropdown.tsx | 24 +- .../src/modules/profiles/ProfilesTab.tsx | 18 +- .../session/SessionExpirationDialog.tsx | 2 +- .../src/modules/settings/SettingsAbout.tsx | 8 +- .../modules/settings/SettingsNavigation.tsx | 2 +- .../src/modules/settings/SettingsPage.tsx | 2 +- .../src/modules/settings/SettingsSSH.tsx | 2 +- .../src/modules/settings/SettingsSection.tsx | 6 +- .../settings/SettingsTroubleshooting.tsx | 4 +- .../src/modules/welcome/WelcomeStepTray.tsx | 2 +- client/ui/frontend/tailwind.config.ts | 180 -- client/ui/frontend/vite.config.ts | 3 +- proxy/web/package-lock.json | 319 +- proxy/web/package.json | 2 +- 54 files changed, 1913 insertions(+), 2009 deletions(-) delete mode 100644 client/ui/frontend/postcss.config.js delete mode 100644 client/ui/frontend/tailwind.config.ts diff --git a/client/ui/frontend/.prettierrc b/client/ui/frontend/.prettierrc index e47a94f56..be99d3270 100644 --- a/client/ui/frontend/.prettierrc +++ b/client/ui/frontend/.prettierrc @@ -8,5 +8,6 @@ "arrowParens": "always", "endOfLine": "lf", "plugins": ["prettier-plugin-tailwindcss"], + "tailwindStylesheet": "./src/globals.css", "tailwindFunctions": ["cn", "clsx", "cva", "tw"] } diff --git a/client/ui/frontend/package.json b/client/ui/frontend/package.json index dcef99ad3..e8b58fde0 100644 --- a/client/ui/frontend/package.json +++ b/client/ui/frontend/package.json @@ -46,11 +46,11 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.3.3", "@types/node": "^25.6.0", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", - "autoprefixer": "^10.4.20", "eslint": "^9.39.4", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", @@ -59,9 +59,9 @@ "globals": "^17.6.0", "postcss": "^8.5.1", "prettier": "^3.8.3", - "prettier-plugin-tailwindcss": "^0.8.0", - "tailwindcss": "^3.4.17", - "tailwindcss-animate": "^1.0.7", + "prettier-plugin-tailwindcss": "^0.8.1", + "tailwindcss": "^4.3.3", + "tw-animate-css": "^1.4.0", "typescript": "^5.7.3", "typescript-eslint": "^8.61.1", "vite": "^6.0.7" diff --git a/client/ui/frontend/pnpm-lock.yaml b/client/ui/frontend/pnpm-lock.yaml index b6b3dd336..3d05e8064 100644 --- a/client/ui/frontend/pnpm-lock.yaml +++ b/client/ui/frontend/pnpm-lock.yaml @@ -10,37 +10,37 @@ importers: dependencies: '@radix-ui/react-dialog': specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.3.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 - version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.2.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-label': specifier: ^2.1.8 - version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-popover': specifier: ^1.1.15 - version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.3.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-radio-group': specifier: ^1.3.8 - version: 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.5.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-scroll-area': specifier: ^1.2.10 - version: 1.2.10(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-switch': specifier: ^1.2.6 - version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.4.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-tabs': specifier: ^1.1.13 - version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.2.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-tooltip': specifier: ^1.2.8 - version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.4.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-visually-hidden': specifier: ^1.2.4 - version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wailsio/runtime': specifier: latest - version: 3.0.0-alpha.79 + version: 3.0.0-beta.33 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -49,13 +49,13 @@ importers: version: 2.1.1 cmdk: specifier: ^1.1.1 - version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) framer-motion: specifier: ^12.38.0 - version: 12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 12.43.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) i18next: specifier: ^26.2.0 - version: 26.3.0(typescript@5.9.3) + version: 26.5.3(typescript@5.9.3) lucide-react: specifier: ^0.566.0 version: 0.566.0(react@18.3.1) @@ -67,87 +67,83 @@ importers: version: 18.3.1(react@18.3.1) react-i18next: specifier: ^17.0.8 - version: 17.0.8(i18next@26.3.0(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + version: 17.1.0(i18next@26.5.3(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) react-loading-skeleton: specifier: ^3.5.0 version: 3.5.0(react@18.3.1) react-router-dom: specifier: ^7.1.3 - version: 7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 7.18.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-virtuoso: specifier: ^4.12.5 - version: 4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.18.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tailwind-merge: specifier: ^2.6.0 version: 2.6.1 devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@9.39.4(jiti@1.21.7)) + version: 10.0.1(eslint@9.39.5(jiti@2.7.0)) + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@6.4.4(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0)) '@types/node': specifier: ^25.6.0 - version: 25.9.1 + version: 25.9.9 '@types/react': specifier: ^18.3.18 - version: 18.3.29 + version: 18.3.31 '@types/react-dom': specifier: ^18.3.5 - version: 18.3.7(@types/react@18.3.29) + version: 18.3.7(@types/react@18.3.31) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7)) - autoprefixer: - specifier: ^10.4.20 - version: 10.5.0(postcss@8.5.15) + version: 4.7.0(vite@6.4.4(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0)) eslint: specifier: ^9.39.4 - version: 9.39.4(jiti@1.21.7) + version: 9.39.5(jiti@2.7.0) eslint-plugin-jsx-a11y: specifier: ^6.10.2 - version: 6.10.2(eslint@9.39.4(jiti@1.21.7)) + version: 6.10.2(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react: specifier: ^7.37.5 - version: 7.37.5(eslint@9.39.4(jiti@1.21.7)) + version: 7.37.5(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react-hooks: specifier: ^7.1.1 - version: 7.1.1(eslint@9.39.4(jiti@1.21.7)) + version: 7.1.1(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react-refresh: specifier: ^0.5.3 - version: 0.5.3(eslint@9.39.4(jiti@1.21.7)) + version: 0.5.7(eslint@9.39.5(jiti@2.7.0)) globals: specifier: ^17.6.0 - version: 17.6.0 + version: 17.13.0 postcss: specifier: ^8.5.1 - version: 8.5.15 + version: 8.5.31 prettier: specifier: ^3.8.3 - version: 3.8.3 + version: 3.9.10 prettier-plugin-tailwindcss: - specifier: ^0.8.0 - version: 0.8.0(prettier@3.8.3) + specifier: ^0.8.1 + version: 0.8.1(prettier@3.9.10) tailwindcss: - specifier: ^3.4.17 - version: 3.4.19 - tailwindcss-animate: - specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.19) + specifier: ^4.3.3 + version: 4.3.3 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 typescript: specifier: ^5.7.3 version: 5.9.3 typescript-eslint: specifier: ^8.61.1 - version: 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + version: 8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) vite: specifier: ^6.0.7 - version: 6.4.2(@types/node@25.9.1)(jiti@1.21.7) + version: 6.4.4(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0) packages: - '@alloc/quick-lru@5.2.0': - resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} - engines: {node: '>=10'} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -160,8 +156,8 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} '@babel/helper-compilation-targets@7.29.7': @@ -198,12 +194,12 @@ packages: resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.7': - resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + '@babel/helpers@7.29.10': + resolution: {integrity: sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} engines: {node: '>=6.0.0'} hasBin: true @@ -219,20 +215,20 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.29.7': - resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} engines: {node: '>=6.9.0'} '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + '@babel/traverse@7.29.10': + resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} '@esbuild/aix-ppc64@0.25.12': @@ -391,8 +387,8 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -413,8 +409,8 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + '@eslint/eslintrc@3.3.7': + resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@10.0.1': @@ -426,8 +422,8 @@ packages: eslint: optional: true - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -438,20 +434,20 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.7.5': - resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} - '@floating-ui/dom@1.7.6': - resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} - '@floating-ui/react-dom@2.1.8': - resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/utils@0.2.11': - resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} @@ -483,32 +479,27 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} + '@radix-ui/number@1.1.3': + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} + '@radix-ui/primitive@1.2.0': + resolution: {integrity: sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==} - '@radix-ui/number@1.1.1': - resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} - - '@radix-ui/primitive@1.1.3': - resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - - '@radix-ui/react-arrow@1.1.7': - resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + '@radix-ui/react-arrow@1.1.17': + resolution: {integrity: sha512-67jjcVvxjXCCStnTW3Z+M7jmaFMuMR7M2JgpDzH4w7JSRrikmzhjfYgP/09ECb8n7whnsgh1ifJ+Ie96qEj+pQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -520,8 +511,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collection@1.1.7': - resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + '@radix-ui/react-collection@1.2.0': + resolution: {integrity: sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -533,8 +524,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-compose-refs@1.1.2': - resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -542,8 +533,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-context@1.1.2': - resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + '@radix-ui/react-context@1.3.0': + resolution: {integrity: sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -551,8 +542,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dialog@1.1.15': - resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + '@radix-ui/react-dialog@1.3.1': + resolution: {integrity: sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -564,8 +555,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-direction@1.1.1': - resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + '@radix-ui/react-direction@1.2.0': + resolution: {integrity: sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -573,8 +564,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dismissable-layer@1.1.11': - resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + '@radix-ui/react-dismissable-layer@1.2.1': + resolution: {integrity: sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -586,8 +577,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dropdown-menu@2.1.16': - resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + '@radix-ui/react-dropdown-menu@2.2.1': + resolution: {integrity: sha512-XsHcmYG2AcqnvTqrOXXWcMrhquEIlqucizuDH1MV/qMRW+DndpBCMvNeTRXOxqmG1H5j89M/+I0/34Zdd+zDcQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -599,8 +590,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-focus-guards@1.1.3': - resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + '@radix-ui/react-focus-guards@1.2.0': + resolution: {integrity: sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -608,8 +599,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-focus-scope@1.1.7': - resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + '@radix-ui/react-focus-scope@1.3.1': + resolution: {integrity: sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -621,8 +612,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-id@1.1.1': - resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -630,8 +621,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-label@2.1.8': - resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + '@radix-ui/react-label@2.1.17': + resolution: {integrity: sha512-xmj7MN/drx1oUAXkfiFIvyDmGNUIN/omGobvA5oAoNqu9zO2MkISYGZOkBcqSzs/UZXXwV+/3ACRE7q7mGcmkA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -643,8 +634,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-menu@2.1.16': - resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + '@radix-ui/react-menu@2.2.1': + resolution: {integrity: sha512-G34BsU90SBj5foYpz8pQqq/Q0Dlfyyl6KitWEfRwXC0QvfwhWNAwS49DWP2+rRJ0iwv20Z2Qx7Sk+T03bGo5JA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -656,8 +647,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popover@1.1.15': - resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + '@radix-ui/react-popover@1.3.1': + resolution: {integrity: sha512-ZNxvXRx5W2Wos4fXCXb5lpdqN0Z5chf+mStOUOTr4ik7d+62MRvoe9rzCA/eGNg36lxXqSrKEKmwuZEcUrIstw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -669,8 +660,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popper@1.2.8': - resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + '@radix-ui/react-popper@1.4.0': + resolution: {integrity: sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -682,8 +673,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-portal@1.1.9': - resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + '@radix-ui/react-portal@1.2.0': + resolution: {integrity: sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -695,8 +686,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-presence@1.1.5': - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + '@radix-ui/react-presence@1.1.11': + resolution: {integrity: sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -708,8 +699,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.1.3': - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + '@radix-ui/react-primitive@2.2.0': + resolution: {integrity: sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -721,8 +712,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.1.4': - resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + '@radix-ui/react-radio-group@1.5.1': + resolution: {integrity: sha512-QbyVT1ddnjqB9H+CUYKkNWdU9fIK3QHCYtjf7mS6ekSqiuXkWtWn5UZW9mhA8qtl3vrVyIsmR9gePstAtKrqkw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -734,8 +725,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-radio-group@1.3.8': - resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + '@radix-ui/react-roving-focus@1.2.1': + resolution: {integrity: sha512-UwraORzVUeEPMclhO1p0vz0qI+TbdDLO9i+FiOqhJyHKwdpCRCck9yVEscUnAMIzCiuNoQa8D4A9bIpOPXGt6Q==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -747,8 +738,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-roving-focus@1.1.11': - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + '@radix-ui/react-scroll-area@1.4.0': + resolution: {integrity: sha512-CFrLqHNejWq5FhKAcj9V/WPGRsol76HBQto81NdOBVFEOqIEkJkKIJt0Z824A/9hHLCtoiR/Kw3euRgzXP2pxg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -760,8 +751,17 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-scroll-area@1.2.10': - resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + '@radix-ui/react-slot@1.5.0': + resolution: {integrity: sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.4.1': + resolution: {integrity: sha512-NpBGwc40rY02ZmSvuyeKk0Bvc8ZsNeTMi4qHvJ+ZH4EiYCv0zIXEgdCW4bVdzgIUeP9xxbLJSxKOUfqITYNmkA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -773,26 +773,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-slot@1.2.3': - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-slot@1.2.4': - resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-switch@1.2.6': - resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + '@radix-ui/react-tabs@1.2.1': + resolution: {integrity: sha512-hRoJlode+hg5ZNlR+3tkfkUliIybdTpD5lkAvHQ9yaXseGf7XVO9Ib2GPEIOuaJJbo7YWYbNpDDRfo8Dhf6/xA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -804,8 +786,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-tabs@1.1.13': - resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + '@radix-ui/react-tooltip@1.4.1': + resolution: {integrity: sha512-NxLFGSFtZ78zVfcoOhTwR1xwqo5qx1Jh8Pw6FMZ9McCaqfRzz7/gIRahs2Oy5XlPgQ+8QkUcKbN5B0U44J/kCg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -817,8 +799,71 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-tooltip@1.2.8': - resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + '@radix-ui/react-use-callback-ref@1.1.5': + resolution: {integrity: sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.3.0': + resolution: {integrity: sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.5': + resolution: {integrity: sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.13': + resolution: {integrity: sha512-feijiN1oPk4rck4u9Dm9tsU4mT6Wp9oeEEhaz1WUEs1VuqLf8xzsFG+Qk+uWq6TnMH9O+WRECRg0NftyP9E4Fw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -830,248 +875,244 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-use-callback-ref@1.1.1': - resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-controllable-state@1.2.2': - resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-effect-event@0.0.2': - resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-escape-keydown@1.1.1': - resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-layout-effect@1.1.1': - resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-previous@1.1.1': - resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-rect@1.1.1': - resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-size@1.1.1': - resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-visually-hidden@1.2.3': - resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-visually-hidden@1.2.4': - resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/rect@1.1.1': - resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - '@rollup/rollup-android-arm-eabi@4.60.4': - resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + '@rollup/rollup-android-arm-eabi@4.64.12': + resolution: {integrity: sha512-Z8lz+5UOiCJLonjeqwJlhUtHA/Sr6R1AidkccQxdWF0nigNzP81aLoU+udgOaGfL5s1uzFXzcux/pCwGAMu0lw==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.60.4': - resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + '@rollup/rollup-android-arm64@4.64.12': + resolution: {integrity: sha512-+tKxNa2psFmiTb0KfXlXs4Yj7vkEhzAvZ0DrQ5+0gWHQJM10jSleQ3l1FIVm5uvD53YM8lHfM4mgCLDDN9f1Gg==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.60.4': - resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + '@rollup/rollup-darwin-arm64@4.64.12': + resolution: {integrity: sha512-iiripdfvyRBugV55/Dk7GplNXQtN/PG85OcKeveU1z2UkTg0MofbBF1qk//qgPWiqJbVvQcXAVCfE56oDGO4EQ==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.60.4': - resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + '@rollup/rollup-darwin-x64@4.64.12': + resolution: {integrity: sha512-BmATib8swSMAagWNIfDZMnb+k34RXR4bbGOUfZgK8lLlniuMyVoSDDXCOGkdhtUmBXL4aygDh7bhjQ+TmhTvIw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.60.4': - resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + '@rollup/rollup-freebsd-arm64@4.64.12': + resolution: {integrity: sha512-GzrO0CiUq8vcAI/hYJuIcp6wNm3T3YhOpumYtf9Z3Fz+cfN1VESCVfy7EPcM3/m2K8l+Gn2U+4XtNZjwhUXctQ==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.60.4': - resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + '@rollup/rollup-freebsd-x64@4.64.12': + resolution: {integrity: sha512-Dpta6gigm4o6SYioXtTkZBlGLA/Sl6OpzgLqY+51uafJE2blPejAtR1w82CVLPN+F9cvo1+/em92x/zcOgY0uQ==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.60.4': - resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + '@rollup/rollup-linux-arm-gnueabihf@4.64.12': + resolution: {integrity: sha512-YpeDwQ5uFaogRvytNC8RK0keDO65kI5wNEhGSs+4GiJc3KHhxj4j5cnQh2nCu9ybJj6RP0/ycB6bkN0yFJ9I+w==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.60.4': - resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + '@rollup/rollup-linux-arm-musleabihf@4.64.12': + resolution: {integrity: sha512-LQrB3/sImIr1ycrlEyaApOLH1rAy8JV4/dH4eoK817aWATdcs4fESXz53/w72NwDnwPM5zEUpoL7socH4ZDnwA==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.60.4': - resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + '@rollup/rollup-linux-arm64-gnu@4.64.12': + resolution: {integrity: sha512-iAF2iYu9S4SBfNsb4k0ewNBjO83WxHEltIuKQBGMzEKFgrYfmJn+gatBDXR2t/0zJN0UW0kzR4rfvQ1UHzkZ1A==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.60.4': - resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + '@rollup/rollup-linux-arm64-musl@4.64.12': + resolution: {integrity: sha512-0MQdRtdbirJCXPIVeW/hd/LXLR8Qz96pc+2EBptuuV5q/zSGOzND7SZt8gBTE58NC9cUG/8UxcUXQiKRZ2WzzA==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.60.4': - resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + '@rollup/rollup-linux-loong64-gnu@4.64.12': + resolution: {integrity: sha512-yGDEwn0AGnrY/KCz2oY8R9KKNiQnTcwNuCWXeSXF0VLxXzlqoCgaS5QHsf1lbQvKWwPuN2Czkjk1wXRCBONTOQ==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.60.4': - resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + '@rollup/rollup-linux-loong64-musl@4.64.12': + resolution: {integrity: sha512-AjlbD0efZEhHaJ3bsNjxSFDoguNq6VZvC+FKGJGBCA+ZN7BrZq2TuuSWFa6AI8IpOeuvhp2LoYcP7jiZwmtU0Q==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.60.4': - resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + '@rollup/rollup-linux-ppc64-gnu@4.64.12': + resolution: {integrity: sha512-sDW0CiPoUCEEUV25uBG5sJ2gKe75DJqLpj91m1eK1vKffywyY7v4i6q1450ZuR6QW4NArXg7JpbMGgKU8di3HQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.60.4': - resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + '@rollup/rollup-linux-ppc64-musl@4.64.12': + resolution: {integrity: sha512-VTQYUhMjl8kv999VuYqScgv7P9cR9KuxI/6bkUsEa3bsswqG0+otFjuLZmJ1U+ffEP4IAz0jxOeV0DX6NRBhjg==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.60.4': - resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + '@rollup/rollup-linux-riscv64-gnu@4.64.12': + resolution: {integrity: sha512-ppsKwpSjNrzJ8hge3Z3uiCcoRgAAQk2tU7auy3MFF348ffWRldFPnWIT/a/enK7huijlTfnwn4DYpDoapEb45A==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.60.4': - resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + '@rollup/rollup-linux-riscv64-musl@4.64.12': + resolution: {integrity: sha512-Gc3cVRC1trFH+GFWWTsJz6YuD4u75fzr/q99jqQGimonN/zvN6Wecck5WGzllOHj+XYXHcq0Ibor0tanGjfI6A==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.60.4': - resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + '@rollup/rollup-linux-s390x-gnu@4.64.12': + resolution: {integrity: sha512-pjmDTFStvwFE3shTeUDvSDiSp8/qAf5bQXejcEUDDavWz4jEhTIpCdpS06G0PEwSCSNK0Lngjz2v9l4hk3Z74A==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.60.4': - resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + '@rollup/rollup-linux-x64-gnu@4.64.12': + resolution: {integrity: sha512-mkbjxPqpR91u1JD7xY4aLZhr/Mqka4/RqGcG4WNGgN/lC1o7TzyCyqJoSJXp9qVVxMwpJGFlEF1VlERb+Ghw1Q==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.60.4': - resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + '@rollup/rollup-linux-x64-musl@4.64.12': + resolution: {integrity: sha512-vIeut2LkuVc3gcsawLJPahNAzvzBcFRhu2O+yVb//BuQKz7PIItMF0piXRbN/lIDO3d68TSDd368Vg6YwgTybw==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.60.4': - resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + '@rollup/rollup-openbsd-x64@4.64.12': + resolution: {integrity: sha512-3Sx+D3r9oPkTdNUcPrIRM2Am+vzEV6VGWoImHG4ZVK2jJeOW+eDjLeTm6nvu/z/m0fiX2YmGLXIypblc336MXg==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.60.4': - resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + '@rollup/rollup-openharmony-arm64@4.64.12': + resolution: {integrity: sha512-Nn2xh0teFnl71EiiUrKvECWcdGXtWF8q0uGclYwXGVw6QClGAcME0/bJSGdK+xIvd6MLEKYmjkTUvCBV+7m5Kw==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.60.4': - resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + '@rollup/rollup-win32-arm64-msvc@4.64.12': + resolution: {integrity: sha512-n1/0H9TS+kBa4xp6n9aV7yiynBNfmerjkj2nAMIfY0ZHv1zShWRXnsAuAPAH0sfEK4ZSMK+z24q2RjAPFLCN1w==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.60.4': - resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + '@rollup/rollup-win32-ia32-msvc@4.64.12': + resolution: {integrity: sha512-AdlDbQpsLfGlsHr1NfLZFtdnvxCKNp9TwlYVYExs8JJLWjLafUQEKJk0r9zUbmU57mw9nglMEvzqPHuLWYmaKw==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.60.4': - resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + '@rollup/rollup-win32-x64-gnu@4.64.12': + resolution: {integrity: sha512-u5SSUJBHod51UamjkigSr+JPre6xMy5vaWTTV2K0SnFG4RuKyYpf66dTSLFKX2cwaldpwyBurXPUCxq8riQMEg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.4': - resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + '@rollup/rollup-win32-x64-msvc@4.64.12': + resolution: {integrity: sha512-j5rXD4R/4DM5+V991AA9xOnG2Rwu2g3p5TPgu1Eu7apju0NUrLl+Bn/KpWc6wYJtlXqzwz1r+LNV1JeBU4VGvw==} cpu: [x64] os: [win32] + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1084,14 +1125,14 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@25.9.1': - resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + '@types/node@25.9.9': + resolution: {integrity: sha512-b4e2xxj/yMeT2hNlD6x7zNFiQK2Dlmkb9CQ6v3/1G2RezKN4YS2lcEv8lzGm1mhs4f2lM4yNAMI/zUDBZBBt7w==} '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -1101,66 +1142,66 @@ packages: peerDependencies: '@types/react': ^18.0.0 - '@types/react@18.3.29': - resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==} + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} - '@typescript-eslint/eslint-plugin@8.61.1': - resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==} + '@typescript-eslint/eslint-plugin@8.72.0': + resolution: {integrity: sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.61.1 + '@typescript-eslint/parser': ^8.72.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.61.1': - resolution: {integrity: sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==} + '@typescript-eslint/parser@8.72.0': + resolution: {integrity: sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.61.1': - resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==} + '@typescript-eslint/project-service@8.72.0': + resolution: {integrity: sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.61.1': - resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==} + '@typescript-eslint/scope-manager@8.72.0': + resolution: {integrity: sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.61.1': - resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==} + '@typescript-eslint/tsconfig-utils@8.72.0': + resolution: {integrity: sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.61.1': - resolution: {integrity: sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==} + '@typescript-eslint/type-utils@8.72.0': + resolution: {integrity: sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.61.1': - resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} + '@typescript-eslint/types@8.72.0': + resolution: {integrity: sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.61.1': - resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==} + '@typescript-eslint/typescript-estree@8.72.0': + resolution: {integrity: sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.61.1': - resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==} + '@typescript-eslint/utils@8.72.0': + resolution: {integrity: sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.61.1': - resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==} + '@typescript-eslint/visitor-keys@8.72.0': + resolution: {integrity: sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitejs/plugin-react@4.7.0': @@ -1169,16 +1210,16 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@wailsio/runtime@3.0.0-alpha.79': - resolution: {integrity: sha512-NITzxKmJsMEruc39L166lbPJVECxzcbdqpHVqOOF7Cu/7Zqk/e3B/gNpkUjhNyo5rVb3V1wpS8oEgLUmpu1cwA==} + '@wailsio/runtime@3.0.0-beta.33': + resolution: {integrity: sha512-3fiyyS06cdiZZk3aLnu+VEY3V8+vFzPZTJMDLIBCJ0Pe8pELn/aVm73fRj7amkYhpEUg24tsuVswnLJAKcvfig==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} engines: {node: '>=0.4.0'} hasBin: true @@ -1189,16 +1230,6 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - any-promise@1.3.0: - resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - arg@5.0.2: - resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1214,8 +1245,8 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} - array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + array-includes@3.2.0: + resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==} engines: {node: '>= 0.4'} array.prototype.findlast@1.2.5: @@ -1245,19 +1276,12 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} - autoprefixer@10.5.0: - resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axe-core@4.12.1: - resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} + axe-core@4.14.0: + resolution: {integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==} engines: {node: '>=4'} axobject-query@4.1.0: @@ -1271,28 +1295,20 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.32: - resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} + baseline-browser-mapping@2.11.30: + resolution: {integrity: sha512-YJs6Pz17RMNv8HoyKREtCY38E3BV1nPkWVmIb+XN1uHC1Vqt3KZCH/uJH9ZKRoiKb/icZ6tvehyee6RZNbFfRA==} engines: {node: '>=6.0.0'} hasBin: true - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} - brace-expansion@1.1.15: - resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.29.4: + resolution: {integrity: sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1312,21 +1328,13 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - camelcase-css@2.0.1: - resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} - engines: {node: '>= 6'} - - caniuse-lite@1.0.30001793: - resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + caniuse-lite@1.0.30001817: + resolution: {integrity: sha512-uZ1JG80BwhtMalIAGPxPfmNhHyd/B/nTdr3hITWlRamSyAdUStP1Gop01e7SD5q6J90ixJvWDbvmiFVfDgn4bw==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} - class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -1347,10 +1355,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - commander@4.1.1: - resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} - engines: {node: '>= 6'} - concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1365,11 +1369,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1408,15 +1407,13 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - didyoumean@1.2.2: - resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - - dlv@1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1425,12 +1422,16 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.362: - resolution: {integrity: sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==} + electron-to-chromium@1.5.460: + resolution: {integrity: sha512-rRaVC7bNUyGsL+GMtmZfv3AfgG6za7gE9v5bT0PIbjEerddA0IQ+tCK05NFAXqCpNlrxBZqbFqquyxHRQONHTA==} emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enhanced-resolve@5.26.0: + resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} + engines: {node: '>=10.13.0'} + es-abstract-get@1.0.0: resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} engines: {node: '>= 0.4'} @@ -1447,8 +1448,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-iterator-helpers@1.3.3: - resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==} + es-iterator-helpers@1.4.0: + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} engines: {node: '>= 0.4'} es-object-atoms@1.1.2: @@ -1463,8 +1464,8 @@ packages: resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} engines: {node: '>= 0.4'} - es-to-primitive@1.3.1: - resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} + es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} engines: {node: '>= 0.4'} esbuild@0.25.12: @@ -1492,8 +1493,8 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 - eslint-plugin-react-refresh@0.5.3: - resolution: {integrity: sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==} + eslint-plugin-react-refresh@0.5.7: + resolution: {integrity: sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==} peerDependencies: eslint: ^9 || ^10 @@ -1519,9 +1520,10 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -1552,19 +1554,12 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1578,10 +1573,6 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1590,18 +1581,15 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - fraction.js@5.3.4: - resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - - framer-motion@12.40.0: - resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} + framer-motion@12.43.0: + resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -1653,10 +1641,6 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1665,8 +1649,8 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} - globals@17.6.0: - resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + globals@17.13.0: + resolution: {integrity: sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==} engines: {node: '>=18'} globalthis@1.0.4: @@ -1677,6 +1661,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -1700,10 +1687,6 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hasown@2.0.3: - resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} - engines: {node: '>= 0.4'} - hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -1714,13 +1697,13 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - html-parse-stringify@3.0.1: - resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-parse-stringify@4.0.1: + resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==} - i18next@26.3.0: - resolution: {integrity: sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==} + i18next@26.5.3: + resolution: {integrity: sha512-6pRwOwJ6lBCBB66fqNZaSoUmvsALHMord7FL6GsDhubT1CrPabTMEJIqddm0nn2YEbKofdI/3VAtGrQPuztY1Q==} peerDependencies: - typescript: ^5 || ^6 + typescript: ^5 || ^6 || ^7 peerDependenciesMeta: typescript: optional: true @@ -1729,8 +1712,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + ignore@7.0.13: + resolution: {integrity: sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==} engines: {node: '>= 4'} import-fresh@3.3.1: @@ -1757,10 +1740,6 @@ packages: resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} engines: {node: '>= 0.4'} - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - is-boolean-object@1.2.2: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} @@ -1769,8 +1748,8 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - is-core-module@2.16.2: - resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + is-core-module@2.17.0: + resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} engines: {node: '>= 0.4'} is-data-view@1.0.2: @@ -1813,10 +1792,6 @@ packages: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -1863,15 +1838,15 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} - jiti@1.21.7: - resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jsesc@3.1.0: @@ -1911,12 +1886,79 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} @@ -1937,27 +1979,22 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - motion-dom@12.40.0: - resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} + motion-dom@12.43.0: + resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==} motion-utils@12.39.0: resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} @@ -1965,37 +2002,26 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - mz@2.7.0: - resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - node-exports-info@1.6.0: - resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + node-exports-info@1.6.2: + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} engines: {node: '>= 0.4'} - node-releases@2.0.46: - resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + node-releases@2.0.59: + resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==} engines: {node: '>=18'} - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - object-hash@3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} - engines: {node: '>= 6'} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -2024,8 +2050,8 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + own-keys@1.0.2: + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} p-limit@3.1.0: @@ -2054,79 +2080,24 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - pify@2.3.0: - resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} - engines: {node: '>=0.10.0'} - - pirates@4.0.7: - resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} - engines: {node: '>= 6'} - possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss-import@15.1.0: - resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} - engines: {node: '>=14.0.0'} - peerDependencies: - postcss: ^8.0.0 - - postcss-js@4.1.0: - resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} - engines: {node: ^12 || ^14 || >= 16} - peerDependencies: - postcss: ^8.4.21 - - postcss-load-config@6.0.1: - resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} - engines: {node: '>= 18'} - peerDependencies: - jiti: '>=1.21.0' - postcss: '>=8.0.9' - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - jiti: - optional: true - postcss: - optional: true - tsx: - optional: true - yaml: - optional: true - - postcss-nested@6.2.0: - resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.2.14 - - postcss-selector-parser@6.1.2: - resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} - engines: {node: '>=4'} - - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier-plugin-tailwindcss@0.8.0: - resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==} + prettier-plugin-tailwindcss@0.8.1: + resolution: {integrity: sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw==} engines: {node: '>=20.19'} peerDependencies: '@ianvs/prettier-plugin-sort-imports': '*' @@ -2180,8 +2151,8 @@ packages: prettier-plugin-svelte: optional: true - prettier@3.8.3: - resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + prettier@3.9.10: + resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} engines: {node: '>=14'} hasBin: true @@ -2192,22 +2163,19 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: react: ^18.3.1 - react-i18next@17.0.8: - resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} + react-i18next@17.1.0: + resolution: {integrity: sha512-4VouOnTDyvhvqXx6WZ+s3/cgSiaLutILdYkO3P1Ms+4qcKR7At+JP4Vi7IeHkVLyVV+DmN1l23Pn73UcwSGGPA==} peerDependencies: i18next: '>= 26.2.0' react: '>= 16.8.0' react-dom: '*' react-native: '*' - typescript: ^5 || ^6 + typescript: ^5 || ^6 || ^7 peerDependenciesMeta: react-dom: optional: true @@ -2248,15 +2216,15 @@ packages: '@types/react': optional: true - react-router-dom@7.15.1: - resolution: {integrity: sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==} + react-router-dom@7.18.4: + resolution: {integrity: sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' react-dom: '>=18' - react-router@7.15.1: - resolution: {integrity: sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==} + react-router@7.18.4: + resolution: {integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -2275,8 +2243,8 @@ packages: '@types/react': optional: true - react-virtuoso@4.18.7: - resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==} + react-virtuoso@4.18.19: + resolution: {integrity: sha512-dewyZisA5IiQTldh7eHM5KpI+qxi0nKRVYi2iM01XmPkNa5tjvVUKJKAIc6ted1hAK1/NFlXuhXpVYqGHHdcdQ==} peerDependencies: react: '>=16 || >=17 || >= 18 || >= 19' react-dom: '>=16 || >=17 || >= 18 || >=19' @@ -2285,13 +2253,6 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} - read-cache@1.0.0: - resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} - - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -2304,28 +2265,16 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - resolve@1.22.12: - resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} - engines: {node: '>= 0.4'} - hasBin: true - resolve@2.0.0-next.7: resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} engines: {node: '>= 0.4'} hasBin: true - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rollup@4.60.4: - resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + rollup@4.64.12: + resolution: {integrity: sha512-GhAu8ZglZ61VWSX/ZW3K/qkazgURBIQL0/Yy51QsS8DPyebR5Cf/Z2PANJFpTU0aVZ9byJcyqR6xvBbkXhdGYQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} @@ -2345,8 +2294,8 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.8.4: - resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -2389,8 +2338,8 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} stop-iteration-iterator@1.1.0: @@ -2401,8 +2350,8 @@ packages: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} - string.prototype.matchall@4.0.12: - resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + string.prototype.matchall@4.1.0: + resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==} engines: {node: '>= 0.4'} string.prototype.repeat@1.0.0: @@ -2424,11 +2373,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - sucrase@3.35.1: - resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2440,43 +2384,29 @@ packages: tailwind-merge@2.6.1: resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} - tailwindcss-animate@1.0.7: - resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} - peerDependencies: - tailwindcss: '>=3.0.0 || insiders' + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} - tailwindcss@3.4.19: - resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} - engines: {node: '>=14.0.0'} - hasBin: true + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} - thenify-all@1.6.0: - resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} - engines: {node: '>=0.8'} - - thenify@3.3.1: - resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' - ts-interface-checker@0.1.13: - resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2489,16 +2419,16 @@ packages: resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} engines: {node: '>= 0.4'} - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + typed-array-byte-offset@1.0.5: + resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==} engines: {node: '>= 0.4'} typed-array-length@1.0.8: resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} - typescript-eslint@8.61.1: - resolution: {integrity: sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==} + typescript-eslint@8.72.0: + resolution: {integrity: sha512-r26Fkc8As+mZvqp7SLsMmNFyYUup/6CjK7/eHBHmhOw6GH6n6YFxHVFrG0jN1c5CMpDzelLPxvgeCOzx7BjN+A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2516,8 +2446,8 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.4: + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -2545,16 +2475,13 @@ packages: '@types/react': optional: true - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - vite@6.4.2: - resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + vite@6.4.4: + resolution: {integrity: sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -2593,10 +2520,6 @@ packages: yaml: optional: true - void-elements@3.1.0: - resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} - engines: {node: '>=0.10.0'} - which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2609,8 +2532,8 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} - which-typed-array@1.1.22: - resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + which-typed-array@1.1.24: + resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==} engines: {node: '>= 0.4'} which@2.0.2: @@ -2635,13 +2558,11 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} snapshots: - '@alloc/quick-lru@5.2.0': {} - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2653,14 +2574,14 @@ snapshots: '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/helpers': 7.29.10 + '@babel/parser': 7.29.9 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.10 + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -2670,10 +2591,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.7': + '@babel/generator@7.29.8': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -2682,7 +2603,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 + browserslist: 4.29.4 lru-cache: 5.1.1 semver: 6.3.1 @@ -2690,8 +2611,8 @@ snapshots: '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.10 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -2700,7 +2621,7 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.10 transitivePeerDependencies: - supports-color @@ -2712,14 +2633,14 @@ snapshots: '@babel/helper-validator-option@7.29.7': {} - '@babel/helpers@7.29.7': + '@babel/helpers@7.29.10': dependencies: '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 - '@babel/parser@7.29.7': + '@babel/parser@7.29.9': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': dependencies: @@ -2731,27 +2652,27 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/runtime@7.29.7': {} + '@babel/runtime@7.29.10': {} '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.10': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.9 '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.29.7': + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 @@ -2834,9 +2755,9 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))': dependencies: - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.5(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -2857,7 +2778,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5': + '@eslint/eslintrc@3.3.7': dependencies: ajv: 6.15.0 debug: 4.4.3 @@ -2865,17 +2786,17 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@10.0.1(eslint@9.39.4(jiti@1.21.7))': + '@eslint/js@10.0.1(eslint@9.39.5(jiti@2.7.0))': optionalDependencies: - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.5(jiti@2.7.0) - '@eslint/js@9.39.4': {} + '@eslint/js@9.39.5': {} '@eslint/object-schema@2.1.7': {} @@ -2884,22 +2805,22 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@floating-ui/core@1.7.5': + '@floating-ui/core@1.8.0': dependencies: - '@floating-ui/utils': 0.2.11 + '@floating-ui/utils': 0.2.12 - '@floating-ui/dom@1.7.6': + '@floating-ui/dom@1.8.0': dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 - '@floating-ui/react-dom@2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@floating-ui/react-dom@2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@floating-ui/dom': 1.7.6 + '@floating-ui/dom': 1.8.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@floating-ui/utils@0.2.11': {} + '@floating-ui/utils@0.2.12': {} '@humanfs/core@0.19.2': dependencies: @@ -2919,7 +2840,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': @@ -2929,656 +2850,690 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - '@nodelib/fs.scandir@2.1.5': + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@radix-ui/number@1.1.3': {} + + '@radix-ui/primitive@1.2.0': {} + + '@radix-ui/react-arrow@1.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - - '@radix-ui/number@1.1.1': {} - - '@radix-ui/primitive@1.1.3': {} - - '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-collection@1.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.29)(react@18.3.1)': + '@radix-ui/react-compose-refs@1.1.5(@types/react@18.3.31)(react@18.3.1)': dependencies: react: 18.3.1 optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - '@radix-ui/react-context@1.1.2(@types/react@18.3.29)(react@18.3.1)': + '@radix-ui/react-context@1.3.0(@types/react@18.3.31)(react@18.3.1)': dependencies: react: 18.3.1 optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dialog@1.3.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-direction@1.1.1(@types/react@18.3.29)(react@18.3.1)': + '@radix-ui/react-direction@1.2.0(@types/react@18.3.31)(react@18.3.1)': dependencies: react: 18.3.1 optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dismissable-layer@1.2.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@18.3.31)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dropdown-menu@2.2.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@18.3.31)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.29)(react@18.3.1)': + '@radix-ui/react-focus-guards@1.2.0(@types/react@18.3.31)(react@18.3.1)': dependencies: react: 18.3.1 optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-focus-scope@1.3.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@18.3.31)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-id@1.1.1(@types/react@18.3.29)(react@18.3.1)': + '@radix-ui/react-id@1.1.4(@types/react@18.3.31)(react@18.3.1)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) react: 18.3.1 optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-label@2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-menu@2.2.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@18.3.31)(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-popover@1.3.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-popper@1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/rect': 1.1.1 + '@floating-ui/react-dom': 2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-rect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/rect': 1.1.3 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-portal@1.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-presence@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-primitive@2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@18.3.31)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-radio-group@1.5.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@18.3.31)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-roving-focus@1.2.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-scroll-area@1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-slot@1.5.0(@types/react@18.3.31)(react@18.3.1)': dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-switch@1.4.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@18.3.31)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-slot@1.2.3(@types/react@18.3.29)(react@18.3.1)': + '@radix-ui/react-tabs@1.2.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.29 - - '@radix-ui/react-slot@1.2.4(@types/react@18.3.29)(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.29 - - '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@18.3.31)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-tooltip@1.4.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-use-callback-ref@1.1.5(@types/react@18.3.31)(react@18.3.1)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-controllable-state@1.3.0(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@18.3.31)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@18.3.31)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-rect@1.1.4(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-size@1.1.5(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-visually-hidden@1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.29)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.29 - - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.29)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.29 - - '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.29)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.29 - - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.29)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.29)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.29 - - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.29)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.29 - - '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.29)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.29 - - '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.29)(react@18.3.1)': - dependencies: - '@radix-ui/rect': 1.1.1 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.29 - - '@radix-ui/react-use-size@1.1.1(@types/react@18.3.29)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.29)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.29 - - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) - - '@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.29 - '@types/react-dom': 18.3.7(@types/react@18.3.29) - - '@radix-ui/rect@1.1.1': {} + '@radix-ui/rect@1.1.3': {} '@rolldown/pluginutils@1.0.0-beta.27': {} - '@rollup/rollup-android-arm-eabi@4.60.4': + '@rollup/rollup-android-arm-eabi@4.64.12': optional: true - '@rollup/rollup-android-arm64@4.60.4': + '@rollup/rollup-android-arm64@4.64.12': optional: true - '@rollup/rollup-darwin-arm64@4.60.4': + '@rollup/rollup-darwin-arm64@4.64.12': optional: true - '@rollup/rollup-darwin-x64@4.60.4': + '@rollup/rollup-darwin-x64@4.64.12': optional: true - '@rollup/rollup-freebsd-arm64@4.60.4': + '@rollup/rollup-freebsd-arm64@4.64.12': optional: true - '@rollup/rollup-freebsd-x64@4.60.4': + '@rollup/rollup-freebsd-x64@4.64.12': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + '@rollup/rollup-linux-arm-gnueabihf@4.64.12': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.60.4': + '@rollup/rollup-linux-arm-musleabihf@4.64.12': optional: true - '@rollup/rollup-linux-arm64-gnu@4.60.4': + '@rollup/rollup-linux-arm64-gnu@4.64.12': optional: true - '@rollup/rollup-linux-arm64-musl@4.60.4': + '@rollup/rollup-linux-arm64-musl@4.64.12': optional: true - '@rollup/rollup-linux-loong64-gnu@4.60.4': + '@rollup/rollup-linux-loong64-gnu@4.64.12': optional: true - '@rollup/rollup-linux-loong64-musl@4.60.4': + '@rollup/rollup-linux-loong64-musl@4.64.12': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.60.4': + '@rollup/rollup-linux-ppc64-gnu@4.64.12': optional: true - '@rollup/rollup-linux-ppc64-musl@4.60.4': + '@rollup/rollup-linux-ppc64-musl@4.64.12': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.60.4': + '@rollup/rollup-linux-riscv64-gnu@4.64.12': optional: true - '@rollup/rollup-linux-riscv64-musl@4.60.4': + '@rollup/rollup-linux-riscv64-musl@4.64.12': optional: true - '@rollup/rollup-linux-s390x-gnu@4.60.4': + '@rollup/rollup-linux-s390x-gnu@4.64.12': optional: true - '@rollup/rollup-linux-x64-gnu@4.60.4': + '@rollup/rollup-linux-x64-gnu@4.64.12': optional: true - '@rollup/rollup-linux-x64-musl@4.60.4': + '@rollup/rollup-linux-x64-musl@4.64.12': optional: true - '@rollup/rollup-openbsd-x64@4.60.4': + '@rollup/rollup-openbsd-x64@4.64.12': optional: true - '@rollup/rollup-openharmony-arm64@4.60.4': + '@rollup/rollup-openharmony-arm64@4.64.12': optional: true - '@rollup/rollup-win32-arm64-msvc@4.60.4': + '@rollup/rollup-win32-arm64-msvc@4.64.12': optional: true - '@rollup/rollup-win32-ia32-msvc@4.60.4': + '@rollup/rollup-win32-ia32-msvc@4.64.12': optional: true - '@rollup/rollup-win32-x64-gnu@4.60.4': + '@rollup/rollup-win32-x64-gnu@4.64.12': optional: true - '@rollup/rollup-win32-x64-msvc@4.60.4': + '@rollup/rollup-win32-x64-msvc@4.64.12': optional: true + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.26.0 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.2 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@6.4.4(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 6.4.4(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0) + '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 - '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} - '@types/node@25.9.1': + '@types/node@25.9.9': dependencies: undici-types: 7.24.6 '@types/prop-types@15.7.15': {} - '@types/react-dom@18.3.7(@types/react@18.3.29)': + '@types/react-dom@18.3.7(@types/react@18.3.31)': dependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - '@types/react@18.3.29': + '@types/react@18.3.31': dependencies: '@types/prop-types': 15.7.15 csstype: 3.2.3 - '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.72.0(@typescript-eslint/parser@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.61.1 - '@typescript-eslint/type-utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.61.1 - eslint: 9.39.4(jiti@1.21.7) - ignore: 7.0.5 + '@typescript-eslint/parser': 8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.72.0 + '@typescript-eslint/type-utils': 8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.72.0 + eslint: 9.39.5(jiti@2.7.0) + ignore: 7.0.13 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.61.1 - '@typescript-eslint/types': 8.61.1 - '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.61.1 + '@typescript-eslint/scope-manager': 8.72.0 + '@typescript-eslint/types': 8.72.0 + '@typescript-eslint/typescript-estree': 8.72.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.72.0 debug: 4.4.3 - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.5(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.61.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.72.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) - '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/tsconfig-utils': 8.72.0(typescript@5.9.3) + '@typescript-eslint/types': 8.72.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.61.1': + '@typescript-eslint/scope-manager@8.72.0': dependencies: - '@typescript-eslint/types': 8.61.1 - '@typescript-eslint/visitor-keys': 8.61.1 + '@typescript-eslint/types': 8.72.0 + '@typescript-eslint/visitor-keys': 8.72.0 - '@typescript-eslint/tsconfig-utils@8.61.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.72.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.61.1 - '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.72.0 + '@typescript-eslint/typescript-estree': 8.72.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.5(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.61.1': {} + '@typescript-eslint/types@8.72.0': {} - '@typescript-eslint/typescript-estree@8.61.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.72.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.61.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) - '@typescript-eslint/types': 8.61.1 - '@typescript-eslint/visitor-keys': 8.61.1 + '@typescript-eslint/project-service': 8.72.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.72.0(typescript@5.9.3) + '@typescript-eslint/types': 8.72.0 + '@typescript-eslint/visitor-keys': 8.72.0 debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.8.4 - tinyglobby: 0.2.16 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/utils@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.61.1 - '@typescript-eslint/types': 8.61.1 - '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) - eslint: 9.39.4(jiti@1.21.7) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.72.0 + '@typescript-eslint/types': 8.72.0 + '@typescript-eslint/typescript-estree': 8.72.0(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.61.1': + '@typescript-eslint/visitor-keys@8.72.0': dependencies: - '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/types': 8.72.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7))': + '@vitejs/plugin-react@4.7.0(vite@6.4.4(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -3586,17 +3541,17 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.2(@types/node@25.9.1)(jiti@1.21.7) + vite: 6.4.4(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0) transitivePeerDependencies: - supports-color - '@wailsio/runtime@3.0.0-alpha.79': {} + '@wailsio/runtime@3.0.0-beta.33': {} - acorn-jsx@5.3.2(acorn@8.17.0): + acorn-jsx@5.3.2(acorn@8.19.0): dependencies: - acorn: 8.17.0 + acorn: 8.19.0 - acorn@8.17.0: {} + acorn@8.19.0: {} ajv@6.15.0: dependencies: @@ -3609,15 +3564,6 @@ snapshots: dependencies: color-convert: 2.0.1 - any-promise@1.3.0: {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.2 - - arg@5.0.2: {} - argparse@2.0.1: {} aria-hidden@1.2.6: @@ -3631,14 +3577,14 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 - array-includes@3.1.9: + array-includes@3.2.0: dependencies: call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 es-abstract: 1.24.2 es-object-atoms: 1.1.2 - get-intrinsic: 1.3.0 + es-shim-unscopables: 1.1.0 is-string: 1.1.1 math-intrinsics: 1.1.0 @@ -3687,20 +3633,11 @@ snapshots: async-function@1.0.0: {} - autoprefixer@10.5.0(postcss@8.5.15): - dependencies: - browserslist: 4.28.2 - caniuse-lite: 1.0.30001793 - fraction.js: 5.3.4 - picocolors: 1.1.1 - postcss: 8.5.15 - postcss-value-parser: 4.2.0 - available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 - axe-core@4.12.1: {} + axe-core@4.14.0: {} axobject-query@4.1.0: {} @@ -3708,30 +3645,24 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.32: {} + baseline-browser-mapping@2.11.30: {} - binary-extensions@2.3.0: {} - - brace-expansion@1.1.15: + brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.6: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 - braces@3.0.3: + browserslist@4.29.4: dependencies: - fill-range: 7.1.1 - - browserslist@4.28.2: - dependencies: - baseline-browser-mapping: 2.10.32 - caniuse-lite: 1.0.30001793 - electron-to-chromium: 1.5.362 - node-releases: 2.0.46 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + baseline-browser-mapping: 2.11.30 + caniuse-lite: 1.0.30001817 + electron-to-chromium: 1.5.460 + node-releases: 2.0.59 + update-browserslist-db: 1.3.4(browserslist@4.29.4) call-bind-apply-helpers@1.0.2: dependencies: @@ -3752,39 +3683,25 @@ snapshots: callsites@3.1.0: {} - camelcase-css@2.0.1: {} - - caniuse-lite@1.0.30001793: {} + caniuse-lite@1.0.30001817: {} chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 clsx@2.1.1: {} - cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.29)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.29))(@types/react@18.3.29)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: @@ -3797,8 +3714,6 @@ snapshots: color-name@1.1.4: {} - commander@4.1.1: {} - concat-map@0.0.1: {} convert-source-map@2.0.0: {} @@ -3811,8 +3726,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - cssesc@3.0.0: {} - csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} @@ -3853,12 +3766,10 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + detect-libc@2.1.2: {} + detect-node-es@1.1.0: {} - didyoumean@1.2.2: {} - - dlv@1.1.3: {} - doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -3869,10 +3780,15 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.362: {} + electron-to-chromium@1.5.460: {} emoji-regex@9.2.2: {} + enhanced-resolve@5.26.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + es-abstract-get@1.0.0: dependencies: es-errors: 1.3.0 @@ -3894,7 +3810,7 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.2 es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.1 + es-to-primitive: 1.3.4 function.prototype.name: 1.2.0 get-intrinsic: 1.3.0 get-proto: 1.0.1 @@ -3904,7 +3820,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -3920,7 +3836,7 @@ snapshots: object-inspect: 1.13.4 object-keys: 1.1.1 object.assign: 4.1.7 - own-keys: 1.0.1 + own-keys: 1.0.2 regexp.prototype.flags: 1.5.4 safe-array-concat: 1.1.4 safe-push-apply: 1.0.0 @@ -3932,16 +3848,16 @@ snapshots: string.prototype.trimstart: 1.0.8 typed-array-buffer: 1.0.3 typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 + typed-array-byte-offset: 1.0.5 typed-array-length: 1.0.8 unbox-primitive: 1.1.0 - which-typed-array: 1.1.22 + which-typed-array: 1.1.24 es-define-property@1.0.1: {} es-errors@1.3.0: {} - es-iterator-helpers@1.3.3: + es-iterator-helpers@1.4.0: dependencies: call-bind: 1.0.9 call-bound: 1.0.4 @@ -3969,15 +3885,16 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.3 + hasown: 2.0.4 - es-to-primitive@1.3.1: + es-to-primitive@1.3.4: dependencies: es-abstract-get: 1.0.0 + es-define-property: 1.0.1 es-errors: 1.3.0 is-callable: 1.2.7 is-date-object: 1.1.0 @@ -4016,18 +3933,18 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@1.21.7)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)): dependencies: aria-query: 5.3.2 - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.flatmap: 1.3.3 ast-types-flow: 0.0.8 - axe-core: 4.12.1 + axe-core: 4.14.0 axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.4(jiti@1.21.7) - hasown: 2.0.3 + eslint: 9.39.5(jiti@2.7.0) + hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 minimatch: 3.1.5 @@ -4035,32 +3952,32 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@1.21.7)): + eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)): dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.7 - eslint: 9.39.4(jiti@1.21.7) + '@babel/parser': 7.29.9 + eslint: 9.39.5(jiti@2.7.0) hermes-parser: 0.25.1 - zod: 4.4.3 - zod-validation-error: 4.0.2(zod@4.4.3) + zod: 4.6.7 + zod-validation-error: 4.0.2(zod@4.6.7) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.5.3(eslint@9.39.4(jiti@1.21.7)): + eslint-plugin-react-refresh@0.5.7(eslint@9.39.5(jiti@2.7.0)): dependencies: - eslint: 9.39.4(jiti@1.21.7) + eslint: 9.39.5(jiti@2.7.0) - eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@1.21.7)): + eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)): dependencies: - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.findlast: 1.2.5 array.prototype.flatmap: 1.3.3 array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 - es-iterator-helpers: 1.3.3 - eslint: 9.39.4(jiti@1.21.7) + es-iterator-helpers: 1.4.0 + eslint: 9.39.5(jiti@2.7.0) estraverse: 5.3.0 - hasown: 2.0.3 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 minimatch: 3.1.5 object.entries: 1.1.9 @@ -4069,7 +3986,7 @@ snapshots: prop-types: 15.8.1 resolve: 2.0.0-next.7 semver: 6.3.1 - string.prototype.matchall: 4.0.12 + string.prototype.matchall: 4.1.0 string.prototype.repeat: 1.0.0 eslint-scope@8.4.0: @@ -4083,20 +4000,20 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4(jiti@1.21.7): + eslint@9.39.5(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 + '@eslint/eslintrc': 3.3.7 + '@eslint/js': 9.39.5 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 @@ -4120,14 +4037,14 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 1.21.7 + jiti: 2.7.0 transitivePeerDependencies: - supports-color espree@10.4.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) eslint-visitor-keys: 4.2.1 esquery@1.7.0: @@ -4144,34 +4061,18 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.7 file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -4179,20 +4080,18 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.2 + flatted: 3.4.4 keyv: 4.5.4 - flatted@3.4.2: {} + flatted@3.4.4: {} for-each@0.3.5: dependencies: is-callable: 1.2.7 - fraction.js@5.3.4: {} - - framer-motion@12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + framer-motion@12.43.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - motion-dom: 12.40.0 + motion-dom: 12.43.0 motion-utils: 12.39.0 tslib: 2.8.1 optionalDependencies: @@ -4232,7 +4131,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -4248,17 +4147,13 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - glob-parent@6.0.2: dependencies: is-glob: 4.0.3 globals@14.0.0: {} - globals@17.6.0: {} + globals@17.13.0: {} globalthis@1.0.4: dependencies: @@ -4267,6 +4162,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -4285,10 +4182,6 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.3: - dependencies: - function-bind: 1.1.2 - hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -4299,17 +4192,15 @@ snapshots: dependencies: hermes-estree: 0.25.1 - html-parse-stringify@3.0.1: - dependencies: - void-elements: 3.1.0 + html-parse-stringify@4.0.1: {} - i18next@26.3.0(typescript@5.9.3): + i18next@26.5.3(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 ignore@5.3.2: {} - ignore@7.0.5: {} + ignore@7.0.13: {} import-fresh@3.3.1: dependencies: @@ -4321,7 +4212,7 @@ snapshots: internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.3 + hasown: 2.0.4 side-channel: 1.1.1 is-array-buffer@3.0.5: @@ -4342,10 +4233,6 @@ snapshots: dependencies: has-bigints: 1.1.0 - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - is-boolean-object@1.2.2: dependencies: call-bound: 1.0.4 @@ -4353,9 +4240,9 @@ snapshots: is-callable@1.2.7: {} - is-core-module@2.16.2: + is-core-module@2.17.0: dependencies: - hasown: 2.0.3 + hasown: 2.0.4 is-data-view@1.0.2: dependencies: @@ -4399,14 +4286,12 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 - is-number@7.0.0: {} - is-regex@1.2.1: dependencies: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 is-set@2.0.3: {} @@ -4427,7 +4312,7 @@ snapshots: is-typed-array@1.1.15: dependencies: - which-typed-array: 1.1.22 + which-typed-array: 1.1.24 is-weakmap@2.0.2: {} @@ -4453,11 +4338,11 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 - jiti@1.21.7: {} + jiti@2.7.0: {} js-tokens@4.0.0: {} - js-yaml@4.2.0: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -4473,7 +4358,7 @@ snapshots: jsx-ast-utils@3.3.5: dependencies: - array-includes: 3.1.9 + array-includes: 3.2.0 array.prototype.flat: 1.3.3 object.assign: 4.1.7 object.values: 1.2.1 @@ -4493,9 +4378,54 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lilconfig@3.1.3: {} + lightningcss-android-arm64@1.32.0: + optional: true - lines-and-columns@1.2.4: {} + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 locate-path@6.0.0: dependencies: @@ -4515,24 +4445,21 @@ snapshots: dependencies: react: 18.3.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + math-intrinsics@1.1.0: {} - merge2@1.4.1: {} - - micromatch@4.0.8: + minimatch@10.2.6: dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.12 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.15 + brace-expansion: 1.1.21 - motion-dom@12.40.0: + motion-dom@12.43.0: dependencies: motion-utils: 12.39.0 @@ -4540,31 +4467,21 @@ snapshots: ms@2.1.3: {} - mz@2.7.0: - dependencies: - any-promise: 1.3.0 - object-assign: 4.1.1 - thenify-all: 1.6.0 - - nanoid@3.3.12: {} + nanoid@3.3.20: {} natural-compare@1.4.0: {} - node-exports-info@1.6.0: + node-exports-info@1.6.2: dependencies: array.prototype.flatmap: 1.3.3 es-errors: 1.3.0 object.entries: 1.1.9 semver: 6.3.1 - node-releases@2.0.46: {} - - normalize-path@3.0.0: {} + node-releases@2.0.59: {} object-assign@4.1.1: {} - object-hash@3.0.0: {} - object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -4608,8 +4525,9 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - own-keys@1.0.1: + own-keys@1.0.2: dependencies: + call-bound: 1.0.4 get-intrinsic: 1.3.0 object-keys: 1.1.1 safe-push-apply: 1.0.0 @@ -4634,60 +4552,23 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.2: {} - - picomatch@4.0.4: {} - - pify@2.3.0: {} - - pirates@4.0.7: {} + picomatch@4.0.7: {} possible-typed-array-names@1.1.0: {} - postcss-import@15.1.0(postcss@8.5.15): + postcss@8.5.31: dependencies: - postcss: 8.5.15 - postcss-value-parser: 4.2.0 - read-cache: 1.0.0 - resolve: 1.22.12 - - postcss-js@4.1.0(postcss@8.5.15): - dependencies: - camelcase-css: 2.0.1 - postcss: 8.5.15 - - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15): - dependencies: - lilconfig: 3.1.3 - optionalDependencies: - jiti: 1.21.7 - postcss: 8.5.15 - - postcss-nested@6.2.0(postcss@8.5.15): - dependencies: - postcss: 8.5.15 - postcss-selector-parser: 6.1.2 - - postcss-selector-parser@6.1.2: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss-value-parser@4.2.0: {} - - postcss@8.5.15: - dependencies: - nanoid: 3.3.12 + nanoid: 3.3.20 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 prelude-ls@1.2.1: {} - prettier-plugin-tailwindcss@0.8.0(prettier@3.8.3): + prettier-plugin-tailwindcss@0.8.1(prettier@3.9.10): dependencies: - prettier: 3.8.3 + prettier: 3.9.10 - prettier@3.8.3: {} + prettier@3.9.10: {} prop-types@15.8.1: dependencies: @@ -4697,21 +4578,19 @@ snapshots: punycode@2.3.1: {} - queue-microtask@1.2.3: {} - react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 react: 18.3.1 scheduler: 0.23.2 - react-i18next@17.0.8(i18next@26.3.0(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3): + react-i18next@17.1.0(i18next@26.5.3(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3): dependencies: - '@babel/runtime': 7.29.7 - html-parse-stringify: 3.0.1 - i18next: 26.3.0(typescript@5.9.3) + '@babel/runtime': 7.29.10 + html-parse-stringify: 4.0.1 + i18next: 26.5.3(typescript@5.9.3) react: 18.3.1 - use-sync-external-store: 1.6.0(react@18.3.1) + use-sync-external-store: 1.7.0(react@18.3.1) optionalDependencies: react-dom: 18.3.1(react@18.3.1) typescript: 5.9.3 @@ -4724,32 +4603,32 @@ snapshots: react-refresh@0.17.0: {} - react-remove-scroll-bar@2.3.8(@types/react@18.3.29)(react@18.3.1): + react-remove-scroll-bar@2.3.8(@types/react@18.3.31)(react@18.3.1): dependencies: react: 18.3.1 - react-style-singleton: 2.2.3(@types/react@18.3.29)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.31)(react@18.3.1) tslib: 2.8.1 optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - react-remove-scroll@2.7.2(@types/react@18.3.29)(react@18.3.1): + react-remove-scroll@2.7.2(@types/react@18.3.31)(react@18.3.1): dependencies: react: 18.3.1 - react-remove-scroll-bar: 2.3.8(@types/react@18.3.29)(react@18.3.1) - react-style-singleton: 2.2.3(@types/react@18.3.29)(react@18.3.1) + react-remove-scroll-bar: 2.3.8(@types/react@18.3.31)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.31)(react@18.3.1) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@18.3.29)(react@18.3.1) - use-sidecar: 1.1.3(@types/react@18.3.29)(react@18.3.1) + use-callback-ref: 1.3.3(@types/react@18.3.31)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@18.3.31)(react@18.3.1) optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - react-router-dom@7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-router-dom@7.18.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-router: 7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-router: 7.18.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-router@7.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-router@7.18.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: cookie: 1.1.1 react: 18.3.1 @@ -4757,15 +4636,15 @@ snapshots: optionalDependencies: react-dom: 18.3.1(react@18.3.1) - react-style-singleton@2.2.3(@types/react@18.3.29)(react@18.3.1): + react-style-singleton@2.2.3(@types/react@18.3.31)(react@18.3.1): dependencies: get-nonce: 1.0.1 react: 18.3.1 tslib: 2.8.1 optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - react-virtuoso@4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-virtuoso@4.18.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -4774,14 +4653,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - read-cache@1.0.0: - dependencies: - pify: 2.3.0 - - readdirp@3.6.0: - dependencies: - picomatch: 2.3.2 - reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -4804,59 +4675,47 @@ snapshots: resolve-from@4.0.0: {} - resolve@1.22.12: - dependencies: - es-errors: 1.3.0 - is-core-module: 2.16.2 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - resolve@2.0.0-next.7: dependencies: es-errors: 1.3.0 - is-core-module: 2.16.2 - node-exports-info: 1.6.0 + is-core-module: 2.17.0 + node-exports-info: 1.6.2 object-keys: 1.1.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - reusify@1.1.0: {} - - rollup@4.60.4: + rollup@4.64.12: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.4 - '@rollup/rollup-android-arm64': 4.60.4 - '@rollup/rollup-darwin-arm64': 4.60.4 - '@rollup/rollup-darwin-x64': 4.60.4 - '@rollup/rollup-freebsd-arm64': 4.60.4 - '@rollup/rollup-freebsd-x64': 4.60.4 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 - '@rollup/rollup-linux-arm-musleabihf': 4.60.4 - '@rollup/rollup-linux-arm64-gnu': 4.60.4 - '@rollup/rollup-linux-arm64-musl': 4.60.4 - '@rollup/rollup-linux-loong64-gnu': 4.60.4 - '@rollup/rollup-linux-loong64-musl': 4.60.4 - '@rollup/rollup-linux-ppc64-gnu': 4.60.4 - '@rollup/rollup-linux-ppc64-musl': 4.60.4 - '@rollup/rollup-linux-riscv64-gnu': 4.60.4 - '@rollup/rollup-linux-riscv64-musl': 4.60.4 - '@rollup/rollup-linux-s390x-gnu': 4.60.4 - '@rollup/rollup-linux-x64-gnu': 4.60.4 - '@rollup/rollup-linux-x64-musl': 4.60.4 - '@rollup/rollup-openbsd-x64': 4.60.4 - '@rollup/rollup-openharmony-arm64': 4.60.4 - '@rollup/rollup-win32-arm64-msvc': 4.60.4 - '@rollup/rollup-win32-ia32-msvc': 4.60.4 - '@rollup/rollup-win32-x64-gnu': 4.60.4 - '@rollup/rollup-win32-x64-msvc': 4.60.4 + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.64.12 + '@rollup/rollup-android-arm64': 4.64.12 + '@rollup/rollup-darwin-arm64': 4.64.12 + '@rollup/rollup-darwin-x64': 4.64.12 + '@rollup/rollup-freebsd-arm64': 4.64.12 + '@rollup/rollup-freebsd-x64': 4.64.12 + '@rollup/rollup-linux-arm-gnueabihf': 4.64.12 + '@rollup/rollup-linux-arm-musleabihf': 4.64.12 + '@rollup/rollup-linux-arm64-gnu': 4.64.12 + '@rollup/rollup-linux-arm64-musl': 4.64.12 + '@rollup/rollup-linux-loong64-gnu': 4.64.12 + '@rollup/rollup-linux-loong64-musl': 4.64.12 + '@rollup/rollup-linux-ppc64-gnu': 4.64.12 + '@rollup/rollup-linux-ppc64-musl': 4.64.12 + '@rollup/rollup-linux-riscv64-gnu': 4.64.12 + '@rollup/rollup-linux-riscv64-musl': 4.64.12 + '@rollup/rollup-linux-s390x-gnu': 4.64.12 + '@rollup/rollup-linux-x64-gnu': 4.64.12 + '@rollup/rollup-linux-x64-musl': 4.64.12 + '@rollup/rollup-openbsd-x64': 4.64.12 + '@rollup/rollup-openharmony-arm64': 4.64.12 + '@rollup/rollup-win32-arm64-msvc': 4.64.12 + '@rollup/rollup-win32-ia32-msvc': 4.64.12 + '@rollup/rollup-win32-x64-gnu': 4.64.12 + '@rollup/rollup-win32-x64-msvc': 4.64.12 fsevents: 2.3.3 - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - safe-array-concat@1.1.4: dependencies: call-bind: 1.0.9 @@ -4882,7 +4741,7 @@ snapshots: semver@6.3.1: {} - semver@7.8.4: {} + semver@7.8.5: {} set-cookie-parser@2.7.2: {} @@ -4942,7 +4801,7 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} stop-iteration-iterator@1.1.0: dependencies: @@ -4955,7 +4814,7 @@ snapshots: define-properties: 1.2.1 es-abstract: 1.24.2 - string.prototype.matchall@4.0.12: + string.prototype.matchall@4.1.0: dependencies: call-bind: 1.0.9 call-bound: 1.0.4 @@ -5002,16 +4861,6 @@ snapshots: strip-json-comments@3.1.1: {} - sucrase@3.35.1: - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - commander: 4.1.1 - lines-and-columns: 1.2.4 - mz: 2.7.0 - pirates: 4.0.7 - tinyglobby: 0.2.16 - ts-interface-checker: 0.1.13 - supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -5020,63 +4869,23 @@ snapshots: tailwind-merge@2.6.1: {} - tailwindcss-animate@1.0.7(tailwindcss@3.4.19): - dependencies: - tailwindcss: 3.4.19 + tailwindcss@4.3.3: {} - tailwindcss@3.4.19: - dependencies: - '@alloc/quick-lru': 5.2.0 - arg: 5.0.2 - chokidar: 3.6.0 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.3.3 - glob-parent: 6.0.2 - is-glob: 4.0.3 - jiti: 1.21.7 - lilconfig: 3.1.3 - micromatch: 4.0.8 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.1.1 - postcss: 8.5.15 - postcss-import: 15.1.0(postcss@8.5.15) - postcss-js: 4.1.0(postcss@8.5.15) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15) - postcss-nested: 6.2.0(postcss@8.5.15) - postcss-selector-parser: 6.1.2 - resolve: 1.22.12 - sucrase: 3.35.1 - transitivePeerDependencies: - - tsx - - yaml + tapable@2.3.3: {} - thenify-all@1.6.0: + tinyglobby@0.2.17: dependencies: - thenify: 3.3.1 - - thenify@3.3.1: - dependencies: - any-promise: 1.3.0 - - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 - ts-interface-checker@0.1.13: {} - tslib@2.8.1: {} + tw-animate-css@1.4.0: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -5095,13 +4904,12 @@ snapshots: has-proto: 1.2.0 is-typed-array: 1.1.15 - typed-array-byte-offset@1.0.4: + typed-array-byte-offset@1.0.5: dependencies: available-typed-arrays: 1.0.7 call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 - has-proto: 1.2.0 is-typed-array: 1.1.15 reflect.getprototypeof: 1.0.10 @@ -5114,13 +4922,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3): + typescript-eslint@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.4(jiti@1.21.7) + '@typescript-eslint/eslint-plugin': 8.72.0(@typescript-eslint/parser@8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.72.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.72.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + eslint: 9.39.5(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5136,9 +4944,9 @@ snapshots: undici-types@7.24.6: {} - update-browserslist-db@1.2.3(browserslist@4.28.2): + update-browserslist-db@1.3.4(browserslist@4.29.4): dependencies: - browserslist: 4.28.2 + browserslist: 4.29.4 escalade: 3.2.0 picocolors: 1.1.1 @@ -5146,41 +4954,38 @@ snapshots: dependencies: punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@18.3.29)(react@18.3.1): + use-callback-ref@1.3.3(@types/react@18.3.31)(react@18.3.1): dependencies: react: 18.3.1 tslib: 2.8.1 optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - use-sidecar@1.1.3(@types/react@18.3.29)(react@18.3.1): + use-sidecar@1.1.3(@types/react@18.3.31)(react@18.3.1): dependencies: detect-node-es: 1.1.0 react: 18.3.1 tslib: 2.8.1 optionalDependencies: - '@types/react': 18.3.29 + '@types/react': 18.3.31 - use-sync-external-store@1.6.0(react@18.3.1): + use-sync-external-store@1.7.0(react@18.3.1): dependencies: react: 18.3.1 - util-deprecate@1.0.2: {} - - vite@6.4.2(@types/node@25.9.1)(jiti@1.21.7): + vite@6.4.4(@types/node@25.9.9)(jiti@2.7.0)(lightningcss@1.32.0): dependencies: esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.15 - rollup: 4.60.4 - tinyglobby: 0.2.16 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.31 + rollup: 4.64.12 + tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.9 fsevents: 2.3.3 - jiti: 1.21.7 - - void-elements@3.1.0: {} + jiti: 2.7.0 + lightningcss: 1.32.0 which-boxed-primitive@1.1.1: dependencies: @@ -5204,7 +5009,7 @@ snapshots: isarray: 2.0.5 which-boxed-primitive: 1.1.1 which-collection: 1.0.2 - which-typed-array: 1.1.22 + which-typed-array: 1.1.24 which-collection@1.0.2: dependencies: @@ -5213,7 +5018,7 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 - which-typed-array@1.1.22: + which-typed-array@1.1.24: dependencies: available-typed-arrays: 1.0.7 call-bind: 1.0.9 @@ -5233,8 +5038,8 @@ snapshots: yocto-queue@0.1.0: {} - zod-validation-error@4.0.2(zod@4.4.3): + zod-validation-error@4.0.2(zod@4.6.7): dependencies: - zod: 4.4.3 + zod: 4.6.7 - zod@4.4.3: {} + zod@4.6.7: {} diff --git a/client/ui/frontend/postcss.config.js b/client/ui/frontend/postcss.config.js deleted file mode 100644 index 2aa7205d4..000000000 --- a/client/ui/frontend/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/client/ui/frontend/src/components/Badge.tsx b/client/ui/frontend/src/components/Badge.tsx index a6f2e886a..05ad9dbfa 100644 --- a/client/ui/frontend/src/components/Badge.tsx +++ b/client/ui/frontend/src/components/Badge.tsx @@ -30,7 +30,7 @@ export const Badge = forwardRef(function Badge( ref={ref} className={cn( "relative top-px inline-flex items-center gap-1 rounded-full px-1.5 py-[0.15rem]", - "shrink-0 text-[0.64rem] font-semibold leading-none", + "shrink-0 text-[0.64rem] leading-none font-semibold", VARIANT_CLASSES[variant], className, )} diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx index b3d5a8715..883890653 100644 --- a/client/ui/frontend/src/components/CopyToClipboard.tsx +++ b/client/ui/frontend/src/components/CopyToClipboard.tsx @@ -4,8 +4,8 @@ import { Check, Copy } from "lucide-react"; import { cn } from "@/lib/cn"; const VARIANT_HOVER = { - default: "group-hover/copy:[&_*]:text-nb-gray-300", - bright: "group-hover/copy:[&_*]:text-nb-gray-200", + default: "**:group-hover/copy:text-nb-gray-300", + bright: "**:group-hover/copy:text-nb-gray-200", } as const; type CopyToClipboardVariant = keyof typeof VARIANT_HOVER; @@ -80,7 +80,7 @@ export const CopyToClipboard = ({ aria-label={resolvedLabel} aria-live={"polite"} className={cn( - "group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-start outline-none", + "group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-xs text-start outline-hidden", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", className, )} @@ -88,8 +88,8 @@ export const CopyToClipboard = ({ @@ -104,7 +104,7 @@ export const CopyToClipboard = ({ - + @@ -163,10 +163,10 @@ const DropdownMenuRadioItem = React.forwardRef<
@@ -164,7 +164,7 @@ export function LanguagePicker() { aria-label={t("settings.general.language.search")} className={cn( "w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-600 dark:placeholder:text-nb-gray-300", - "border-none outline-none", + "border-none outline-hidden", )} />
@@ -195,7 +195,7 @@ export function LanguagePicker() { value={`${lang.displayName} ${lang.englishName} ${lang.code}`} onSelect={() => void select(lang.code)} className={cn( - "my-0.5 flex cursor-default items-center gap-2 rounded-md px-2 py-2 outline-none", + "my-0.5 flex cursor-default items-center gap-2 rounded-md px-2 py-2 outline-hidden", "text-xs font-semibold text-nb-gray-200", "data-[selected=true]:bg-nb-gray-850 data-[selected=true]:text-nb-gray-50", )} @@ -224,7 +224,7 @@ export function LanguagePicker() { diff --git a/client/ui/frontend/src/components/ManagementServerSwitch.tsx b/client/ui/frontend/src/components/ManagementServerSwitch.tsx index 0083a767a..7d0cfb8a1 100644 --- a/client/ui/frontend/src/components/ManagementServerSwitch.tsx +++ b/client/ui/frontend/src/components/ManagementServerSwitch.tsx @@ -26,7 +26,7 @@ export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: P src={netbirdLogo} alt={""} aria-hidden={"true"} - className={"aspect-[31/23] h-[0.8rem] shrink-0"} + className={"aspect-31/23 h-[0.8rem] shrink-0"} /> {t("settings.general.management.cloud")} diff --git a/client/ui/frontend/src/components/Tooltip.tsx b/client/ui/frontend/src/components/Tooltip.tsx index 86589e47f..74f99752c 100644 --- a/client/ui/frontend/src/components/Tooltip.tsx +++ b/client/ui/frontend/src/components/Tooltip.tsx @@ -84,8 +84,8 @@ export const Tooltip = ({ onPointerLeave={interactive ? scheduleClose : undefined} onPointerDownOutside={interactive ? undefined : (e) => e.preventDefault()} className={cn( - "z-50 select-none text-xs text-nb-gray-100 shadow-sm dark:shadow-lg", - "data-[state=delayed-open]:animate-in data-[state=closed]:animate-out", + "z-50 text-xs text-nb-gray-100 shadow-xs select-none dark:shadow-lg", + "data-[state=closed]:animate-out data-[state=delayed-open]:animate-in", "data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0", !interactive && "pointer-events-none", contentClassName ?? diff --git a/client/ui/frontend/src/components/VerticalTabs.tsx b/client/ui/frontend/src/components/VerticalTabs.tsx index bd1912662..ee215549d 100644 --- a/client/ui/frontend/src/components/VerticalTabs.tsx +++ b/client/ui/frontend/src/components/VerticalTabs.tsx @@ -49,7 +49,7 @@ const Trigger = forwardRef(function VerticalTab (function VerticalTab className={cn( "ms-2 shrink-0 transition-colors duration-150", "text-nb-gray-350 dark:text-nb-gray-400", - "group-data-[state=active]:text-nb-gray-100", + "group-data-[state=active]:text-nb-gray-100 dark:group-data-[state=active]:text-nb-gray-100", )} /> {title} {adornment && ( -
+
{adornment}
)} @@ -94,7 +94,7 @@ const Content = forwardRef(function Verti ); diff --git a/client/ui/frontend/src/components/buttons/Button.tsx b/client/ui/frontend/src/components/buttons/Button.tsx index 50e8c919d..39fab683a 100644 --- a/client/ui/frontend/src/components/buttons/Button.tsx +++ b/client/ui/frontend/src/components/buttons/Button.tsx @@ -16,9 +16,9 @@ interface ButtonProps extends ButtonHTMLAttributes, ButtonVar const buttonVariants = cva( [ "relative", - "cursor-default select-none whitespace-nowrap text-sm font-medium shadow-sm focus:z-10 focus:outline-none focus:ring-2", + "cursor-default text-sm font-medium whitespace-nowrap shadow-xs select-none focus:z-10 focus:ring-2 focus:outline-hidden", "inline-flex items-center justify-center gap-2 transition-colors focus:ring-offset-1", - "disabled:cursor-not-allowed disabled:opacity-40 dark:ring-offset-neutral-950/50 disabled:dark:text-nb-gray-300", + "disabled:cursor-not-allowed disabled:opacity-40 dark:ring-offset-neutral-950/50 dark:disabled:text-nb-gray-300", ], { variants: { @@ -28,7 +28,7 @@ const buttonVariants = cva( "dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-nb-gray-50 dark:focus:ring-zinc-800/50", ], primary: [ - "dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-nb-gray-50 disabled:dark:bg-nb-gray-900", + "dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 dark:enabled:bg-netbird dark:enabled:hover:bg-netbird-500/80 dark:enabled:hover:text-nb-gray-50 dark:disabled:bg-nb-gray-900", "enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50 disabled:bg-nb-gray-700", ], secondary: [ @@ -63,11 +63,11 @@ const buttonVariants = cva( ], tertiary: [ "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", - "dark:border-gray-700/40 dark:bg-white dark:text-gray-800 dark:hover:bg-neutral-200 dark:focus:ring-zinc-800/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", + "dark:border-gray-700/40 dark:bg-white dark:text-gray-800 dark:hover:bg-neutral-200 dark:focus:ring-zinc-800/50 dark:disabled:bg-nb-gray-920 dark:disabled:text-nb-gray-300", ], white: [ - "border-white bg-white text-neutral-800 outline-none hover:bg-neutral-200 focus:ring-white/50 dark:text-gray-800 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", - "disabled:dark:border-nb-gray-900 disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300", + "border-white bg-white text-neutral-800 outline-hidden hover:bg-neutral-200 focus:ring-white/50 dark:text-gray-800 dark:disabled:bg-nb-gray-920 dark:disabled:text-nb-gray-300", + "dark:disabled:border-nb-gray-900 dark:disabled:bg-nb-gray-900 dark:disabled:text-nb-gray-300", ], outline: [ "border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50", @@ -75,10 +75,10 @@ const buttonVariants = cva( ], "danger-outline": [ "bg-transparent text-red-600 enabled:hover:bg-red-50 enabled:focus:ring-red-200/50", - "dark:bg-transparent dark:text-red-500 enabled:dark:hover:border-red-800/50 enabled:hover:dark:bg-red-950/50 enabled:dark:focus:bg-red-950/40 enabled:dark:focus:ring-red-800/20", + "dark:bg-transparent dark:text-red-500 dark:enabled:hover:border-red-800/50 dark:enabled:hover:bg-red-950/50 dark:enabled:focus:bg-red-950/40 dark:enabled:focus:ring-red-800/20", ], "danger-text": [ - "rounded-sm border-transparent bg-transparent !px-0 !py-0 text-red-600 !shadow-none hover:text-red-700 focus:ring-red-500/30", + "rounded-xs border-transparent bg-transparent px-0! py-0! text-red-600 shadow-none! hover:text-red-700 focus:ring-red-500/30", "dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600", ], "default-outline": [ @@ -92,12 +92,12 @@ const buttonVariants = cva( ], danger: [ "bg-red-600 text-red-50 hover:bg-red-700 focus:bg-red-700 focus:ring-red-700/20", - "dark:bg-red-600 dark:text-red-100 dark:hover:border-red-800/50 hover:dark:bg-red-700 dark:focus:bg-red-700 dark:focus:ring-red-700/20", + "dark:bg-red-600 dark:text-red-100 dark:hover:border-red-800/50 dark:hover:bg-red-700 dark:focus:bg-red-700 dark:focus:ring-red-700/20", ], }, size: { xs: "px-3.5 py-2.5 text-xs", - xs2: "px-4 py-[1.1rem] text-[0.78rem] leading-[0]", + xs2: "px-4 py-[1.1rem] text-[0.78rem] leading-0", sm: "px-4 py-[9px] text-sm", md: "px-4 py-[9px]", lg: "px-4 py-[9px] text-lg", @@ -109,7 +109,7 @@ const buttonVariants = cva( border: { 0: "border", 1: "border border-transparent", - 2: "border border-b-0 border-t-0", + 2: "border border-t-0 border-b-0", }, }, }, diff --git a/client/ui/frontend/src/components/buttons/IconButton.tsx b/client/ui/frontend/src/components/buttons/IconButton.tsx index 8d688cb0b..98c34250c 100644 --- a/client/ui/frontend/src/components/buttons/IconButton.tsx +++ b/client/ui/frontend/src/components/buttons/IconButton.tsx @@ -21,7 +21,7 @@ export const IconButton = forwardRef(function IconButt disabled={disabled} tabIndex={disabled ? -1 : 0} className={cn( - "flex h-10 w-10 cursor-default items-center justify-center rounded-lg outline-none", + "flex h-10 w-10 cursor-default items-center justify-center rounded-lg outline-hidden", "text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-300", isFocusVisible && "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", diff --git a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx index caf98c2c8..388284537 100644 --- a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx +++ b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx @@ -18,13 +18,13 @@ export const ConfirmDialog = forwardRef(func aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} className={ - "wails-draggable static m-0 flex max-h-none w-full max-w-none select-none flex-col items-center border-0 bg-transparent p-0 text-inherit" + "wails-draggable static m-0 flex max-h-none w-full max-w-none flex-col items-center border-0 bg-transparent p-0 text-inherit select-none" } >
diff --git a/client/ui/frontend/src/components/dialog/Dialog.tsx b/client/ui/frontend/src/components/dialog/Dialog.tsx index 3d0d47fc2..43584607f 100644 --- a/client/ui/frontend/src/components/dialog/Dialog.tsx +++ b/client/ui/frontend/src/components/dialog/Dialog.tsx @@ -65,8 +65,8 @@ export const Content = forwardRef, Co , Co {showClose && ( @@ -119,7 +119,7 @@ export const Title = forwardRef< *]:w-full sm:[&>*]:w-auto", + "*:w-full sm:*:w-auto", "px-8 pt-6", className, )} diff --git a/client/ui/frontend/src/components/dialog/DialogDescription.tsx b/client/ui/frontend/src/components/dialog/DialogDescription.tsx index 3d6ecbba7..95a235fbb 100644 --- a/client/ui/frontend/src/components/dialog/DialogDescription.tsx +++ b/client/ui/frontend/src/components/dialog/DialogDescription.tsx @@ -20,7 +20,7 @@ export const DialogDescription = ({ className, align = "center", }: DialogDescriptionProps) => ( -

+

{children}

); diff --git a/client/ui/frontend/src/components/dialog/DialogHeading.tsx b/client/ui/frontend/src/components/dialog/DialogHeading.tsx index 52eb0c369..1cec1f8c7 100644 --- a/client/ui/frontend/src/components/dialog/DialogHeading.tsx +++ b/client/ui/frontend/src/components/dialog/DialogHeading.tsx @@ -25,7 +25,7 @@ export const DialogHeading = ({

{ return (
diff --git a/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx index 89c121e21..4b1363c16 100644 --- a/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx +++ b/client/ui/frontend/src/components/empty-state/DaemonUnavailableOverlay.tsx @@ -19,7 +19,7 @@ export const DaemonUnavailableOverlay = () => { return (
diff --git a/client/ui/frontend/src/components/empty-state/NoResults.tsx b/client/ui/frontend/src/components/empty-state/NoResults.tsx index cf0995b37..8b3ce2063 100644 --- a/client/ui/frontend/src/components/empty-state/NoResults.tsx +++ b/client/ui/frontend/src/components/empty-state/NoResults.tsx @@ -16,7 +16,7 @@ export const NoResults = ({ icon = FunnelXIcon, title, description }: Props) => icon={icon} title={title ?? t("common.noResults.title")} description={description ?? t("common.noResults.description")} - className={"pointer-events-none relative -top-[3.8rem]"} + className={"pointer-events-none relative top-[-3.8rem]"} /> ); }; diff --git a/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx b/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx index 2bcf70376..fd5330835 100644 --- a/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx +++ b/client/ui/frontend/src/components/empty-state/NotConnectedState.tsx @@ -5,7 +5,7 @@ import { EmptyState } from "./EmptyState"; export const NotConnectedState = () => { const { t } = useTranslation(); return ( -
+
@@ -138,7 +138,7 @@ function InputSuffixSlot({ return (
@@ -349,7 +349,7 @@ export const Input = forwardRef(function Input( {icon && } -
+
(function SearchIn {...props} className={cn( "w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-600 dark:placeholder:text-nb-gray-400", - "border-none outline-none", + "border-none outline-hidden", disabled && "cursor-not-allowed", className, )} @@ -45,7 +45,7 @@ export const SearchInput = forwardRef(function SearchIn className={cn( "shrink-0 select-none", "inline-flex items-center justify-center", - "h-5 min-w-[20px] rounded px-1.5", + "h-5 min-w-[20px] rounded-sm px-1.5", "border border-nb-gray-850 bg-nb-gray-920", "text-[10px] font-medium text-nb-gray-400", "wails-no-draggable", diff --git a/client/ui/frontend/src/components/inputs/Select.tsx b/client/ui/frontend/src/components/inputs/Select.tsx index d9c7512d2..0938df091 100644 --- a/client/ui/frontend/src/components/inputs/Select.tsx +++ b/client/ui/frontend/src/components/inputs/Select.tsx @@ -49,7 +49,7 @@ export function Select({ "inline-flex h-[40px] min-w-[160px] items-center gap-2 px-3", "rounded-md border bg-white dark:bg-nb-gray-900", "border-neutral-200 dark:border-nb-gray-700", - "cursor-default text-xs font-semibold text-nb-gray-100 outline-none", + "cursor-default text-xs font-semibold text-nb-gray-100 outline-hidden", "hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600", isFocusVisible && "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", @@ -76,7 +76,7 @@ export function Select({ align={"start"} sideOffset={6} className={ - "w-[var(--radix-dropdown-menu-trigger-width)] border-nb-gray-850 bg-nb-gray-920" + "w-(--radix-dropdown-menu-trigger-width) border-nb-gray-850 bg-nb-gray-920" } > onChange(v as T)}> diff --git a/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx index b6331ee2d..292e35e14 100644 --- a/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx +++ b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx @@ -36,7 +36,7 @@ export default function FancyToggleSwitch({ if (loading) { const shimmer = - "text-transparent select-none rounded bg-nb-gray-920 box-decoration-clone animate-pulse"; + "text-transparent select-none rounded-sm bg-nb-gray-920 box-decoration-clone animate-pulse"; return (
{ value={value} className={cn( "relative inline-flex items-center justify-center gap-1 rounded-md px-3.5 py-2 text-xs font-semibold", - "cursor-default outline-none", + "cursor-default outline-hidden", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", active ? "text-nb-gray-100" @@ -31,7 +31,7 @@ export const SwitchItem = ({ value, children, className }: Props) => { diff --git a/client/ui/frontend/src/components/switches/ToggleSwitch.tsx b/client/ui/frontend/src/components/switches/ToggleSwitch.tsx index 6862f85d8..7ef097632 100644 --- a/client/ui/frontend/src/components/switches/ToggleSwitch.tsx +++ b/client/ui/frontend/src/components/switches/ToggleSwitch.tsx @@ -36,9 +36,9 @@ const switchVariants = cva("", { }, "thumb-size": { default: - "h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 rtl:data-[state=checked]:-translate-x-5", - small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0 rtl:data-[state=checked]:-translate-x-[17px]", - large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-[1px] rtl:data-[state=checked]:-translate-x-[31px] rtl:data-[state=unchecked]:-translate-x-[1px]", + "h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 data-[state=checked]:rtl:-translate-x-5", + small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0 data-[state=checked]:rtl:translate-x-[-17px]", + large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-px data-[state=checked]:rtl:translate-x-[-31px] data-[state=unchecked]:rtl:-translate-x-px", }, }, }); @@ -52,7 +52,7 @@ const ToggleSwitch = React.forwardRef< disabled={disabled} tabIndex={disabled ? -1 : 0} className={cn( - "wails-no-draggable peer inline-flex shrink-0 cursor-default items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940 disabled:cursor-not-allowed disabled:opacity-50", + "wails-no-draggable peer inline-flex shrink-0 cursor-default items-center rounded-full border-2 border-transparent transition-colors focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50", className, switchVariants({ size, variant }), )} diff --git a/client/ui/frontend/src/components/typography/Label.tsx b/client/ui/frontend/src/components/typography/Label.tsx index a8e1a446f..fc83dfd0c 100644 --- a/client/ui/frontend/src/components/typography/Label.tsx +++ b/client/ui/frontend/src/components/typography/Label.tsx @@ -4,7 +4,7 @@ import { type ComponentPropsWithoutRef, forwardRef, type Ref } from "react"; import { cn } from "@/lib/cn"; const labelVariants = cva( - "mb-1.5 inline-block flex items-center gap-2 text-sm font-medium leading-none tracking-wider peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-nb-gray-100", + "mb-1.5 flex items-center gap-2 text-sm leading-none font-medium tracking-wider peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-nb-gray-100", ); type LabelProps = ComponentPropsWithoutRef & @@ -20,7 +20,7 @@ export const Label = forwardRef(function Label( const classes = cn( labelVariants(), className, - "select-none transition-all duration-300", + "transition-all duration-300 select-none", disabled && "pointer-events-none opacity-30", ); diff --git a/client/ui/frontend/src/globals.css b/client/ui/frontend/src/globals.css index bfdabeb31..ac9813c12 100644 --- a/client/ui/frontend/src/globals.css +++ b/client/ui/frontend/src/globals.css @@ -1,22 +1,280 @@ -@font-face { - font-family: "Inter Variable"; - font-style: normal; - font-weight: 100 900; - src: url("./assets/fonts/inter-variable.ttf") format("truetype"); +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --font-*: initial; + --font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif; + --font-mono: "JetBrains Mono Variable", ui-monospace, monospace; + + --color-nb-gray-50: rgb(var(--nb-gray-50)); + --color-nb-gray-100: rgb(var(--nb-gray-100)); + --color-nb-gray-200: rgb(var(--nb-gray-200)); + --color-nb-gray-250: rgb(var(--nb-gray-250)); + --color-nb-gray-300: rgb(var(--nb-gray-300)); + --color-nb-gray-350: rgb(var(--nb-gray-350)); + --color-nb-gray-400: rgb(var(--nb-gray-400)); + --color-nb-gray-500: rgb(var(--nb-gray-500)); + --color-nb-gray-600: rgb(var(--nb-gray-600)); + --color-nb-gray-700: rgb(var(--nb-gray-700)); + --color-nb-gray-800: rgb(var(--nb-gray-800)); + --color-nb-gray-850: rgb(var(--nb-gray-850)); + --color-nb-gray-900: rgb(var(--nb-gray-900)); + --color-nb-gray-910: rgb(var(--nb-gray-910)); + --color-nb-gray-920: rgb(var(--nb-gray-920)); + --color-nb-gray-925: rgb(var(--nb-gray-925)); + --color-nb-gray-930: rgb(var(--nb-gray-930)); + --color-nb-gray-935: rgb(var(--nb-gray-935)); + --color-nb-gray-940: rgb(var(--nb-gray-940)); + --color-nb-gray-950: rgb(var(--nb-gray-950)); + --color-nb-gray-960: rgb(var(--nb-gray-960)); + --color-nb-gray: rgb(var(--nb-gray-DEFAULT)); + + --color-gray-50: #f9fafb; + --color-gray-100: #f3f4f6; + --color-gray-200: #e5e7eb; + --color-gray-300: #d1d5db; + --color-gray-400: #9ca3af; + --color-gray-500: #6b7280; + --color-gray-600: #4b5563; + --color-gray-700: #374151; + --color-gray-800: #1f2937; + --color-gray-900: #111827; + + --color-red-50: #fdf2f2; + --color-red-100: #fde8e8; + --color-red-200: #fbd5d5; + --color-red-300: #f8b4b4; + --color-red-400: #f98080; + --color-red-500: #f05252; + --color-red-600: #e02424; + --color-red-700: #c81e1e; + --color-red-800: #9b1c1c; + --color-red-900: #771d1d; + + --color-yellow-50: #fdfdea; + --color-yellow-100: #fdf6b2; + --color-yellow-200: #fce96a; + --color-yellow-300: #faca15; + --color-yellow-400: #e3a008; + --color-yellow-500: #c27803; + --color-yellow-600: #9f580a; + --color-yellow-700: #8e4b10; + --color-yellow-800: #723b13; + --color-yellow-900: #633112; + + --color-green-50: #f3faf7; + --color-green-100: #def7ec; + --color-green-200: #bcf0da; + --color-green-300: #84e1bc; + --color-green-400: #31c48d; + --color-green-500: #0e9f6e; + --color-green-600: #057a55; + --color-green-700: #046c4e; + --color-green-800: #03543f; + --color-green-900: #014737; + + --color-blue-50: #ebf5ff; + --color-blue-100: #e1effe; + --color-blue-200: #c3ddfd; + --color-blue-300: #a4cafe; + --color-blue-400: #76a9fa; + --color-blue-500: #3f83f8; + --color-blue-600: #1c64f2; + --color-blue-700: #1a56db; + --color-blue-800: #1e429f; + --color-blue-900: #233876; + + --color-indigo-50: #f0f5ff; + --color-indigo-100: #e5edff; + --color-indigo-200: #cddbfe; + --color-indigo-300: #b4c6fc; + --color-indigo-400: #8da2fb; + --color-indigo-500: #6875f5; + --color-indigo-600: #5850ec; + --color-indigo-700: #5145cd; + --color-indigo-800: #42389d; + --color-indigo-900: #362f78; + + --color-purple-50: #f6f5ff; + --color-purple-100: #edebfe; + --color-purple-200: #dcd7fe; + --color-purple-300: #cabffd; + --color-purple-400: #ac94fa; + --color-purple-500: #9061f9; + --color-purple-600: #7e3af2; + --color-purple-700: #6c2bd9; + --color-purple-800: #5521b5; + --color-purple-900: #4a1d96; + + --color-pink-50: #fdf2f8; + --color-pink-100: #fce8f3; + --color-pink-200: #fad1e8; + --color-pink-300: #f8b4d9; + --color-pink-400: #f17eb8; + --color-pink-500: #e74694; + --color-pink-600: #d61f69; + --color-pink-700: #bf125d; + --color-pink-800: #99154b; + --color-pink-900: #751a3d; + + --color-netbird-50: #fff6ed; + --color-netbird-100: #feecd6; + --color-netbird-150: #ffdfb8; + --color-netbird-200: #ffd4a6; + --color-netbird-300: #fab677; + --color-netbird-400: #f68330; + --color-netbird-500: #f46d1b; + --color-netbird-600: #e55311; + --color-netbird-700: #be3e10; + --color-netbird-800: #973215; + --color-netbird-900: #7a2b14; + --color-netbird-950: #421308; + --color-netbird: #f68330; + + /* Tailwind 4 rebuilt its default palette in OKLCH, which shifts hues such as + sky-400 and orange-400 noticeably. These are the Tailwind 3 values for the + families the UI still uses from the default palette, so colors stay as they + were before the upgrade. */ + --color-neutral-50: #fafafa; + --color-neutral-100: #f5f5f5; + --color-neutral-200: #e5e5e5; + --color-neutral-300: #d4d4d4; + --color-neutral-400: #a3a3a3; + --color-neutral-500: #737373; + --color-neutral-600: #525252; + --color-neutral-700: #404040; + --color-neutral-800: #262626; + --color-neutral-900: #171717; + --color-neutral-950: #0a0a0a; + + --color-zinc-50: #fafafa; + --color-zinc-100: #f4f4f5; + --color-zinc-200: #e4e4e7; + --color-zinc-300: #d4d4d8; + --color-zinc-400: #a1a1aa; + --color-zinc-500: #71717a; + --color-zinc-600: #52525b; + --color-zinc-700: #3f3f46; + --color-zinc-800: #27272a; + --color-zinc-900: #18181b; + --color-zinc-950: #09090b; + + --color-sky-50: #f0f9ff; + --color-sky-100: #e0f2fe; + --color-sky-200: #bae6fd; + --color-sky-300: #7dd3fc; + --color-sky-400: #38bdf8; + --color-sky-500: #0ea5e9; + --color-sky-600: #0284c7; + --color-sky-700: #0369a1; + --color-sky-800: #075985; + --color-sky-900: #0c4a6e; + --color-sky-950: #082f49; + + --color-orange-50: #fff7ed; + --color-orange-100: #ffedd5; + --color-orange-200: #fed7aa; + --color-orange-300: #fdba74; + --color-orange-400: #fb923c; + --color-orange-500: #f97316; + --color-orange-600: #ea580c; + --color-orange-700: #c2410c; + --color-orange-800: #9a3412; + --color-orange-900: #7c2d12; + --color-orange-950: #431407; + + --color-amber-50: #fffbeb; + --color-amber-100: #fef3c7; + --color-amber-200: #fde68a; + --color-amber-300: #fcd34d; + --color-amber-400: #fbbf24; + --color-amber-500: #f59e0b; + --color-amber-600: #d97706; + --color-amber-700: #b45309; + --color-amber-800: #92400e; + --color-amber-900: #78350f; + --color-amber-950: #451a03; + + --background-image-conic-netbird: conic-gradient( + from 0deg, + #e55311 0%, + #f68330 10%, + #e55311 20%, + #e55311 100% + ); + + --animate-ping-slow: ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite; + --animate-pulse-slow: pulse-reverse 2s cubic-bezier(0.5, 0, 0.6, 1) infinite; + --animate-pulse-slower: pulse-reverse 3s cubic-bezier(0.5, 0, 0.6, 1) infinite; + --animate-spin-slow: spin-slow 2s linear infinite; + + @keyframes pulse-reverse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } + } + @keyframes spin-slow { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + @keyframes ping-slow { + 0% { + transform: scale(1); + opacity: 1; + } + 75%, + 100% { + transform: scale(2); + opacity: 0; + } + } } -@font-face { - font-family: "JetBrains Mono Variable"; - font-style: normal; - font-weight: 100 800; - src: url("./assets/fonts/jetbrains-mono-variable.ttf") format("truetype"); +/* + The default border color has changed to `currentcolor` in Tailwind CSS v4, + so we've added these compatibility styles to make sure everything still + looks the same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add an explicit border + color utility to any element that depends on these defaults. +*/ +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } } -@tailwind base; -@tailwind components; -@tailwind utilities; +@layer base { + @font-face { + font-family: "Inter Variable"; + font-style: normal; + font-weight: 100 900; + src: url("./assets/fonts/inter-variable.ttf") format("truetype"); + } -/* nb-gray channels (space-separated RGB) consumed by tailwind.config.ts. + @font-face { + font-family: "JetBrains Mono Variable"; + font-style: normal; + font-weight: 100 800; + src: url("./assets/fonts/jetbrains-mono-variable.ttf") format("truetype"); + } +} + +/* nb-gray channels (space-separated RGB) consumed by the @theme block above. :root is the inverted light ramp — high stops are surfaces (near-white), low stops are text (near-black); .dark restores the original dark ramp. Surfaces stay darker than content cards to preserve their separation. */ diff --git a/client/ui/frontend/src/modules/error/ErrorDialog.tsx b/client/ui/frontend/src/modules/error/ErrorDialog.tsx index 0be13b86e..3b5e27b24 100644 --- a/client/ui/frontend/src/modules/error/ErrorDialog.tsx +++ b/client/ui/frontend/src/modules/error/ErrorDialog.tsx @@ -54,7 +54,7 @@ export default function ErrorDialog() { {/* select-text: the message often names a path, a flag or an address the user needs to act on. */} - + {message} @@ -71,7 +71,7 @@ export default function ErrorDialog() { aria-label={t("common.copy")} > {command} diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx index df48524c1..e0fc5baf6 100644 --- a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -255,7 +255,7 @@ export const MainConnectionStatusSwitch = () => { role={"status"} aria-live={"polite"} className={ - "wails-no-draggable mb-1 select-none text-sm font-medium tracking-wide text-nb-gray-200 transition-colors duration-300" + "wails-no-draggable mb-1 text-sm font-medium tracking-wide text-nb-gray-200 transition-colors duration-300 select-none" } > {t(STATUS_KEY[connState])} @@ -267,7 +267,7 @@ export const MainConnectionStatusSwitch = () => { tabIndex={show && fqdn ? 0 : -1} className={cn( "mt-1 max-h-[1em] min-h-[1em] max-w-full transition-opacity duration-300", - "relative start-[0.55rem]", + "relative inset-s-[0.55rem]", show && fqdn ? "opacity-100" : "pointer-events-none opacity-0", )} > @@ -299,7 +299,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole tabIndex={show && ip ? 0 : -1} className={cn( "mt-1 max-h-[1em] min-h-[1em] transition-opacity duration-300", - "relative start-[0.55rem]", + "relative inset-s-[0.55rem]", show && ip ? "opacity-100" : "pointer-events-none opacity-0", )} > @@ -327,7 +327,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole aria-haspopup={"dialog"} aria-expanded={open} className={cn( - "group relative inline-flex cursor-default items-center rounded-sm outline-none", + "group relative inline-flex cursor-default items-center rounded-xs outline-hidden", isFocusVisible && "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", "transition-colors", @@ -346,7 +346,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole size={14} aria-hidden={"true"} className={cn( - "absolute -end-5 top-1/2 -translate-y-1/2", + "absolute -inset-e-5 top-1/2 -translate-y-1/2", "shrink-0 text-nb-gray-300 transition-colors", "group-hover:text-nb-gray-200", "group-data-[state=open]:text-nb-gray-200", @@ -361,9 +361,9 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole sideOffset={6} onOpenAutoFocus={(e) => e.preventDefault()} className={cn( - "z-50 min-w-64 max-w-[280px] overflow-hidden", + "z-50 max-w-[280px] min-w-64 overflow-hidden", "rounded-lg border border-nb-gray-900 bg-nb-gray-935", - "p-1 text-nb-gray-200 shadow-lg outline-none", + "p-1 text-nb-gray-200 shadow-lg outline-hidden", "flex flex-col", )} > @@ -401,9 +401,9 @@ const IpRow = ({ value }: { value: string }) => { "group/iprow relative flex items-center justify-between gap-3", "rounded-md px-2 py-1.5 text-start", "text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50", - "cursor-default outline-none transition-colors", + "cursor-default outline-hidden transition-colors", isFocusVisible && - "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-inset", )} > {value} diff --git a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx index 3e6281352..3396d3b54 100644 --- a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx +++ b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx @@ -81,8 +81,8 @@ export const MainExitNodeSwitcher = () => { }} style={{ width: "var(--radix-popover-trigger-width)" }} className={cn( - "wails-no-draggable z-50 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg", - "data-[state=open]:animate-in data-[state=closed]:animate-out", + "wails-no-draggable z-50 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none", + "data-[state=closed]:animate-out data-[state=open]:animate-in", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", @@ -93,12 +93,16 @@ export const MainExitNodeSwitcher = () => { loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()} - className={"outline-none focus:outline-none focus-visible:outline-none"} + className={ + "outline-hidden focus:outline-hidden focus-visible:outline-hidden" + } > handleSelect(NONE_VALUE)} /> {hasAny &&
} @@ -122,7 +126,7 @@ export const MainExitNodeSwitcher = () => { @@ -161,7 +165,7 @@ const ExitNodeTriggerCard = forwardRef( tabIndex={0} disabled={disabled} className={cn( - "flex w-full items-center gap-3 rounded-xl p-2.5 pe-5 text-start outline-none", + "flex w-full items-center gap-3 rounded-xl p-2.5 pe-5 text-start outline-hidden", "border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920", "transition-colors duration-150", "wails-no-draggable", @@ -219,7 +223,7 @@ const NoneRow = ({ isActive, onSelect }: NoneRowProps) => { onSelect={onSelect} className={cn( "flex items-center gap-2 px-2 py-2 pe-3", - "cursor-default rounded-md text-sm outline-none", + "cursor-default rounded-md text-sm outline-hidden", "data-[selected=true]:bg-nb-gray-900", )} > @@ -244,7 +248,7 @@ const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => ( onSelect={onSelect} className={cn( "flex items-center gap-2 px-2 py-2 pe-3", - "cursor-default rounded-md text-sm outline-none", + "cursor-default rounded-md text-sm outline-hidden", "data-[selected=true]:bg-nb-gray-900", )} > diff --git a/client/ui/frontend/src/modules/main/MainHeader.tsx b/client/ui/frontend/src/modules/main/MainHeader.tsx index 6aa5b7993..d560666f3 100644 --- a/client/ui/frontend/src/modules/main/MainHeader.tsx +++ b/client/ui/frontend/src/modules/main/MainHeader.tsx @@ -89,7 +89,7 @@ export const MainHeader = () => { align={pinSettingsRight && dir === "rtl" ? "start" : "end"} sideOffset={8} className={ - "min-w-52 select-none data-[state=closed]:!animate-none data-[state=closed]:!duration-0" + "min-w-52 select-none data-[state=closed]:animate-none! data-[state=closed]:duration-0!" } > {updateAvailable && ( @@ -141,7 +141,7 @@ export const MainHeader = () => { {
{settingsSlot} diff --git a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx index 1f0f527d1..6aa071906 100644 --- a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx +++ b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx @@ -110,10 +110,10 @@ export const Navigation = () => { className={cn( "group relative flex flex-1 items-center justify-center", "gap-2.5 px-5 py-3.5", - "outline-none transition-all", + "outline-hidden transition-all", isFirst && "rounded-ss-xl", isLast && "rounded-se-xl", - "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-inset", isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300", isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default", )} diff --git a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx index 3686275f0..d9a871067 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx @@ -42,7 +42,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => className={cn( "inline-flex h-9 items-center gap-1.5 rounded-md px-2", "text-sm text-nb-gray-200", - "outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900", + "outline-hidden transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", "disabled:pointer-events-none disabled:opacity-50", "wails-no-draggable cursor-default", @@ -54,7 +54,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => - + {filters.map((f) => { const checked = f.value === value; return ( diff --git a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx index da8028155..6f2f3a710 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx @@ -194,7 +194,7 @@ export const Networks = () => { @@ -213,7 +213,7 @@ export const Networks = () => { "border-t border-nb-gray-910", )} > - + {t("networks.bulk.selectionCount", { selected: selectedInView, total: filtered.length, @@ -228,7 +228,7 @@ export const Networks = () => { "inline-flex h-8 items-center rounded-md px-3", "text-xs font-medium text-nb-gray-100", "border border-nb-gray-900 bg-nb-gray-920 hover:border-nb-gray-850 hover:bg-nb-gray-910", - "wails-no-draggable cursor-pointer outline-none transition-colors", + "wails-no-draggable cursor-pointer outline-hidden transition-colors", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", )} > @@ -349,7 +349,7 @@ const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: Netwo return (
onToggle(n.id, n.selected)} onKeyDown={handleKey} className={cn( - "absolute inset-0 cursor-pointer outline-none", - "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60", + "absolute inset-0 cursor-pointer outline-hidden", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-inset", )} /> @@ -401,7 +401,7 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
{ const { t } = useTranslation(); return ( <> -
+
{t("networks.ips.heading")}
    @@ -496,7 +496,7 @@ const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => { {ip} @@ -516,7 +516,7 @@ type ToggleProps = { const NetworkToggle = ({ checked, mixed }: ToggleProps) => { const checkedTranslate = checked - ? "translate-x-[1.125rem] rtl:-translate-x-[1.125rem]" + ? "translate-x-4.5 rtl:-translate-x-4.5" : "translate-x-0.5 rtl:-translate-x-0.5"; return ( { className={cn( "flex h-8 w-8 shrink-0 items-center justify-center rounded-md", "text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100", - "cursor-default outline-none transition-colors", + "cursor-default outline-hidden transition-colors", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", "wails-no-draggable", )} @@ -241,7 +241,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { className={cn( "flex h-8 w-8 shrink-0 items-center justify-center rounded-md", "text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100", - "cursor-default outline-none transition-colors", + "cursor-default outline-hidden transition-colors", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", "wails-no-draggable", "disabled:opacity-50 disabled:hover:bg-transparent", @@ -266,7 +266,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => { @@ -317,7 +317,7 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => { @@ -335,7 +335,7 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => { @@ -408,7 +408,7 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => { @@ -446,7 +446,7 @@ const IceRow = ({ icon, baseLabel, type, endpoint }: IceRowProps) => { @@ -474,11 +474,11 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => { aria-haspopup={"dialog"} aria-expanded={open} className={cn( - "inline-flex shrink-0 items-center gap-1 rounded", + "inline-flex shrink-0 items-center gap-1 rounded-sm", "bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910", "border border-nb-gray-900", - "py-1 pe-2 ps-2.5 text-xs font-medium text-nb-gray-300", - "wails-no-draggable cursor-default outline-none transition-all", + "py-1 ps-2.5 pe-2 text-xs font-medium text-nb-gray-300", + "wails-no-draggable cursor-default outline-hidden transition-all", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", )} > @@ -497,9 +497,9 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => { sideOffset={6} onOpenAutoFocus={(e) => e.preventDefault()} className={cn( - "z-50 max-h-72 min-w-64 max-w-[280px] overflow-auto", + "z-50 max-h-72 max-w-[280px] min-w-64 overflow-auto", "rounded-lg border border-nb-gray-900 bg-nb-gray-935", - "p-1 text-nb-gray-200 shadow-lg outline-none", + "p-1 text-nb-gray-200 shadow-lg outline-hidden", "flex flex-col", )} > @@ -539,9 +539,9 @@ const ResourceRow = ({ value }: { value: string }) => { "group/resourcerow relative flex items-center justify-between gap-3", "rounded-md px-2 py-1.5 text-start", "text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50", - "cursor-default outline-none transition-colors", + "cursor-default outline-hidden transition-colors", isFocusVisible && - "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-inset", )} > {value} @@ -560,7 +560,7 @@ const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) = text={value} dir={mono ? "ltr" : "auto"} className={cn( - "inline-block min-w-0 max-w-[260px] truncate align-middle", + "inline-block max-w-[260px] min-w-0 truncate align-middle", mono && "font-mono", )} /> diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx index 524bae5d4..92cbe548e 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerFilters.tsx @@ -42,7 +42,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => { className={cn( "inline-flex h-9 items-center gap-1.5 rounded-md px-2", "text-sm text-nb-gray-200", - "outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900", + "outline-hidden transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", "disabled:pointer-events-none disabled:opacity-50", "wails-no-draggable cursor-default", @@ -54,7 +54,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => { - + {filters.map((f) => { const checked = f.value === value; return ( diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx index 79a02a471..71ca4d103 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -148,7 +148,7 @@ export const Peers = () => { @@ -280,7 +280,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) return (
    onSelect(peer)} onKeyDown={handleKey} className={cn( - "absolute inset-0 cursor-default outline-none", - "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60", + "absolute inset-0 cursor-default outline-hidden", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-inset", )} /> diff --git a/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx b/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx index 70d45acc5..0eaf3e88f 100644 --- a/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileAvatar.tsx @@ -62,7 +62,7 @@ export const ProfileAvatar = forwardRef(function Profi type={type} className={cn( "inline-grid place-items-center rounded-full bg-nb-gray-900 p-0 text-center", - "cursor-default outline-none", + "cursor-default outline-hidden", "transition-colors duration-150 hover:bg-nb-gray-850", "data-[state=open]:bg-nb-gray-850", className, diff --git a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx index ec70fb923..34d87d928 100644 --- a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx @@ -94,8 +94,8 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { listRef.current?.focus(); }} className={cn( - "wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-800 bg-nb-gray-950 p-1 text-nb-gray-200 shadow-lg dark:border-nb-gray-900 dark:bg-nb-gray-935", - "data-[state=open]:animate-in data-[state=closed]:animate-out", + "wails-no-draggable z-50 min-w-64 overflow-hidden rounded-lg border border-nb-gray-800 bg-nb-gray-950 p-1 text-nb-gray-200 shadow-lg select-none dark:border-nb-gray-900 dark:bg-nb-gray-935", + "data-[state=closed]:animate-out data-[state=open]:animate-in", "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", "data-[side=bottom]:origin-top data-[side=top]:origin-bottom", @@ -108,12 +108,16 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()} - className={"outline-none focus:outline-none focus-visible:outline-none"} + className={ + "outline-hidden focus:outline-hidden focus-visible:outline-hidden" + } > {sortedProfiles.length > 0 && ( <> @@ -135,7 +139,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { @@ -157,7 +161,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { disabled={!onManageProfiles} className={cn( "flex items-center gap-2 px-2 py-1.5", - "cursor-default rounded-md text-sm outline-none", + "cursor-default rounded-md text-sm outline-hidden", "data-[selected=true]:bg-nb-gray-900", "data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50", )} @@ -185,13 +189,13 @@ const ProfileTriggerSkeleton = () => ( role={"status"} aria-busy={"true"} aria-live={"polite"} - className={"wails-no-draggable flex h-10 select-none items-center gap-2 rounded-lg px-3"} + className={"wails-no-draggable flex h-10 items-center gap-2 rounded-lg px-3 select-none"} >
    -
    +
    ); @@ -213,7 +217,7 @@ const ProfileTriggerButton = forwardRef { onSelect={() => onSelect(profile.id)} className={cn( "flex w-auto gap-2 px-2 py-2 pe-3 last:mb-1", - "cursor-default rounded-md text-sm outline-none", + "cursor-default rounded-md text-sm outline-hidden", "data-[selected=true]:bg-nb-gray-900", showEmail ? "items-start" : "items-center", )} diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx index 509f48061..ae43c6d06 100644 --- a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx @@ -204,7 +204,7 @@ export function ProfilesTab() {

    {t("settings.profiles.emptyTitle")}

    -

    +

    {t("settings.profiles.emptyDescription")}

    @@ -409,10 +409,10 @@ const ProfileRow = ({ className={cn( "flex items-center gap-4 px-4 py-2.5", "border-b border-nb-gray-910 last:border-b-0", - "outline-none", + "outline-hidden", isFirst && "rounded-t-xl", isLast && "rounded-b-xl", - "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60", + "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-inset", )} > {profile.name} @@ -470,7 +470,7 @@ const TruncatedEmail = ({ email }: { email: string }) => { const span = ( {email} @@ -558,7 +558,7 @@ const RowMoreMenu = ({ aria-label={moreLabel} tabIndex={rowFocused ? 0 : -1} className={cn( - "inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none", + "inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-hidden", "text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100", "transition-colors duration-150", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", @@ -605,7 +605,7 @@ const DeleteMenuItem = ({ disabled, disabledReason, onDelete }: DeleteMenuItemPr
    @@ -653,7 +653,7 @@ const ActionIconButton = ({ aria-disabled={disabled || undefined} tabIndex={hidden || !tabbable ? -1 : 0} className={cn( - "inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none", + "inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-hidden", "transition-colors duration-150", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", variant === "danger" @@ -661,7 +661,7 @@ const ActionIconButton = ({ : "text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100", hidden && "pointer-events-none opacity-0", disabled && - "cursor-not-allowed opacity-40 hover:!bg-transparent hover:!text-nb-gray-400", + "cursor-not-allowed opacity-40 hover:bg-transparent! hover:text-nb-gray-400!", )} > diff --git a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx index e57040a7a..9ca324781 100644 --- a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx +++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx @@ -221,7 +221,7 @@ export default function SessionExpirationDialog() { {!expired && (
    diff --git a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx index 7228e2a6c..23fa4168a 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx @@ -106,7 +106,7 @@ export function SettingsAbout() { type={"button"} onClick={handleVersionClick} className={ - "cursor-text select-text bg-transparent text-sm font-semibold text-nb-gray-100 outline-none" + "cursor-text bg-transparent text-sm font-semibold text-nb-gray-100 outline-hidden select-text" } > {daemonVersion === "development" ? ( @@ -120,7 +120,7 @@ export function SettingsAbout() { t("settings.about.client", { version: daemonVersion }) )} -

    +

    {guiVersion === "development" ? ( {t("settings.about.guiName")}{" "} @@ -149,7 +149,7 @@ export function SettingsAbout() { tabIndex={0} onClick={() => openUrl(url)} className={ - "inline-flex items-center gap-1.5 rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940" + "inline-flex items-center gap-1.5 rounded-xs decoration-[0.5px] underline-offset-4 outline-hidden transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940" } > @@ -167,7 +167,7 @@ export function SettingsAbout() { tabIndex={0} onClick={() => openUrl(link.url)} className={ - "rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940" + "rounded-xs decoration-[0.5px] underline-offset-4 outline-hidden transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940" } > {link.label} diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx index 816dcb71f..50b013273 100644 --- a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx @@ -28,7 +28,7 @@ export const SettingsNavigation = () => { ) : undefined; return ( -

    +
    { diff --git a/client/ui/frontend/src/modules/settings/SettingsSSH.tsx b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx index d74afae73..95ef4db86 100644 --- a/client/ui/frontend/src/modules/settings/SettingsSSH.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx @@ -261,7 +261,7 @@ function GuardedHint({ {t("settings.ssh.privilege.hint", { actor })} - + {command} diff --git a/client/ui/frontend/src/modules/settings/SettingsSection.tsx b/client/ui/frontend/src/modules/settings/SettingsSection.tsx index 3a39d661f..86650bcb9 100644 --- a/client/ui/frontend/src/modules/settings/SettingsSection.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsSection.tsx @@ -15,12 +15,12 @@ export const SectionGroup = ({ tabIndex={disabled ? -1 : 0} {...(disabled ? { inert: "" } : {})} className={cn( - "mb-8 rounded-md px-1 outline-none last:mb-1", + "mb-8 rounded-md px-1 outline-hidden last:mb-1", "focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940", disabled && "pointer-events-none opacity-30", )} > -

    +

    {title}

    {children}
    @@ -30,7 +30,7 @@ export const SectionGroup = ({ export const SettingsBottomBar = ({ children }: { children: ReactNode }) => ( <>
    -
    +
    +
    {t("settings.troubleshooting.anonymize.info")}
    } @@ -213,7 +213,7 @@ function ProgressSection({ {stage.kind === "capturing" && (
    diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx index 8838e5c92..7dab2a5e5 100644 --- a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx @@ -32,7 +32,7 @@ export function WelcomeStepTray({ onContinue }: Readonly) {""}
    diff --git a/client/ui/frontend/tailwind.config.ts b/client/ui/frontend/tailwind.config.ts deleted file mode 100644 index da41ee111..000000000 --- a/client/ui/frontend/tailwind.config.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { Config } from "tailwindcss"; - -const config: Config = { - content: ["./index.html", "./src/**/*.{ts,tsx}"], - darkMode: "class", - theme: { - fontFamily: { - sans: ['"Inter Variable"', 'ui-sans-serif', 'system-ui', 'sans-serif'], - mono: ['"JetBrains Mono Variable"', 'ui-monospace', 'monospace'], - }, - extend: { - colors: { - // nb-gray is theme-aware: channels come from CSS variables in - // globals.css (:root = light ramp, .dark = original dark ramp). The - // rgb(var(...) / ) form keeps opacity modifiers working. - "nb-gray": { - DEFAULT: "rgb(var(--nb-gray-DEFAULT) / )", - 50: "rgb(var(--nb-gray-50) / )", - 100: "rgb(var(--nb-gray-100) / )", - 200: "rgb(var(--nb-gray-200) / )", - 250: "rgb(var(--nb-gray-250) / )", - 300: "rgb(var(--nb-gray-300) / )", - 350: "rgb(var(--nb-gray-350) / )", - 400: "rgb(var(--nb-gray-400) / )", - 500: "rgb(var(--nb-gray-500) / )", - 600: "rgb(var(--nb-gray-600) / )", - 700: "rgb(var(--nb-gray-700) / )", - 800: "rgb(var(--nb-gray-800) / )", - 850: "rgb(var(--nb-gray-850) / )", - 900: "rgb(var(--nb-gray-900) / )", - 910: "rgb(var(--nb-gray-910) / )", - 920: "rgb(var(--nb-gray-920) / )", - 925: "rgb(var(--nb-gray-925) / )", - 930: "rgb(var(--nb-gray-930) / )", - 935: "rgb(var(--nb-gray-935) / )", - 940: "rgb(var(--nb-gray-940) / )", - 950: "rgb(var(--nb-gray-950) / )", - 960: "rgb(var(--nb-gray-960) / )", - }, - gray: { - 50: "#F9FAFB", - 100: "#F3F4F6", - 200: "#E5E7EB", - 300: "#D1D5DB", - 400: "#9CA3AF", - 500: "#6B7280", - 600: "#4B5563", - 700: "#374151", - 800: "#1F2937", - 900: "#111827", - }, - red: { - 50: "#FDF2F2", - 100: "#FDE8E8", - 200: "#FBD5D5", - 300: "#F8B4B4", - 400: "#F98080", - 500: "#F05252", - 600: "#E02424", - 700: "#C81E1E", - 800: "#9B1C1C", - 900: "#771D1D", - }, - yellow: { - 50: "#FDFDEA", - 100: "#FDF6B2", - 200: "#FCE96A", - 300: "#FACA15", - 400: "#E3A008", - 500: "#C27803", - 600: "#9F580A", - 700: "#8E4B10", - 800: "#723B13", - 900: "#633112", - }, - green: { - 50: "#F3FAF7", - 100: "#DEF7EC", - 200: "#BCF0DA", - 300: "#84E1BC", - 400: "#31C48D", - 500: "#0E9F6E", - 600: "#057A55", - 700: "#046C4E", - 800: "#03543F", - 900: "#014737", - }, - blue: { - 50: "#EBF5FF", - 100: "#E1EFFE", - 200: "#C3DDFD", - 300: "#A4CAFE", - 400: "#76A9FA", - 500: "#3F83F8", - 600: "#1C64F2", - 700: "#1A56DB", - 800: "#1E429F", - 900: "#233876", - }, - indigo: { - 50: "#F0F5FF", - 100: "#E5EDFF", - 200: "#CDDBFE", - 300: "#B4C6FC", - 400: "#8DA2FB", - 500: "#6875F5", - 600: "#5850EC", - 700: "#5145CD", - 800: "#42389D", - 900: "#362F78", - }, - purple: { - 50: "#F6F5FF", - 100: "#EDEBFE", - 200: "#DCD7FE", - 300: "#CABFFD", - 400: "#AC94FA", - 500: "#9061F9", - 600: "#7E3AF2", - 700: "#6C2BD9", - 800: "#5521B5", - 900: "#4A1D96", - }, - pink: { - 50: "#FDF2F8", - 100: "#FCE8F3", - 200: "#FAD1E8", - 300: "#F8B4D9", - 400: "#F17EB8", - 500: "#E74694", - 600: "#D61F69", - 700: "#BF125D", - 800: "#99154B", - 900: "#751A3D", - }, - netbird: { - DEFAULT: "#f68330", - 50: "#fff6ed", - 100: "#feecd6", - 150: "#ffdfb8", - 200: "#ffd4a6", - 300: "#fab677", - 400: "#f68330", - 500: "#f46d1b", - 600: "#e55311", - 700: "#be3e10", - 800: "#973215", - 900: "#7a2b14", - 950: "#421308", - }, - }, - backgroundImage: { - "conic-netbird": "conic-gradient(from 0deg, #e55311 0%, #f68330 10%, #e55311 20%, #e55311 100%)", - }, - keyframes: { - "pulse-reverse": { - "0%, 100%": { opacity: "1" }, - "50%": { opacity: "0.4" }, - }, - "spin-slow": { - "0%": { transform: "rotate(0deg)" }, - "100%": { transform: "rotate(360deg)" }, - }, - "ping-slow": { - "0%": { transform: "scale(1)", opacity: "1" }, - "75%, 100%": { transform: "scale(2)", opacity: "0" }, - }, - }, - animation: { - "ping-slow": "ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite", - "pulse-slow": "pulse-reverse 2s cubic-bezier(0.5, 0, 0.6, 1) infinite", - "pulse-slower": "pulse-reverse 3s cubic-bezier(0.5, 0, 0.6, 1) infinite", - "spin-slow": "spin-slow 2s linear infinite", - }, - }, - }, - plugins: [require("tailwindcss-animate")], -}; - -export default config; diff --git a/client/ui/frontend/vite.config.ts b/client/ui/frontend/vite.config.ts index 48b1c5630..8804216a7 100644 --- a/client/ui/frontend/vite.config.ts +++ b/client/ui/frontend/vite.config.ts @@ -1,6 +1,7 @@ import path from "path"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; import wails from "@wailsio/runtime/plugins/vite"; // https://vitejs.dev/config/ @@ -14,7 +15,7 @@ export default defineConfig({ ), }, }, - plugins: [react(), wails("./bindings")], + plugins: [react(), tailwindcss(), wails("./bindings")], server: { host: "127.0.0.1", port: 9245, diff --git a/proxy/web/package-lock.json b/proxy/web/package-lock.json index 1611323a7..1e29722cc 100644 --- a/proxy/web/package-lock.json +++ b/proxy/web/package-lock.json @@ -29,17 +29,17 @@ "tsx": "^4.21.0", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "7.3.2" + "vite": "7.3.7" } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -48,9 +48,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -58,22 +58,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -90,14 +89,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", - "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -107,14 +106,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -124,9 +123,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -134,29 +133,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -176,9 +175,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -186,9 +185,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -196,9 +195,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -206,27 +205,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -268,33 +267,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -302,14 +301,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -915,29 +914,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1710,7 +1723,6 @@ "integrity": "sha512-+0/4J266CBGPUq/ELg7QUHhN25WYjE0wYTPSQJn1xeu8DOlIOPxXxrNGiLmfAWl7HMMgWFWXpt9IDjMWrF5Iow==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1721,7 +1733,6 @@ "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1781,7 +1792,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -1926,9 +1936,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -2033,7 +2043,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2099,19 +2108,22 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.11.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.30.tgz", + "integrity": "sha512-YJs6Pz17RMNv8HoyKREtCY38E3BV1nPkWVmIb+XN1uHC1Vqt3KZCH/uJH9ZKRoiKb/icZ6tvehyee6RZNbFfRA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -2120,9 +2132,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.29.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", + "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", "dev": true, "funding": [ { @@ -2139,13 +2151,12 @@ } ], "license": "MIT", - "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.29", + "caniuse-lite": "^1.0.30001816", + "electron-to-chromium": "^1.5.459", + "node-releases": "^2.0.59", + "update-browserslist-db": "^1.3.4" }, "bin": { "browserslist": "cli.js" @@ -2165,9 +2176,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001767", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", - "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", + "version": "1.0.30001817", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001817.tgz", + "integrity": "sha512-uZ1JG80BwhtMalIAGPxPfmNhHyd/B/nTdr3hITWlRamSyAdUStP1Gop01e7SD5q6J90ixJvWDbvmiFVfDgn4bw==", "dev": true, "funding": [ { @@ -2303,9 +2314,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.463", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.463.tgz", + "integrity": "sha512-nVWNU6ltFjopvOdD44J4zoBkgO0M+WZiFKKFV7EjebOH9z0KNphk6bCEjplXtJofL21ARIuuwWOCsFkBVklm6w==", "dev": true, "license": "ISC" }, @@ -2394,7 +2405,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2846,10 +2856,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -3263,9 +3283,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -3289,11 +3309,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.59", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", + "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/optionator": { "version": "0.9.4", @@ -3391,7 +3414,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3400,9 +3422,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "dev": true, "funding": [ { @@ -3420,9 +3442,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -3453,7 +3475,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3585,9 +3606,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3687,7 +3708,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -3721,7 +3741,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3762,9 +3781,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { @@ -3803,14 +3822,13 @@ } }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -3930,7 +3948,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/proxy/web/package.json b/proxy/web/package.json index 9a7c84ed4..5c8e3e94e 100644 --- a/proxy/web/package.json +++ b/proxy/web/package.json @@ -31,6 +31,6 @@ "tsx": "^4.21.0", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "7.3.2" + "vite": "7.3.7" } } From 11799f6b94713025c79ccdd299d6aa901fc2f1aa Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Wed, 21 Oct 2026 18:33:31 +0300 Subject: [PATCH 189/209] [management] Use account owner as inviter for service user invites (#8276) --- management/server/user.go | 4 +-- management/server/user_test.go | 63 ++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/management/server/user.go b/management/server/user.go index e64320666..84c4c5022 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -102,11 +102,11 @@ func (am *DefaultAccountManager) inviteNewUser(ctx context.Context, accountID, u inviterID := userID if initiatorUser.IsServiceUser { - createdBy, err := am.Store.GetAccountCreatedBy(ctx, store.LockingStrengthNone, accountID) + owner, err := am.Store.GetAccountOwner(ctx, store.LockingStrengthNone, accountID) if err != nil { return nil, err } - inviterID = createdBy + inviterID = owner.Id } var idpUser *idp.UserData diff --git a/management/server/user_test.go b/management/server/user_test.go index fcfb7a1e7..305ed5a24 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -706,6 +706,69 @@ func TestUser_InviteNewUser(t *testing.T) { assert.Errorf(t, err, "Invite user with owner role should throw error") } +func TestUser_InviteNewUser_ServiceUserAfterCreatorRemoved(t *testing.T) { + s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + const ( + creatorID = "creatorID" + newOwnerID = "newOwnerID" + newOwnerMail = "new-owner@test.com" + ) + + account := newAccountWithId(context.Background(), mockAccountID, creatorID, "", "", "", false) + delete(account.Users, creatorID) + account.Users[newOwnerID] = &types.User{ + Id: newOwnerID, + AccountID: mockAccountID, + Role: types.UserRoleOwner, + } + account.Users[mockServiceUserID] = &types.User{ + Id: mockServiceUserID, + AccountID: mockAccountID, + Role: types.UserRoleAdmin, + IsServiceUser: true, + ServiceUserName: mockServiceUserName, + } + require.NoError(t, s.SaveAccount(context.Background(), account)) + + am := DefaultAccountManager{ + Store: s, + eventStore: &activity.InMemoryEventStore{}, + cacheLoading: map[string]chan struct{}{}, + permissionsManager: permissions.NewManager(s), + } + + cs, err := nbcache.NewStore(context.Background(), nbcache.DefaultIDPCacheExpirationMax, nbcache.DefaultIDPCacheCleanupInterval, nbcache.DefaultIDPCacheOpenConn) + require.NoError(t, err) + cacheManager := nbcache.NewAccountUserDataCache(am.loadAccount, cs) + t.Cleanup(func() { assert.NoError(t, cacheManager.Close()) }) + am.cacheManager = cacheManager + + idpUsers := []*idp.UserData{{ID: newOwnerID, Email: newOwnerMail, Name: "new owner"}} + var invitedBy string + am.idpManager = &idp.MockIDP{ + CreateUserFunc: func(_ context.Context, email, name, _, invitedByEmail string) (*idp.UserData, error) { + invitedBy = invitedByEmail + newUser := &idp.UserData{ID: "invitedID", Email: email, Name: name} + idpUsers = append(idpUsers, newUser) + return newUser, nil + }, + GetAccountFunc: func(_ context.Context, _ string) ([]*idp.UserData, error) { + return idpUsers, nil + }, + } + + _, err = am.inviteNewUser(context.Background(), mockAccountID, mockServiceUserID, &types.UserInfo{ + Name: "invited", + Email: "invited@test.com", + Role: mockRole, + }) + require.NoError(t, err) + assert.Equal(t, newOwnerMail, invitedBy, "service user invites should be sent on behalf of the current owner") +} + func TestUser_DeleteUser_ServiceUser(t *testing.T) { tests := []struct { name string From 4ca20f0c7a6f806c7b9ac27f1aa8e14ec4e8cd52 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Wed, 21 Oct 2026 18:38:08 +0300 Subject: [PATCH 190/209] [management] Make EDR sync interval configurable (#8273) --- shared/management/http/api/openapi.yml | 45 +++++++++++++++++++++++++ shared/management/http/api/types.gen.go | 30 +++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index ca8c1339b..f129ada26 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -3961,6 +3961,11 @@ components: type: integer description: The devices last sync requirement interval in hours. Minimum value is 24 hours. minimum: 24 + sync_interval: + type: integer + description: How often devices are synced from the integration, in minutes. Minimum value is 1 minute, maximum is 1440 minutes. + minimum: 1 + maximum: 1440 enabled: type: boolean description: Indicates whether the integration is enabled @@ -3979,6 +3984,7 @@ components: - tenant_id - groups - last_synced_interval + - sync_interval - enabled properties: id: @@ -4025,6 +4031,9 @@ components: last_synced_interval: type: integer description: The devices last sync requirement interval in hours. + sync_interval: + type: integer + description: How often devices are synced from the integration, in minutes. enabled: type: boolean description: Indicates whether the integration is enabled @@ -4047,6 +4056,11 @@ components: type: integer description: The devices last sync requirement interval in hours. Minimum value is 24 hours. minimum: 24 + sync_interval: + type: integer + description: How often devices are synced from the integration, in minutes. Minimum value is 1 minute, maximum is 1440 minutes. + minimum: 1 + maximum: 1440 enabled: type: boolean description: Indicates whether the integration is enabled @@ -4073,6 +4087,7 @@ components: - groups - last_synced_interval - match_attributes + - sync_interval - enabled properties: id: @@ -4115,6 +4130,9 @@ components: description: The devices last sync requirement interval in hours. match_attributes: $ref: '#/components/schemas/SentinelOneMatchAttributes' + sync_interval: + type: integer + description: How often devices are synced from the integration, in minutes. enabled: type: boolean description: Indicates whether the integration is enabled @@ -4174,6 +4192,11 @@ components: minimum: 0 maximum: 100 example: 75 + sync_interval: + type: integer + description: How often devices are synced from the integration, in minutes. Minimum value is 1 minute, maximum is 1440 minutes. + minimum: 1 + maximum: 1440 enabled: type: boolean description: Indicates whether the integration is enabled @@ -4197,6 +4220,7 @@ components: - cloud_id - groups - zta_score_threshold + - sync_interval - enabled properties: id: @@ -4237,6 +4261,9 @@ components: zta_score_threshold: type: integer description: The minimum Zero Trust Assessment score required for agent approval (0-100) + sync_interval: + type: integer + description: How often devices are synced from the integration, in minutes. enabled: type: boolean description: Indicates whether the integration is enabled @@ -4260,6 +4287,11 @@ components: type: integer description: The devices last sync requirement interval in hours. Minimum value is 24 hours minimum: 24 + sync_interval: + type: integer + description: How often devices are synced from the integration, in minutes. Minimum value is 1 minute, maximum is 1440 minutes. + minimum: 1 + maximum: 1440 enabled: type: boolean description: Indicates whether the integration is enabled @@ -4285,6 +4317,7 @@ components: - groups - last_synced_interval - match_attributes + - sync_interval - enabled properties: id: @@ -4322,6 +4355,9 @@ components: last_synced_interval: type: integer description: The devices last sync requirement interval in hours. + sync_interval: + type: integer + description: How often devices are synced from the integration, in minutes. enabled: type: boolean description: Indicates whether the integration is enabled @@ -4370,6 +4406,11 @@ components: type: integer description: The devices last sync requirement interval in hours. Minimum value is 24 hours minimum: 24 + sync_interval: + type: integer + description: How often devices are synced from the integration, in minutes. Minimum value is 1 minute, maximum is 1440 minutes. + minimum: 1 + maximum: 1440 enabled: type: boolean description: Indicates whether the integration is enabled @@ -4396,6 +4437,7 @@ components: - groups - last_synced_interval - match_attributes + - sync_interval - enabled properties: id: @@ -4436,6 +4478,9 @@ components: last_synced_interval: type: integer description: The devices last sync requirement interval in hours. + sync_interval: + type: integer + description: How often devices are synced from the integration, in minutes. enabled: type: boolean description: Indicates whether the integration is enabled diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 1595553ed..a5ffe4145 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -2918,6 +2918,9 @@ type EDRFalconRequest struct { // Secret CrowdStrike API client secret Secret string `json:"secret"` + // SyncInterval How often devices are synced from the integration, in minutes. Minimum value is 1 minute, maximum is 1440 minutes. + SyncInterval *int `json:"sync_interval,omitempty"` + // ZtaScoreThreshold The minimum Zero Trust Assessment score required for agent approval (0-100) ZtaScoreThreshold int `json:"zta_score_threshold"` } @@ -2948,6 +2951,9 @@ type EDRFalconResponse struct { // LastSyncedAt Timestamp of when the integration was last synced. LastSyncedAt time.Time `json:"last_synced_at"` + // SyncInterval How often devices are synced from the integration, in minutes. + SyncInterval int `json:"sync_interval"` + // UpdatedAt Timestamp of when the integration was last updated. UpdatedAt time.Time `json:"updated_at"` @@ -2974,6 +2980,9 @@ type EDRFleetDMRequest struct { // MatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open source version. Premium-only attributes are marked accordingly MatchAttributes FleetDMMatchAttributes `json:"match_attributes"` + + // SyncInterval How often devices are synced from the integration, in minutes. Minimum value is 1 minute, maximum is 1440 minutes. + SyncInterval *int `json:"sync_interval,omitempty"` } // EDRFleetDMResponse Represents a FleetDM EDR integration configuration @@ -3008,6 +3017,9 @@ type EDRFleetDMResponse struct { // MatchAttributes Attribute conditions to match when approving FleetDM hosts. Most attributes work with FleetDM's free/open source version. Premium-only attributes are marked accordingly MatchAttributes FleetDMMatchAttributes `json:"match_attributes"` + // SyncInterval How often devices are synced from the integration, in minutes. + SyncInterval int `json:"sync_interval"` + // UpdatedAt Timestamp of when the integration was last updated. UpdatedAt time.Time `json:"updated_at"` } @@ -3031,6 +3043,9 @@ type EDRHuntressRequest struct { // MatchAttributes Attribute conditions to match when approving agents MatchAttributes HuntressMatchAttributes `json:"match_attributes"` + + // SyncInterval How often devices are synced from the integration, in minutes. Minimum value is 1 minute, maximum is 1440 minutes. + SyncInterval *int `json:"sync_interval,omitempty"` } // EDRHuntressResponse Represents a Huntress EDR integration configuration @@ -3062,6 +3077,9 @@ type EDRHuntressResponse struct { // MatchAttributes Attribute conditions to match when approving agents MatchAttributes HuntressMatchAttributes `json:"match_attributes"` + // SyncInterval How often devices are synced from the integration, in minutes. + SyncInterval int `json:"sync_interval"` + // UpdatedAt Timestamp of when the integration was last updated. UpdatedAt time.Time `json:"updated_at"` } @@ -3083,6 +3101,9 @@ type EDRIntuneRequest struct { // Secret The Azure application client secret Secret string `json:"secret"` + // SyncInterval How often devices are synced from the integration, in minutes. Minimum value is 1 minute, maximum is 1440 minutes. + SyncInterval *int `json:"sync_interval,omitempty"` + // TenantId The Azure tenant id TenantId string `json:"tenant_id"` } @@ -3116,6 +3137,9 @@ type EDRIntuneResponse struct { // LastSyncedInterval The devices last sync requirement interval in hours. LastSyncedInterval int `json:"last_synced_interval"` + // SyncInterval How often devices are synced from the integration, in minutes. + SyncInterval int `json:"sync_interval"` + // TenantId The Azure tenant id TenantId string `json:"tenant_id"` @@ -3142,6 +3166,9 @@ type EDRSentinelOneRequest struct { // MatchAttributes Attribute conditions to match when approving agents MatchAttributes SentinelOneMatchAttributes `json:"match_attributes"` + + // SyncInterval How often devices are synced from the integration, in minutes. Minimum value is 1 minute, maximum is 1440 minutes. + SyncInterval *int `json:"sync_interval,omitempty"` } // EDRSentinelOneResponse Represents a SentinelOne EDR integration configuration @@ -3176,6 +3203,9 @@ type EDRSentinelOneResponse struct { // MatchAttributes Attribute conditions to match when approving agents MatchAttributes SentinelOneMatchAttributes `json:"match_attributes"` + // SyncInterval How often devices are synced from the integration, in minutes. + SyncInterval int `json:"sync_interval"` + // UpdatedAt Timestamp of when the integration was last updated. UpdatedAt time.Time `json:"updated_at"` } From 73829c8d968bc2c0a8dd423449638ecc57134adf Mon Sep 17 00:00:00 2001 From: Mohammadreza Razavian <62871170+smrrazavian@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:16:33 +0330 Subject: [PATCH 191/209] [management] Give the /api/setup owner account the single account mode domain (#8147) --- management/server/account_test.go | 44 ++++++++++ .../instance/instance_handler_test.go | 18 ++--- management/server/instance/setup_service.go | 29 ++++++- .../server/instance/setup_service_test.go | 81 +++++++++++++++---- 4 files changed, 145 insertions(+), 27 deletions(-) diff --git a/management/server/account_test.go b/management/server/account_test.go index 8f7b1f18b..983ee8680 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -50,6 +50,7 @@ import ( "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/instance" "github.com/netbirdio/netbird/management/server/job" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" @@ -844,6 +845,49 @@ func TestAccountManager_GetAccountByUserID(t *testing.T) { } } +type setupOwnerInstanceManager struct { + instance.Manager + user *idp.UserData +} + +func (m setupOwnerInstanceManager) CreateOwnerUser(context.Context, string, string, string) (*idp.UserData, error) { + return m.user, nil +} + +// The /api/setup owner account must get the single account mode domain, so later +// users join that account instead of forking a new one. +func TestSetupOwner_SingleAccountMode_LaterUsersJoinSetupAccount(t *testing.T) { + t.Setenv(instance.SetupPATEnabledEnvKey, "true") + + manager, _, err := createManager(t) + require.NoError(t, err) + manager.singleAccountMode = true + manager.singleAccountModeDomain = "netbird.selfhosted" + + ctx := context.Background() + owner := &idp.UserData{ID: "owner", Email: "owner@example.com", Name: "Owner"} + setup := instance.NewSetupService(setupOwnerInstanceManager{user: owner}, manager) + _, err = setup.SetupOwner(ctx, owner.Email, "password", owner.Name, instance.SetupOptions{CreatePAT: true}) + require.NoError(t, err) + + ownerAccountID, err := manager.Store.GetAccountIDByUserID(ctx, store.LockingStrengthNone, owner.ID) + require.NoError(t, err) + acc, err := manager.Store.GetAccount(ctx, ownerAccountID) + require.NoError(t, err) + assert.Equal(t, "netbird.selfhosted", acc.Domain) + assert.Equal(t, types.PrivateCategory, acc.DomainCategory) + assert.True(t, acc.IsDomainPrimaryAccount) + + jitAccountID, _, err := manager.GetAccountIDFromUserAuth(ctx, auth.UserAuth{ + UserId: "jit-user", + Email: "jit@gmail.com", + Domain: "gmail.com", + DomainCategory: types.PublicCategory, + }) + require.NoError(t, err) + assert.Equal(t, ownerAccountID, jitAccountID) +} + func createAccount(am *DefaultAccountManager, accountID, userID, domain string) (*types.Account, error) { account := newAccountWithId(context.Background(), accountID, userID, domain, "", "", false) err := am.Store.SaveAccount(context.Background(), account) diff --git a/management/server/http/handlers/instance/instance_handler_test.go b/management/server/http/handlers/instance/instance_handler_test.go index ba59497fa..8c72c112f 100644 --- a/management/server/http/handlers/instance/instance_handler_test.go +++ b/management/server/http/handlers/instance/instance_handler_test.go @@ -363,9 +363,9 @@ func TestSetup_PAT_MissingExpireIn_DefaultsToOneDay(t *testing.T) { }, } accountMgr := &mock_server.MockAccountManager{ - GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) { + GetAccountIDFromUserAuthFunc: func(_ context.Context, userAuth auth.UserAuth) (string, string, error) { assert.Equal(t, "u1", userAuth.UserId) - return "acc-1", nil + return "acc-1", "u1", nil }, CreatePATFunc: func(_ context.Context, accountID, initiator, target, name string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) { assert.Equal(t, "acc-1", accountID) @@ -425,10 +425,10 @@ func TestSetup_PAT_Success(t *testing.T) { email string }{} accountMgr := &mock_server.MockAccountManager{ - GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) { + GetAccountIDFromUserAuthFunc: func(_ context.Context, userAuth auth.UserAuth) (string, string, error) { gotAccountArgs.userID = userAuth.UserId gotAccountArgs.email = userAuth.Email - return "acc-1", nil + return "acc-1", "u1", nil }, CreatePATFunc: func(_ context.Context, accountID, initiator, target, name string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) { assert.Equal(t, "acc-1", accountID) @@ -479,8 +479,8 @@ func TestSetup_PAT_AccountCreationFails_Rollback(t *testing.T) { }, } accountMgr := &mock_server.MockAccountManager{ - GetAccountIDByUserIdFunc: func(_ context.Context, _ auth.UserAuth) (string, error) { - return "", errors.New("db down") + GetAccountIDFromUserAuthFunc: func(_ context.Context, _ auth.UserAuth) (string, string, error) { + return "", "", errors.New("db down") }, GetStoreFunc: func() nbstore.Store { return accountStore @@ -505,7 +505,7 @@ func TestSetup_PAT_CreatePATFails_Rollback(t *testing.T) { ctrl := gomock.NewController(t) accountStore := nbstore.NewMockStore(ctrl) - account := &types.Account{Id: "acc-1"} + account := &types.Account{Id: "acc-1", CreatedBy: "owner-id"} accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(account, nil) accountStore.EXPECT().DeleteAccount(gomock.Any(), account).Return(nil) @@ -521,8 +521,8 @@ func TestSetup_PAT_CreatePATFails_Rollback(t *testing.T) { }, } accountMgr := &mock_server.MockAccountManager{ - GetAccountIDByUserIdFunc: func(_ context.Context, _ auth.UserAuth) (string, error) { - return "acc-1", nil + GetAccountIDFromUserAuthFunc: func(_ context.Context, _ auth.UserAuth) (string, string, error) { + return "acc-1", "u1", nil }, CreatePATFunc: func(_ context.Context, _, _, _, _ string, _ int) (*types.PersonalAccessTokenGenerated, error) { return nil, status.Errorf(status.Internal, "token store unavailable") diff --git a/management/server/instance/setup_service.go b/management/server/instance/setup_service.go index 92a4923be..1ef3e9736 100644 --- a/management/server/instance/setup_service.go +++ b/management/server/instance/setup_service.go @@ -10,6 +10,7 @@ import ( "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/management/status" ) @@ -111,7 +112,9 @@ func (m *SetupService) SetupOwner(ctx context.Context, email, password, name str Name: userData.Name, } - accountID, err := m.accountManager.GetAccountIDByUserID(ctx, userAuth) + // Resolve through the same path as a first login so single account mode + // stamps the configured domain, private category and primary flag. + accountID, _, err := m.accountManager.GetAccountIDFromUserAuth(ctx, userAuth) if err != nil { err = fmt.Errorf("create account for setup user: %w", err) if rollbackErr := m.rollbackSetup(ctx, userData.ID, "account provisioning failed", err, ""); rollbackErr != nil { @@ -145,7 +148,7 @@ func (m *SetupService) rollbackSetup(ctx context.Context, userID, reason string, } if accountID != "" { - if err := m.rollbackSetupAccount(ctx, accountID); err != nil { + if err := m.rollbackSetupAccount(ctx, accountID, userID); err != nil { rollbackErr := fmt.Errorf("roll back setup account %s: %w", accountID, err) log.WithContext(ctx).Errorf("failed to roll back setup account %s for user %s after %s: original error: %v, rollback error: %v", accountID, userID, reason, origErr, rollbackErr) return rollbackErr @@ -186,8 +189,9 @@ func (m *SetupService) lookupSetupAccountIDForRollback(ctx context.Context, user // rollbackSetupAccount removes only the setup-created account data from the // store. It intentionally avoids accountManager.DeleteAccount because the normal // account deletion path also deletes users from the IdP; embedded IdP cleanup is -// owned by instanceManager.RollbackSetup. -func (m *SetupService) rollbackSetupAccount(ctx context.Context, accountID string) error { +// owned by instanceManager.RollbackSetup. It keeps an account another user +// created or joined, which a concurrent first login can produce. +func (m *SetupService) rollbackSetupAccount(ctx context.Context, accountID, userID string) error { if m.accountManager == nil { return fmt.Errorf("account manager is required to roll back setup account") } @@ -205,6 +209,11 @@ func (m *SetupService) rollbackSetupAccount(ctx context.Context, accountID strin return fmt.Errorf("get setup account for rollback: %w", err) } + if !isSetupOnlyAccount(account, userID) { + log.WithContext(ctx).Warnf("keeping account %s on setup rollback: shared with another user", accountID) + return nil + } + if err := accountStore.DeleteAccount(ctx, account); err != nil { if isNotFoundError(err) { return nil @@ -214,3 +223,15 @@ func (m *SetupService) rollbackSetupAccount(ctx context.Context, accountID strin return nil } + +func isSetupOnlyAccount(account *types.Account, userID string) bool { + if account.CreatedBy != userID { + return false + } + for id := range account.Users { + if id != userID { + return false + } + } + return true +} diff --git a/management/server/instance/setup_service_test.go b/management/server/instance/setup_service_test.go index af3a91b75..4fc210f5b 100644 --- a/management/server/instance/setup_service_test.go +++ b/management/server/instance/setup_service_test.go @@ -5,9 +5,9 @@ import ( "errors" "testing" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/mock_server" @@ -87,9 +87,9 @@ func TestSetupOwner_PATFeatureEnabled_MissingExpireDefaultsToOneDay(t *testing.T }, }, &mock_server.MockAccountManager{ - GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) { + GetAccountIDFromUserAuthFunc: func(_ context.Context, userAuth auth.UserAuth) (string, string, error) { assert.Equal(t, "owner-id", userAuth.UserId) - return "acc-1", nil + return "acc-1", "owner-id", nil }, CreatePATFunc: func(_ context.Context, accountID, initiatorUserID, targetUserID, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) { assert.Equal(t, "acc-1", accountID) @@ -147,7 +147,7 @@ func TestSetupOwner_AccountProvisioningFails_RollsBackSideEffectAccountAndUser(t ctrl := gomock.NewController(t) accountStore := nbstore.NewMockStore(ctrl) - account := &types.Account{Id: "acc-1"} + account := &types.Account{Id: "acc-1", CreatedBy: "owner-id"} accountStore.EXPECT().GetAccountIDByUserID(gomock.Any(), nbstore.LockingStrengthNone, "owner-id").Return("acc-1", nil) accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(account, nil) accountStore.EXPECT().DeleteAccount(gomock.Any(), account).Return(nil) @@ -163,9 +163,9 @@ func TestSetupOwner_AccountProvisioningFails_RollsBackSideEffectAccountAndUser(t }, }, &mock_server.MockAccountManager{ - GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) { + GetAccountIDFromUserAuthFunc: func(_ context.Context, userAuth auth.UserAuth) (string, string, error) { assert.Equal(t, "owner-id", userAuth.UserId) - return "", errors.New("metadata update failed") + return "", "", errors.New("metadata update failed") }, GetStoreFunc: func() nbstore.Store { return accountStore @@ -190,7 +190,7 @@ func TestSetupOwner_CreatePATFails_RollsBackSetupAccountAndUser(t *testing.T) { ctrl := gomock.NewController(t) accountStore := nbstore.NewMockStore(ctrl) - account := &types.Account{Id: "acc-1"} + account := &types.Account{Id: "acc-1", CreatedBy: "owner-id"} accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(account, nil) accountStore.EXPECT().DeleteAccount(gomock.Any(), account).Return(nil) @@ -204,9 +204,9 @@ func TestSetupOwner_CreatePATFails_RollsBackSetupAccountAndUser(t *testing.T) { }, }, &mock_server.MockAccountManager{ - GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) { + GetAccountIDFromUserAuthFunc: func(_ context.Context, userAuth auth.UserAuth) (string, string, error) { assert.Equal(t, "owner-id", userAuth.UserId) - return "acc-1", nil + return "acc-1", "owner-id", nil }, CreatePATFunc: func(_ context.Context, accountID, initiatorUserID, targetUserID, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) { assert.Equal(t, "acc-1", accountID) @@ -233,6 +233,59 @@ func TestSetupOwner_CreatePATFails_RollsBackSetupAccountAndUser(t *testing.T) { assert.Equal(t, 1, rollbackCalls) } +// A concurrent first login can make the setup account shared: either a JIT user +// created the primary account first and the owner joined it, or a JIT user joined +// the setup account. A PAT failure must not delete an account other users rely on. +// DeleteAccount is not expected, so the mock store fails the test if it is called. +func TestSetupOwner_CreatePATFails_KeepsSharedAccount(t *testing.T) { + tests := map[string]*types.Account{ + "created by another user": {Id: "acc-1", CreatedBy: "jit-user"}, + "another user joined": {Id: "acc-1", CreatedBy: "owner-id", Users: map[string]*types.User{ + "owner-id": {Id: "owner-id"}, + "jit-user": {Id: "jit-user"}, + }}, + } + + for name, account := range tests { + t.Run(name, func(t *testing.T) { + t.Setenv(SetupPATEnabledEnvKey, "true") + + ctrl := gomock.NewController(t) + accountStore := nbstore.NewMockStore(ctrl) + accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(account, nil) + + rollbackCalls := 0 + setupManager := NewSetupService( + &setupInstanceManagerMock{ + rollbackSetupFn: func(_ context.Context, _ string) error { + rollbackCalls++ + return nil + }, + }, + &mock_server.MockAccountManager{ + GetAccountIDFromUserAuthFunc: func(_ context.Context, _ auth.UserAuth) (string, string, error) { + return "acc-1", "owner-id", nil + }, + CreatePATFunc: func(_ context.Context, _, _, _, _ string, _ int) (*types.PersonalAccessTokenGenerated, error) { + return nil, status.Errorf(status.PermissionDenied, "user is blocked") + }, + GetStoreFunc: func() nbstore.Store { + return accountStore + }, + }, + ) + + _, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{ + CreatePAT: true, + PATExpireInDays: intPtr(30), + }) + + require.Error(t, err) + assert.Equal(t, 1, rollbackCalls, "setup user should still be rolled back") + }) + } +} + func TestSetupOwner_CreatePATFails_AccountAlreadyGoneStillRollsBackUser(t *testing.T) { t.Setenv(SetupPATEnabledEnvKey, "true") @@ -251,8 +304,8 @@ func TestSetupOwner_CreatePATFails_AccountAlreadyGoneStillRollsBackUser(t *testi }, }, &mock_server.MockAccountManager{ - GetAccountIDByUserIdFunc: func(_ context.Context, _ auth.UserAuth) (string, error) { - return "acc-1", nil + GetAccountIDFromUserAuthFunc: func(_ context.Context, _ auth.UserAuth) (string, string, error) { + return "acc-1", "owner-id", nil }, CreatePATFunc: func(_ context.Context, _, _, _, _ string, _ int) (*types.PersonalAccessTokenGenerated, error) { return nil, errors.New("token failure") @@ -280,7 +333,7 @@ func TestSetupOwner_CreatePATFails_AccountRollbackFailureStopsBeforeUserRollback ctrl := gomock.NewController(t) accountStore := nbstore.NewMockStore(ctrl) - account := &types.Account{Id: "acc-1"} + account := &types.Account{Id: "acc-1", CreatedBy: "owner-id"} accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(account, nil) accountStore.EXPECT().DeleteAccount(gomock.Any(), account).Return(errors.New("delete failed")) @@ -293,8 +346,8 @@ func TestSetupOwner_CreatePATFails_AccountRollbackFailureStopsBeforeUserRollback }, }, &mock_server.MockAccountManager{ - GetAccountIDByUserIdFunc: func(_ context.Context, _ auth.UserAuth) (string, error) { - return "acc-1", nil + GetAccountIDFromUserAuthFunc: func(_ context.Context, _ auth.UserAuth) (string, string, error) { + return "acc-1", "owner-id", nil }, CreatePATFunc: func(_ context.Context, _, _, _, _ string, _ int) (*types.PersonalAccessTokenGenerated, error) { return nil, errors.New("token failure") From b03d1c7d0794dec3d96072c98e01811535ace19a Mon Sep 17 00:00:00 2001 From: Bruno Mercier Costa <94469565+emrcbrn@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:37:48 +0200 Subject: [PATCH 192/209] [client] Stop the service and UI before the NSIS installer extracts files (#8255) --- client/installer.nsis | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/installer.nsis b/client/installer.nsis index 304d38864..732932ac1 100644 --- a/client/installer.nsis +++ b/client/installer.nsis @@ -198,6 +198,15 @@ FunctionEnd ###################################################################### Section -MainProgram ${INSTALL_TYPE} + ; The previous uninstaller stops the service and the UI only when its uninstall key + ; is found and endpoint protection lets the unsigned uninstaller run. Extraction + ; fails on any exe that is still running. + DetailPrint "Stopping Netbird service..." + ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service stop' + DetailPrint "Terminating ${UI_APP_NAME} process..." + ExecWait '"$SYSDIR\taskkill.exe" /im ${UI_APP_EXE}.exe /f' + ; Both return before the processes release their image files. + Sleep 3000 # SetOverwrite ifnewer SetOutPath "$INSTDIR" !ifndef ARCH From f886f4b06ce00f7ab14afd7ce53e41afd4bfe108 Mon Sep 17 00:00:00 2001 From: Theodor Midtlien Date: Thu, 22 Oct 2026 09:47:39 +0200 Subject: [PATCH 193/209] Bump wasm build size to 70MB (#8281) --- .github/workflows/wasm-build-validation.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml index 5f21472e5..e821e6234 100644 --- a/.github/workflows/wasm-build-validation.yml +++ b/.github/workflows/wasm-build-validation.yml @@ -66,7 +66,7 @@ jobs: echo "Size: ${SIZE} bytes (${SIZE_MB} MB)" - if [ ${SIZE} -gt 62914560 ]; then - echo "Wasm binary size (${SIZE_MB}MB) exceeds 60MB limit!" + if [ ${SIZE} -gt 73400320 ]; then + echo "Wasm binary size (${SIZE_MB}MB) exceeds 70MB limit!" exit 1 fi From abf50e3ad4bde92c0cb8136e536bd8e622011cc1 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:39:22 +0900 Subject: [PATCH 194/209] [client] Remove the replace directives from the embedded client (#8280) --- client/embed/embed.go | 9 +++-- client/firewall/iface.go | 5 ++- client/firewall/uspfilter/filter.go | 4 +-- .../firewall/uspfilter/filter_filter_test.go | 10 +++--- .../uspfilter/filter_routeacl_test.go | 28 +++++++-------- client/firewall/uspfilter/filter_test.go | 6 ++-- .../firewall/uspfilter/forwarder/endpoint.go | 4 +-- .../firewall/uspfilter/forwarder/forwarder.go | 4 +-- client/iface/bind/control.go | 5 ++- client/iface/bind/endpoint.go | 4 +-- client/iface/bind/ice_bind.go | 30 ++++++++-------- client/iface/bind/ice_bind_close_test.go | 11 +++--- client/iface/bind/ice_bind_test.go | 9 +++-- client/iface/bind/relay_bind.go | 14 ++++---- client/iface/bind/stun_filter_test.go | 5 +-- client/iface/configurer/uapi.go | 6 ++-- client/iface/configurer/uapi_windows.go | 4 +-- client/iface/configurer/usp.go | 35 +++++++++---------- .../iface/configurer/usp_allowedips_test.go | 5 ++- client/iface/configurer/usp_test.go | 14 ++++---- client/iface/device.go | 9 ++--- client/iface/device/device_android.go | 16 ++++----- client/iface/device/device_darwin.go | 16 ++++----- client/iface/device/device_filter.go | 15 ++++---- client/iface/device/device_ios.go | 16 ++++----- client/iface/device/device_kernel_unix.go | 7 ++-- client/iface/device/device_netstack.go | 18 +++++----- client/iface/device/device_usp_unix.go | 16 ++++----- client/iface/device/device_windows.go | 20 +++++------ client/iface/device/renewable_tun.go | 25 ++++++------- client/iface/device/wg_log.go | 6 ++-- client/iface/device_android.go | 9 ++--- client/iface/iface.go | 8 ++--- client/iface/iface_close_test.go | 8 ++--- client/iface/mocks/tun.go | 4 +-- client/iface/netstack/dialer.go | 7 ++-- client/iface/netstack/tun.go | 10 +++--- client/iface/udpmux/mux.go | 2 +- client/iface/wgbackend/conn.go | 27 ++++++++++++++ client/iface/wgbackend/device.go | 24 +++++++++++++ client/iface/wgbackend/doc.go | 5 +++ client/iface/wgbackend/ipc_unix.go | 10 ++++++ client/iface/wgbackend/ipc_windows.go | 5 +++ client/iface/wgbackend/tun.go | 19 ++++++++++ client/iface/wgbackend/tun_linux.go | 5 +++ client/iface/wgbackend/tun_unix.go | 10 ++++++ client/iface/wgbackend/tun_windows.go | 7 ++++ client/internal/acl/mocks/iface_mapper.go | 6 ++-- client/internal/dns/response_writer.go | 5 +-- client/internal/dns/server_test.go | 4 +-- client/internal/dns/service_memory.go | 4 +-- client/internal/dns/tcpstack.go | 9 ++--- client/internal/dns/upstream.go | 6 ++-- client/internal/dns/upstream_general.go | 4 +-- client/internal/dns/upstream_test.go | 4 +-- client/internal/dns/wgiface.go | 5 ++- client/internal/dns/wgiface_windows.go | 5 ++- client/internal/dnsfwd/forwarder.go | 8 ++--- client/internal/dnsfwd/manager.go | 4 +-- client/internal/engine.go | 9 +++-- client/internal/engine_test.go | 11 +++--- client/internal/iface_common.go | 7 ++-- client/internal/peer/conn.go | 2 +- client/internal/peer/guard/ice_monitor.go | 2 +- client/internal/peer/ice/agent.go | 2 +- client/internal/peer/ice/config.go | 2 +- client/internal/peer/signaler.go | 2 +- client/internal/peer/worker_ice.go | 2 +- client/internal/peer/worker_ice_test.go | 2 +- .../routemanager/iface/iface_common.go | 5 ++- .../internal/routemanager/reconcile_test.go | 4 +-- client/ssh/server/server.go | 6 ++-- combined/cmd/admin.go | 2 +- go.mod | 26 +++++--------- go.sum | 27 +++++++------- idp/dex/config.go | 6 ++-- idp/dex/connector.go | 2 +- idp/dex/connector_test.go | 4 +-- idp/dex/provider.go | 8 ++--- idp/dex/provider_test.go | 6 ++-- idp/dex/sqlite_cgo.go | 2 +- idp/dex/sqlite_nocgo.go | 2 +- idp/sdk/sdk.go | 2 +- management/cmd/admin.go | 2 +- management/cmd/admin/admin.go | 2 +- management/cmd/admin/admin_test.go | 4 +-- management/server/identity_provider.go | 2 +- management/server/idp/dex.go | 2 +- management/server/idp/embedded.go | 2 +- management/server/instance/manager.go | 2 +- management/server/instance/manager_test.go | 2 +- 91 files changed, 418 insertions(+), 343 deletions(-) create mode 100644 client/iface/wgbackend/conn.go create mode 100644 client/iface/wgbackend/device.go create mode 100644 client/iface/wgbackend/doc.go create mode 100644 client/iface/wgbackend/ipc_unix.go create mode 100644 client/iface/wgbackend/ipc_windows.go create mode 100644 client/iface/wgbackend/tun.go create mode 100644 client/iface/wgbackend/tun_linux.go create mode 100644 client/iface/wgbackend/tun_unix.go create mode 100644 client/iface/wgbackend/tun_windows.go diff --git a/client/embed/embed.go b/client/embed/embed.go index 208e71817..5905c1cf6 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -12,11 +12,10 @@ import ( "sync" "github.com/sirupsen/logrus" - wgdevice "golang.zx2c4.com/wireguard/device" - wgnetstack "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/netstack" + "github.com/netbirdio/netbird/client/iface/wgbackend" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/auth" "github.com/netbirdio/netbird/client/internal/peer" @@ -256,10 +255,10 @@ func New(opts Options) (*Client, error) { } if opts.Performance.PreallocatedBuffersPerPool != nil { - wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool) + wgbackend.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool) } if opts.Performance.MaxBatchSize != nil { - wgdevice.SetMaxBatchSizeOverride(*opts.Performance.MaxBatchSize) + wgbackend.SetMaxBatchSizeOverride(*opts.Performance.MaxBatchSize) } return &Client{ @@ -651,7 +650,7 @@ func (c *Client) getEngine() (*internal.Engine, error) { return engine, nil } -func (c *Client) getNet() (*wgnetstack.Net, netip.Addr, error) { +func (c *Client) getNet() (*wgbackend.NetstackNet, netip.Addr, error) { engine, err := c.getEngine() if err != nil { return nil, netip.Addr{}, err diff --git a/client/firewall/iface.go b/client/firewall/iface.go index 491f03269..dc172bba7 100644 --- a/client/firewall/iface.go +++ b/client/firewall/iface.go @@ -1,10 +1,9 @@ package firewall import ( - wgdevice "golang.zx2c4.com/wireguard/device" - "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) // EnvForceUserspaceFirewall forces the use of the userspace packet filter even when @@ -20,5 +19,5 @@ type IFaceMapper interface { IsUserspaceBind() bool SetFilter(device.PacketFilter) error GetDevice() *device.FilteredDevice - GetWGDevice() *wgdevice.Device + GetWGDevice() *wgbackend.Device } diff --git a/client/firewall/uspfilter/filter.go b/client/firewall/uspfilter/filter.go index 0c73400f1..56441a639 100644 --- a/client/firewall/uspfilter/filter.go +++ b/client/firewall/uspfilter/filter.go @@ -19,7 +19,6 @@ import ( "github.com/google/uuid" "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" - wgdevice "golang.zx2c4.com/wireguard/device" nberrors "github.com/netbirdio/netbird/client/errors" "github.com/netbirdio/netbird/client/firewall/firewalld" @@ -31,6 +30,7 @@ import ( "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" nbid "github.com/netbirdio/netbird/client/internal/acl/id" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" "github.com/netbirdio/netbird/client/internal/statemanager" @@ -121,7 +121,7 @@ type Iface interface { Name() string Address() wgaddr.Address SetFilter(device.PacketFilter) error - GetWGDevice() *wgdevice.Device + GetWGDevice() *wgbackend.Device } // InterfaceAllower opens the NetBird interface in the host firewall so it diff --git a/client/firewall/uspfilter/filter_filter_test.go b/client/firewall/uspfilter/filter_filter_test.go index 57b59c9bc..d6e9f4f72 100644 --- a/client/firewall/uspfilter/filter_filter_test.go +++ b/client/firewall/uspfilter/filter_filter_test.go @@ -5,17 +5,17 @@ import ( "net/netip" "testing" - "go.uber.org/mock/gomock" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/stretchr/testify/require" - wgdevice "golang.zx2c4.com/wireguard/device" + "go.uber.org/mock/gomock" fw "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/mocks" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" "github.com/netbirdio/netbird/shared/management/domain" ) @@ -791,10 +791,10 @@ func setupRoutedManager(tb testing.TB, network string) *Manager { } }, GetDeviceFunc: func() *device.FilteredDevice { - return &device.FilteredDevice{Device: dev} + return &device.FilteredDevice{TUN: dev} }, - GetWGDeviceFunc: func() *wgdevice.Device { - return &wgdevice.Device{} + GetWGDeviceFunc: func() *wgbackend.Device { + return &wgbackend.Device{} }, } diff --git a/client/firewall/uspfilter/filter_routeacl_test.go b/client/firewall/uspfilter/filter_routeacl_test.go index 2a0980e83..8c27aa4db 100644 --- a/client/firewall/uspfilter/filter_routeacl_test.go +++ b/client/firewall/uspfilter/filter_routeacl_test.go @@ -4,17 +4,17 @@ import ( "net/netip" "testing" - "go.uber.org/mock/gomock" "github.com/google/gopacket/layers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - wgdevice "golang.zx2c4.com/wireguard/device" + "go.uber.org/mock/gomock" fw "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/mocks" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) // TestAddRouteFilteringReturnsExistingRule verifies that adding the same route @@ -180,10 +180,10 @@ func TestBlockInvalidRoutedDualStack(t *testing.T) { } }, GetDeviceFunc: func() *device.FilteredDevice { - return &device.FilteredDevice{Device: dev} + return &device.FilteredDevice{TUN: dev} }, - GetWGDeviceFunc: func() *wgdevice.Device { - return &wgdevice.Device{} + GetWGDeviceFunc: func() *wgbackend.Device { + return &wgbackend.Device{} }, } @@ -228,10 +228,10 @@ func TestBlockInvalidRoutedIdempotent(t *testing.T) { } }, GetDeviceFunc: func() *device.FilteredDevice { - return &device.FilteredDevice{Device: dev} + return &device.FilteredDevice{TUN: dev} }, - GetWGDeviceFunc: func() *wgdevice.Device { - return &wgdevice.Device{} + GetWGDeviceFunc: func() *wgbackend.Device { + return &wgbackend.Device{} }, } @@ -291,10 +291,10 @@ func TestBlockRuleNotAccumulatedOnRepeatedEnableRouting(t *testing.T) { } }, GetDeviceFunc: func() *device.FilteredDevice { - return &device.FilteredDevice{Device: dev} + return &device.FilteredDevice{TUN: dev} }, - GetWGDeviceFunc: func() *wgdevice.Device { - return &wgdevice.Device{} + GetWGDeviceFunc: func() *wgbackend.Device { + return &wgbackend.Device{} }, } @@ -410,10 +410,10 @@ func setupTestManager(t *testing.T) *Manager { } }, GetDeviceFunc: func() *device.FilteredDevice { - return &device.FilteredDevice{Device: dev} + return &device.FilteredDevice{TUN: dev} }, - GetWGDeviceFunc: func() *wgdevice.Device { - return &wgdevice.Device{} + GetWGDeviceFunc: func() *wgbackend.Device { + return &wgbackend.Device{} }, } diff --git a/client/firewall/uspfilter/filter_test.go b/client/firewall/uspfilter/filter_test.go index 280ce9311..973b22537 100644 --- a/client/firewall/uspfilter/filter_test.go +++ b/client/firewall/uspfilter/filter_test.go @@ -14,7 +14,6 @@ import ( "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - wgdevice "golang.zx2c4.com/wireguard/device" fw "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/firewall/uspfilter/conntrack" @@ -22,6 +21,7 @@ import ( nbiface "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" "github.com/netbirdio/netbird/client/internal/netflow" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" "github.com/netbirdio/netbird/shared/management/domain" @@ -34,7 +34,7 @@ type IFaceMock struct { NameFunc func() string SetFilterFunc func(device.PacketFilter) error AddressFunc func() wgaddr.Address - GetWGDeviceFunc func() *wgdevice.Device + GetWGDeviceFunc func() *wgbackend.Device GetDeviceFunc func() *device.FilteredDevice } @@ -45,7 +45,7 @@ func (i *IFaceMock) Name() string { return i.NameFunc() } -func (i *IFaceMock) GetWGDevice() *wgdevice.Device { +func (i *IFaceMock) GetWGDevice() *wgbackend.Device { if i.GetWGDeviceFunc == nil { return nil } diff --git a/client/firewall/uspfilter/forwarder/endpoint.go b/client/firewall/uspfilter/forwarder/endpoint.go index fab776f2a..d7e017203 100644 --- a/client/firewall/uspfilter/forwarder/endpoint.go +++ b/client/firewall/uspfilter/forwarder/endpoint.go @@ -5,12 +5,12 @@ import ( "strconv" "sync/atomic" - wgdevice "golang.zx2c4.com/wireguard/device" "gvisor.dev/gvisor/pkg/tcpip" "gvisor.dev/gvisor/pkg/tcpip/header" "gvisor.dev/gvisor/pkg/tcpip/stack" nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) // PacketCapture captures raw packets for debugging. Implementations must be @@ -23,7 +23,7 @@ type PacketCapture interface { type endpoint struct { logger *nblog.Logger dispatcher stack.NetworkDispatcher - device *wgdevice.Device + device *wgbackend.Device mtu atomic.Uint32 capture atomic.Pointer[PacketCapture] } diff --git a/client/firewall/uspfilter/forwarder/forwarder.go b/client/firewall/uspfilter/forwarder/forwarder.go index 7308b38bd..b63686223 100644 --- a/client/firewall/uspfilter/forwarder/forwarder.go +++ b/client/firewall/uspfilter/forwarder/forwarder.go @@ -12,7 +12,6 @@ import ( "time" log "github.com/sirupsen/logrus" - wgdevice "golang.zx2c4.com/wireguard/device" "gvisor.dev/gvisor/pkg/buffer" "gvisor.dev/gvisor/pkg/tcpip" "gvisor.dev/gvisor/pkg/tcpip/header" @@ -26,6 +25,7 @@ import ( "github.com/netbirdio/netbird/client/firewall/uspfilter/conntrack" nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" ) @@ -43,7 +43,7 @@ const ( // IFace provides the WireGuard device and overlay addresses the forwarder needs. type IFace interface { - GetWGDevice() *wgdevice.Device + GetWGDevice() *wgbackend.Device Address() wgaddr.Address } diff --git a/client/iface/bind/control.go b/client/iface/bind/control.go index 32b07c330..da495c186 100644 --- a/client/iface/bind/control.go +++ b/client/iface/bind/control.go @@ -1,8 +1,7 @@ package bind import ( - wireguard "golang.zx2c4.com/wireguard/conn" - + "github.com/netbirdio/netbird/client/iface/wgbackend" nbnet "github.com/netbirdio/netbird/client/net" ) @@ -10,6 +9,6 @@ import ( func init() { listener := nbnet.NewListener() if listener.ListenConfig.Control != nil { - *wireguard.ControlFns = append(*wireguard.ControlFns, listener.ListenConfig.Control) + *wgbackend.ControlFns = append(*wgbackend.ControlFns, listener.ListenConfig.Control) } } diff --git a/client/iface/bind/endpoint.go b/client/iface/bind/endpoint.go index caa92f05d..31ffe6da9 100644 --- a/client/iface/bind/endpoint.go +++ b/client/iface/bind/endpoint.go @@ -3,10 +3,10 @@ package bind import ( "net" - wgConn "golang.zx2c4.com/wireguard/conn" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) -type Endpoint = wgConn.StdNetEndpoint +type Endpoint = wgbackend.StdNetEndpoint func EndpointToUDPAddr(e Endpoint) *net.UDPAddr { return &net.UDPAddr{ diff --git a/client/iface/bind/ice_bind.go b/client/iface/bind/ice_bind.go index cd1ed12f1..28f8cec1e 100644 --- a/client/iface/bind/ice_bind.go +++ b/client/iface/bind/ice_bind.go @@ -15,10 +15,10 @@ import ( "github.com/pion/transport/v3" log "github.com/sirupsen/logrus" "golang.org/x/net/ipv6" - wgConn "golang.zx2c4.com/wireguard/conn" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" nbnet "github.com/netbirdio/netbird/client/net" ) @@ -36,7 +36,7 @@ type receiverCreator struct { iceBind *ICEBind } -func (rc receiverCreator) CreateReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxOffload bool, msgPool *sync.Pool) wgConn.ReceiveFunc { +func (rc receiverCreator) CreateReceiverFn(pc wgbackend.BatchReader, conn *net.UDPConn, rxOffload bool, msgPool *sync.Pool) wgbackend.ReceiveFunc { return rc.iceBind.createReceiverFn(pc, conn, rxOffload, msgPool) } @@ -48,7 +48,7 @@ func (rc receiverCreator) CreateReceiverFn(pc wgConn.BatchReader, conn *net.UDPC // without port, in the format of 127.1.x.x where x.x is the last two octets of the peer address. We try to avoid to // use the port because in the Send function the wgConn.Endpoint the port info is not exported. type ICEBind struct { - *wgConn.StdNetBind + *wgbackend.StdNetBind transportNet transport.Net address wgaddr.Address @@ -74,7 +74,7 @@ type ICEBind struct { } func NewICEBind(transportNet transport.Net, address wgaddr.Address, mtu uint16) *ICEBind { - b, _ := wgConn.NewStdNetBind().(*wgConn.StdNetBind) + b, _ := wgbackend.NewStdNetBind().(*wgbackend.StdNetBind) ib := &ICEBind{ StdNetBind: b, transportNet: transportNet, @@ -90,11 +90,11 @@ func NewICEBind(transportNet transport.Net, address wgaddr.Address, mtu uint16) rc := receiverCreator{ ib, } - ib.StdNetBind = wgConn.NewStdNetBindWithReceiverCreator(rc) + ib.StdNetBind = wgbackend.NewStdNetBindWithReceiverCreator(rc) return ib } -func (s *ICEBind) Open(uport uint16) ([]wgConn.ReceiveFunc, uint16, error) { +func (s *ICEBind) Open(uport uint16) ([]wgbackend.ReceiveFunc, uint16, error) { s.closedChanMu.Lock() defer s.closedChanMu.Unlock() @@ -190,7 +190,7 @@ func (b *ICEBind) ReceiveFromEndpoint(ctx context.Context, ep *Endpoint, buf []b } } -func (b *ICEBind) Send(bufs [][]byte, ep wgConn.Endpoint) error { +func (b *ICEBind) Send(bufs [][]byte, ep wgbackend.Endpoint) error { b.endpointsMu.Lock() conn, ok := b.endpoints[ep.DstIP()] b.endpointsMu.Unlock() @@ -206,7 +206,7 @@ func (b *ICEBind) Send(bufs [][]byte, ep wgConn.Endpoint) error { return nil } -func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxOffload bool, msgsPool *sync.Pool) wgConn.ReceiveFunc { +func (s *ICEBind) createReceiverFn(pc wgbackend.BatchReader, conn *net.UDPConn, rxOffload bool, msgsPool *sync.Pool) wgbackend.ReceiveFunc { s.muUDPMux.Lock() defer s.muUDPMux.Unlock() @@ -218,7 +218,7 @@ func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxO } s.createOrUpdateMux() - return func(bufs [][]byte, sizes []int, eps []wgConn.Endpoint) (n int, err error) { + return func(bufs [][]byte, sizes []int, eps []wgbackend.Endpoint) (n int, err error) { msgs := getMessages(msgsPool) for i := range bufs { (*msgs)[i].Buffers[0] = bufs[i] @@ -229,13 +229,13 @@ func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxO var numMsgs int if runtime.GOOS == "linux" || runtime.GOOS == "android" { if rxOffload { - readAt := len(*msgs) - (wgConn.IdealBatchSize / wgConn.UdpSegmentMaxDatagrams) + readAt := len(*msgs) - (wgbackend.IdealBatchSize / wgbackend.UdpSegmentMaxDatagrams) //nolint:staticcheck _, err = pc.ReadBatch((*msgs)[readAt:], 0) if err != nil { return 0, err } - numMsgs, err = wgConn.SplitCoalescedMessages(*msgs, readAt, wgConn.GetGSOSize) + numMsgs, err = wgbackend.SplitCoalescedMessages(*msgs, readAt, wgbackend.GetGSOSize) if err != nil { return 0, err } @@ -278,8 +278,8 @@ func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxO s.activityRecorder.record(addrPort) } - ep := &wgConn.StdNetEndpoint{AddrPort: addrPort} // TODO: remove allocation - wgConn.GetSrcFromControl(msg.OOB[:msg.NN], ep) + ep := &wgbackend.StdNetEndpoint{AddrPort: addrPort} // TODO: remove allocation + wgbackend.GetSrcFromControl(msg.OOB[:msg.NN], ep) eps[i] = ep } return numMsgs, nil @@ -363,7 +363,7 @@ func (s *ICEBind) parseSTUNMessage(raw []byte) (*stun.Message, error) { // receiveRelayed is a receive function that is used to receive packets from the relayed connection and forward to the // WireGuard. Critical part is do not block if the Closed() has been called. -func (c *ICEBind) receiveRelayed(buffs [][]byte, sizes []int, eps []wgConn.Endpoint) (int, error) { +func (c *ICEBind) receiveRelayed(buffs [][]byte, sizes []int, eps []wgbackend.Endpoint) (int, error) { closedChan := c.currentClosedChan() select { @@ -375,7 +375,7 @@ func (c *ICEBind) receiveRelayed(buffs [][]byte, sizes []int, eps []wgConn.Endpo } copy(buffs[0], msg.Buffer) sizes[0] = len(msg.Buffer) - eps[0] = wgConn.Endpoint(msg.Endpoint) + eps[0] = wgbackend.Endpoint(msg.Endpoint) if isTransportPkg(buffs, sizes[0]) { if ep, ok := eps[0].(*Endpoint); ok { diff --git a/client/iface/bind/ice_bind_close_test.go b/client/iface/bind/ice_bind_close_test.go index 829d27663..415ca930f 100644 --- a/client/iface/bind/ice_bind_close_test.go +++ b/client/iface/bind/ice_bind_close_test.go @@ -6,7 +6,8 @@ import ( "time" "github.com/stretchr/testify/require" - wgConn "golang.zx2c4.com/wireguard/conn" + + "github.com/netbirdio/netbird/client/iface/wgbackend" ) // startReceivers runs every receive function the way wireguard-go's device @@ -14,7 +15,7 @@ import ( // calling Bind.Close, closeBindLocked waits on exactly that WaitGroup while // holding device.net, so a receive function that never returns wedges the // device and every goroutine that needs the same lock. -func startReceivers(fns []wgConn.ReceiveFunc) *sync.WaitGroup { +func startReceivers(fns []wgbackend.ReceiveFunc) *sync.WaitGroup { wg, _ := startReceiversEntered(fns) return wg } @@ -23,18 +24,18 @@ func startReceivers(fns []wgConn.ReceiveFunc) *sync.WaitGroup { // function has been called at least once. A receive function that has been // entered is either inside its blocking receive or about to be, which is a // stronger signal to synchronise on than a bare sleep. -func startReceiversEntered(fns []wgConn.ReceiveFunc) (*sync.WaitGroup, <-chan struct{}) { +func startReceiversEntered(fns []wgbackend.ReceiveFunc) (*sync.WaitGroup, <-chan struct{}) { var wg sync.WaitGroup var entered sync.WaitGroup wg.Add(len(fns)) entered.Add(len(fns)) for i := range fns { - go func(fn wgConn.ReceiveFunc) { + go func(fn wgbackend.ReceiveFunc) { defer wg.Done() buffs := [][]byte{make([]byte, 1500)} sizes := make([]int, 1) - eps := make([]wgConn.Endpoint, 1) + eps := make([]wgbackend.Endpoint, 1) first := true for { if first { diff --git a/client/iface/bind/ice_bind_test.go b/client/iface/bind/ice_bind_test.go index 8dd82d7df..25b3337e3 100644 --- a/client/iface/bind/ice_bind_test.go +++ b/client/iface/bind/ice_bind_test.go @@ -16,10 +16,9 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" - "golang.zx2c4.com/wireguard/device" - "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) func TestICEBind_CreatesReceiverForBothIPv4AndIPv6(t *testing.T) { @@ -370,16 +369,16 @@ func TestICEBind_OpenDoesNotDiscoverInterfaces(t *testing.T) { } func TestICEBind_DeviceUpDoesNotWaitOnInterfaceDiscovery(t *testing.T) { - tunDev, _, err := netstack.CreateNetTUN([]netip.Addr{netip.MustParseAddr("100.64.0.1")}, nil, 1280) + tunDev, _, err := wgbackend.CreateNetTUN([]netip.Addr{netip.MustParseAddr("100.64.0.1")}, nil, 1280) require.NoError(t, err) - var dev *device.Device + var dev *wgbackend.Device n := newCountingNet() n.before = func() { _ = dev.IpcGetOperation(io.Discard) } iceBind := NewICEBind(n, testAddress(), 1280) - dev = device.NewDevice(tunDev, iceBind, device.NewLogger(device.LogLevelSilent, "")) + dev = wgbackend.NewDevice(tunDev, iceBind, wgbackend.NewLogger(wgbackend.LogLevelSilent, "")) upDone := make(chan error, 1) go func() { diff --git a/client/iface/bind/relay_bind.go b/client/iface/bind/relay_bind.go index 4c179d6a5..2f962e68e 100644 --- a/client/iface/bind/relay_bind.go +++ b/client/iface/bind/relay_bind.go @@ -7,15 +7,15 @@ import ( "sync" log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/conn" "github.com/netbirdio/netbird/client/iface/udpmux" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) // RelayBindJS is a conn.Bind implementation for WebAssembly environments. // Do not limit to build only js, because we want to be able to run tests type RelayBindJS struct { - *conn.StdNetBind + *wgbackend.StdNetBind recvChan chan recvMessage endpoints map[netip.Addr]net.Conn @@ -34,12 +34,12 @@ func NewRelayBindJS() *RelayBindJS { } // Open creates a receive function for handling relay packets in WASM. -func (s *RelayBindJS) Open(uport uint16) ([]conn.ReceiveFunc, uint16, error) { +func (s *RelayBindJS) Open(uport uint16) ([]wgbackend.ReceiveFunc, uint16, error) { log.Debugf("Open: creating receive function for port %d", uport) s.ctx, s.cancel = context.WithCancel(context.Background()) - receiveFn := func(bufs [][]byte, sizes []int, eps []conn.Endpoint) (int, error) { + receiveFn := func(bufs [][]byte, sizes []int, eps []wgbackend.Endpoint) (int, error) { select { case <-s.ctx.Done(): return 0, net.ErrClosed @@ -49,13 +49,13 @@ func (s *RelayBindJS) Open(uport uint16) ([]conn.ReceiveFunc, uint16, error) { } copy(bufs[0], msg.Buffer) sizes[0] = len(msg.Buffer) - eps[0] = conn.Endpoint(msg.Endpoint) + eps[0] = wgbackend.Endpoint(msg.Endpoint) return 1, nil } } log.Debugf("Open: receive function created, returning port %d", uport) - return []conn.ReceiveFunc{receiveFn}, uport, nil + return []wgbackend.ReceiveFunc{receiveFn}, uport, nil } func (s *RelayBindJS) Close() error { @@ -78,7 +78,7 @@ func (s *RelayBindJS) ReceiveFromEndpoint(ctx context.Context, ep *Endpoint, buf } // Send forwards packets through the relay connection for WASM. -func (s *RelayBindJS) Send(bufs [][]byte, ep conn.Endpoint) error { +func (s *RelayBindJS) Send(bufs [][]byte, ep wgbackend.Endpoint) error { if ep == nil { return nil } diff --git a/client/iface/bind/stun_filter_test.go b/client/iface/bind/stun_filter_test.go index 0e118e0fd..18befe539 100644 --- a/client/iface/bind/stun_filter_test.go +++ b/client/iface/bind/stun_filter_test.go @@ -12,7 +12,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/net/ipv4" - wgConn "golang.zx2c4.com/wireguard/conn" + + "github.com/netbirdio/netbird/client/iface/wgbackend" ) // magicCookieBytes is the STUN magic cookie as it appears on the wire. In a @@ -165,7 +166,7 @@ func TestReceiveFn_ClearsSizeOfConsumedPacket(t *testing.T) { // A leftover size from an earlier read, which is what makes the missing reset // observable. sizes := []int{148} - eps := make([]wgConn.Endpoint, 1) + eps := make([]wgbackend.Endpoint, 1) n, err := recvFn(bufs, sizes, eps) require.NoError(t, err) diff --git a/client/iface/configurer/uapi.go b/client/iface/configurer/uapi.go index d9bd9bfab..231e26879 100644 --- a/client/iface/configurer/uapi.go +++ b/client/iface/configurer/uapi.go @@ -5,16 +5,16 @@ package configurer import ( "net" - "golang.zx2c4.com/wireguard/ipc" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) func openUAPI(deviceName string) (net.Listener, error) { - uapiSock, err := ipc.UAPIOpen(deviceName) + uapiSock, err := wgbackend.UAPIOpen(deviceName) if err != nil { return nil, err } - listener, err := ipc.UAPIListen(deviceName, uapiSock) + listener, err := wgbackend.UAPIListen(deviceName, uapiSock) if err != nil { _ = uapiSock.Close() return nil, err diff --git a/client/iface/configurer/uapi_windows.go b/client/iface/configurer/uapi_windows.go index 46fa90c2e..43d03ea0a 100644 --- a/client/iface/configurer/uapi_windows.go +++ b/client/iface/configurer/uapi_windows.go @@ -3,9 +3,9 @@ package configurer import ( "net" - "golang.zx2c4.com/wireguard/ipc" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) func openUAPI(deviceName string) (net.Listener, error) { - return ipc.UAPIListen(deviceName) + return wgbackend.UAPIListen(deviceName) } diff --git a/client/iface/configurer/usp.go b/client/iface/configurer/usp.go index 1b0965f0d..2b4f73810 100644 --- a/client/iface/configurer/usp.go +++ b/client/iface/configurer/usp.go @@ -10,11 +10,10 @@ import ( "time" log "github.com/sirupsen/logrus" - wgconn "golang.zx2c4.com/wireguard/conn" - "golang.zx2c4.com/wireguard/device" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/iface/bind" + "github.com/netbirdio/netbird/client/iface/wgbackend" nbnet "github.com/netbirdio/netbird/client/net" "github.com/netbirdio/netbird/monotime" ) @@ -22,7 +21,7 @@ import ( var ErrAllowedIPNotFound = fmt.Errorf("allowed IP not found") type WGUSPConfigurer struct { - device *device.Device + device *wgbackend.Device deviceName string activityRecorder *bind.ActivityRecorder statsCache *statsCache @@ -31,14 +30,14 @@ type WGUSPConfigurer struct { } // NewUSPConfigurer creates a userspace configurer and starts its UAPI listener. -func NewUSPConfigurer(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer { +func NewUSPConfigurer(device *wgbackend.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer { wgCfg := NewUSPConfigurerNoUAPI(device, deviceName, activityRecorder) wgCfg.startUAPI() return wgCfg } // NewUSPConfigurerNoUAPI creates a userspace configurer without a UAPI listener. -func NewUSPConfigurerNoUAPI(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer { +func NewUSPConfigurerNoUAPI(device *wgbackend.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer { wgCfg := &WGUSPConfigurer{ device: device, deviceName: deviceName, @@ -59,7 +58,7 @@ func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error return fmt.Errorf("invalid listen port %d", port) } - if err := c.device.SetPrivateKey(device.NoisePrivateKey(key)); err != nil { + if err := c.device.SetPrivateKey(wgbackend.NoisePrivateKey(key)); err != nil { return fmt.Errorf("set private key: %w", err) } if err := c.device.SetListenPort(uint16(port)); err != nil { @@ -80,8 +79,8 @@ func (c *WGUSPConfigurer) SetPresharedKey(peerKey string, psk wgtypes.Key, updat return err } - presharedKey := device.NoisePresharedKey(psk) - return c.device.ConfigurePeer(device.NoisePublicKey(parsedPeerKey), device.PeerConfig{ + presharedKey := wgbackend.NoisePresharedKey(psk) + return c.device.ConfigurePeer(wgbackend.NoisePublicKey(parsedPeerKey), wgbackend.PeerConfig{ PresharedKey: &presharedKey, UpdateOnly: updateOnly, }) @@ -99,7 +98,7 @@ func (c *WGUSPConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, return err } - cfg := device.PeerConfig{ + cfg := wgbackend.PeerConfig{ PersistentKeepalive: &keepAlive, AllowedIPs: prefixes, } @@ -116,11 +115,11 @@ func (c *WGUSPConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, } if preSharedKey != nil { - psk := device.NoisePresharedKey(*preSharedKey) + psk := wgbackend.NoisePresharedKey(*preSharedKey) cfg.PresharedKey = &psk } - if err := c.device.ConfigurePeer(device.NoisePublicKey(peerKeyParsed), cfg); err != nil { + if err := c.device.ConfigurePeer(wgbackend.NoisePublicKey(peerKeyParsed), cfg); err != nil { return fmt.Errorf("configure peer: %w", err) } @@ -138,7 +137,7 @@ func (c *WGUSPConfigurer) RemoveEndpointAddress(peerKey string) error { return fmt.Errorf("parse peer key: %w", err) } - peer := c.device.LookupPeer(device.NoisePublicKey(peerKeyParsed)) + peer := c.device.LookupPeer(wgbackend.NoisePublicKey(peerKeyParsed)) if peer == nil { return ErrPeerNotFound } @@ -147,7 +146,7 @@ func (c *WGUSPConfigurer) RemoveEndpointAddress(peerKey string) error { peer.ZeroAndFlushAll() var noKeepAlive time.Duration - if err := c.device.ConfigurePeer(device.NoisePublicKey(peerKeyParsed), device.PeerConfig{ + if err := c.device.ConfigurePeer(wgbackend.NoisePublicKey(peerKeyParsed), wgbackend.PeerConfig{ PersistentKeepalive: &noKeepAlive, UpdateOnly: true, }); err != nil { @@ -163,7 +162,7 @@ func (c *WGUSPConfigurer) RemovePeer(peerKey string) error { return err } - c.device.RemovePeer(device.NoisePublicKey(peerKeyParsed)) + c.device.RemovePeer(wgbackend.NoisePublicKey(peerKeyParsed)) c.activityRecorder.Remove(peerKey) return nil } @@ -178,7 +177,7 @@ func (c *WGUSPConfigurer) AddAllowedIP(peerKey string, allowedIP netip.Prefix) e return fmt.Errorf("invalid allowed IP %v", allowedIP) } - peer := c.device.LookupPeer(device.NoisePublicKey(peerKeyParsed)) + peer := c.device.LookupPeer(wgbackend.NoisePublicKey(peerKeyParsed)) if peer == nil { return nil } @@ -198,7 +197,7 @@ func (c *WGUSPConfigurer) RemoveAllowedIP(peerKey string, allowedIP netip.Prefix return ErrAllowedIPNotFound } - peer := c.device.LookupPeer(device.NoisePublicKey(peerKeyParsed)) + peer := c.device.LookupPeer(wgbackend.NoisePublicKey(peerKeyParsed)) if peer == nil { return ErrPeerNotFound } @@ -284,7 +283,7 @@ func (c *WGUSPConfigurer) fetchStats() (map[string]WGStats, error) { return stats, nil } -func peerStats(peer *device.Peer) Peer { +func peerStats(peer *wgbackend.Peer) Peer { stats := Peer{ PublicKey: wgtypes.Key(peer.PublicKey()).String(), AllowedIPs: prefixesToIPNets(peer.AllowedIPs()), @@ -299,7 +298,7 @@ func peerStats(peer *device.Peer) Peer { return stats } -func endpointAddrPort(endpoint wgconn.Endpoint) (netip.AddrPort, bool) { +func endpointAddrPort(endpoint wgbackend.Endpoint) (netip.AddrPort, bool) { if endpoint == nil { return netip.AddrPort{}, false } diff --git a/client/iface/configurer/usp_allowedips_test.go b/client/iface/configurer/usp_allowedips_test.go index effb360e9..81bd26553 100644 --- a/client/iface/configurer/usp_allowedips_test.go +++ b/client/iface/configurer/usp_allowedips_test.go @@ -8,11 +8,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - wgconn "golang.zx2c4.com/wireguard/conn" - wgdevice "golang.zx2c4.com/wireguard/device" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/iface/bind" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) // newTestUSPConfigurer builds a configurer over a real wireguard-go device backed by an @@ -20,7 +19,7 @@ import ( func newTestUSPConfigurer(t *testing.T) *WGUSPConfigurer { t.Helper() - dev := wgdevice.NewDevice(newIdleTUN(), wgconn.NewDefaultBind(), wgdevice.NewLogger(wgdevice.LogLevelSilent, "")) + dev := wgbackend.NewDevice(newIdleTUN(), wgbackend.NewDefaultBind(), wgbackend.NewLogger(wgbackend.LogLevelSilent, "")) t.Cleanup(dev.Close) c := NewUSPConfigurerNoUAPI(dev, "wgtest0", bind.NewActivityRecorder()) diff --git a/client/iface/configurer/usp_test.go b/client/iface/configurer/usp_test.go index 48b5ad1f0..1ea406a1e 100644 --- a/client/iface/configurer/usp_test.go +++ b/client/iface/configurer/usp_test.go @@ -11,20 +11,20 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - wgdevice "golang.zx2c4.com/wireguard/device" - "golang.zx2c4.com/wireguard/tun" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/netbirdio/netbird/client/iface/wgbackend" ) // idleTUN never reports an up event, so the wireguard-go device stays down: no socket is // opened, no peer goroutine starts, and the configuration state is all the tests observe. type idleTUN struct { - events chan tun.Event + events chan wgbackend.TUNEvent closed chan struct{} } func newIdleTUN() *idleTUN { - return &idleTUN{events: make(chan tun.Event), closed: make(chan struct{})} + return &idleTUN{events: make(chan wgbackend.TUNEvent), closed: make(chan struct{})} } func (t *idleTUN) File() *os.File { return nil } @@ -40,7 +40,7 @@ func (t *idleTUN) MTU() (int, error) { return 1420, nil } func (t *idleTUN) Name() (string, error) { return "idle", nil } -func (t *idleTUN) Events() <-chan tun.Event { return t.events } +func (t *idleTUN) Events() <-chan wgbackend.TUNEvent { return t.events } func (t *idleTUN) Close() error { close(t.closed) @@ -142,12 +142,12 @@ func TestRemoveEndpointAddressKeepsThePeerAndStopsTheKeepalive(t *testing.T) { endpoint := &net.UDPAddr{IP: net.IPv4(192, 0, 2, 10), Port: 51820} prefixes := []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")} require.NoError(t, c.UpdatePeer(peerKey, prefixes, 25*time.Second, endpoint, nil), "add peer") - before := c.device.LookupPeer(wgdevice.NoisePublicKey(priv.PublicKey())) + before := c.device.LookupPeer(wgbackend.NoisePublicKey(priv.PublicKey())) require.NotNil(t, before, "the peer must be on the device") require.NoError(t, c.RemoveEndpointAddress(peerKey), "remove endpoint address") - assert.Same(t, before, c.device.LookupPeer(wgdevice.NoisePublicKey(priv.PublicKey())), + assert.Same(t, before, c.device.LookupPeer(wgbackend.NoisePublicKey(priv.PublicKey())), "the peer object must survive the endpoint removal") stats, err := c.FullStats() require.NoError(t, err, "read device stats") diff --git a/client/iface/device.go b/client/iface/device.go index c0c829825..664093abd 100644 --- a/client/iface/device.go +++ b/client/iface/device.go @@ -3,13 +3,10 @@ package iface import ( - "golang.zx2c4.com/wireguard/tun/netstack" - - wgdevice "golang.zx2c4.com/wireguard/device" - "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) type WGTunDevice interface { @@ -21,7 +18,7 @@ type WGTunDevice interface { DeviceName() string Close() error FilteredDevice() *device.FilteredDevice - Device() *wgdevice.Device - GetNet() *netstack.Net + Device() *wgbackend.Device + GetNet() *wgbackend.NetstackNet GetICEBind() device.EndpointManager } diff --git a/client/iface/device/device_android.go b/client/iface/device/device_android.go index 0ed1299ae..0c6b1c03c 100644 --- a/client/iface/device/device_android.go +++ b/client/iface/device/device_android.go @@ -8,14 +8,12 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/sys/unix" - "golang.zx2c4.com/wireguard/device" - "golang.zx2c4.com/wireguard/tun" - "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) // WGTunDevice ignore the WGTunDevice interface on Android because the creation of the tun device is different on this platform @@ -30,7 +28,7 @@ type WGTunDevice struct { disableDNS bool name string - device *device.Device + device *wgbackend.Device filteredDevice *FilteredDevice udpMux *udpmux.UniversalUDPMuxDefault configurer WGConfigurer @@ -74,7 +72,7 @@ func (t *WGTunDevice) Create(routes []string, dns string, searchDomains []string return nil, err } - unmonitoredTUN, name, err := tun.CreateUnmonitoredTUNFromFD(fd) + unmonitoredTUN, name, err := wgbackend.CreateUnmonitoredTUNFromFD(fd) if err != nil { _ = unix.Close(fd) log.Errorf("failed to create Android interface: %s", err) @@ -87,7 +85,7 @@ func (t *WGTunDevice) Create(routes []string, dns string, searchDomains []string t.filteredDevice = newDeviceFilter(t.renewableTun) log.Debugf("attaching to interface %v", name) - t.device = device.NewDevice(t.filteredDevice, t.iceBind, device.NewLogger(wgLogLevel(), "[netbird] ")) + t.device = wgbackend.NewDevice(t.filteredDevice, t.iceBind, wgbackend.NewLogger(wgLogLevel(), "[netbird] ")) // without this property mobile devices can discover remote endpoints if the configured one was wrong. // this helps with support for the older NetBird clients that had a hardcoded direct mode // t.device.DisableSomeRoamingForBrokenMobileSemantics() @@ -121,7 +119,7 @@ func (t *WGTunDevice) RenewTun(fd int) error { return fmt.Errorf("device not initialized") } - unmonitoredTUN, _, err := tun.CreateUnmonitoredTUNFromFD(fd) + unmonitoredTUN, _, err := wgbackend.CreateUnmonitoredTUNFromFD(fd) if err != nil { _ = unix.Close(fd) log.Errorf("failed to renew Android interface: %s", err) @@ -155,7 +153,7 @@ func (t *WGTunDevice) Close() error { return nil } -func (t *WGTunDevice) Device() *device.Device { +func (t *WGTunDevice) Device() *wgbackend.Device { return t.device } @@ -175,7 +173,7 @@ func (t *WGTunDevice) FilteredDevice() *FilteredDevice { return t.filteredDevice } -func (t *WGTunDevice) GetNet() *netstack.Net { +func (t *WGTunDevice) GetNet() *wgbackend.NetstackNet { return nil } diff --git a/client/iface/device/device_darwin.go b/client/iface/device/device_darwin.go index ac8f8a51b..cf03c12fe 100644 --- a/client/iface/device/device_darwin.go +++ b/client/iface/device/device_darwin.go @@ -7,14 +7,12 @@ import ( "os/exec" log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/device" - "golang.zx2c4.com/wireguard/tun" - "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) type TunDevice struct { @@ -25,7 +23,7 @@ type TunDevice struct { mtu uint16 iceBind *bind.ICEBind - device *device.Device + device *wgbackend.Device filteredDevice *FilteredDevice udpMux *udpmux.UniversalUDPMuxDefault configurer WGConfigurer @@ -43,17 +41,17 @@ func NewTunDevice(name string, address wgaddr.Address, port int, key string, mtu } func (t *TunDevice) Create() (WGConfigurer, error) { - tunDevice, err := tun.CreateTUN(t.name, int(t.mtu)) + tunDevice, err := wgbackend.CreateTUN(t.name, int(t.mtu)) if err != nil { return nil, fmt.Errorf("error creating tun device: %s", err) } t.filteredDevice = newDeviceFilter(tunDevice) // We need to create a wireguard-go device and listen to configuration requests - t.device = device.NewDevice( + t.device = wgbackend.NewDevice( t.filteredDevice, t.iceBind, - device.NewLogger(wgLogLevel(), "[netbird] "), + wgbackend.NewLogger(wgLogLevel(), "[netbird] "), ) err = t.assignAddr() @@ -125,7 +123,7 @@ func (t *TunDevice) FilteredDevice() *FilteredDevice { } // Device returns the wireguard device -func (t *TunDevice) Device() *device.Device { +func (t *TunDevice) Device() *wgbackend.Device { return t.device } @@ -160,7 +158,7 @@ func (t *TunDevice) assignAddr() error { return nil } -func (t *TunDevice) GetNet() *netstack.Net { +func (t *TunDevice) GetNet() *wgbackend.NetstackNet { return nil } diff --git a/client/iface/device/device_filter.go b/client/iface/device/device_filter.go index 7d7493835..d922af5fa 100644 --- a/client/iface/device/device_filter.go +++ b/client/iface/device/device_filter.go @@ -8,7 +8,8 @@ import ( "sync/atomic" log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/tun" + + "github.com/netbirdio/netbird/client/iface/wgbackend" ) // PacketFilter interface for firewall abilities @@ -42,7 +43,7 @@ type PacketCapture interface { // FilteredDevice to override Read or Write of packets type FilteredDevice struct { - tun.Device + wgbackend.TUN filter PacketFilter capture atomic.Pointer[PacketCapture] @@ -54,9 +55,9 @@ type FilteredDevice struct { } // newDeviceFilter constructor function -func newDeviceFilter(device tun.Device) *FilteredDevice { +func newDeviceFilter(device wgbackend.TUN) *FilteredDevice { return &FilteredDevice{ - Device: device, + TUN: device, } } @@ -66,7 +67,7 @@ func newDeviceFilter(device tun.Device) *FilteredDevice { func (d *FilteredDevice) Close() error { var err error d.closeOnce.Do(func() { - err = d.Device.Close() + err = d.TUN.Close() }) if err != nil { return err @@ -142,14 +143,14 @@ func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) { // wintun read path and converting them into errors. func (d *FilteredDevice) deviceRead(bufs [][]byte, sizes []int, offset int) (n int, err error) { defer d.recoverFromPanic("read", &n, &err) - return d.Device.Read(bufs, sizes, offset) + return d.TUN.Read(bufs, sizes, offset) } // deviceWrite calls the underlying device Write, recovering from panics in the // wintun write path and converting them into errors. func (d *FilteredDevice) deviceWrite(bufs [][]byte, offset int) (n int, err error) { defer d.recoverFromPanic("write", &n, &err) - return d.Device.Write(bufs, offset) + return d.TUN.Write(bufs, offset) } // recoverFromPanic converts a panic in the underlying device into a regular diff --git a/client/iface/device/device_ios.go b/client/iface/device/device_ios.go index 8368c8dce..df51c0e15 100644 --- a/client/iface/device/device_ios.go +++ b/client/iface/device/device_ios.go @@ -6,14 +6,12 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/sys/unix" - "golang.zx2c4.com/wireguard/device" - "golang.zx2c4.com/wireguard/tun" - "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) type TunDevice struct { @@ -25,7 +23,7 @@ type TunDevice struct { iceBind *bind.ICEBind tunFd int - device *device.Device + device *wgbackend.Device filteredDevice *FilteredDevice udpMux *udpmux.UniversalUDPMuxDefault configurer WGConfigurer @@ -50,7 +48,7 @@ var ErrInvalidTunnelFD = fmt.Errorf("invalid tunnel file descriptor: fd is 0 (Sw func (t *TunDevice) Create() (WGConfigurer, error) { log.Infof("create tun interface") - var tunDevice tun.Device + var tunDevice wgbackend.TUN var err error // Validate the tunnel file descriptor. @@ -79,7 +77,7 @@ func (t *TunDevice) Create() (WGConfigurer, error) { _ = unix.Close(dupTunFd) return nil, err } - tunDevice, err = tun.CreateTUNFromFile(os.NewFile(uintptr(dupTunFd), "/dev/tun"), 0) + tunDevice, err = wgbackend.CreateTUNFromFile(os.NewFile(uintptr(dupTunFd), "/dev/tun"), 0) if err != nil { log.Errorf("Unable to create new tun device from fd: %v", err) _ = unix.Close(dupTunFd) @@ -88,7 +86,7 @@ func (t *TunDevice) Create() (WGConfigurer, error) { t.filteredDevice = newDeviceFilter(tunDevice) log.Debug("Attaching to interface") - t.device = device.NewDevice(t.filteredDevice, t.iceBind, device.NewLogger(wgLogLevel(), "[netbird] ")) + t.device = wgbackend.NewDevice(t.filteredDevice, t.iceBind, wgbackend.NewLogger(wgLogLevel(), "[netbird] ")) // without this property mobile devices can discover remote endpoints if the configured one was wrong. // this helps with support for the older NetBird clients that had a hardcoded direct mode // t.device.DisableSomeRoamingForBrokenMobileSemantics() @@ -118,7 +116,7 @@ func (t *TunDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) { return udpMux, nil } -func (t *TunDevice) Device() *device.Device { +func (t *TunDevice) Device() *wgbackend.Device { return t.device } @@ -163,7 +161,7 @@ func (t *TunDevice) FilteredDevice() *FilteredDevice { return t.filteredDevice } -func (t *TunDevice) GetNet() *netstack.Net { +func (t *TunDevice) GetNet() *wgbackend.NetstackNet { return nil } diff --git a/client/iface/device/device_kernel_unix.go b/client/iface/device/device_kernel_unix.go index ee965dd24..240154f81 100644 --- a/client/iface/device/device_kernel_unix.go +++ b/client/iface/device/device_kernel_unix.go @@ -9,12 +9,11 @@ import ( "github.com/pion/transport/v3" log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/device" - "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" nbnet "github.com/netbirdio/netbird/client/net" "github.com/netbirdio/netbird/sharedsock" ) @@ -170,7 +169,7 @@ func (t *TunKernelDevice) DeviceName() string { } // Device returns the wireguard device, not applicable for kernel devices -func (t *TunKernelDevice) Device() *device.Device { +func (t *TunKernelDevice) Device() *wgbackend.Device { return nil } @@ -183,7 +182,7 @@ func (t *TunKernelDevice) assignAddr() error { return t.link.assignAddr(&t.address) } -func (t *TunKernelDevice) GetNet() *netstack.Net { +func (t *TunKernelDevice) GetNet() *wgbackend.NetstackNet { return nil } diff --git a/client/iface/device/device_netstack.go b/client/iface/device/device_netstack.go index b3bce3925..69e0d6bba 100644 --- a/client/iface/device/device_netstack.go +++ b/client/iface/device/device_netstack.go @@ -6,20 +6,18 @@ import ( "net/netip" log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/conn" - "golang.zx2c4.com/wireguard/device" - "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/configurer" nbnetstack "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" nbnet "github.com/netbirdio/netbird/client/net" ) type Bind interface { - conn.Bind + wgbackend.Bind GetICEMux() (*udpmux.UniversalUDPMuxDefault, error) ActivityRecorder() *bind.ActivityRecorder EndpointManager @@ -34,13 +32,13 @@ type TunNetstackDevice struct { listenAddress string bind Bind - device *device.Device + device *wgbackend.Device filteredDevice *FilteredDevice nsTun *nbnetstack.NetStackTun udpMux *udpmux.UniversalUDPMuxDefault configurer WGConfigurer - net *netstack.Net + net *wgbackend.NetstackNet } func NewNetstackDevice(name string, address wgaddr.Address, wgPort int, key string, mtu uint16, bind Bind, listenAddress string) *TunNetstackDevice { @@ -78,10 +76,10 @@ func (t *TunNetstackDevice) create() (WGConfigurer, error) { t.filteredDevice = newDeviceFilter(tunIface) t.net = net - t.device = device.NewDevice( + t.device = wgbackend.NewDevice( t.filteredDevice, t.bind, - device.NewLogger(wgLogLevel(), "[netbird] "), + wgbackend.NewLogger(wgLogLevel(), "[netbird] "), ) t.configurer = configurer.NewUSPConfigurerNoUAPI(t.device, t.name, t.bind.ActivityRecorder()) @@ -156,11 +154,11 @@ func (t *TunNetstackDevice) FilteredDevice() *FilteredDevice { } // Device returns the wireguard device -func (t *TunNetstackDevice) Device() *device.Device { +func (t *TunNetstackDevice) Device() *wgbackend.Device { return t.device } -func (t *TunNetstackDevice) GetNet() *netstack.Net { +func (t *TunNetstackDevice) GetNet() *wgbackend.NetstackNet { return t.net } diff --git a/client/iface/device/device_usp_unix.go b/client/iface/device/device_usp_unix.go index 04c265c49..2b5f0a2b7 100644 --- a/client/iface/device/device_usp_unix.go +++ b/client/iface/device/device_usp_unix.go @@ -6,14 +6,12 @@ import ( "fmt" log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/device" - "golang.zx2c4.com/wireguard/tun" - "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) type TunDevice struct { @@ -24,7 +22,7 @@ type TunDevice struct { mtu uint16 iceBind *bind.ICEBind - device *device.Device + device *wgbackend.Device filteredDevice *FilteredDevice udpMux *udpmux.UniversalUDPMuxDefault configurer WGConfigurer @@ -45,7 +43,7 @@ func NewTunDevice(name string, address wgaddr.Address, port int, key string, mtu func (t *TunDevice) Create() (WGConfigurer, error) { log.Info("create tun interface") - tunIface, err := tun.CreateTUN(t.name, int(t.mtu)) + tunIface, err := wgbackend.CreateTUN(t.name, int(t.mtu)) if err != nil { log.Debugf("failed to create tun interface (%s, %d): %s", t.name, int(t.mtu), err) return nil, fmt.Errorf("error creating tun device: %s", err) @@ -53,10 +51,10 @@ func (t *TunDevice) Create() (WGConfigurer, error) { t.filteredDevice = newDeviceFilter(tunIface) // We need to create a wireguard-go device and listen to configuration requests - t.device = device.NewDevice( + t.device = wgbackend.NewDevice( t.filteredDevice, t.iceBind, - device.NewLogger(wgLogLevel(), "[netbird] "), + wgbackend.NewLogger(wgLogLevel(), "[netbird] "), ) err = t.assignAddr() @@ -132,7 +130,7 @@ func (t *TunDevice) FilteredDevice() *FilteredDevice { } // Device returns the wireguard device -func (t *TunDevice) Device() *device.Device { +func (t *TunDevice) Device() *wgbackend.Device { return t.device } @@ -143,7 +141,7 @@ func (t *TunDevice) assignAddr() error { return link.assignAddr(&t.address) } -func (t *TunDevice) GetNet() *netstack.Net { +func (t *TunDevice) GetNet() *wgbackend.NetstackNet { return nil } diff --git a/client/iface/device/device_windows.go b/client/iface/device/device_windows.go index f52392fa2..4e9dd1eac 100644 --- a/client/iface/device/device_windows.go +++ b/client/iface/device/device_windows.go @@ -6,15 +6,13 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/sys/windows" - "golang.zx2c4.com/wireguard/device" - "golang.zx2c4.com/wireguard/tun" - "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" "github.com/netbirdio/netbird/client/iface/bind" "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) const defaultWindowsGUIDSTring = "{f2f29e61-d91f-4d76-8151-119b20c4bdeb}" @@ -27,8 +25,8 @@ type TunDevice struct { mtu uint16 iceBind *bind.ICEBind - device *device.Device - nativeTunDevice *tun.NativeTun + device *wgbackend.Device + nativeTunDevice *wgbackend.NativeTUN filteredDevice *FilteredDevice udpMux *udpmux.UniversalUDPMuxDefault configurer WGConfigurer @@ -60,18 +58,18 @@ func (t *TunDevice) Create() (WGConfigurer, error) { return nil, err } log.Info("create tun interface") - tunDevice, err := tun.CreateTUNWithRequestedGUID(t.name, &guid, int(t.mtu)) + tunDevice, err := wgbackend.CreateTUNWithRequestedGUID(t.name, &guid, int(t.mtu)) if err != nil { return nil, fmt.Errorf("error creating tun device: %s", err) } - t.nativeTunDevice = tunDevice.(*tun.NativeTun) + t.nativeTunDevice = tunDevice.(*wgbackend.NativeTUN) t.filteredDevice = newDeviceFilter(tunDevice) // We need to create a wireguard-go device and listen to configuration requests - t.device = device.NewDevice( + t.device = wgbackend.NewDevice( t.filteredDevice, t.iceBind, - device.NewLogger(wgLogLevel(), "[netbird] "), + wgbackend.NewLogger(wgLogLevel(), "[netbird] "), ) luid := winipcfg.LUID(t.nativeTunDevice.LUID()) @@ -172,7 +170,7 @@ func (t *TunDevice) FilteredDevice() *FilteredDevice { } // Device returns the wireguard device -func (t *TunDevice) Device() *device.Device { +func (t *TunDevice) Device() *wgbackend.Device { return t.device } @@ -209,7 +207,7 @@ func (t *TunDevice) assignAddr() error { return luid.SetIPAddresses([]netip.Prefix{v4Prefix}) } -func (t *TunDevice) GetNet() *netstack.Net { +func (t *TunDevice) GetNet() *wgbackend.NetstackNet { return nil } diff --git a/client/iface/device/renewable_tun.go b/client/iface/device/renewable_tun.go index a501eebbb..222dc40f5 100644 --- a/client/iface/device/renewable_tun.go +++ b/client/iface/device/renewable_tun.go @@ -10,7 +10,8 @@ import ( "time" log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/tun" + + "github.com/netbirdio/netbird/client/iface/wgbackend" ) // closeAwareDevice wraps a tun.Device along with a flag @@ -23,15 +24,15 @@ import ( // goroutine is awaited and closed only once. type closeAwareDevice struct { isClosed atomic.Bool - tun.Device + wgbackend.TUN closeEventCh chan struct{} wg sync.WaitGroup closeOnce sync.Once } -func newClosableDevice(tunDevice tun.Device) *closeAwareDevice { +func newClosableDevice(tunDevice wgbackend.TUN) *closeAwareDevice { return &closeAwareDevice{ - Device: tunDevice, + TUN: tunDevice, isClosed: atomic.Bool{}, closeEventCh: make(chan struct{}), } @@ -39,18 +40,18 @@ func newClosableDevice(tunDevice tun.Device) *closeAwareDevice { // redirectEvents redirects the Events() method of the underlying tun.Device // to the given channel (RenewableTUN's events channel). -func (c *closeAwareDevice) redirectEvents(out chan tun.Event) { +func (c *closeAwareDevice) redirectEvents(out chan wgbackend.TUNEvent) { c.wg.Add(1) go func() { defer c.wg.Done() for { select { - case ev, ok := <-c.Device.Events(): + case ev, ok := <-c.TUN.Events(): if !ok { return } - if ev == tun.EventDown { + if ev == wgbackend.TUNEventDown { continue } @@ -72,7 +73,7 @@ func (c *closeAwareDevice) Close() (err error) { c.closeOnce.Do(func() { c.isClosed.Store(true) close(c.closeEventCh) - err = c.Device.Close() + err = c.TUN.Close() c.wg.Wait() }) @@ -87,7 +88,7 @@ type RenewableTUN struct { devices []*closeAwareDevice mu sync.Mutex cond *sync.Cond - events chan tun.Event + events chan wgbackend.TUNEvent closed atomic.Bool } @@ -95,7 +96,7 @@ func NewRenewableTUN() *RenewableTUN { r := &RenewableTUN{ devices: make([]*closeAwareDevice, 0), mu: sync.Mutex{}, - events: make(chan tun.Event, 16), + events: make(chan wgbackend.TUNEvent, 16), } r.cond = sync.NewCond(&r.mu) return r @@ -225,7 +226,7 @@ func (r *RenewableTUN) Name() (string, error) { // Events returns a channel that is fed events from the underlying tun.Device's events channel // once it is added. -func (r *RenewableTUN) Events() <-chan tun.Event { +func (r *RenewableTUN) Events() <-chan wgbackend.TUNEvent { return r.events } @@ -259,7 +260,7 @@ func (r *RenewableTUN) BatchSize() int { return 1 } -func (r *RenewableTUN) AddDevice(device tun.Device) { +func (r *RenewableTUN) AddDevice(device wgbackend.TUN) { r.mu.Lock() if r.closed.Load() { r.mu.Unlock() diff --git a/client/iface/device/wg_log.go b/client/iface/device/wg_log.go index db2f3111f..107d739ec 100644 --- a/client/iface/device/wg_log.go +++ b/client/iface/device/wg_log.go @@ -3,13 +3,13 @@ package device import ( "os" - "golang.zx2c4.com/wireguard/device" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) func wgLogLevel() int { if os.Getenv("NB_WG_DEBUG") == "true" { - return device.LogLevelVerbose + return wgbackend.LogLevelVerbose } else { - return device.LogLevelSilent + return wgbackend.LogLevelSilent } } diff --git a/client/iface/device_android.go b/client/iface/device_android.go index 3899bf426..c012b9c64 100644 --- a/client/iface/device_android.go +++ b/client/iface/device_android.go @@ -1,13 +1,10 @@ package iface import ( - wgdevice "golang.zx2c4.com/wireguard/device" - - "golang.zx2c4.com/wireguard/tun/netstack" - "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) type WGTunDevice interface { @@ -19,8 +16,8 @@ type WGTunDevice interface { DeviceName() string Close() error FilteredDevice() *device.FilteredDevice - Device() *wgdevice.Device - GetNet() *netstack.Net + Device() *wgbackend.Device + GetNet() *wgbackend.NetstackNet RenewTun(fd int) error GetICEBind() device.EndpointManager } diff --git a/client/iface/iface.go b/client/iface/iface.go index f6006fa87..d241d2146 100644 --- a/client/iface/iface.go +++ b/client/iface/iface.go @@ -10,17 +10,15 @@ import ( "github.com/hashicorp/go-multierror" "github.com/pion/transport/v3" log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - wgdevice "golang.zx2c4.com/wireguard/device" - "github.com/netbirdio/netbird/client/errors" "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/device" nbnetstack "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" "github.com/netbirdio/netbird/client/iface/wgproxy" "github.com/netbirdio/netbird/monotime" ) @@ -271,7 +269,7 @@ func (w *WGIface) GetDevice() *device.FilteredDevice { } // GetWGDevice returns the WireGuard device -func (w *WGIface) GetWGDevice() *wgdevice.Device { +func (w *WGIface) GetWGDevice() *wgbackend.Device { return w.tun.Device() } @@ -344,7 +342,7 @@ func (w *WGIface) waitUntilRemoved() error { } // GetNet returns the netstack.Net for the netstack device -func (w *WGIface) GetNet() *netstack.Net { +func (w *WGIface) GetNet() *wgbackend.NetstackNet { w.mu.Lock() defer w.mu.Unlock() diff --git a/client/iface/iface_close_test.go b/client/iface/iface_close_test.go index ecbbf4866..e4b267b70 100644 --- a/client/iface/iface_close_test.go +++ b/client/iface/iface_close_test.go @@ -8,13 +8,11 @@ import ( "testing" "time" - wgdevice "golang.zx2c4.com/wireguard/device" - "golang.zx2c4.com/wireguard/tun/netstack" - "github.com/netbirdio/netbird/client/iface/device" nbnetstack "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" "github.com/netbirdio/netbird/client/iface/wgproxy" ) @@ -40,8 +38,8 @@ func (f *fakeTunDevice) WgAddress() wgaddr.Address { return wgaddr. func (f *fakeTunDevice) MTU() uint16 { return DefaultMTU } func (f *fakeTunDevice) DeviceName() string { return "nb-close-test" } func (f *fakeTunDevice) FilteredDevice() *device.FilteredDevice { return nil } -func (f *fakeTunDevice) Device() *wgdevice.Device { return nil } -func (f *fakeTunDevice) GetNet() *netstack.Net { return nil } +func (f *fakeTunDevice) Device() *wgbackend.Device { return nil } +func (f *fakeTunDevice) GetNet() *wgbackend.NetstackNet { return nil } func (f *fakeTunDevice) GetICEBind() device.EndpointManager { return nil } func (f *fakeTunDevice) Close() error { diff --git a/client/iface/mocks/tun.go b/client/iface/mocks/tun.go index 519ee6005..124e6a5cd 100644 --- a/client/iface/mocks/tun.go +++ b/client/iface/mocks/tun.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: golang.zx2c4.com/wireguard/tun (interfaces: Device) +// Source: github.com/netbirdio/wireguard-go/tun (interfaces: Device) // Package mocks is a generated GoMock package. package mocks @@ -8,8 +8,8 @@ import ( os "os" reflect "reflect" + tun "github.com/netbirdio/wireguard-go/tun" gomock "go.uber.org/mock/gomock" - tun "golang.zx2c4.com/wireguard/tun" ) // MockDevice is a mock of Device interface. diff --git a/client/iface/netstack/dialer.go b/client/iface/netstack/dialer.go index a9ed6d45b..a09d03545 100644 --- a/client/iface/netstack/dialer.go +++ b/client/iface/netstack/dialer.go @@ -5,7 +5,8 @@ import ( "net" log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/tun/netstack" + + "github.com/netbirdio/netbird/client/iface/wgbackend" ) type Dialer interface { @@ -13,10 +14,10 @@ type Dialer interface { } type NSDialer struct { - net *netstack.Net + net *wgbackend.NetstackNet } -func NewNSDialer(net *netstack.Net) *NSDialer { +func NewNSDialer(net *wgbackend.NetstackNet) *NSDialer { return &NSDialer{ net: net, } diff --git a/client/iface/netstack/tun.go b/client/iface/netstack/tun.go index 8c7526bbb..1c8b02483 100644 --- a/client/iface/netstack/tun.go +++ b/client/iface/netstack/tun.go @@ -6,8 +6,8 @@ import ( "strconv" log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/tun" - "golang.zx2c4.com/wireguard/tun/netstack" + + "github.com/netbirdio/netbird/client/iface/wgbackend" ) const EnvSkipProxy = "NB_NETSTACK_SKIP_PROXY" @@ -19,7 +19,7 @@ type NetStackTun struct { //nolint:revive listenAddress string proxy *Proxy - tundev tun.Device + tundev wgbackend.TUN } func NewNetStackTun(listenAddress string, addresses []netip.Addr, dnsAddress netip.Addr, mtu int) *NetStackTun { @@ -31,8 +31,8 @@ func NewNetStackTun(listenAddress string, addresses []netip.Addr, dnsAddress net } } -func (t *NetStackTun) Create() (tun.Device, *netstack.Net, error) { - nsTunDev, tunNet, err := netstack.CreateNetTUN( +func (t *NetStackTun) Create() (wgbackend.TUN, *wgbackend.NetstackNet, error) { + nsTunDev, tunNet, err := wgbackend.CreateNetTUN( t.addresses, []netip.Addr{t.dnsAddress}, t.mtu) diff --git a/client/iface/udpmux/mux.go b/client/iface/udpmux/mux.go index 2f7287b53..f39aef2ad 100644 --- a/client/iface/udpmux/mux.go +++ b/client/iface/udpmux/mux.go @@ -9,7 +9,7 @@ import ( "strings" "sync" - "github.com/pion/ice/v4" + "github.com/netbirdio/ice/v4" "github.com/pion/logging" "github.com/pion/stun/v3" "github.com/pion/transport/v3" diff --git a/client/iface/wgbackend/conn.go b/client/iface/wgbackend/conn.go new file mode 100644 index 000000000..cd33c3102 --- /dev/null +++ b/client/iface/wgbackend/conn.go @@ -0,0 +1,27 @@ +package wgbackend + +import "github.com/netbirdio/wireguard-go/conn" + +type ( + BatchReader = conn.BatchReader + Bind = conn.Bind + Endpoint = conn.Endpoint + ReceiveFunc = conn.ReceiveFunc + StdNetBind = conn.StdNetBind + StdNetEndpoint = conn.StdNetEndpoint +) + +const ( + IdealBatchSize = conn.IdealBatchSize + UdpSegmentMaxDatagrams = conn.UdpSegmentMaxDatagrams +) + +var ( + ControlFns = conn.ControlFns + GetGSOSize = conn.GetGSOSize + GetSrcFromControl = conn.GetSrcFromControl + NewDefaultBind = conn.NewDefaultBind + NewStdNetBind = conn.NewStdNetBind + NewStdNetBindWithReceiverCreator = conn.NewStdNetBindWithReceiverCreator + SplitCoalescedMessages = conn.SplitCoalescedMessages +) diff --git a/client/iface/wgbackend/device.go b/client/iface/wgbackend/device.go new file mode 100644 index 000000000..4fe5048df --- /dev/null +++ b/client/iface/wgbackend/device.go @@ -0,0 +1,24 @@ +package wgbackend + +import "github.com/netbirdio/wireguard-go/device" + +type ( + Device = device.Device + NoisePresharedKey = device.NoisePresharedKey + NoisePrivateKey = device.NoisePrivateKey + NoisePublicKey = device.NoisePublicKey + Peer = device.Peer + PeerConfig = device.PeerConfig +) + +const ( + LogLevelSilent = device.LogLevelSilent + LogLevelVerbose = device.LogLevelVerbose +) + +var ( + NewDevice = device.NewDevice + NewLogger = device.NewLogger + SetMaxBatchSizeOverride = device.SetMaxBatchSizeOverride + SetPreallocatedBuffersPerPool = device.SetPreallocatedBuffersPerPool +) diff --git a/client/iface/wgbackend/doc.go b/client/iface/wgbackend/doc.go new file mode 100644 index 000000000..3274f40f6 --- /dev/null +++ b/client/iface/wgbackend/doc.go @@ -0,0 +1,5 @@ +// Package wgbackend is the single place in the client's hand-written code that names +// the WireGuard implementation. Other packages import it instead of the wireguard-go +// packages, so swapping the implementation only touches the aliases here and the +// generated mocks. +package wgbackend diff --git a/client/iface/wgbackend/ipc_unix.go b/client/iface/wgbackend/ipc_unix.go new file mode 100644 index 000000000..b7f191e02 --- /dev/null +++ b/client/iface/wgbackend/ipc_unix.go @@ -0,0 +1,10 @@ +//go:build linux || darwin || freebsd || openbsd + +package wgbackend + +import "github.com/netbirdio/wireguard-go/ipc" + +var ( + UAPIListen = ipc.UAPIListen + UAPIOpen = ipc.UAPIOpen +) diff --git a/client/iface/wgbackend/ipc_windows.go b/client/iface/wgbackend/ipc_windows.go new file mode 100644 index 000000000..4d0aa941e --- /dev/null +++ b/client/iface/wgbackend/ipc_windows.go @@ -0,0 +1,5 @@ +package wgbackend + +import "github.com/netbirdio/wireguard-go/ipc" + +var UAPIListen = ipc.UAPIListen diff --git a/client/iface/wgbackend/tun.go b/client/iface/wgbackend/tun.go new file mode 100644 index 000000000..f5df72536 --- /dev/null +++ b/client/iface/wgbackend/tun.go @@ -0,0 +1,19 @@ +package wgbackend + +import ( + "github.com/netbirdio/wireguard-go/tun" + "github.com/netbirdio/wireguard-go/tun/netstack" +) + +type ( + // TUN is the packet device the WireGuard device reads from and writes to. + TUN = tun.Device + // TUNEvent is a status change reported by a TUN. + TUNEvent = tun.Event + // NetstackNet is the userspace network stack a netstack TUN is attached to. + NetstackNet = netstack.Net +) + +const TUNEventDown = tun.EventDown + +var CreateNetTUN = netstack.CreateNetTUN diff --git a/client/iface/wgbackend/tun_linux.go b/client/iface/wgbackend/tun_linux.go new file mode 100644 index 000000000..0b3cbf36f --- /dev/null +++ b/client/iface/wgbackend/tun_linux.go @@ -0,0 +1,5 @@ +package wgbackend + +import "github.com/netbirdio/wireguard-go/tun" + +var CreateUnmonitoredTUNFromFD = tun.CreateUnmonitoredTUNFromFD diff --git a/client/iface/wgbackend/tun_unix.go b/client/iface/wgbackend/tun_unix.go new file mode 100644 index 000000000..12b39c36d --- /dev/null +++ b/client/iface/wgbackend/tun_unix.go @@ -0,0 +1,10 @@ +//go:build linux || darwin || freebsd || openbsd + +package wgbackend + +import "github.com/netbirdio/wireguard-go/tun" + +var ( + CreateTUN = tun.CreateTUN + CreateTUNFromFile = tun.CreateTUNFromFile +) diff --git a/client/iface/wgbackend/tun_windows.go b/client/iface/wgbackend/tun_windows.go new file mode 100644 index 000000000..7d6a8ceac --- /dev/null +++ b/client/iface/wgbackend/tun_windows.go @@ -0,0 +1,7 @@ +package wgbackend + +import "github.com/netbirdio/wireguard-go/tun" + +type NativeTUN = tun.NativeTun + +var CreateTUNWithRequestedGUID = tun.CreateTUNWithRequestedGUID diff --git a/client/internal/acl/mocks/iface_mapper.go b/client/internal/acl/mocks/iface_mapper.go index f8cca1c2d..b4cff0c59 100644 --- a/client/internal/acl/mocks/iface_mapper.go +++ b/client/internal/acl/mocks/iface_mapper.go @@ -8,10 +8,10 @@ import ( reflect "reflect" gomock "go.uber.org/mock/gomock" - wgdevice "golang.zx2c4.com/wireguard/device" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) // MockIFaceMapper is a mock of IFaceMapper interface. @@ -108,10 +108,10 @@ func (mr *MockIFaceMapperMockRecorder) GetDevice() *gomock.Call { } // GetWGDevice mocks base method. -func (m *MockIFaceMapper) GetWGDevice() *wgdevice.Device { +func (m *MockIFaceMapper) GetWGDevice() *wgbackend.Device { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetWGDevice") - ret0, _ := ret[0].(*wgdevice.Device) + ret0, _ := ret[0].(*wgbackend.Device) return ret0 } diff --git a/client/internal/dns/response_writer.go b/client/internal/dns/response_writer.go index 287cf28b0..39ea2cd5e 100644 --- a/client/internal/dns/response_writer.go +++ b/client/internal/dns/response_writer.go @@ -7,14 +7,15 @@ import ( "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/miekg/dns" - "golang.zx2c4.com/wireguard/tun" + + "github.com/netbirdio/netbird/client/iface/wgbackend" ) type responseWriter struct { local net.Addr remote net.Addr packet gopacket.Packet - device tun.Device + device wgbackend.TUN } // LocalAddr returns the net.Addr of the server diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go index 777594272..59d5c0124 100644 --- a/client/internal/dns/server_test.go +++ b/client/internal/dns/server_test.go @@ -15,7 +15,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/firewall/uspfilter" @@ -23,6 +22,7 @@ import ( "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" "github.com/netbirdio/netbird/client/internal/dns/local" "github.com/netbirdio/netbird/client/internal/dns/test" "github.com/netbirdio/netbird/client/internal/dns/types" @@ -83,7 +83,7 @@ func (w *mocWGIface) GetStats(_ string) (configurer.WGStats, error) { return configurer.WGStats{}, nil } -func (w *mocWGIface) GetNet() *netstack.Net { +func (w *mocWGIface) GetNet() *wgbackend.NetstackNet { return nil } diff --git a/client/internal/dns/service_memory.go b/client/internal/dns/service_memory.go index e8c036076..938a6d489 100644 --- a/client/internal/dns/service_memory.go +++ b/client/internal/dns/service_memory.go @@ -108,7 +108,7 @@ func (s *ServiceViaMemory) filterDNSTraffic() error { if s.tcpDNS == nil { if dev := s.wgInterface.GetDevice(); dev != nil { // MTU only affects TCP segment sizing; DNS messages are small so this has no practical impact. - s.tcpDNS = newTCPDNSServer(s.dnsMux, dev.Device, s.runtimeIP, uint16(s.runtimePort), iface.DefaultMTU) + s.tcpDNS = newTCPDNSServer(s.dnsMux, dev.TUN, s.runtimeIP, uint16(s.runtimePort), iface.DefaultMTU) } } @@ -143,7 +143,7 @@ func (s *ServiceViaMemory) filterDNSTraffic() error { writer := &responseWriter{ remote: remoteAddrFromPacket(packet), packet: packet, - device: dev.Device, + device: dev.TUN, } go s.dnsMux.ServeDNS(writer, msg) return true diff --git a/client/internal/dns/tcpstack.go b/client/internal/dns/tcpstack.go index 88e72e767..9419b53dc 100644 --- a/client/internal/dns/tcpstack.go +++ b/client/internal/dns/tcpstack.go @@ -12,7 +12,6 @@ import ( "github.com/miekg/dns" log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/tun" "gvisor.dev/gvisor/pkg/buffer" "gvisor.dev/gvisor/pkg/tcpip" "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" @@ -21,6 +20,8 @@ import ( "gvisor.dev/gvisor/pkg/tcpip/stack" "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" "gvisor.dev/gvisor/pkg/waiter" + + "github.com/netbirdio/netbird/client/iface/wgbackend" ) const ( @@ -38,7 +39,7 @@ type tcpDNSServer struct { s *stack.Stack ep *dnsEndpoint mux *dns.ServeMux - tunDev tun.Device + tunDev wgbackend.TUN ip netip.Addr port uint16 mtu uint16 @@ -49,7 +50,7 @@ type tcpDNSServer struct { timer *time.Timer } -func newTCPDNSServer(mux *dns.ServeMux, tunDev tun.Device, ip netip.Addr, port uint16, mtu uint16) *tcpDNSServer { +func newTCPDNSServer(mux *dns.ServeMux, tunDev wgbackend.TUN, ip netip.Addr, port uint16, mtu uint16) *tcpDNSServer { return &tcpDNSServer{ mux: mux, tunDev: tunDev, @@ -270,7 +271,7 @@ func (t *tcpDNSServer) handleTCPDNS(r *tcp.ForwarderRequest) { // dnsEndpoint implements stack.LinkEndpoint for writing packets back via the tun device. type dnsEndpoint struct { dispatcher stack.NetworkDispatcher - tunDev tun.Device + tunDev wgbackend.TUN mtu atomic.Uint32 } diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go index 72fc0450c..863962f9b 100644 --- a/client/internal/dns/upstream.go +++ b/client/internal/dns/upstream.go @@ -44,10 +44,10 @@ import ( "github.com/miekg/dns" log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" "github.com/netbirdio/netbird/client/internal/dns/resutil" "github.com/netbirdio/netbird/client/internal/dns/types" "github.com/netbirdio/netbird/client/internal/peer" @@ -714,7 +714,7 @@ func toTCPClient(c *dns.Client) *dns.Client { // ExchangeWithNetstack performs a DNS exchange using netstack for dialing. // This is needed when netstack is enabled to reach peer IPs through the tunnel. -func ExchangeWithNetstack(ctx context.Context, nsNet *netstack.Net, r *dns.Msg, upstream string) (*dns.Msg, error) { +func ExchangeWithNetstack(ctx context.Context, nsNet *wgbackend.NetstackNet, r *dns.Msg, upstream string) (*dns.Msg, error) { // If request came in over TCP, go straight to TCP upstream if dnsProtocolFromContext(ctx) == protoTCP { rm, err := netstackExchange(ctx, nsNet, r, upstream, protoTCP) @@ -758,7 +758,7 @@ func ExchangeWithNetstack(ctx context.Context, nsNet *netstack.Net, r *dns.Msg, return reply, nil } -func netstackExchange(ctx context.Context, nsNet *netstack.Net, r *dns.Msg, upstream, network string) (*dns.Msg, error) { +func netstackExchange(ctx context.Context, nsNet *wgbackend.NetstackNet, r *dns.Msg, upstream, network string) (*dns.Msg, error) { conn, err := nsNet.DialContext(ctx, network, upstream) if err != nil { return nil, fmt.Errorf("with %s: %w", network, err) diff --git a/client/internal/dns/upstream_general.go b/client/internal/dns/upstream_general.go index dc841757b..b726d431b 100644 --- a/client/internal/dns/upstream_general.go +++ b/client/internal/dns/upstream_general.go @@ -9,15 +9,15 @@ import ( "time" "github.com/miekg/dns" - "golang.zx2c4.com/wireguard/tun/netstack" + "github.com/netbirdio/netbird/client/iface/wgbackend" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/shared/management/domain" ) type upstreamResolver struct { *upstreamResolverBase - nsNet *netstack.Net + nsNet *wgbackend.NetstackNet } func newUpstreamResolver( diff --git a/client/internal/dns/upstream_test.go b/client/internal/dns/upstream_test.go index 4c2784545..312c1f226 100644 --- a/client/internal/dns/upstream_test.go +++ b/client/internal/dns/upstream_test.go @@ -13,10 +13,10 @@ import ( "github.com/miekg/dns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" "github.com/netbirdio/netbird/client/internal/dns/test" ) @@ -128,7 +128,7 @@ func (m *mockNetstackProvider) ToInterface() *net.Interface { return nil } func (m *mockNetstackProvider) IsUserspaceBind() bool { return false } func (m *mockNetstackProvider) GetFilter() device.PacketFilter { return nil } func (m *mockNetstackProvider) GetDevice() *device.FilteredDevice { return nil } -func (m *mockNetstackProvider) GetNet() *netstack.Net { return nil } +func (m *mockNetstackProvider) GetNet() *wgbackend.NetstackNet { return nil } func (m *mockNetstackProvider) GetInterfaceGUIDString() (string, error) { return "", nil } diff --git a/client/internal/dns/wgiface.go b/client/internal/dns/wgiface.go index 717e16325..9b57bd0c3 100644 --- a/client/internal/dns/wgiface.go +++ b/client/internal/dns/wgiface.go @@ -5,10 +5,9 @@ package dns import ( "net" - "golang.zx2c4.com/wireguard/tun/netstack" - "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) // WGIface defines subset methods of interface required for manager @@ -19,5 +18,5 @@ type WGIface interface { IsUserspaceBind() bool GetFilter() device.PacketFilter GetDevice() *device.FilteredDevice - GetNet() *netstack.Net + GetNet() *wgbackend.NetstackNet } diff --git a/client/internal/dns/wgiface_windows.go b/client/internal/dns/wgiface_windows.go index 347e0233a..11d24ead0 100644 --- a/client/internal/dns/wgiface_windows.go +++ b/client/internal/dns/wgiface_windows.go @@ -1,10 +1,9 @@ package dns import ( - "golang.zx2c4.com/wireguard/tun/netstack" - "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) // WGIface defines subset methods of interface required for manager @@ -14,6 +13,6 @@ type WGIface interface { IsUserspaceBind() bool GetFilter() device.PacketFilter GetDevice() *device.FilteredDevice - GetNet() *netstack.Net + GetNet() *wgbackend.NetstackNet GetInterfaceGUIDString() (string, error) } diff --git a/client/internal/dnsfwd/forwarder.go b/client/internal/dnsfwd/forwarder.go index e3cb597be..9d58384ad 100644 --- a/client/internal/dnsfwd/forwarder.go +++ b/client/internal/dnsfwd/forwarder.go @@ -14,10 +14,10 @@ import ( "github.com/hashicorp/go-multierror" "github.com/miekg/dns" log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/tun/netstack" nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface/wgbackend" "github.com/netbirdio/netbird/client/internal/dns/resutil" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/route" @@ -90,7 +90,7 @@ func NewDNSForwarder(listenAddress netip.AddrPort, ttl uint32, firewall firewall } func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error { - var netstackNet *netstack.Net + var netstackNet *wgbackend.NetstackNet if f.wgIface != nil { netstackNet = f.wgIface.GetNet() } @@ -153,7 +153,7 @@ func (f *DNSForwarder) Listen(entries []*ForwarderEntry) error { return <-errCh } -func (f *DNSForwarder) createUDPListener(netstackNet *netstack.Net) (net.PacketConn, error) { +func (f *DNSForwarder) createUDPListener(netstackNet *wgbackend.NetstackNet) (net.PacketConn, error) { if netstackNet != nil { return netstackNet.ListenUDPAddrPort(f.listenAddress) } @@ -161,7 +161,7 @@ func (f *DNSForwarder) createUDPListener(netstackNet *netstack.Net) (net.PacketC return net.ListenUDP("udp", net.UDPAddrFromAddrPort(f.listenAddress)) } -func (f *DNSForwarder) createTCPListener(netstackNet *netstack.Net) (net.Listener, error) { +func (f *DNSForwarder) createTCPListener(netstackNet *wgbackend.NetstackNet) (net.Listener, error) { if netstackNet != nil { return netstackNet.ListenTCPAddrPort(f.listenAddress) } diff --git a/client/internal/dnsfwd/manager.go b/client/internal/dnsfwd/manager.go index 1c62e908d..474555550 100644 --- a/client/internal/dnsfwd/manager.go +++ b/client/internal/dnsfwd/manager.go @@ -9,11 +9,11 @@ import ( "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/tun/netstack" nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" "github.com/netbirdio/netbird/client/internal/peer" nbdns "github.com/netbirdio/netbird/dns" @@ -28,7 +28,7 @@ const ( // wgIface defines the interface for WireGuard interface operations needed by the DNS forwarder. type wgIface interface { - GetNet() *netstack.Net + GetNet() *wgbackend.NetstackNet Address() wgaddr.Address } diff --git a/client/internal/engine.go b/client/internal/engine.go index c4bdf5121..4de0209b0 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -18,11 +18,9 @@ import ( "time" "github.com/hashicorp/go-multierror" - "github.com/pion/ice/v4" + "github.com/netbirdio/ice/v4" "github.com/pion/stun/v3" log "github.com/sirupsen/logrus" - wgdevice "golang.zx2c4.com/wireguard/device" - "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/anonymize" @@ -36,6 +34,7 @@ import ( nbnetstack "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" "github.com/netbirdio/netbird/client/internal/acl" "github.com/netbirdio/netbird/client/internal/certproof" "github.com/netbirdio/netbird/client/internal/debug" @@ -245,7 +244,7 @@ type Engine struct { // wgInterface. Reaching the device through wgInterface requires // syncMsgMux, which handleSync holds while it adds and removes peers; // SetPerformance must stay reachable exactly when that work is stuck. - wgDevice atomic.Pointer[wgdevice.Device] + wgDevice atomic.Pointer[wgbackend.Device] udpMux *udpmux.UniversalUDPMuxDefault @@ -2703,7 +2702,7 @@ func (e *Engine) stopDNSForwarder() { e.dnsForwardMgr = nil } -func (e *Engine) GetNet() (*netstack.Net, error) { +func (e *Engine) GetNet() (*wgbackend.NetstackNet, error) { e.syncMsgMux.Lock() intf := e.wgInterface e.syncMsgMux.Unlock() diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 4974eca87..14fcbe5ea 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -14,10 +14,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - wgdevice "golang.zx2c4.com/wireguard/device" - "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "github.com/netbirdio/netbird/client/iface/wgbackend" "github.com/netbirdio/netbird/client/internal/stdnet" "github.com/netbirdio/netbird/client/iface" @@ -61,11 +60,11 @@ type MockWGIface struct { SetFilterFunc func(filter device.PacketFilter) error GetFilterFunc func() device.PacketFilter GetDeviceFunc func() *device.FilteredDevice - GetWGDeviceFunc func() *wgdevice.Device + GetWGDeviceFunc func() *wgbackend.Device GetStatsFunc func() (map[string]configurer.WGStats, error) GetInterfaceGUIDStringFunc func() (string, error) GetProxyFunc func() wgproxy.Proxy - GetNetFunc func() *netstack.Net + GetNetFunc func() *wgbackend.NetstackNet LastActivitiesFunc func() map[string]monotime.Time } @@ -149,7 +148,7 @@ func (m *MockWGIface) GetDevice() *device.FilteredDevice { return m.GetDeviceFunc() } -func (m *MockWGIface) GetWGDevice() *wgdevice.Device { +func (m *MockWGIface) GetWGDevice() *wgbackend.Device { return m.GetWGDeviceFunc() } @@ -161,7 +160,7 @@ func (m *MockWGIface) GetProxy() wgproxy.Proxy { return m.GetProxyFunc() } -func (m *MockWGIface) GetNet() *netstack.Net { +func (m *MockWGIface) GetNet() *wgbackend.NetstackNet { return m.GetNetFunc() } diff --git a/client/internal/iface_common.go b/client/internal/iface_common.go index d772a3a03..69d43a237 100644 --- a/client/internal/iface_common.go +++ b/client/internal/iface_common.go @@ -5,14 +5,13 @@ import ( "net/netip" "time" - wgdevice "golang.zx2c4.com/wireguard/device" - "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" "github.com/netbirdio/netbird/client/iface/wgproxy" "github.com/netbirdio/netbird/monotime" ) @@ -37,9 +36,9 @@ type wgIfaceBase interface { SetFilter(filter device.PacketFilter) error GetFilter() device.PacketFilter GetDevice() *device.FilteredDevice - GetWGDevice() *wgdevice.Device + GetWGDevice() *wgbackend.Device GetStats() (map[string]configurer.WGStats, error) - GetNet() *netstack.Net + GetNet() *wgbackend.NetstackNet FullStats() (*configurer.Stats, error) LastActivities() map[string]monotime.Time SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index b8a32b0cb..b3d3a95ae 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "github.com/pion/ice/v4" + "github.com/netbirdio/ice/v4" log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" diff --git a/client/internal/peer/guard/ice_monitor.go b/client/internal/peer/guard/ice_monitor.go index a201dd095..ac6290c10 100644 --- a/client/internal/peer/guard/ice_monitor.go +++ b/client/internal/peer/guard/ice_monitor.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/pion/ice/v4" + "github.com/netbirdio/ice/v4" log "github.com/sirupsen/logrus" icemaker "github.com/netbirdio/netbird/client/internal/peer/ice" diff --git a/client/internal/peer/ice/agent.go b/client/internal/peer/ice/agent.go index a919cded5..54cafd0c8 100644 --- a/client/internal/peer/ice/agent.go +++ b/client/internal/peer/ice/agent.go @@ -6,7 +6,7 @@ import ( "sync" "time" - "github.com/pion/ice/v4" + "github.com/netbirdio/ice/v4" "github.com/pion/logging" "github.com/pion/randutil" log "github.com/sirupsen/logrus" diff --git a/client/internal/peer/ice/config.go b/client/internal/peer/ice/config.go index 9d6b19925..d6047e6dc 100644 --- a/client/internal/peer/ice/config.go +++ b/client/internal/peer/ice/config.go @@ -1,7 +1,7 @@ package ice import ( - "github.com/pion/ice/v4" + "github.com/netbirdio/ice/v4" "github.com/netbirdio/netbird/client/internal/stdnet" ) diff --git a/client/internal/peer/signaler.go b/client/internal/peer/signaler.go index 5e437d96b..590bf3fed 100644 --- a/client/internal/peer/signaler.go +++ b/client/internal/peer/signaler.go @@ -1,7 +1,7 @@ package peer import ( - "github.com/pion/ice/v4" + "github.com/netbirdio/ice/v4" log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go index 257164bbd..03ae19819 100644 --- a/client/internal/peer/worker_ice.go +++ b/client/internal/peer/worker_ice.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/pion/ice/v4" + "github.com/netbirdio/ice/v4" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/iface" diff --git a/client/internal/peer/worker_ice_test.go b/client/internal/peer/worker_ice_test.go index 3089841c2..17f611c7e 100644 --- a/client/internal/peer/worker_ice_test.go +++ b/client/internal/peer/worker_ice_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - "github.com/pion/ice/v4" + "github.com/netbirdio/ice/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/client/internal/routemanager/iface/iface_common.go b/client/internal/routemanager/iface/iface_common.go index 9b7bce751..8e8a507bf 100644 --- a/client/internal/routemanager/iface/iface_common.go +++ b/client/internal/routemanager/iface/iface_common.go @@ -4,10 +4,9 @@ import ( "net" "net/netip" - "golang.zx2c4.com/wireguard/tun/netstack" - "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" ) type wgIfaceBase interface { @@ -20,5 +19,5 @@ type wgIfaceBase interface { IsUserspaceBind() bool GetFilter() device.PacketFilter GetDevice() *device.FilteredDevice - GetNet() *netstack.Net + GetNet() *wgbackend.NetstackNet } diff --git a/client/internal/routemanager/reconcile_test.go b/client/internal/routemanager/reconcile_test.go index bc9693229..7f409c560 100644 --- a/client/internal/routemanager/reconcile_test.go +++ b/client/internal/routemanager/reconcile_test.go @@ -10,10 +10,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" ) @@ -48,7 +48,7 @@ func (m *reconcileWGMock) ToInterface() *net.Interface { return n func (m *reconcileWGMock) IsUserspaceBind() bool { return false } func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil } func (m *reconcileWGMock) GetDevice() *device.FilteredDevice { return nil } -func (m *reconcileWGMock) GetNet() *netstack.Net { return nil } +func (m *reconcileWGMock) GetNet() *wgbackend.NetstackNet { return nil } // TestReconcilePeerAllowedIPs verifies the declarative reconcile re-applies every routed prefix // tracked for the peer (self-heal, independent of refcount level) and stays scoped to that peer. diff --git a/client/ssh/server/server.go b/client/ssh/server/server.go index b32da796e..a33784329 100644 --- a/client/ssh/server/server.go +++ b/client/ssh/server/server.go @@ -20,9 +20,9 @@ import ( log "github.com/sirupsen/logrus" cryptossh "golang.org/x/crypto/ssh" "golang.org/x/exp/maps" - "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgbackend" sshauth "github.com/netbirdio/netbird/client/ssh/auth" "github.com/netbirdio/netbird/client/ssh/detection" "github.com/netbirdio/netbird/shared/auth" @@ -167,7 +167,7 @@ type Server struct { allowSFTP bool jwtEnabled bool - netstackNet *netstack.Net + netstackNet *wgbackend.NetstackNet wgAddress wgaddr.Address @@ -445,7 +445,7 @@ func (s *Server) buildSessionInfo(state *sessionState) SessionInfo { } // SetNetstackNet sets the netstack network for userspace networking -func (s *Server) SetNetstackNet(net *netstack.Net) { +func (s *Server) SetNetstackNet(net *wgbackend.NetstackNet) { s.mu.Lock() defer s.mu.Unlock() s.netstackNet = net diff --git a/combined/cmd/admin.go b/combined/cmd/admin.go index 66fac4ac9..15dc15130 100644 --- a/combined/cmd/admin.go +++ b/combined/cmd/admin.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/dexidp/dex/storage" + "github.com/netbirdio/dex/storage" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" diff --git a/go.mod b/go.mod index beb612a72..f93141d5c 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/golang/protobuf v1.5.4 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 - github.com/kardianos/service v1.2.3-0.20240613133416-becf2eb62b83 + github.com/kardianos/service v1.3.0 github.com/onsi/ginkgo v1.16.5 github.com/onsi/gomega v1.34.1 github.com/rs/cors v1.8.0 @@ -23,7 +23,6 @@ require ( github.com/vishvananda/netlink v1.3.1 golang.org/x/crypto v0.55.0 golang.org/x/sys v0.47.0 - golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 golang.zx2c4.com/wireguard/windows v0.5.3 google.golang.org/grpc v1.80.0 @@ -48,8 +47,6 @@ require ( github.com/creack/pty v1.1.24 github.com/crowdsecurity/crowdsec v1.7.7 github.com/crowdsecurity/go-cs-bouncer v0.0.21 - github.com/dexidp/dex v2.13.0+incompatible - github.com/dexidp/dex/api/v2 v2.4.0 github.com/docker/docker v28.0.1+incompatible github.com/docker/go-connections v0.6.0 github.com/ebitengine/purego v0.9.1 @@ -85,16 +82,19 @@ require ( github.com/miekg/dns v1.1.72 github.com/mitchellh/hashstructure/v2 v2.0.2 github.com/moby/moby/api v1.54.1 + github.com/netbirdio/dex v0.244.1-0.20261021152327-0319b501f1a6 + github.com/netbirdio/dex/api/v2 v2.0.0-20261021115841-9f3e6d4c2ce3 github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 - github.com/netbirdio/management-integrations/integrations v0.0.0-20261006132740-6e6b0cb01f2e + github.com/netbirdio/ice/v4 v4.0.0-20261021152109-d0e5dfacb548 + github.com/netbirdio/management-integrations/integrations v0.0.0-20261021190039-6c1585eb11de github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 + github.com/netbirdio/wireguard-go v0.0.0-20261021152105-0de46b309cdb github.com/oapi-codegen/runtime v1.1.2 github.com/okta/okta-sdk-golang/v2 v2.18.0 github.com/ory/dockertest/v4 v4.0.0 github.com/oschwald/maxminddb-golang v1.12.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 - github.com/pion/ice/v4 v4.0.0-00010101000000-000000000000 github.com/pion/logging v0.2.4 github.com/pion/randutil v0.1.0 github.com/pion/stun/v2 v2.0.0 @@ -148,6 +148,8 @@ require ( howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 ) +require golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect + require ( cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect @@ -363,20 +365,8 @@ require ( rsc.io/qr v0.2.0 // indirect ) -replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 - -replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-20231030152038-ef1ed2a27949 - -replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20261016113635-bd0cb75a3df0 - replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 -replace github.com/pion/ice/v4 => github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 - -replace github.com/dexidp/dex => github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5 - -replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1 - replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0 replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d diff --git a/go.sum b/go.sum index 82e2daf78..a6d3dedca 100644 --- a/go.sum +++ b/go.sum @@ -424,6 +424,8 @@ github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI= +github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= @@ -522,26 +524,24 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5 h1:3PwQv8aR46qN2u16+Dv6udnH3sbVKX5KrGwF35CKSI0= -github.com/netbirdio/dex v0.244.1-0.20260716205454-a163de3129e5/go.mod h1:IHH+H8vK2GfqtIt5u/5OdPh18yk0oDHuj2vz5+Goetg= -github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1 h1:neE7z+FPUkldl3faK/Jt+hJK2L+1XfQ1W33TQhU9m88= -github.com/netbirdio/dex/api/v2 v2.0.0-20260512110716-8d70ad8647c1/go.mod h1:awuTyT29CYALpEyET0S307EgNlPWrc7fFKRAyhsO45M= +github.com/netbirdio/dex v0.244.1-0.20261021152327-0319b501f1a6 h1:DVyt4qYA79/4eVdw7BNKDCmR9wti2YT3iHvIdPiV4UI= +github.com/netbirdio/dex v0.244.1-0.20261021152327-0319b501f1a6/go.mod h1:98h6OBd3fEU5lyQ5dhLWtSna5IUGjBz5awC0NmtS6s4= +github.com/netbirdio/dex/api/v2 v2.0.0-20261021115841-9f3e6d4c2ce3 h1:5yuaucSBsXe/wcXTdjo2UKmY352xK3ESWzbwfOyuQvw= +github.com/netbirdio/dex/api/v2 v2.0.0-20261021115841-9f3e6d4c2ce3/go.mod h1:SnT8viXNefO9j0z9gdfZ8vk7t1TJ2CmbJZkIL4fuDus= github.com/netbirdio/easyjson v0.9.0 h1:6Nw2lghSVuy8RSkAYDhDv1thBVEmfVbKZnV7T7Z6Aus= github.com/netbirdio/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 h1:pBxXEsxcsO3qVUND//5j1kelYlO57x5IrRviNF0+0iA= github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8/go.mod h1:mFViabv4PpnoDw9w7W21a7xux6APA4q7KQZRsv4BCl8= -github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI= -github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51/go.mod h1:ZSIbPdBn5hePO8CpF1PekH2SfpTxg1PDhEwtbqZS7R8= -github.com/netbirdio/management-integrations/integrations v0.0.0-20261006132740-6e6b0cb01f2e h1:SnDCreUnY+QDxKPueUoNrniMALCu2VFkXTCr/RIZjdg= -github.com/netbirdio/management-integrations/integrations v0.0.0-20261006132740-6e6b0cb01f2e/go.mod h1:n47r67ZSPgwSmT/Z1o48JjZQW9YJ6m/6Bd/uAXkL3Pg= -github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9axERMVN63dqyFqnvuD+EMJHzM7mNGON8= -github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= +github.com/netbirdio/ice/v4 v4.0.0-20261021152109-d0e5dfacb548 h1:dMRtLZZm7qpyfuw2uRKmCYySYz51wihEWY3JUYMpuK4= +github.com/netbirdio/ice/v4 v4.0.0-20261021152109-d0e5dfacb548/go.mod h1:UsaaFHJPfIMNzvOybklN9AO3v/4YGj6bCC1vpmYA0vo= +github.com/netbirdio/management-integrations/integrations v0.0.0-20261021190039-6c1585eb11de h1:aCmxkLAjH3pF/88eUAXp9yMOwGtcvNTC5Mqszm9xuo0= +github.com/netbirdio/management-integrations/integrations v0.0.0-20261021190039-6c1585eb11de/go.mod h1:LLvpw/iWbXX9+7HF7ueqkyE7SoFaeqoCmbskOQL9gbs= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ= github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d h1:DV0FHFhNAaU69JHXKclYQWr/ZJoc2BA/ZpqlnX8l22U= github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20261009120932-4e087a8bfd6d/go.mod h1:62UsqQRqanuCbFi6xElIw3/2X+yGF+TKlyiWImA5taU= -github.com/netbirdio/wireguard-go v0.0.0-20261016113635-bd0cb75a3df0 h1:nCK1YzDybjbAfrx+C2QPmcQ+7jYj4Rcb3FskqTNZEXo= -github.com/netbirdio/wireguard-go v0.0.0-20261016113635-bd0cb75a3df0/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +github.com/netbirdio/wireguard-go v0.0.0-20261021152105-0de46b309cdb h1:3X7pVeZeneq4MK7hvvqeSGi1ir6OElgIqHd6cI4KsXo= +github.com/netbirdio/wireguard-go v0.0.0-20261021152105-0de46b309cdb/go.mod h1:Vk/c438m8FV2hHcT/6Efp8xe/JhHTzf3eUcuaohKKkw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= @@ -889,7 +889,6 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -969,6 +968,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4= +golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= diff --git a/idp/dex/config.go b/idp/dex/config.go index 00b5ce745..009426602 100644 --- a/idp/dex/config.go +++ b/idp/dex/config.go @@ -14,9 +14,9 @@ import ( "golang.org/x/crypto/bcrypt" "gopkg.in/yaml.v3" - "github.com/dexidp/dex/server" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/sql" + "github.com/netbirdio/dex/server" + "github.com/netbirdio/dex/storage" + "github.com/netbirdio/dex/storage/sql" "github.com/netbirdio/netbird/idp/dex/web" ) diff --git a/idp/dex/connector.go b/idp/dex/connector.go index fb20fdcc3..a4653cc48 100644 --- a/idp/dex/connector.go +++ b/idp/dex/connector.go @@ -8,7 +8,7 @@ import ( "fmt" "strings" - "github.com/dexidp/dex/storage" + "github.com/netbirdio/dex/storage" ) // ConnectorConfig represents the configuration for an identity provider connector diff --git a/idp/dex/connector_test.go b/idp/dex/connector_test.go index 4253e02b7..780cfebcb 100644 --- a/idp/dex/connector_test.go +++ b/idp/dex/connector_test.go @@ -8,8 +8,8 @@ import ( "path/filepath" "testing" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/sql" + "github.com/netbirdio/dex/storage" + "github.com/netbirdio/dex/storage/sql" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/idp/dex/provider.go b/idp/dex/provider.go index 95c511c01..8062dd00b 100644 --- a/idp/dex/provider.go +++ b/idp/dex/provider.go @@ -16,12 +16,12 @@ import ( "sync" "time" - dexapi "github.com/dexidp/dex/api/v2" - "github.com/dexidp/dex/server" - "github.com/dexidp/dex/server/signer" - "github.com/dexidp/dex/storage" "github.com/go-jose/go-jose/v4" "github.com/google/uuid" + dexapi "github.com/netbirdio/dex/api/v2" + "github.com/netbirdio/dex/server" + "github.com/netbirdio/dex/server/signer" + "github.com/netbirdio/dex/storage" "github.com/prometheus/client_golang/prometheus" "golang.org/x/crypto/bcrypt" "google.golang.org/grpc" diff --git a/idp/dex/provider_test.go b/idp/dex/provider_test.go index 5e132d544..5ea1260a8 100644 --- a/idp/dex/provider_test.go +++ b/idp/dex/provider_test.go @@ -12,9 +12,9 @@ import ( "path/filepath" "testing" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/memory" - sqllib "github.com/dexidp/dex/storage/sql" + "github.com/netbirdio/dex/storage" + "github.com/netbirdio/dex/storage/memory" + sqllib "github.com/netbirdio/dex/storage/sql" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/idp/dex/sqlite_cgo.go b/idp/dex/sqlite_cgo.go index 5de66f647..0339b48fd 100644 --- a/idp/dex/sqlite_cgo.go +++ b/idp/dex/sqlite_cgo.go @@ -3,7 +3,7 @@ package dex import ( - sql "github.com/dexidp/dex/storage/sql" + sql "github.com/netbirdio/dex/storage/sql" ) // newSQLite3 builds the dex SQLite3 config. CGO builds use the upstream diff --git a/idp/dex/sqlite_nocgo.go b/idp/dex/sqlite_nocgo.go index 4def12143..f67fa64e3 100644 --- a/idp/dex/sqlite_nocgo.go +++ b/idp/dex/sqlite_nocgo.go @@ -3,7 +3,7 @@ package dex import ( - sql "github.com/dexidp/dex/storage/sql" + sql "github.com/netbirdio/dex/storage/sql" ) // newSQLite3 for non-CGO builds. The dex SQLite3 stub has no fields and its diff --git a/idp/sdk/sdk.go b/idp/sdk/sdk.go index d2189135b..6a5d3d49c 100644 --- a/idp/sdk/sdk.go +++ b/idp/sdk/sdk.go @@ -4,7 +4,7 @@ package sdk import ( "context" - "github.com/dexidp/dex/storage" + "github.com/netbirdio/dex/storage" "github.com/netbirdio/netbird/idp/dex" ) diff --git a/management/cmd/admin.go b/management/cmd/admin.go index e5c0f6ac9..14591fa99 100644 --- a/management/cmd/admin.go +++ b/management/cmd/admin.go @@ -6,7 +6,7 @@ import ( "path" "path/filepath" - "github.com/dexidp/dex/storage" + "github.com/netbirdio/dex/storage" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" diff --git a/management/cmd/admin/admin.go b/management/cmd/admin/admin.go index bd56af39b..702725fd5 100644 --- a/management/cmd/admin/admin.go +++ b/management/cmd/admin/admin.go @@ -13,7 +13,7 @@ import ( "strings" "time" - "github.com/dexidp/dex/storage" + "github.com/netbirdio/dex/storage" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "golang.org/x/crypto/bcrypt" diff --git a/management/cmd/admin/admin_test.go b/management/cmd/admin/admin_test.go index dd1b8ed06..07e0f10da 100644 --- a/management/cmd/admin/admin_test.go +++ b/management/cmd/admin/admin_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/dexidp/dex/storage" - "github.com/dexidp/dex/storage/memory" + "github.com/netbirdio/dex/storage" + "github.com/netbirdio/dex/storage/memory" "github.com/spf13/cobra" "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" diff --git a/management/server/identity_provider.go b/management/server/identity_provider.go index 764d8598b..a0f6ff457 100644 --- a/management/server/identity_provider.go +++ b/management/server/identity_provider.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/dexidp/dex/storage" + "github.com/netbirdio/dex/storage" "github.com/rs/xid" log "github.com/sirupsen/logrus" diff --git a/management/server/idp/dex.go b/management/server/idp/dex.go index 7d25c6ed0..e9e3d4d90 100644 --- a/management/server/idp/dex.go +++ b/management/server/idp/dex.go @@ -7,7 +7,7 @@ import ( "strings" "sync" - "github.com/dexidp/dex/api/v2" + "github.com/netbirdio/dex/api/v2" log "github.com/sirupsen/logrus" "golang.org/x/crypto/bcrypt" "google.golang.org/grpc" diff --git a/management/server/idp/embedded.go b/management/server/idp/embedded.go index b045d6ad6..8125b0449 100644 --- a/management/server/idp/embedded.go +++ b/management/server/idp/embedded.go @@ -11,8 +11,8 @@ import ( "path" "strings" - "github.com/dexidp/dex/storage" "github.com/google/uuid" + "github.com/netbirdio/dex/storage" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/idp/dex" diff --git a/management/server/instance/manager.go b/management/server/instance/manager.go index 2c355bb3b..33ab57643 100644 --- a/management/server/instance/manager.go +++ b/management/server/instance/manager.go @@ -12,8 +12,8 @@ import ( "sync" "time" - "github.com/dexidp/dex/storage" goversion "github.com/hashicorp/go-version" + "github.com/netbirdio/dex/storage" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/management/server/idp" diff --git a/management/server/instance/manager_test.go b/management/server/instance/manager_test.go index 5ffb016de..74084634a 100644 --- a/management/server/instance/manager_test.go +++ b/management/server/instance/manager_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/dexidp/dex/storage" + "github.com/netbirdio/dex/storage" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" From 0fc596e2e1a80aacf2502ce7b750196b6b510fcd Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 22 Oct 2026 14:32:52 +0200 Subject: [PATCH 195/209] [management] Skip inactivity expiration rescan on connect when a job is scheduled (#8287) * [management] Skip inactivity expiration rescan when a job is scheduled Every SSO peer connect and disconnect cancelled the account's inactivity expiration job and re-armed it, loading every inactivity-enabled peer of the account each time. During a management deploy, when all peers reconnect, that is a full account scan per peer. A connecting peer cannot expire, and a disconnecting peer expires after every peer that was already disconnected, so neither can move the next run earlier. Connect and disconnect now leave an already scheduled job in place, as login expiration already does. Settings and per-peer toggle changes still cancel and reschedule, since they can move the run earlier. * [management] Keep inactivity rescan on disconnect A disconnect that lands while the inactivity job is finishing its last run saw the job as scheduled and skipped arming a new one; the scheduler then removed the job, leaving the disconnected peer without an inactivity timer. Only connect, where the peer cannot expire, now skips a scheduled job; disconnect cancels and re-arms as before. The test asserts on the synchronous Cancel call instead of sleeping. --- management/server/account.go | 15 +++++++++ management/server/account_test.go | 52 +++++++++++++++++++++++++++++++ management/server/peer.go | 4 +-- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/management/server/account.go b/management/server/account.go index 68150a947..7cab037a2 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -806,6 +806,21 @@ func (am *DefaultAccountManager) schedulePeerLoginExpiration(ctx context.Context } } +// schedulePeerInactivityExpiration arms the account's inactivity expiration job for a +// connecting peer unless one is already scheduled: a connected peer cannot expire, so it +// never moves the scheduled run earlier. Disconnects must not skip a running job, which may +// be finishing its last run without seeing the newly disconnected peer. +func (am *DefaultAccountManager) schedulePeerInactivityExpiration(ctx context.Context, accountID string) { + if am.peerInactivityExpiry.IsSchedulerRunning(accountID) { + log.WithContext(ctx).Tracef("peer inactivity expiration job for account %s is already scheduled", accountID) + return + } + jobCtx := context.WithoutCancel(ctx) + if nextRun, ok := am.getNextInactivePeerExpiration(ctx, accountID); ok { + go am.peerInactivityExpiry.Schedule(jobCtx, nextRun, accountID, am.peerInactivityExpirationJob(jobCtx, accountID)) + } +} + // peerInactivityExpirationJob marks login expired for all inactive peers and returns the minimum duration in which the next peer of the account will expire by inactivity if found func (am *DefaultAccountManager) peerInactivityExpirationJob(ctx context.Context, accountID string) func() (time.Duration, bool) { return func() (time.Duration, bool) { diff --git a/management/server/account_test.go b/management/server/account_test.go index 983ee8680..7acc72d1f 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "testing" "testing/synctest" "time" @@ -2277,6 +2278,57 @@ func TestDefaultAccountManager_MarkPeerDisconnected_SchedulesInactivityExpiratio } } +func TestDefaultAccountManager_PeerConnect_KeepsScheduledInactivityExpiration(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + key, err := wgtypes.GenerateKey() + require.NoError(t, err, "unable to generate WireGuard key") + peerPubKey := key.PublicKey().String() + + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: peerPubKey, + Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, + InactivityExpirationEnabled: true, + }, false) + require.NoError(t, err, "unable to add peer") + + _, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{ + PeerLoginExpiration: time.Hour, + PeerLoginExpirationEnabled: true, + PeerInactivityExpiration: time.Hour, + PeerInactivityExpirationEnabled: true, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err, "expecting to update account settings successfully but got error") + + // The account already has an inactivity job scheduled. A connecting peer + // cannot expire, so connect must leave that job alone instead of cancelling + // it and re-scanning every peer of the account. Cancel runs synchronously, + // so counting it observes the decision deterministically. + var cancels atomic.Int32 + manager.peerInactivityExpiry = &MockScheduler{ + IsSchedulerRunningFunc: func(ID string) bool { return ID == accountID }, + CancelFunc: func(ctx context.Context, IDs []string) { cancels.Add(1) }, + ScheduleFunc: func(ctx context.Context, in time.Duration, ID string, job func() (nextRunIn time.Duration, reschedule bool)) { + }, + } + + streamStartTime := time.Now().UTC() + err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano(), nil) + require.NoError(t, err, "unable to mark peer connected") + assert.Zero(t, cancels.Load(), "connect must not cancel a scheduled inactivity job") + + // A disconnect still cancels and re-arms: the running job may be finishing + // its last run without having seen this peer as disconnected. + err = manager.MarkPeerDisconnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano()) + require.NoError(t, err, "unable to mark peer disconnected") + assert.Equal(t, int32(1), cancels.Load(), "disconnect must cancel and re-arm the inactivity job") +} + func TestDefaultAccountManager_MarkPeerDisconnected_SkipsInactivityExpirationWhenDisabled(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err, "unable to create account manager") diff --git a/management/server/peer.go b/management/server/peer.go index aa3abcb86..5b33ca9f8 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -140,7 +140,7 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK return nil } -// schedulePeerExpirations reschedules the account's login/inactivity expiration +// schedulePeerExpirations arms the account's login/inactivity expiration // timers for an SSO peer that just connected. func (am *DefaultAccountManager) schedulePeerExpirations(ctx context.Context, accountID string, peer *nbpeer.Peer) error { if !peer.AddedWithSSOLogin() { @@ -154,7 +154,7 @@ func (am *DefaultAccountManager) schedulePeerExpirations(ctx context.Context, ac am.schedulePeerLoginExpiration(ctx, accountID) } if peer.InactivityExpirationEnabled && settings.PeerInactivityExpirationEnabled { - am.checkAndSchedulePeerInactivityExpiration(ctx, accountID) + am.schedulePeerInactivityExpiration(ctx, accountID) } return nil } From c476196653658dc872b93458e9385a606f778a70 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:12:17 +0200 Subject: [PATCH 196/209] [management] Add additional indexes for groups, events, pats and access logs (#8289) --- .../modules/reverseproxy/accesslogs/accesslogentry.go | 4 ++-- management/server/activity/event.go | 4 ++-- management/server/types/group.go | 2 +- management/server/types/personal_access_token.go | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go b/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go index 6705eab1a..a0b6c60df 100644 --- a/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go +++ b/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go @@ -22,9 +22,9 @@ const ( type AccessLogEntry struct { ID string `gorm:"primaryKey"` - AccountID string `gorm:"index"` + AccountID string `gorm:"index;index:idx_access_log_entries_account_timestamp,priority:1,option:CONCURRENTLY"` ServiceID string `gorm:"index"` - Timestamp time.Time `gorm:"index"` + Timestamp time.Time `gorm:"index;index:idx_access_log_entries_account_timestamp,priority:2"` GeoLocation peer.Location `gorm:"embedded;embeddedPrefix:location_"` SubdivisionCode string Method string `gorm:"index"` diff --git a/management/server/activity/event.go b/management/server/activity/event.go index 8fd5e3371..2d66418a3 100644 --- a/management/server/activity/event.go +++ b/management/server/activity/event.go @@ -17,7 +17,7 @@ type ActivityDescriber interface { //nolint:revive // Event represents a network/system activity event. type Event struct { // Timestamp of the event - Timestamp time.Time + Timestamp time.Time `gorm:"index:idx_events_account_timestamp,priority:2"` // Activity that was performed during the event Activity Activity `gorm:"type:integer"` // ID of the event (can be empty, meaning that it wasn't yet generated) @@ -31,7 +31,7 @@ type Event struct { // TargetID is the ID of an object that was effected by the event (e.g., a peer) TargetID string // AccountID is the ID of an account where the event happened - AccountID string `gorm:"index"` + AccountID string `gorm:"index;index:idx_events_account_timestamp,priority:1,option:CONCURRENTLY"` // Meta of the event, e.g. deleted peer information like name, IP, etc Meta map[string]any `gorm:"serializer:json"` diff --git a/management/server/types/group.go b/management/server/types/group.go index ac0a2a7f2..84fcda828 100644 --- a/management/server/types/group.go +++ b/management/server/types/group.go @@ -40,7 +40,7 @@ type Group struct { type GroupPeer struct { AccountID string `gorm:"index"` GroupID string `gorm:"primaryKey"` - PeerID string `gorm:"primaryKey"` + PeerID string `gorm:"primaryKey;index:idx_group_peers_peer_id,option:CONCURRENTLY"` } func (g *Group) LoadGroupPeers() { diff --git a/management/server/types/personal_access_token.go b/management/server/types/personal_access_token.go index 0aa6b152b..8351928b5 100644 --- a/management/server/types/personal_access_token.go +++ b/management/server/types/personal_access_token.go @@ -31,7 +31,7 @@ type PersonalAccessToken struct { // User is a reference to Account that this object belongs UserID string `gorm:"index"` Name string - HashedToken string + HashedToken string `gorm:"index:idx_personal_access_tokens_hashed_token"` ExpirationDate *time.Time // scope could be added in future CreatedBy string From 431e20135521f5f7a8bb425f9c9ea86bf0df9f93 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:13:11 +0200 Subject: [PATCH 197/209] [management] reduce calls to update last usage for users and pats (#8290) --- .../server/http/middleware/auth_middleware.go | 7 ++- .../http/middleware/auth_middleware_test.go | 63 +++++++++++++++++++ .../store/sql_store_personal_access_token.go | 26 +++++--- .../sql_store_personal_access_token_test.go | 19 ++++++ management/server/store/sql_store_user.go | 34 ++++++---- .../server/store/sql_store_user_test.go | 47 ++++++++++++++ .../server/types/personal_access_token.go | 2 + management/server/user.go | 7 ++- 8 files changed, 178 insertions(+), 27 deletions(-) diff --git a/management/server/http/middleware/auth_middleware.go b/management/server/http/middleware/auth_middleware.go index 7c21b6a55..88b4fcdb9 100644 --- a/management/server/http/middleware/auth_middleware.go +++ b/management/server/http/middleware/auth_middleware.go @@ -185,9 +185,10 @@ func (m *AuthMiddleware) checkPATFromRequest(r *http.Request, authHeaderParts [] return fmt.Errorf("token expired") } - err = m.authManager.MarkPATUsed(ctx, pat.ID) - if err != nil { - return err + if pat.LastUsed == nil || time.Since(*pat.LastUsed) >= types.PATLastUsedUpdateInterval { + if err := m.authManager.MarkPATUsed(ctx, pat.ID); err != nil { + return err + } } userAuth := auth.UserAuth{ diff --git a/management/server/http/middleware/auth_middleware_test.go b/management/server/http/middleware/auth_middleware_test.go index 9e60af1fa..127ca06d1 100644 --- a/management/server/http/middleware/auth_middleware_test.go +++ b/management/server/http/middleware/auth_middleware_test.go @@ -779,3 +779,66 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) { }) } } + +func TestAuthMiddleware_MarkPATUsedThrottled(t *testing.T) { + tt := []struct { + name string + lastUsed *time.Time + expectMark bool + }{ + {name: "never used", lastUsed: nil, expectMark: true}, + {name: "used just now", lastUsed: util.ToPtr(time.Now().UTC()), expectMark: false}, + {name: "used before the update interval", lastUsed: util.ToPtr(time.Now().UTC().Add(-2 * types.PATLastUsedUpdateInterval)), expectMark: true}, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + pat := &types.PersonalAccessToken{ + ID: tokenID, + ExpirationDate: util.ToPtr(time.Now().UTC().AddDate(0, 0, 7)), + LastUsed: tc.lastUsed, + } + + marked := 0 + mockAuth := &auth.MockManager{ + GetPATInfoFunc: func(_ context.Context, _ string) (*types.User, *types.PersonalAccessToken, string, string, error) { + return testAccount.Users[userID], pat, testAccount.Domain, testAccount.DomainCategory, nil + }, + MarkPATUsedFunc: func(_ context.Context, _ string) error { + marked++ + return nil + }, + } + + disabledLimiter := ratelimit.NewAPIRateLimiter(nil) + disabledLimiter.SetEnabled(false) + authMiddleware := NewAuthMiddleware( + mockAuth, + func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) { + return userAuth.AccountId, userAuth.UserId, nil + }, + func(ctx context.Context, userAuth nbauth.UserAuth) error { + return nil + }, + func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { + return &types.User{}, nil + }, + disabledLimiter, + nil, + ) + + handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + req := httptest.NewRequest("GET", "http://testing/test", nil) + req.Header.Set("Authorization", "Token "+PAT) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + if tc.expectMark { + assert.Equal(t, 1, marked, "MarkPATUsed must be called") + } else { + assert.Equal(t, 0, marked, "MarkPATUsed must be skipped") + } + }) + } +} diff --git a/management/server/store/sql_store_personal_access_token.go b/management/server/store/sql_store_personal_access_token.go index d5be3e327..67afc4d09 100644 --- a/management/server/store/sql_store_personal_access_token.go +++ b/management/server/store/sql_store_personal_access_token.go @@ -12,7 +12,6 @@ import ( "gorm.io/gorm/clause" "github.com/netbirdio/netbird/management/server/types" - "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/shared/management/status" ) @@ -130,22 +129,29 @@ func (s *SqlStore) GetUserPATs(ctx context.Context, lockStrength LockingStrength return pats, nil } -// MarkPATUsed marks a personal access token as used. +// MarkPATUsed marks a personal access token as used. The write is skipped when LastUsed is newer than +// types.PATLastUsedUpdateInterval, which also turns concurrent calls for the same token into no-ops. It returns a +// NotFound error when the token doesn't exist. func (s *SqlStore) MarkPATUsed(ctx context.Context, patID string) error { - patCopy := types.PersonalAccessToken{ - LastUsed: util.ToPtr(time.Now().UTC()), - } - - fieldsToUpdate := []string{"last_used"} - result := s.db.Select(fieldsToUpdate). - Where(idQueryCondition, patID).Updates(&patCopy) + now := time.Now().UTC() + result := s.db.Model(&types.PersonalAccessToken{}). + Where(idQueryCondition, patID). + Where("last_used IS NULL OR last_used < ?", now.Add(-types.PATLastUsedUpdateInterval)). + Update("last_used", now) if result.Error != nil { log.WithContext(ctx).Errorf("failed to mark pat as used: %s", result.Error) return status.Errorf(status.Internal, "failed to mark pat as used") } if result.RowsAffected == 0 { - return status.NewPATNotFoundError(patID) + var count int64 + if err := s.db.Model(&types.PersonalAccessToken{}).Where(idQueryCondition, patID).Count(&count).Error; err != nil { + log.WithContext(ctx).Errorf("failed to check pat existence: %s", err) + return status.Errorf(status.Internal, "failed to mark pat as used") + } + if count == 0 { + return status.NewPATNotFoundError(patID) + } } return nil diff --git a/management/server/store/sql_store_personal_access_token_test.go b/management/server/store/sql_store_personal_access_token_test.go index f40e0d9c6..4c2d97014 100644 --- a/management/server/store/sql_store_personal_access_token_test.go +++ b/management/server/store/sql_store_personal_access_token_test.go @@ -136,6 +136,25 @@ func TestSqlStore_MarkPATUsed(t *testing.T) { require.NoError(t, err) now := time.Now().UTC() require.WithinRange(t, pat.LastUsed.UTC(), now.Add(-15*time.Second), now, "LastUsed should be within 1 second of now") + + recent := now.Add(-types.PATLastUsedUpdateInterval / 2).Truncate(time.Second) + require.NoError(t, store.(*SqlStore).db.Model(&types.PersonalAccessToken{}).Where("id = ?", patID).Update("last_used", recent).Error) + require.NoError(t, store.MarkPATUsed(context.Background(), patID)) + pat, err = store.GetPATByID(context.Background(), LockingStrengthNone, userID, patID) + require.NoError(t, err) + assert.True(t, recent.Equal(pat.LastUsed.UTC()), "LastUsed within the update interval must not be rewritten") + + stale := now.Add(-2 * types.PATLastUsedUpdateInterval) + require.NoError(t, store.(*SqlStore).db.Model(&types.PersonalAccessToken{}).Where("id = ?", patID).Update("last_used", stale).Error) + require.NoError(t, store.MarkPATUsed(context.Background(), patID)) + pat, err = store.GetPATByID(context.Background(), LockingStrengthNone, userID, patID) + require.NoError(t, err) + assert.True(t, pat.LastUsed.UTC().After(stale), "LastUsed older than the update interval must be rewritten") + + err = store.MarkPATUsed(context.Background(), "unknown-pat") + sErr, ok := status.FromError(err) + require.True(t, ok, "unknown pat must return a status error") + assert.Equal(t, status.NotFound, sErr.Type()) } func TestSqlStore_SavePAT(t *testing.T) { diff --git a/management/server/store/sql_store_user.go b/management/server/store/sql_store_user.go index 4db03fb71..111ce08ff 100644 --- a/management/server/store/sql_store_user.go +++ b/management/server/store/sql_store_user.go @@ -252,20 +252,32 @@ func (s *SqlStore) GetAccountIDByUserID(ctx context.Context, lockStrength Lockin return accountID, nil } -// SaveUserLastLogin stores the last login time for a user in DB. +// SaveUserLastLogin stores the last login time for a user in DB. It only writes when the stored value is unset or +// older than lastLogin, so repeating the same login doesn't rewrite the row and an older login never overwrites a newer one. +// It returns a NotFound error when the user doesn't exist. func (s *SqlStore) SaveUserLastLogin(ctx context.Context, accountID, userID string, lastLogin time.Time) error { - var user types.User - result := s.db.Take(&user, accountAndIDQueryCondition, accountID, userID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return status.NewUserNotFoundError(userID) - } - return status.NewGetUserFromStoreError() + if lastLogin.IsZero() { + return nil } - if !lastLogin.IsZero() { - user.LastLogin = &lastLogin - return s.db.Save(&user).Error + result := s.db.Model(&types.User{}). + Where(accountAndIDQueryCondition, accountID, userID). + Where("last_login IS NULL OR last_login < ?", lastLogin). + Update("last_login", lastLogin) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save user last login: %s", result.Error) + return status.Errorf(status.Internal, "failed to save user last login") + } + + if result.RowsAffected == 0 { + var count int64 + if err := s.db.Model(&types.User{}).Where(accountAndIDQueryCondition, accountID, userID).Count(&count).Error; err != nil { + log.WithContext(ctx).Errorf("failed to check user existence: %s", err) + return status.NewGetUserFromStoreError() + } + if count == 0 { + return status.NewUserNotFoundError(userID) + } } return nil diff --git a/management/server/store/sql_store_user_test.go b/management/server/store/sql_store_user_test.go index 34bc458fe..d5ee14a6a 100644 --- a/management/server/store/sql_store_user_test.go +++ b/management/server/store/sql_store_user_test.go @@ -341,3 +341,50 @@ func TestSqlStore_SaveUsers_LargeBatch(t *testing.T) { require.NoError(t, err) require.Equal(t, 8002, len(accountUsers)) } + +func TestSqlStore_SaveUserLastLogin(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + ctx := context.Background() + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + userID := "f4f6d672-63fb-11ec-90d6-0242ac120003" + + before, err := store.GetUserByUserID(ctx, LockingStrengthNone, userID) + require.NoError(t, err) + + lastLoginOf := func() time.Time { + user, err := store.GetUserByUserID(ctx, LockingStrengthNone, userID) + require.NoError(t, err) + return user.GetLastLogin().UTC() + } + + first := time.Now().UTC().Truncate(time.Second) + require.NoError(t, store.SaveUserLastLogin(ctx, accountID, userID, first)) + assert.True(t, first.Equal(lastLoginOf()), "first login must be stored") + + require.NoError(t, store.SaveUserLastLogin(ctx, accountID, userID, first)) + assert.True(t, first.Equal(lastLoginOf()), "repeating the same login must keep the stored value") + + later := first.Add(30 * time.Second) + require.NoError(t, store.SaveUserLastLogin(ctx, accountID, userID, later)) + assert.True(t, later.Equal(lastLoginOf()), "a newer login must be written") + + require.NoError(t, store.SaveUserLastLogin(ctx, accountID, userID, first)) + assert.True(t, later.Equal(lastLoginOf()), "an older login must not overwrite a newer one") + + require.NoError(t, store.SaveUserLastLogin(ctx, accountID, userID, time.Time{})) + assert.True(t, later.Equal(lastLoginOf()), "zero login must be ignored") + + err = store.SaveUserLastLogin(ctx, accountID, "unknown-user", later.Add(time.Hour)) + sErr, ok := status.FromError(err) + require.True(t, ok, "unknown user must return a status error") + assert.Equal(t, status.NotFound, sErr.Type()) + + after, err := store.GetUserByUserID(ctx, LockingStrengthNone, userID) + require.NoError(t, err) + assert.Equal(t, before.Role, after.Role) + assert.Equal(t, before.AutoGroups, after.AutoGroups) + assert.Equal(t, before.Blocked, after.Blocked) +} diff --git a/management/server/types/personal_access_token.go b/management/server/types/personal_access_token.go index 8351928b5..9478be5b2 100644 --- a/management/server/types/personal_access_token.go +++ b/management/server/types/personal_access_token.go @@ -23,6 +23,8 @@ const ( PATChecksumLength = 6 // PATLength total number of characters used for the token PATLength = 40 + // PATLastUsedUpdateInterval is the minimum age of a token's LastUsed before it is written again + PATLastUsedUpdateInterval = time.Minute ) // PersonalAccessToken holds all information about a PAT including a hashed version of it for verification diff --git a/management/server/user.go b/management/server/user.go index 84c4c5022..3e84fcd03 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -238,9 +238,10 @@ func (am *DefaultAccountManager) GetUserFromUserAuth(ctx context.Context, userAu // server when user authenticates a device. And we need to separate the Dashboard login event from the Device login event. newLogin := user.LastDashboardLoginChanged(userAuth.LastLogin) - err = am.Store.SaveUserLastLogin(ctx, userAuth.AccountId, userAuth.UserId, userAuth.LastLogin) - if err != nil { - log.WithContext(ctx).Debugf("failed to update user last login: %v", err) + if userAuth.LastLogin.After(user.GetLastLogin()) { + if err := am.Store.SaveUserLastLogin(ctx, userAuth.AccountId, userAuth.UserId, userAuth.LastLogin); err != nil { + log.WithContext(ctx).Debugf("failed to update user last login: %v", err) + } } if newLogin { From 48ecdfbcfc7f81154c4eab4976079089eac622ae Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:13:55 +0200 Subject: [PATCH 198/209] [management] remove account id check from inner groups query to use index (#8297) --- management/internals/network_map_db/pgsql/group.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/management/internals/network_map_db/pgsql/group.go b/management/internals/network_map_db/pgsql/group.go index 874e743a5..a52b4abea 100644 --- a/management/internals/network_map_db/pgsql/group.go +++ b/management/internals/network_map_db/pgsql/group.go @@ -17,7 +17,7 @@ const ( ( select array_agg(group_peers.peer_id) from group_peers - where group_peers.group_id = groups.id and group_peers.account_id=$1 + where group_peers.group_id = groups.id ) as peers from groups where account_id=$1 ` From f548a74c7c5b7cde2d5cdc658737b1eedff3e90c Mon Sep 17 00:00:00 2001 From: dmitri-netbird Date: Thu, 22 Oct 2026 20:27:53 +0200 Subject: [PATCH 199/209] [management] Compensate for a possible nil peer when returning "empty" network map (#8285) * we somehow can get a nil peer when returning empty components; this fix compensates for the issue, but doesn't fix the root cause (which is unknown atm) Signed-off-by: Dmitri Dolguikh * fix test Signed-off-by: Dmitri Dolguikh --------- Signed-off-by: Dmitri Dolguikh --- .../controllers/network_map/controller/controller.go | 2 +- management/internals/shared/grpc/components_encoder.go | 5 +++-- management/internals/shared/grpc/components_encoder_test.go | 3 +++ .../internals/shared/grpc/components_envelope_response.go | 1 + shared/management/networkmap/envelope_test.go | 1 + 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 9b8471c1b..08d018e5f 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -933,7 +933,7 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi } // it's possible that the peer gets deleted between the call to "sendInitialSync()" and here, bail out in this case - if _, ok := account.Peers[peer.ID]; !ok { + if p, _ := account.Peers[peer.ID]; p == nil { return nil, nil, nil, 0, fmt.Errorf("peer '%s' no longer exists", peer.ID) } diff --git a/management/internals/shared/grpc/components_encoder.go b/management/internals/shared/grpc/components_encoder.go index a4516be52..4763177ab 100644 --- a/management/internals/shared/grpc/components_encoder.go +++ b/management/internals/shared/grpc/components_encoder.go @@ -20,6 +20,7 @@ const wgKeyRawLen = 32 // computed alongside the components in the network_map controller and reused // from the legacy proto path) and the dns_domain string. type ComponentsEnvelopeInput struct { + Peer *nmdata.Peer // only used when encoding "empty" component maps Components *types.NetworkMapComponents PeerConfig *proto.PeerConfig DNSDomain string @@ -59,8 +60,8 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel Serial: networkSerial(c.Network), Network: toAccountNetwork(c.Network), PeerConfig: in.PeerConfig, - // components.Peers always contains the target peer - Peers: []*proto.PeerCompact{toPeerCompact(c.Peers[c.PeerID])}, + // components.Peers may not contain the target peer + Peers: []*proto.PeerCompact{toPeerCompact(in.Peer)}, DnsDomain: in.DNSDomain, DnsForwarderPort: in.DNSForwarderPort, UserIdClaim: in.UserIDClaim, diff --git a/management/internals/shared/grpc/components_encoder_test.go b/management/internals/shared/grpc/components_encoder_test.go index a6421af11..a9e9cd939 100644 --- a/management/internals/shared/grpc/components_encoder_test.go +++ b/management/internals/shared/grpc/components_encoder_test.go @@ -717,6 +717,7 @@ func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) { // nil Components → minimal envelope, no crash. Matches the legacy // behaviour for missing/unvalidated peers. env := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ + Peer: &nmdata.Peer{ID: "missing-peer", SSHKey: "missing-peer-key"}, Components: emptyNetworkMapComponents(), DNSDomain: "netbird.cloud", }) @@ -731,6 +732,8 @@ func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) { require.NotNil(t, full.Network, "client runs Calculate() over the envelope and dereferences Network unconditionally; a nil here would crash the receiver") assert.Equal(t, "net-empty", full.Network.Identifier) assert.Equal(t, uint64(9), full.Serial) + assert.Len(t, env.GetFull().Peers, 1) + assert.Equal(t, string(env.GetFull().Peers[0].SshPubKey), "missing-peer-key") } func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) { diff --git a/management/internals/shared/grpc/components_envelope_response.go b/management/internals/shared/grpc/components_envelope_response.go index cbf9bb477..40ac7a0c7 100644 --- a/management/internals/shared/grpc/components_envelope_response.go +++ b/management/internals/shared/grpc/components_envelope_response.go @@ -55,6 +55,7 @@ func ToComponentSyncResponse( } envelope := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ + Peer: peer, Components: components, PeerConfig: peerConfig, DNSDomain: dnsName, diff --git a/shared/management/networkmap/envelope_test.go b/shared/management/networkmap/envelope_test.go index 98333a3c8..fe71016b8 100644 --- a/shared/management/networkmap/envelope_test.go +++ b/shared/management/networkmap/envelope_test.go @@ -244,6 +244,7 @@ func TestEnvelopeToNetworkMap_EmptyComponents(t *testing.T) { }) envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ + Peer: &nmdata.Peer{ID: "peer-A", Key: localPeerKey, IP: netip.AddrFrom4([4]byte{100, 64, 0, 1})}, Components: c, DNSDomain: "netbird.cloud", }) From 8397bed4afff8b2e554f8d6ccc5d5f6684972967 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Thu, 22 Oct 2026 22:57:47 +0200 Subject: [PATCH 200/209] [management] Push network maps when background validation changes (#8299) An integrated validator can approve or revoke peers outside a login or sync, for example during a periodic EDR sync. Only revocations of SSO peers reached anyone; a newly approved peer kept its empty map, and its peers did not see it, until an unrelated update or a reconnect. Add SetPeerValidationChangeListener to the IntegratedValidator interface. The account manager registers a handler that resolves the affected peers and calls OnPeersUpdated, the same path a login-time approval takes. The OSS validator and the mock implement it as a no-op. --- management/server/account.go | 19 ++++++ management/server/integrated_validator.go | 4 ++ .../integrated_validator_mock.go | 12 ++++ .../integrated_validator/interface.go | 3 + .../validator/validator.go | 3 + .../server/peer_validation_change_test.go | 59 +++++++++++++++++++ 6 files changed, 100 insertions(+) create mode 100644 management/server/peer_validation_change_test.go diff --git a/management/server/account.go b/management/server/account.go index 7cab037a2..ddc8d4bbc 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -298,6 +298,9 @@ func BuildManager( am.integratedPeerValidator.SetPeerInvalidationListener(func(accountID string, peerIDs []string) { am.onPeersInvalidated(ctx, accountID, peerIDs) }) + am.integratedPeerValidator.SetPeerValidationChangeListener(func(accountID string, peerIDs []string) { + am.onPeerValidationChanged(ctx, accountID, peerIDs) + }) // Started last: a manager that fails to build is never returned, so nothing would // stop its refresher. @@ -2102,6 +2105,22 @@ func (am *DefaultAccountManager) onPeersInvalidated(ctx context.Context, account log.WithContext(ctx).Debugf("invalidated peers have been expired for account %s", accountID) } +// onPeerValidationChanged pushes fresh network maps to peers whose validation state +// changed in the background, and to the peers that can reach them. +func (am *DefaultAccountManager) onPeerValidationChanged(ctx context.Context, accountID string, peerIDs []string) { + if len(peerIDs) == 0 { + return + } + affected := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, peerIDs) + if len(affected) == 0 { + // Resolution failed or found nothing; the changed peers' own maps still differ. + affected = peerIDs + } + if err := am.networkMapController.OnPeersUpdated(ctx, accountID, peerIDs, affected); err != nil { + log.WithContext(ctx).Errorf("failed to update peers after validation change for account %s: %v", accountID, err) + } +} + func (am *DefaultAccountManager) FindExistingPostureCheck(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error) { return am.Store.GetPostureCheckByChecksDefinition(accountID, checks) } diff --git a/management/server/integrated_validator.go b/management/server/integrated_validator.go index 5928a8ed2..63d3ef280 100644 --- a/management/server/integrated_validator.go +++ b/management/server/integrated_validator.go @@ -167,6 +167,10 @@ func (MockIntegratedValidator) SetPeerInvalidationListener(func(accountID string // just a dummy } +func (MockIntegratedValidator) SetPeerValidationChangeListener(func(accountID string, peerIDs []string)) { + // just a dummy +} + func (MockIntegratedValidator) Stop(_ context.Context) { // just a dummy } diff --git a/management/server/integrations/integrated_validator/integrated_validator_mock.go b/management/server/integrations/integrated_validator/integrated_validator_mock.go index 73178a869..27f61a79c 100644 --- a/management/server/integrations/integrated_validator/integrated_validator_mock.go +++ b/management/server/integrations/integrated_validator/integrated_validator_mock.go @@ -130,6 +130,18 @@ func (mr *MockIntegratedValidatorMockRecorder) SetPeerInvalidationListener(fn an return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPeerInvalidationListener", reflect.TypeOf((*MockIntegratedValidator)(nil).SetPeerInvalidationListener), fn) } +// SetPeerValidationChangeListener mocks base method. +func (m *MockIntegratedValidator) SetPeerValidationChangeListener(fn func(string, []string)) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetPeerValidationChangeListener", fn) +} + +// SetPeerValidationChangeListener indicates an expected call of SetPeerValidationChangeListener. +func (mr *MockIntegratedValidatorMockRecorder) SetPeerValidationChangeListener(fn any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPeerValidationChangeListener", reflect.TypeOf((*MockIntegratedValidator)(nil).SetPeerValidationChangeListener), fn) +} + // Stop mocks base method. func (m *MockIntegratedValidator) Stop(ctx context.Context) { m.ctrl.T.Helper() diff --git a/management/server/integrations/integrated_validator/interface.go b/management/server/integrations/integrated_validator/interface.go index dc3332177..eb5ce8815 100644 --- a/management/server/integrations/integrated_validator/interface.go +++ b/management/server/integrations/integrated_validator/interface.go @@ -21,6 +21,9 @@ type IntegratedValidator interface { GetInvalidPeers(ctx context.Context, accountID string, extraSettings *types.ExtraSettings) (map[string]string, error) PeerDeleted(ctx context.Context, accountID, peerID string, extraSettings *types.ExtraSettings) error SetPeerInvalidationListener(fn func(accountID string, peerIDs []string)) + // SetPeerValidationChangeListener registers fn to be called with the peers whose + // validation state changed outside a login or sync, so their network maps can be pushed. + SetPeerValidationChangeListener(fn func(accountID string, peerIDs []string)) Stop(ctx context.Context) ValidateFlowResponse(ctx context.Context, peerKey string, flowResponse *proto.PKCEAuthorizationFlow) *proto.PKCEAuthorizationFlow } diff --git a/management/server/integrations/integrated_validator/validator/validator.go b/management/server/integrations/integrated_validator/validator/validator.go index 33199c065..ec4c4827d 100644 --- a/management/server/integrations/integrated_validator/validator/validator.go +++ b/management/server/integrations/integrated_validator/validator/validator.go @@ -55,6 +55,9 @@ func (v *IntegratedValidatorImpl) PeerDeleted(_ context.Context, _, _ string, _ func (v *IntegratedValidatorImpl) SetPeerInvalidationListener(_ func(accountID string, peerIDs []string)) { } +func (v *IntegratedValidatorImpl) SetPeerValidationChangeListener(_ func(accountID string, peerIDs []string)) { +} + func (v *IntegratedValidatorImpl) Stop(_ context.Context) { } diff --git a/management/server/peer_validation_change_test.go b/management/server/peer_validation_change_test.go new file mode 100644 index 000000000..14c179fee --- /dev/null +++ b/management/server/peer_validation_change_test.go @@ -0,0 +1,59 @@ +package server + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/server/types" +) + +// A peer that the background validator approves must get its own map and appear in the +// maps of the peers a policy connects it to, without waiting for a login or sync. +func TestOnPeerValidationChanged_UpdatesChangedPeerAndItsPeers(t *testing.T) { + _, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + err := s.CreatePolicy(ctx, &types.Policy{ + ID: "policy-validation-change", + AccountID: accountID, + Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-validation-change", + PolicyID: "policy-validation-change", + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }}, + }) + require.NoError(t, err) + + var changed, affected []string + controller := network_map.NewMockController(gomock.NewController(t)) + controller.EXPECT().OnPeersUpdated(gomock.Any(), accountID, gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, peerIDs, affectedPeerIDs []string) error { + changed, affected = peerIDs, affectedPeerIDs + return nil + }) + // A separate manager over the same store: the setup's manager still has peer update + // goroutines in flight that read its controller. + manager := &DefaultAccountManager{Store: s, networkMapController: controller} + + manager.onPeerValidationChanged(ctx, accountID, []string{peerIDs[0]}) + + assert.Equal(t, []string{peerIDs[0]}, changed, "changed peers should be passed through") + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, affected, "the changed peer and its policy peer should be updated") +} + +func TestOnPeerValidationChanged_NoPeersNoUpdate(t *testing.T) { + // Any call on the mock fails the test. + manager := &DefaultAccountManager{networkMapController: network_map.NewMockController(gomock.NewController(t))} + + manager.onPeerValidationChanged(context.Background(), "account", nil) +} From 69f13e3733c40cc9d83a14c3e044a6de8c54bbf6 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Fri, 23 Oct 2026 16:04:09 +0300 Subject: [PATCH 201/209] [management] Move reverse proxy access logs to a dedicated database (#8303) --- combined/cmd/admin_config_test.go | 20 +++ combined/cmd/config.go | 1 + combined/cmd/root.go | 16 ++ combined/config.yaml.example | 7 + .../modules/reverseproxy/accesslogs/filter.go | 2 +- .../reverseproxy/accesslogs/manager/conn.go | 92 ++++++++++++ .../accesslogs/manager/conn_test.go | 142 ++++++++++++++++++ .../accesslogs/manager/repository.go | 4 +- .../accesslogs/manager/repository_test.go | 34 +++++ management/internals/server/boot.go | 21 ++- management/internals/shared/db/conn.go | 32 +++- management/internals/shared/db/conn_test.go | 19 +++ management/internals/shared/db/open.go | 19 ++- .../testing/testing_tools/channel/channel.go | 16 +- management/server/store/sql_store.go | 3 +- management/server/store/store.go | 7 +- shared/management/http/api/openapi.yml | 2 +- shared/management/http/api/types.gen.go | 2 +- 18 files changed, 421 insertions(+), 18 deletions(-) create mode 100644 management/internals/modules/reverseproxy/accesslogs/manager/conn.go create mode 100644 management/internals/modules/reverseproxy/accesslogs/manager/conn_test.go diff --git a/combined/cmd/admin_config_test.go b/combined/cmd/admin_config_test.go index 5cec26552..c1ea2e8da 100644 --- a/combined/cmd/admin_config_test.go +++ b/combined/cmd/admin_config_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" + accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" ) @@ -41,3 +42,22 @@ func TestApplyServerStoreEnv(t *testing.T) { require.Equal(t, "postgres-dsn", os.Getenv("NB_STORE_ENGINE_POSTGRES_DSN")) require.Equal(t, "store.db", os.Getenv("NB_STORE_ENGINE_SQLITE_FILE")) } + +func TestApplyAccessLogStoreEnv(t *testing.T) { + keys := []string{accesslogsmanager.StoreEngineEnv, accesslogsmanager.PostgresDSNEnv, accesslogsmanager.SqliteFileEnv} + for _, key := range keys { + t.Setenv(key, "") + require.NoError(t, os.Unsetenv(key)) + } + + applyAccessLogStoreEnv(StoreConfig{}) + for _, key := range keys { + _, ok := os.LookupEnv(key) + require.False(t, ok, "%s should stay unset for an empty accessLogStore block", key) + } + + applyAccessLogStoreEnv(StoreConfig{Engine: "Postgres", DSN: "postgres-dsn", File: "access_logs.db"}) + require.Equal(t, "postgres", os.Getenv(accesslogsmanager.StoreEngineEnv)) + require.Equal(t, "postgres-dsn", os.Getenv(accesslogsmanager.PostgresDSNEnv)) + require.Equal(t, "access_logs.db", os.Getenv(accesslogsmanager.SqliteFileEnv)) +} diff --git a/combined/cmd/config.go b/combined/cmd/config.go index ca3b25617..0c7302fe5 100644 --- a/combined/cmd/config.go +++ b/combined/cmd/config.go @@ -71,6 +71,7 @@ type ServerConfig struct { Auth AuthConfig `yaml:"auth"` Store StoreConfig `yaml:"store"` ActivityStore StoreConfig `yaml:"activityStore"` + AccessLogStore StoreConfig `yaml:"accessLogStore"` AuthStore StoreConfig `yaml:"authStore"` ReverseProxy ReverseProxyConfig `yaml:"reverseProxy"` diff --git a/combined/cmd/root.go b/combined/cmd/root.go index fedb0e1cc..7f4549643 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -26,6 +26,7 @@ import ( "github.com/netbirdio/netbird/encryption" agentnetworkpricing "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing" + accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager" mgmtServer "github.com/netbirdio/netbird/management/internals/server" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/telemetry" @@ -152,6 +153,20 @@ func applyActivityStoreEnv(storeConfig StoreConfig) error { return nil } +// applyAccessLogStoreEnv leaves unset fields to follow the main store, so a +// postgres engine without a DSN reuses the main store DSN. +func applyAccessLogStoreEnv(storeConfig StoreConfig) { + if engine := storeConfig.Engine; engine != "" { + os.Setenv(accesslogsmanager.StoreEngineEnv, strings.ToLower(engine)) + } + if dsn := storeConfig.DSN; dsn != "" { + os.Setenv(accesslogsmanager.PostgresDSNEnv, dsn) + } + if file := storeConfig.File; file != "" { + os.Setenv(accesslogsmanager.SqliteFileEnv, file) + } +} + func initializeConfig() error { var err error config, err = LoadConfig(configPath) @@ -172,6 +187,7 @@ func initializeConfig() error { if err := applyActivityStoreEnv(config.Server.ActivityStore); err != nil { return err } + applyAccessLogStoreEnv(config.Server.AccessLogStore) log.Infof("Starting combined NetBird server") logConfig(config) diff --git a/combined/config.yaml.example b/combined/config.yaml.example index 68dbf701e..7f2d85210 100644 --- a/combined/config.yaml.example +++ b/combined/config.yaml.example @@ -121,6 +121,13 @@ server: # dsn: "" # Connection string for postgres # file: "" # Custom SQLite file path (optional, defaults to {dataDir}/events.db) + # Reverse proxy access log store configuration (optional, follows the store engine: + # sqlite uses its own file in dataDir, postgres reuses the store DSN with a separate pool) + # accessLogStore: + # engine: "postgres" # sqlite or postgres + # dsn: "" # Connection string for a dedicated postgres database + # file: "" # Custom SQLite file path (optional, defaults to {dataDir}/access_logs.db) + # Auth (embedded IdP) store configuration (optional, defaults to sqlite3 in dataDir/idp.db) # authStore: # engine: "sqlite3" # sqlite3 or postgres diff --git a/management/internals/modules/reverseproxy/accesslogs/filter.go b/management/internals/modules/reverseproxy/accesslogs/filter.go index a1fa28312..996828c51 100644 --- a/management/internals/modules/reverseproxy/accesslogs/filter.go +++ b/management/internals/modules/reverseproxy/accesslogs/filter.go @@ -46,7 +46,7 @@ type AccessLogFilter struct { SortOrder string // Sort order: asc or desc (default: desc) // Filtering parameters - Search *string // General search across log ID, host, path, source IP, and user fields + Search *string // General search across log ID, host, path, and source IP SourceIP *string // Filter by source IP address Host *string // Filter by host header Path *string // Filter by request path (supports LIKE pattern) diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/conn.go b/management/internals/modules/reverseproxy/accesslogs/manager/conn.go new file mode 100644 index 000000000..b1ca05d16 --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/manager/conn.go @@ -0,0 +1,92 @@ +package manager + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/shared/db" + "github.com/netbirdio/netbird/management/server/store" +) + +const ( + // StoreEngineEnv selects the access log store engine. Unset follows the main store engine. + StoreEngineEnv = "NB_ACCESS_LOG_STORE_ENGINE" + // PostgresDSNEnv is the access log Postgres DSN. Unset reuses the main store DSN. + PostgresDSNEnv = "NB_ACCESS_LOG_POSTGRES_DSN" + // SqliteFileEnv is the access log SQLite file, resolved against the data directory when relative. + SqliteFileEnv = "NB_ACCESS_LOG_SQLITE_FILE" + // MaxOpenConnsEnv sizes the access log connection pool. Unset applies NB_SQL_MAX_OPEN_CONNS. + MaxOpenConnsEnv = "NB_ACCESS_LOG_SQL_MAX_OPEN_CONNS" + + sqliteFileName = "access_logs.db" +) + +// OpenConn opens and migrates the access log database. Unless configured +// otherwise, it follows mainEngine: SQLite uses its own file in dataDir and +// Postgres reuses the main DSN. +func OpenConn(ctx context.Context, dataDir string, mainEngine db.Engine) (*db.Conn, error) { + engine := mainEngine + if v := os.Getenv(StoreEngineEnv); v != "" { + engine = db.Engine(strings.ToLower(v)) + } + + conn, err := openConn(ctx, dataDir, engine) + if err != nil { + return nil, err + } + if err := conn.AutoMigrate(&accesslogs.AccessLogEntry{}); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("migrate access log store: %w", err) + } + return conn, nil +} + +func openConn(ctx context.Context, dataDir string, engine db.Engine) (*db.Conn, error) { + switch engine { + case db.SqliteStoreEngine: + log.WithContext(ctx).Info("using SQLite access log store engine") + file := sqliteFileName + if v := os.Getenv(SqliteFileEnv); v != "" { + file = v + } + return db.OpenSqliteFile(ctx, dataDir, file) + case db.PostgresStoreEngine: + log.WithContext(ctx).Info("using Postgres access log store engine") + dsn, err := postgresDSN() + if err != nil { + return nil, err + } + return db.OpenPostgresWithoutPool(ctx, dsn, connOptions(ctx)...) + default: + return nil, fmt.Errorf("unsupported access log store engine: %s", engine) + } +} + +func postgresDSN() (string, error) { + if dsn := os.Getenv(PostgresDSNEnv); dsn != "" { + return dsn, nil + } + if dsn, ok := store.LookupPostgresDSN(); ok && dsn != "" { + return dsn, nil + } + return "", fmt.Errorf("neither %s nor %s is set", PostgresDSNEnv, store.PostgresDsnEnv) +} + +func connOptions(ctx context.Context) []db.Option { + val := os.Getenv(MaxOpenConnsEnv) + if val == "" { + return nil + } + conns, err := strconv.Atoi(val) + if err != nil || conns <= 0 { + log.WithContext(ctx).Warnf("ignoring invalid %s value %q", MaxOpenConnsEnv, val) + return nil + } + return []db.Option{db.WithMaxOpenConns(conns)} +} diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/conn_test.go b/management/internals/modules/reverseproxy/accesslogs/manager/conn_test.go new file mode 100644 index 000000000..90fdf8ffa --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/manager/conn_test.go @@ -0,0 +1,142 @@ +package manager + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/shared/db" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/testutil" +) + +func unsetStoreEnv(t *testing.T) { + t.Helper() + for _, key := range []string{StoreEngineEnv, PostgresDSNEnv, SqliteFileEnv, MaxOpenConnsEnv, store.PostgresDsnEnv, store.PostgresDsnEnvLegacy} { + t.Setenv(key, "") + require.NoError(t, os.Unsetenv(key)) + } +} + +func openTestConn(t *testing.T, dataDir string, mainEngine db.Engine) *db.Conn { + t.Helper() + conn, err := OpenConn(context.Background(), dataDir, mainEngine) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + +func assertStoresEntries(t *testing.T, conn *db.Conn) { + t.Helper() + ctx := context.Background() + repo := NewRepository(conn) + require.NoError(t, repo.Create(ctx, newEntry("entry", "acc", "GET", time.Minute))) + + logs, total, err := repo.ListByAccount(ctx, db.LockingStrengthNone, "acc", accesslogs.AccessLogFilter{Page: 1, PageSize: 10}) + require.NoError(t, err) + assert.EqualValues(t, 1, total, "the stored entry should be listed") + require.Len(t, logs, 1) + assert.Equal(t, "entry", logs[0].ID) +} + +func TestOpenConn_SqliteUsesItsOwnFile(t *testing.T) { + unsetStoreEnv(t) + dataDir := t.TempDir() + + conn := openTestConn(t, dataDir, db.SqliteStoreEngine) + + assert.Equal(t, db.SqliteStoreEngine, conn.Engine()) + assert.FileExists(t, filepath.Join(dataDir, sqliteFileName)) + assert.NoFileExists(t, filepath.Join(dataDir, db.SqliteFileName), "the main store file must not be opened") + assertStoresEntries(t, conn) +} + +func TestOpenConn_SqliteFileFromEnv(t *testing.T) { + unsetStoreEnv(t) + dataDir := t.TempDir() + t.Setenv(SqliteFileEnv, "custom_logs.db") + + conn := openTestConn(t, dataDir, db.SqliteStoreEngine) + + assert.FileExists(t, filepath.Join(dataDir, "custom_logs.db")) + assert.NoFileExists(t, filepath.Join(dataDir, sqliteFileName)) + assertStoresEntries(t, conn) +} + +func TestOpenConn_EngineFromEnvOverridesMainEngine(t *testing.T) { + unsetStoreEnv(t) + t.Setenv(StoreEngineEnv, "SQLite") + + conn := openTestConn(t, t.TempDir(), db.PostgresStoreEngine) + + assert.Equal(t, db.SqliteStoreEngine, conn.Engine(), "the configured engine should win over the main store engine") +} + +func TestOpenConn_PostgresWithoutDSNFails(t *testing.T) { + unsetStoreEnv(t) + + _, err := OpenConn(context.Background(), t.TempDir(), db.PostgresStoreEngine) + + require.Error(t, err) + assert.Contains(t, err.Error(), PostgresDSNEnv) +} + +func TestOpenConn_UnsupportedEngineFails(t *testing.T) { + unsetStoreEnv(t) + t.Setenv(StoreEngineEnv, "mysql") + + _, err := OpenConn(context.Background(), t.TempDir(), db.SqliteStoreEngine) + + require.Error(t, err) + assert.Contains(t, err.Error(), "mysql") +} + +func TestOpenConn_PostgresReusesMainDSNWithItsOwnPool(t *testing.T) { + if os.Getenv("NETBIRD_STORE_ENGINE") != string(db.PostgresStoreEngine) { + t.Skip("requires NETBIRD_STORE_ENGINE=postgres") + } + unsetStoreEnv(t) + cleanup, dsn, err := testutil.CreatePostgresTestContainer() + require.NoError(t, err) + t.Cleanup(cleanup) + t.Setenv(store.PostgresDsnEnv, dsn) + t.Setenv(MaxOpenConnsEnv, "3") + + conn := openTestConn(t, t.TempDir(), db.PostgresStoreEngine) + + assert.Equal(t, db.PostgresStoreEngine, conn.Engine()) + assert.Nil(t, conn.Pool(nil), "access logs never take the pgx read paths, so no pgx pool should be opened") + sqlDB, err := conn.DB(nil).DB() + require.NoError(t, err) + assert.Equal(t, 3, sqlDB.Stats().MaxOpenConnections, "the pool should be sized by %s", MaxOpenConnsEnv) + assertStoresEntries(t, conn) +} + +func TestConnOptions_SizesPoolFromEnv(t *testing.T) { + // Invalid values fall back to the store-wide setting instead of failing boot. + cases := map[string]int{"3": 3, "": 7, "zero": 7, "0": 7, "-1": 7} + for value, want := range cases { + t.Run(value, func(t *testing.T) { + t.Setenv("NB_SQL_MAX_OPEN_CONNS", "7") + t.Setenv(MaxOpenConnsEnv, value) + + gormDB, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "pool.db")), db.GormConfig()) + require.NoError(t, err) + conn, err := db.NewConn(context.Background(), gormDB, db.PostgresStoreEngine, nil, connOptions(context.Background())...) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + sqlDB, err := conn.DB(nil).DB() + require.NoError(t, err) + assert.Equal(t, want, sqlDB.Stats().MaxOpenConnections, "max open connections for %s=%q", MaxOpenConnsEnv, value) + }) + } +} diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/repository.go b/management/internals/modules/reverseproxy/accesslogs/manager/repository.go index 105696ab7..3967442d8 100644 --- a/management/internals/modules/reverseproxy/accesslogs/manager/repository.go +++ b/management/internals/modules/reverseproxy/accesslogs/manager/repository.go @@ -85,8 +85,8 @@ func applyFilters(query *gorm.DB, filter accesslogs.AccessLogFilter) *gorm.DB { if filter.Search != nil { searchPattern := "%" + *filter.Search + "%" query = query.Where( - "id LIKE ? OR location_connection_ip LIKE ? OR host LIKE ? OR path LIKE ? OR CONCAT(host, path) LIKE ? OR user_id IN (SELECT id FROM users WHERE email LIKE ? OR name LIKE ?)", - searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, + "id LIKE ? OR location_connection_ip LIKE ? OR host LIKE ? OR path LIKE ? OR CONCAT(host, path) LIKE ?", + searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, ) } diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/repository_test.go b/management/internals/modules/reverseproxy/accesslogs/manager/repository_test.go index 9931cc27c..7fea12a85 100644 --- a/management/internals/modules/reverseproxy/accesslogs/manager/repository_test.go +++ b/management/internals/modules/reverseproxy/accesslogs/manager/repository_test.go @@ -123,3 +123,37 @@ func TestSqlRepository_ListByAccount_StatusFilter(t *testing.T) { assert.Equal(t, []string{"info", "notfound"}, listIDs("failed")) assert.Equal(t, []string{"ok"}, listIDs("success")) } + +func TestSqlRepository_ListByAccount_Search(t *testing.T) { + repo, _ := newTestRepository(t) + ctx := context.Background() + api := newEntry("req-api", "acc", "GET", time.Hour) + api.Host = "api.example.com" + api.Path = "/v1/peers" + web := newEntry("req-web", "acc", "GET", 2*time.Hour) + web.Host = "web.example.com" + web.Path = "/login" + foreign := newEntry("foreign", "other", "GET", time.Hour) + foreign.Host = "api.example.com" + for _, entry := range []*accesslogs.AccessLogEntry{api, web, foreign} { + require.NoError(t, repo.Create(ctx, entry)) + } + + search := func(term string) []string { + logs, total, err := repo.ListByAccount(ctx, db.LockingStrengthNone, "acc", accesslogs.AccessLogFilter{Page: 1, PageSize: 10, Search: &term}) + require.NoError(t, err) + require.EqualValues(t, len(logs), total) + ids := make([]string, 0, len(logs)) + for _, entry := range logs { + ids = append(ids, entry.ID) + } + return ids + } + + assert.Equal(t, []string{"req-api"}, search("api.example"), "search should match the host") + assert.Equal(t, []string{"req-web"}, search("/login"), "search should match the path") + assert.Equal(t, []string{"req-api"}, search("example.com/v1"), "search should match host and path joined") + assert.Equal(t, []string{"req-web"}, search("req-web"), "search should match the log ID") + assert.Equal(t, []string{"req-api", "req-web"}, search("example.com"), "search should list every match newest first") + assert.Empty(t, search("nothing-matches")) +} diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index e466005fe..28f6294d6 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -180,7 +180,8 @@ func (s *BaseServer) APIHandler() http.Handler { s.GroupsManager(), s.GeoLocationManager(), s.PermissionsManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, - s.AgentNetworkManager(), nil) + s.AgentNetworkManager(), nil, + ) if err != nil { log.Fatalf("failed to create API handler: %v", err) } @@ -360,14 +361,30 @@ func (s *BaseServer) ProxyActivityManager() proxyactivity.Manager { }) } +func (s *BaseServer) AccessLogDBConn() *db.Conn { + return CreateNamed(s, "accesslogs", func() *db.Conn { + conn, err := accesslogsmanager.OpenConn(context.Background(), s.Config.Datadir, s.DBConn().Engine()) + if err != nil { + log.Fatalf("failed to open access log database connection: %v", err) + } + s.OnStop(func() { + if err := conn.Close(); err != nil { + log.Debugf("failed to close access log database connection: %v", err) + } + }) + return conn + }) +} + func (s *BaseServer) AccessLogsManager() accesslogs.Manager { return Create(s, func() accesslogs.Manager { - accessLogManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(s.DBConn()), s.Store(), s.PermissionsManager(), s.GeoLocationManager()) + accessLogManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(s.AccessLogDBConn()), s.Store(), s.PermissionsManager(), s.GeoLocationManager()) accessLogManager.StartPeriodicCleanup( context.Background(), s.Config.ReverseProxy.AccessLogRetentionDays, s.Config.ReverseProxy.AccessLogCleanupIntervalHours, ) + s.OnStop(accessLogManager.StopPeriodicCleanup) return accessLogManager }) } diff --git a/management/internals/shared/db/conn.go b/management/internals/shared/db/conn.go index 8edaa1e4e..b21b066e2 100644 --- a/management/internals/shared/db/conn.go +++ b/management/internals/shared/db/conn.go @@ -24,8 +24,8 @@ type TxMetrics interface { CountTransactionDuration(duration time.Duration) } -// Conn is the database connection shared by all repositories: one gorm handle, -// the pgx pool of a Postgres deployment and the engine they talk to. +// Conn is a database connection the repositories run on: one gorm handle, the +// pgx pool of a Postgres deployment and the engine they talk to. type Conn struct { db *gorm.DB pool *pgxpool.Pool @@ -34,15 +34,36 @@ type Conn struct { metrics TxMetrics } +// Option tunes a Conn as it is opened. +type Option func(*connOptions) + +type connOptions struct { + maxOpenConns int +} + +// WithMaxOpenConns sizes the gorm connection pool of this Conn, taking +// precedence over NB_SQL_MAX_OPEN_CONNS. A pgx pool keeps its PoolConfig size, +// and SQLite always uses one connection. +func WithMaxOpenConns(n int) Option { + return func(o *connOptions) { + o.maxOpenConns = n + } +} + // NewConn takes ownership of an open gorm handle and pool once it returns // without error, applying the connection limits and transaction timeout -// configured through the environment. -func NewConn(ctx context.Context, gormDB *gorm.DB, engine Engine, pool *pgxpool.Pool) (*Conn, error) { +// configured through the environment and opts. +func NewConn(ctx context.Context, gormDB *gorm.DB, engine Engine, pool *pgxpool.Pool, opts ...Option) (*Conn, error) { sqlDB, err := gormDB.DB() if err != nil { return nil, err } + var options connOptions + for _, opt := range opts { + opt(&options) + } + txTimeout := defaultTransactionTimeout if v := os.Getenv("NB_STORE_TRANSACTION_TIMEOUT"); v != "" { if parsed, err := time.ParseDuration(v); err == nil { @@ -57,6 +78,9 @@ func NewConn(ctx context.Context, gormDB *gorm.DB, engine Engine, pool *pgxpool. if connsConfigured { conns = configuredConns } + if options.maxOpenConns > 0 { + conns = options.maxOpenConns + } if engine == SqliteStoreEngine { if connsConfigured { log.WithContext(ctx).Warnf("setting NB_SQL_MAX_OPEN_CONNS is not supported for sqlite, using default value 1") diff --git a/management/internals/shared/db/conn_test.go b/management/internals/shared/db/conn_test.go index 4f1a234b2..cd7488422 100644 --- a/management/internals/shared/db/conn_test.go +++ b/management/internals/shared/db/conn_test.go @@ -146,3 +146,22 @@ func TestNewConn_MaxOpenConnsFromEnv(t *testing.T) { require.NoError(t, err) assert.Equal(t, 1, sqliteDB.Stats().MaxOpenConnections) } + +func TestNewConn_MaxOpenConnsOptionOverridesEnv(t *testing.T) { + t.Setenv("NB_SQL_MAX_OPEN_CONNS", "7") + + open := func(engine Engine, opts ...Option) int { + gormDB, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "store.db")), GormConfig()) + require.NoError(t, err) + conn, err := NewConn(context.Background(), gormDB, engine, nil, opts...) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + sqlDB, err := conn.DB(nil).DB() + require.NoError(t, err) + return sqlDB.Stats().MaxOpenConnections + } + + assert.Equal(t, 3, open(PostgresStoreEngine, WithMaxOpenConns(3)), "option should take precedence over the environment") + assert.Equal(t, 7, open(PostgresStoreEngine, WithMaxOpenConns(0)), "a non-positive option should fall back to the environment") + assert.Equal(t, 1, open(SqliteStoreEngine, WithMaxOpenConns(3)), "sqlite should stay on a single connection") +} diff --git a/management/internals/shared/db/open.go b/management/internals/shared/db/open.go index c573e8c08..3c3299bb9 100644 --- a/management/internals/shared/db/open.go +++ b/management/internals/shared/db/open.go @@ -101,7 +101,7 @@ func OpenSqliteFile(ctx context.Context, dataDir, storeFile string) (*Conn, erro } // OpenPostgres opens a Postgres database through gorm and a pgx pool sized by pool. -func OpenPostgres(ctx context.Context, dsn string, pool PoolConfig) (*Conn, error) { +func OpenPostgres(ctx context.Context, dsn string, pool PoolConfig, opts ...Option) (*Conn, error) { gormDB, err := gorm.Open(postgres.Open(dsn), GormConfig()) if err != nil { return nil, err @@ -111,7 +111,7 @@ func OpenPostgres(ctx context.Context, dsn string, pool PoolConfig) (*Conn, erro closeGorm(gormDB) return nil, err } - conn, err := NewConn(ctx, gormDB, PostgresStoreEngine, pgxPool) + conn, err := NewConn(ctx, gormDB, PostgresStoreEngine, pgxPool, opts...) if err != nil { pgxPool.Close() closeGorm(gormDB) @@ -120,6 +120,21 @@ func OpenPostgres(ctx context.Context, dsn string, pool PoolConfig) (*Conn, erro return conn, nil } +// OpenPostgresWithoutPool opens a Postgres database through gorm alone, for +// repositories that never take the pgx read paths. +func OpenPostgresWithoutPool(ctx context.Context, dsn string, opts ...Option) (*Conn, error) { + gormDB, err := gorm.Open(postgres.Open(dsn), GormConfig()) + if err != nil { + return nil, err + } + conn, err := NewConn(ctx, gormDB, PostgresStoreEngine, nil, opts...) + if err != nil { + closeGorm(gormDB) + return nil, err + } + return conn, nil +} + func newPgxPool(ctx context.Context, dsn string, cfg PoolConfig) (*pgxpool.Pool, error) { config, err := pgxpool.ParseConfig(dsn) if err != nil { diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index 88ab70b9c..326dfb9a0 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -14,10 +14,12 @@ import ( "github.com/netbirdio/management-integrations/integrations" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" reverseproxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service/manager" + "github.com/netbirdio/netbird/management/internals/shared/db" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" zonesManager "github.com/netbirdio/netbird/management/internals/modules/zones/manager" @@ -107,7 +109,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee t.Fatalf("Failed to create manager: %v", err) } - accessLogsManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(store.(*nbstore.SqlStore).Conn()), store, permissionsManager, nil) + accessLogsManager := accesslogsmanager.NewManager(newAccessLogsRepository(t), store, permissionsManager, nil) proxyTokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) singleUseStore := nbgrpc.NewSingleUseStore(ctx, cacheStore) noopMeter := noop.NewMeterProvider().Meter("") @@ -249,7 +251,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin t.Fatalf("Failed to create manager: %v", err) } - accessLogsManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(store.(*nbstore.SqlStore).Conn()), store, permissionsManager, nil) + accessLogsManager := accesslogsmanager.NewManager(newAccessLogsRepository(t), store, permissionsManager, nil) proxyTokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) singleUseStore := nbgrpc.NewSingleUseStore(ctx, cacheStore) noopMeter := noop.NewMeterProvider().Meter("") @@ -299,6 +301,16 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin return apiHandler, am, updMsg } +func newAccessLogsRepository(t testing_tools.TB) accesslogs.Repository { + t.Helper() + conn, err := accesslogsmanager.OpenConn(context.Background(), t.TempDir(), db.SqliteStoreEngine) + if err != nil { + t.Fatalf("Failed to open access log store: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + return accesslogsmanager.NewRepository(conn) +} + func mockValidateAndParseToken(_ context.Context, token string) (auth.UserAuth, *jwt.Token, error) { userAuth := auth.UserAuth{} diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 1734129c3..dc1edca88 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -12,7 +12,6 @@ import ( nbdns "github.com/netbirdio/netbird/dns" agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" - "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" @@ -83,7 +82,7 @@ func NewSqlStore(ctx context.Context, conn *db.Conn, metrics telemetry.AppMetric &installation{}, &types.ExtraSettings{}, &posture.Checks{}, &nbpeer.NetworkAddress{}, &networkTypes.Network{}, &routerTypes.NetworkRouter{}, &resourceTypes.NetworkResource{}, &types.AccountOnboarding{}, &types.Job{}, &zones.Zone{}, &records.Record{}, &types.UserInviteRecord{}, &rpservice.Service{}, &rpservice.Target{}, &domain.Domain{}, - &accesslogs.AccessLogEntry{}, &proxy.Proxy{}, + &proxy.Proxy{}, &agentNetworkTypes.Provider{}, &agentNetworkTypes.Policy{}, &agentNetworkTypes.Guardrail{}, &agentNetworkTypes.Settings{}, &agentNetworkTypes.Consumption{}, &agentNetworkTypes.AccountBudgetRule{}, &agentNetworkTypes.AgentNetworkAccessLog{}, &agentNetworkTypes.AgentNetworkAccessLogGroup{}, diff --git a/management/server/store/store.go b/management/server/store/store.go index 6d20c1e2b..670bd3956 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -460,6 +460,11 @@ func lookupDSNEnv(nbKey, legacyKey string) (string, bool) { return os.LookupEnv(legacyKey) } +// LookupPostgresDSN returns the DSN the main store opens a Postgres engine with. +func LookupPostgresDSN() (string, bool) { + return lookupDSNEnv(PostgresDsnEnv, PostgresDsnEnvLegacy) +} + var supportedEngines = []types.Engine{types.SqliteStoreEngine, types.PostgresStoreEngine} func getStoreEngineFromEnv() types.Engine { @@ -530,7 +535,7 @@ func OpenConn(ctx context.Context, kind types.Engine, dataDir string) (*db.Conn, return db.OpenSqlite(ctx, dataDir) case types.PostgresStoreEngine: log.WithContext(ctx).Info("using Postgres store engine") - dsn, ok := lookupDSNEnv(PostgresDsnEnv, PostgresDsnEnvLegacy) + dsn, ok := LookupPostgresDSN() if !ok { return nil, fmt.Errorf("%s is not set", PostgresDsnEnv) } diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index f129ada26..e64c67ce0 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -9381,7 +9381,7 @@ paths: name: search schema: type: string - description: General search across request ID, host, path, source IP, user email, and user name + description: General search across request ID, host, path, and source IP - in: query name: source_ip schema: diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index a5ffe4145..6a7450153 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -6051,7 +6051,7 @@ type GetApiEventsProxyParams struct { // SortOrder Sort order (ascending or descending) SortOrder *GetApiEventsProxyParamsSortOrder `form:"sort_order,omitempty" json:"sort_order,omitempty"` - // Search General search across request ID, host, path, source IP, user email, and user name + // Search General search across request ID, host, path, and source IP Search *string `form:"search,omitempty" json:"search,omitempty"` // SourceIp Filter by source IP address From c051ca8f8767617091ce8fdb23edccd83487cba0 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Sat, 24 Oct 2026 01:07:20 +0200 Subject: [PATCH 202/209] [client,proxy,management,signal,relay] Distrust compromised eMudhra CA keys (#8326) * [client,proxy,management,signal,relay] Distrust compromised eMudhra CA keys Eight eMudhra intermediate CA private keys were compromised and browsers blocked them on 2026-10-17. Our binaries were built with go1.26.7, which accepts chains through those intermediates whenever the trust store still holds emSign ECC Root CA - G3. go1.26.10 rejects them in crypto/x509 on every platform, regardless of the trust store. The UBI base still ships the root and Red Hat has no fixed package yet, so the UBI images blocklist it with p11-kit. The ingest image, the UI server image and the pinned Chainguard digests also carried the root and move to bases that no longer do. * [client,proxy,management,signal,relay] Use the fixed UBI base instead of a blocklist Red Hat shipped ca-certificates 2026.2.92_v9.0.319-90.3 on 2026-10-22, which drops emSign ECC Root CA - G3 and AAA Certificate Services. The current ubi9-minimal image carries it, so pin that digest and remove the p11-kit blocklist step. * [misc] Pin upload-server images by digest only and restore go.mod comment --- .devcontainer/Dockerfile | 2 +- .github/workflows/golang-test-linux.yml | 2 +- client/Dockerfile-rootless.ubi | 2 +- client/internal/metrics/infra/ingest/Dockerfile | 4 ++-- client/testutil/privileged/runner_test.go | 2 +- client/ui/build/docker/Dockerfile.cross | 2 +- client/ui/build/docker/Dockerfile.server | 4 ++-- combined/Dockerfile.multistage | 2 +- combined/Dockerfile.ubi | 2 +- docs/testing-privileged.md | 2 +- e2e/harness/Dockerfile.client | 2 +- e2e/harness/Dockerfile.nblink | 2 +- go.mod | 2 +- management/Dockerfile.multistage | 2 +- management/Dockerfile.ubi | 2 +- proxy/Dockerfile | 2 +- proxy/Dockerfile.multistage | 2 +- proxy/Dockerfile.ubi | 2 +- relay/Dockerfile.ubi | 2 +- signal/Dockerfile.ubi | 2 +- upload-server/Dockerfile | 11 ++++++----- 21 files changed, 28 insertions(+), 27 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 9b6a0edfd..dc7475a06 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.7-bookworm +FROM golang:1.26.10-bookworm RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends\ diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index b3e4dd4f7..eea69eaa7 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -303,7 +303,7 @@ jobs: -e GOCACHE=${CONTAINER_GOCACHE} \ -e GOMODCACHE=${CONTAINER_GOMODCACHE} \ -e CONTAINER=${CONTAINER} \ - golang:1.26.7-alpine \ + golang:1.26.10-alpine \ sh -c ' \ apk update; apk add --no-cache \ ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \ diff --git a/client/Dockerfile-rootless.ubi b/client/Dockerfile-rootless.ubi index 4701728c1..396e0d562 100644 --- a/client/Dockerfile-rootless.ubi +++ b/client/Dockerfile-rootless.ubi @@ -1,4 +1,4 @@ -FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:2d4c4551af6831dcd26bbf86108c4385de5183ec8fe6b5868f6f9640fe47f4ed ARG TARGETPLATFORM ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird diff --git a/client/internal/metrics/infra/ingest/Dockerfile b/client/internal/metrics/infra/ingest/Dockerfile index 3620c524b..1b4862a7d 100644 --- a/client/internal/metrics/infra/ingest/Dockerfile +++ b/client/internal/metrics/infra/ingest/Dockerfile @@ -1,9 +1,9 @@ -FROM golang:1.25-alpine AS build +FROM golang:1.26.10-alpine AS build WORKDIR /app COPY go.mod main.go ./ RUN CGO_ENABLED=0 go build -o ingest . -FROM alpine:3.20 +FROM alpine:3.24 RUN adduser -D -H ingest COPY --from=build /app/ingest /usr/local/bin/ingest USER ingest diff --git a/client/testutil/privileged/runner_test.go b/client/testutil/privileged/runner_test.go index 157005d3e..c7bde1316 100644 --- a/client/testutil/privileged/runner_test.go +++ b/client/testutil/privileged/runner_test.go @@ -25,7 +25,7 @@ import ( // (.github/workflows/golang-test-linux.yml, test_client_on_docker). const ( containerImage = "golang" - containerTag = "1.26.7-alpine" + containerTag = "1.26.10-alpine" ) const ( diff --git a/client/ui/build/docker/Dockerfile.cross b/client/ui/build/docker/Dockerfile.cross index 55c0d69e1..650087fb3 100644 --- a/client/ui/build/docker/Dockerfile.cross +++ b/client/ui/build/docker/Dockerfile.cross @@ -13,7 +13,7 @@ # docker run --rm -v $(pwd):/app wails-cross windows amd64 # docker run --rm -v $(pwd):/app wails-cross windows arm64 -FROM golang:1.26.7-bookworm +FROM golang:1.26.10-bookworm ARG TARGETARCH diff --git a/client/ui/build/docker/Dockerfile.server b/client/ui/build/docker/Dockerfile.server index 57183f1d2..7a7f36575 100644 --- a/client/ui/build/docker/Dockerfile.server +++ b/client/ui/build/docker/Dockerfile.server @@ -2,7 +2,7 @@ # Multi-stage build for minimal image size # Build stage -FROM golang:1.26.7-alpine AS builder +FROM golang:1.26.10-alpine AS builder WORKDIR /app @@ -22,7 +22,7 @@ RUN go mod tidy RUN go build -tags server -ldflags="-s -w" -o server . # Runtime stage - minimal image -FROM gcr.io/distroless/static-debian12 +FROM gcr.io/distroless/static-debian13 # Copy the binary COPY --from=builder /app/server /server diff --git a/combined/Dockerfile.multistage b/combined/Dockerfile.multistage index 011379c2f..0551516fd 100644 --- a/combined/Dockerfile.multistage +++ b/combined/Dockerfile.multistage @@ -1,4 +1,4 @@ -FROM golang:1.26.7-bookworm AS builder +FROM golang:1.26.10-bookworm AS builder WORKDIR /app # Install build dependencies diff --git a/combined/Dockerfile.ubi b/combined/Dockerfile.ubi index 66ef55a34..dbbb920c9 100644 --- a/combined/Dockerfile.ubi +++ b/combined/Dockerfile.ubi @@ -1,4 +1,4 @@ -FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:2d4c4551af6831dcd26bbf86108c4385de5183ec8fe6b5868f6f9640fe47f4ed ARG TARGETPLATFORM ARG VERSION=dev diff --git a/docs/testing-privileged.md b/docs/testing-privileged.md index 939770de9..7d9552c4a 100644 --- a/docs/testing-privileged.md +++ b/docs/testing-privileged.md @@ -32,7 +32,7 @@ list; both are optional and default to the full privileged suite. 1. Skips immediately when it detects it is already inside the container (`DOCKER_CI=true`), so the privileged tests run in place instead of recursing. -2. Otherwise spins up a `golang:1.26.7-alpine` container (matching CI), +2. Otherwise spins up a `golang:1.26.10-alpine` container (matching CI), bind-mounts the repo and the host Go build/module caches, installs the required packages, and runs `go test -tags 'devcert privileged'` over the client packages. diff --git a/e2e/harness/Dockerfile.client b/e2e/harness/Dockerfile.client index 4c76b95c6..4bcb2b079 100644 --- a/e2e/harness/Dockerfile.client +++ b/e2e/harness/Dockerfile.client @@ -3,7 +3,7 @@ # artifact), so this mirrors its alpine runtime + entrypoint while compiling the # CGO-free client inline. BuildKit cache mounts keep rebuilds incremental. -FROM golang:1.26.7-bookworm AS builder +FROM golang:1.26.10-bookworm AS builder WORKDIR /src COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod go mod download diff --git a/e2e/harness/Dockerfile.nblink b/e2e/harness/Dockerfile.nblink index dac3aceec..0628aa083 100644 --- a/e2e/harness/Dockerfile.nblink +++ b/e2e/harness/Dockerfile.nblink @@ -8,7 +8,7 @@ # that quietly hands it NET_ADMIN would make the suite prove the opposite of # what it is for. -FROM golang:1.26.7-bookworm AS builder +FROM golang:1.26.10-bookworm AS builder WORKDIR /src COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod go mod download diff --git a/go.mod b/go.mod index f93141d5c..0d1492731 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.0 // Pin the toolchain to a patch release >= go1.26.2 // See https://go.dev/issue/77875. -toolchain go1.26.7 +toolchain go1.26.10 require ( cunicu.li/go-rosenpass v0.5.42 diff --git a/management/Dockerfile.multistage b/management/Dockerfile.multistage index 5d037f1b1..1e9d11227 100644 --- a/management/Dockerfile.multistage +++ b/management/Dockerfile.multistage @@ -1,4 +1,4 @@ -FROM golang:1.26.7-bookworm AS builder +FROM golang:1.26.10-bookworm AS builder WORKDIR /app # Install build dependencies diff --git a/management/Dockerfile.ubi b/management/Dockerfile.ubi index 628d1a086..163302aa9 100644 --- a/management/Dockerfile.ubi +++ b/management/Dockerfile.ubi @@ -1,4 +1,4 @@ -FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:2d4c4551af6831dcd26bbf86108c4385de5183ec8fe6b5868f6f9640fe47f4ed ARG TARGETPLATFORM ARG VERSION=dev diff --git a/proxy/Dockerfile b/proxy/Dockerfile index 5944d944e..e4f9a0239 100644 --- a/proxy/Dockerfile +++ b/proxy/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.7-alpine AS builder +FROM golang:1.26.10-alpine AS builder WORKDIR /app RUN echo "netbird:x:1000:1000:netbird:/var/lib/netbird:/sbin/nologin" > /tmp/passwd && \ diff --git a/proxy/Dockerfile.multistage b/proxy/Dockerfile.multistage index d1db32296..ff46280d2 100644 --- a/proxy/Dockerfile.multistage +++ b/proxy/Dockerfile.multistage @@ -1,4 +1,4 @@ -FROM golang:1.26.7-alpine AS builder +FROM golang:1.26.10-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ diff --git a/proxy/Dockerfile.ubi b/proxy/Dockerfile.ubi index a74280a49..013eb423d 100644 --- a/proxy/Dockerfile.ubi +++ b/proxy/Dockerfile.ubi @@ -1,4 +1,4 @@ -FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:2d4c4551af6831dcd26bbf86108c4385de5183ec8fe6b5868f6f9640fe47f4ed ARG TARGETPLATFORM ARG VERSION=dev diff --git a/relay/Dockerfile.ubi b/relay/Dockerfile.ubi index 86b0cbc01..b4987a424 100644 --- a/relay/Dockerfile.ubi +++ b/relay/Dockerfile.ubi @@ -1,4 +1,4 @@ -FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:2d4c4551af6831dcd26bbf86108c4385de5183ec8fe6b5868f6f9640fe47f4ed ARG TARGETPLATFORM ARG VERSION=dev diff --git a/signal/Dockerfile.ubi b/signal/Dockerfile.ubi index f0a6552da..5b4036469 100644 --- a/signal/Dockerfile.ubi +++ b/signal/Dockerfile.ubi @@ -1,4 +1,4 @@ -FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:2d4c4551af6831dcd26bbf86108c4385de5183ec8fe6b5868f6f9640fe47f4ed ARG TARGETPLATFORM ARG VERSION=dev diff --git a/upload-server/Dockerfile b/upload-server/Dockerfile index 8098a0186..16fd79871 100644 --- a/upload-server/Dockerfile +++ b/upload-server/Dockerfile @@ -16,13 +16,14 @@ # VARIANT=release|debug debug swaps the base for Chainguard busybox (a shell) # VERSION stamped into the binary the same way goreleaser does # -# Chainguard publishes only :latest for free, so the bases are pinned by digest -# and moved by Dependabot. +# Images are pinned by digest alone. The builder is golang:1.26.10-bookworm and +# the bases are Chainguard's :latest, the only free tag; bump them by hand with +# docker buildx imagetools inspect . ARG VARIANT=release # Pure Go: cross-compile from the build host instead of emulating the target. -FROM --platform=$BUILDPLATFORM golang:1.26.7-bookworm@sha256:e8c859f5632dcfde7b32d2012b4351728f6437930887c2f6a91ea242459e5514 AS builder +FROM --platform=$BUILDPLATFORM golang@sha256:632dff7351799a5a9954a48dc06670ade72ee44e8c351cdc0e5a57af2fea21af AS builder WORKDIR /app COPY go.mod go.sum ./ @@ -39,8 +40,8 @@ RUN --mount=type=cache,target=/go/pkg/mod \ -o /out/netbird-upload ./upload-server \ && mkdir -p /out/var/lib/netbird -FROM cgr.dev/chainguard/static:latest@sha256:41e17ed83c594a64a9396b6ab96dd26d5ddc290dacf4c177464712ff21ad534f AS base-release -FROM cgr.dev/chainguard/busybox:latest@sha256:b2953ab1cae4a6265e18cf675851bd99975211b150d7a014774911d76eb309ba AS base-debug +FROM cgr.dev/chainguard/static@sha256:17367e4d3ae2b46c3290858e483fac09be085c92b372360fe3bf6a6150848fde AS base-release +FROM cgr.dev/chainguard/busybox@sha256:98e9e7306c899a8af252ef1ff50c54def91c2fe04677298eafdf0b902f2980ed AS base-debug # hadolint ignore=DL3006 FROM base-${VARIANT} From 9dd51ca42e975780bc6c9691111a712b7f08f430 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:49:51 +0900 Subject: [PATCH 203/209] [client] Drop SSH privileges through libc in PKCS#11 package builds (#8323) --- .github/workflows/golang-test-linux.yml | 8 ++ .golangci.yaml | 12 ++ .../executor_drop_privileged_linux_test.go | 102 +++++++++++++++++ client/ssh/server/executor_unix.go | 11 +- client/ssh/server/setid_libc_linux.go | 108 ++++++++++++++++++ client/ssh/server/setid_unix.go | 25 ++++ 6 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 client/ssh/server/executor_drop_privileged_linux_test.go create mode 100644 client/ssh/server/setid_libc_linux.go create mode 100644 client/ssh/server/setid_unix.go diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml index eea69eaa7..e4388bfe3 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -244,6 +244,14 @@ jobs: NB_TEST_PKCS11_DISPOSABLE: "1" run: CGO_ENABLED=0 go test -tags pkcs11 -timeout 5m ./client/internal/pkcs11/... ./client/internal/certproof/... + - name: Test SSH privilege drop + # purego marks the runtime as cgo without libc's setxid hooks, so the syscall + # package's setuid family panics in this build. Drop privileges as root to cover it. + run: | + CGO_ENABLED=0 go test -c -tags 'pkcs11 privileged' -o "$RUNNER_TEMP/ssh-server.test" ./client/ssh/server/ + sudo "$RUNNER_TEMP/ssh-server.test" -test.run '^TestDropPrivilegesAllThreads$' -test.v + CGO_ENABLED=0 GOARCH=arm64 go test -c -tags 'pkcs11 privileged' -o "$RUNNER_TEMP/ssh-server-arm64.test" ./client/ssh/server/ + test_client_on_docker: name: "Client (Docker) / Unit" needs: [build-cache] diff --git a/.golangci.yaml b/.golangci.yaml index e350b9de7..f52f822dd 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -24,6 +24,15 @@ linters: settings: errcheck: check-type-assertions: false + forbidigo: + analyze-types: true + forbid: + - pattern: ^(fmt\.Print(|f|ln)|print|println)$ + # Builds that link libc through purego mark the runtime as cgo without + # registering the libc setxid hooks, so these panic in AllThreadsSyscall. + - pattern: ^(syscall|unix)\.(Setgroups|Set(e|re|res)?[ug]id|AllThreadsSyscall6?)$ + pkg: ^(syscall|golang\.org/x/sys/unix)$ + msg: use the setgroups/setgid/setuid helpers in client/ssh/server, which go through libc in purego builds gocritic: disabled-checks: - commentFormatting @@ -86,6 +95,9 @@ linters: - linters: - forbidigo path: signal/cmd/root\.go + - linters: + - forbidigo + path: client/ssh/server/setid_unix\.go - linters: - unused path: sharedsock/filter\.go diff --git a/client/ssh/server/executor_drop_privileged_linux_test.go b/client/ssh/server/executor_drop_privileged_linux_test.go new file mode 100644 index 000000000..27cc1384b --- /dev/null +++ b/client/ssh/server/executor_drop_privileged_linux_test.go @@ -0,0 +1,102 @@ +//go:build linux && privileged + +package server + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const envDropPrivilegesChild = "NB_TEST_DROP_PRIVILEGES_CHILD" + +const ( + dropTargetUID = 65534 + dropTargetGID = 65534 + dropExtraGID = 65533 +) + +// TestDropPrivilegesAllThreads drops privileges in a child process, because the drop +// cannot be undone, and checks that every OS thread of that process carries the target +// credentials. A drop applied to the calling thread only would leave the others as root. +func TestDropPrivilegesAllThreads(t *testing.T) { + if os.Getenv(envDropPrivilegesChild) == "1" { + dropPrivilegesChild(t) + return + } + if os.Geteuid() != 0 { + t.Skip("requires root") + } + + cmd := exec.Command(os.Args[0], "-test.run=^TestDropPrivilegesAllThreads$", "-test.v") + cmd.Env = append(os.Environ(), envDropPrivilegesChild+"=1") + out, err := cmd.CombinedOutput() + t.Logf("child output:\n%s", out) + require.NoError(t, err, "child must drop privileges on all threads") + assert.Contains(t, string(out), "--- PASS: TestDropPrivilegesAllThreads", "child test must run and pass") +} + +func dropPrivilegesChild(t *testing.T) { + // Park goroutines on their own OS threads so the process has threads other than + // the one that performs the drop. + release := make(chan struct{}) + defer close(release) + ready := make(chan struct{}) + for range 4 { + go func() { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + ready <- struct{}{} + <-release + }() + <-ready + } + + pd := NewPrivilegeDropper() + require.NoError(t, pd.DropPrivileges(dropTargetUID, dropTargetGID, []uint32{dropTargetGID, dropExtraGID})) + + tasks, err := filepath.Glob("/proc/self/task/*/status") + require.NoError(t, err) + require.Greater(t, len(tasks), 4, "process must have several threads for the check to mean anything") + + want := map[string]string{ + "Uid": fmt.Sprintf("%d\t%d\t%d\t%d", dropTargetUID, dropTargetUID, dropTargetUID, dropTargetUID), + "Gid": fmt.Sprintf("%d\t%d\t%d\t%d", dropTargetGID, dropTargetGID, dropTargetGID, dropTargetGID), + "Groups": fmt.Sprintf("%d %d", dropExtraGID, dropTargetGID), + } + for _, task := range tasks { + got := readStatusFields(t, task) + for field, value := range want { + assert.Equal(t, value, got[field], "%s of %s", field, task) + } + } + + assert.Error(t, seteuid(0), "euid 0 must not be restorable after the drop") + assert.Error(t, setegid(0), "egid 0 must not be restorable after the drop") +} + +func readStatusFields(t *testing.T, path string) map[string]string { + f, err := os.Open(path) + require.NoError(t, err) + defer f.Close() + + fields := make(map[string]string) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + key, value, ok := strings.Cut(scanner.Text(), ":") + if !ok { + continue + } + fields[key] = strings.TrimSpace(value) + } + require.NoError(t, scanner.Err()) + return fields +} diff --git a/client/ssh/server/executor_unix.go b/client/ssh/server/executor_unix.go index ee0b0ff78..5b42190ab 100644 --- a/client/ssh/server/executor_unix.go +++ b/client/ssh/server/executor_unix.go @@ -11,7 +11,6 @@ import ( "path/filepath" "runtime" "strings" - "syscall" log "github.com/sirupsen/logrus" ) @@ -151,15 +150,15 @@ func (pd *PrivilegeDropper) setGroupsAndIDs(targetUID, targetGID uint32, supplem } } - if err := syscall.Setgroups(groups); err != nil { + if err := setgroups(groups); err != nil { return fmt.Errorf("setgroups to %v: %w", groups, err) } - if err := syscall.Setgid(int(targetGID)); err != nil { + if err := setgid(int(targetGID)); err != nil { return fmt.Errorf("setgid to %d: %w", targetGID, err) } - if err := syscall.Setuid(int(targetUID)); err != nil { + if err := setuid(int(targetUID)); err != nil { return fmt.Errorf("setuid to %d: %w", targetUID, err) } @@ -182,12 +181,12 @@ func (pd *PrivilegeDropper) validatePrivilegeDropSuccess(targetUID, targetGID ui // validatePrivilegeDropReversibility ensures privileges cannot be restored func (pd *PrivilegeDropper) validatePrivilegeDropReversibility(targetUID, targetGID uint32, originalUID, originalGID int) error { if originalGID != int(targetGID) { - if err := syscall.Setegid(originalGID); err == nil { + if err := setegid(originalGID); err == nil { return fmt.Errorf("privilege drop validation failed: able to restore original GID %d", originalGID) } } if originalUID != int(targetUID) { - if err := syscall.Seteuid(originalUID); err == nil { + if err := seteuid(originalUID); err == nil { return fmt.Errorf("privilege drop validation failed: able to restore original UID %d", originalUID) } } diff --git a/client/ssh/server/setid_libc_linux.go b/client/ssh/server/setid_libc_linux.go new file mode 100644 index 000000000..13a9f254c --- /dev/null +++ b/client/ssh/server/setid_libc_linux.go @@ -0,0 +1,108 @@ +//go:build pkcs11 && linux && !android && (amd64 || arm64) + +package server + +import ( + "fmt" + "runtime" + "sync" + "syscall" + + "github.com/ebitengine/purego" +) + +// This build links libc through purego, whose fakecgo runtime marks the binary as cgo +// without registering the libc hooks a real cgo build provides. syscall's setuid family +// then panics in AllThreadsSyscall, so the calls go to libc directly: glibc applies them +// to every thread, and all Go threads here are created through pthread_create. + +type libcSetID struct { + setgroups func(size uintptr, list *uint32) int32 + setgid func(gid uint32) int32 + setuid func(uid uint32) int32 + setegid func(egid uint32) int32 + seteuid func(euid uint32) int32 + errnoLocation func() *int32 +} + +var ( + libcSetIDOnce sync.Once + libcSetIDFns libcSetID + libcSetIDErr error +) + +func loadLibcSetID() (*libcSetID, error) { + libcSetIDOnce.Do(func() { + syms := []struct { + fn any + name string + }{ + {&libcSetIDFns.setgroups, "setgroups"}, + {&libcSetIDFns.setgid, "setgid"}, + {&libcSetIDFns.setuid, "setuid"}, + {&libcSetIDFns.setegid, "setegid"}, + {&libcSetIDFns.seteuid, "seteuid"}, + {&libcSetIDFns.errnoLocation, "__errno_location"}, + } + for _, s := range syms { + addr, err := purego.Dlsym(purego.RTLD_DEFAULT, s.name) + if err != nil { + libcSetIDErr = fmt.Errorf("resolve libc %s: %w", s.name, err) + return + } + purego.RegisterFunc(s.fn, addr) + } + }) + if libcSetIDErr != nil { + return nil, libcSetIDErr + } + return &libcSetIDFns, nil +} + +// libcCall runs call on a locked thread so errno is read from the thread that set it. +func libcCall(call func(*libcSetID) int32) error { + fns, err := loadLibcSetID() + if err != nil { + return err + } + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + if call(fns) == 0 { + return nil + } + if errno := *fns.errnoLocation(); errno != 0 { + return syscall.Errno(errno) + } + return syscall.EINVAL +} + +func setgroups(gids []int) error { + list := make([]uint32, len(gids)) + for i, g := range gids { + list[i] = uint32(g) + } + return libcCall(func(f *libcSetID) int32 { + if len(list) == 0 { + return f.setgroups(0, nil) + } + return f.setgroups(uintptr(len(list)), &list[0]) + }) +} + +func setgid(gid int) error { + return libcCall(func(f *libcSetID) int32 { return f.setgid(uint32(gid)) }) +} + +func setuid(uid int) error { + return libcCall(func(f *libcSetID) int32 { return f.setuid(uint32(uid)) }) +} + +func setegid(egid int) error { + return libcCall(func(f *libcSetID) int32 { return f.setegid(uint32(egid)) }) +} + +func seteuid(euid int) error { + return libcCall(func(f *libcSetID) int32 { return f.seteuid(uint32(euid)) }) +} diff --git a/client/ssh/server/setid_unix.go b/client/ssh/server/setid_unix.go new file mode 100644 index 000000000..bd9fe3856 --- /dev/null +++ b/client/ssh/server/setid_unix.go @@ -0,0 +1,25 @@ +//go:build unix && !(pkcs11 && linux && !android && (amd64 || arm64)) + +package server + +import "syscall" + +func setgroups(gids []int) error { + return syscall.Setgroups(gids) +} + +func setgid(gid int) error { + return syscall.Setgid(gid) +} + +func setuid(uid int) error { + return syscall.Setuid(uid) +} + +func setegid(egid int) error { + return syscall.Setegid(egid) +} + +func seteuid(euid int) error { + return syscall.Seteuid(euid) +} From dd38fabc82d7314c11500d7fb8f1ee8161258250 Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Sat, 24 Oct 2026 14:01:45 -0400 Subject: [PATCH 204/209] [client] Add --table output to netbird status (#8305) --- client/cmd/status.go | 8 +- client/cmd/status_test.go | 59 ++++++++++++ client/status/table.go | 55 +++++++++++ client/status/table_test.go | 176 ++++++++++++++++++++++++++++++++++++ 4 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 client/status/table.go create mode 100644 client/status/table_test.go diff --git a/client/cmd/status.go b/client/cmd/status.go index f2e5bcc66..6049d701a 100644 --- a/client/cmd/status.go +++ b/client/cmd/status.go @@ -19,6 +19,7 @@ import ( var ( detailFlag bool + tableFlag bool ipv4Flag bool ipv6Flag bool jsonFlag bool @@ -43,11 +44,12 @@ func init() { ipsFilterMap = make(map[string]struct{}) prefixNamesFilterMap = make(map[string]struct{}) statusCmd.PersistentFlags().BoolVarP(&detailFlag, "detail", "d", false, "display detailed status information in human-readable format") + statusCmd.PersistentFlags().BoolVarP(&tableFlag, "table", "t", false, "display peers in a table, supports the --filter-by-* flags") statusCmd.PersistentFlags().BoolVarP(&jsonFlag, "json", "j", false, "display detailed status information in json format") statusCmd.PersistentFlags().BoolVarP(&yamlFlag, "yaml", "y", false, "display detailed status information in yaml format") statusCmd.PersistentFlags().BoolVarP(&ipv4Flag, "ipv4", "4", false, "display only NetBird IPv4 of this peer, e.g., --ipv4 will output 100.64.0.33") statusCmd.PersistentFlags().BoolVarP(&ipv6Flag, "ipv6", "6", false, "display only NetBird IPv6 of this peer") - statusCmd.MarkFlagsMutuallyExclusive("detail", "json", "yaml", "ipv4", "ipv6") + statusCmd.MarkFlagsMutuallyExclusive("detail", "table", "json", "yaml", "ipv4", "ipv6") statusCmd.PersistentFlags().StringSliceVarP(&ipsFilter, "filter-by-ips", "I", []string{}, "filters the detailed output by a list of one or more IPs (v4 or v6), e.g., --filter-by-ips 100.64.0.100,fd00::1") statusCmd.PersistentFlags().StringSliceVarP(&prefixNamesFilter, "filter-by-names", "N", []string{}, "filters the detailed output by a list of one or more peer FQDN or hostnames, e.g., --filter-by-names peer-a,peer-b.netbird.cloud") statusCmd.PersistentFlags().StringVarP(&statusFilter, "filter-by-status", "S", "", "filters the detailed output by connection status(idle|connecting|connected), e.g., --filter-by-status connected") @@ -143,6 +145,8 @@ func statusFunc(cmd *cobra.Command, args []string) error { switch { case detailFlag: statusOutputString = outputInformationHolder.FullDetailSummary() + case tableFlag: + statusOutputString = outputInformationHolder.PeersTable() case jsonFlag: statusOutputString, err = outputInformationHolder.JSON() case yamlFlag: @@ -238,7 +242,7 @@ func parseFilters() error { } func enableDetailFlagWhenFilterFlag() { - if !detailFlag && !jsonFlag && !yamlFlag { + if !detailFlag && !jsonFlag && !yamlFlag && !tableFlag { detailFlag = true } } diff --git a/client/cmd/status_test.go b/client/cmd/status_test.go index 03608eab0..ca0fd97dd 100644 --- a/client/cmd/status_test.go +++ b/client/cmd/status_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParsingOfIP(t *testing.T) { @@ -13,3 +14,61 @@ func TestParsingOfIP(t *testing.T) { assert.Equal(t, "192.168.178.123\n", parsedIP) } + +// resetStatusFlags restores only the status-specific output flags. The global +// root flags (setup-key and friends) are shared with other tests in the +// package, so they must not be touched here. +func resetStatusFlags(t *testing.T) { + t.Helper() + t.Cleanup(func() { + for _, name := range []string{"detail", "table", "json", "yaml", "ipv4", "ipv6"} { + f := statusCmd.Flags().Lookup(name) + require.NotNil(t, f, "flag %s must exist", name) + require.NoError(t, f.Value.Set(f.DefValue)) + f.Changed = false + } + }) +} + +func TestStatusTableFlagExclusive(t *testing.T) { + for _, other := range []string{"detail", "json", "yaml", "ipv4", "ipv6"} { + t.Run(other, func(t *testing.T) { + resetStatusFlags(t) + require.NoError(t, statusCmd.Flags().Set("table", "true")) + require.NoError(t, statusCmd.Flags().Set(other, "true")) + + assert.Error(t, statusCmd.ValidateFlagGroups(), "--table with --%s must be rejected", other) + }) + } + + t.Run("alone", func(t *testing.T) { + resetStatusFlags(t) + require.NoError(t, statusCmd.Flags().Set("table", "true")) + + assert.NoError(t, statusCmd.ValidateFlagGroups()) + }) +} + +func TestFilterEnablesDetailOnlyWithoutOutputFormat(t *testing.T) { + tests := []struct { + name string + table bool + json bool + wantDetail bool + }{ + {name: "no format", wantDetail: true}, + {name: "table", table: true, wantDetail: false}, + {name: "json", json: true, wantDetail: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetStatusFlags(t) + tableFlag, jsonFlag = tt.table, tt.json + + enableDetailFlagWhenFilterFlag() + + assert.Equal(t, tt.wantDetail, detailFlag) + }) + } +} diff --git a/client/status/table.go b/client/status/table.go new file mode 100644 index 000000000..f1cd7e01d --- /dev/null +++ b/client/status/table.go @@ -0,0 +1,55 @@ +package status + +import ( + "fmt" + "strings" + "text/tabwriter" + "time" +) + +// PeersTable renders the peers as an aligned table, one row per peer. +func (o *OutputOverview) PeersTable() string { + var sb strings.Builder + fmt.Fprintf(&sb, "Peers (%d/%d connected):\n", o.Peers.Connected, o.Peers.Total) + + tw := tabwriter.NewWriter(&sb, 0, 0, 3, ' ', 0) + fmt.Fprintln(tw, "NAME\tIP\tSTATUS\tCONN\tLATENCY\tRX / TX") + for _, p := range o.Peers.Details { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s / %s\n", + shortName(p.FQDN), + dashIfEmpty(p.IP), + p.Status, + dashIfEmpty(p.ConnType), + tableLatency(p), + toIEC(p.TransferReceived), + toIEC(p.TransferSent), + ) + } + // The writer targets a strings.Builder, which never returns an error. + _ = tw.Flush() + + return sb.String() +} + +// shortName drops the domain suffix, which is the same for every peer. +func shortName(fqdn string) string { + name, _, _ := strings.Cut(fqdn, ".") + return dashIfEmpty(name) +} + +func tableLatency(p PeerStateDetailOutput) string { + if p.Latency <= 0 { + return "-" + } + if p.Latency >= time.Millisecond { + return p.Latency.Round(time.Millisecond).String() + } + return p.Latency.Round(time.Microsecond).String() +} + +func dashIfEmpty(s string) string { + if s == "" { + return "-" + } + return s +} diff --git a/client/status/table_test.go b/client/status/table_test.go new file mode 100644 index 000000000..c60e57917 --- /dev/null +++ b/client/status/table_test.go @@ -0,0 +1,176 @@ +package status + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + protobuf "google.golang.org/protobuf/proto" + + "github.com/netbirdio/netbird/client/anonymize" + "github.com/netbirdio/netbird/client/proto" +) + +func TestPeersTable(t *testing.T) { + overview := OutputOverview{Peers: PeersStateOutput{ + Total: 2, + Connected: 1, + Details: []PeerStateDetailOutput{ + { + FQDN: "web-01.netbird.cloud", + IP: "100.64.0.12", + PubKey: "k3Vx9pQ2abcdefghijklmnopqrstuvwxyz0123456789=", + Status: "Connected", + ConnType: "P2P", + Latency: 12 * time.Millisecond, + TransferReceived: 1024, + TransferSent: 2048, + }, + { + FQDN: "old-nas.netbird.cloud", + IP: "100.64.0.77", + Status: "Idle", + }, + }, + }} + + lines := strings.Split(strings.TrimRight(overview.PeersTable(), "\n"), "\n") + require.Len(t, lines, 4, "title, header and one line per peer") + + assert.Equal(t, "Peers (1/2 connected):", lines[0]) + assert.Equal(t, + []string{"NAME", "IP", "STATUS", "CONN", "LATENCY", "RX", "/", "TX"}, + strings.Fields(lines[1])) + + connected := strings.Fields(lines[2]) + assert.Equal(t, "web-01", connected[0], "domain suffix must be dropped") + assert.Equal(t, "Connected", connected[2]) + assert.Equal(t, "P2P", connected[3]) + assert.Equal(t, "12ms", connected[4]) + assert.NotContains(t, lines[2], "k3Vx9pQ2", "public key must not be shown") + assert.Contains(t, lines[2], "1.0 KiB / 2.0 KiB") + + idle := strings.Fields(lines[3]) + assert.Equal(t, []string{"old-nas", "100.64.0.77", "Idle", "-", "-", "0", "B", "/", "0", "B"}, idle, + "missing values render as dashes") +} + +func TestPeersTable_NoPeers(t *testing.T) { + out := (&OutputOverview{}).PeersTable() + assert.True(t, strings.HasPrefix(out, "Peers (0/0 connected):\n")) + assert.Contains(t, out, "NAME") +} + +func tableRows(t *testing.T, opts ConvertOptions) []string { + t.Helper() + + opts.DaemonVersion = resp.GetDaemonVersion() + opts.DaemonStatus = ParseDaemonStatus(resp.GetStatus()) + // The anonymizer rewrites slices in place, so work on a copy to keep the + // shared fixture intact for the other tests. + fullStatus := protobuf.Clone(resp.GetFullStatus()).(*proto.FullStatus) + overview := ConvertToStatusOutputOverview(fullStatus, opts) + out := overview.PeersTable() + + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + require.GreaterOrEqual(t, len(lines), 2, "title and header are always present") + return lines +} + +// The fixture holds peer-1 (connected, P2P) and peer-2 (connected, relayed). +func TestPeersTable_Filters(t *testing.T) { + tests := []struct { + name string + opts ConvertOptions + wantTitle string + wantPeers []string + }{ + { + name: "no filter lists every peer sorted by IP", + wantTitle: "Peers (2/2 connected):", + wantPeers: []string{"peer-1", "peer-2"}, + }, + { + name: "connection type P2P", + opts: ConvertOptions{ConnectionTypeFilter: "P2P"}, + wantTitle: "Peers (1/1 connected):", + wantPeers: []string{"peer-1"}, + }, + { + name: "connection type Relayed", + opts: ConvertOptions{ConnectionTypeFilter: "relayed"}, + wantTitle: "Peers (1/1 connected):", + wantPeers: []string{"peer-2"}, + }, + { + name: "status with no match", + opts: ConvertOptions{StatusFilter: "idle"}, + wantTitle: "Peers (0/0 connected):", + }, + { + name: "name prefix", + opts: ConvertOptions{ + PrefixNamesFilter: []string{"peer-2"}, + PrefixNamesFilterMap: map[string]struct{}{"peer-2": {}}, + }, + wantTitle: "Peers (1/1 connected):", + wantPeers: []string{"peer-2"}, + }, + { + name: "IPv4", + opts: ConvertOptions{IPsFilter: map[string]struct{}{"192.168.178.101": {}}}, + wantTitle: "Peers (1/1 connected):", + wantPeers: []string{"peer-1"}, + }, + { + name: "IPv6", + opts: ConvertOptions{IPsFilter: map[string]struct{}{"fd00::1": {}}}, + wantTitle: "Peers (1/1 connected):", + wantPeers: []string{"peer-1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lines := tableRows(t, tt.opts) + + assert.Equal(t, tt.wantTitle, lines[0]) + rows := lines[2:] + require.Len(t, rows, len(tt.wantPeers), "one row per matching peer") + for i, name := range tt.wantPeers { + assert.True(t, strings.HasPrefix(rows[i], name+" "), "row %d should be %s, got %q", i, name, rows[i]) + } + }) + } +} + +// The table shows whatever the anonymizer left in the overview, so it must +// carry the redacted names rather than the originals. +func TestPeersTable_Anonymized(t *testing.T) { + lines := tableRows(t, ConvertOptions{Anonymize: true, AnonymizeLevel: anonymize.LevelStrict}) + + for _, row := range lines[2:] { + assert.NotContains(t, row, "awesome-domain.com", "FQDN must be anonymized") + assert.NotContains(t, row, "peer-", "peer name must be anonymized") + } +} + +func TestShortName(t *testing.T) { + assert.Equal(t, "web-01", shortName("web-01.netbird.cloud")) + assert.Equal(t, "web-01", shortName("web-01")) + assert.Equal(t, "-", shortName("")) +} + +func TestTableLatency(t *testing.T) { + tests := map[time.Duration]string{ + 0: "-", + 145939625 * time.Nanosecond: "146ms", + 12 * time.Millisecond: "12ms", + 420*time.Microsecond + 700: "421µs", + } + for in, want := range tests { + assert.Equal(t, want, tableLatency(PeerStateDetailOutput{Latency: in}), "latency %s", in) + } +} From fdc51d6f9539756c38b777f091534c804eb2f9a3 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:34:44 +0100 Subject: [PATCH 205/209] [management] shift network map data store onto the read replica (#8302) --- .../network_map_db/network_map_data_test.go | 75 ++++- .../network_map/controller/controller.go | 2 +- management/internals/modules/peers/manager.go | 4 + .../internals/modules/peers/manager_test.go | 32 ++ .../service/manager/l4_port_test.go | 3 +- .../reverseproxy/service/manager/manager.go | 31 ++ .../service/manager/manager_test.go | 58 +++- .../network_map_db/factory/db_store.go | 45 ++- .../internals/network_map_db/metrics.go | 96 ++++++ .../network_map_db/network_map_data.go | 163 ++++++++-- .../network_map_db/network_map_data_test.go | 278 +++++++++++++++++- .../internals/network_map_db/shared_types.go | 17 +- management/internals/server/boot.go | 24 ++ management/internals/shared/db/open.go | 5 +- .../internals/shared/requestbuffer/buffer.go | 31 ++ .../shared/requestbuffer/buffer_test.go | 71 +++++ management/server/account.go | 17 +- management/server/account_test.go | 45 ++- management/server/peer.go | 42 ++- management/server/peer_test.go | 68 +++++ management/server/user.go | 13 +- management/server/user_test.go | 22 ++ 22 files changed, 1095 insertions(+), 47 deletions(-) create mode 100644 management/internals/network_map_db/metrics.go diff --git a/integration_tests/management/network_map_db/network_map_data_test.go b/integration_tests/management/network_map_db/network_map_data_test.go index 00c0ec03f..96172bd49 100644 --- a/integration_tests/management/network_map_db/network_map_data_test.go +++ b/integration_tests/management/network_map_db/network_map_data_test.go @@ -10,14 +10,21 @@ import ( "path/filepath" "runtime" "strings" + "sync" "testing" + "time" + "github.com/jackc/pgx/v5/pgxpool" networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/management/server/types" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" "go.uber.org/mock/gomock" ) @@ -29,6 +36,19 @@ var goldenNMap string const EnvUpdateGoldenData = "NMAP_UPDATE_GOLDEN_DATA" +var seedNetworkMapDataOnce sync.Once + +func seedNetworkMapData(t *testing.T, ctx context.Context) { + t.Helper() + seedNetworkMapDataOnce.Do(func() { + for _, query := range strings.Split(nmapData, ";") { + if err := store(t).Exec(ctx, query); err != nil { + log.Fatalf("error initializing nmap test: %s", err.Error()) + } + } + }) +} + func TestGetNetworkMapData(t *testing.T) { ctx := context.TODO() @@ -51,11 +71,7 @@ func TestGetNetworkMapData(t *testing.T) { IntegratedPeerValidator: peerValidators, } - for _, query := range strings.Split(nmapData, ";") { - if err := store(t).Exec(ctx, query); err != nil { - log.Fatalf("error initializing nmap test: %s", err.Error()) - } - } + seedNetworkMapData(t, ctx) nmap, err := storeImpl.GetNetworkMapData(ctx, "account-33") assert.NoError(t, err) @@ -72,3 +88,52 @@ func TestGetNetworkMapData(t *testing.T) { } assert.Equal(t, goldenNMap, string(serializedNMap)) } + +func TestGetNetworkMapData_FromReplica(t *testing.T) { + if engine != string(types.PostgresStoreEngine) { + t.Skip("replicas exist only for Postgres") + } + ctx := context.TODO() + + extraSettingsManager := settings.NewMockManager(gomock.NewController(t)) + extraSettingsManager.EXPECT().GetExtraSettings(gomock.Any(), gomock.Any()).Return(&types.ExtraSettings{}, nil) + peerValidators := integrated_validator.NewMockIntegratedValidator(gomock.NewController(t)) + peerValidators.EXPECT().GetValidatedPeers(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(map[string]struct{}{}, nil) + + seedNetworkMapData(t, ctx) + + reader := sdkmetric.NewManualReader() + metrics, err := networkmapdb.NewMetrics(sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test"), + map[string]*pgxpool.Pool{"replica": pgstore.Pool}) + require.NoError(t, err) + + primary := store(t) + storeImpl := networkmapdb.NetworkMapDBStoreImpl{ + Metrics: metrics, + Store: primary, + Replica: primary, + ReplicaWait: time.Second, + ExtraSettingsManager: extraSettingsManager, + IntegratedPeerValidator: peerValidators, + } + + nmap, err := storeImpl.GetNetworkMapData(ctx, "account-33") + require.NoError(t, err) + assert.NotEmpty(t, nmap.Peers, "data must be read through the replica transaction after its attempt deadline is released") + + var rm metricdata.ResourceMetrics + require.NoError(t, reader.Collect(ctx, &rm)) + var totalConns int64 + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + if gauge, ok := m.Data.(metricdata.Gauge[int64]); ok { + for _, dp := range gauge.DataPoints { + if state, _ := dp.Attributes.Value(attribute.Key("state")); state.AsString() == "total" { + totalConns += dp.Value + } + } + } + } + } + assert.Positive(t, totalConns, "the replica pool gauge reports its open connections") +} diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 08d018e5f..0554811db 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -110,7 +110,7 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App if nmdataStore != nil { interval := requestbuffer.Interval(ctx, "NB_NETWORK_MAP_DATA_BUFFER_INTERVAL", defaultNetworkMapDataBufferInterval) log.WithContext(ctx).Infof("set network map data request buffer interval to %s", interval) - c.nmdataBuffer = requestbuffer.New(ctx, "network map data request buffer", interval, c.fetchNetworkMapData) + c.nmdataBuffer = requestbuffer.NewSerialized(ctx, "network map data request buffer", interval, c.fetchNetworkMapData) } return c diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go index 6f9705efe..b49667948 100644 --- a/management/internals/modules/peers/manager.go +++ b/management/internals/modules/peers/manager.go @@ -199,6 +199,10 @@ func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs return err } + if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) + } + log.WithContext(ctx).Debugf("DeletePeers: deleted peer %s", peerID) if !(peer.ProxyMeta.Embedded || peer.Meta.KernelVersion == "wasm") { diff --git a/management/internals/modules/peers/manager_test.go b/management/internals/modules/peers/manager_test.go index 2645276b7..eba565fbf 100644 --- a/management/internals/modules/peers/manager_test.go +++ b/management/internals/modules/peers/manager_test.go @@ -5,6 +5,8 @@ import ( "fmt" "testing" + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" @@ -195,3 +197,33 @@ func TestManager_GetPeersPaginated_NoPermission_User_IsRestrictable_ViewBlocked( assert.Equal(t, 0, total) assert.NoError(t, err) } + +func TestManager_DeletePeers_IncrementsNetworkSerial(t *testing.T) { + ctrl := gomock.NewController(t) + mockStore := store.NewMockStore(ctrl) + txStore := store.NewMockStore(ctrl) + accountManager := account.NewMockManager(ctrl) + networkMapController := network_map.NewMockController(ctrl) + + m := NewManager(mockStore, permissions.NewMockManager(ctrl)).(*managerImpl) + m.accountManager = accountManager + m.networkMapController = networkMapController + + ctx := context.Background() + mockStore.EXPECT().GetAccountSettings(gomock.Any(), store.LockingStrengthNone, "test-account").Return(&types.Settings{}, nil) + networkMapController.EXPECT().GetDNSDomain(gomock.Any()).Return("netbird.selfhosted") + mockStore.EXPECT().ExecuteInTransaction(gomock.Any(), gomock.Any()).Times(2). + DoAndReturn(func(_ context.Context, fn func(store.Store) error) error { return fn(txStore) }) + + for _, id := range []string{"peer-1", "peer-2"} { + txStore.EXPECT().GetPeerByID(gomock.Any(), store.LockingStrengthNone, "test-account", id).Return(&peer.Peer{ID: id, Status: &peer.PeerStatus{}}, nil) + txStore.EXPECT().RemovePeerFromAllGroups(gomock.Any(), id).Return(nil) + txStore.EXPECT().GetPolicyRulesByResourceID(gomock.Any(), store.LockingStrengthNone, "test-account", id).Return(nil, nil) + txStore.EXPECT().DeletePeer(gomock.Any(), "test-account", id).Return(nil) + } + txStore.EXPECT().IncrementNetworkSerial(gomock.Any(), "test-account").Times(2).Return(nil) + accountManager.EXPECT().StoreEvent(gomock.Any(), "test-user", gomock.Any(), "test-account", gomock.Any(), gomock.Any()).Times(2) + accountManager.EXPECT().UpdateAccountPeers(gomock.Any(), "test-account", gomock.Any()) + + assert.NoError(t, m.DeletePeers(ctx, "test-account", []string{"peer-1", "peer-2"}, "test-user", false)) +} diff --git a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go index a44e759c4..5ec34a073 100644 --- a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go +++ b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go @@ -6,9 +6,9 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" @@ -38,6 +38,7 @@ func setupL4Test(t *testing.T, customPortsSupported *bool) (*Manager, store.Stor err = testStore.SaveAccount(ctx, &types.Account{ Id: testAccountID, CreatedBy: testUserID, + Network: types.NewNetwork(), Settings: &types.Settings{ PeerExposeEnabled: true, PeerExposeGroups: []string{testGroupID}, diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go index ebb52c74b..6173a381d 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager.go +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -363,6 +363,10 @@ func (m *Manager) persistNewService(ctx context.Context, accountID string, svc * return fmt.Errorf("create service: %w", err) } + if err := transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) + } + return nil }) } @@ -534,6 +538,10 @@ func (m *Manager) persistNewEphemeralService(ctx context.Context, accountID, pee return fmt.Errorf("create service: %w", err) } + if err := transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) + } + return nil }) } @@ -744,6 +752,10 @@ func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.St return fmt.Errorf("update service: %w", err) } + if err := transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) + } + return nil } @@ -1027,6 +1039,10 @@ func (m *Manager) DeleteService(ctx context.Context, accountID, userID, serviceI return fmt.Errorf("failed to delete service: %w", err) } + if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) + } + return nil }) if err != nil { @@ -1069,6 +1085,12 @@ func (m *Manager) DeleteAllServices(ctx context.Context, accountID, userID strin } } + if len(services) > 0 { + if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) + } + } + return nil }) if err != nil { @@ -1425,6 +1447,10 @@ func (m *Manager) deletePeerService(ctx context.Context, accountID, peerID, serv return fmt.Errorf("delete service: %w", err) } + if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) + } + return nil }) if err != nil { @@ -1477,6 +1503,11 @@ func (m *Manager) deleteExpiredPeerService(ctx context.Context, accountID, peerI if err = transaction.DeleteService(ctx, accountID, serviceID); err != nil { return fmt.Errorf("delete service: %w", err) } + + if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) + } + deleted = true return nil diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go index f9273d594..85d7c4574 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go @@ -227,6 +227,13 @@ func TestCheckDomainAvailable_EdgeCases(t *testing.T) { }) } +func networkSerial(t *testing.T, s store.Store, accountID string) uint64 { + t.Helper() + account, err := s.GetAccount(context.Background(), accountID) + require.NoError(t, err) + return account.Network.Serial +} + func TestPersistNewService(t *testing.T) { ctx := context.Background() accountID := "test-account" @@ -254,6 +261,9 @@ func TestPersistNewService(t *testing.T) { txMock.EXPECT(). CreateService(ctx, service). Return(nil) + txMock.EXPECT(). + IncrementNetworkSerial(ctx, accountID). + Return(nil) return fn(txMock) }) @@ -465,6 +475,9 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) { txMock.EXPECT(). DeleteService(ctx, accountID, serviceID). Return(nil) + txMock.EXPECT(). + IncrementNetworkSerial(ctx, accountID). + Return(nil) return fn(txMock) }) mockStore.EXPECT(). @@ -570,6 +583,9 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) { txMock.EXPECT(). DeleteService(ctx, accountID, serviceID). Return(nil) + txMock.EXPECT(). + IncrementNetworkSerial(ctx, accountID). + Return(nil) return fn(txMock) }) mockStore.EXPECT(). @@ -617,6 +633,9 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) { txMock.EXPECT(). DeleteService(ctx, accountID, serviceID). Return(nil) + txMock.EXPECT(). + IncrementNetworkSerial(ctx, accountID). + Return(nil) return fn(txMock) }) mockStore.EXPECT(). @@ -679,6 +698,7 @@ func setupIntegrationTest(t *testing.T) (*Manager, store.Store) { err = testStore.SaveAccount(ctx, &types.Account{ Id: testAccountID, CreatedBy: testUserID, + Network: types.NewNetwork(), Settings: &types.Settings{ PeerExposeEnabled: true, PeerExposeGroups: []string{testGroupID}, @@ -843,6 +863,7 @@ func TestCreateServiceFromPeer(t *testing.T) { assert.Equal(t, rpservice.SourceEphemeral, persisted.Source, "source should be ephemeral") assert.Equal(t, testPeerID, persisted.SourcePeer, "source peer should be set") assert.NotNil(t, persisted.Meta.LastRenewedAt, "last renewed should be set") + assert.Equal(t, uint64(1), networkSerial(t, testStore, testAccountID), "network serial should be incremented") }) t.Run("creates service with custom domain", func(t *testing.T) { @@ -1049,8 +1070,10 @@ func TestDeleteService_DeletesEphemeralExpose(t *testing.T) { svc, err := testStore.GetServiceByDomain(ctx, resp.Domain) require.NoError(t, err) + serial := networkSerial(t, testStore, testAccountID) err = mgr.DeleteService(ctx, testAccountID, testUserID, svc.ID) require.NoError(t, err) + assert.Equal(t, serial+1, networkSerial(t, testStore, testAccountID), "network serial should be incremented") count, err = mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) require.NoError(t, err) @@ -1065,7 +1088,7 @@ func TestDeleteService_DeletesEphemeralExpose(t *testing.T) { func TestDeleteAllServices_DeletesEphemeralExposes(t *testing.T) { ctx := context.Background() - mgr, _ := setupIntegrationTest(t) + mgr, testStore := setupIntegrationTest(t) for i := range 3 { _, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ @@ -1079,8 +1102,10 @@ func TestDeleteAllServices_DeletesEphemeralExposes(t *testing.T) { require.NoError(t, err) assert.Equal(t, int64(3), count, "all ephemeral services should exist") + serial := networkSerial(t, testStore, testAccountID) err = mgr.DeleteAllServices(ctx, testAccountID, testUserID) require.NoError(t, err) + assert.Equal(t, serial+1, networkSerial(t, testStore, testAccountID), "network serial should be incremented") count, err = mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) require.NoError(t, err) @@ -1223,8 +1248,10 @@ func TestDeleteExpiredPeerService_DeletesTargets(t *testing.T) { require.Len(t, targets, 1, "ephemeral peer-exposed service should have exactly one persisted target before reaping") expireEphemeralService(t, testStore, testAccountID, resp.Domain) + serial := networkSerial(t, testStore, testAccountID) err = mgr.deleteExpiredPeerService(ctx, testAccountID, testPeerID, svcID) require.NoError(t, err) + assert.Equal(t, serial+1, networkSerial(t, testStore, testAccountID), "network serial should be incremented") _, err = testStore.GetServiceByDomain(ctx, resp.Domain) require.Error(t, err, "expired peer-exposed service should be deleted") @@ -1253,8 +1280,10 @@ func TestDeleteServiceFromPeer_DeletesTargets(t *testing.T) { require.NoError(t, err) require.Len(t, targets, 1, "ephemeral peer-exposed service should have exactly one persisted target before stopping") + serial := networkSerial(t, testStore, testAccountID) err = mgr.StopServiceFromPeer(ctx, testAccountID, testPeerID, svcID) require.NoError(t, err) + assert.Equal(t, serial+1, networkSerial(t, testStore, testAccountID), "network serial should be incremented") _, err = testStore.GetServiceByDomain(ctx, resp.Domain) require.Error(t, err, "stopped peer-exposed service should be deleted") @@ -1521,3 +1550,30 @@ func TestReloadAllServicesForAccount_ContinuesAfterFailedService(t *testing.T) { assert.Contains(t, err.Error(), "svc-broken") assert.NotContains(t, err.Error(), "svc-healthy") } + +func TestServiceWrites_IncrementNetworkSerial(t *testing.T) { + ctx := context.Background() + mgr, testStore, _ := setupL4Test(t, nil) + + created, err := mgr.CreateService(ctx, testAccountID, testUserID, &rpservice.Service{ + Name: "serial", Domain: "serial.test.netbird.io", Mode: rpservice.ModeHTTP, Enabled: true, + Targets: []*rpservice.Target{{ + AccountID: testAccountID, TargetId: testPeerID, + TargetType: rpservice.TargetTypePeer, Protocol: "http", Port: 8080, Enabled: true, + }}, + }) + require.NoError(t, err) + serial := networkSerial(t, testStore, testAccountID) + assert.Equal(t, uint64(1), serial, "create should increment the network serial") + + updated := created.Copy() + updated.Name = "serial renamed" + _, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated) + require.NoError(t, err) + assert.Equal(t, serial+1, networkSerial(t, testStore, testAccountID), "update should increment the network serial") + serial++ + + require.NoError(t, mgr.SetStatus(ctx, testAccountID, created.ID, rpservice.StatusActive)) + require.NoError(t, mgr.SetCertificateIssuedAt(ctx, testAccountID, created.ID)) + assert.Equal(t, serial, networkSerial(t, testStore, testAccountID), "meta-only writes must not increment the network serial") +} diff --git a/management/internals/network_map_db/factory/db_store.go b/management/internals/network_map_db/factory/db_store.go index 3eea0ae69..e60c1f6f9 100644 --- a/management/internals/network_map_db/factory/db_store.go +++ b/management/internals/network_map_db/factory/db_store.go @@ -5,10 +5,15 @@ import ( "errors" "fmt" "os" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "go.opentelemetry.io/otel/metric" networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql" networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite" + "github.com/netbirdio/netbird/management/internals/shared/requestbuffer" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/management/server/store" @@ -16,7 +21,16 @@ import ( log "github.com/sirupsen/logrus" ) -const storeSqliteFileName = "store.db" +const ( + storeSqliteFileName = "store.db" + + // ReplicaDsnEnv names the Postgres DSN of a read replica serving network map data reads. + ReplicaDsnEnv = "NB_STORE_ENGINE_POSTGRES_REPLICA_DSN" + // replicaWaitEnv bounds how long a network map data read waits for the replica. + replicaWaitEnv = "NB_NETWORK_MAP_DATA_REPLICA_WAIT" + + defaultReplicaWait = 2 * time.Second +) var ErrNotSupportedStoreEngine = errors.New("unsupported store engine") @@ -25,7 +39,9 @@ func NewNetworkMapDBStore( kind types.Engine, dataDir string, integratedPeerValidator integrated_validator.IntegratedValidator, - extraSettingsManager settings.Manager) (*networkmapdb.NetworkMapDBStoreImpl, error) { + extraSettingsManager settings.Manager, + replicaPool *pgxpool.Pool, + meter metric.Meter) (*networkmapdb.NetworkMapDBStoreImpl, error) { switch kind { case types.SqliteStoreEngine: log.WithContext(ctx).Info("networkmap store is using SQLite") @@ -54,11 +70,32 @@ func NewNetworkMapDBStore( return nil, err } - return &networkmapdb.NetworkMapDBStoreImpl{ + impl := &networkmapdb.NetworkMapDBStoreImpl{ Store: store, IntegratedPeerValidator: integratedPeerValidator, ExtraSettingsManager: extraSettingsManager, - }, nil + } + + pools := map[string]*pgxpool.Pool{"primary": store.Pool} + if replicaPool != nil { + impl.Replica = &networkmap_pgsql.PgStore{Pool: replicaPool} + impl.ReplicaWait = requestbuffer.Interval(ctx, replicaWaitEnv, defaultReplicaWait) + pools["replica"] = replicaPool + replicaConfig := replicaPool.Config().ConnConfig + log.WithContext(ctx).Infof("networkmap store reads from Postgres replica %s:%d/%s, waiting up to %s for replication", + replicaConfig.Host, replicaConfig.Port, replicaConfig.Database, impl.ReplicaWait) + } else { + log.WithContext(ctx).Info("networkmap store reads from the primary, no replica configured") + } + + if meter != nil { + impl.Metrics, err = networkmapdb.NewMetrics(meter, pools) + if err != nil { + return nil, fmt.Errorf("create network map data metrics: %w", err) + } + } + + return impl, nil } return nil, fmt.Errorf("networkmap store doesn't support engine %s, %w", kind, ErrNotSupportedStoreEngine) diff --git a/management/internals/network_map_db/metrics.go b/management/internals/network_map_db/metrics.go new file mode 100644 index 000000000..0cfd8f92d --- /dev/null +++ b/management/internals/network_map_db/metrics.go @@ -0,0 +1,96 @@ +package networkmapdb + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +const ( + readSourceReplica = "replica" + readSourcePrimary = "primary" + + readReasonNoReplica = "no_replica" + readReasonCaughtUp = "caught_up" + readReasonLagTimeout = "lag_timeout" + readReasonReplicaError = "replica_error" + readReasonSerialReadError = "serial_read_error" +) + +// Metrics records which database serves network map data reads, how long reads +// wait for the replica and the state of the connection pools. +type Metrics struct { + reads metric.Int64Counter + replicaWait metric.Int64Histogram +} + +// NewMetrics creates the network map data metrics on meter and observes the +// connection pools keyed by pool name. +func NewMetrics(meter metric.Meter, pools map[string]*pgxpool.Pool) (*Metrics, error) { + reads, err := meter.Int64Counter("management.nmdata.reads", + metric.WithUnit("1"), + metric.WithDescription("Network map data reads by the database that served them and the reason it was chosen"), + ) + if err != nil { + return nil, fmt.Errorf("create reads counter: %w", err) + } + + replicaWait, err := meter.Int64Histogram("management.nmdata.replica.wait.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("Time a network map data read waited for the replica to replicate the primary's network serial"), + ) + if err != nil { + return nil, fmt.Errorf("create replica wait histogram: %w", err) + } + + connections, err := meter.Int64ObservableGauge("management.nmdata.pool.connections", + metric.WithUnit("1"), + metric.WithDescription("Connections of the network map data connection pools by state"), + ) + if err != nil { + return nil, fmt.Errorf("create pool connections gauge: %w", err) + } + + _, err = meter.RegisterCallback(func(_ context.Context, observer metric.Observer) error { + for name, pool := range pools { + stat := pool.Stat() + observePool(observer, connections, name, "acquired", stat.AcquiredConns()) + observePool(observer, connections, name, "idle", stat.IdleConns()) + observePool(observer, connections, name, "total", stat.TotalConns()) + } + return nil + }, connections) + if err != nil { + return nil, fmt.Errorf("create pool connections callback: %w", err) + } + + return &Metrics{reads: reads, replicaWait: replicaWait}, nil +} + +func observePool(observer metric.Observer, gauge metric.Int64ObservableGauge, pool, state string, value int32) { + observer.ObserveInt64(gauge, int64(value), metric.WithAttributes( + attribute.String("pool", pool), + attribute.String("state", state), + )) +} + +func (m *Metrics) countRead(ctx context.Context, source, reason string) { + if m == nil { + return + } + m.reads.Add(ctx, 1, metric.WithAttributes( + attribute.String("source", source), + attribute.String("reason", reason), + )) +} + +func (m *Metrics) recordReplicaWait(ctx context.Context, wait time.Duration) { + if m == nil { + return + } + m.replicaWait.Record(ctx, wait.Milliseconds()) +} diff --git a/management/internals/network_map_db/network_map_data.go b/management/internals/network_map_db/network_map_data.go index 1e83bfb34..2c1e7abcc 100644 --- a/management/internals/network_map_db/network_map_data.go +++ b/management/internals/network_map_db/network_map_data.go @@ -2,7 +2,9 @@ package networkmapdb import ( "context" + "errors" "fmt" + "time" log "github.com/sirupsen/logrus" "golang.org/x/exp/maps" @@ -11,12 +13,147 @@ import ( "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" ) +// GetNetworkMapData reads the network map data of the account. With a Replica +// configured it reads from the replica once that has replicated the account's +// current network serial, and from Store when it does not catch up within +// ReplicaWait or the replica read fails. func (s *NetworkMapDBStoreImpl) GetNetworkMapData(ctx context.Context, accountId string) (*networkmap.NetworkMapData, error) { - tx, err := s.Store.BeginTx(ctx) + toret, err := s.readNetworkMapData(ctx, accountId) if err != nil { return nil, err } + extraSettings, err := s.ExtraSettingsManager.GetExtraSettings(ctx, accountId) + if err != nil { + return nil, err + } + + toret.ValidatedPeers, err = s.IntegratedPeerValidator.GetValidatedPeers(ctx, accountId, maps.Values(toret.Groups), maps.Values(toret.Peers), extraSettings) + if err != nil { + return nil, err + } + + return toret, nil +} + +func (s *NetworkMapDBStoreImpl) readNetworkMapData(ctx context.Context, accountId string) (*networkmap.NetworkMapData, error) { + reason := readReasonNoReplica + if s.Replica != nil { + toret, err := s.readFromReplica(ctx, accountId) + if err == nil { + s.Metrics.countRead(ctx, readSourceReplica, readReasonCaughtUp) + return toret, nil + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + reason = fallbackReason(err) + if reason == readReasonLagTimeout { + log.WithContext(ctx).Debugf("reading network map data of account %s from primary: %v", accountId, err) + } else { + log.WithContext(ctx).Warnf("reading network map data of account %s from primary: %v", accountId, err) + } + } + + tx, network, err := beginRead(ctx, s.Store, accountId) + if err != nil { + return nil, err + } + toret, err := readFromTx(ctx, tx, accountId, network) + if err != nil { + return nil, err + } + s.Metrics.countRead(ctx, readSourcePrimary, reason) + return toret, nil +} + +func fallbackReason(err error) string { + switch { + case errors.Is(err, errReplicaLagging): + return readReasonLagTimeout + case errors.Is(err, errPrimarySerialRead): + return readReasonSerialReadError + default: + return readReasonReplicaError + } +} + +func (s *NetworkMapDBStoreImpl) readFromReplica(ctx context.Context, accountId string) (*networkmap.NetworkMapData, error) { + tx, network, err := s.beginOnReplica(ctx, accountId) + if err != nil { + return nil, err + } + return readFromTx(ctx, tx, accountId, network) +} + +// beginOnReplica opens a read transaction on the replica whose snapshot holds at +// least the account's network serial on the primary at the time of the call. +func (s *NetworkMapDBStoreImpl) beginOnReplica(ctx context.Context, accountId string) (NetworkMapDBStoreConn, nmdata.Network, error) { + primaryTx, primaryNetwork, err := beginRead(ctx, s.Store, accountId) + if err != nil { + return nil, nmdata.Network{}, fmt.Errorf("%w: %w", errPrimarySerialRead, err) + } + rollbackTx(ctx, primaryTx) + + start := time.Now() + deadline := start.Add(s.ReplicaWait) + + for { + tx, network, err := beginReadBefore(ctx, s.Replica, accountId, deadline) + if err != nil { + if ctx.Err() == nil && errors.Is(err, context.DeadlineExceeded) { + s.Metrics.recordReplicaWait(ctx, time.Since(start)) + return nil, nmdata.Network{}, fmt.Errorf("%w: no answer within %s: %w", errReplicaLagging, s.ReplicaWait, err) + } + return nil, nmdata.Network{}, err + } + if network.Serial >= primaryNetwork.Serial { + s.Metrics.recordReplicaWait(ctx, time.Since(start)) + return tx, network, nil + } + rollbackTx(ctx, tx) + + remaining := time.Until(deadline) + if remaining <= 0 { + s.Metrics.recordReplicaWait(ctx, time.Since(start)) + return nil, nmdata.Network{}, fmt.Errorf("%w: at network serial %d after %s, primary at %d", errReplicaLagging, network.Serial, s.ReplicaWait, primaryNetwork.Serial) + } + + select { + case <-ctx.Done(): + return nil, nmdata.Network{}, ctx.Err() + case <-time.After(min(replicaPollInterval, remaining)): + } + } +} + +// beginReadBefore runs beginRead with its connect, begin and network read bounded +// by deadline, so a stalled replica cannot hold the read past it. +func beginReadBefore(ctx context.Context, store NetworkMapDBStore, accountId string, deadline time.Time) (NetworkMapDBStoreConn, nmdata.Network, error) { + attemptCtx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + return beginRead(attemptCtx, store, accountId) +} + +// beginRead opens a read transaction and reads the account's network as its +// first statement, so the network serial belongs to the transaction's snapshot. +func beginRead(ctx context.Context, store NetworkMapDBStore, accountId string) (NetworkMapDBStoreConn, nmdata.Network, error) { + tx, err := store.BeginTx(ctx) + if err != nil { + return nil, nmdata.Network{}, err + } + + network, err := tx.GetNetwork(ctx, accountId) + if err != nil { + rollbackTx(ctx, tx) + return nil, nmdata.Network{}, fmt.Errorf("get network: %w", err) + } + + return tx, network, nil +} + +// readFromTx reads the remaining network map data in tx and ends it. +func readFromTx(ctx context.Context, tx NetworkMapDBStoreConn, accountId string, network nmdata.Network) (*networkmap.NetworkMapData, error) { acctSettings, err := tx.GetAccountSettings(ctx, accountId) if err != nil { return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get account settings: %w", err)) @@ -41,10 +178,6 @@ func (s *NetworkMapDBStoreImpl) GetNetworkMapData(ctx context.Context, accountId if err != nil { return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network routers: %w", err)) } - network, err := tx.GetNetwork(ctx, accountId) - if err != nil { - return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get network: %w", err)) - } peers, _, err := tx.GetPeers(ctx, accountId) if err != nil { return rollbackAndReturnError(ctx, tx, fmt.Errorf("failed to get peers: %w", err)) @@ -111,26 +244,20 @@ func (s *NetworkMapDBStoreImpl) GetNetworkMapData(ctx context.Context, accountId ProxyTargetedDomainResourceIDs: proxyTargetedDomainResourceIDs, } - extraSettings, err := s.ExtraSettingsManager.GetExtraSettings(ctx, accountId) - if err != nil { - return nil, err - } - - toret.ValidatedPeers, err = s.IntegratedPeerValidator.GetValidatedPeers(ctx, accountId, maps.Values(toret.Groups), maps.Values(toret.Peers), extraSettings) - if err != nil { - return nil, err - } - return &toret, nil } func rollbackAndReturnError(ctx context.Context, tx NetworkMapDBStoreConn, err error) (*networkmap.NetworkMapData, error) { - if errr := tx.RollbackTx(ctx); errr != nil { - log.WithContext(ctx).Warnf("failed to rollback network map read transaction: %v", errr) - } + rollbackTx(ctx, tx) return nil, err } +func rollbackTx(ctx context.Context, tx NetworkMapDBStoreConn) { + if err := tx.RollbackTx(ctx); err != nil { + log.WithContext(ctx).Warnf("failed to rollback network map read transaction: %v", err) + } +} + func toMap[T any](all []T, id func(t T) string) map[string]*T { toret := make(map[string]*T, len(all)) for _, t := range all { diff --git a/management/internals/network_map_db/network_map_data_test.go b/management/internals/network_map_db/network_map_data_test.go index 2b694f5bc..d2d24ecc6 100644 --- a/management/internals/network_map_db/network_map_data_test.go +++ b/management/internals/network_map_db/network_map_data_test.go @@ -1,10 +1,21 @@ package networkmapdb import ( + "context" + "errors" + "sync/atomic" "testing" + "testing/synctest" + "time" - "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" ) // disabled network resource shouldn't be in the resulting map @@ -156,3 +167,268 @@ func TestBuildResourcePolicies_ViaPolicyToDestinationGroupIdx(t *testing.T) { }, }, resourceToPolicies) } + +type fakeNmdataStore struct { + name string + serial atomic.Int64 + readErr error + stall bool + begins atomic.Int32 + finishes atomic.Int32 +} + +func (f *fakeNmdataStore) BeginTx(ctx context.Context) (NetworkMapDBStoreConn, error) { + if f.stall { + <-ctx.Done() + return nil, ctx.Err() + } + f.begins.Add(1) + return &fakeNmdataConn{store: f}, nil +} + +func (f *fakeNmdataStore) Exec(context.Context, string, ...any) error { return nil } + +type fakeNmdataConn struct { + store *fakeNmdataStore +} + +func (c *fakeNmdataConn) GetNetwork(context.Context, string) (nmdata.Network, error) { + return nmdata.Network{Identifier: c.store.name, Serial: c.store.serial.Load()}, nil +} + +func (c *fakeNmdataConn) GetPeers(context.Context, string) ([]nmdata.Peer, map[string][]*nmdata.Peer, error) { + return nil, nil, c.store.readErr +} + +func (c *fakeNmdataConn) GetGroups(context.Context, string) ([]nmdata.Group, map[string]map[string]any, error) { + return nil, nil, nil +} +func (c *fakeNmdataConn) GetDomains(context.Context, string) ([]Domain, error) { return nil, nil } +func (c *fakeNmdataConn) GetPolicies(context.Context, string) ([]nmdata.Policy, map[string]map[string]any, map[string]map[string]any, error) { + return nil, nil, nil, nil +} +func (c *fakeNmdataConn) GetRoutes(context.Context, string) ([]nmdata.Route, error) { return nil, nil } +func (c *fakeNmdataConn) GetNameServerGroups(context.Context, string) ([]nmdata.NameServerGroup, error) { + return nil, nil +} +func (c *fakeNmdataConn) GetNetworkResources(context.Context, string) ([]nmdata.NetworkResource, error) { + return nil, nil +} +func (c *fakeNmdataConn) GetNetworkRouters(context.Context, string) (map[string]map[string]*nmdata.NetworkRouter, error) { + return map[string]map[string]*nmdata.NetworkRouter{}, nil +} +func (c *fakeNmdataConn) GetAppliedZoneCandidates(context.Context, string) ([]networkmap.AppliedZoneCandidate, error) { + return nil, nil +} +func (c *fakeNmdataConn) GetAccountSettings(context.Context, string) (nmdata.AccountSettingsInfo, error) { + return nmdata.AccountSettingsInfo{}, nil +} +func (c *fakeNmdataConn) GetPostureChecks(context.Context, string) ([]nmdata.PostureChecks, map[string]string, error) { + return nil, nil, nil +} +func (c *fakeNmdataConn) GetAllowedUsers(context.Context, string) (map[string]struct{}, map[string][]string, error) { + return nil, nil, nil +} +func (c *fakeNmdataConn) GetDnsSettings(context.Context, string) (nmdata.DNSSettings, error) { + return nmdata.DNSSettings{}, nil +} +func (c *fakeNmdataConn) GetNetworkXIDToPublicIdMap(context.Context, string) (map[string]string, error) { + return map[string]string{}, nil +} +func (c *fakeNmdataConn) GetPrivateServices(context.Context, string) ([]Service, error) { + return nil, nil +} +func (c *fakeNmdataConn) GetProxyTargetedDomainResourceIDs(context.Context, string) (map[string]struct{}, error) { + return map[string]struct{}{}, nil +} + +func (c *fakeNmdataConn) CommitTx(context.Context) error { + c.store.finishes.Add(1) + return nil +} + +func (c *fakeNmdataConn) RollbackTx(context.Context) error { + c.store.finishes.Add(1) + return nil +} + +func newReplicaTestStores(primarySerial, replicaSerial int64) (*NetworkMapDBStoreImpl, *fakeNmdataStore, *fakeNmdataStore) { + primary := &fakeNmdataStore{name: "primary"} + primary.serial.Store(primarySerial) + replica := &fakeNmdataStore{name: "replica"} + replica.serial.Store(replicaSerial) + return &NetworkMapDBStoreImpl{Store: primary, Replica: replica, ReplicaWait: time.Second}, primary, replica +} + +func TestReadNetworkMapData_ReplicaCaughtUp(t *testing.T) { + s, primary, replica := newReplicaTestStores(5, 5) + + data, err := s.readNetworkMapData(context.Background(), "account") + require.NoError(t, err) + + assert.Equal(t, "replica", data.Network.Identifier) + assert.Equal(t, int32(1), replica.begins.Load()) + assert.Equal(t, primary.begins.Load(), primary.finishes.Load()) + assert.Equal(t, replica.begins.Load(), replica.finishes.Load()) +} + +func TestReadNetworkMapData_WaitsForReplica(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + s, _, replica := newReplicaTestStores(5, 4) + + time.AfterFunc(100*time.Millisecond, func() { replica.serial.Store(5) }) + + start := time.Now() + data, err := s.readNetworkMapData(context.Background(), "account") + require.NoError(t, err) + + assert.Equal(t, "replica", data.Network.Identifier) + assert.Equal(t, int64(5), data.Network.Serial) + assert.GreaterOrEqual(t, time.Since(start), 100*time.Millisecond) + assert.Equal(t, replica.begins.Load(), replica.finishes.Load()) + }) +} + +func TestReadNetworkMapData_FallsBackToPrimaryWhenReplicaLags(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + s, primary, replica := newReplicaTestStores(5, 4) + + start := time.Now() + data, err := s.readNetworkMapData(context.Background(), "account") + require.NoError(t, err) + + assert.Equal(t, "primary", data.Network.Identifier) + assert.GreaterOrEqual(t, time.Since(start), s.ReplicaWait) + assert.Equal(t, primary.begins.Load(), primary.finishes.Load()) + assert.Equal(t, replica.begins.Load(), replica.finishes.Load()) + }) +} + +func TestReadNetworkMapData_FallsBackToPrimaryOnReplicaReadError(t *testing.T) { + s, primary, replica := newReplicaTestStores(5, 5) + replica.readErr = errors.New("canceling statement due to conflict with recovery") + + data, err := s.readNetworkMapData(context.Background(), "account") + require.NoError(t, err) + + assert.Equal(t, "primary", data.Network.Identifier) + assert.Equal(t, primary.begins.Load(), primary.finishes.Load()) + assert.Equal(t, replica.begins.Load(), replica.finishes.Load()) +} + +func TestReadNetworkMapData_WithoutReplicaReadsPrimaryOnce(t *testing.T) { + primary := &fakeNmdataStore{name: "primary"} + s := &NetworkMapDBStoreImpl{Store: primary} + + data, err := s.readNetworkMapData(context.Background(), "account") + require.NoError(t, err) + + assert.Equal(t, "primary", data.Network.Identifier) + assert.Equal(t, int32(1), primary.begins.Load()) + assert.Equal(t, int32(1), primary.finishes.Load()) +} + +func TestReadNetworkMapData_CancelledWhileWaitingForReplica(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + s, primary, replica := newReplicaTestStores(5, 4) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, err := s.readNetworkMapData(ctx, "account") + require.ErrorIs(t, err, context.DeadlineExceeded) + + assert.Equal(t, int32(1), primary.begins.Load(), "primary should only be read for the serial") + assert.Equal(t, primary.begins.Load(), primary.finishes.Load()) + assert.Equal(t, replica.begins.Load(), replica.finishes.Load()) + }) +} + +func TestReadNetworkMapData_FallsBackToPrimaryWhenReplicaStalls(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + reader := sdkmetric.NewManualReader() + metrics, err := NewMetrics(sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test"), nil) + require.NoError(t, err) + + s, primary, replica := newReplicaTestStores(5, 5) + s.Metrics = metrics + replica.stall = true + + start := time.Now() + data, err := s.readNetworkMapData(context.Background(), "account") + require.NoError(t, err) + + assert.Equal(t, "primary", data.Network.Identifier) + assert.Equal(t, s.ReplicaWait, time.Since(start), "a stalled replica must be abandoned at the wait deadline") + assert.Equal(t, primary.begins.Load(), primary.finishes.Load()) + + reads, waits := collectReadMetrics(t, reader) + assert.Equal(t, map[[2]string]int64{{readSourcePrimary, readReasonLagTimeout}: 1}, reads) + assert.Equal(t, uint64(1), waits) + }) +} + +func TestReadNetworkMapData_CountsReadsBySourceAndReason(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + reader := sdkmetric.NewManualReader() + metrics, err := NewMetrics(sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test"), nil) + require.NoError(t, err) + + s, _, replica := newReplicaTestStores(5, 5) + s.Metrics = metrics + + _, err = s.readNetworkMapData(context.Background(), "account") + require.NoError(t, err) + + replica.serial.Store(4) + _, err = s.readNetworkMapData(context.Background(), "account") + require.NoError(t, err) + + replica.serial.Store(5) + replica.readErr = errors.New("canceling statement due to conflict with recovery") + _, err = s.readNetworkMapData(context.Background(), "account") + require.NoError(t, err) + + s.Replica = nil + _, err = s.readNetworkMapData(context.Background(), "account") + require.NoError(t, err) + + reads, waits := collectReadMetrics(t, reader) + + assert.Equal(t, map[[2]string]int64{ + {readSourceReplica, readReasonCaughtUp}: 1, + {readSourcePrimary, readReasonLagTimeout}: 1, + {readSourcePrimary, readReasonReplicaError}: 1, + {readSourcePrimary, readReasonNoReplica}: 1, + }, reads) + assert.Equal(t, uint64(3), waits, "every replica attempt that reached the serial check records its wait") + }) +} + +// collectReadMetrics returns the read counts keyed by source and reason and the +// number of recorded replica waits. +func collectReadMetrics(t *testing.T, reader *sdkmetric.ManualReader) (map[[2]string]int64, uint64) { + t.Helper() + var rm metricdata.ResourceMetrics + require.NoError(t, reader.Collect(context.Background(), &rm)) + + reads := map[[2]string]int64{} + var waits uint64 + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + switch data := m.Data.(type) { + case metricdata.Sum[int64]: + for _, dp := range data.DataPoints { + source, _ := dp.Attributes.Value(attribute.Key("source")) + reason, _ := dp.Attributes.Value(attribute.Key("reason")) + reads[[2]string{source.AsString(), reason.AsString()}] = dp.Value + } + case metricdata.Histogram[int64]: + for _, dp := range data.DataPoints { + waits += dp.Count + } + } + } + } + return reads, waits +} diff --git a/management/internals/network_map_db/shared_types.go b/management/internals/network_map_db/shared_types.go index dc065073c..ca92d4eea 100644 --- a/management/internals/network_map_db/shared_types.go +++ b/management/internals/network_map_db/shared_types.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "reflect" + "time" "github.com/miekg/dns" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" @@ -17,6 +18,13 @@ import ( var ErrDnsUnsupportedRecordType = errors.New("unsupported record type") +const replicaPollInterval = 10 * time.Millisecond + +var ( + errReplicaLagging = errors.New("replica lagging") + errPrimarySerialRead = errors.New("read primary network serial") +) + type NetworkMapDBStore interface { //nolint:revive // established name across the codebase BeginTx(ctx context.Context) (NetworkMapDBStoreConn, error) Exec(ctx context.Context, query string, args ...any) error @@ -46,7 +54,14 @@ type NetworkMapDBStoreConn interface { //nolint:revive // established name acros } type NetworkMapDBStoreImpl struct { //nolint:revive // established name across the codebase - Store NetworkMapDBStore + Store NetworkMapDBStore + // Replica is an optional read replica of Store that serves reads once it has + // replicated the account's current network serial. + Replica NetworkMapDBStore + // ReplicaWait bounds how long a read waits for Replica before it falls back to Store. + ReplicaWait time.Duration + // Metrics records how reads are served. It is optional. + Metrics *Metrics IntegratedPeerValidator integrated_validator.IntegratedValidator ExtraSettingsManager settings.Manager } diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 28f6294d6..64d5ee4bc 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -8,12 +8,14 @@ import ( "errors" "net/http" "net/netip" + "os" "slices" "time" "github.com/gorilla/mux" grpcMiddleware "github.com/grpc-ecosystem/go-grpc-middleware/v2" "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip" + "github.com/jackc/pgx/v5/pgxpool" "github.com/rs/cors" "github.com/rs/xid" log "github.com/sirupsen/logrus" @@ -44,6 +46,7 @@ import ( "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/management/server/types" mgmtProto "github.com/netbirdio/netbird/shared/management/proto" "github.com/netbirdio/netbird/shared/ratelimit" "github.com/netbirdio/netbird/util/crypt" @@ -130,6 +133,8 @@ func (s *BaseServer) NetworkMapStore() *networkmapdb.NetworkMapDBStoreImpl { s.Config.Datadir, s.IntegratedValidator(), s.SettingsManager(), + s.NetworkMapReplicaPool(), + s.Metrics().GetMeter(), ) // networkmap db store supports postgres and sqlite backends only // for other backends a fallback is used, so NotSupportedStoreEngineError @@ -141,6 +146,25 @@ func (s *BaseServer) NetworkMapStore() *networkmapdb.NetworkMapDBStoreImpl { }) } +// NetworkMapReplicaPool opens the pgx pool to the Postgres read replica serving +// network map data reads. It is nil unless the store engine is Postgres and +// networkmapdbfactory.ReplicaDsnEnv is set. +func (s *BaseServer) NetworkMapReplicaPool() *pgxpool.Pool { + return CreateNamed(s, "NetworkMapReplica", func() *pgxpool.Pool { + dsn := os.Getenv(networkmapdbfactory.ReplicaDsnEnv) + if dsn == "" || s.Config.StoreConfig.Engine != types.PostgresStoreEngine { + return nil + } + pool, err := db.OpenPgxPool(context.Background(), dsn, db.DefaultPoolConfig) + if err != nil { + log.Errorf("network map data reads use the primary, connecting to the replica failed: %v", err) + return nil + } + s.OnStop(pool.Close) + return pool + }) +} + func (s *BaseServer) EventStore() activity.Store { return Create(s, func() activity.Store { var err error diff --git a/management/internals/shared/db/open.go b/management/internals/shared/db/open.go index 3c3299bb9..f813b0ce8 100644 --- a/management/internals/shared/db/open.go +++ b/management/internals/shared/db/open.go @@ -106,7 +106,7 @@ func OpenPostgres(ctx context.Context, dsn string, pool PoolConfig, opts ...Opti if err != nil { return nil, err } - pgxPool, err := newPgxPool(ctx, dsn, pool) + pgxPool, err := OpenPgxPool(ctx, dsn, pool) if err != nil { closeGorm(gormDB) return nil, err @@ -135,7 +135,8 @@ func OpenPostgresWithoutPool(ctx context.Context, dsn string, opts ...Option) (* return conn, nil } -func newPgxPool(ctx context.Context, dsn string, cfg PoolConfig) (*pgxpool.Pool, error) { +// OpenPgxPool opens a pgx pool to dsn sized by cfg and verifies it with a ping. +func OpenPgxPool(ctx context.Context, dsn string, cfg PoolConfig) (*pgxpool.Pool, error) { config, err := pgxpool.ParseConfig(dsn) if err != nil { return nil, fmt.Errorf("unable to parse database config: %w", err) diff --git a/management/internals/shared/requestbuffer/buffer.go b/management/internals/shared/requestbuffer/buffer.go index c3823776c..22f9d1952 100644 --- a/management/internals/shared/requestbuffer/buffer.go +++ b/management/internals/shared/requestbuffer/buffer.go @@ -26,6 +26,9 @@ type Buffer[T any] struct { mu sync.Mutex waiting map[string][]chan result[T] + // inFlight holds the keys with a running fetch and whether their next flush + // is due once it ends. It is nil unless fetches are serialized. + inFlight map[string]bool } type result[T any] struct { @@ -45,6 +48,15 @@ func New[T any](ctx context.Context, name string, interval time.Duration, fetch } } +// NewSerialized returns a Buffer like New that runs at most one fetch per key at +// a time. Requests arriving during a fetch form the next batch, which is fetched +// once both its window closed and the running fetch ended. +func NewSerialized[T any](ctx context.Context, name string, interval time.Duration, fetch FetchFunc[T]) *Buffer[T] { + b := New(ctx, name, interval, fetch) + b.inFlight = make(map[string]bool) + return b +} + // Get returns the value for key, sharing one fetch with the other callers of // the current batch. The value is shared as is, so callers must treat it as // read-only unless the fetch hands out copies. @@ -71,8 +83,16 @@ func (b *Buffer[T]) Get(ctx context.Context, key string) (T, error) { func (b *Buffer[T]) flush(key string) { b.mu.Lock() + if _, running := b.inFlight[key]; running { + b.inFlight[key] = true + b.mu.Unlock() + return + } waiting := b.waiting[key] delete(b.waiting, key) + if b.inFlight != nil && len(waiting) > 0 { + b.inFlight[key] = false + } b.mu.Unlock() if len(waiting) == 0 { @@ -86,6 +106,17 @@ func (b *Buffer[T]) flush(key string) { for _, ch := range waiting { ch <- result[T]{value: value, err: err} } + + if b.inFlight == nil { + return + } + b.mu.Lock() + due := b.inFlight[key] + delete(b.inFlight, key) + b.mu.Unlock() + if due { + go b.flush(key) + } } // Interval reads a buffer interval from envVar, falling back to def. diff --git a/management/internals/shared/requestbuffer/buffer_test.go b/management/internals/shared/requestbuffer/buffer_test.go index 9e356145e..3bfb73de4 100644 --- a/management/internals/shared/requestbuffer/buffer_test.go +++ b/management/internals/shared/requestbuffer/buffer_test.go @@ -6,6 +6,7 @@ import ( "sync" "sync/atomic" "testing" + "testing/synctest" "time" "github.com/stretchr/testify/assert" @@ -104,3 +105,73 @@ func TestBufferHonorsCallerContext(t *testing.T) { _, err := buffer.Get(ctx, "account") assert.ErrorIs(t, err, context.DeadlineExceeded) } + +func TestSerializedBufferQueuesRequestsDuringFetch(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + var fetches, running, maxRunning atomic.Int32 + buffer := NewSerialized(context.Background(), "test", 10*time.Millisecond, + func(ctx context.Context, key string) (int32, error) { + n := running.Add(1) + defer running.Add(-1) + if n > maxRunning.Load() { + maxRunning.Store(n) + } + time.Sleep(time.Second) + return fetches.Add(1), nil + }) + + var wg sync.WaitGroup + get := func(want int32) { + wg.Add(1) + go func() { + defer wg.Done() + value, err := buffer.Get(context.Background(), "account") + assert.NoError(t, err) + assert.Equal(t, want, value) + }() + } + + get(1) + time.Sleep(100 * time.Millisecond) + synctest.Wait() + require.Equal(t, int32(1), running.Load(), "first fetch should be running") + + for range 5 { + get(2) + } + wg.Wait() + + assert.Equal(t, int32(2), fetches.Load(), "requests queued during the fetch should share one fetch") + assert.Equal(t, int32(1), maxRunning.Load(), "fetches of a key should not overlap") + }) +} + +func TestUnserializedBufferOverlapsFetches(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + var running, maxRunning atomic.Int32 + buffer := New(context.Background(), "test", 10*time.Millisecond, + func(ctx context.Context, key string) (string, error) { + n := running.Add(1) + defer running.Add(-1) + if n > maxRunning.Load() { + maxRunning.Store(n) + } + time.Sleep(time.Second) + return key, nil + }) + + var wg sync.WaitGroup + wg.Go(func() { + _, err := buffer.Get(context.Background(), "account") + assert.NoError(t, err) + }) + time.Sleep(100 * time.Millisecond) + wg.Go(func() { + _, err := buffer.Get(context.Background(), "account") + assert.NoError(t, err) + }) + wg.Wait() + + assert.Equal(t, int32(2), maxRunning.Load()) + }) +} diff --git a/management/server/account.go b/management/server/account.go index ddc8d4bbc..21b9fa431 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -1825,10 +1825,10 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth return fmt.Errorf("reconcile IPv6 for group changes: %w", err) } change.ChangedPeerIDs = append(change.ChangedPeerIDs, ipv6Changed...) + } - if err = transaction.IncrementNetworkSerial(ctx, userAuth.AccountId); err != nil { - return fmt.Errorf("error incrementing network serial: %w", err) - } + if err = transaction.IncrementNetworkSerial(ctx, userAuth.AccountId); err != nil { + return fmt.Errorf("error incrementing network serial: %w", err) } if snap, err = affectedpeers.Load(ctx, transaction, userAuth.AccountId, change); err != nil { @@ -2844,6 +2844,10 @@ func (am *DefaultAccountManager) savePeerIPUpdate(ctx context.Context, transacti return fmt.Errorf("save peer: %w", err) } + if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) + } + eventMeta["old_ip"] = oldIP eventMeta["ip"] = newIP.String() am.StoreEvent(ctx, userID, peer.ID, accountID, activity.PeerIPUpdated, eventMeta) @@ -2884,7 +2888,8 @@ func (am *DefaultAccountManager) UpdatePeerIPv6(ctx context.Context, accountID, // updatePeerIPv6InTransaction validates and applies an IPv6 address change within a store transaction. func (am *DefaultAccountManager) updatePeerIPv6InTransaction(ctx context.Context, transaction store.Store, accountID, peerID string, newIPv6 netip.Addr) (bool, error) { - network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthShare, accountID) + // Locked for update because the serial increment below writes the same row. + network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthUpdate, accountID) if err != nil { return false, fmt.Errorf("get network: %w", err) } @@ -2928,6 +2933,10 @@ func (am *DefaultAccountManager) updatePeerIPv6InTransaction(ctx context.Context return false, fmt.Errorf("save peer: %w", err) } + if err := transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return false, fmt.Errorf("increment network serial: %w", err) + } + return true, nil } diff --git a/management/server/account_test.go b/management/server/account_test.go index 7acc72d1f..c343575b8 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -3690,7 +3690,7 @@ func createManagerWithNetworkMapStore(t testing.TB) (*DefaultAccountManager, *up store, err := createStoreAt(t, dataDir) require.NoError(t, err) - nmdataStore, err := networkmapdbfactory.NewNetworkMapDBStore(context.Background(), types.SqliteStoreEngine, dataDir, MockIntegratedValidator{}, newSettingsMockManager(t)) + nmdataStore, err := networkmapdbfactory.NewNetworkMapDBStore(context.Background(), types.SqliteStoreEngine, dataDir, MockIntegratedValidator{}, newSettingsMockManager(t), nil, nil) require.NoError(t, err) sqliteStore, ok := nmdataStore.Store.(*networkmap_sqlite.SqliteStore) require.True(t, ok, "network map store is %T, want *networkmap_sqlite.SqliteStore", nmdataStore.Store) @@ -5110,3 +5110,46 @@ func TestDefaultAccountManager_ExpirationJobsStopForDeletedAccount(t *testing.T) }) } } + +func networkSerial(t *testing.T, manager *DefaultAccountManager, accountID string) uint64 { + t.Helper() + + network, err := manager.Store.GetAccountNetwork(context.Background(), store.LockingStrengthNone, accountID) + require.NoError(t, err) + return network.Serial +} + +func TestDefaultAccountManager_UpdatePeerIP_BumpsNetworkSerial(t *testing.T) { + manager, _, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + + newIP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), []netip.Addr{peer1.IP, peer2.IP, peer3.IP}) + require.NoError(t, err) + + before := networkSerial(t, manager, account.Id) + require.NoError(t, manager.UpdatePeerIP(context.Background(), account.Id, userID, peer1.ID, newIP)) + assert.Greater(t, networkSerial(t, manager, account.Id), before) +} + +func TestDefaultAccountManager_SyncUserJWTGroups_BumpsNetworkSerialWithoutPropagation(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: "jwt-user", Domain: "test.domain"}) + require.NoError(t, err) + acc, err := manager.Store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + acc.Settings.JWTGroupsEnabled = true + acc.Settings.JWTGroupsClaimName = "idp-groups" + acc.Settings.GroupsPropagationEnabled = false + require.NoError(t, manager.Store.SaveAccount(context.Background(), acc)) + + before := networkSerial(t, manager, accountID) + err = manager.SyncUserJWTGroups(context.Background(), auth.UserAuth{ + AccountId: accountID, + Domain: "test.domain", + UserId: "jwt-user", + Groups: []string{"group1"}, + }) + require.NoError(t, err) + assert.Greater(t, networkSerial(t, manager, accountID), before) +} diff --git a/management/server/peer.go b/management/server/peer.go index 5b33ca9f8..c3454179e 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -103,7 +103,9 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK return err } - updated, err := am.Store.MarkPeerConnectedIfNewerSession(ctx, accountID, peer.ID, sessionStartedAt) + updated, err := am.writePeerStatus(ctx, accountID, peer, func(st store.Store) (bool, error) { + return st.MarkPeerConnectedIfNewerSession(ctx, accountID, peer.ID, sessionStartedAt) + }) if err != nil { am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusError) return err @@ -140,6 +142,28 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK return nil } +// writePeerStatus runs a peer status write. The connection state of an embedded +// proxy peer is part of the network map, so for those peers an applied write also +// increments the account network serial in the same transaction. +func (am *DefaultAccountManager) writePeerStatus(ctx context.Context, accountID string, peer *nbpeer.Peer, write func(store.Store) (bool, error)) (bool, error) { + if !peer.ProxyMeta.Embedded { + return write(am.Store) + } + + var updated bool + err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + var err error + if updated, err = write(transaction); err != nil || !updated { + return err + } + if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) + } + return nil + }) + return updated, err +} + // schedulePeerExpirations arms the account's login/inactivity expiration // timers for an SSO peer that just connected. func (am *DefaultAccountManager) schedulePeerExpirations(ctx context.Context, accountID string, peer *nbpeer.Peer) error { @@ -179,7 +203,9 @@ func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerP return err } - updated, err := am.Store.MarkPeerDisconnectedIfSameSession(ctx, accountID, peer.ID, sessionStartedAt) + updated, err := am.writePeerStatus(ctx, accountID, peer, func(st store.Store) (bool, error) { + return st.MarkPeerDisconnectedIfSameSession(ctx, accountID, peer.ID, sessionStartedAt) + }) if err != nil { am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusError) return err @@ -349,7 +375,7 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user } if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { - return fmt.Errorf("failed to increment network serial: %w", err) + return fmt.Errorf("increment network serial: %w", err) } return transaction.SavePeer(ctx, accountID, peer) @@ -618,7 +644,7 @@ func (am *DefaultAccountManager) deletePeerInTransaction(ctx context.Context, ac } if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { - return fmt.Errorf("failed to increment network serial: %w", err) + return fmt.Errorf("increment network serial: %w", err) } return nil @@ -956,7 +982,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe err = transaction.IncrementNetworkSerial(ctx, accountID) if err != nil { - return fmt.Errorf("failed to increment network serial: %w", err) + return fmt.Errorf("increment network serial: %w", err) } if ephemeral { @@ -1097,6 +1123,9 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy if err = transaction.SavePeer(ctx, accountID, peer); err != nil { return err } + if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) + } } return nil }) @@ -1272,6 +1301,9 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer if err = transaction.SavePeer(ctx, accountID, peer); err != nil { return err } + if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) + } } return nil diff --git a/management/server/peer_test.go b/management/server/peer_test.go index b75dd1b72..bf8093c1d 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -3255,3 +3255,71 @@ func TestAddPeer_ExhaustedIPAllocationFails(t *testing.T) { require.Len(t, peers, 1) assert.Equal(t, netip.MustParseAddr("100.64.0.1"), peers[0].IP) } + +func TestPeerWrites_BumpNetworkSerial(t *testing.T) { + manager, _, account, peer1, peer2, _ := setupNetworkMapTest(t) + ctx := context.Background() + + t.Run("SyncPeer with changed meta bumps, unchanged does not", func(t *testing.T) { + current, err := manager.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer1.ID) + require.NoError(t, err) + + before := networkSerial(t, manager, account.Id) + _, _, _, _, err = manager.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peer1.Key, Meta: current.Meta}, account.Id) + require.NoError(t, err) + assert.Equal(t, before, networkSerial(t, manager, account.Id)) + + meta := current.Meta + meta.Hostname = "renamed-host" + _, _, _, _, err = manager.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peer1.Key, Meta: meta}, account.Id) + require.NoError(t, err) + assert.Greater(t, networkSerial(t, manager, account.Id), before) + }) + + t.Run("LoginPeer with changed ssh key bumps, unchanged does not", func(t *testing.T) { + current, err := manager.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer1.ID) + require.NoError(t, err) + + before := networkSerial(t, manager, account.Id) + _, _, _, _, err = manager.LoginPeer(ctx, types.PeerLogin{WireGuardPubKey: peer1.Key, SSHKey: current.SSHKey, Meta: current.Meta}) + require.NoError(t, err) + assert.Equal(t, before, networkSerial(t, manager, account.Id)) + + _, _, _, _, err = manager.LoginPeer(ctx, types.PeerLogin{WireGuardPubKey: peer1.Key, SSHKey: "ssh-ed25519 AAAAnew", Meta: current.Meta}) + require.NoError(t, err) + assert.Greater(t, networkSerial(t, manager, account.Id), before) + }) + + t.Run("MarkPeerConnected and MarkPeerDisconnected", func(t *testing.T) { + session := time.Now().UTC().UnixNano() + + before := networkSerial(t, manager, account.Id) + require.NoError(t, manager.MarkPeerConnected(ctx, peer2.Key, account.Id, session, nil)) + require.NoError(t, manager.MarkPeerDisconnected(ctx, peer2.Key, account.Id, session)) + assert.Equal(t, before, networkSerial(t, manager, account.Id), "regular peers must not bump") + + expired, err := manager.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer2.ID) + require.NoError(t, err) + expired.Status.LoginExpired = true + require.NoError(t, manager.Store.SavePeer(ctx, account.Id, expired)) + + session++ + require.NoError(t, manager.MarkPeerConnected(ctx, peer2.Key, account.Id, session, nil)) + afterExpired := networkSerial(t, manager, account.Id) + assert.Equal(t, before, afterExpired, "login-expired reconnect writes no network map data") + + embedded, err := manager.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer2.ID) + require.NoError(t, err) + embedded.Status.LoginExpired = false + embedded.ProxyMeta.Embedded = true + require.NoError(t, manager.Store.SavePeer(ctx, account.Id, embedded)) + + require.NoError(t, manager.MarkPeerDisconnected(ctx, peer2.Key, account.Id, session)) + afterDisconnect := networkSerial(t, manager, account.Id) + assert.Greater(t, afterDisconnect, afterExpired, "embedded proxy disconnect must bump") + + session++ + require.NoError(t, manager.MarkPeerConnected(ctx, peer2.Key, account.Id, session, nil)) + assert.Greater(t, networkSerial(t, manager, account.Id), afterDisconnect, "embedded proxy connect must bump") + }) +} diff --git a/management/server/user.go b/management/server/user.go index 3e84fcd03..89149367d 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -670,6 +670,12 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, var updatedUsersInfo = make([]*types.UserInfo, 0, len(usersToSave)) + if len(usersToSave) > 0 { + if err = am.Store.IncrementNetworkSerial(ctx, accountID); err != nil { + return nil, fmt.Errorf("increment network serial: %w", err) + } + } + userInfos, err := am.GetUsersFromAccount(ctx, accountID, initiatorUserID) if err != nil { return nil, err @@ -693,9 +699,6 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, return nil, err } } else if len(usersToSave) > 0 { - if err = am.Store.IncrementNetworkSerial(ctx, accountID); err != nil { - return nil, fmt.Errorf("failed to increment network serial: %w", err) - } go am.dispatchAffectedPeerIDs(ctx, accountID, affected.ids()) } @@ -1425,6 +1428,10 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI return fmt.Errorf("failed to delete user: %s %w", targetUserInfo.ID, err) } + if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("increment network serial: %w", err) + } + return nil }) if err != nil { diff --git a/management/server/user_test.go b/management/server/user_test.go index 305ed5a24..2e1b51572 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -14,6 +14,7 @@ import ( "github.com/netbirdio/netbird/management/internals/controllers/network_map" nbcache "github.com/netbirdio/netbird/management/server/cache" + nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/roles" @@ -2352,3 +2353,24 @@ func TestUser_Operations_WithEmbeddedIDP(t *testing.T) { t.Logf("Duplicate email error: %v", err) }) } + +func TestDefaultAccountManager_UserMutations_BumpNetworkSerial(t *testing.T) { + manager, _, account, _, _, _ := setupNetworkMapTest(t) + + account, err := manager.Store.GetAccount(context.Background(), account.Id) + require.NoError(t, err) + account.Users["regular-user"] = types.NewRegularUser("regular-user", "", "") + require.NoError(t, manager.Store.SaveAccount(context.Background(), account)) + + ctx := nbcontext.WithRole(context.Background(), string(types.UserRoleOwner)) + + before := networkSerial(t, manager, account.Id) + _, err = manager.SaveUser(ctx, account.Id, userID, &types.User{Id: "regular-user", Role: types.UserRoleUser, Blocked: true}) + require.NoError(t, err) + afterSave := networkSerial(t, manager, account.Id) + assert.Greater(t, afterSave, before, "SaveUser must bump the serial") + + _, err = manager.deleteRegularUser(ctx, account.Id, userID, &types.UserInfo{ID: "regular-user"}) + require.NoError(t, err) + assert.Greater(t, networkSerial(t, manager, account.Id), afterSave, "deleteRegularUser must bump the serial") +} From 280aa188d8e019bad9b7768251d9b968f452615b Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:35:02 +0900 Subject: [PATCH 206/209] [client] Validate and bound SSH session requests and channels (#8344) --- .../ssh/server/command_execution_windows.go | 23 +- client/ssh/server/server.go | 21 +- client/ssh/server/session_hardening_test.go | 340 ++++++++++++++++++ client/ssh/server/session_limits.go | 246 +++++++++++++ client/ssh/server/session_limits_test.go | 82 +++++ go.mod | 2 +- go.sum | 4 +- 7 files changed, 709 insertions(+), 9 deletions(-) create mode 100644 client/ssh/server/session_hardening_test.go create mode 100644 client/ssh/server/session_limits.go create mode 100644 client/ssh/server/session_limits_test.go diff --git a/client/ssh/server/command_execution_windows.go b/client/ssh/server/command_execution_windows.go index feb8daa26..54981276a 100644 --- a/client/ssh/server/command_execution_windows.go +++ b/client/ssh/server/command_execution_windows.go @@ -267,7 +267,7 @@ func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, sess return env } -func (s *Server) handlePtyLogin(logger *log.Entry, session ssh.Session, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, _ <-chan ssh.Window) bool { +func (s *Server) handlePtyLogin(logger *log.Entry, session ssh.Session, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, winCh <-chan ssh.Window) bool { if privilegeResult.User == nil { logger.Errorf("no user in privilege result") return false @@ -276,7 +276,7 @@ func (s *Server) handlePtyLogin(logger *log.Entry, session ssh.Session, privileg shell := getUserShell(privilegeResult.User.Uid) logger.Infof("starting interactive shell: %s", shell) - s.executeCommandWithPty(logger, session, nil, privilegeResult, ptyReq, nil) + s.executeCommandWithPty(logger, session, nil, privilegeResult, ptyReq, winCh) return true } @@ -376,7 +376,9 @@ func (s *Server) detectUtilLinuxLogin(context.Context) bool { } // executeCommandWithPty executes a command with PTY allocation on Windows using ConPty -func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _ *exec.Cmd, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, _ <-chan ssh.Window) bool { +func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _ *exec.Cmd, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, winCh <-chan ssh.Window) bool { + discardWindowChanges(winCh) + localUser := privilegeResult.User if localUser == nil { logger.Errorf("no user in privilege result") @@ -406,3 +408,18 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _ logger.Debug("ConPty execution completed") return true } + +// discardWindowChanges drains the session's window-change channel. The ConPty is sized +// once at start, and an undrained channel blocks the session's request loop on the +// next resize. +func discardWindowChanges(winCh <-chan ssh.Window) { + if winCh == nil { + return + } + go func() { + for range winCh { + // The ConPty keeps the size it was started with. + continue + } + }() +} diff --git a/client/ssh/server/server.go b/client/ssh/server/server.go index a33784329..b850c8e70 100644 --- a/client/ssh/server/server.go +++ b/client/ssh/server/server.go @@ -58,6 +58,10 @@ const ( // the forwarded destination before rejecting the SSH channel. const directTCPIPDialTimeout = 30 * time.Second +// defaultHandshakeTimeout bounds how long a connection may take to complete the SSH +// handshake and authentication before it is dropped. +const defaultHandshakeTimeout = 60 * time.Second + var ( ErrPrivilegedUserDisabled = errors.New(msgPrivilegedUserDisabled) ErrUserNotFound = errors.New("user not found") @@ -165,6 +169,7 @@ type Server struct { allowRemotePortForwarding bool allowRootLogin bool allowSFTP bool + handshakeTimeout time.Duration jwtEnabled bool netstackNet *wgbackend.NetstackNet @@ -227,6 +232,7 @@ func New(config *Config) *Server { jwtEnabled: config.JWT != nil, jwtConfig: config.JWT, authorizer: sshauth.NewAuthorizer(), + handshakeTimeout: defaultHandshakeTimeout, } if config.Auth != nil { @@ -884,15 +890,16 @@ func (s *Server) createSSHServer(addr net.Addr) (*ssh.Server, error) { }, HostSigners: []ssh.Signer{}, ChannelHandlers: map[string]ssh.ChannelHandler{ - "session": ssh.DefaultSessionHandler, + "session": s.sessionChannelHandler, "direct-tcpip": s.directTCPIPHandler, }, RequestHandlers: map[string]ssh.RequestHandler{ "tcpip-forward": s.tcpipForwardHandler, "cancel-tcpip-forward": s.cancelTcpipForwardHandler, }, - ConnCallback: s.connectionValidator, - Version: serverVersion, + ConnCallback: s.connectionValidator, + HandshakeTimeout: s.handshakeTimeout, + Version: serverVersion, } if s.jwtEnabled { @@ -934,6 +941,14 @@ func (s *Server) removeRemoteForwardListener(key forwardKey) bool { func (s *Server) directTCPIPHandler(srv *ssh.Server, conn *cryptossh.ServerConn, newChan cryptossh.NewChannel, ctx ssh.Context) { logger := s.getRequestLogger(ctx) + release, ok := acquireConnSlot(ctx, directTCPIPCountKey{}, maxDirectTCPIPPerConn) + if !ok { + logger.Warnf("local port forwarding denied: too many forwarded connections") + _ = newChan.Reject(cryptossh.ResourceShortage, "too many forwarded connections") + return + } + defer release() + var payload struct { Host string Port uint32 diff --git a/client/ssh/server/session_hardening_test.go b/client/ssh/server/session_hardening_test.go new file mode 100644 index 000000000..ed107a32a --- /dev/null +++ b/client/ssh/server/session_hardening_test.go @@ -0,0 +1,340 @@ +//go:build !js + +package server + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "net" + "os/user" + "runtime" + "strings" + "testing" + "time" + + cryptossh "golang.org/x/crypto/ssh" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbssh "github.com/netbirdio/netbird/client/ssh" +) + +func dialNoAuthServer(t *testing.T, addr string, hostKey []byte) *cryptossh.Client { + t.Helper() + + clientKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + signer, err := cryptossh.ParsePrivateKey(clientKey) + require.NoError(t, err) + hostSigner, err := cryptossh.ParsePrivateKey(hostKey) + require.NoError(t, err) + current, err := user.Current() + require.NoError(t, err) + + client, err := cryptossh.Dial("tcp", addr, &cryptossh.ClientConfig{ + User: current.Username, + Auth: []cryptossh.AuthMethod{cryptossh.PublicKeys(signer)}, + HostKeyCallback: cryptossh.FixedHostKey(hostSigner.PublicKey()), + Timeout: 3 * time.Second, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + return client +} + +func startNoAuthServer(t *testing.T) (string, []byte) { + t.Helper() + + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + srv := New(&Config{HostKeyPEM: hostKey}) + srv.SetAllowRootLogin(true) + addr := StartTestServer(t, srv) + t.Cleanup(func() { require.NoError(t, srv.Stop()) }) + return addr, hostKey +} + +// ptyReqPayload builds a pty-req payload whose term string claims the given length. +func ptyReqPayload(termLen uint32, term string) []byte { + buf := make([]byte, 4, 64) + binary.BigEndian.PutUint32(buf, termLen) + buf = append(buf, term...) + buf = append(buf, make([]byte, 16)...) + return buf +} + +// A pty-req with a term length close to the uint32 maximum must be refused and must +// not take the daemon down. +func TestSession_MalformedPtyReqDoesNotCrashServer(t *testing.T) { + addr, hostKey := startNoAuthServer(t) + client := dialNoAuthServer(t, addr, hostKey) + + session, err := client.NewSession() + require.NoError(t, err) + + ok, err := session.SendRequest("pty-req", true, ptyReqPayload(0xFFFFFFFF, "xterm")) + require.NoError(t, err) + assert.False(t, ok, "malformed pty-req should be rejected") + _ = session.Close() + + // The server must still serve a fresh connection. + again := dialNoAuthServer(t, addr, hostKey) + s2, err := again.NewSession() + require.NoError(t, err) + require.NoError(t, s2.Close()) +} + +// Env requests past the per-session cap are refused in order, and the ones before it +// are still accepted. +func TestSession_EnvRequestsAreCapped(t *testing.T) { + addr, hostKey := startNoAuthServer(t) + client := dialNoAuthServer(t, addr, hostKey) + + session, err := client.NewSession() + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + + payload := cryptossh.Marshal(struct{ Key, Value string }{"FOO", "bar"}) + accepted := 0 + for i := 0; i < maxEnvRequests+50; i++ { + ok, err := session.SendRequest("env", true, payload) + require.NoError(t, err) + if ok { + accepted++ + } + } + assert.Equal(t, maxEnvRequests, accepted, "only the first maxEnvRequests env requests should be accepted") + + oversized := cryptossh.Marshal(struct{ Key, Value string }{"BIG", string(make([]byte, maxEnvPayload))}) + fresh, err := client.NewSession() + require.NoError(t, err) + t.Cleanup(func() { _ = fresh.Close() }) + ok, err := fresh.SendRequest("env", true, oversized) + require.NoError(t, err) + assert.False(t, ok, "an env request over the payload limit should be refused") +} + +// A flood of window-change requests before the command starts must not stall the +// session's request loop: the next request still gets its reply. +func TestSession_WindowChangeFloodDoesNotStallRequests(t *testing.T) { + addr, hostKey := startNoAuthServer(t) + client := dialNoAuthServer(t, addr, hostKey) + + session, err := client.NewSession() + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + + require.NoError(t, session.RequestPty("xterm", 24, 80, cryptossh.TerminalModes{})) + + winch := cryptossh.Marshal(struct{ Columns, Rows, Width, Height uint32 }{100, 40, 0, 0}) + for i := 0; i < 500; i++ { + _, err := session.SendRequest("window-change", false, winch) + require.NoError(t, err) + } + + done := make(chan bool, 1) + go func() { + ok, err := session.SendRequest("env", true, cryptossh.Marshal(struct{ Key, Value string }{"A", "b"})) + done <- err == nil && ok + }() + select { + case ok := <-done: + assert.True(t, ok, "env request after the flood should be accepted") + case <-time.After(5 * time.Second): + t.Fatal("session request loop stalled behind window-change requests") + } +} + +func TestSession_PerConnectionSessionLimit(t *testing.T) { + addr, hostKey := startNoAuthServer(t) + client := dialNoAuthServer(t, addr, hostKey) + + for i := 0; i < maxSessionsPerConn; i++ { + s, err := client.NewSession() + require.NoError(t, err, "session %d should open", i) + t.Cleanup(func() { _ = s.Close() }) + } + + _, err := client.NewSession() + assert.Error(t, err, "session beyond the limit should be refused") + + // The limit is per connection: another connection is unaffected. + other := dialNoAuthServer(t, addr, hostKey) + s, err := other.NewSession() + require.NoError(t, err, "a second connection should still open sessions") + require.NoError(t, s.Close()) +} + +func TestValidPtyReqPayload(t *testing.T) { + assert.True(t, validPtyReqPayload(ptyReqPayload(5, "xterm"))) + assert.False(t, validPtyReqPayload(nil), "empty payload") + assert.False(t, validPtyReqPayload([]byte{0, 0, 0}), "short length prefix") + assert.False(t, validPtyReqPayload(ptyReqPayload(0xFFFFFFFF, "xterm")), "length that wraps uint32") + assert.False(t, validPtyReqPayload(ptyReqPayload(1000, "xterm")), "length beyond the payload") +} + +// A resize sent while the command runs reaches the pty. +func TestSession_ResizeWhileRunningIsApplied(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("stty is not available") + } + addr, hostKey := startNoAuthServer(t) + client := dialNoAuthServer(t, addr, hostKey) + + session, err := client.NewSession() + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + + var out bytes.Buffer + session.Stdout = &out + require.NoError(t, session.RequestPty("xterm", 24, 80, cryptossh.TerminalModes{})) + require.NoError(t, session.Start("sleep 1; stty size")) + + // Let the handler read the initial window before resizing. + time.Sleep(300 * time.Millisecond) + require.NoError(t, session.WindowChange(50, 132)) + require.NoError(t, session.Wait()) + // Only the first line is the resize report. Coverage-instrumented binaries can append + // runtime warnings to the same output. + firstLine := strings.TrimSpace(strings.SplitN(out.String(), "\n", 2)[0]) + assert.Equal(t, "50 132", firstLine, "pty should have the resized dimensions") +} + +// Forwarded connections beyond the per-connection cap are refused, and a closed one +// frees its slot. +func TestDirectTCPIP_PerConnectionLimit(t *testing.T) { + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + srv := New(&Config{HostKeyPEM: hostKey}) + srv.SetAllowRootLogin(true) + srv.SetAllowLocalPortForwarding(true) + addr := StartTestServer(t, srv) + t.Cleanup(func() { require.NoError(t, srv.Stop()) }) + + // Target that holds each connection open until the peer half-closes, then closes it + // like a regular server. + target, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = target.Close() }) + go func() { + for { + c, err := target.Accept() + if err != nil { + return + } + go func() { + _, _ = io.Copy(io.Discard, c) + _ = c.Close() + }() + } + }() + + client := dialNoAuthServer(t, addr, hostKey) + + conns := make([]net.Conn, 0, maxDirectTCPIPPerConn) + for i := 0; i < maxDirectTCPIPPerConn; i++ { + c, err := client.Dial("tcp", target.Addr().String()) + require.NoError(t, err, "forward %d should open", i) + conns = append(conns, c) + } + + _, err = client.Dial("tcp", target.Addr().String()) + assert.Error(t, err, "forward beyond the limit should be refused") + + // The limit is per connection: another connection is unaffected. + other := dialNoAuthServer(t, addr, hostKey) + oc, err := other.Dial("tcp", target.Addr().String()) + require.NoError(t, err, "a second connection should still open forwards") + require.NoError(t, oc.Close()) + + require.NoError(t, conns[0].Close()) + assert.Eventually(t, func() bool { + c, err := client.Dial("tcp", target.Addr().String()) + if err != nil { + return false + } + _ = c.Close() + return true + }, 3*time.Second, 50*time.Millisecond, "a closed forward should free its slot") + + for _, c := range conns[1:] { + _ = c.Close() + } +} + +// A connection that never completes the SSH handshake is dropped, and one that does +// complete it is not subject to the handshake deadline afterwards. +func TestHandshakeTimeout(t *testing.T) { + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + srv := New(&Config{HostKeyPEM: hostKey}) + srv.handshakeTimeout = 500 * time.Millisecond + addr := StartTestServer(t, srv) + t.Cleanup(func() { require.NoError(t, srv.Stop()) }) + + stalled, err := net.Dial("tcp", addr) + require.NoError(t, err) + t.Cleanup(func() { _ = stalled.Close() }) + + // The server sends its version line first; after that it waits for ours. + require.NoError(t, stalled.SetReadDeadline(time.Now().Add(5*time.Second))) + _, err = io.Copy(io.Discard, stalled) + var nerr net.Error + assert.False(t, errors.As(err, &nerr) && nerr.Timeout(), "server should close the stalled connection, not leave it open") + + client := dialNoAuthServer(t, addr, hostKey) + time.Sleep(time.Second) + session, err := client.NewSession() + require.NoError(t, err, "an established connection must outlive the handshake deadline") + require.NoError(t, session.Close()) +} + +// Refused requests are forwarded to the session loop under a type it answers with a +// failure, so every reply is sent by the loop in request order. +func TestFilterSessionRequests_RefusedRequestsStayInOrder(t *testing.T) { + in := make(chan *cryptossh.Request) + out := filterSessionRequests(nil, in) + + validWinch := cryptossh.Marshal(struct{ Columns, Rows, Width, Height uint32 }{100, 40, 0, 0}) + reqs := []*cryptossh.Request{ + {Type: "pty-req", Payload: ptyReqPayload(0xFFFFFFFF, "xterm")}, + {Type: "pty-req", Payload: ptyReqPayload(5, "xterm")}, + {Type: "window-change", Payload: validWinch}, + {Type: "shell"}, + {Type: "window-change", Payload: validWinch}, + {Type: "window-change", Payload: validWinch}, + } + want := []string{ + refusedRequestType, // malformed pty-req + "pty-req", + refusedRequestType, // resize before the command starts + "shell", + refusedRequestType, // older of two queued resizes + "window-change", + } + + // Hold all requests in the filter's queue before the loop consumes any, so the + // coalescing of the two trailing resizes is exercised. + for _, r := range reqs { + in <- r + } + for i, w := range want { + select { + case got := <-out: + assert.Equal(t, w, got.Type, "request %d", i) + case <-time.After(2 * time.Second): + t.Fatalf("request %d not delivered", i) + } + } + + close(in) + select { + case _, ok := <-out: + assert.False(t, ok, "filter should close its output when the input closes") + case <-time.After(2 * time.Second): + t.Fatal("filter did not close its output") + } +} diff --git a/client/ssh/server/session_limits.go b/client/ssh/server/session_limits.go new file mode 100644 index 000000000..68850e4bd --- /dev/null +++ b/client/ssh/server/session_limits.go @@ -0,0 +1,246 @@ +package server + +import ( + "encoding/binary" + "math" + "sync/atomic" + + "github.com/gliderlabs/ssh" + log "github.com/sirupsen/logrus" + cryptossh "golang.org/x/crypto/ssh" +) + +const ( + // maxSessionsPerConn bounds concurrent session channels on one SSH connection. + maxSessionsPerConn = 64 + + // maxDirectTCPIPPerConn bounds concurrent direct-tcpip channels, dialing or + // relaying, on one SSH connection. + maxDirectTCPIPPerConn = 512 + + // maxEnvRequests and maxEnvPayload bound the environment a single session can + // accumulate before its command starts. + maxEnvRequests = 128 + maxEnvPayload = 8 * 1024 + + // maxQueuedRequests bounds the requests held for a session whose request loop is + // not consuming them. A client that exceeds it has its channel closed. + maxQueuedRequests = 256 +) + +type ( + sessionCountKey struct{} + directTCPIPCountKey struct{} +) + +// sessionChannelHandler serves "session" channels like ssh.DefaultSessionHandler, with +// the per-connection session count capped, the request stream bounded, and any panic +// from the session request loop contained to its channel. +func (s *Server) sessionChannelHandler(srv *ssh.Server, conn *cryptossh.ServerConn, newChan cryptossh.NewChannel, ctx ssh.Context) { + release, ok := acquireConnSlot(ctx, sessionCountKey{}, maxSessionsPerConn) + if !ok { + if err := newChan.Reject(cryptossh.ResourceShortage, "too many sessions"); err != nil { + log.Debugf("reject session channel: %v", err) + } + return + } + defer release() + + guarded := &guardedSessionChannel{NewChannel: newChan} + defer func() { + r := recover() + if r == nil { + return + } + log.Errorf("SSH session request loop panicked from %s: %v", ctx.RemoteAddr(), r) + guarded.closeChannel() + }() + + ssh.DefaultSessionHandler(srv, conn, guarded, ctx) +} + +// acquireConnSlot takes one of limit slots counted under key for the connection that +// ctx belongs to. The returned func releases the slot. +func acquireConnSlot(ctx ssh.Context, key any, limit int32) (func(), bool) { + counter := connCounter(ctx, key) + if counter.Add(1) > limit { + counter.Add(-1) + return nil, false + } + return func() { counter.Add(-1) }, true +} + +func connCounter(ctx ssh.Context, key any) *atomic.Int32 { + ctx.Lock() + defer ctx.Unlock() + + if c, ok := ctx.Value(key).(*atomic.Int32); ok { + return c + } + c := new(atomic.Int32) + ctx.SetValue(key, c) + return c +} + +// guardedSessionChannel hands the session loop a filtered request stream. +type guardedSessionChannel struct { + cryptossh.NewChannel + ch atomic.Pointer[cryptossh.Channel] +} + +// Accept accepts the channel and returns its requests after filtering. +func (g *guardedSessionChannel) Accept() (cryptossh.Channel, <-chan *cryptossh.Request, error) { + ch, reqs, err := g.NewChannel.Accept() + if err != nil { + return nil, nil, err + } + g.ch.Store(&ch) + return ch, filterSessionRequests(ch, reqs), nil +} + +func (g *guardedSessionChannel) closeChannel() { + if ch := g.ch.Load(); ch != nil { + _ = (*ch).Close() + } +} + +// refusedRequestType is the type given to a request the filter refuses. The session +// loop answers unknown request types with a failure reply, so a refused request is +// answered by the loop itself and in its position among the replies, which the +// protocol requires since replies carry no request identifier. +const refusedRequestType = "refused@netbird.io" + +// requestFilter passes session channel requests to the session loop while keeping +// what the loop can accumulate or block on bounded: +// - a malformed pty-req is refused instead of reaching the loop's parser +// - env requests beyond maxEnvRequests, or with an oversized payload, are refused +// - window-change requests are refused until the command starts and coalesced +// after, as the loop blocks sending on a one-slot channel that only the command +// handler drains +// - the queue behind a stalled loop is capped, after which the channel is closed +type requestFilter struct { + ch cryptossh.Channel + queue []*cryptossh.Request + lastWinch *cryptossh.Request + started bool + envCount int +} + +// filterSessionRequests returns the requests of in as the session loop should see them. +func filterSessionRequests(ch cryptossh.Channel, in <-chan *cryptossh.Request) <-chan *cryptossh.Request { + out := make(chan *cryptossh.Request) + f := &requestFilter{ch: ch} + go func() { + defer close(out) + f.run(in, out) + }() + return out +} + +func (f *requestFilter) run(in <-chan *cryptossh.Request, out chan<- *cryptossh.Request) { + for { + var ( + sendCh chan<- *cryptossh.Request + next *cryptossh.Request + ) + if len(f.queue) > 0 { + sendCh = out + next = f.queue[0] + } + + select { + case req, ok := <-in: + if !ok { + return + } + if !f.enqueue(req) { + return + } + case sendCh <- next: + f.dropHead() + } + } +} + +func (f *requestFilter) dropHead() { + if f.queue[0] == f.lastWinch { + f.lastWinch = nil + } + f.queue = f.queue[1:] +} + +// enqueue classifies req and queues it. It returns false after closing the channel +// because the queue is full. +func (f *requestFilter) enqueue(req *cryptossh.Request) bool { + if len(f.queue) >= maxQueuedRequests { + log.Warnf("SSH session request queue full, closing channel") + _ = f.ch.Close() + return false + } + + switch req.Type { + case "pty-req": + if !validPtyReqPayload(req.Payload) { + refuseRequest(req) + } + case "env": + f.envCount++ + if f.envCount > maxEnvRequests || len(req.Payload) > maxEnvPayload { + refuseRequest(req) + } + case "shell", "exec", "subsystem": + f.started = true + case "window-change": + f.coalesceWindowChange(req) + } + f.queue = append(f.queue, req) + return true +} + +func (f *requestFilter) coalesceWindowChange(req *cryptossh.Request) { + if !f.started || !validWinchPayload(req.Payload) { + refuseRequest(req) + return + } + if f.lastWinch != nil { + refuseRequest(f.lastWinch) + } + f.lastWinch = req +} + +// validWinchPayload reports whether p parses as a window-change payload whose columns and +// rows are non-zero and fit the uint16 the PTY takes. A malformed or unusable replacement +// must not displace a valid queued resize. +func validWinchPayload(p []byte) bool { + var win struct{ Columns, Rows, Width, Height uint32 } + if err := cryptossh.Unmarshal(p, &win); err != nil { + return false + } + return usablePtyDimension(win.Columns) && usablePtyDimension(win.Rows) +} + +func usablePtyDimension(v uint32) bool { + return v > 0 && v <= math.MaxUint16 +} + +func refuseRequest(req *cryptossh.Request) { + req.Type = refusedRequestType + req.Payload = nil +} + +// validPtyReqPayload reports whether p holds a term string followed by the two +// uint32 window dimensions, without overflowing on a hostile string length. The +// dimensions must fit the Windows int16 ConPty coordinate, which is narrower than the +// Unix uint16 winsize; zero is allowed and defaulted by the Unix path. +func validPtyReqPayload(p []byte) bool { + if len(p) < 4 { + return false + } + termLen := uint64(binary.BigEndian.Uint32(p)) + if uint64(len(p)) < 4+termLen+8 { + return false + } + dims := p[4+termLen:] + return binary.BigEndian.Uint32(dims) <= math.MaxInt16 && + binary.BigEndian.Uint32(dims[4:]) <= math.MaxInt16 +} diff --git a/client/ssh/server/session_limits_test.go b/client/ssh/server/session_limits_test.go new file mode 100644 index 000000000..8c2a35d37 --- /dev/null +++ b/client/ssh/server/session_limits_test.go @@ -0,0 +1,82 @@ +package server + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cryptossh "golang.org/x/crypto/ssh" +) + +// A malformed window-change arriving after a valid one must not cost the valid resize: +// the valid request stays queued for the PTY, and only the malformed one is refused. +func TestRequestFilter_MalformedWinchKeepsQueuedValidResize(t *testing.T) { + f := &requestFilter{} + require.True(t, f.enqueue(&cryptossh.Request{Type: "shell"})) + + valid := &cryptossh.Request{Type: "window-change", Payload: cryptossh.Marshal(struct{ Columns, Rows, Width, Height uint32 }{100, 40, 0, 0})} + require.True(t, f.enqueue(valid)) + + malformed := &cryptossh.Request{Type: "window-change", Payload: []byte{1, 2, 3}} + require.True(t, f.enqueue(malformed)) + + assert.Equal(t, "window-change", valid.Type, "valid resize must still reach the PTY handler") + assert.Equal(t, refusedRequestType, malformed.Type, "malformed resize must be refused") +} + +// A zero-row or zero-column resize parses but would size the PTY to nothing, so it must +// not displace a valid queued resize either. +func TestRequestFilter_ZeroSizeWinchKeepsQueuedValidResize(t *testing.T) { + f := &requestFilter{} + require.True(t, f.enqueue(&cryptossh.Request{Type: "shell"})) + + valid := &cryptossh.Request{Type: "window-change", Payload: cryptossh.Marshal(struct{ Columns, Rows, Width, Height uint32 }{100, 40, 0, 0})} + require.True(t, f.enqueue(valid)) + + zeroRows := &cryptossh.Request{Type: "window-change", Payload: cryptossh.Marshal(struct{ Columns, Rows, Width, Height uint32 }{100, 0, 0, 0})} + require.True(t, f.enqueue(zeroRows)) + + assert.Equal(t, "window-change", valid.Type, "valid resize must still reach the PTY handler") + assert.Equal(t, refusedRequestType, zeroRows.Type, "zero-row resize must be refused") +} + +// The initial pty-req size must fit both the Unix uint16 winsize and the Windows int16 +// ConPty coordinate, so larger values are refused instead of wrapping. Zero stays +// accepted because the Unix path defaults it to 80x24. +func TestValidPtyReqPayload_BoundsInitialSize(t *testing.T) { + ptyReq := func(columns, rows uint32) []byte { + return cryptossh.Marshal(struct { + Term string + Columns, Rows, Width, Height uint32 + Modes string + }{"xterm", columns, rows, 0, 0, ""}) + } + + assert.True(t, validPtyReqPayload(ptyReq(80, 24)), "ordinary size") + assert.True(t, validPtyReqPayload(ptyReq(0, 0)), "zero size is defaulted later") + assert.True(t, validPtyReqPayload(ptyReq(32767, 32767)), "largest size both platforms take") + assert.False(t, validPtyReqPayload(ptyReq(32768, 24)), "columns past the Windows int16 range") + assert.False(t, validPtyReqPayload(ptyReq(80, 32768)), "rows past the Windows int16 range") + assert.False(t, validPtyReqPayload(ptyReq(65536, 24)), "columns that wrap to zero as uint16") +} + +// Sizes that are zero or do not fit the PTY's uint16 dimensions are refused, so they +// cannot replace a valid queued resize. +func TestValidWinchPayload_RejectsUnusableSizes(t *testing.T) { + tests := []struct { + name string + columns, rows uint32 + }{ + {name: "zero columns", columns: 0, rows: 40}, + {name: "zero rows", columns: 100, rows: 0}, + {name: "columns wrap to zero as uint16", columns: 65536, rows: 40}, + {name: "rows wrap to zero as uint16", columns: 100, rows: 65536}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + payload := cryptossh.Marshal(struct{ Columns, Rows, Width, Height uint32 }{tc.columns, tc.rows, 0, 0}) + assert.False(t, validWinchPayload(payload)) + }) + } + assert.True(t, validWinchPayload(cryptossh.Marshal(struct{ Columns, Rows, Width, Height uint32 }{65535, 65535, 0, 0})), "largest uint16 size is usable") +} diff --git a/go.mod b/go.mod index 0d1492731..699a553af 100644 --- a/go.mod +++ b/go.mod @@ -54,7 +54,7 @@ require ( github.com/eko/gocache/store/go_cache/v4 v4.2.2 github.com/eko/gocache/store/redis/v4 v4.2.2 github.com/fsnotify/fsnotify v1.9.0 - github.com/gliderlabs/ssh v0.3.8 + github.com/gliderlabs/ssh v0.3.9-0.20250127223818-909fa952d4e4 github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-ole/go-ole v1.3.0 github.com/gobwas/ws v1.4.0 diff --git a/go.sum b/go.sum index a6d3dedca..de7b77649 100644 --- a/go.sum +++ b/go.sum @@ -200,8 +200,8 @@ github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+r github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= -github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= -github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/gliderlabs/ssh v0.3.9-0.20250127223818-909fa952d4e4 h1:iLDHIkpEUuk277c53BF7v9yCAzgQHOcaY8tOu5ZX9Ws= +github.com/gliderlabs/ssh v0.3.9-0.20250127223818-909fa952d4e4/go.mod h1:1nh+SYql7iCMwTrbe9K8l/lah6xcgJTH7W4ZlVoq22c= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= From 08b924f7398a8e31ae04a78a744d87fc0376f37f Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:04:32 +0900 Subject: [PATCH 207/209] [client] Bound the SSH session request queue and keep resizes from blocking the session loop (#8363) * Bound the SSH session request queue by bytes and stop resizes reaching a loop with no consumer * Give the window change drain loop a body * Avoid an empty case clause in the SSH request filter * Charge only the newest resize to the request queue and classify requests before the byte limit --- client/ssh/server/session_handlers.go | 9 +- client/ssh/server/session_hardening_test.go | 62 ++++++++++ client/ssh/server/session_limits.go | 130 +++++++++++++++----- client/ssh/server/session_limits_test.go | 104 ++++++++++++++++ 4 files changed, 276 insertions(+), 29 deletions(-) diff --git a/client/ssh/server/session_handlers.go b/client/ssh/server/session_handlers.go index 0e531bb96..45b0fc3be 100644 --- a/client/ssh/server/session_handlers.go +++ b/client/ssh/server/session_handlers.go @@ -53,13 +53,20 @@ func (s *Server) sessionHandler(session ssh.Session) { logger.Infof("SSH session closed after %v", duration) }() + ptyReq, winCh, isPty := session.Pty() + if isPty { + // The request loop blocks sending a resize nobody reads, and only sees its request + // stream close once that send completes. Covers the paths below that return + // without consuming window changes. + defer drainWindowChanges(winCh) + } + privilegeResult, err := s.userPrivilegeCheck(session.User()) if err != nil { s.handlePrivError(logger, session, err) return } - ptyReq, winCh, isPty := session.Pty() hasCommand := session.RawCommand() != "" if isPty && !hasCommand { diff --git a/client/ssh/server/session_hardening_test.go b/client/ssh/server/session_hardening_test.go index ed107a32a..f60bb3c3d 100644 --- a/client/ssh/server/session_hardening_test.go +++ b/client/ssh/server/session_hardening_test.go @@ -338,3 +338,65 @@ func TestFilterSessionRequests_RefusedRequestsStayInOrder(t *testing.T) { t.Fatal("filter did not close its output") } } + +// A rejected subsystem starts no command, so nothing drains the window channel. A resize +// after it must be refused instead of blocking the session's request loop. +func TestSession_ResizeAfterRejectedSubsystemDoesNotStallLoop(t *testing.T) { + addr, hostKey := startNoAuthServer(t) + client := dialNoAuthServer(t, addr, hostKey) + + session, err := client.NewSession() + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + + require.NoError(t, session.RequestPty("xterm", 24, 80, cryptossh.TerminalModes{})) + ok, err := session.SendRequest("subsystem", true, cryptossh.Marshal(struct{ Name string }{"nope"})) + require.NoError(t, err) + require.False(t, ok, "unknown subsystem must be rejected") + + winch := cryptossh.Marshal(struct{ Columns, Rows, Width, Height uint32 }{100, 40, 0, 0}) + _, err = session.SendRequest("window-change", false, winch) + require.NoError(t, err) + + done := make(chan bool, 1) + go func() { + ok, err := session.SendRequest("env", true, cryptossh.Marshal(struct{ Key, Value string }{"A", "b"})) + done <- err == nil && ok + }() + select { + case ok := <-done: + assert.True(t, ok, "env request after the resize should be accepted") + case <-time.After(5 * time.Second): + t.Fatal("session request loop stalled behind a resize with no command running") + } +} + +// A resize that reaches the loop while the command handler never reads window changes +// (sftp) must not leave the loop goroutine behind once the handler returns. +func TestSession_ResizeDuringSFTPDoesNotLeakRequestLoop(t *testing.T) { + addr, hostKey := startNoAuthServer(t) + loops := func() int { + buf := make([]byte, 1<<22) + return strings.Count(string(buf[:runtime.Stack(buf, true)]), "ssh.(*session).handleRequests") + } + before := loops() + + winch := cryptossh.Marshal(struct{ Columns, Rows, Width, Height uint32 }{100, 40, 0, 0}) + for i := 0; i < 10; i++ { + client := dialNoAuthServer(t, addr, hostKey) + session, err := client.NewSession() + require.NoError(t, err) + require.NoError(t, session.RequestPty("xterm", 24, 80, cryptossh.TerminalModes{})) + ok, err := session.SendRequest("subsystem", true, cryptossh.Marshal(struct{ Name string }{"sftp"})) + require.NoError(t, err) + require.True(t, ok, "the sftp subsystem must be accepted for the resize to matter") + // The sftp handler may already have ended the session, so a send can fail. + _, _ = session.SendRequest("window-change", false, winch) + _, _ = session.SendRequest("window-change", false, winch) + _ = session.Close() + _ = client.Close() + } + + assert.Eventually(t, func() bool { return loops() <= before }, 10*time.Second, 100*time.Millisecond, + "session request loops must exit after the clients disconnect") +} diff --git a/client/ssh/server/session_limits.go b/client/ssh/server/session_limits.go index 68850e4bd..4e004b00b 100644 --- a/client/ssh/server/session_limits.go +++ b/client/ssh/server/session_limits.go @@ -23,9 +23,11 @@ const ( maxEnvRequests = 128 maxEnvPayload = 8 * 1024 - // maxQueuedRequests bounds the requests held for a session whose request loop is - // not consuming them. A client that exceeds it has its channel closed. - maxQueuedRequests = 256 + // maxQueuedRequests and maxQueuedBytes bound the requests held for a session whose + // request loop is not consuming them. A client that exceeds either has its channel + // closed. + maxQueuedRequests = 128 + maxQueuedBytes = 256 * 1024 ) type ( @@ -59,6 +61,18 @@ func (s *Server) sessionChannelHandler(srv *ssh.Server, conn *cryptossh.ServerCo ssh.DefaultSessionHandler(srv, conn, guarded, ctx) } +// drainWindowChanges discards window changes until winCh is closed. It must run after +// the session's command handler has returned. +func drainWindowChanges(winCh <-chan ssh.Window) { + go func() { + for { + if _, ok := <-winCh; !ok { + return + } + } + }() +} + // acquireConnSlot takes one of limit slots counted under key for the connection that // ctx belongs to. The returned func releases the slot. func acquireConnSlot(ctx ssh.Context, key any, limit int32) (func(), bool) { @@ -114,16 +128,31 @@ const refusedRequestType = "refused@netbird.io" // what the loop can accumulate or block on bounded: // - a malformed pty-req is refused instead of reaching the loop's parser // - env requests beyond maxEnvRequests, or with an oversized payload, are refused -// - window-change requests are refused until the command starts and coalesced -// after, as the loop blocks sending on a one-slot channel that only the command -// handler drains -// - the queue behind a stalled loop is capped, after which the channel is closed +// - window-change requests are refused unless the session's command is a shell or +// exec, and coalesced after it starts, as the loop blocks sending on a one-slot +// channel that only a shell or exec handler drains. Subsystems never read it. +// - request types the loop does not act on are refused, which also drops their payload +// - the queue behind a stalled loop is capped by count and by payload bytes, after +// which the channel is closed type requestFilter struct { - ch cryptossh.Channel - queue []*cryptossh.Request - lastWinch *cryptossh.Request - started bool - envCount int + ch cryptossh.Channel + queue []*queuedRequest + queuedBytes int + lastWinch *queuedRequest + envCount int + + // commandSeen is set by the first shell, exec or subsystem request, which decides + // the session's command as far as the loop is concerned. + commandSeen bool + // started is set when that first command is a shell or exec. + started bool +} + +// queuedRequest remembers the payload size counted against the queue, as a refusal +// drops the payload and releases that size. +type queuedRequest struct { + req *cryptossh.Request + size int } // filterSessionRequests returns the requests of in as the session loop should see them. @@ -145,7 +174,7 @@ func (f *requestFilter) run(in <-chan *cryptossh.Request, out chan<- *cryptossh. ) if len(f.queue) > 0 { sendCh = out - next = f.queue[0] + next = f.queue[0].req } select { @@ -163,49 +192,83 @@ func (f *requestFilter) run(in <-chan *cryptossh.Request, out chan<- *cryptossh. } func (f *requestFilter) dropHead() { - if f.queue[0] == f.lastWinch { + head := f.queue[0] + if head == f.lastWinch { f.lastWinch = nil } + f.queuedBytes -= head.size + f.queue[0] = nil f.queue = f.queue[1:] } // enqueue classifies req and queues it. It returns false after closing the channel -// because the queue is full. +// because the queue is full. Requests are classified first so a refused request's +// dropped payload does not count against the limit. func (f *requestFilter) enqueue(req *cryptossh.Request) bool { - if len(f.queue) >= maxQueuedRequests { + q := &queuedRequest{req: req} + f.classify(q) + q.size = len(req.Payload) + + if len(f.queue) >= maxQueuedRequests || f.queuedBytes+q.size > maxQueuedBytes { log.Warnf("SSH session request queue full, closing channel") - _ = f.ch.Close() + if f.ch != nil { + _ = f.ch.Close() + } return false } + f.queue = append(f.queue, q) + f.queuedBytes += q.size + return true +} + +func (f *requestFilter) classify(q *queuedRequest) { + req := q.req switch req.Type { case "pty-req": if !validPtyReqPayload(req.Payload) { - refuseRequest(req) + f.refuse(q) } case "env": f.envCount++ if f.envCount > maxEnvRequests || len(req.Payload) > maxEnvPayload { - refuseRequest(req) + f.refuse(q) } case "shell", "exec", "subsystem": - f.started = true + f.noteCommand(req.Type) case "window-change": - f.coalesceWindowChange(req) + f.coalesceWindowChange(q) + default: + if !loopActsOn(req.Type) { + f.refuse(q) + } } - f.queue = append(f.queue, req) - return true } -func (f *requestFilter) coalesceWindowChange(req *cryptossh.Request) { - if !f.started || !validWinchPayload(req.Payload) { - refuseRequest(req) +// refuse drops the payload of q and releases the size it was charged against the queue. +func (f *requestFilter) refuse(q *queuedRequest) { + f.queuedBytes -= q.size + q.size = 0 + refuseRequest(q.req) +} + +func (f *requestFilter) noteCommand(reqType string) { + if f.commandSeen { + return + } + f.commandSeen = true + f.started = reqType != "subsystem" +} + +func (f *requestFilter) coalesceWindowChange(q *queuedRequest) { + if !f.started || !validWinchPayload(q.req.Payload) { + f.refuse(q) return } if f.lastWinch != nil { - refuseRequest(f.lastWinch) + f.refuse(f.lastWinch) } - f.lastWinch = req + f.lastWinch = q } // validWinchPayload reports whether p parses as a window-change payload whose columns and @@ -223,6 +286,17 @@ func usablePtyDimension(v uint32) bool { return v > 0 && v <= math.MaxUint16 } +// loopActsOn reports whether the session loop does anything with a request type other +// than the ones the filter classifies itself. The loop answers every other type with a +// failure, so its payload is not worth queueing. +func loopActsOn(reqType string) bool { + switch reqType { + case "signal", "break", "auth-agent-req@openssh.com": + return true + } + return false +} + func refuseRequest(req *cryptossh.Request) { req.Type = refusedRequestType req.Payload = nil diff --git a/client/ssh/server/session_limits_test.go b/client/ssh/server/session_limits_test.go index 8c2a35d37..93f4d5f48 100644 --- a/client/ssh/server/session_limits_test.go +++ b/client/ssh/server/session_limits_test.go @@ -2,7 +2,9 @@ package server import ( "testing" + "time" + "github.com/gliderlabs/ssh" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" cryptossh "golang.org/x/crypto/ssh" @@ -80,3 +82,105 @@ func TestValidWinchPayload_RejectsUnusableSizes(t *testing.T) { } assert.True(t, validWinchPayload(cryptossh.Marshal(struct{ Columns, Rows, Width, Height uint32 }{65535, 65535, 0, 0})), "largest uint16 size is usable") } + +// A client that stops the loop from reading must not make the server buffer more than +// maxQueuedBytes of payload, however few requests that takes. +func TestRequestFilter_QueueIsBoundedByBytes(t *testing.T) { + f := &requestFilter{} + payload := make([]byte, 32*1024) + + accepted := 0 + for f.enqueue(&cryptossh.Request{Type: "exec", Payload: payload}) { + accepted++ + require.LessOrEqual(t, accepted, maxQueuedRequests, "queue must stop growing") + } + assert.LessOrEqual(t, f.queuedBytes, maxQueuedBytes, "queued payload bytes must stay under the limit") + assert.Equal(t, maxQueuedBytes/len(payload), accepted, "queue must hold payloads up to the byte limit") +} + +// Request types the session loop only answers with a failure carry no useful payload, so +// they are refused and cost nothing against the queue. +func TestRequestFilter_UnhandledTypesDropPayload(t *testing.T) { + f := &requestFilter{} + req := &cryptossh.Request{Type: "x11-req", Payload: make([]byte, 32*1024)} + require.True(t, f.enqueue(req)) + + assert.Equal(t, refusedRequestType, req.Type, "unhandled type must be refused") + assert.Zero(t, f.queuedBytes, "refused request must not count its payload") +} + +// Nothing drains window changes for a subsystem session, so a resize after one must be +// refused for the rest of the session, even if a shell request follows. +func TestRequestFilter_SubsystemSessionRefusesResize(t *testing.T) { + f := &requestFilter{} + winch := cryptossh.Marshal(struct{ Columns, Rows, Width, Height uint32 }{100, 40, 0, 0}) + + require.True(t, f.enqueue(&cryptossh.Request{Type: "subsystem", Payload: cryptossh.Marshal(struct{ Name string }{"sftp"})})) + require.True(t, f.enqueue(&cryptossh.Request{Type: "shell"})) + resize := &cryptossh.Request{Type: "window-change", Payload: winch} + require.True(t, f.enqueue(resize)) + assert.Equal(t, refusedRequestType, resize.Type, "resize in a subsystem session must be refused") +} + +// A loop blocked sending a resize that no handler reads is released by the drain, and the +// drain ends when the loop closes the channel. +func TestDrainWindowChanges_ReleasesBlockedSender(t *testing.T) { + winCh := make(chan ssh.Window, 1) + winCh <- ssh.Window{Width: 80, Height: 24} + + sent := make(chan struct{}) + go func() { + winCh <- ssh.Window{Width: 100, Height: 40} + close(sent) + }() + select { + case <-sent: + t.Fatal("sender should block while nothing reads the channel") + case <-time.After(50 * time.Millisecond): + } + + drainWindowChanges(winCh) + select { + case <-sent: + case <-time.After(2 * time.Second): + t.Fatal("drain did not release the blocked sender") + } + + // A drain that read one window change and exited would release the first sender but + // leave the loop blocked on the next resize. + for i := 0; i < 5; i++ { + select { + case winCh <- ssh.Window{Width: 120, Height: 48}: + case <-time.After(2 * time.Second): + t.Fatal("drain stopped consuming after the first window change") + } + } + close(winCh) +} + +// A superseded resize keeps its place in the queue so its refusal is answered in order, +// but must stop counting against the byte limit. +func TestRequestFilter_CoalescedResizeReleasesQueuedBytes(t *testing.T) { + f := &requestFilter{} + require.True(t, f.enqueue(&cryptossh.Request{Type: "shell"})) + winch := cryptossh.Marshal(struct{ Columns, Rows, Width, Height uint32 }{100, 40, 0, 0}) + + for i := 0; i < 50; i++ { + require.True(t, f.enqueue(&cryptossh.Request{Type: "window-change", Payload: winch})) + } + + assert.Equal(t, len(winch), f.queuedBytes, "only the newest resize should be charged") +} + +// A large request the loop ignores is refused and its payload dropped, so it cannot close +// the channel by pushing the queue over the byte limit. +func TestRequestFilter_IgnoredLargeRequestDoesNotCloseChannel(t *testing.T) { + f := &requestFilter{} + for i := 0; i < maxQueuedBytes/len(make([]byte, 32*1024)); i++ { + require.True(t, f.enqueue(&cryptossh.Request{Type: "exec", Payload: make([]byte, 32*1024)})) + } + + ignored := &cryptossh.Request{Type: "x11-req", Payload: make([]byte, 200*1024)} + assert.True(t, f.enqueue(ignored), "an ignored request must be refused rather than close the channel") + assert.Equal(t, refusedRequestType, ignored.Type) +} From afa6eec6a73e43c965fffea7596411f98731664c Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Mon, 26 Oct 2026 11:02:26 -0400 Subject: [PATCH 208/209] [client] Skip peers without a PTR record in kubernetes commands (#8360) * [client] Skip peers without a PTR record in kubernetes commands getKubernetesClusters returned on the first failed reverse lookup, so a single peer without a PTR record made `netbird kubernetes list` and `write-kubeconfig` fail even when a netbird-kubeapi-proxy peer was reachable later in the list. Log the failure at debug and keep scanning, stopping only when the context is cancelled. * [client] Return cancellation when the last peer's lookup is skipped A failed lookup or fingerprint on the final peer was skipped and the loop ended with a nil error, so a cancelled command reported no clusters instead of the cancellation. * [client] Report the lookup error when no peer resolves Skipping every failed reverse lookup hid a broken resolver behind "No Kubernetes clusters available" or "cluster not found", with the cause only in the debug log. When no lookup succeeds, return the last lookup error. One resolvable peer still means the resolver works, so per-peer failures stay skipped. * [client] Stop the kubernetes peer scan when the resolver is down With a dead resolver every peer waited out the resolver timeout in turn before the error was reported. A missing PTR record answers NXDOMAIN, so a timeout or SERVFAIL before any peer resolved means the resolver itself is failing: return it at once. After one peer resolved, later failures are still skipped. * [client] Extract kubernetes cluster probing from the peer scan The lookup error handling pushed findKubernetesClusters past the cognitive complexity limit. Move the FQDN match and fingerprint into probeCluster so the scan loop only handles lookups and deduplication. --- client/cmd/kubernetes.go | 81 ++++++++++++++++++------- client/cmd/kubernetes_test.go | 108 ++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 20 deletions(-) diff --git a/client/cmd/kubernetes.go b/client/cmd/kubernetes.go index cc91477c6..c04d8385e 100644 --- a/client/cmd/kubernetes.go +++ b/client/cmd/kubernetes.go @@ -132,48 +132,89 @@ func getKubernetesClusters(ctx context.Context, peers []*proto.PeerState, nameFi // https://github.com/golang/go/issues/17093 PreferGo: true, } + return findKubernetesClusters(ctx, httpClient, resolver.LookupAddr, peers, nameFilter) +} +func findKubernetesClusters( + ctx context.Context, + httpClient *http.Client, + lookupAddr func(context.Context, string) ([]string, error), + peers []*proto.PeerState, + nameFilter string, +) ([]kubernetesCluster, error) { kcs := []kubernetesCluster{} attempted := map[string]struct{}{} + var lookupErr error + resolvedAny := false for _, peer := range peers { - fqdns, err := resolver.LookupAddr(ctx, peer.IP) - if err != nil { + if err := ctx.Err(); err != nil { return nil, err } + // Peers without a DNS record have no PTR, which must not hide the remaining peers. + fqdns, err := lookupAddr(ctx, peer.IP) + if err != nil { + log.Debugf("reverse lookup of peer %s: %v", peer.IP, err) + // Before any success, a timeout or SERVFAIL means the resolver is down; waiting + // on each remaining peer would only repeat the timeout. + if !resolvedAny && !isNotFoundErr(err) { + return nil, fmt.Errorf("reverse lookup of peers: %w", err) + } + lookupErr = err + continue + } + resolvedAny = true for _, fqdn := range fqdns { if _, ok := attempted[fqdn]; ok { continue } attempted[fqdn] = struct{}{} - comps := strings.Split(fqdn, ".") - if len(comps) < 2 { + kc, ok := probeCluster(ctx, httpClient, fqdn, nameFilter) + if !ok { continue } - if comps[1] != KubernetesDNSSuffix { - continue - } - if nameFilter != "" && nameFilter != comps[0] { - continue - } - clusterURL, clusterVersion, err := fingerprintClusters(ctx, httpClient, fqdn) - if err != nil { - log.Debugf("could not fingerprint Kubernetes cluster %s %q", fqdn, err) - continue - } - kc := kubernetesCluster{ - name: comps[0], - url: clusterURL, - version: clusterVersion, - } if nameFilter != "" { return []kubernetesCluster{kc}, nil } kcs = append(kcs, kc) } } + // A skipped failure on the last peer would otherwise hide a cancellation. + if err := ctx.Err(); err != nil { + return nil, err + } + // Every lookup failing points at the resolver rather than at individual peers. + if !resolvedAny && lookupErr != nil { + return nil, fmt.Errorf("reverse lookup of peers: %w", lookupErr) + } return kcs, nil } +// probeCluster fingerprints fqdn when it names a netbird-kubeapi-proxy peer matching nameFilter. +func probeCluster(ctx context.Context, httpClient *http.Client, fqdn, nameFilter string) (kubernetesCluster, bool) { + comps := strings.Split(fqdn, ".") + if len(comps) < 2 || comps[1] != KubernetesDNSSuffix { + return kubernetesCluster{}, false + } + if nameFilter != "" && nameFilter != comps[0] { + return kubernetesCluster{}, false + } + clusterURL, clusterVersion, err := fingerprintClusters(ctx, httpClient, fqdn) + if err != nil { + log.Debugf("could not fingerprint Kubernetes cluster %s %q", fqdn, err) + return kubernetesCluster{}, false + } + return kubernetesCluster{ + name: comps[0], + url: clusterURL, + version: clusterVersion, + }, true +} + +func isNotFoundErr(err error) bool { + var dnsErr *net.DNSError + return errors.As(err, &dnsErr) && dnsErr.IsNotFound +} + func fingerprintClusters(ctx context.Context, httpClient *http.Client, fqdn string) (*url.URL, string, error) { clusterURL, err := url.Parse("https://" + fqdn) if err != nil { diff --git a/client/cmd/kubernetes_test.go b/client/cmd/kubernetes_test.go index c40d20996..c547c08c8 100644 --- a/client/cmd/kubernetes_test.go +++ b/client/cmd/kubernetes_test.go @@ -1,6 +1,10 @@ package cmd import ( + "context" + "crypto/tls" + "errors" + "net" "net/http" "net/http/httptest" "net/url" @@ -9,7 +13,10 @@ import ( "testing" "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/proto" ) func TestFingerprintClusters(t *testing.T) { @@ -27,6 +34,107 @@ func TestFingerprintClusters(t *testing.T) { require.Equal(t, "foobar", clusterVersion) } +func TestFindKubernetesClustersSkipsFailedLookup(t *testing.T) { + t.Parallel() + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"gitVersion": "v1.30.0"}`)) //nolint:errcheck + })) + defer srv.Close() + + // Route every cluster FQDN to the test server, regardless of the resolved name. + httpClient := &http.Client{Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, srv.Listener.Addr().String()) + }, + }} + + // The first peer has no DNS record in NetBird, so its reverse lookup fails. + lookupAddr := func(_ context.Context, addr string) ([]string, error) { + switch addr { + case "100.64.0.1": + return nil, &net.DNSError{Err: "no such host", Name: "1.0.64.100.in-addr.arpa.", IsNotFound: true} + case "100.64.0.2": + return []string{"homelab." + KubernetesDNSSuffix + ".netbird.cloud."}, nil + } + return nil, errors.New("unexpected address " + addr) + } + peers := []*proto.PeerState{{IP: "100.64.0.1"}, {IP: "100.64.0.2"}} + + for _, filter := range []string{"", "homelab"} { + kcs, err := findKubernetesClusters(t.Context(), httpClient, lookupAddr, peers, filter) + require.NoError(t, err) + require.Len(t, kcs, 1, "cluster behind the failing peer must be found (filter %q)", filter) + require.Equal(t, "homelab", kcs[0].name) + require.Equal(t, "v1.30.0", kcs[0].version) + } + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err := findKubernetesClusters(ctx, httpClient, lookupAddr, peers, "homelab") + require.ErrorIs(t, err, context.Canceled) + + // Cancellation during the last peer's lookup must surface instead of an empty result. + ctx, cancel = context.WithCancel(t.Context()) + cancelOnLookup := func(ctx context.Context, _ string) ([]string, error) { + cancel() + return nil, ctx.Err() + } + _, err = findKubernetesClusters(ctx, httpClient, cancelOnLookup, peers[:1], "") + require.ErrorIs(t, err, context.Canceled) +} + +func TestFindKubernetesClustersReportsDeadResolver(t *testing.T) { + t.Parallel() + + peers := []*proto.PeerState{{IP: "100.64.0.1"}, {IP: "100.64.0.2"}, {IP: "100.64.0.3"}} + timeoutErr := &net.DNSError{Err: "i/o timeout", Name: "1.0.64.100.in-addr.arpa.", IsTimeout: true} + notFoundErr := &net.DNSError{Err: "no such host", Name: "1.0.64.100.in-addr.arpa.", IsNotFound: true} + + // A timeout before any peer resolved means the resolver is down: stop at the first + // peer instead of waiting out the timeout once per peer. + for _, filter := range []string{"", "homelab"} { + calls := 0 + timeoutAll := func(context.Context, string) ([]string, error) { + calls++ + return nil, timeoutErr + } + kcs, err := findKubernetesClusters(t.Context(), http.DefaultClient, timeoutAll, peers, filter) + require.ErrorIs(t, err, timeoutErr, "filter %q", filter) + assert.Empty(t, kcs) + assert.Equal(t, 1, calls, "scan must stop after the first timeout (filter %q)", filter) + } + + // No PTR anywhere is not a resolver outage, so every peer is tried before the + // last lookup error is reported instead of an empty cluster list. + calls := 0 + notFoundAll := func(context.Context, string) ([]string, error) { + calls++ + return nil, notFoundErr + } + kcs, err := findKubernetesClusters(t.Context(), http.DefaultClient, notFoundAll, peers, "") + require.ErrorIs(t, err, notFoundErr) + assert.Empty(t, kcs) + assert.Equal(t, len(peers), calls, "every peer must be tried when lookups only miss") + + // Once one peer resolved the resolver is known to work, so a later timeout is + // skipped like any other per-peer failure. + resolveThenTimeout := func(_ context.Context, addr string) ([]string, error) { + if addr == "100.64.0.1" { + return []string{"laptop.netbird.cloud."}, nil + } + return nil, timeoutErr + } + kcs, err = findKubernetesClusters(t.Context(), http.DefaultClient, resolveThenTimeout, peers, "") + require.NoError(t, err) + assert.Empty(t, kcs) + + kcs, err = findKubernetesClusters(t.Context(), http.DefaultClient, notFoundAll, nil, "") + require.NoError(t, err, "an empty peer list is not a resolver failure") + assert.Empty(t, kcs) +} + func TestResolveKubeconfigPath(t *testing.T) { home, err := os.UserHomeDir() if err != nil { From 2c68a84a976e8e3fb7f90aa59347fe2271e8de1d Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:03:33 +0100 Subject: [PATCH 209/209] [management] optimize posture cache (#8366) --- .../network_map/controller/controller.go | 6 +- .../network_map/nmaptest/runner.go | 1 - .../certposture/certposture_test.go | 18 ++ shared/management/certposture/chain.go | 9 + shared/management/networkmap/admission.go | 239 +++++++++++++++ .../networkmap/networkmapcompute.go | 283 ++++++++---------- .../networkmap/networkmapcompute_test.go | 241 +++++++++++++++ .../management/networkmap/networkmapdata.go | 10 +- .../networkmap/nmdata/posture_certificate.go | 17 +- .../nmdata/posture_certificate_test.go | 24 ++ .../networkmap/nmdata/posture_nb_version.go | 24 +- 11 files changed, 690 insertions(+), 182 deletions(-) create mode 100644 shared/management/networkmap/admission.go diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 0554811db..55c1fb875 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -489,9 +489,8 @@ func (c *Controller) getNetworkMapData(ctx context.Context, accountID string) *n // fetchNetworkMapData reads the twin once per buffer window. Its result is // shared by every waiter of that window, so the mutating steps run here, before -// it is handed out: the twin the callers see is read-only. Injected proxy -// policies carry no posture checks, so precomputing after the injection yields -// the same validation as precomputing before it. +// it is handed out: the twin the callers see is read-only. Posture checks are +// evaluated lazily by the components calculation. func (c *Controller) fetchNetworkMapData(ctx context.Context, accountID string) (*networkmap.NetworkMapData, error) { nmData, err := c.nmdataStore.GetNetworkMapData(ctx, accountID) if err != nil { @@ -501,7 +500,6 @@ func (c *Controller) fetchNetworkMapData(ctx context.Context, accountID string) nmData.Services = c.proxyServicesFromRepo(ctx, accountID) nmData.BuildPrivateServiceCandidates() nmData.InjectProxyPolicies() - nmData.PrecomputePostureValidation() return nmData, nil } diff --git a/management/internals/controllers/network_map/nmaptest/runner.go b/management/internals/controllers/network_map/nmaptest/runner.go index b6fc81df6..8c0f87ece 100644 --- a/management/internals/controllers/network_map/nmaptest/runner.go +++ b/management/internals/controllers/network_map/nmaptest/runner.go @@ -184,7 +184,6 @@ func RunCase(t *testing.T, c Case) { nmData := c.Data applyFixtureDefaults(nmData) nmData.BuildPrivateServiceCandidates() - nmData.PrecomputePostureValidation() dnsDomain := c.DNSDomain if nmData.AccountSettings.DNSDomain != "" { diff --git a/shared/management/certposture/certposture_test.go b/shared/management/certposture/certposture_test.go index e8f3d20c1..7f12409f7 100644 --- a/shared/management/certposture/certposture_test.go +++ b/shared/management/certposture/certposture_test.go @@ -175,6 +175,24 @@ func TestChainMatchesCAs(t *testing.T) { assert.False(t, ChainMatchesCAs("not a pem", []string{ca.PEM}, now)) } +func TestAnyChainMatchesCAs(t *testing.T) { + ca := certtest.NewCA(t, "root") + otherCA := certtest.NewCA(t, "other-root") + chainPEM := EncodeChainPEM([]*x509.Certificate{ca.Issue(t, certtest.ECDSAKey(t), "device")}) + otherPEM := EncodeChainPEM([]*x509.Certificate{otherCA.Issue(t, certtest.ECDSAKey(t), "device")}) + + assert.True(t, AnyChainMatchesCAs([]string{otherPEM, chainPEM}, []string{ca.PEM}, now)) + assert.False(t, AnyChainMatchesCAs([]string{otherPEM}, []string{ca.PEM}, now)) + assert.False(t, AnyChainMatchesCAs(nil, []string{ca.PEM}, now)) + assert.False(t, AnyChainMatchesCAs([]string{chainPEM}, []string{"not a pem"}, now)) + + roots, err := ParseCAs([]string{ca.PEM}) + require.NoError(t, err) + assert.True(t, AnyChainMatchesPool([]string{otherPEM, chainPEM}, roots, now)) + assert.False(t, AnyChainMatchesPool([]string{otherPEM}, roots, now)) + assert.False(t, AnyChainMatchesPool(nil, roots, now)) +} + func TestChainPEM_RoundTrip(t *testing.T) { ca := certtest.NewCA(t, "root") leaf := ca.Issue(t, certtest.ECDSAKey(t), "device") diff --git a/shared/management/certposture/chain.go b/shared/management/certposture/chain.go index 80fb46b4b..6c550ed96 100644 --- a/shared/management/certposture/chain.go +++ b/shared/management/certposture/chain.go @@ -68,10 +68,19 @@ func ChainMatchesCAs(chainPEM string, caPEMs []string, now time.Time) bool { // AnyChainMatchesCAs reports whether at least one of the peer's verified chains // is anchored in one of the configured CAs. func AnyChainMatchesCAs(chainPEMs, caPEMs []string, now time.Time) bool { + if len(chainPEMs) == 0 { + return false + } roots, err := ParseCAs(caPEMs) if err != nil { return false } + return AnyChainMatchesPool(chainPEMs, roots, now) +} + +// AnyChainMatchesPool is AnyChainMatchesCAs over an already parsed root pool, for +// callers that verify many peers against the same CAs. +func AnyChainMatchesPool(chainPEMs []string, roots *x509.CertPool, now time.Time) bool { for _, chainPEM := range chainPEMs { if chainMatchesPool(chainPEM, roots, now) { return true diff --git a/shared/management/networkmap/admission.go b/shared/management/networkmap/admission.go new file mode 100644 index 000000000..5409524fe --- /dev/null +++ b/shared/management/networkmap/admission.go @@ -0,0 +1,239 @@ +package networkmap + +import ( + "slices" + "sync" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/netbirdio/netbird/shared/management/types" +) + +// admissionCache memoises the peer-independent parts of the components calculation: +// the posture-check verdicts and the admitted peers of every policy side. Its index is +// built once per NetworkMapData and every entry is filled on first use, so lookups +// take no lock and nothing is computed for checks or policies no peer needs. The +// NetworkMapData must not change once the index is built. +type admissionCache struct { + policies map[*nmdata.Policy]*policyAdmission + verdicts map[string]*postureVerdicts + + ruleFailuresOnce sync.Once + ruleFailures map[string]map[string]struct{} +} + +// policyAdmission holds the admissions of one policy: the union of its source sides, +// used by the network resource loop, and the sides of each rule, in rule order. +type policyAdmission struct { + sources admittedPeers + rules []ruleAdmission +} + +type ruleAdmission struct { + sources admittedPeers + destinations admittedPeers +} + +// admittedPeers is the admission of one policy side: the peers that exist, are +// validated and pass the side's posture checks and, keyed by posture check ID, the +// peers a check rejected. Both are shared and must not be mutated. +type admittedPeers struct { + once sync.Once + peers []string + failed map[string]map[string]struct{} +} + +// postureVerdicts holds the results of one posture check keyed by peer ID. A nil +// map marks a check ID that resolves to no posture check, which passes. +type postureVerdicts struct { + once sync.Once + results map[string]bool +} + +func (nmd *NetworkMapData) admissions() *admissionCache { + nmd.admissionsOnce.Do(func() { + nmd.InjectProxyPolicies() + c := &admissionCache{ + policies: make(map[*nmdata.Policy]*policyAdmission, len(nmd.Policies)), + verdicts: make(map[string]*postureVerdicts, len(nmd.PostureChecks)), + } + for _, policy := range nmd.Policies { + c.addPolicy(policy) + } + for _, policies := range nmd.ResourcePolicies { + for _, policy := range policies { + c.addPolicy(policy) + } + } + for id := range nmd.PostureChecks { + c.verdicts[id] = &postureVerdicts{} + } + nmd.admissionsIdx = c + }) + return nmd.admissionsIdx +} + +func (c *admissionCache) addPolicy(policy *nmdata.Policy) { + if policy == nil { + return + } + if _, ok := c.policies[policy]; ok { + return + } + c.policies[policy] = &policyAdmission{rules: make([]ruleAdmission, len(policy.Rules))} +} + +// ruleAdmissions returns the admitted source and destination peers of the policy's +// rule at index i. +func (nmd *NetworkMapData) ruleAdmissions(policy *nmdata.Policy, i int) (*admittedPeers, *admittedPeers) { + rule := policy.Rules[i] + ra := &nmd.admissions().policies[policy].rules[i] + src := ra.sources.fill(func(failed *map[string]map[string]struct{}) []string { + return nmd.admitSide(rule.Sources, rule.SourceResource, policy.SourcePostureChecks, failed) + }) + dst := ra.destinations.fill(func(failed *map[string]map[string]struct{}) []string { + return nmd.admitSide(rule.Destinations, rule.DestinationResource, nil, failed) + }) + return src, dst +} + +// resourcePolicySources returns the admitted source peers of a resource policy across +// all its rules, shared by every routing peer of the policy's resources. +func (nmd *NetworkMapData) resourcePolicySources(policy *nmdata.Policy) *admittedPeers { + pa := nmd.admissions().policies[policy] + return pa.sources.fill(func(failed *map[string]map[string]struct{}) []string { + if resource := policy.Rules[0].SourceResource; isPeerResource(resource) { + return nmd.getPeerFromResource(resource, policy.SourcePostureChecks, failed) + } + var admitted []string + for _, pid := range nmd.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) { + if nmd.admitPolicyPeer(pid, policy.SourcePostureChecks, failed) { + admitted = append(admitted, pid) + } + } + return admitted + }) +} + +func (a *admittedPeers) fill(admit func(failed *map[string]map[string]struct{}) []string) *admittedPeers { + a.once.Do(func() { + a.failed = make(map[string]map[string]struct{}) + a.peers = admit(&a.failed) + }) + return a +} + +func (nmd *NetworkMapData) admitSide(groups []string, resource nmdata.Resource, postureChecksIDs []string, failed *map[string]map[string]struct{}) []string { + if isPeerResource(resource) { + return nmd.getPeerFromResource(resource, postureChecksIDs, failed) + } + return nmd.getPeersFromGroups(groups, postureChecksIDs, failed) +} + +// ruleFailures returns the posture failures of every enabled rule side. Every peer's +// rule loop walks all of them, so they are merged once per NetworkMapData. +func (nmd *NetworkMapData) ruleFailures() map[string]map[string]struct{} { + c := nmd.admissions() + c.ruleFailuresOnce.Do(func() { + failures := make(map[string]map[string]struct{}) + for _, policy := range nmd.Policies { + if policy == nil || !policy.Enabled { + continue + } + for i, rule := range policy.Rules { + if rule == nil || !rule.Enabled { + continue + } + sources, destinations := nmd.ruleAdmissions(policy, i) + mergePostureFailures(&failures, sources.failed) + mergePostureFailures(&failures, destinations.failed) + } + } + c.ruleFailures = failures + }) + return c.ruleFailures +} + +// sideAdmitsPeer reports whether the peer is an admitted member of a rule side, +// resolving membership like getPeersFromGroups without walking the group members. +func (nmd *NetworkMapData) sideAdmitsPeer(groups []string, resource nmdata.Resource, postureChecksIDs []string, peerID string, peerGroups map[string]struct{}) bool { + member := false + if isPeerResource(resource) { + member = resource.ID == peerID + } else { + for _, gid := range groups { + group := nmd.Groups[gid] + if group == nil { + continue + } + _, inGroup := peerGroups[gid] + if group.IsGroupAll() || len(groups) == 1 { + member = inGroup + break + } + member = member || inGroup + } + } + if !member { + return false + } + admitted, _ := nmd.admitsPeer(peerID, postureChecksIDs) + return admitted +} + +// postureVerdict returns the cached result of a posture check for the peer and +// whether one is cached. The results of a check are evaluated on first use for the +// source peers of every enabled policy using it, or taken from PostureValidation +// when it was precomputed. +func (nmd *NetworkMapData) postureVerdict(postureChecksID, peerID string) (bool, bool) { + v, ok := nmd.admissions().verdicts[postureChecksID] + if !ok { + return false, false + } + v.once.Do(func() { + if results, seeded := nmd.PostureValidation[postureChecksID]; seeded { + v.results = results + return + } + v.results = nmd.evaluatePostureChecksForPeers(postureChecksID, nmd.postureCheckPeerIDs(postureChecksID)) + }) + if v.results == nil { + return true, true + } + valid, found := v.results[peerID] + return valid, found +} + +// postureCheckPeerIDs collects the source peers of every enabled policy using the +// posture check. +func (nmd *NetworkMapData) postureCheckPeerIDs(postureChecksID string) map[string]struct{} { + peerIDs := make(map[string]struct{}) + for _, policy := range nmd.Policies { + if policy == nil || !policy.Enabled || !slices.Contains(policy.SourcePostureChecks, postureChecksID) { + continue + } + for _, pid := range nmd.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) { + peerIDs[pid] = struct{}{} + } + for _, rule := range policy.Rules { + if rule != nil && isPeerResource(rule.SourceResource) { + peerIDs[rule.SourceResource.ID] = struct{}{} + } + } + } + return peerIDs +} + +func isPeerResource(resource nmdata.Resource) bool { + return resource.Type == string(types.ResourceTypePeer) && resource.ID != "" +} + +func mergePostureFailures(dst *map[string]map[string]struct{}, failed map[string]map[string]struct{}) { + for checkID, peerIDs := range failed { + if _, ok := (*dst)[checkID]; !ok { + (*dst)[checkID] = make(map[string]struct{}, len(peerIDs)) + } + for pid := range peerIDs { + (*dst)[checkID][pid] = struct{}{} + } + } +} diff --git a/shared/management/networkmap/networkmapcompute.go b/shared/management/networkmap/networkmapcompute.go index 65e76d097..dfab621b2 100644 --- a/shared/management/networkmap/networkmapcompute.go +++ b/shared/management/networkmap/networkmapcompute.go @@ -1,8 +1,6 @@ package networkmap import ( - "slices" - "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/types" ) @@ -57,7 +55,7 @@ func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCusto ForceRoutingPeerDNSResolution: forceRoutingPeerDNS, } - relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := nmd.getPeersGroupsPoliciesRoutes(peerID, peer.SSHEnabled, &components.PostureFailedPeers) + relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := nmd.getPeersGroupsPoliciesRoutes(peerID, peer.SSHEnabled) if len(sshReqs.neededGroupIDs) > 0 { components.GroupIDToUserIDs = filterGroupIDToUserIDs(nmd.GroupIDToUserIDs, sshReqs.neededGroupIDs) @@ -87,6 +85,8 @@ func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCusto } } + seenSourcePolicies := make(map[*nmdata.Policy]struct{}) + var sourceFailures []map[string]map[string]struct{} for _, resource := range nmd.NetworkResources { if resource == nil || !resource.Enabled { continue @@ -111,16 +111,11 @@ func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCusto continue } if addSourcePeers { - var peers []string - if policy.Rules[0].SourceResource.Type == string(types.ResourceTypePeer) && policy.Rules[0].SourceResource.ID != "" { - peers = []string{policy.Rules[0].SourceResource.ID} - } else { - peers = nmd.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) - } - for _, pID := range nmd.getPostureValidPeersSaveFailed(peers, policy.SourcePostureChecks, &components.PostureFailedPeers) { - if _, exists := components.Peers[pID]; !exists { - components.Peers[pID] = nmd.Peers[pID] - } + if _, seen := seenSourcePolicies[policy]; !seen { + seenSourcePolicies[policy] = struct{}{} + sources := nmd.resourcePolicySources(policy) + nmd.mergeSourcePeers(components, sources) + sourceFailures = append(sourceFailures, sources.failed) } } else { peerInSources := false @@ -128,7 +123,7 @@ func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCusto peerInSources = policy.Rules[0].SourceResource.ID == peerID } else { for _, groupID := range policy.SourceGroups() { - if group := nmd.Groups[groupID]; group != nil && slices.Contains(group.Peers, peerID) { + if _, ok := peerGroups[groupID]; ok { peerInSources = true break } @@ -195,7 +190,7 @@ func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCusto } filterGroupPeers(&components.Groups, components.Peers) - filterPostureFailedPeers(&components.PostureFailedPeers, components.Policies, components.ResourcePoliciesMap, components.Peers) + nmd.collectPostureFailedPeers(components, sourceFailures) return components } @@ -203,7 +198,6 @@ func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCusto func (nmd *NetworkMapData) getPeersGroupsPoliciesRoutes( peerID string, peerSSHEnabled bool, - postureFailedPeers *map[string]map[string]struct{}, ) (map[string]*nmdata.Peer, map[string]*nmdata.Group, []*nmdata.Policy, []*nmdata.Route, sshRequirements) { relevantPeerIDs := make(map[string]*nmdata.Peer, len(nmd.Peers)/4) relevantGroupIDs := make(map[string]*nmdata.Group, len(nmd.Groups)/4) @@ -296,7 +290,7 @@ func (nmd *NetworkMapData) getPeersGroupsPoliciesRoutes( } policyRelevant := false - for _, rule := range policy.Rules { + for i, rule := range policy.Rules { if rule == nil || !rule.Enabled { continue } @@ -320,24 +314,13 @@ func (nmd *NetworkMapData) getPeersGroupsPoliciesRoutes( } } - var sourcePeers, destinationPeers []string - var peerInSources, peerInDestinations bool - - if rule.SourceResource.Type == string(types.ResourceTypePeer) && rule.SourceResource.ID != "" { - sourcePeers, peerInSources = nmd.getPeerFromResource(rule.SourceResource, peerID, policy.SourcePostureChecks, postureFailedPeers) - } else { - sourcePeers, peerInSources = nmd.getPeersFromGroups(rule.Sources, peerID, policy.SourcePostureChecks, postureFailedPeers) - } - - if rule.DestinationResource.Type == string(types.ResourceTypePeer) && rule.DestinationResource.ID != "" { - destinationPeers, peerInDestinations = nmd.getPeerFromResource(rule.DestinationResource, peerID, nil, postureFailedPeers) - } else { - destinationPeers, peerInDestinations = nmd.getPeersFromGroups(rule.Destinations, peerID, nil, postureFailedPeers) - } + sources, destinations := nmd.ruleAdmissions(policy, i) + peerInSources := nmd.sideAdmitsPeer(rule.Sources, rule.SourceResource, policy.SourcePostureChecks, peerID, peerGroupSet) + peerInDestinations := nmd.sideAdmitsPeer(rule.Destinations, rule.DestinationResource, nil, peerID, peerGroupSet) if peerInSources { policyRelevant = true - for _, pid := range destinationPeers { + for _, pid := range destinations.peers { relevantPeerIDs[pid] = nmd.Peers[pid] } for _, dstGroupID := range rule.Destinations { @@ -349,7 +332,7 @@ func (nmd *NetworkMapData) getPeersGroupsPoliciesRoutes( if peerInDestinations { policyRelevant = true - for _, pid := range sourcePeers { + for _, pid := range sources.peers { relevantPeerIDs[pid] = nmd.Peers[pid] } for _, srcGroupID := range rule.Sources { @@ -381,9 +364,11 @@ func (nmd *NetworkMapData) getPeersGroupsPoliciesRoutes( return relevantPeerIDs, relevantGroupIDs, relevantPolicies, relevantRoutes, sshReqs } -func (nmd *NetworkMapData) getPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string, - postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) { - peerInGroups := false +// getPeersFromGroups returns the admitted members of a rule side given by groups. +// The All group, or a single group, alone decides the side. Posture failures are +// recorded in postureFailedPeers. +func (nmd *NetworkMapData) getPeersFromGroups(groups []string, sourcePostureChecksIDs []string, + postureFailedPeers *map[string]map[string]struct{}) []string { filteredPeerIDs := make([]string, 0, len(groups)) seenPeerIds := make(map[string]struct{}, len(groups)) @@ -395,20 +380,12 @@ func (nmd *NetworkMapData) getPeersFromGroups(groups []string, peerID string, so if group.IsGroupAll() || len(groups) == 1 { filteredPeerIDs = make([]string, 0, len(group.Peers)) - peerInGroups = false for _, pid := range group.Peers { - if !nmd.admitPolicyPeer(pid, sourcePostureChecksIDs, postureFailedPeers) { - continue + if nmd.admitPolicyPeer(pid, sourcePostureChecksIDs, postureFailedPeers) { + filteredPeerIDs = append(filteredPeerIDs, pid) } - - if pid == peerID { - peerInGroups = true - continue - } - - filteredPeerIDs = append(filteredPeerIDs, pid) } - return filteredPeerIDs, peerInGroups + return filteredPeerIDs } for _, pid := range group.Peers { @@ -416,57 +393,55 @@ func (nmd *NetworkMapData) getPeersFromGroups(groups []string, peerID string, so continue } seenPeerIds[pid] = struct{}{} - if !nmd.admitPolicyPeer(pid, sourcePostureChecksIDs, postureFailedPeers) { - continue + if nmd.admitPolicyPeer(pid, sourcePostureChecksIDs, postureFailedPeers) { + filteredPeerIDs = append(filteredPeerIDs, pid) } - - if pid == peerID { - peerInGroups = true - continue - } - - filteredPeerIDs = append(filteredPeerIDs, pid) } } - return filteredPeerIDs, peerInGroups + return filteredPeerIDs } // getPeerFromResource resolves a rule side that names a peer directly, admitting it // like a member of a group holding only that peer. -func (nmd *NetworkMapData) getPeerFromResource(resource nmdata.Resource, peerID string, sourcePostureChecksIDs []string, - postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) { +func (nmd *NetworkMapData) getPeerFromResource(resource nmdata.Resource, sourcePostureChecksIDs []string, + postureFailedPeers *map[string]map[string]struct{}) []string { if !nmd.admitPolicyPeer(resource.ID, sourcePostureChecksIDs, postureFailedPeers) { - return nil, false + return nil } - if resource.ID == peerID { - return nil, true - } - return []string{resource.ID}, false + return []string{resource.ID} } -// admitPolicyPeer applies the per-peer admission of a rule side: the peer must exist, -// be validated and pass the rule's posture checks. A failed check is recorded in -// postureFailedPeers. +// admitPolicyPeer is admitsPeer that records a failed posture check in postureFailedPeers. func (nmd *NetworkMapData) admitPolicyPeer(pid string, sourcePostureChecksIDs []string, postureFailedPeers *map[string]map[string]struct{}) bool { + admitted, failedCheck := nmd.admitsPeer(pid, sourcePostureChecksIDs) + if failedCheck != "" { + if _, ok := (*postureFailedPeers)[failedCheck]; !ok { + (*postureFailedPeers)[failedCheck] = make(map[string]struct{}) + } + (*postureFailedPeers)[failedCheck][pid] = struct{}{} + } + return admitted +} + +// admitsPeer applies the per-peer admission of a rule side: the peer must exist, be +// validated and pass the rule's posture checks. It returns the first failed posture +// check ID, if any. +func (nmd *NetworkMapData) admitsPeer(pid string, sourcePostureChecksIDs []string) (bool, string) { peer, ok := nmd.Peers[pid] if !ok || peer == nil { - return false + return false, "" } if _, ok := nmd.ValidatedPeers[pid]; !ok { - return false + return false, "" } isValid, pname := nmd.validatePostureChecksOnPeerGetFailed(sourcePostureChecksIDs, pid) if !isValid && len(pname) > 0 { - if _, ok := (*postureFailedPeers)[pname]; !ok { - (*postureFailedPeers)[pname] = make(map[string]struct{}) - } - (*postureFailedPeers)[pname][pid] = struct{}{} - return false + return false, pname } - return true + return true, "" } func (nmd *NetworkMapData) validatePostureChecksOnPeerGetFailed(sourcePostureChecksID []string, peerID string) (bool, string) { @@ -476,7 +451,7 @@ func (nmd *NetworkMapData) validatePostureChecksOnPeerGetFailed(sourcePostureChe } for _, postureChecksID := range sourcePostureChecksID { - if valid, cached := nmd.cachedPostureCheckResult(postureChecksID, peerID); cached { + if valid, cached := nmd.postureVerdict(postureChecksID, peerID); cached { if !valid { return false, postureChecksID } @@ -494,43 +469,25 @@ func (nmd *NetworkMapData) validatePostureChecksOnPeerGetFailed(sourcePostureChe return true, "" } +// PrecomputePostureValidation evaluates every posture check used by an enabled policy +// for the policy's source peers and stores the results in PostureValidation. func (nmd *NetworkMapData) PrecomputePostureValidation() { if len(nmd.PostureChecks) == 0 { nmd.PostureValidation = nil return } - checkPeerIDs := make(map[string]map[string]struct{}) + results := make(map[string]map[string]bool) for _, policy := range nmd.Policies { - if policy == nil || !policy.Enabled || len(policy.SourcePostureChecks) == 0 { + if policy == nil || !policy.Enabled { continue } - - groupPeerIDs := nmd.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) for _, postureChecksID := range policy.SourcePostureChecks { - set := checkPeerIDs[postureChecksID] - if set == nil { - set = make(map[string]struct{}, len(groupPeerIDs)) - checkPeerIDs[postureChecksID] = set - } - for _, pid := range groupPeerIDs { - set[pid] = struct{}{} - } - for _, rule := range policy.Rules { - if rule == nil { - continue - } - if rule.SourceResource.Type == string(types.ResourceTypePeer) && rule.SourceResource.ID != "" { - set[rule.SourceResource.ID] = struct{}{} - } + if _, done := results[postureChecksID]; !done { + results[postureChecksID] = nmd.evaluatePostureChecksForPeers(postureChecksID, nmd.postureCheckPeerIDs(postureChecksID)) } } } - - results := make(map[string]map[string]bool, len(checkPeerIDs)) - for postureChecksID, peerIDs := range checkPeerIDs { - results[postureChecksID] = nmd.evaluatePostureChecksForPeers(postureChecksID, peerIDs) - } nmd.PostureValidation = results } @@ -552,38 +509,77 @@ func (nmd *NetworkMapData) evaluatePostureChecksForPeers(postureChecksID string, return results } -func (nmd *NetworkMapData) cachedPostureCheckResult(postureChecksID, peerID string) (bool, bool) { - results, ok := nmd.PostureValidation[postureChecksID] - if !ok { - return false, false +// mergeSourcePeers adds the admitted source peers of a resource policy to the components. +func (nmd *NetworkMapData) mergeSourcePeers(components *types.NetworkMapComponents, sources *admittedPeers) { + for _, pID := range sources.peers { + if _, exists := components.Peers[pID]; !exists { + components.Peers[pID] = nmd.Peers[pID] + } } - if results == nil { - return true, true - } - valid, found := results[peerID] - return valid, found } -func (nmd *NetworkMapData) getPostureValidPeersSaveFailed(inputPeers []string, postureChecksIDs []string, postureFailedPeers *map[string]map[string]struct{}) []string { - var dest []string - for _, peerID := range inputPeers { - if _, validated := nmd.ValidatedPeers[peerID]; !validated { - continue +// collectPostureFailedPeers sets the components' posture failures to those of every +// enabled rule side, of the given resource-policy sources and the ones already +// recorded, limited to the checks the components' policies reference and to the peers +// in the components. Each failure set is intersected with the components' peers from +// its smaller side, so the cost follows the peer's own map, not the account size. +func (nmd *NetworkMapData) collectPostureFailedPeers(components *types.NetworkMapComponents, sourceFailures []map[string]map[string]struct{}) { + failures := append([]map[string]map[string]struct{}{nmd.ruleFailures(), components.PostureFailedPeers}, sourceFailures...) + + referenced := make(map[string]struct{}) + addReferenced := func(policy *nmdata.Policy) { + if policy == nil { + return } - valid, pname := nmd.validatePostureChecksOnPeerGetFailed(postureChecksIDs, peerID) - if valid { - dest = append(dest, peerID) - continue + for _, checkID := range policy.SourcePostureChecks { + referenced[checkID] = struct{}{} } - if pname == "" { - continue - } - if _, ok := (*postureFailedPeers)[pname]; !ok { - (*postureFailedPeers)[pname] = make(map[string]struct{}) - } - (*postureFailedPeers)[pname][peerID] = struct{}{} } - return dest + for _, policy := range components.Policies { + addReferenced(policy) + } + for _, policies := range components.ResourcePoliciesMap { + for _, policy := range policies { + addReferenced(policy) + } + } + + result := make(map[string]map[string]struct{}) + for checkID := range referenced { + var kept map[string]struct{} + for _, failed := range failures { + kept = addFailedPeersInMap(kept, failed[checkID], components.Peers) + } + if len(kept) > 0 { + result[checkID] = kept + } + } + components.PostureFailedPeers = result +} + +// addFailedPeersInMap adds the failed peers that are in peers to kept, allocating kept +// on the first match, and returns it. +func addFailedPeersInMap(kept, failed map[string]struct{}, peers map[string]*nmdata.Peer) map[string]struct{} { + add := func(pid string) { + if kept == nil { + kept = make(map[string]struct{}) + } + kept[pid] = struct{}{} + } + if len(failed) <= len(peers) { + for pid := range failed { + if _, ok := peers[pid]; ok { + add(pid) + } + } + return kept + } + for pid := range peers { + if _, ok := failed[pid]; ok { + add(pid) + } + } + return kept } // forcesRoutingPeerDNSResolution reports whether the given peer must run @@ -739,41 +735,6 @@ func filterGroupPeers(groups *map[string]*nmdata.Group, peers map[string]*nmdata } } -func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*nmdata.Policy, resourcePoliciesMap map[string][]*nmdata.Policy, peers map[string]*nmdata.Peer) { - if len(*postureFailedPeers) == 0 { - return - } - - referencedPostureChecks := make(map[string]struct{}) - for _, policy := range policies { - for _, checkID := range policy.SourcePostureChecks { - referencedPostureChecks[checkID] = struct{}{} - } - } - for _, resPolicies := range resourcePoliciesMap { - for _, policy := range resPolicies { - for _, checkID := range policy.SourcePostureChecks { - referencedPostureChecks[checkID] = struct{}{} - } - } - } - - for checkID, failedPeers := range *postureFailedPeers { - if _, referenced := referencedPostureChecks[checkID]; !referenced { - delete(*postureFailedPeers, checkID) - continue - } - for peerID := range failedPeers { - if _, exists := peers[peerID]; !exists { - delete(failedPeers, peerID) - } - } - if len(failedPeers) == 0 { - delete(*postureFailedPeers, checkID) - } - } -} - func filterDNSRecordsByPeers(records []nmdata.SimpleRecord, peers map[string]*nmdata.Peer, includeIPv6 bool) []nmdata.SimpleRecord { if len(records) == 0 || len(peers) == 0 { return nil diff --git a/shared/management/networkmap/networkmapcompute_test.go b/shared/management/networkmap/networkmapcompute_test.go index ad21fd70f..97f1bd1ee 100644 --- a/shared/management/networkmap/networkmapcompute_test.go +++ b/shared/management/networkmap/networkmapcompute_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/netip" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -1662,3 +1663,243 @@ func TestNetworkMapData_GetPeerGroups(t *testing.T) { assert.Equal(t, map[string]struct{}{"g-1": {}, "g-2": {}}, nmd.GetPeerGroups(targetID)) assert.Empty(t, nmd.GetPeerGroups("missing")) } + +func TestGetPeerNetworkMapComponents_NetworkResources_SharedPolicyAcrossResourcesAndRouters(t *testing.T) { + target := newPeer(targetID, 1) + router2 := newPeer("peer-router-2", 2) + clientOK := newPeer("peer-client-ok", 3) + clientFail := newPeer("peer-client-fail", 4) + clientFail.Meta.WtVersion = failingVersion + nmd := newNMD(target, router2, clientOK, clientFail) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addGroup(nmd, "g-clients-a", clientOK.ID) + addGroup(nmd, "g-clients-b", clientFail.ID) + addGroup(nmd, "g-routers", targetID, router2.ID) + rp := newPolicy("rp-1", newRule([]string{"g-clients-a", "g-clients-b"}, []string{"g-resource"})) + rp.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = []*nmdata.Policy{newPolicy("p-acl", newRule([]string{"g-clients-b"}, []string{"g-routers"}))} + nmd.ResourcePolicies = map[string][]*nmdata.Policy{} + for i := range 3 { + res := &nmdata.NetworkResource{ID: fmt.Sprintf("res-%d", i), NetworkID: "net-1", Enabled: true} + nmd.NetworkResources = append(nmd.NetworkResources, res) + nmd.ResourcePolicies[res.ID] = []*nmdata.Policy{rp} + } + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}, router2.ID: {}}} + nmd.PrecomputePostureValidation() + + first := compute(nmd, targetID) + first.PostureFailedPeers["pc-1"]["mutated"] = struct{}{} + second := compute(nmd, router2.ID) + + for _, c := range []*nbtypes.NetworkMapComponents{first, second} { + assert.Contains(t, peerIDSet(c.Peers), clientOK.ID) + assert.Len(t, c.ResourcePoliciesMap, 3) + } + assert.Equal(t, map[string]map[string]struct{}{"pc-1": {clientFail.ID: {}}}, second.PostureFailedPeers, + "a component's failure set must not alias the shared per-policy result") +} + +func BenchmarkGetPeerNetworkMapComponents_RouterManyResources(b *testing.B) { + const ( + peerCount = 2000 + groupCount = 4 + resourceCount = 200 + routerCount = 10 + ) + peers := make([]*nmdata.Peer, 0, peerCount) + for i := range peerCount { + p := newPeer(fmt.Sprintf("peer-%d", i), byte(i)) + if i%3 == 0 { + p.Meta.WtVersion = failingVersion + } + peers = append(peers, p) + } + nmd := newNMD(peers...) + addVersionCheck(nmd, "pc-1", postureMinVersion) + sourceGroups := make([]string, 0, groupCount) + for g := range groupCount { + id := fmt.Sprintf("g-src-%d", g) + var members []string + for i := g; i < peerCount; i += groupCount { + members = append(members, peers[i].ID) + } + addGroup(nmd, id, members...) + sourceGroups = append(sourceGroups, id) + } + rp := newPolicy("rp-1", newRule(sourceGroups, []string{"g-resource"})) + rp.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = []*nmdata.Policy{rp} + nmd.ResourcePolicies = map[string][]*nmdata.Policy{} + for i := range resourceCount { + res := &nmdata.NetworkResource{ID: fmt.Sprintf("res-%d", i), NetworkID: "net-1", Enabled: true} + nmd.NetworkResources = append(nmd.NetworkResources, res) + nmd.ResourcePolicies[res.ID] = []*nmdata.Policy{rp} + } + routers := make(map[string]*nmdata.NetworkRouter, routerCount) + for i := range routerCount { + routers[peers[i].ID] = &nmdata.NetworkRouter{} + } + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": routers} + nmd.PrecomputePostureValidation() + + b.ReportAllocs() + for b.Loop() { + for routerID := range routers { + compute(nmd, routerID) + } + } +} + +// newPolicyFixture builds an account with ACL policies over overlapping groups, the +// All group, peer resources and posture checks, plus a routed resource policy. +func newPolicyFixture(peerCount, policyCount int) *networkmap.NetworkMapData { + peers := make([]*nmdata.Peer, 0, peerCount) + allIDs := make([]string, 0, peerCount) + for i := range peerCount { + p := newPeer(fmt.Sprintf("peer-%d", i), byte(i)) + if i%5 == 0 { + p.Meta.WtVersion = failingVersion + } + peers = append(peers, p) + allIDs = append(allIDs, p.ID) + } + nmd := newNMD(peers...) + delete(nmd.ValidatedPeers, peers[peerCount-1].ID) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addVersionCheck(nmd, "pc-2", "0.0.1") + addGroup(nmd, "g-all", allIDs...).Name = nmdata.GroupAllName + for g := range 8 { + var members []string + for i := g; i < peerCount; i += 3 + g { + members = append(members, peers[i].ID) + } + addGroup(nmd, fmt.Sprintf("g-%d", g), members...) + } + for i := range policyCount { + rule := newRule([]string{fmt.Sprintf("g-%d", i%8), fmt.Sprintf("g-%d", (i+3)%8)}, []string{fmt.Sprintf("g-%d", (i+1)%8)}) + switch i % 4 { + case 1: + rule.Sources = []string{fmt.Sprintf("g-%d", i%8), "g-all"} + case 2: + rule.SourceResource = nmdata.Resource{ID: peers[i%peerCount].ID, Type: string(nbtypes.ResourceTypePeer)} + } + policy := newPolicy(fmt.Sprintf("p-%d", i), rule) + if i%2 == 0 { + policy.SourcePostureChecks = []string{"pc-2", "pc-1"} + } + nmd.Policies = append(nmd.Policies, policy) + } + rp := newPolicy("rp-1", newRule([]string{"g-1", "g-2"}, []string{"g-resource"})) + rp.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = append(nmd.Policies, rp) + nmd.NetworkResources = []*nmdata.NetworkResource{{ID: "res-1", NetworkID: "net-1", Enabled: true}} + nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}} + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {peers[0].ID: {}, peers[1].ID: {}}} + return nmd +} + +func TestGetPeerNetworkMapComponents_SharedAdmissionsMatchFreshData(t *testing.T) { + const peerCount = 60 + expected := make(map[string]*nbtypes.NetworkMapComponents, peerCount) + for i := range peerCount { + peerID := fmt.Sprintf("peer-%d", i) + expected[peerID] = compute(newPolicyFixture(peerCount, 24), peerID) + } + + shared := newPolicyFixture(peerCount, 24) + got := make([]*nbtypes.NetworkMapComponents, peerCount) + var wg sync.WaitGroup + for i := peerCount - 1; i >= 0; i-- { + wg.Go(func() { + got[i] = compute(shared, fmt.Sprintf("peer-%d", i)) + }) + } + wg.Wait() + + for i, c := range got { + peerID := fmt.Sprintf("peer-%d", i) + want := expected[peerID] + assert.ElementsMatch(t, peerIDSet(want.Peers), peerIDSet(c.Peers), peerID) + assert.ElementsMatch(t, groupIDSet(want.Groups), groupIDSet(c.Groups), peerID) + assert.ElementsMatch(t, policyIDs(want.Policies), policyIDs(c.Policies), peerID) + assert.Equal(t, want.PostureFailedPeers, c.PostureFailedPeers, peerID) + } +} + +func BenchmarkGetPeerNetworkMapComponents_AllPeersPosturePolicies(b *testing.B) { + const peerCount = 2000 + b.ReportAllocs() + for b.Loop() { + b.StopTimer() + nmd := newPolicyFixture(peerCount, 50) + b.StartTimer() + for i := range peerCount { + compute(nmd, fmt.Sprintf("peer-%d", i)) + } + } +} + +func TestGetPeerNetworkMapComponents_SeededPostureValidation(t *testing.T) { + target := newPeer(targetID, 1) + src := newPeer("peer-src", 2) + src.Meta.WtVersion = failingVersion + nmd := newNMD(target, src) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addGroup(nmd, "g-src", src.ID) + addGroup(nmd, "g-dst", targetID) + policy := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"})) + policy.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = []*nmdata.Policy{policy} + nmd.PostureValidation = map[string]map[string]bool{"pc-1": {src.ID: true}} + + c := compute(nmd, targetID) + + assert.Contains(t, peerIDSet(c.Peers), src.ID, "the seeded verdict wins over evaluating the check") + assert.Empty(t, c.PostureFailedPeers) +} + +// newClientsBehindRouterFixture builds clients that reach a routed network resource +// and the routing peer through policies with posture checks a fifth of them fail. +func newClientsBehindRouterFixture(clientCount int) (*networkmap.NetworkMapData, []string) { + router := newPeer("peer-router", 1) + peers := []*nmdata.Peer{router} + clientIDs := make([]string, 0, clientCount) + for i := range clientCount { + p := newPeer(fmt.Sprintf("peer-client-%d", i), byte(i)) + if i%5 == 0 { + p.Meta.WtVersion = failingVersion + } + peers = append(peers, p) + clientIDs = append(clientIDs, p.ID) + } + nmd := newNMD(peers...) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addGroup(nmd, "g-clients", clientIDs...) + addGroup(nmd, "g-routers", router.ID) + addGroup(nmd, "g-resource") + acl := newPolicy("p-acl", newRule([]string{"g-clients"}, []string{"g-routers"})) + acl.SourcePostureChecks = []string{"pc-1"} + rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"})) + rp.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = []*nmdata.Policy{acl, rp} + nmd.ResourcePolicies = map[string][]*nmdata.Policy{} + for i := range 5 { + res := &nmdata.NetworkResource{ID: fmt.Sprintf("res-%d", i), NetworkID: "net-1", Enabled: true} + nmd.NetworkResources = append(nmd.NetworkResources, res) + nmd.ResourcePolicies[res.ID] = []*nmdata.Policy{rp} + } + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {router.ID: {}}} + return nmd, append(clientIDs, router.ID) +} + +func BenchmarkGetPeerNetworkMapComponents_ClientsBehindOneRouter(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + b.StopTimer() + nmd, peerIDs := newClientsBehindRouterFixture(10000) + b.StartTimer() + for _, peerID := range peerIDs { + compute(nmd, peerID) + } + } +} diff --git a/shared/management/networkmap/networkmapdata.go b/shared/management/networkmap/networkmapdata.go index 4e0e1c420..cf96de3a0 100644 --- a/shared/management/networkmap/networkmapdata.go +++ b/shared/management/networkmap/networkmapdata.go @@ -25,10 +25,11 @@ type NetworkMapData struct { //nolint:revive // established name across the code PostureChecks map[string]*nmdata.PostureChecks - // PostureValidation holds the precomputed posture-check results, keyed by - // posture check ID then peer ID. Filled by PrecomputePostureValidation; a + // PostureValidation optionally seeds the posture-check results, keyed by + // posture check ID then peer ID. May be filled by PrecomputePostureValidation; a // present but nil inner map marks a check ID that resolves to no posture - // check, which the calc treats as passing. + // check, which the calc treats as passing. Checks it lacks are evaluated + // on first use. PostureValidation map[string]map[string]bool AllowedUserIDs map[string]struct{} @@ -62,6 +63,9 @@ type NetworkMapData struct { //nolint:revive // established name across the code peerGroupsIdx map[string]map[string]struct{} proxyPoliciesOnce sync.Once + + admissionsOnce sync.Once + admissionsIdx *admissionCache } // AppliedZoneCandidate is an account-level custom DNS zone reduced to the diff --git a/shared/management/networkmap/nmdata/posture_certificate.go b/shared/management/networkmap/nmdata/posture_certificate.go index 37cfd4b5b..854783c08 100644 --- a/shared/management/networkmap/nmdata/posture_certificate.go +++ b/shared/management/networkmap/nmdata/posture_certificate.go @@ -1,6 +1,8 @@ package nmdata import ( + "crypto/x509" + "sync" "time" "github.com/netbirdio/netbird/shared/management/certposture" @@ -9,8 +11,21 @@ import ( // CertificateCheck is the slim twin of posture.CertificateCheck. type CertificateCheck struct { CACertificates []string + + rootsOnce sync.Once + roots *x509.CertPool + rootsErr error } func (c *CertificateCheck) check(peer *Peer) (bool, error) { - return certposture.AnyChainMatchesCAs(peer.Meta.Certificates, c.CACertificates, time.Now()), nil + if len(peer.Meta.Certificates) == 0 { + return false, nil + } + c.rootsOnce.Do(func() { + c.roots, c.rootsErr = certposture.ParseCAs(c.CACertificates) + }) + if c.rootsErr != nil { + return false, c.rootsErr + } + return certposture.AnyChainMatchesPool(peer.Meta.Certificates, c.roots, time.Now()), nil } diff --git a/shared/management/networkmap/nmdata/posture_certificate_test.go b/shared/management/networkmap/nmdata/posture_certificate_test.go index aa67dab77..3d808c331 100644 --- a/shared/management/networkmap/nmdata/posture_certificate_test.go +++ b/shared/management/networkmap/nmdata/posture_certificate_test.go @@ -26,3 +26,27 @@ func TestCertificateCheck_Check(t *testing.T) { otherOnly := bundle(ChecksDefinition{CertificateCheck: &CertificateCheck{CACertificates: []string{otherCA.PEM}}}) assert.False(t, otherOnly[0].Passes(with)) } + +func TestCertificateCheck_ReusesParsedCAs(t *testing.T) { + ca := certtest.NewCA(t, "corp-root") + chain := certposture.EncodeChainPEM([]*x509.Certificate{ca.Issue(t, certtest.ECDSAKey(t), "device")}) + with := &Peer{Meta: PeerSystemMeta{Certificates: []string{chain}}} + + check := &CertificateCheck{CACertificates: []string{ca.PEM}} + for range 3 { + valid, err := check.check(with) + assert.NoError(t, err) + assert.True(t, valid) + } + + invalid := &CertificateCheck{CACertificates: []string{"not a pem"}} + for range 2 { + valid, err := invalid.check(with) + assert.ErrorIs(t, err, certposture.ErrNoCertificateInPEM) + assert.False(t, valid) + } + + valid, err := invalid.check(&Peer{}) + assert.NoError(t, err) + assert.False(t, valid, "a peer without certificates fails before the CAs are parsed") +} diff --git a/shared/management/networkmap/nmdata/posture_nb_version.go b/shared/management/networkmap/nmdata/posture_nb_version.go index 3d82a4c80..c958666ff 100644 --- a/shared/management/networkmap/nmdata/posture_nb_version.go +++ b/shared/management/networkmap/nmdata/posture_nb_version.go @@ -2,6 +2,7 @@ package nmdata import ( "strings" + "sync" "github.com/hashicorp/go-version" ) @@ -9,27 +10,26 @@ import ( // NBVersionCheck is the slim twin of posture.NBVersionCheck. type NBVersionCheck struct { MinVersion string + + constraintOnce sync.Once + constraint version.Constraints + constraintErr error } func (n *NBVersionCheck) check(peer *Peer) (bool, error) { - return meetsMinVersion(n.MinVersion, peer.Meta.WtVersion) -} - -func meetsMinVersion(minVer, peerVer string) (bool, error) { - peerVer = sanitizeVersion(peerVer) - minVer = sanitizeVersion(minVer) - - peerNBVer, err := version.NewVersion(peerVer) + peerNBVer, err := version.NewVersion(sanitizeVersion(peer.Meta.WtVersion)) if err != nil { return false, err } - constraints, err := version.NewConstraint(">= " + minVer) - if err != nil { - return false, err + n.constraintOnce.Do(func() { + n.constraint, n.constraintErr = version.NewConstraint(">= " + sanitizeVersion(n.MinVersion)) + }) + if n.constraintErr != nil { + return false, n.constraintErr } - return constraints.Check(peerNBVer), nil + return n.constraint.Check(peerNBVer), nil } func sanitizeVersion(v string) string {