diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b15185198..b335aad72 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -161,6 +161,8 @@ jobs:
${{ runner.os }}-go-releaser-
- name: Install modules
run: go mod tidy
+ - name: run openapi generator
+ run: bash shared/management/http/api/generate.sh
- name: check git status
run: git --no-pager diff --exit-code
- name: Set up QEMU
diff --git a/client/cmd/debug.go b/client/cmd/debug.go
index 02a742b28..bc7b0e98c 100644
--- a/client/cmd/debug.go
+++ b/client/cmd/debug.go
@@ -3,12 +3,14 @@ package cmd
import (
"context"
"fmt"
+ "os/user"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"google.golang.org/grpc/status"
+ "google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/durationpb"
"github.com/netbirdio/netbird/client/internal"
@@ -85,6 +87,73 @@ var persistenceCmd = &cobra.Command{
RunE: setSyncResponsePersistence,
}
+var debugConfigCmd = &cobra.Command{
+ Use: "config",
+ Example: " netbird debug config",
+ Short: "Dump the effective configuration",
+ Long: "Prints the daemon's resolved configuration (after applying defaults, file, env, CLI input, and MDM policy overrides) as JSON. Includes the list of MDM-managed fields.",
+ RunE: debugConfigDump,
+}
+
+// debugConfigDump implements `netbird debug config`. It resolves the
+// active profile, queries the daemon for the effective configuration
+// via GetConfig, and prints the resulting GetConfigResponse as JSON
+// (via protojson with EmitUnpopulated=true so the output is stable
+// across runs and includes zero-valued fields).
+//
+// Useful for verifying MDM enforcement end-to-end: the response's
+// mDMManagedFields array is the single source of truth for "which
+// fields is the daemon currently enforcing from the MDM source", and
+// every config field side-by-side with that list confirms the merge
+// result. Secrets in the response (e.g. PreSharedKey) are already
+// redacted by the daemon-side handler.
+func debugConfigDump(cmd *cobra.Command, _ []string) error {
+ pm := profilemanager.NewProfileManager()
+ activeProf, err := pm.GetActiveProfile()
+ if err != nil {
+ return fmt.Errorf("get active profile: %v", err)
+ }
+ currUser, err := user.Current()
+ if err != nil {
+ return fmt.Errorf("get current user: %v", err)
+ }
+
+ conn, err := getClient(cmd)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err := conn.Close(); err != nil {
+ log.Errorf(errCloseConnection, err)
+ }
+ }()
+
+ client := proto.NewDaemonServiceClient(conn)
+ resp, err := client.GetConfig(cmd.Context(), &proto.GetConfigRequest{
+ ProfileName: activeProf.Name,
+ Username: currUser.Username,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to get config: %v", status.Convert(err).Message())
+ }
+
+ // Use protojson so well-known fields render correctly; emit defaults so
+ // the operator sees every field even when zero/empty.
+ m := protojson.MarshalOptions{Multiline: true, Indent: " ", EmitUnpopulated: true}
+ out, err := m.Marshal(resp)
+ if err != nil {
+ return fmt.Errorf("marshal config: %w", err)
+ }
+ cmd.Println(string(out))
+ return nil
+}
+
+// debugBundle requests the daemon to create a debug bundle and prints
+// the resulting local file path and, if uploaded, the uploaded file
+// key. It uses the package flags (anonymize, system info, log file
+// count, CLI version, optional upload URL) to configure the bundle
+// request. Returns an error if the RPC fails or if the daemon reports
+// an upload failure reason.
func debugBundle(cmd *cobra.Command, _ []string) error {
conn, err := getClient(cmd)
if err != nil {
diff --git a/client/cmd/kubernetes.go b/client/cmd/kubernetes.go
new file mode 100644
index 000000000..cc91477c6
--- /dev/null
+++ b/client/cmd/kubernetes.go
@@ -0,0 +1,301 @@
+package cmd
+
+import (
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ "github.com/goccy/go-yaml"
+ log "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+const (
+ KubernetesDNSSuffix = "netbird-kubeapi-proxy"
+)
+
+var kubernetesCmd = &cobra.Command{
+ Use: "kubernetes",
+ Short: "Kubernetes cluster commands.",
+ Long: "Kubernetes cluster commands.",
+}
+
+var kubernetesListCmd = &cobra.Command{
+ Use: "list",
+ RunE: kubernetesList,
+ Short: "List Kubernetes clusters.",
+ Long: "List Kubernetes clusters by discovering NetBird peers running netbird-kubeapi-proxy.",
+}
+
+var kubernetesWriteKubeconfigCmd = &cobra.Command{
+ Use: "write-kubeconfig",
+ RunE: kubernetesWriteKubeconfig,
+ Args: cobra.ExactArgs(1),
+ Short: "Write kubeconfig for a Kubernetes cluster.",
+ Long: "Updates kubeconfig in place to allow token-less access to the Kubernetes cluster through NetBird.",
+}
+
+func init() {
+ kubernetesWriteKubeconfigCmd.Flags().String("kubeconfig", "", "path to kubeconfig file")
+}
+
+func kubernetesList(cmd *cobra.Command, _ []string) error {
+ conn, err := getClient(cmd)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+ client := proto.NewDaemonServiceClient(conn)
+ statusResp, err := client.Status(cmd.Context(), &proto.StatusRequest{GetFullPeerStatus: true})
+ if err != nil {
+ return err
+ }
+
+ kcs, err := getKubernetesClusters(cmd.Context(), statusResp.FullStatus.Peers, "")
+ if err != nil {
+ return err
+ }
+ if len(kcs) == 0 {
+ cmd.Println("No Kubernetes clusters available.")
+ return nil
+ }
+ cmd.Println("Available Kubernetes clusters:")
+ for _, k := range kcs {
+ cmd.Printf("\n - Name: %s\n FQDN: %s\n Version: %s\n", k.name, k.url.Host, k.version)
+ }
+ return nil
+}
+
+func kubernetesWriteKubeconfig(cmd *cobra.Command, args []string) error {
+ kubeconfigPath, err := resolveKubeconfigPath(cmd)
+ if err != nil {
+ return err
+ }
+
+ conn, err := getClient(cmd)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+ client := proto.NewDaemonServiceClient(conn)
+ statusResp, err := client.Status(cmd.Context(), &proto.StatusRequest{GetFullPeerStatus: true})
+ if err != nil {
+ return err
+ }
+
+ clusterName := args[0]
+ kcs, err := getKubernetesClusters(cmd.Context(), statusResp.FullStatus.Peers, clusterName)
+ if err != nil {
+ return err
+ }
+ if len(kcs) == 0 {
+ return fmt.Errorf("kubernetes cluster named %s not found", clusterName)
+ }
+ if len(kcs) > 1 {
+ return fmt.Errorf("too many Kubernetes clusters returned")
+ }
+ err = writeKubeconfig(kubeconfigPath, kcs[0])
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+type kubernetesCluster struct {
+ name string
+ url *url.URL
+ version string
+}
+
+func getKubernetesClusters(ctx context.Context, peers []*proto.PeerState, nameFilter string) ([]kubernetesCluster, error) {
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.TLSClientConfig = &tls.Config{
+ InsecureSkipVerify: true,
+ }
+ httpClient := &http.Client{
+ Transport: transport,
+ }
+ resolver := net.Resolver{
+ // Required so both DNS records are returned.
+ // https://github.com/golang/go/issues/17093
+ PreferGo: true,
+ }
+
+ kcs := []kubernetesCluster{}
+ attempted := map[string]struct{}{}
+ for _, peer := range peers {
+ fqdns, err := resolver.LookupAddr(ctx, peer.IP)
+ if err != nil {
+ return nil, err
+ }
+ for _, fqdn := range fqdns {
+ if _, ok := attempted[fqdn]; ok {
+ continue
+ }
+ attempted[fqdn] = struct{}{}
+ comps := strings.Split(fqdn, ".")
+ if len(comps) < 2 {
+ 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)
+ }
+ }
+ return kcs, nil
+}
+
+func fingerprintClusters(ctx context.Context, httpClient *http.Client, fqdn string) (*url.URL, string, error) {
+ clusterURL, err := url.Parse("https://" + fqdn)
+ if err != nil {
+ return nil, "", err
+ }
+ versionURL, err := clusterURL.Parse("/version")
+ if err != nil {
+ return nil, "", err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL.String(), nil)
+ if err != nil {
+ return nil, "", err
+ }
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, "", fmt.Errorf("expected %d response but got %s", http.StatusOK, resp.Status)
+ }
+ b, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, "", err
+ }
+ versionData := map[string]string{}
+ err = json.Unmarshal(b, &versionData)
+ if err != nil {
+ return nil, "", err
+ }
+ version, ok := versionData["gitVersion"]
+ if !ok {
+ return nil, "", errors.New("no version found in response")
+ }
+ return clusterURL, version, nil
+}
+
+func resolveKubeconfigPath(cmd *cobra.Command) (string, error) {
+ if cmd.Flags().Changed("kubeconfig") {
+ path, err := cmd.Flags().GetString("kubeconfig")
+ if err != nil {
+ return "", err
+ }
+ return path, nil
+ }
+ if env := os.Getenv("KUBECONFIG"); env != "" {
+ return env, nil
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("could not determine home directory: %w", err)
+ }
+ return filepath.Join(home, ".kube", "config"), nil
+}
+
+func writeKubeconfig(kubeconfigPath string, kc kubernetesCluster) error {
+ b, err := os.ReadFile(kubeconfigPath)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ var cfg map[string]any
+ if err := yaml.Unmarshal(b, &cfg); err != nil {
+ return err
+ }
+ if cfg == nil {
+ cfg = map[string]any{
+ "apiVersion": "v1",
+ "kind": "Config",
+ }
+ }
+
+ cfg["clusters"] = appendWithName(cfg["clusters"], map[string]any{
+ "name": kc.name,
+ "cluster": map[string]any{
+ "server": kc.url.String(),
+ "insecure-skip-tls-verify": true,
+ },
+ })
+ cfg["users"] = appendWithName(cfg["users"], map[string]any{
+ "name": "netbird",
+ "user": map[string]any{
+ "token": "none",
+ },
+ })
+ cfg["contexts"] = appendWithName(cfg["contexts"], map[string]any{
+ "name": kc.name,
+ "context": map[string]any{
+ "cluster": kc.name,
+ "user": "netbird",
+ "namespace": "default",
+ },
+ })
+ cfg["current-context"] = kc.name
+
+ out, err := yaml.Marshal(cfg)
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(kubeconfigPath, out, 0o600); err != nil {
+ return err
+ }
+ return nil
+}
+
+func appendWithName(data any, add map[string]any) any {
+ if data == nil {
+ return []any{add}
+ }
+ v, ok := data.([]any)
+ if !ok {
+ return []any{add}
+ }
+ i := slices.IndexFunc(v, func(item any) bool {
+ m, ok := item.(map[string]any)
+ if !ok {
+ return false
+ }
+ return m["name"] == add["name"]
+ })
+ if i == -1 {
+ return append(v, add)
+ }
+ v[i] = add
+ return v
+}
diff --git a/client/cmd/kubernetes_test.go b/client/cmd/kubernetes_test.go
new file mode 100644
index 000000000..c40d20996
--- /dev/null
+++ b/client/cmd/kubernetes_test.go
@@ -0,0 +1,120 @@
+package cmd
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/spf13/cobra"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFingerprintClusters(t *testing.T) {
+ t.Parallel()
+
+ srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ //nolint: errcheck
+ w.Write([]byte(`{"gitVersion": "foobar"}`))
+ }))
+ defer srv.Close()
+
+ clusterURL, clusterVersion, err := fingerprintClusters(t.Context(), srv.Client(), srv.Listener.Addr().String())
+ require.NoError(t, err)
+ require.Equal(t, srv.URL, clusterURL.String())
+ require.Equal(t, "foobar", clusterVersion)
+}
+
+func TestResolveKubeconfigPath(t *testing.T) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ t.Fatalf("could not determine home directory: %v", err)
+ }
+ defaultPath := filepath.Join(home, ".kube", "config")
+ path, err := resolveKubeconfigPath(&cobra.Command{})
+ require.NoError(t, err)
+ require.Equal(t, defaultPath, path)
+
+ flagPath := "flag-path"
+ cmd := &cobra.Command{}
+ cmd.Flags().String("kubeconfig", "", "")
+ err = cmd.Flags().Set("kubeconfig", flagPath)
+ require.NoError(t, err)
+ path, err = resolveKubeconfigPath(cmd)
+ require.NoError(t, err)
+ require.Equal(t, flagPath, path)
+
+ envPath := "env-path"
+ t.Setenv("KUBECONFIG", envPath)
+ path, err = resolveKubeconfigPath(&cobra.Command{})
+ require.NoError(t, err)
+ require.Equal(t, envPath, path)
+}
+
+func TestWriteKubeconfig(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ existing string
+ }{
+ {
+ name: "empty file",
+ },
+ {
+ name: "existing content",
+ existing: `apiVersion: v1
+clusters:
+- cluster:
+ insecure-skip-tls-verify: true
+ server: https://foobar.com
+ name: foo
+current-context: test
+kind: Config
+users: []
+`,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ kubeconfigPath := filepath.Join(t.TempDir(), "config")
+ err := os.WriteFile(kubeconfigPath, []byte(tt.existing), 0o644)
+ require.NoError(t, err)
+
+ kc := kubernetesCluster{
+ name: "foo",
+ url: &url.URL{Scheme: "https", Host: "example.com"},
+ }
+ err = writeKubeconfig(kubeconfigPath, kc)
+ require.NoError(t, err)
+
+ b, err := os.ReadFile(kubeconfigPath)
+ require.NoError(t, err)
+ expected := `apiVersion: v1
+clusters:
+- cluster:
+ insecure-skip-tls-verify: true
+ server: https://example.com
+ name: foo
+contexts:
+- context:
+ cluster: foo
+ namespace: default
+ user: netbird
+ name: foo
+current-context: foo
+kind: Config
+users:
+- name: netbird
+ user:
+ token: none
+`
+ require.Equal(t, expected, string(b))
+ })
+ }
+
+}
diff --git a/client/cmd/root.go b/client/cmd/root.go
index 7d43d41f3..f3fde2f1c 100644
--- a/client/cmd/root.go
+++ b/client/cmd/root.go
@@ -95,7 +95,9 @@ var (
}
)
-// Execute executes the root command.
+// Execute runs the appropriate Cobra command for the CLI.
+// If the process is the update binary it delegates to updateCmd; otherwise it runs the root command.
+// It returns any error produced during command execution.
func Execute() error {
if isUpdateBinary() {
return updateCmd.Execute()
@@ -103,6 +105,16 @@ func Execute() error {
return rootCmd.Execute()
}
+// init initialises package-level defaults and configures the root
+// Cobra command tree. Sets platform-specific config / log directory
+// paths (including legacy Wiretrustee fallbacks) and a default daemon
+// address; registers persistent CLI flags (daemon address,
+// management / admin URLs, logging, setup key (file and inline,
+// mutually exclusive), preshared key, hostname, anonymise, config
+// path); attaches top-level and nested subcommands to the root
+// command; and registers `up`-specific persistent flags (external IP
+// maps, custom DNS resolver address, Rosenpass options, auto-connect
+// disabling, lazy connection).
func init() {
defaultConfigPathDir = "/etc/netbird/"
defaultLogFileDir = "/var/log/netbird/"
@@ -168,6 +180,12 @@ func init() {
logCmd.AddCommand(logLevelCmd)
debugCmd.AddCommand(forCmd)
debugCmd.AddCommand(persistenceCmd)
+ debugCmd.AddCommand(debugConfigCmd)
+
+ // kubernetes commands
+ rootCmd.AddCommand(kubernetesCmd)
+ kubernetesCmd.AddCommand(kubernetesListCmd)
+ kubernetesCmd.AddCommand(kubernetesWriteKubeconfigCmd)
// profile commands
profileCmd.AddCommand(profileListCmd)
diff --git a/client/embed/embed.go b/client/embed/embed.go
index 04bc60fb8..0e8991be2 100644
--- a/client/embed/embed.go
+++ b/client/embed/embed.go
@@ -279,6 +279,10 @@ func (c *Client) Start(startCtx context.Context) error {
select {
case <-startCtx.Done():
+ // Cancel the client context before stopping: Engine.Start blocks on the
+ // signal stream while holding the engine mutex and only unblocks on
+ // cancellation. Stopping first would deadlock on that mutex.
+ cancel()
if stopErr := client.Stop(); stopErr != nil {
return fmt.Errorf("stop error after context done. Stop error: %w. Context done: %w", stopErr, startCtx.Err())
}
@@ -442,8 +446,8 @@ func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession,
// IdentityForIP looks up a remote peer by its tunnel IP using the
// embedded client's status recorder. Returns the peer's WireGuard public
-// key and FQDN. ok=false means the IP isn't in this client's peer
-// roster — callers should treat that as "unknown peer".
+// key and FQDN. ok=false means the IP doesn't belong to an active peer
+// — offline roster peers are treated as unknown, same as foreign IPs.
func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
if !ip.IsValid() || c.recorder == nil {
return "", "", false
diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go
new file mode 100644
index 000000000..a2f438975
--- /dev/null
+++ b/client/embed/embed_test.go
@@ -0,0 +1,168 @@
+package embed
+
+import (
+ "context"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc"
+
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
+ "github.com/netbirdio/netbird/management/internals/modules/peers"
+ "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
+ "github.com/netbirdio/netbird/management/internals/server/config"
+ nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+ mgmt "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/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"
+ "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/util"
+)
+
+const testSetupKey = "A2C8E62B-38F5-4553-B31E-DD66C696CEBB"
+
+// TestClientStartTimeoutRollback reproduces a deadlock between Engine.Start and
+// Engine.Stop. The signal endpoint accepts gRPC connections but never serves the
+// SignalExchange service, so Engine.Start parks in WaitStreamConnected while
+// holding the engine mutex. When the Start context expires, the rollback path
+// calls ConnectClient.Stop, which must not block forever acquiring that mutex.
+func TestClientStartTimeoutRollback(t *testing.T) {
+ signalAddr := startBlackholeSignal(t)
+ mgmAddr := startManagement(t, signalAddr)
+
+ wgPort := 0
+ client, err := New(Options{
+ DeviceName: "embed-rollback-test",
+ SetupKey: testSetupKey,
+ ManagementURL: "http://" + mgmAddr,
+ WireguardPort: &wgPort,
+ })
+ require.NoError(t, err, "embed client creation must succeed")
+
+ startCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ startErr := make(chan error, 1)
+ go func() {
+ startErr <- client.Start(startCtx)
+ }()
+
+ select {
+ case err := <-startErr:
+ require.ErrorIs(t, err, context.DeadlineExceeded)
+ case <-time.After(60 * time.Second):
+ t.Fatal("client.Start did not return after its context expired: Engine.Stop deadlocked against Engine.Start waiting for the signal stream")
+ }
+}
+
+// startBlackholeSignal starts a gRPC server without the SignalExchange service
+// registered. Connections succeed, but the signal stream can never be
+// established, which keeps Engine.Start parked in WaitStreamConnected.
+func startBlackholeSignal(t *testing.T) string {
+ t.Helper()
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ require.NoError(t, err)
+
+ s := grpc.NewServer()
+ go func() {
+ if err := s.Serve(lis); err != nil {
+ t.Error(err)
+ }
+ }()
+ t.Cleanup(s.Stop)
+
+ return lis.Addr().String()
+}
+
+func startManagement(t *testing.T, signalAddr string) string {
+ t.Helper()
+
+ cfg := &config.Config{
+ Stuns: []*config.Host{},
+ TURNConfig: &config.TURNConfig{},
+ Relay: &config.Relay{
+ Addresses: []string{"127.0.0.1:1234"},
+ CredentialsTTL: util.Duration{Duration: time.Hour},
+ Secret: "222222222222222222",
+ },
+ Signal: &config.Host{
+ Proto: "http",
+ URI: signalAddr,
+ },
+ Datadir: t.TempDir(),
+ HttpConfig: nil,
+ }
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ require.NoError(t, err)
+
+ s := grpc.NewServer()
+
+ testStore, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", cfg.Datadir)
+ require.NoError(t, err)
+ t.Cleanup(cleanUp)
+
+ eventStore := &activity.InMemoryEventStore{}
+
+ permissionsManager := permissions.NewManager(testStore)
+ peersManager := peers.NewManager(testStore, permissionsManager)
+ jobManager := job.NewJobManager(nil, testStore, peersManager)
+
+ cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
+ require.NoError(t, err)
+
+ iv, err := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
+ require.NoError(t, err)
+ metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
+ require.NoError(t, err)
+
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+ settingsMockManager := settings.NewMockManager(ctrl)
+ settingsMockManager.EXPECT().
+ GetSettings(gomock.Any(), gomock.Any(), gomock.Any()).
+ Return(&types.Settings{}, nil).
+ AnyTimes()
+ settingsMockManager.EXPECT().
+ GetExtraSettings(gomock.Any(), gomock.Any()).
+ Return(&types.ExtraSettings{}, nil).
+ AnyTimes()
+
+ groupsManager := groups.NewManagerMock()
+
+ 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)
+ accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
+ require.NoError(t, err)
+
+ secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, cfg.TURNConfig, cfg.Relay, settingsMockManager, groupsManager)
+ require.NoError(t, err)
+
+ mgmtServer, err := nbgrpc.NewServer(cfg, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &mgmt.MockIntegratedValidator{}, networkMapController, nil, nil)
+ require.NoError(t, err)
+ mgmtProto.RegisterManagementServiceServer(s, mgmtServer)
+
+ go func() {
+ if err := s.Serve(lis); err != nil {
+ t.Error(err)
+ }
+ }()
+ t.Cleanup(s.Stop)
+
+ return lis.Addr().String()
+}
diff --git a/client/firewall/iptables/acl_linux.go b/client/firewall/iptables/acl_linux.go
index e5e19cec9..4b4cebf9c 100644
--- a/client/firewall/iptables/acl_linux.go
+++ b/client/firewall/iptables/acl_linux.go
@@ -3,6 +3,7 @@ package iptables
import (
"errors"
"fmt"
+ "maps"
"net"
"slices"
@@ -421,12 +422,17 @@ func (m *aclManager) updateState() {
currentState.Lock()
defer currentState.Unlock()
+ // Clone the maps so the persisted state holds a private snapshot. The
+ // live maps keep being mutated by subsequent rule operations while the
+ // state manager marshals the state from its periodic-save goroutine.
+ // Sharing them by reference races the two and aborts the process with a
+ // concurrent map iteration and write.
if m.v6 {
- currentState.ACLEntries6 = m.entries
- currentState.ACLIPsetStore6 = m.ipsetStore
+ currentState.ACLEntries6 = maps.Clone(m.entries)
+ currentState.ACLIPsetStore6 = m.ipsetStore.clone()
} else {
- currentState.ACLEntries = m.entries
- currentState.ACLIPsetStore = m.ipsetStore
+ currentState.ACLEntries = maps.Clone(m.entries)
+ currentState.ACLIPsetStore = m.ipsetStore.clone()
}
if err := m.stateManager.UpdateState(currentState); err != nil {
diff --git a/client/firewall/iptables/router_linux.go b/client/firewall/iptables/router_linux.go
index 290e5da1e..42d305f5c 100644
--- a/client/firewall/iptables/router_linux.go
+++ b/client/firewall/iptables/router_linux.go
@@ -4,6 +4,7 @@ package iptables
import (
"fmt"
+ "maps"
"net/netip"
"strconv"
"strings"
@@ -749,11 +750,17 @@ func (r *router) updateState() {
currentState.Lock()
defer currentState.Unlock()
+ // Clone the rule map so the persisted state holds a private snapshot. The
+ // live map keeps being mutated by subsequent rule operations while the
+ // state manager marshals the state from its periodic-save goroutine.
+ // Sharing it by reference races the two and aborts the process with a
+ // concurrent map iteration and write. The ipset counter guards itself
+ // during marshaling, so it can be shared directly.
if r.v6 {
- currentState.RouteRules6 = r.rules
+ currentState.RouteRules6 = maps.Clone(r.rules)
currentState.RouteIPsetCounter6 = r.ipsetCounter
} else {
- currentState.RouteRules = r.rules
+ currentState.RouteRules = maps.Clone(r.rules)
currentState.RouteIPsetCounter = r.ipsetCounter
}
diff --git a/client/firewall/iptables/rulestore_linux.go b/client/firewall/iptables/rulestore_linux.go
index 004c512a4..a6d36540e 100644
--- a/client/firewall/iptables/rulestore_linux.go
+++ b/client/firewall/iptables/rulestore_linux.go
@@ -1,6 +1,9 @@
package iptables
-import "encoding/json"
+import (
+ "encoding/json"
+ "maps"
+)
type ipList struct {
ips map[string]struct{}
@@ -19,6 +22,14 @@ func (s *ipList) addIP(ip string) {
s.ips[ip] = struct{}{}
}
+// clone returns a deep copy of the ipList with its own ips map.
+func (s *ipList) clone() *ipList {
+ if s == nil {
+ return nil
+ }
+ return &ipList{ips: maps.Clone(s.ips)}
+}
+
// MarshalJSON implements json.Marshaler
func (s *ipList) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
@@ -55,6 +66,19 @@ func newIpsetStore() *ipsetStore {
}
}
+// clone returns a deep copy of the ipsetStore with its own ipsets map and
+// independent ipList entries.
+func (s *ipsetStore) clone() *ipsetStore {
+ if s == nil {
+ return nil
+ }
+ cloned := &ipsetStore{ipsets: make(map[string]*ipList, len(s.ipsets))}
+ for name, list := range s.ipsets {
+ cloned.ipsets[name] = list.clone()
+ }
+ return cloned
+}
+
func (s *ipsetStore) ipset(ipsetName string) (*ipList, bool) {
r, ok := s.ipsets[ipsetName]
return r, ok
diff --git a/client/iface/bind/ice_bind.go b/client/iface/bind/ice_bind.go
index bf79ecd79..156450c61 100644
--- a/client/iface/bind/ice_bind.go
+++ b/client/iface/bind/ice_bind.go
@@ -41,7 +41,6 @@ type ICEBind struct {
*wgConn.StdNetBind
transportNet transport.Net
- filterFn udpmux.FilterFn
address wgaddr.Address
mtu uint16
@@ -61,12 +60,11 @@ type ICEBind struct {
ipv6Conn *net.UDPConn
}
-func NewICEBind(transportNet transport.Net, filterFn udpmux.FilterFn, address wgaddr.Address, mtu uint16) *ICEBind {
+func NewICEBind(transportNet transport.Net, address wgaddr.Address, mtu uint16) *ICEBind {
b, _ := wgConn.NewStdNetBind().(*wgConn.StdNetBind)
ib := &ICEBind{
StdNetBind: b,
transportNet: transportNet,
- filterFn: filterFn,
address: address,
mtu: mtu,
endpoints: make(map[netip.Addr]net.Conn),
@@ -265,7 +263,6 @@ func (s *ICEBind) createOrUpdateMux() {
udpmux.UniversalUDPMuxParams{
UDPConn: muxConn,
Net: s.transportNet,
- FilterFn: s.filterFn,
WGAddress: s.address,
MTU: s.mtu,
},
diff --git a/client/iface/bind/ice_bind_test.go b/client/iface/bind/ice_bind_test.go
index f49e68508..0b8db7640 100644
--- a/client/iface/bind/ice_bind_test.go
+++ b/client/iface/bind/ice_bind_test.go
@@ -289,7 +289,7 @@ func setupICEBind(t *testing.T) *ICEBind {
IP: netip.MustParseAddr("100.64.0.1"),
Network: netip.MustParsePrefix("100.64.0.0/10"),
}
- return NewICEBind(transportNet, nil, address, 1280)
+ return NewICEBind(transportNet, address, 1280)
}
func createDualStackConns(t *testing.T) (*net.UDPConn, *net.UDPConn) {
diff --git a/client/iface/device/device_filter.go b/client/iface/device/device_filter.go
index fc1c65efa..7d7493835 100644
--- a/client/iface/device/device_filter.go
+++ b/client/iface/device/device_filter.go
@@ -1,10 +1,13 @@
package device
import (
+ "fmt"
"net/netip"
+ "runtime/debug"
"sync"
"sync/atomic"
+ log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/tun"
)
@@ -41,10 +44,13 @@ type PacketCapture interface {
type FilteredDevice struct {
tun.Device
- filter PacketFilter
- capture atomic.Pointer[PacketCapture]
- mutex sync.RWMutex
- closeOnce sync.Once
+ filter PacketFilter
+ capture atomic.Pointer[PacketCapture]
+ // panicHandler is invoked after a panic in the underlying device is
+ // recovered in Read or Write.
+ panicHandler atomic.Pointer[func()]
+ mutex sync.RWMutex
+ closeOnce sync.Once
}
// newDeviceFilter constructor function
@@ -70,7 +76,7 @@ func (d *FilteredDevice) Close() error {
// Read wraps read method with filtering feature
func (d *FilteredDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, err error) {
- if n, err = d.Device.Read(bufs, sizes, offset); err != nil {
+ if n, err = d.deviceRead(bufs, sizes, offset); err != nil {
return 0, err
}
@@ -112,7 +118,7 @@ func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) {
d.mutex.RUnlock()
if filter == nil {
- return d.Device.Write(bufs, offset)
+ return d.deviceWrite(bufs, offset)
}
filteredBufs := make([][]byte, 0, len(bufs))
@@ -125,9 +131,44 @@ func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) {
}
}
- n, err := d.Device.Write(filteredBufs, offset)
- n += dropped
- return n, err
+ n, err := d.deviceWrite(filteredBufs, offset)
+ if err != nil {
+ return n, err
+ }
+ return n + dropped, nil
+}
+
+// deviceRead calls the underlying device Read, recovering from panics in the
+// 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)
+}
+
+// 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)
+}
+
+// recoverFromPanic converts a panic in the underlying device into a regular
+// error and invokes the registered panic handler. The wintun read path is
+// known to panic on zero-length packets that third-party filter drivers can
+// place in the ring.
+func (d *FilteredDevice) recoverFromPanic(op string, n *int, err *error) {
+ r := recover()
+ if r == nil {
+ return
+ }
+
+ log.Errorf("recovered panic in tun device %s: %v\n%s", op, r, debug.Stack())
+ *n = 0
+ *err = fmt.Errorf("tun device %s panic: %v", op, r)
+
+ if handler := d.panicHandler.Load(); handler != nil {
+ (*handler)()
+ }
}
// SetFilter sets packet filter to device
@@ -137,6 +178,17 @@ func (d *FilteredDevice) SetFilter(filter PacketFilter) {
d.mutex.Unlock()
}
+// SetPanicHandler registers a handler invoked after a recovered panic in Read
+// or Write. The device is unusable after such a panic; the handler should
+// trigger recreation of the interface. Pass nil to remove.
+func (d *FilteredDevice) SetPanicHandler(handler func()) {
+ if handler == nil {
+ d.panicHandler.Store(nil)
+ return
+ }
+ d.panicHandler.Store(&handler)
+}
+
// SetCapture sets or clears the packet capture sink. Pass nil to disable.
// Uses atomic store so the hot path (Read/Write) is a single pointer load
// with no locking overhead when capture is off.
diff --git a/client/iface/device/device_filter_test.go b/client/iface/device/device_filter_test.go
index 8fb16ca8d..0d86c9323 100644
--- a/client/iface/device/device_filter_test.go
+++ b/client/iface/device/device_filter_test.go
@@ -221,3 +221,60 @@ func TestDeviceWrapperRead(t *testing.T) {
}
})
}
+
+func TestDeviceWrapperReadPanic(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ tun := mocks.NewMockDevice(ctrl)
+ tun.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any()).
+ DoAndReturn(func(bufs [][]byte, sizes []int, offset int) (int, error) {
+ // Reproduce the wintun zero-length packet panic (index out of range).
+ packet := make([]byte, 0)
+ return int(packet[0]), nil
+ })
+
+ wrapped := newDeviceFilter(tun)
+
+ handlerCalled := false
+ wrapped.SetPanicHandler(func() { handlerCalled = true })
+
+ n, err := wrapped.Read([][]byte{{}}, []int{0}, 0)
+ if err == nil {
+ t.Errorf("expected error from recovered panic, got nil")
+ }
+ if n != 0 {
+ t.Errorf("expected n=0, got %d", n)
+ }
+ if !handlerCalled {
+ t.Errorf("expected panic handler to be called")
+ }
+}
+
+func TestDeviceWrapperWritePanic(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ tun := mocks.NewMockDevice(ctrl)
+ tun.EXPECT().Write(gomock.Any(), gomock.Any()).
+ DoAndReturn(func(bufs [][]byte, offset int) (int, error) {
+ packet := make([]byte, 0)
+ return int(packet[0]), nil
+ })
+
+ wrapped := newDeviceFilter(tun)
+
+ handlerCalled := false
+ wrapped.SetPanicHandler(func() { handlerCalled = true })
+
+ n, err := wrapped.Write([][]byte{{0x45, 0x00}}, 0)
+ if err == nil {
+ t.Errorf("expected error from recovered panic, got nil")
+ }
+ if n != 0 {
+ t.Errorf("expected n=0, got %d", n)
+ }
+ if !handlerCalled {
+ t.Errorf("expected panic handler to be called")
+ }
+}
diff --git a/client/iface/device/device_kernel_unix.go b/client/iface/device/device_kernel_unix.go
index 25c4148a6..3c429fb96 100644
--- a/client/iface/device/device_kernel_unix.go
+++ b/client/iface/device/device_kernel_unix.go
@@ -32,8 +32,6 @@ type TunKernelDevice struct {
link *wgLink
udpMuxConn net.PacketConn
udpMux *udpmux.UniversalUDPMuxDefault
-
- filterFn udpmux.FilterFn
}
func NewKernelDevice(name string, address wgaddr.Address, wgPort int, key string, mtu uint16, transportNet transport.Net) *TunKernelDevice {
@@ -104,7 +102,6 @@ func (t *TunKernelDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) {
bindParams := udpmux.UniversalUDPMuxParams{
UDPConn: nbnet.WrapPacketConn(rawSock),
Net: t.transportNet,
- FilterFn: t.filterFn,
WGAddress: t.address,
MTU: t.mtu,
}
diff --git a/client/iface/iface.go b/client/iface/iface.go
index 78c5080e7..247f421a2 100644
--- a/client/iface/iface.go
+++ b/client/iface/iface.go
@@ -63,7 +63,6 @@ type WGIFaceOpts struct {
MTU uint16
MobileArgs *device.MobileIFaceArguments
TransportNet transport.Net
- FilterFn udpmux.FilterFn
DisableDNS bool
}
diff --git a/client/iface/iface_new.go b/client/iface/iface_new.go
index 28f350e3f..96a0e670f 100644
--- a/client/iface/iface_new.go
+++ b/client/iface/iface_new.go
@@ -11,7 +11,7 @@ import (
// NewWGIFace Creates a new WireGuard interface instance
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
- iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
+ iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
var tun WGTunDevice
if netstack.IsEnabled() {
diff --git a/client/iface/iface_new_android.go b/client/iface/iface_new_android.go
index e28dcc0de..ce8b4da23 100644
--- a/client/iface/iface_new_android.go
+++ b/client/iface/iface_new_android.go
@@ -9,7 +9,7 @@ import (
// NewWGIFace Creates a new WireGuard interface instance
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
- iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
+ iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
if netstack.IsEnabled() {
wgIFace := &WGIface{
diff --git a/client/iface/iface_new_ios.go b/client/iface/iface_new_ios.go
index 41e0022b2..cedd55ce2 100644
--- a/client/iface/iface_new_ios.go
+++ b/client/iface/iface_new_ios.go
@@ -10,7 +10,7 @@ import (
// NewWGIFace Creates a new WireGuard interface instance
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
- iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
+ iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
wgIFace := &WGIface{
tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, opts.MobileArgs.TunFd),
diff --git a/client/iface/iface_new_linux.go b/client/iface/iface_new_linux.go
index 65ce67e88..2465130e6 100644
--- a/client/iface/iface_new_linux.go
+++ b/client/iface/iface_new_linux.go
@@ -14,7 +14,7 @@ import (
// NewWGIFace Creates a new WireGuard interface instance
func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
if netstack.IsEnabled() {
- iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
+ iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
return &WGIface{
tun: device.NewNetstackDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind, netstack.ListenAddr()),
userspaceBind: true,
@@ -30,7 +30,7 @@ func NewWGIFace(opts WGIFaceOpts) (*WGIface, error) {
}
if device.ModuleTunIsLoaded() {
- iceBind := bind.NewICEBind(opts.TransportNet, opts.FilterFn, opts.Address, opts.MTU)
+ iceBind := bind.NewICEBind(opts.TransportNet, opts.Address, opts.MTU)
return &WGIface{
tun: device.NewTunDevice(opts.IFaceName, opts.Address, opts.WGPort, opts.WGPrivKey, opts.MTU, iceBind),
userspaceBind: true,
diff --git a/client/iface/udpmux/universal.go b/client/iface/udpmux/universal.go
index 89a7eefb9..77e1b1b35 100644
--- a/client/iface/udpmux/universal.go
+++ b/client/iface/udpmux/universal.go
@@ -8,8 +8,6 @@ import (
"context"
"fmt"
"net"
- "net/netip"
- "sync"
"time"
log "github.com/sirupsen/logrus"
@@ -22,10 +20,6 @@ import (
"github.com/netbirdio/netbird/client/iface/wgaddr"
)
-// FilterFn is a function that filters out candidates based on the address.
-// If it returns true, the address is to be filtered. It also returns the prefix of matching route.
-type FilterFn func(address netip.Addr) (bool, netip.Prefix, error)
-
// UniversalUDPMuxDefault handles STUN and TURN servers packets by wrapping the original UDPConn
// It then passes packets to the UDPMux that does the actual connection muxing.
type UniversalUDPMuxDefault struct {
@@ -43,7 +37,6 @@ type UniversalUDPMuxParams struct {
UDPConn net.PacketConn
XORMappedAddrCacheTTL time.Duration
Net transport.Net
- FilterFn FilterFn
WGAddress wgaddr.Address
MTU uint16
}
@@ -68,7 +61,6 @@ func NewUniversalUDPMuxDefault(params UniversalUDPMuxParams) *UniversalUDPMuxDef
PacketConn: params.UDPConn,
mux: m,
logger: params.Logger,
- filterFn: params.FilterFn,
address: params.WGAddress,
}
@@ -115,15 +107,12 @@ func (m *UniversalUDPMuxDefault) ReadFromConn(ctx context.Context) {
}
}
-// UDPConn is a wrapper around UDPMux conn that overrides ReadFrom and handles STUN/TURN packets
+// UDPConn is a wrapper around UDPMux conn that overrides WriteTo to drop packets destined for the overlay subnet.
type UDPConn struct {
net.PacketConn
- mux *UniversalUDPMuxDefault
- logger logging.LeveledLogger
- filterFn FilterFn
- // TODO: reset cache on route changes
- addrCache sync.Map
- address wgaddr.Address
+ mux *UniversalUDPMuxDefault
+ logger logging.LeveledLogger
+ address wgaddr.Address
}
// GetPacketConn returns the underlying PacketConn
@@ -132,65 +121,16 @@ func (u *UDPConn) GetPacketConn() net.PacketConn {
}
func (u *UDPConn) WriteTo(b []byte, addr net.Addr) (int, error) {
- if u.filterFn == nil {
+ udpAddr, ok := addr.(*net.UDPAddr)
+ if !ok {
return u.PacketConn.WriteTo(b, addr)
}
-
- if isRouted, found := u.addrCache.Load(addr.String()); found {
- return u.handleCachedAddress(isRouted.(bool), b, addr)
- }
-
- return u.handleUncachedAddress(b, addr)
-}
-
-func (u *UDPConn) handleCachedAddress(isRouted bool, b []byte, addr net.Addr) (int, error) {
- if isRouted {
- return 0, fmt.Errorf("address %s is part of a routed network, refusing to write", addr)
- }
- return u.PacketConn.WriteTo(b, addr)
-}
-
-func (u *UDPConn) handleUncachedAddress(b []byte, addr net.Addr) (int, error) {
- if err := u.performFilterCheck(addr); err != nil {
- return 0, err
- }
- return u.PacketConn.WriteTo(b, addr)
-}
-
-func (u *UDPConn) performFilterCheck(addr net.Addr) error {
- host, err := getHostFromAddr(addr)
- if err != nil {
- log.Errorf("Failed to get host from address %s: %v", addr, err)
- return nil
- }
-
- a, err := netip.ParseAddr(host)
- if err != nil {
- log.Errorf("Failed to parse address %s: %v", addr, err)
- return nil
- }
-
- if u.address.Network.Contains(a) {
+ dst := udpAddr.AddrPort().Addr().Unmap()
+ if (u.address.Network.IsValid() && u.address.Network.Contains(dst)) || (u.address.IPv6Net.IsValid() && u.address.IPv6Net.Contains(dst)) {
log.Warnf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
- return fmt.Errorf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
+ return 0, fmt.Errorf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
}
-
- if isRouted, prefix, err := u.filterFn(a); err != nil {
- log.Errorf("Failed to check if address %s is routed: %v", addr, err)
- } else {
- u.addrCache.Store(addr.String(), isRouted)
- if isRouted {
- // Extra log, as the error only shows up with ICE logging enabled
- log.Infof("address %s is part of routed network %s, refusing to write", addr, prefix)
- return fmt.Errorf("address %s is part of routed network %s, refusing to write", addr, prefix)
- }
- }
- return nil
-}
-
-func getHostFromAddr(addr net.Addr) (string, error) {
- host, _, err := net.SplitHostPort(addr.String())
- return host, err
+ return u.PacketConn.WriteTo(b, addr)
}
// GetSharedConn returns the shared udp conn
@@ -225,6 +165,13 @@ func (m *UniversalUDPMuxDefault) HandleSTUNMessage(msg *stun.Message, addr net.A
return nil
}
+ src := udpAddr.AddrPort().Addr().Unmap()
+ wg := m.params.WGAddress
+ if (wg.Network.IsValid() && wg.Network.Contains(src)) || (wg.IPv6Net.IsValid() && wg.IPv6Net.Contains(src)) {
+ log.Debugf("dropping STUN message from overlay source %s", udpAddr)
+ return nil
+ }
+
if m.isXORMappedResponse(msg, udpAddr.String()) {
err := m.handleXORMappedResponse(udpAddr, msg)
if err != nil {
diff --git a/client/iface/wgproxy/proxy_linux_test.go b/client/iface/wgproxy/proxy_linux_test.go
index dd24d1cdc..7f7abcb4a 100644
--- a/client/iface/wgproxy/proxy_linux_test.go
+++ b/client/iface/wgproxy/proxy_linux_test.go
@@ -66,7 +66,7 @@ func seedProxyForProxyCloseByRemoteConn() ([]proxyInstance, error) {
if err != nil {
return nil, err
}
- iceBind := bind.NewICEBind(nil, nil, wgAddress, 1280)
+ iceBind := bind.NewICEBind(nil, wgAddress, 1280)
endpointAddress := &net.UDPAddr{
IP: net.IPv4(10, 0, 0, 1),
Port: 1234,
diff --git a/client/iface/wgproxy/proxy_seed_test.go b/client/iface/wgproxy/proxy_seed_test.go
index ad375ccde..9278029a5 100644
--- a/client/iface/wgproxy/proxy_seed_test.go
+++ b/client/iface/wgproxy/proxy_seed_test.go
@@ -22,7 +22,7 @@ func seedProxyForProxyCloseByRemoteConn() ([]proxyInstance, error) {
if err != nil {
return nil, err
}
- iceBind := bind.NewICEBind(nil, nil, wgAddress, 1280)
+ iceBind := bind.NewICEBind(nil, wgAddress, 1280)
endpointAddress := &net.UDPAddr{
IP: net.IPv4(10, 0, 0, 1),
Port: 1234,
diff --git a/client/internal/connect.go b/client/internal/connect.go
index e38bc2f58..d93b62bb5 100644
--- a/client/internal/connect.go
+++ b/client/internal/connect.go
@@ -118,6 +118,8 @@ func (c *ConnectClient) RunOniOS(
networkChangeListener listener.NetworkChangeListener,
dnsManager dns.IosDnsManager,
stateFilePath string,
+ cacheDir string,
+ logFilePath string,
) error {
// Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension.
debug.SetGCPercent(5)
@@ -127,8 +129,9 @@ func (c *ConnectClient) RunOniOS(
NetworkChangeListener: networkChangeListener,
DnsManager: dnsManager,
StateFilePath: stateFilePath,
+ TempDir: cacheDir,
}
- return c.run(mobileDependency, nil, "")
+ return c.run(mobileDependency, nil, logFilePath)
}
func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan struct{}, logPath string) error {
diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go
index 9ab18dd80..a65d8bd05 100644
--- a/client/internal/debug/debug.go
+++ b/client/internal/debug/debug.go
@@ -250,6 +250,7 @@ type BundleGenerator struct {
syncResponse *mgmProto.SyncResponse
logPath string
tempDir string
+ statePath string
cpuProfile []byte
capturePath string
refreshStatus func() // Optional callback to refresh status before bundle generation
@@ -276,6 +277,7 @@ type GeneratorDependencies struct {
SyncResponse *mgmProto.SyncResponse
LogPath string
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
+ StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
CPUProfile []byte
CapturePath string
RefreshStatus func()
@@ -299,6 +301,7 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
syncResponse: deps.SyncResponse,
logPath: deps.LogPath,
tempDir: deps.TempDir,
+ statePath: deps.StatePath,
cpuProfile: deps.CPUProfile,
capturePath: deps.CapturePath,
refreshStatus: deps.RefreshStatus,
@@ -516,6 +519,14 @@ func (g *BundleGenerator) addConfig() error {
}
}
+ // Surface the set of MDM-enforced keys so a support engineer reading
+ // the bundle can tell which field values are user-set vs MDM-overridden.
+ // Same semantics as the mDMManagedFields list returned by the
+ // GetConfig RPC consumed by `netbird debug config`.
+ if managed := g.internalConfig.Policy().ManagedKeys(); len(managed) > 0 {
+ configContent.WriteString(fmt.Sprintf("MDMManagedFields: %v\n", managed))
+ }
+
configReader := strings.NewReader(configContent.String())
if err := g.addFileToZip(configReader, "config.txt"); err != nil {
return fmt.Errorf("add config file to zip: %w", err)
@@ -842,8 +853,11 @@ func (g *BundleGenerator) maskSecrets() {
}
func (g *BundleGenerator) addStateFile() error {
- sm := profilemanager.NewServiceManager("")
- path := sm.GetStatePath()
+ path := g.statePath
+ if path == "" {
+ sm := profilemanager.NewServiceManager("")
+ path = sm.GetStatePath()
+ }
if path == "" {
return nil
}
diff --git a/client/internal/debug/debug_ios.go b/client/internal/debug/debug_ios.go
new file mode 100644
index 000000000..a07c23dbd
--- /dev/null
+++ b/client/internal/debug/debug_ios.go
@@ -0,0 +1,36 @@
+//go:build ios
+
+package debug
+
+import (
+ "path/filepath"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// swiftLogFile is the Swift app log written by the iOS app into the same log
+// directory as the Go client log, so it can be collected into the bundle.
+const swiftLogFile = "swift-log.log"
+
+// addPlatformLog collects logs for the iOS debug bundle. iOS has no logcat or
+// systemd journal, so we rely on file-based logs. addLogfile handles the Go
+// client log (logPath) with rotation, the stderr/stdout companions and
+// anonymization. The iOS app writes its own Swift log into the same directory,
+// so we add it alongside the Go log.
+func (g *BundleGenerator) addPlatformLog() error {
+ if err := g.addLogfile(); err != nil {
+ return err
+ }
+
+ if g.logPath == "" {
+ return nil
+ }
+
+ swiftLogPath := filepath.Join(filepath.Dir(g.logPath), swiftLogFile)
+ if err := g.addSingleLogfile(swiftLogPath, swiftLogFile); err != nil {
+ // The Swift log is best-effort: the app may not have written it yet.
+ log.Warnf("failed to add %s to debug bundle: %v", swiftLogFile, err)
+ }
+
+ return nil
+}
diff --git a/client/internal/debug/debug_nonandroid.go b/client/internal/debug/debug_nonandroid.go
index 117238dec..2dfca6ddc 100644
--- a/client/internal/debug/debug_nonandroid.go
+++ b/client/internal/debug/debug_nonandroid.go
@@ -1,4 +1,4 @@
-//go:build !android
+//go:build !android && !ios
package debug
diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go
index 196a1da2b..ca7785d35 100644
--- a/client/internal/debug/debug_test.go
+++ b/client/internal/debug/debug_test.go
@@ -844,6 +844,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
"SSHKey": "sensitive: SSH private key",
"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",
}
mURL, _ := url.Parse("https://api.example.com:443")
diff --git a/client/internal/dns/local/local.go b/client/internal/dns/local/local.go
index d13aa672e..d0268186c 100644
--- a/client/internal/dns/local/local.go
+++ b/client/internal/dns/local/local.go
@@ -482,7 +482,7 @@ func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16,
// completely when every proxy peer is offline (the upstream may still
// be reachable some other way, or the peerstore may be stale).
func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns.Question, records []dns.RR) []dns.RR {
- if len(records) == 0 {
+ if len(records) < 2 {
return records
}
d.mu.RLock()
diff --git a/client/internal/dns/local/local_test.go b/client/internal/dns/local/local_test.go
index fdf7f2659..9b7dac231 100644
--- a/client/internal/dns/local/local_test.go
+++ b/client/internal/dns/local/local_test.go
@@ -2738,6 +2738,17 @@ func TestLocalResolver_FilterDisconnectedPeerAnswers(t *testing.T) {
connByIP: nil,
wantInOrder: []string{"100.64.0.10", "100.64.0.11"},
},
+ {
+ // A single answer is never filtered: dropping it would only
+ // trigger the empty-answer escape hatch, so the fast path
+ // returns it untouched.
+ name: "single disconnected answer passes through",
+ records: []nbdns.SimpleRecord{disconnectedRec},
+ connByIP: map[string]ipState{
+ "100.64.0.11": {known: true, connected: false},
+ },
+ wantInOrder: []string{"100.64.0.11"},
+ },
}
for _, tc := range tests {
diff --git a/client/internal/dns/resutil/resolve.go b/client/internal/dns/resutil/resolve.go
index 5a3744719..07a70d6d1 100644
--- a/client/internal/dns/resutil/resolve.go
+++ b/client/internal/dns/resutil/resolve.go
@@ -14,6 +14,10 @@ import (
log "github.com/sirupsen/logrus"
)
+// errNoSuitableAddress mirrors the unexported error string the net package
+// uses when a resolved host has no addresses of the requested family.
+const errNoSuitableAddress = "no suitable address found"
+
// GenerateRequestID creates a random 8-character hex string for request tracing.
func GenerateRequestID() string {
bytes := make([]byte, 4)
@@ -126,6 +130,14 @@ func LookupIP(ctx context.Context, r resolver, network, host string, qtype uint1
}
func getRcodeForError(ctx context.Context, r resolver, host string, qtype uint16, err error) int {
+ // The net package returns this AddrError when the host resolves but has
+ // no addresses of the requested family. The domain exists, so answer
+ // NODATA instead of SERVFAIL.
+ var addrErr *net.AddrError
+ if errors.As(err, &addrErr) && addrErr.Err == errNoSuitableAddress {
+ return dns.RcodeSuccess
+ }
+
var dnsErr *net.DNSError
if !errors.As(err, &dnsErr) {
return dns.RcodeServerFailure
diff --git a/client/internal/dns/resutil/resolve_test.go b/client/internal/dns/resutil/resolve_test.go
new file mode 100644
index 000000000..432367c22
--- /dev/null
+++ b/client/internal/dns/resutil/resolve_test.go
@@ -0,0 +1,122 @@
+package resutil
+
+import (
+ "context"
+ "errors"
+ "net"
+ "net/netip"
+ "testing"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type mockResolver struct {
+ // results maps network ("ip4"/"ip6") to the lookup outcome.
+ results map[string]mockLookup
+}
+
+type mockLookup struct {
+ ips []netip.Addr
+ err error
+}
+
+func (m *mockResolver) LookupNetIP(_ context.Context, network, _ string) ([]netip.Addr, error) {
+ res, ok := m.results[network]
+ if !ok {
+ return nil, errors.New("unexpected network: " + network)
+ }
+ return res.ips, res.err
+}
+
+func TestLookupIP_Success(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip4": {ips: []netip.Addr{netip.MustParseAddr("::ffff:192.0.2.1")}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
+
+ assert.Equal(t, dns.RcodeSuccess, result.Rcode, "successful lookup should return NOERROR")
+ require.Len(t, result.IPs, 1, "should return the resolved address")
+ assert.Equal(t, netip.MustParseAddr("192.0.2.1"), result.IPs[0], "v4-mapped address should be unmapped")
+}
+
+func TestLookupIP_NoSuitableAddress(t *testing.T) {
+ // The net package returns this AddrError when the host resolves but has
+ // no addresses of the requested family (e.g. AAAA query for a v4-only
+ // hosts file entry). The domain exists, so this is NODATA, not SERVFAIL.
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip6": {err: &net.AddrError{Err: "no suitable address found", Addr: "example.com."}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip6", "example.com.", dns.TypeAAAA)
+
+ assert.Equal(t, dns.RcodeSuccess, result.Rcode, "no suitable address should map to NODATA")
+ assert.Empty(t, result.IPs, "NODATA response should carry no addresses")
+}
+
+// TestErrNoSuitableAddressMatchesNetPackage pins our copy of the error string
+// to what the net package actually emits. A literal IP of the wrong family
+// takes the same filterAddrList path as a resolved hostname, without network
+// access.
+func TestErrNoSuitableAddressMatchesNetPackage(t *testing.T) {
+ _, err := (&net.Resolver{}).LookupNetIP(context.Background(), "ip6", "192.0.2.1")
+ require.Error(t, err)
+
+ var addrErr *net.AddrError
+ require.ErrorAs(t, err, &addrErr, "wrong-family lookup should return AddrError")
+ assert.Equal(t, errNoSuitableAddress, addrErr.Err, "net package error string should match our constant")
+}
+
+func TestLookupIP_OtherAddrError(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip4": {err: &net.AddrError{Err: "some other address problem", Addr: "example.com."}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
+
+ assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "unrecognized AddrError should map to SERVFAIL")
+}
+
+func TestLookupIP_NotFoundNXDomain(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip4": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}},
+ "ip6": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
+
+ assert.Equal(t, dns.RcodeNameError, result.Rcode, "not found for both families should map to NXDOMAIN")
+}
+
+func TestLookupIP_NotFoundNoData(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip6": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}},
+ "ip4": {ips: []netip.Addr{netip.MustParseAddr("192.0.2.1")}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip6", "example.com.", dns.TypeAAAA)
+
+ assert.Equal(t, dns.RcodeSuccess, result.Rcode, "not found with the other family present should map to NODATA")
+}
+
+func TestLookupIP_GenericError(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip4": {err: errors.New("connection refused")},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
+
+ assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "generic error should map to SERVFAIL")
+}
+
+func TestLookupIP_DNSErrorNotIsNotFound(t *testing.T) {
+ r := &mockResolver{results: map[string]mockLookup{
+ "ip4": {err: &net.DNSError{Err: "server misbehaving", Name: "example.com.", IsTemporary: true}},
+ }}
+
+ result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA)
+
+ assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "upstream failure should map to SERVFAIL")
+}
diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go
index 7a35e56d8..dcd4cb9d0 100644
--- a/client/internal/dns/server.go
+++ b/client/internal/dns/server.go
@@ -777,13 +777,24 @@ func (s *DefaultServer) applyHostConfig() {
// context is released rather than leaked until GC.
func (s *DefaultServer) registerFallback() {
originalNameservers := s.hostManager.getOriginalNameservers()
- if len(originalNameservers) == 0 {
+
+ serverIP := s.service.RuntimeIP()
+ var servers []netip.AddrPort
+ for _, ns := range originalNameservers {
+ if ns == serverIP {
+ log.Debugf("skipping original nameserver %s as it is the same as the server IP %s", ns, serverIP)
+ continue
+ }
+ servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
+ }
+
+ if len(servers) == 0 {
log.Debugf("no fallback upstreams to register; clearing PriorityFallback handler")
s.clearFallback()
return
}
- log.Infof("registering original nameservers %v as upstream handlers with priority %d", originalNameservers, PriorityFallback)
+ log.Infof("registering original nameservers %v as upstream handlers with priority %d", servers, PriorityFallback)
handler, err := newUpstreamResolver(
s.ctx,
@@ -797,11 +808,6 @@ func (s *DefaultServer) registerFallback() {
return
}
handler.selectedRoutes = s.selectedRoutes
-
- var servers []netip.AddrPort
- for _, ns := range originalNameservers {
- servers = append(servers, netip.AddrPortFrom(ns, DefaultPort))
- }
handler.addRace(servers)
prev := s.fallbackHandler
diff --git a/client/internal/engine.go b/client/internal/engine.go
index 980326720..cf40d8983 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -53,7 +53,6 @@ import (
"github.com/netbirdio/netbird/client/internal/relay"
"github.com/netbirdio/netbird/client/internal/rosenpass"
"github.com/netbirdio/netbird/client/internal/routemanager"
- "github.com/netbirdio/netbird/client/internal/routemanager/systemops"
"github.com/netbirdio/netbird/client/internal/statemanager"
"github.com/netbirdio/netbird/client/internal/syncstore"
"github.com/netbirdio/netbird/client/internal/updater"
@@ -240,7 +239,7 @@ type Engine struct {
syncStore syncstore.Store
syncStoreDir string
- flowManager nftypes.FlowManager
+ flowManager nftypes.FlowManager
// auto-update
updateManager *updater.Manager
@@ -531,6 +530,10 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
return fmt.Errorf("create wg interface: %w", err)
}
+ if filteredDevice := e.wgInterface.GetDevice(); filteredDevice != nil {
+ filteredDevice.SetPanicHandler(e.triggerClientRestart)
+ }
+
if err := e.createFirewall(); err != nil {
e.close()
return err
@@ -880,62 +883,25 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
}
- if update.GetNetbirdConfig() != nil {
- wCfg := update.GetNetbirdConfig()
- err := e.updateTURNs(wCfg.GetTurns())
- if err != nil {
- return fmt.Errorf("update TURNs: %w", err)
- }
+ if err := e.updateNetbirdConfig(update.GetNetbirdConfig()); err != nil {
+ return err
+ }
- err = e.updateSTUNs(wCfg.GetStuns())
- if err != nil {
- return fmt.Errorf("update STUNs: %w", err)
- }
-
- var stunTurn []*stun.URI
- stunTurn = append(stunTurn, e.STUNs...)
- stunTurn = append(stunTurn, e.TURNs...)
- e.stunTurn.Store(stunTurn)
-
- err = e.handleRelayUpdate(wCfg.GetRelay())
- if err != nil {
- return err
- }
-
- err = e.handleFlowUpdate(wCfg.GetFlow())
- if err != nil {
- return fmt.Errorf("handle the flow configuration: %w", err)
- }
-
- if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil {
- log.Warnf("Failed to update DNS server config: %v", err)
- }
-
- // todo update signal
+ // Posture checks are bound to the network map presence:
+ // NetworkMap != nil, checks present -> apply the received checks
+ // NetworkMap != nil, checks nil -> posture checks were removed, clear them
+ // NetworkMap == nil -> config-only update (e.g. relay token rotation),
+ // leave the previously applied checks untouched
+ nm := update.GetNetworkMap()
+ if nm == nil {
+ return nil
}
if err := e.updateChecksIfNew(update.Checks); err != nil {
return err
}
- nm := update.GetNetworkMap()
- if nm == nil {
- return nil
- }
-
- // Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux.
- // A non-nil syncStore is what marks persistence as enabled. Hold the lock for
- // the whole Set so the store cannot be cleared (disabled / engine close)
- // mid-call and have this write resurrect a file that was just removed.
- e.syncRespMux.RLock()
- if e.syncStore != nil {
- if err := e.syncStore.Set(update); err != nil {
- log.Errorf("failed to persist sync response: %v", err)
- } else {
- log.Debugf("sync response persisted with serial %d", nm.GetSerial())
- }
- }
- e.syncRespMux.RUnlock()
+ e.persistSyncResponse(update)
// only apply new changes and ignore old ones
if err := e.updateNetworkMap(nm); err != nil {
@@ -947,6 +913,64 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return nil
}
+// updateNetbirdConfig applies the management-provided NetBird configuration:
+// STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op,
+// which is the case for sync updates carrying only a network map.
+func (e *Engine) updateNetbirdConfig(wCfg *mgmProto.NetbirdConfig) error {
+ if wCfg == nil {
+ return nil
+ }
+
+ if err := e.updateTURNs(wCfg.GetTurns()); err != nil {
+ return fmt.Errorf("update TURNs: %w", err)
+ }
+
+ if err := e.updateSTUNs(wCfg.GetStuns()); err != nil {
+ return fmt.Errorf("update STUNs: %w", err)
+ }
+
+ var stunTurn []*stun.URI
+ stunTurn = append(stunTurn, e.STUNs...)
+ stunTurn = append(stunTurn, e.TURNs...)
+ e.stunTurn.Store(stunTurn)
+
+ if err := e.handleRelayUpdate(wCfg.GetRelay()); err != nil {
+ return err
+ }
+
+ if err := e.handleFlowUpdate(wCfg.GetFlow()); err != nil {
+ return fmt.Errorf("handle the flow configuration: %w", err)
+ }
+
+ if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil {
+ log.Warnf("Failed to update DNS server config: %v", err)
+ }
+
+ // todo update signal
+
+ return nil
+}
+
+// persistSyncResponse stores the full sync response so it can be restored on the next
+// startup. Persistence is enabled only when syncStore is set. The dedicated syncRespMux
+// (not syncMsgMux) is held for the whole Set so the store cannot be cleared (disabled /
+// engine close) mid-call and have this write resurrect a file that was just removed.
+func (e *Engine) persistSyncResponse(update *mgmProto.SyncResponse) {
+ e.syncRespMux.RLock()
+ defer e.syncRespMux.RUnlock()
+
+ if e.syncStore == nil {
+ return
+ }
+
+ if err := e.syncStore.Set(update); err != nil {
+ log.Errorf("failed to persist sync response: %v", err)
+ return
+ }
+
+ log.Debugf("sync response persisted with serial %d", update.GetNetworkMap().GetSerial())
+}
+
func (e *Engine) handleRelayUpdate(update *mgmProto.RelayConfig) error {
if update != nil {
// when we receive token we expect valid address list too
@@ -1888,7 +1912,6 @@ func (e *Engine) newWgIface() (*iface.WGIface, error) {
WGPrivKey: e.config.WgPrivateKey.String(),
MTU: e.config.MTU,
TransportNet: transportNet,
- FilterFn: e.addrViaRoutes,
DisableDNS: e.config.DisableDNS,
}
@@ -2136,21 +2159,6 @@ func (e *Engine) startNetworkMonitor() {
}()
}
-func (e *Engine) addrViaRoutes(addr netip.Addr) (bool, netip.Prefix, error) {
- var vpnRoutes []netip.Prefix
- for _, routes := range e.routeManager.GetClientRoutes() {
- if len(routes) > 0 && routes[0] != nil {
- vpnRoutes = append(vpnRoutes, routes[0].Network)
- }
- }
-
- if isVpn, prefix := systemops.IsAddrRouted(addr, vpnRoutes); isVpn {
- return true, prefix, nil
- }
-
- return false, netip.Prefix{}, nil
-}
-
func (e *Engine) stopDNSServer() {
if e.dnsServer == nil {
return
diff --git a/client/internal/peer/conn_status.go b/client/internal/peer/conn_status.go
index b43e245f3..d6ad37b70 100644
--- a/client/internal/peer/conn_status.go
+++ b/client/internal/peer/conn_status.go
@@ -26,7 +26,6 @@ type connStatusInputs struct {
iceInProgress bool // a negotiation is currently in flight
}
-
// ConnStatus describe the status of a peer's connection
type ConnStatus int32
diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go
index b6c6c14ac..31e0d6e25 100644
--- a/client/internal/peer/status.go
+++ b/client/internal/peer/status.go
@@ -193,6 +193,7 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
type Status struct {
mux sync.RWMutex
peers map[string]State
+ ipToKey map[string]string
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
signalState bool
signalError error
@@ -231,6 +232,7 @@ type Status struct {
func NewRecorder(mgmAddress string) *Status {
return &Status{
peers: make(map[string]State),
+ ipToKey: make(map[string]string),
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
eventStreams: make(map[string]chan *proto.SystemEvent),
eventQueue: NewEventQueue(eventQueueSize),
@@ -282,6 +284,12 @@ func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string, ipv6 string)
Mux: new(sync.RWMutex),
}
d.peerListChangedForNotification = true
+ if ipv6 != "" {
+ d.ipToKey[ipv6] = peerPubKey
+ }
+ if ip != "" {
+ d.ipToKey[ip] = peerPubKey
+ }
return nil
}
@@ -311,28 +319,22 @@ func (d *Status) PeerByIP(ip string) (string, bool) {
// 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. Searches
-// both d.peers and d.offlinePeers — peers that have been moved into
-// the offline slice by ReplaceOfflinePeers are still part of the
-// account's roster and callers (DNS filter, embed.Client.IdentityForIP)
-// need to recognise them rather than treating them as unknown. Returns
-// the zero State and false when no peer matches or the input is empty.
+// address so dual-stack peers are reachable on either family. Only
+// active peers are matched; peers moved into the offline slice by
+// ReplaceOfflinePeers are intentionally treated as unknown.
func (d *Status) PeerStateByIP(ip string) (State, bool) {
if ip == "" {
return State{}, false
}
d.mux.RLock()
defer d.mux.RUnlock()
-
- for _, state := range d.peers {
- if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
- return state, true
- }
+ key, ok := d.ipToKey[ip]
+ if !ok {
+ return State{}, false
}
- for _, state := range d.offlinePeers {
- if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
- return state, true
- }
+ state, ok := d.peers[key]
+ if ok {
+ return state, true
}
return State{}, false
}
@@ -342,12 +344,18 @@ func (d *Status) RemovePeer(peerPubKey string) error {
d.mux.Lock()
defer d.mux.Unlock()
- _, ok := d.peers[peerPubKey]
+ p, ok := d.peers[peerPubKey]
if !ok {
return errors.New("no peer with to remove")
}
delete(d.peers, peerPubKey)
+ if mappedKey, exists := d.ipToKey[p.IP]; exists && mappedKey == peerPubKey {
+ delete(d.ipToKey, p.IP)
+ }
+ if mappedKey, exists := d.ipToKey[p.IPv6]; exists && mappedKey == peerPubKey {
+ delete(d.ipToKey, p.IPv6)
+ }
d.peerListChangedForNotification = true
return nil
}
diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go
index 97fb32c03..17ed47cd3 100644
--- a/client/internal/peer/status_test.go
+++ b/client/internal/peer/status_test.go
@@ -90,12 +90,11 @@ func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
}
-// TestStatus_PeerStateByIP_MatchesOfflinePeers covers peers that have
-// been moved into the offline slice via ReplaceOfflinePeers. Callers
-// (DNS filter, embed.Client.IdentityForIP) need to treat them as known
-// rather than unknown — otherwise authentication / DNS filtering treats
-// known-but-offline peers as foreign IPs.
-func TestStatus_PeerStateByIP_MatchesOfflinePeers(t *testing.T) {
+// TestStatus_PeerStateByIP_IgnoresOfflinePeers documents that peers
+// moved into the offline slice via ReplaceOfflinePeers are intentionally
+// not resolvable by IP: only active peers can carry traffic, so callers
+// (DNS filter, embed.Client.IdentityForIP) treat them as unknown.
+func TestStatus_PeerStateByIP_IgnoresOfflinePeers(t *testing.T) {
status := NewRecorder("https://mgm")
req := require.New(t)
@@ -103,13 +102,31 @@ func TestStatus_PeerStateByIP_MatchesOfflinePeers(t *testing.T) {
{PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", IPv6: "fd00::20"},
})
- state, ok := status.PeerStateByIP("100.64.0.20")
- req.True(ok, "offline peer must resolve by IPv4 tunnel address")
- req.Equal("pk-offline", state.PubKey, "matching state must carry the offline peer's pub key")
+ _, ok := status.PeerStateByIP("100.64.0.20")
+ req.False(ok, "offline peer must not resolve by IPv4 tunnel address")
- state, ok = status.PeerStateByIP("fd00::20")
- req.True(ok, "offline peer must resolve by IPv6 tunnel address")
- req.Equal("pk-offline", state.PubKey, "IPv6 match must carry the offline peer's pub key")
+ _, ok = status.PeerStateByIP("fd00::20")
+ req.False(ok, "offline peer must not resolve by IPv6 tunnel address")
+}
+
+// TestStatus_PeerStateByIP_RemovedPeer verifies RemovePeer drops the
+// IP index entries for both address families.
+func TestStatus_PeerStateByIP_RemovedPeer(t *testing.T) {
+ status := NewRecorder("https://mgm")
+ req := require.New(t)
+
+ req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", "fd00::1"))
+
+ _, ok := status.PeerStateByIP("100.64.0.10")
+ req.True(ok, "active peer must resolve before removal")
+
+ req.NoError(status.RemovePeer("pk-1"))
+
+ _, ok = status.PeerStateByIP("100.64.0.10")
+ req.False(ok, "removed peer must not resolve by IPv4 tunnel address")
+
+ _, ok = status.PeerStateByIP("fd00::1")
+ req.False(ok, "removed peer must not resolve by IPv6 tunnel address")
}
func TestStatus_UpdatePeerFQDN(t *testing.T) {
diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go
index 29bf5aaaa..b1aa3e0f9 100644
--- a/client/internal/peer/worker_ice.go
+++ b/client/internal/peer/worker_ice.go
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"net"
- "net/netip"
"strconv"
"sync"
"time"
@@ -165,10 +164,6 @@ func (w *WorkerICE) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HA
return
}
- if candidateViaRoutes(candidate, haRoutes) {
- return
- }
-
if err := w.agent.AddRemoteCandidate(candidate); err != nil {
w.log.Errorf("error while handling remote candidate")
return
@@ -589,34 +584,6 @@ func extraSrflxCandidate(candidate ice.Candidate) (*ice.CandidateServerReflexive
return ec, nil
}
-func candidateViaRoutes(candidate ice.Candidate, clientRoutes route.HAMap) bool {
- addr, err := netip.ParseAddr(candidate.Address())
- if err != nil {
- log.Errorf("Failed to parse IP address %s: %v", candidate.Address(), err)
- return false
- }
-
- var routePrefixes []netip.Prefix
- for _, routes := range clientRoutes {
- if len(routes) > 0 && routes[0] != nil {
- routePrefixes = append(routePrefixes, routes[0].Network)
- }
- }
-
- for _, prefix := range routePrefixes {
- // default route is handled by route exclusion / ip rules
- if prefix.Bits() == 0 {
- continue
- }
-
- if prefix.Contains(addr) {
- log.Debugf("Ignoring candidate [%s], its address is part of routed network %s", candidate.String(), prefix)
- return true
- }
- }
- return false
-}
-
func isRelayCandidate(candidate ice.Candidate) bool {
return candidate.Type() == ice.CandidateTypeRelay
}
diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go
index 2ec4fb50c..a77f0ff32 100644
--- a/client/internal/profilemanager/config.go
+++ b/client/internal/profilemanager/config.go
@@ -22,6 +22,7 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
+ "github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/ssh"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -57,6 +58,10 @@ var DefaultInterfaceBlacklist = []string{
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
}
+// loadMDMPolicy is the package-level indirection used by apply() to read the
+// active MDM policy. Tests override this to inject a fake policy.
+var loadMDMPolicy = mdm.LoadPolicy
+
// ConfigInput carries configuration changes to the client
type ConfigInput struct {
ManagementURL string
@@ -178,6 +183,23 @@ type Config struct {
LazyConnectionEnabled bool
MTU uint16
+
+ // policy is the MDM policy that produced the currently-set values for
+ // any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
+ // and reset on every apply() invocation. Never persisted to disk.
+ // Callers query enforcement state via Policy() and the mdm.Policy API
+ // (HasKey, ManagedKeys, IsEmpty).
+ policy *mdm.Policy `json:"-"`
+}
+
+// Policy returns the MDM policy applied to this Config. Returns a non-nil
+// empty Policy when MDM enforcement is inactive; callers can always invoke
+// HasKey / ManagedKeys / IsEmpty without a nil check.
+func (config *Config) Policy() *mdm.Policy {
+ if config == nil || config.policy == nil {
+ return mdm.NewPolicy(nil)
+ }
+ return config.policy
}
var ConfigDirOverride string
@@ -626,10 +648,93 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
+ // MDM is the last override layer: any key present in the policy
+ // supersedes defaults, on-disk config, env vars and CLI input.
+ config.applyMDMPolicy(loadMDMPolicy())
+
return updated, nil
}
-// parseURL parses and validates a service URL
+// applyMDMPolicy overlays MDM-supplied values on top of the resolved Config.
+// The provided Policy is also stored on the Config so callers can later query
+// which fields are enforced. Invalid values (e.g. malformed URLs) are logged
+// and skipped to avoid bricking the client; the field keeps its previous
+// resolved value but is still marked as managed (Policy.HasKey returns true
+// for the key, so per-field rejection of user writes still applies).
+func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
+ config.policy = policy
+ if policy.IsEmpty() {
+ return
+ }
+
+ // Helper: log the application of a single MDM-managed key. Values for
+ // keys in mdm.SecretKeys are redacted.
+ logApplied := func(key string, displayValue any) {
+ if _, secret := mdm.SecretKeys[key]; secret {
+ log.Infof("MDM override %s = ********** (secret)", key)
+ return
+ }
+ log.Infof("MDM override %s = %v", key, displayValue)
+ }
+
+ if v, ok := policy.GetString(mdm.KeyManagementURL); ok {
+ if u, err := parseURL("Management URL", v); err != nil {
+ log.Warnf("MDM management URL %q invalid: %v; keeping previous value", v, err)
+ } else {
+ config.ManagementURL = u
+ logApplied(mdm.KeyManagementURL, u.String())
+ }
+ }
+
+ if v, ok := policy.GetString(mdm.KeyPreSharedKey); ok {
+ // Defensive: refuse the redaction mask in case it round-tripped
+ // through a manifest by mistake.
+ if !isPreSharedKeyHidden(&v) {
+ config.PreSharedKey = v
+ logApplied(mdm.KeyPreSharedKey, "")
+ }
+ }
+
+ // applyBool collapses the per-key "read + set + log" boilerplate
+ // for every plain bool MDM key into a single helper. Keeps the
+ // outer function's cognitive complexity below SonarCube's
+ // threshold; functional behaviour is identical to the inlined
+ // branches it replaces.
+ applyBool := func(key string, setter func(bool)) {
+ v, ok := policy.GetBool(key)
+ if !ok {
+ return
+ }
+ setter(v)
+ logApplied(key, v)
+ }
+
+ applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv })
+ applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v })
+ applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v })
+ applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v })
+ applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v })
+ applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v })
+ applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v })
+
+ if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok {
+ // REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the
+ // upper bound and reject obviously-invalid values to avoid the
+ // engine binding to an unusable port if the admin pushes garbage.
+ if v >= 1 && v <= 65535 {
+ config.WgPort = int(v)
+ logApplied(mdm.KeyWireguardPort, v)
+ } else {
+ log.Warnf("MDM wireguard port %d out of range [1,65535]; keeping previous value", v)
+ }
+ }
+}
+
+// parseURL parses and validates the URL for the named service. The URL
+// must use the http or https scheme; if no port is present, ":443" is
+// appended for https or ":80" for http. The serviceName parameter is
+// used to contextualise error messages. On success returns the parsed
+// *url.URL; on failure returns a non-nil error.
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
if err != nil {
diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go
new file mode 100644
index 000000000..6a201235e
--- /dev/null
+++ b/client/internal/profilemanager/config_mdm_test.go
@@ -0,0 +1,152 @@
+package profilemanager
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/mdm"
+)
+
+// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so
+// apply() observes the supplied Policy. The original loader is restored at
+// test cleanup.
+func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
+ t.Helper()
+ prev := loadMDMPolicy
+ loadMDMPolicy = func() *mdm.Policy { return policy }
+ t.Cleanup(func() { loadMDMPolicy = prev })
+}
+
+func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(nil))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy")
+ assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
+ assert.Empty(t, cfg.Policy().ManagedKeys())
+
+ // Default management URL still resolves.
+ assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
+}
+
+func TestApply_MDMOnly_OverridesDefaults(t *testing.T) {
+ const mdmURL = "https://corp.mdm.example.com:443"
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: mdmURL,
+ mdm.KeyDisableClientRoutes: true,
+ mdm.KeyBlockInbound: true,
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ assert.Equal(t, mdmURL, cfg.ManagementURL.String())
+ assert.True(t, cfg.DisableClientRoutes)
+ assert.True(t, cfg.BlockInbound)
+
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyBlockInbound))
+ assert.False(t, cfg.Policy().HasKey(mdm.KeyAllowServerSSH))
+}
+
+func TestApply_MDMBeatsCLIInput(t *testing.T) {
+ const mdmURL = "https://mdm.example.com:443"
+ const cliURL = "https://cli.example.com:443"
+
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: mdmURL,
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ ManagementURL: cliURL,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ // MDM wins over CLI-supplied management URL.
+ assert.Equal(t, mdmURL, cfg.ManagementURL.String())
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
+}
+
+func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: "not-a-url",
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ // Invalid MDM URL is logged and skipped: default URL stays in place
+ // to keep the client functional.
+ assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
+
+ // But the key is still considered MDM-managed (admin intent is to
+ // enforce, daemon rejects user writes to this field — phase-1 scaffolding
+ // reflects this by keeping Policy.HasKey true even on parse failure).
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
+}
+
+func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
+ tmp := filepath.Join(t.TempDir(), "config.json")
+
+ // Seed without MDM.
+ withMDMPolicy(t, mdm.NewPolicy(nil))
+ _, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: tmp,
+ DisableClientRoutes: boolPtr(false),
+ RosenpassEnabled: boolPtr(false),
+ })
+ require.NoError(t, err)
+
+ // Now enable MDM enforcement for these keys.
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyDisableClientRoutes: true,
+ mdm.KeyRosenpassEnabled: true,
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true")
+ assert.True(t, cfg.RosenpassEnabled)
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled))
+}
+
+func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
+ const maskSentinel = "**********"
+
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyPreSharedKey: maskSentinel,
+ }))
+
+ cfg, err := UpdateOrCreateConfig(ConfigInput{
+ ConfigPath: filepath.Join(t.TempDir(), "config.json"),
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+
+ // Mask sentinel must not be persisted as the actual PSK.
+ assert.NotEqual(t, maskSentinel, cfg.PreSharedKey)
+ // Key still marked managed so user writes are still rejected.
+ assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey))
+}
+
+func boolPtr(b bool) *bool { return &b }
diff --git a/client/internal/rosenpass/manager_test.go b/client/internal/rosenpass/manager_test.go
index ace6f88da..d74960d0d 100644
--- a/client/internal/rosenpass/manager_test.go
+++ b/client/internal/rosenpass/manager_test.go
@@ -22,14 +22,14 @@ type removePeerCall struct {
}
type mockServer struct {
- mu sync.Mutex
- addCalls []addPeerCall
- removed []removePeerCall
- nextID rp.PeerID
- addErr error
- removeErr error
- closed bool
- ran bool
+ mu sync.Mutex
+ addCalls []addPeerCall
+ removed []removePeerCall
+ nextID rp.PeerID
+ addErr error
+ removeErr error
+ closed bool
+ ran bool
}
func (m *mockServer) AddPeer(cfg rp.PeerConfig) (rp.PeerID, error) {
@@ -51,7 +51,7 @@ func (m *mockServer) RemovePeer(id rp.PeerID) error {
return m.removeErr
}
-func (m *mockServer) Run() error { m.ran = true; return nil }
+func (m *mockServer) Run() error { m.ran = true; return nil }
func (m *mockServer) Close() error { m.closed = true; return nil }
type setPSKCall struct {
diff --git a/client/internal/rosenpass/seed_test.go b/client/internal/rosenpass/seed_test.go
index 0dfa478c7..b6a9a5991 100644
--- a/client/internal/rosenpass/seed_test.go
+++ b/client/internal/rosenpass/seed_test.go
@@ -41,4 +41,3 @@ func TestDeterministicSeedKey_TooShortKey_ReturnsError(t *testing.T) {
_, err = DeterministicSeedKey(long, short)
require.Error(t, err)
}
-
diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go
index 839ec14c0..0edf4607f 100644
--- a/client/internal/routemanager/manager.go
+++ b/client/internal/routemanager/manager.go
@@ -9,6 +9,7 @@ import (
"net/url"
"runtime"
"slices"
+ "strings"
"sync"
"sync/atomic"
"time"
@@ -700,6 +701,15 @@ func resolveURLsToIPs(urls []string) []net.IP {
// updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server
func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) {
+ m.mirrorV6ExitPairSelections(clientRoutes)
+
+ // An explicit user "deselect all" must not be overridden by management auto-apply.
+ // Auto-applying an exit node here would call SelectRoutes, which clears the
+ // deselect-all flag and re-enables every route the user turned off.
+ if m.routeSelector.IsDeselectAll() {
+ return
+ }
+
exitNodeInfo := m.collectExitNodeInfo(clientRoutes)
if len(exitNodeInfo.allIDs) == 0 {
return
@@ -709,6 +719,24 @@ func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HA
m.logExitNodeUpdate(exitNodeInfo)
}
+// mirrorV6ExitPairSelections keeps every synthesized "-v6" exit route's selection
+// consistent with its v4 base. The v4/v6 exit pair is a single toggle, so the v6
+// entry always follows the base: deselecting the v4 exit node also drops its ::/0
+// pair, and any stale (orphaned) explicit selection on the v6 entry is reset. This
+// runs before selection is read so both collectExitNodeInfo and FilterSelectedExitNodes
+// see consistent state, including pairs loaded from persisted selector state.
+func (m *DefaultManager) mirrorV6ExitPairSelections(clientRoutes route.HAMap) {
+ routesByNetID := make(map[route.NetID][]*route.Route, len(clientRoutes))
+ for haID, routes := range clientRoutes {
+ routesByNetID[haID.NetID()] = routes
+ }
+
+ for v6ID := range route.V6ExitMergeSet(routesByNetID) {
+ baseID := route.NetID(strings.TrimSuffix(string(v6ID), route.V6ExitSuffix))
+ m.routeSelector.SyncPairedSelection(baseID, v6ID)
+ }
+}
+
type exitNodeInfo struct {
allIDs []route.NetID
selectedByManagement []route.NetID
diff --git a/client/internal/routemanager/manager_v6exit_test.go b/client/internal/routemanager/manager_v6exit_test.go
new file mode 100644
index 000000000..15ab99cbd
--- /dev/null
+++ b/client/internal/routemanager/manager_v6exit_test.go
@@ -0,0 +1,47 @@
+package routemanager
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/internal/routeselector"
+ "github.com/netbirdio/netbird/route"
+)
+
+// TestUpdateRouteSelectorFromManagement_MirrorsV6ExitPair reproduces the bug seen
+// in netbird-engine.log: persisted selector state has the v4 exit node deselected
+// but its synthesized "-v6" pair explicitly selected (orphaned), so the ::/0 route
+// leaked onto the tunnel. The management update must mirror the v4 deselect onto the
+// v6 pair so FilterSelectedExitNodes drops it.
+func TestUpdateRouteSelectorFromManagement_MirrorsV6ExitPair(t *testing.T) {
+ const (
+ v4ID = route.NetID("Exit Node (raspberrypi)")
+ v6ID = route.NetID("Exit Node (raspberrypi)-v6")
+ )
+ all := []route.NetID{v4ID, v6ID}
+
+ rs := routeselector.NewRouteSelector()
+ // Orphan the v6 selection: select the pair, then deselect only the v4 base.
+ require.NoError(t, rs.SelectRoutes([]route.NetID{v4ID, v6ID}, true, all))
+ require.NoError(t, rs.DeselectRoutes([]route.NetID{v4ID}, all))
+ require.True(t, rs.IsSelected(v6ID), "precondition: orphaned v6 selection survives v4 deselect")
+
+ m := &DefaultManager{routeSelector: rs}
+
+ v4Route := &route.Route{NetID: v4ID, Network: netip.MustParsePrefix("0.0.0.0/0")}
+ v6Route := &route.Route{NetID: v6ID, Network: netip.MustParsePrefix("::/0")}
+ clientRoutes := route.HAMap{
+ "Exit Node (raspberrypi)|0.0.0.0/0": {v4Route},
+ "Exit Node (raspberrypi)-v6|::/0": {v6Route},
+ }
+
+ m.updateRouteSelectorFromManagement(clientRoutes)
+
+ assert.False(t, rs.IsSelected(v6ID), "v6 pair must follow the v4 base deselect after the management update")
+
+ filtered := rs.FilterSelectedExitNodes(clientRoutes)
+ assert.Empty(t, filtered, "deselected v4 exit node must not leak its ::/0 pair onto the tunnel")
+}
diff --git a/client/internal/routemanager/selector_management_test.go b/client/internal/routemanager/selector_management_test.go
new file mode 100644
index 000000000..04659db65
--- /dev/null
+++ b/client/internal/routemanager/selector_management_test.go
@@ -0,0 +1,71 @@
+package routemanager
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/client/internal/routeselector"
+ "github.com/netbirdio/netbird/route"
+)
+
+func exitNodeRoutes(netID route.NetID, skipAutoApply bool) route.HAMap {
+ haID := route.HAUniqueID(string(netID) + "|0.0.0.0/0")
+ return route.HAMap{
+ haID: []*route.Route{
+ {
+ ID: "r-" + route.ID(netID),
+ NetID: netID,
+ Network: netip.MustParsePrefix("0.0.0.0/0"),
+ NetworkType: route.IPv4Network,
+ Enabled: true,
+ SkipAutoApply: skipAutoApply,
+ },
+ },
+ }
+}
+
+func TestUpdateRouteSelectorFromManagement(t *testing.T) {
+ t.Run("management auto-apply selects exit node without user selection", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ routes := exitNodeRoutes("exit1", false)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.True(t, m.routeSelector.IsSelected("exit1"), "auto-apply exit node should be selected")
+ require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "selected exit node should pass the filter")
+ })
+
+ t.Run("management SkipAutoApply leaves exit node deselected", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ routes := exitNodeRoutes("exit1", true)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.False(t, m.routeSelector.IsSelected("exit1"), "SkipAutoApply exit node should not be selected")
+ require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "deselected exit node should be filtered out")
+ })
+
+ t.Run("user selection is not overridden by management", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exit1"}, true, []route.NetID{"exit1"}))
+ routes := exitNodeRoutes("exit1", true)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.True(t, m.routeSelector.IsSelected("exit1"), "explicit user selection must survive a management sync that wants to skip auto-apply")
+ require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "user-selected exit node should pass the filter")
+ })
+
+ t.Run("deselect-all is preserved across a management sync", func(t *testing.T) {
+ m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()}
+ m.routeSelector.DeselectAllRoutes()
+ routes := exitNodeRoutes("exit1", false)
+
+ m.updateRouteSelectorFromManagement(routes)
+
+ require.True(t, m.routeSelector.IsDeselectAll(), "an explicit deselect-all must not be cleared by management auto-apply")
+ require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "no routes should be selected while deselect-all is set")
+ })
+}
diff --git a/client/internal/routemanager/systemops/systemops_generic.go b/client/internal/routemanager/systemops/systemops_generic.go
index 2b96c14dc..bb9ac494d 100644
--- a/client/internal/routemanager/systemops/systemops_generic.go
+++ b/client/internal/routemanager/systemops/systemops_generic.go
@@ -121,9 +121,12 @@ func (r *SysOps) addRouteToNonVPNIntf(prefix netip.Prefix, vpnIntf wgIface, init
return Nexthop{}, vars.ErrRouteNotAllowed
}
- // Check if the prefix is part of any local subnets
- if isLocal, subnet := r.isPrefixInLocalSubnets(prefix); isLocal {
- return Nexthop{}, fmt.Errorf("prefix %s is part of local subnet %s: %w", prefix, subnet, vars.ErrRouteNotAllowed)
+ // BSDs blackhole a /32 added inside a directly-connected subnet; Linux/Windows need it to beat the wt0 route.
+ switch runtime.GOOS {
+ case "darwin", "freebsd", "netbsd", "openbsd", "dragonfly":
+ if isLocal, subnet := r.isPrefixInLocalSubnets(prefix); isLocal {
+ return Nexthop{}, fmt.Errorf("prefix %s is part of local subnet %s: %w", prefix, subnet, vars.ErrRouteNotAllowed)
+ }
}
// Determine the exit interface and next hop for the prefix, so we can add a specific route
diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go
index 2ddc24bf2..232baf746 100644
--- a/client/internal/routeselector/routeselector.go
+++ b/client/internal/routeselector/routeselector.go
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"slices"
- "strings"
"sync"
"github.com/hashicorp/go-multierror"
@@ -116,6 +115,14 @@ func (rs *RouteSelector) DeselectAllRoutes() {
clear(rs.selectedRoutes)
}
+// IsDeselectAll reports whether the user has explicitly deselected all routes.
+func (rs *RouteSelector) IsDeselectAll() bool {
+ rs.mu.RLock()
+ defer rs.mu.RUnlock()
+
+ return rs.deselectAll
+}
+
// IsSelected checks if a specific route is selected.
func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
rs.mu.RLock()
@@ -124,6 +131,33 @@ func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
return rs.isSelectedLocked(routeID)
}
+// SyncPairedSelection forces pairedID's explicit selection state to match baseID's,
+// so a synthesized "-v6" exit route always follows its v4 base: selecting or
+// deselecting the v4 exit node governs the ::/0 pair, and any stale (orphaned)
+// explicit state on the v6 entry is reset. The v4/v6 exit pair is treated as a single
+// toggle, so the v6 entry carries no independent selection of its own.
+func (rs *RouteSelector) SyncPairedSelection(baseID, pairedID route.NetID) {
+ rs.mu.Lock()
+ defer rs.mu.Unlock()
+
+ if rs.deselectAll {
+ return
+ }
+
+ _, baseSelected := rs.selectedRoutes[baseID]
+ _, baseDeselected := rs.deselectedRoutes[baseID]
+
+ delete(rs.selectedRoutes, pairedID)
+ delete(rs.deselectedRoutes, pairedID)
+
+ switch {
+ case baseSelected:
+ rs.selectedRoutes[pairedID] = struct{}{}
+ case baseDeselected:
+ rs.deselectedRoutes[pairedID] = struct{}{}
+ }
+}
+
// FilterSelected removes unselected routes from the provided map.
func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap {
rs.mu.RLock()
@@ -143,14 +177,13 @@ func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap {
}
// HasUserSelectionForRoute returns true if the user has explicitly selected or deselected this route.
-// Intended for exit-node code paths: a v6 exit-node pair (e.g. "MyExit-v6") with no explicit state of
-// its own inherits its v4 base's state, so legacy persisted selections that predate v6 pairing
-// transparently apply to the synthesized v6 entry.
+// The lookup is literal; v4/v6 exit pairs are kept consistent at write time via SyncPairedSelection,
+// so a synthesized "-v6" entry carries the same explicit state as its v4 base.
func (rs *RouteSelector) HasUserSelectionForRoute(routeID route.NetID) bool {
rs.mu.RLock()
defer rs.mu.RUnlock()
- return rs.hasUserSelectionForRouteLocked(rs.effectiveNetID(routeID))
+ return rs.hasUserSelectionForRouteLocked(routeID)
}
func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap {
@@ -179,83 +212,6 @@ func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap
return filtered
}
-// effectiveNetID returns the v4 base for a "-v6" exit pair entry that has no explicit
-// state of its own, so selections made on the v4 entry govern the v6 entry automatically.
-// Only call this from exit-node-specific code paths: applying it to a non-exit "-v6" route
-// would make it inherit unrelated v4 state. Must be called with rs.mu held.
-func (rs *RouteSelector) effectiveNetID(id route.NetID) route.NetID {
- name := string(id)
- if !strings.HasSuffix(name, route.V6ExitSuffix) {
- return id
- }
- if _, ok := rs.selectedRoutes[id]; ok {
- return id
- }
- if _, ok := rs.deselectedRoutes[id]; ok {
- return id
- }
- return route.NetID(strings.TrimSuffix(name, route.V6ExitSuffix))
-}
-
-func (rs *RouteSelector) isSelectedLocked(routeID route.NetID) bool {
- if rs.deselectAll {
- return false
- }
- _, deselected := rs.deselectedRoutes[routeID]
- return !deselected
-}
-
-func (rs *RouteSelector) isDeselectedLocked(netID route.NetID) bool {
- if rs.deselectAll {
- return true
- }
- _, deselected := rs.deselectedRoutes[netID]
- return deselected
-}
-
-func (rs *RouteSelector) hasUserSelectionForRouteLocked(routeID route.NetID) bool {
- _, selected := rs.selectedRoutes[routeID]
- _, deselected := rs.deselectedRoutes[routeID]
- return selected || deselected
-}
-
-func isExitNode(rt []*route.Route) bool {
- return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network))
-}
-
-func (rs *RouteSelector) applyExitNodeFilter(
- id route.HAUniqueID,
- netID route.NetID,
- rt []*route.Route,
- out route.HAMap,
-) {
- // Exit-node path: apply the v4/v6 pair mirror so a deselect on the v4 base also
- // drops the synthesized v6 entry that lacks its own explicit state.
- effective := rs.effectiveNetID(netID)
- if rs.hasUserSelectionForRouteLocked(effective) {
- if rs.isSelectedLocked(effective) {
- out[id] = rt
- }
- return
- }
-
- // no explicit selection for this route: defer to management's SkipAutoApply flag
- sel := collectSelected(rt)
- if len(sel) > 0 {
- out[id] = sel
- }
-}
-
-func collectSelected(rt []*route.Route) []*route.Route {
- var sel []*route.Route
- for _, r := range rt {
- if !r.SkipAutoApply {
- sel = append(sel, r)
- }
- }
- return sel
-}
-
// MarshalJSON implements the json.Marshaler interface
func (rs *RouteSelector) MarshalJSON() ([]byte, error) {
rs.mu.RLock()
@@ -309,3 +265,59 @@ func (rs *RouteSelector) UnmarshalJSON(data []byte) error {
return nil
}
+
+func (rs *RouteSelector) isSelectedLocked(routeID route.NetID) bool {
+ if rs.deselectAll {
+ return false
+ }
+ _, deselected := rs.deselectedRoutes[routeID]
+ return !deselected
+}
+
+func (rs *RouteSelector) isDeselectedLocked(netID route.NetID) bool {
+ if rs.deselectAll {
+ return true
+ }
+ _, deselected := rs.deselectedRoutes[netID]
+ return deselected
+}
+
+func (rs *RouteSelector) hasUserSelectionForRouteLocked(routeID route.NetID) bool {
+ _, selected := rs.selectedRoutes[routeID]
+ _, deselected := rs.deselectedRoutes[routeID]
+ return selected || deselected
+}
+
+func (rs *RouteSelector) applyExitNodeFilter(
+ id route.HAUniqueID,
+ netID route.NetID,
+ rt []*route.Route,
+ out route.HAMap,
+) {
+ if rs.hasUserSelectionForRouteLocked(netID) {
+ if rs.isSelectedLocked(netID) {
+ out[id] = rt
+ }
+ return
+ }
+
+ // no explicit selection for this route: defer to management's SkipAutoApply flag
+ sel := collectSelected(rt)
+ if len(sel) > 0 {
+ out[id] = sel
+ }
+}
+
+func isExitNode(rt []*route.Route) bool {
+ return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network))
+}
+
+func collectSelected(rt []*route.Route) []*route.Route {
+ var sel []*route.Route
+ for _, r := range rt {
+ if !r.SkipAutoApply {
+ sel = append(sel, r)
+ }
+ }
+ return sel
+}
diff --git a/client/internal/routeselector/routeselector_test.go b/client/internal/routeselector/routeselector_test.go
index 3f0d9f120..c9d6acb4d 100644
--- a/client/internal/routeselector/routeselector_test.go
+++ b/client/internal/routeselector/routeselector_test.go
@@ -330,39 +330,73 @@ func TestRouteSelector_FilterSelectedExitNodes(t *testing.T) {
assert.Len(t, filtered, 0) // No routes should be selected
}
-// TestRouteSelector_V6ExitPairInherits covers the v4/v6 exit-node pair selection
-// mirror. The mirror is scoped to exit-node code paths: HasUserSelectionForRoute
-// and FilterSelectedExitNodes resolve a "-v6" entry without explicit state to its
-// v4 base, so legacy persisted selections that predate v6 pairing transparently
-// apply to the synthesized v6 entry. General lookups (IsSelected, FilterSelected)
-// stay literal so unrelated routes named "*-v6" don't inherit unrelated state.
-func TestRouteSelector_V6ExitPairInherits(t *testing.T) {
+// TestRouteSelector_V6ExitPairSync covers SyncPairedSelection, which keeps a v4
+// exit node and its synthesized "-v6" counterpart consistent. The selector itself
+// is literal and never infers a v6 entry's state from its v4 base; callers that know
+// the pairing (exit-node code paths) call SyncPairedSelection to force the v6 entry
+// to follow the base, treating the pair as a single toggle.
+func TestRouteSelector_V6ExitPairSync(t *testing.T) {
all := []route.NetID{"exit1", "exit1-v6", "exit2", "exit2-v6", "corp", "corp-v6"}
- t.Run("HasUserSelectionForRoute mirrors deselected v4 base", func(t *testing.T) {
+ t.Run("selector lookups stay literal without sync", func(t *testing.T) {
rs := routeselector.NewRouteSelector()
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
- assert.True(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 pair sees v4 base's user selection")
+ // The selector does not pair-resolve: the v6 entry is independent until synced.
+ assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 entry has no state of its own")
+ assert.True(t, rs.IsSelected("exit1-v6"), "unsynced v6 entry stays selected by default")
- // unrelated v6 with no v4 base touched is unaffected
- assert.False(t, rs.HasUserSelectionForRoute("exit2-v6"))
+ // A route literally named "exit1-something" must never pair-resolve either.
+ assert.False(t, rs.HasUserSelectionForRoute("exit1-something"))
})
- t.Run("IsSelected stays literal for non-exit lookups", func(t *testing.T) {
- rs := routeselector.NewRouteSelector()
- require.NoError(t, rs.DeselectRoutes([]route.NetID{"corp"}, all))
-
- // A non-exit route literally named "corp-v6" must not inherit "corp"'s state
- // via the mirror; the mirror only applies in exit-node code paths.
- assert.False(t, rs.IsSelected("corp"))
- assert.True(t, rs.IsSelected("corp-v6"), "non-exit *-v6 routes must not inherit unrelated v4 state")
- })
-
- t.Run("explicit v6 state overrides v4 base in filter", func(t *testing.T) {
+ t.Run("sync mirrors deselected v4 base onto v6", func(t *testing.T) {
rs := routeselector.NewRouteSelector()
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
+
+ rs.SyncPairedSelection("exit1", "exit1-v6")
+
+ assert.False(t, rs.IsSelected("exit1"))
+ assert.False(t, rs.IsSelected("exit1-v6"), "v6 pair follows v4 base deselect")
+ assert.True(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 carries explicit deselect after sync")
+ })
+
+ t.Run("sync mirrors selected v4 base onto v6", func(t *testing.T) {
+ rs := routeselector.NewRouteSelector()
+ require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1"}, false, all))
+
+ rs.SyncPairedSelection("exit1", "exit1-v6")
+
+ assert.True(t, rs.IsSelected("exit1"))
+ assert.True(t, rs.IsSelected("exit1-v6"), "v6 pair follows v4 base select")
+ })
+
+ t.Run("sync clears v6 state when base has no explicit selection", func(t *testing.T) {
+ rs := routeselector.NewRouteSelector()
require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1-v6"}, true, all))
+ require.True(t, rs.HasUserSelectionForRoute("exit1-v6"))
+
+ rs.SyncPairedSelection("exit1", "exit1-v6")
+
+ assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"),
+ "v6 explicit state is cleared so it follows management like its base")
+ })
+
+ // Regression for the observed bug (see netbird-engine.log): persisted state has
+ // the v4 base deselected but the v6 sibling explicitly selected (orphaned). The
+ // sync must reset the orphan so the ::/0 route does not leak onto the tunnel.
+ t.Run("sync clears orphaned explicit v6 selection on deselected base", func(t *testing.T) {
+ rs := routeselector.NewRouteSelector()
+
+ // Prior state: both explicitly selected, then only the v4 base deselected,
+ // leaving the v6 entry as a stale explicit selection.
+ require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1", "exit1-v6"}, true, all))
+ require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
+ require.True(t, rs.IsSelected("exit1-v6"), "precondition: orphaned v6 selection")
+
+ rs.SyncPairedSelection("exit1", "exit1-v6")
+
+ assert.False(t, rs.IsSelected("exit1-v6"), "orphaned v6 selection reset to follow v4 deselect")
v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")}
v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")}
@@ -370,23 +404,14 @@ func TestRouteSelector_V6ExitPairInherits(t *testing.T) {
"exit1|0.0.0.0/0": {v4Route},
"exit1-v6|::/0": {v6Route},
}
-
filtered := rs.FilterSelectedExitNodes(routes)
- assert.NotContains(t, filtered, route.HAUniqueID("exit1|0.0.0.0/0"))
- assert.Contains(t, filtered, route.HAUniqueID("exit1-v6|::/0"), "explicit v6 select wins over v4 base")
+ assert.Empty(t, filtered, "deselecting v4 base must drop the v6 pair even if it was explicitly selected before")
})
- t.Run("non-v6-suffix routes unaffected", func(t *testing.T) {
- rs := routeselector.NewRouteSelector()
- require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
-
- // A route literally named "exit1-something" must not pair-resolve.
- assert.False(t, rs.HasUserSelectionForRoute("exit1-something"))
- })
-
- t.Run("filter v6 paired with deselected v4 base", func(t *testing.T) {
+ t.Run("filter drops synced v6 pair of deselected v4 base", func(t *testing.T) {
rs := routeselector.NewRouteSelector()
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
+ rs.SyncPairedSelection("exit1", "exit1-v6")
v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")}
v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")}
@@ -399,6 +424,15 @@ func TestRouteSelector_V6ExitPairInherits(t *testing.T) {
assert.Empty(t, filtered, "deselecting v4 base must also drop the v6 pair")
})
+ t.Run("deselectAll makes sync a no-op", func(t *testing.T) {
+ rs := routeselector.NewRouteSelector()
+ rs.DeselectAllRoutes()
+
+ rs.SyncPairedSelection("exit1", "exit1-v6")
+
+ assert.False(t, rs.HasUserSelectionForRoute("exit1-v6"), "sync must not write explicit state under deselectAll")
+ })
+
t.Run("non-exit *-v6 routes pass through FilterSelectedExitNodes", func(t *testing.T) {
rs := routeselector.NewRouteSelector()
require.NoError(t, rs.DeselectRoutes([]route.NetID{"corp"}, all))
diff --git a/client/ios/NetBirdSDK/client.go b/client/ios/NetBirdSDK/client.go
index bafbb0031..359a83556 100644
--- a/client/ios/NetBirdSDK/client.go
+++ b/client/ios/NetBirdSDK/client.go
@@ -17,6 +17,7 @@ import (
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
+ "github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/dns"
"github.com/netbirdio/netbird/client/internal/listener"
"github.com/netbirdio/netbird/client/internal/peer"
@@ -25,6 +26,7 @@ import (
"github.com/netbirdio/netbird/formatter"
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/domain"
+ types "github.com/netbirdio/netbird/upload-server/types"
)
// ConnectionListener export internal Listener for mobile
@@ -54,6 +56,7 @@ type selectRoute struct {
Network netip.Prefix
Domains domain.List
Selected bool
+ Status string
extraNetworks []netip.Prefix
}
@@ -65,6 +68,8 @@ func init() {
type Client struct {
cfgFile string
stateFile string
+ cacheDir string
+ logFilePath string
recorder *peer.Status
ctxCancel context.CancelFunc
ctxCancelLock *sync.Mutex
@@ -75,16 +80,21 @@ type Client struct {
onHostDnsFn func([]string)
dnsManager dns.IosDnsManager
loginComplete bool
- connectClient *internal.ConnectClient
// preloadedConfig holds config loaded from JSON (used on tvOS where file writes are blocked)
preloadedConfig *profilemanager.Config
+
+ stateMu sync.RWMutex
+ connectClient *internal.ConnectClient
+ config *profilemanager.Config
}
// NewClient instantiate a new Client
-func NewClient(cfgFile, stateFile, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client {
+func NewClient(cfgFile, stateFile, cacheDir, logFilePath, deviceName string, osVersion string, osName string, networkChangeListener NetworkChangeListener, dnsManager DnsManager) *Client {
return &Client{
cfgFile: cfgFile,
stateFile: stateFile,
+ cacheDir: cacheDir,
+ logFilePath: logFilePath,
deviceName: deviceName,
osName: osName,
osVersion: osVersion,
@@ -161,8 +171,13 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
c.onHostDnsFn = func([]string) {}
cfg.WgIface = interfaceName
- c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder)
- return c.connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile)
+ connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
+ c.setState(cfg, connectClient)
+ // Persist the latest sync response so DebugBundle can include the network
+ // map. On iOS this is backed by disk to keep it out of the constrained
+ // process memory (see the syncstore package).
+ connectClient.SetSyncResponsePersistence(true)
+ return connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile, c.cacheDir, c.logFilePath)
}
// Stop the internal client and free the resources
@@ -174,6 +189,84 @@ func (c *Client) Stop() {
}
c.ctxCancel()
+ c.setState(nil, nil)
+}
+
+// DebugBundle generates a debug bundle, uploads it and returns the upload key.
+// It works with or without a running engine: when the engine is up it reuses
+// the live config, sync response and client metrics; otherwise it loads the
+// config from disk (or the preloaded tvOS config).
+func (c *Client) DebugBundle(anonymize bool) (string, error) {
+ cfg, cc := c.stateSnapshot()
+
+ // If the engine hasn't been started, load config so we can reach management.
+ if cfg == nil {
+ if c.preloadedConfig != nil {
+ cfg = c.preloadedConfig
+ } else {
+ var err error
+ // Use DirectUpdateOrCreateConfig to avoid atomic file operations
+ // (temp file + rename) blocked by the tvOS sandbox.
+ cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: c.cfgFile,
+ StateFilePath: c.stateFile,
+ })
+ if err != nil {
+ return "", fmt.Errorf("load config: %w", err)
+ }
+ }
+ }
+
+ deps := debug.GeneratorDependencies{
+ InternalConfig: cfg,
+ StatusRecorder: c.recorder,
+ TempDir: c.cacheDir,
+ StatePath: c.stateFile,
+ LogPath: c.logFilePath,
+ }
+
+ if cc != nil {
+ resp, err := cc.GetLatestSyncResponse()
+ if err != nil {
+ log.Warnf("get latest sync response: %v", err)
+ }
+ deps.SyncResponse = resp
+
+ if e := cc.Engine(); e != nil {
+ if cm := e.GetClientMetrics(); cm != nil {
+ deps.ClientMetrics = cm
+ }
+ }
+ }
+
+ bundleGenerator := debug.NewBundleGenerator(
+ deps,
+ debug.BundleConfig{
+ Anonymize: anonymize,
+ IncludeSystemInfo: true,
+ },
+ )
+
+ path, err := bundleGenerator.Generate()
+ if err != nil {
+ return "", fmt.Errorf("generate debug bundle: %w", err)
+ }
+ defer func() {
+ if err := os.Remove(path); err != nil {
+ log.Errorf("failed to remove debug bundle file: %v", err)
+ }
+ }()
+
+ uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path)
+ if err != nil {
+ return "", fmt.Errorf("upload debug bundle: %w", err)
+ }
+
+ log.Infof("debug bundle uploaded with key %s", key)
+ return key, nil
}
// SetTraceLogLevel configure the logger to trace level
@@ -227,6 +320,16 @@ func (c *Client) RemoveConnectionListener() {
c.recorder.RemoveConnectionListener()
}
+// IsLoginRequiredCached reports whether the LAST observed management error was an
+// auth failure (PermissionDenied/InvalidArgument), using the in-memory status
+// recorder. Unlike IsLoginRequired() it performs NO network call, so it is safe to
+// call from the connection listener during teardown (e.g. onDisconnected) without
+// blocking on a slow or unavailable network. Returns false while connected to
+// management or when the last error was not auth-related.
+func (c *Client) IsLoginRequiredCached() bool {
+ return c.recorder.IsLoginRequired()
+}
+
func (c *Client) IsLoginRequired() bool {
var ctx context.Context
//nolint
@@ -354,11 +457,12 @@ func (c *Client) ClearLoginComplete() {
}
func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) {
- if c.connectClient == nil {
+ _, connectClient := c.stateSnapshot()
+ if connectClient == nil {
return nil, fmt.Errorf("not connected")
}
- engine := c.connectClient.Engine()
+ engine := connectClient.Engine()
if engine == nil {
return nil, fmt.Errorf("not connected")
}
@@ -377,9 +481,57 @@ func (c *Client) GetRoutesSelectionDetails() (*RoutesSelectionDetails, error) {
routes := buildSelectRoutes(routesMap, routeSelector.IsSelected, v6ExitMerged)
resolvedDomains := c.recorder.GetResolvedDomainsStates()
+ // Compute each route's connection status in the core (mirroring the Android
+ // bridge), so the UI doesn't have to infer it by string-matching the joined
+ // Network value against peer routes. For a merged exit node the status reflects
+ // whichever of the v4/v6 prefixes is served by a connected peer; for dynamic
+ // (DNS) routes the peer route key is the domain pattern (see dynamic.Route.String).
+ connectedRoutes := c.connectedRouteSet()
+ for _, r := range routes {
+ r.Status = routeStatus(r, connectedRoutes)
+ }
+
return prepareRouteSelectionDetails(routes, resolvedDomains), nil
}
+// connectedRouteSet returns the set of route keys (as strings) currently served by a
+// connected peer, gathered across all connected peers' route tables. The keys match
+// what the route manager records: a prefix string for static routes (e.g. "0.0.0.0/0")
+// and the domain pattern for dynamic routes (e.g. "*.example.com").
+func (c *Client) connectedRouteSet() map[string]struct{} {
+ connected := map[string]struct{}{}
+ for _, p := range c.recorder.GetFullStatus().Peers {
+ if p.ConnStatus != peer.StatusConnected {
+ continue
+ }
+ for r := range p.GetRoutes() {
+ connected[r] = struct{}{}
+ }
+ }
+ return connected
+}
+
+// routeStatus reports "Connected" if any of the route's keys is served by a connected
+// peer: the primary Network prefix, an extra v6 network of a merged exit node, or the
+// domain pattern for a dynamic DNS route. Otherwise "Idle".
+func routeStatus(r *selectRoute, connectedRoutes map[string]struct{}) string {
+ keys := make([]string, 0, 1+len(r.extraNetworks))
+ if len(r.Domains) > 0 {
+ keys = append(keys, r.Domains.SafeString())
+ } else {
+ keys = append(keys, r.Network.String())
+ }
+ for _, extra := range r.extraNetworks {
+ keys = append(keys, extra.String())
+ }
+ for _, k := range keys {
+ if _, ok := connectedRoutes[k]; ok {
+ return peer.StatusConnected.String()
+ }
+ }
+ return peer.StatusIdle.String()
+}
+
func buildSelectRoutes(routesMap map[route.NetID][]*route.Route, isSelected func(route.NetID) bool, v6Merged map[route.NetID]struct{}) []*selectRoute {
var routes []*selectRoute
for id, rt := range routesMap {
@@ -462,6 +614,7 @@ func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[dom
Network: netStr,
Domains: &domainDetails,
Selected: r.Selected,
+ Status: r.Status,
})
}
@@ -470,11 +623,12 @@ func prepareRouteSelectionDetails(routes []*selectRoute, resolvedDomains map[dom
}
func (c *Client) SelectRoute(id string) error {
- if c.connectClient == nil {
+ _, connectClient := c.stateSnapshot()
+ if connectClient == nil {
return fmt.Errorf("not connected")
}
- engine := c.connectClient.Engine()
+ engine := connectClient.Engine()
if engine == nil {
return fmt.Errorf("not connected")
}
@@ -500,10 +654,11 @@ func (c *Client) SelectRoute(id string) error {
}
func (c *Client) DeselectRoute(id string) error {
- if c.connectClient == nil {
+ _, connectClient := c.stateSnapshot()
+ if connectClient == nil {
return fmt.Errorf("not connected")
}
- engine := c.connectClient.Engine()
+ engine := connectClient.Engine()
if engine == nil {
return fmt.Errorf("not connected")
}
@@ -527,6 +682,22 @@ func (c *Client) DeselectRoute(id string) error {
return nil
}
+// setState stores the running engine state so DebugBundle can reuse the live
+// config and ConnectClient. It is cleared on Stop.
+func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) {
+ c.stateMu.Lock()
+ defer c.stateMu.Unlock()
+ c.config = cfg
+ c.connectClient = cc
+}
+
+// stateSnapshot returns the current config and ConnectClient under the lock.
+func (c *Client) stateSnapshot() (*profilemanager.Config, *internal.ConnectClient) {
+ c.stateMu.RLock()
+ defer c.stateMu.RUnlock()
+ return c.config, c.connectClient
+}
+
func formatDuration(d time.Duration) string {
ds := d.String()
dotIndex := strings.Index(ds, ".")
diff --git a/client/ios/NetBirdSDK/routes.go b/client/ios/NetBirdSDK/routes.go
index 025313bfa..56af2a1ad 100644
--- a/client/ios/NetBirdSDK/routes.go
+++ b/client/ios/NetBirdSDK/routes.go
@@ -20,6 +20,7 @@ type RoutesSelectionInfo struct {
Network string
Domains *DomainDetails
Selected bool
+ Status string
}
type DomainCollection interface {
diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go
new file mode 100644
index 000000000..6e7ab19cb
--- /dev/null
+++ b/client/mdm/canonical_loaders.go
@@ -0,0 +1,50 @@
+//go:build windows || darwin
+
+package mdm
+
+import "strings"
+
+// allKeys is the set of recognised MDM keys. Unknown keys in a managed
+// configuration are ignored but logged. Lives in this build-tagged file
+// (windows || darwin) because only desktop loaders need the
+// canonicalisation table that consumes it; including it unconditionally
+// would trigger the `unused` golangci-lint check on platforms that
+// don't import canonical_loaders.go.
+var allKeys = []string{
+ KeyManagementURL,
+ KeyDisableUpdateSettings,
+ KeyDisableProfiles,
+ KeyDisableNetworks,
+ KeyDisableClientRoutes,
+ KeyDisableServerRoutes,
+ KeyBlockInbound,
+ KeyDisableMetricsCollection,
+ KeyAllowServerSSH,
+ KeyDisableAutoConnect,
+ KeyPreSharedKey,
+ KeyRosenpassEnabled,
+ KeyRosenpassPermissive,
+ KeyWireguardPort,
+ KeySplitTunnelMode,
+ KeySplitTunnelApps,
+}
+
+// canonicalKey maps the lowercase form of a managed-config value name to
+// its canonical mdm.Key* form. Admins commonly write PascalCase value
+// names in ADMX / Group Policy ("ManagementURL"); the iOS/AppConfig and
+// macOS plist conventions are camelCase ("managementURL"); both must
+// resolve to the same Policy lookup.
+//
+// Lives in a desktop-loader-only file (build tag `windows || darwin`)
+// because no other build path consumes it. Linux / FreeBSD / mobile
+// builds don't ship a platform loader that reads arbitrary-case key
+// names, so they don't need the canonicalisation table — and including
+// the var unconditionally would trigger the `unused` golangci-lint
+// check on those platforms.
+var canonicalKey = func() map[string]string {
+ m := make(map[string]string, len(allKeys))
+ for _, k := range allKeys {
+ m[strings.ToLower(k)] = k
+ }
+ return m
+}()
diff --git a/client/mdm/policy.go b/client/mdm/policy.go
new file mode 100644
index 000000000..109fb322e
--- /dev/null
+++ b/client/mdm/policy.go
@@ -0,0 +1,247 @@
+// Package mdm reads MDM-managed configuration from platform-native sources
+// (plist on macOS, registry on Windows, UserDefaults on iOS,
+// RestrictionsManager on Android). The returned Policy is consumed by
+// profilemanager.Config.apply() as the highest-priority override layer.
+//
+// An empty Policy (no source present, or source present with zero keys)
+// means no MDM enforcement is active and the client behaves as if the
+// feature did not exist.
+package mdm
+
+import (
+ "sort"
+ "strconv"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// Well-known policy keys. Names mirror the corresponding ConfigInput Go field
+// names (lowerCamelCase) so the daemon can map a Policy key directly to a
+// configuration field.
+const (
+ KeyManagementURL = "managementURL"
+ KeyDisableUpdateSettings = "disableUpdateSettings"
+ KeyDisableProfiles = "disableProfiles"
+ KeyDisableNetworks = "disableNetworks"
+ KeyDisableClientRoutes = "disableClientRoutes"
+ KeyDisableServerRoutes = "disableServerRoutes"
+ KeyBlockInbound = "blockInbound"
+ KeyDisableMetricsCollection = "disableMetricsCollection"
+ KeyAllowServerSSH = "allowServerSSH"
+ KeyDisableAutoConnect = "disableAutoConnect"
+ KeyPreSharedKey = "preSharedKey"
+ KeyRosenpassEnabled = "rosenpassEnabled"
+ KeyRosenpassPermissive = "rosenpassPermissive"
+ KeyWireguardPort = "wireguardPort"
+
+ // Split tunnel is modeled as a single conceptual policy with two
+ // registry/plist values. KeySplitTunnelMode is the discriminator
+ // ("allow" or "disallow"); KeySplitTunnelApps is a comma-separated
+ // list of package names. The values are mutually exclusive by
+ // construction — only one mode can be set at a time.
+ KeySplitTunnelMode = "splitTunnelMode"
+ KeySplitTunnelApps = "splitTunnelApps"
+)
+
+// Split-tunnel mode literals (KeySplitTunnelMode values).
+const (
+ SplitTunnelModeAllow = "allow"
+ SplitTunnelModeDisallow = "disallow"
+)
+
+// SecretKeys lists keys whose values must be redacted in logs.
+var SecretKeys = map[string]struct{}{
+ KeyPreSharedKey: {},
+}
+
+// boolStringLiterals enumerates the textual boolean encodings the
+// platform loaders may produce (Windows REG_SZ "true", iOS / Android
+// managed-config booleans-as-strings, etc.). Lookup keeps GetBool flat
+// (no nested switch on the string case).
+var boolStringLiterals = map[string]bool{
+ "true": true,
+ "1": true,
+ "yes": true,
+ "false": false,
+ "0": false,
+ "no": false,
+}
+
+
+// Policy holds MDM-managed settings read from the platform source. A nil or
+// empty Policy means no enforcement is active.
+type Policy struct {
+ values map[string]any
+}
+
+// NewPolicy constructs a Policy from a key→value map. Pass nil or an
+// empty map to construct an empty (no-enforcement) Policy. The returned
+// *Policy is always non-nil.
+func NewPolicy(values map[string]any) *Policy {
+ if values == nil {
+ values = map[string]any{}
+ }
+ return &Policy{values: values}
+}
+
+// LoadPolicy reads the platform-native MDM configuration. Returns an
+// empty (but non-nil) Policy when no source is present, the source is
+// empty, or the platform is unsupported.
+//
+// Diagnostic logging differentiates the three states:
+// - source absent / unsupported platform: trace log only
+// - source present, zero keys: info "MDM enrolled (no managed keys)"
+// - source present, N keys: info "MDM enrolled with N managed keys: [...]"
+func LoadPolicy() *Policy {
+ values, err := loadPlatformPolicy()
+ if err != nil {
+ log.Tracef("MDM policy load: %v", err)
+ return &Policy{values: map[string]any{}}
+ }
+ if values == nil {
+ return &Policy{values: map[string]any{}}
+ }
+ if len(values) == 0 {
+ log.Info("MDM enrolled (no managed keys)")
+ } else {
+ log.Infof("MDM enrolled with %d managed key(s): %v", len(values), sortedKeys(values))
+ }
+ return &Policy{values: values}
+}
+
+// IsEmpty reports whether the Policy has no managed keys.
+func (p *Policy) IsEmpty() bool {
+ return p == nil || len(p.values) == 0
+}
+
+// HasKey reports whether the given key is MDM-managed.
+func (p *Policy) HasKey(key string) bool {
+ if p == nil {
+ return false
+ }
+ _, ok := p.values[key]
+ return ok
+}
+
+// ManagedKeys returns the sorted list of managed key names. Returns an empty
+// slice (not nil) on an empty Policy.
+func (p *Policy) ManagedKeys() []string {
+ if p == nil {
+ return []string{}
+ }
+ return sortedKeys(p.values)
+}
+
+// GetString returns the managed value for key coerced to string, and whether
+// the key was set. A non-string value returns ("", false).
+func (p *Policy) GetString(key string) (string, bool) {
+ if p == nil {
+ return "", false
+ }
+ v, ok := p.values[key]
+ if !ok {
+ return "", false
+ }
+ s, ok := v.(string)
+ if !ok || s == "" {
+ return "", false
+ }
+ return s, true
+}
+
+// GetBool returns the managed value for key coerced to bool, and whether the
+// key was set. Accepts native bool and string literals "true"/"false"/"1"/"0".
+func (p *Policy) GetBool(key string) (bool, bool) {
+ if p == nil {
+ return false, false
+ }
+ v, ok := p.values[key]
+ if !ok {
+ return false, false
+ }
+ switch t := v.(type) {
+ case bool:
+ return t, true
+ case string:
+ b, known := boolStringLiterals[t]
+ return b, known
+ case int:
+ return t != 0, true
+ case int64:
+ return t != 0, true
+ }
+ return false, false
+}
+
+// GetInt returns the managed value for key as int64, and whether the key
+// was set. Accepts native int / int64 (as produced by the Windows registry
+// loader for REG_DWORD/REG_QWORD) and numeric strings (decimal).
+func (p *Policy) GetInt(key string) (int64, bool) {
+ if p == nil {
+ return 0, false
+ }
+ v, ok := p.values[key]
+ if !ok {
+ return 0, false
+ }
+ switch t := v.(type) {
+ case int64:
+ return t, true
+ case int:
+ return int64(t), true
+ case int32:
+ return int64(t), true
+ case uint64:
+ return int64(t), true
+ case float64:
+ return int64(t), true
+ case string:
+ if n, err := strconv.ParseInt(t, 10, 64); err == nil {
+ return n, true
+ }
+ }
+ return 0, false
+}
+
+// GetStringSlice returns the managed value for key as []string, and whether
+// the key was set. Accepts []string, []any (of strings), and a single string
+// (treated as a one-element list).
+func (p *Policy) GetStringSlice(key string) ([]string, bool) {
+ if p == nil {
+ return nil, false
+ }
+ v, ok := p.values[key]
+ if !ok {
+ return nil, false
+ }
+ switch t := v.(type) {
+ case []string:
+ return append([]string(nil), t...), true
+ case []any:
+ out := make([]string, 0, len(t))
+ for _, item := range t {
+ s, ok := item.(string)
+ if !ok {
+ return nil, false
+ }
+ out = append(out, s)
+ }
+ return out, true
+ case string:
+ return []string{t}, true
+ }
+ return nil, false
+}
+
+// sortedKeys returns the keys of m as a deterministic, lexicographically
+// sorted slice. Used internally by Policy.ManagedKeys and LoadPolicy's
+// diagnostic log line so callers see a stable key order across runs
+// regardless of Go's randomised map iteration.
+func sortedKeys(m map[string]any) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
diff --git a/client/mdm/policy_darwin.go b/client/mdm/policy_darwin.go
new file mode 100644
index 000000000..57aa1168c
--- /dev/null
+++ b/client/mdm/policy_darwin.go
@@ -0,0 +1,90 @@
+//go:build darwin && !ios
+
+package mdm
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+ "howett.net/plist"
+)
+
+// policyPlistPath is the well-known location where macOS writes the
+// device-level mandatory MDM payload for NetBird. The path is fixed by
+// Apple convention: when an MDM provider (Jamf / Kandji / Mosyle /
+// Intune for Mac / Workspace ONE) pushes a Configuration Profile that
+// contains a com.apple.ManagedClient.preferences payload targeting the
+// bundle id io.netbird.client, the OS materializes the payload here.
+//
+// Read-only — only the OS (root) is supposed to write this file. The
+// loader sanity-checks the file mode and refuses to honour a world-
+// writable plist, as a defense against tampered installs.
+const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist"
+
+// loadPlatformPolicy reads the MDM-managed configuration from the macOS
+// managed-preferences plist at policyPlistPath. Returns:
+// - (nil, nil) when the plist is absent (device not MDM-enrolled for
+// NetBird, or admin has not yet pushed a payload)
+// - (map, nil) with N entries when N managed values are present
+// (N may be 0 — empty plist still signals enrollment to the caller)
+// - (nil, err) on permission / parse / safety errors (including
+// refusal to read a world-writable plist)
+//
+// Top-level plist keys are canonicalised case-insensitively to the
+// package's internal mdm.Key* names; unknown keys are logged and
+// skipped so a stray entry in the payload does not block startup.
+// Native plist value types map naturally onto the Policy accessor
+// expectations (GetString / GetBool / GetInt / GetStringSlice).
+func loadPlatformPolicy() (map[string]any, error) {
+ f, err := os.Open(policyPlistPath)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ // Not enrolled for NetBird. Caller treats nil as
+ // "no MDM source present".
+ //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
+ return nil, nil
+ }
+ return nil, fmt.Errorf("open %s: %w", policyPlistPath, err)
+ }
+ defer func() {
+ if closeErr := f.Close(); closeErr != nil {
+ log.Warnf("MDM close plist %s: %v", policyPlistPath, closeErr)
+ }
+ }()
+
+ info, err := f.Stat()
+ if err != nil {
+ return nil, fmt.Errorf("stat %s: %w", policyPlistPath, err)
+ }
+ // World-writable plist => tampered install. Refuse rather than
+ // honour potentially attacker-controlled policy values.
+ if info.Mode().Perm()&0o002 != 0 {
+ return nil, fmt.Errorf("refusing to read world-writable MDM source %s (mode %o)",
+ policyPlistPath, info.Mode().Perm())
+ }
+
+ raw := make(map[string]any)
+ if err := plist.NewDecoder(f).Decode(&raw); err != nil {
+ return nil, fmt.Errorf("decode plist %s: %w", policyPlistPath, err)
+ }
+
+ out := make(map[string]any, len(raw))
+ for name, val := range raw {
+ // macOS / AppConfig conventions both use camelCase for managed
+ // preferences keys; canonicalize to the mdm.Key* form so a key
+ // written as "ManagementURL" (PascalCase, rare on macOS but
+ // possible if the admin reused an ADMX-style name) still
+ // resolves.
+ canonical, known := canonicalKey[strings.ToLower(name)]
+ if !known {
+ log.Warnf("MDM ignoring unknown plist key %s: %s", policyPlistPath, name)
+ continue
+ }
+ out[canonical] = val
+ }
+ return out, nil
+}
diff --git a/client/mdm/policy_mobile.go b/client/mdm/policy_mobile.go
new file mode 100644
index 000000000..ec25d4bb1
--- /dev/null
+++ b/client/mdm/policy_mobile.go
@@ -0,0 +1,14 @@
+//go:build ios || android
+
+package mdm
+
+// loadPlatformPolicy is unused on mobile: the native layer (Swift on iOS,
+// Kotlin/Java on Android) reads the OS managed-config store and pushes the
+// resulting dictionary in-process via a gomobile entry point that lands in
+// Phase 5 / Phase 6. The stub keeps the package compilable for mobile
+// builds and returns (nil, nil) — the platform-absent sentinel that
+// LoadPolicy in policy.go treats as "no MDM source present".
+func loadPlatformPolicy() (map[string]any, error) {
+ //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
+ return nil, nil
+}
diff --git a/client/mdm/policy_other.go b/client/mdm/policy_other.go
new file mode 100644
index 000000000..f4263afa2
--- /dev/null
+++ b/client/mdm/policy_other.go
@@ -0,0 +1,14 @@
+//go:build !windows && !darwin && !ios && !android
+
+package mdm
+
+// loadPlatformPolicy returns no policy on platforms without an MDM channel
+// (Linux, FreeBSD). MDM enforcement is off and the client behaves as if
+// the feature did not exist. Returns (nil, nil) — the platform-absent
+// sentinel the caller (LoadPolicy in policy.go) treats as "no MDM
+// source present"; an error here would just translate to the same
+// outcome with an extra log line.
+func loadPlatformPolicy() (map[string]any, error) {
+ //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
+ return nil, nil
+}
diff --git a/client/mdm/policy_test.go b/client/mdm/policy_test.go
new file mode 100644
index 000000000..47a6ed2c9
--- /dev/null
+++ b/client/mdm/policy_test.go
@@ -0,0 +1,160 @@
+package mdm
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPolicy_NilSafe(t *testing.T) {
+ var p *Policy
+ assert.True(t, p.IsEmpty())
+ assert.False(t, p.HasKey(KeyManagementURL))
+ assert.Empty(t, p.ManagedKeys())
+
+ _, ok := p.GetString(KeyManagementURL)
+ assert.False(t, ok)
+ _, ok = p.GetBool(KeyDisableProfiles)
+ assert.False(t, ok)
+ _, ok = p.GetStringSlice(KeySplitTunnelApps)
+ assert.False(t, ok)
+}
+
+func TestPolicy_Empty(t *testing.T) {
+ p := NewPolicy(nil)
+ require.NotNil(t, p)
+ assert.True(t, p.IsEmpty())
+ assert.False(t, p.HasKey(KeyManagementURL))
+ assert.Empty(t, p.ManagedKeys())
+}
+
+func TestPolicy_HasKey(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeyManagementURL: "https://corp.example.com",
+ KeyDisableProfiles: true,
+ })
+ assert.False(t, p.IsEmpty())
+ assert.True(t, p.HasKey(KeyManagementURL))
+ assert.True(t, p.HasKey(KeyDisableProfiles))
+ assert.False(t, p.HasKey(KeyPreSharedKey))
+}
+
+func TestPolicy_ManagedKeysSorted(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeyDisableProfiles: true,
+ KeyManagementURL: "https://x",
+ KeyAllowServerSSH: false,
+ })
+ got := p.ManagedKeys()
+ assert.Equal(t, []string{KeyAllowServerSSH, KeyDisableProfiles, KeyManagementURL}, got)
+}
+
+func TestPolicy_GetString(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeyManagementURL: "https://corp.example.com",
+ KeyDisableProfiles: true, // wrong type for GetString
+ KeyPreSharedKey: "", // empty rejected
+ })
+ v, ok := p.GetString(KeyManagementURL)
+ assert.True(t, ok)
+ assert.Equal(t, "https://corp.example.com", v)
+
+ _, ok = p.GetString(KeyDisableProfiles)
+ assert.False(t, ok, "non-string value must not be reported as string")
+
+ _, ok = p.GetString(KeyPreSharedKey)
+ assert.False(t, ok, "empty string treated as unset")
+
+ _, ok = p.GetString("nonexistent")
+ assert.False(t, ok)
+}
+
+func TestPolicy_GetBool(t *testing.T) {
+ cases := []struct {
+ name string
+ raw any
+ want bool
+ ok bool
+ }{
+ {"native true", true, true, true},
+ {"native false", false, false, true},
+ {"string true", "true", true, true},
+ {"string false", "false", false, true},
+ {"string 1", "1", true, true},
+ {"string 0", "0", false, true},
+ {"string yes", "yes", true, true},
+ {"string no", "no", false, true},
+ {"int nonzero", 1, true, true},
+ {"int zero", 0, false, true},
+ {"int64 nonzero", int64(2), true, true},
+ {"int64 zero", int64(0), false, true},
+ {"string garbage", "maybe", false, false},
+ {"float unsupported", 1.0, false, false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ p := NewPolicy(map[string]any{KeyDisableProfiles: c.raw})
+ got, ok := p.GetBool(KeyDisableProfiles)
+ assert.Equal(t, c.ok, ok)
+ if c.ok {
+ assert.Equal(t, c.want, got)
+ }
+ })
+ }
+
+ _, ok := NewPolicy(nil).GetBool(KeyDisableProfiles)
+ assert.False(t, ok)
+}
+
+func TestPolicy_GetStringSlice(t *testing.T) {
+ t.Run("native string slice", func(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeySplitTunnelApps: []string{"com.a", "com.b"},
+ })
+ got, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.True(t, ok)
+ assert.Equal(t, []string{"com.a", "com.b"}, got)
+ })
+
+ t.Run("any slice of strings", func(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeySplitTunnelApps: []any{"com.a", "com.b"},
+ })
+ got, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.True(t, ok)
+ assert.Equal(t, []string{"com.a", "com.b"}, got)
+ })
+
+ t.Run("single string lifts to one-element slice", func(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeySplitTunnelApps: "com.a",
+ })
+ got, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.True(t, ok)
+ assert.Equal(t, []string{"com.a"}, got)
+ })
+
+ t.Run("mixed any slice rejected", func(t *testing.T) {
+ p := NewPolicy(map[string]any{
+ KeySplitTunnelApps: []any{"com.a", 1},
+ })
+ _, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.False(t, ok)
+ })
+
+ t.Run("missing key", func(t *testing.T) {
+ p := NewPolicy(nil)
+ _, ok := p.GetStringSlice(KeySplitTunnelApps)
+ assert.False(t, ok)
+ })
+}
+
+func TestLoadPolicy_PlatformStubReturnsEmpty(t *testing.T) {
+ // loadPlatformPolicy is a stub on every OS for Phase 1. LoadPolicy must
+ // degrade gracefully and never return nil.
+ p := LoadPolicy()
+ require.NotNil(t, p)
+ assert.True(t, p.IsEmpty())
+ assert.Empty(t, p.ManagedKeys())
+}
diff --git a/client/mdm/policy_windows.go b/client/mdm/policy_windows.go
new file mode 100644
index 000000000..0c2629f98
--- /dev/null
+++ b/client/mdm/policy_windows.go
@@ -0,0 +1,108 @@
+//go:build windows
+
+package mdm
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+ "golang.org/x/sys/windows/registry"
+)
+
+// policyRegistryPath is the well-known MDM policy registry key for NetBird.
+// Admins push values here through Group Policy, Intune ADMX ingestion, an
+// Intune custom Registry CSP profile, or `reg add` during MSI deployment.
+// Listed in the project's docs/mdm/netbird.admx schema.
+const policyRegistryPath = `Software\Policies\NetBird`
+
+// readRegistryValue reads a single value under policyRegistryPath and,
+// on success, stores the type-coerced result in out[canonical]. Type
+// coercion mirrors loadPlatformPolicy's documented mapping:
+// - REG_SZ / REG_EXPAND_SZ -> string (REG_EXPAND_SZ is expanded by the API)
+// - REG_DWORD / REG_QWORD -> int64
+// - REG_MULTI_SZ -> []string
+//
+// Unsupported value types and per-value read failures are logged at
+// warn level and skipped — one malformed value must not block the
+// surrounding loop. Extracted from loadPlatformPolicy to keep that
+// function's cognitive complexity in check.
+func readRegistryValue(k registry.Key, name, canonical string, out map[string]any) {
+ _, valType, err := k.GetValue(name, nil)
+ if err != nil {
+ log.Warnf("MDM stat %s\\%s: %v", policyRegistryPath, name, err)
+ return
+ }
+ switch valType {
+ case registry.SZ, registry.EXPAND_SZ:
+ if v, _, err := k.GetStringValue(name); err == nil {
+ out[canonical] = v
+ } else {
+ log.Warnf("MDM read string %s\\%s: %v", policyRegistryPath, name, err)
+ }
+ case registry.DWORD, registry.QWORD:
+ if v, _, err := k.GetIntegerValue(name); err == nil {
+ // uint64 from the registry API; Policy.GetBool / GetInt
+ // helpers consume int64, so narrow safely.
+ out[canonical] = int64(v)
+ } else {
+ log.Warnf("MDM read int %s\\%s: %v", policyRegistryPath, name, err)
+ }
+ case registry.MULTI_SZ:
+ if v, _, err := k.GetStringsValue(name); err == nil {
+ out[canonical] = v
+ } else {
+ log.Warnf("MDM read multi-string %s\\%s: %v", policyRegistryPath, name, err)
+ }
+ default:
+ log.Warnf("MDM ignoring unsupported registry value type %d at %s\\%s",
+ valType, policyRegistryPath, name)
+ }
+}
+
+// loadPlatformPolicy reads the MDM-managed configuration from the
+// Windows registry under HKLM\Software\Policies\NetBird. Returns:
+// - (nil, nil) when the key is absent (device not MDM-enrolled for NetBird)
+// - (map, nil) with N entries when N managed values are set (N may be 0)
+// - (nil, err) on open / enumerate registry errors
+//
+// Per-value type coercion + skip-on-error is delegated to
+// readRegistryValue. Unknown value names are logged and skipped so a
+// malformed deployment does not block startup.
+func loadPlatformPolicy() (map[string]any, error) {
+ k, err := registry.OpenKey(registry.LOCAL_MACHINE, policyRegistryPath, registry.QUERY_VALUE)
+ if err != nil {
+ if errors.Is(err, registry.ErrNotExist) {
+ // Not enrolled. Caller treats nil as "no MDM source present".
+ //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy.
+ return nil, nil
+ }
+ return nil, fmt.Errorf("open %s: %w", policyRegistryPath, err)
+ }
+ defer func() {
+ if closeErr := k.Close(); closeErr != nil {
+ log.Warnf("MDM close registry key %s: %v", policyRegistryPath, closeErr)
+ }
+ }()
+
+ names, err := k.ReadValueNames(-1)
+ if err != nil {
+ return nil, fmt.Errorf("enumerate values of %s: %w", policyRegistryPath, err)
+ }
+
+ out := make(map[string]any, len(names))
+ for _, name := range names {
+ // Canonicalize the registry value name against the known MDM key
+ // set so Policy.HasKey lookups (which use the canonical names)
+ // succeed regardless of the casing used by the admin's ADMX or
+ // `reg add` command.
+ canonical, known := canonicalKey[strings.ToLower(name)]
+ if !known {
+ log.Warnf("MDM ignoring unknown registry value %s\\%s", policyRegistryPath, name)
+ continue
+ }
+ readRegistryValue(k, name, canonical, out)
+ }
+ return out, nil
+}
diff --git a/client/mdm/ticker.go b/client/mdm/ticker.go
new file mode 100644
index 000000000..abd6ae233
--- /dev/null
+++ b/client/mdm/ticker.go
@@ -0,0 +1,129 @@
+package mdm
+
+import (
+ "context"
+ "reflect"
+ "sort"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// DefaultReloadInterval is the production cadence at which the desktop daemon
+// re-reads the OS-native MDM policy. Picked to balance responsiveness against
+// registry/plist I/O overhead. Mobile builds use OS-side notifications
+// instead, hence anticipating the ticker mechanism entirely.
+const DefaultReloadInterval = 1 * time.Minute
+
+// policyLoader is the indirection through which the ticker reads the
+// OS-native policy, both for the initial observation and on every tick.
+// Production points it at LoadPolicy; tests in this package override it to
+// feed a scripted sequence of policies without touching the real OS store.
+var policyLoader = LoadPolicy
+
+// Ticker periodically re-reads the OS-native MDM policy via LoadPolicy and
+// invokes the onChange callback (supplied to Run) whenever the observed
+// Policy diverges from the last observation (added / removed / changed
+// keys). Launch with Run from a goroutine; cancel the supplied context
+// to stop.
+type Ticker struct {
+ interval time.Duration
+ prev *Policy
+}
+
+// NewTicker constructs a Ticker that will re-read the OS-native policy
+// every reloadInterval once Run is called.
+// The initial snapshot is populated by calling policyLoader at
+// construction time so the first tick only fires
+// onChange when the policy actually changed since boot — without
+// this baseline the first tick would report every currently-managed
+// key as "added" and trigger a spurious engine restart.
+func NewTicker(reloadInterval time.Duration) *Ticker {
+ return &Ticker{
+ interval: reloadInterval,
+ prev: policyLoader(),
+ }
+}
+
+// Run blocks until ctx is cancelled, polling the OS-native policy store at
+// the configured cadence and emitting log lines + onChange callback on
+// every observed diff. onChange must be non-nil.
+func (t *Ticker) Run(ctx context.Context, onChange func(prev, curr *Policy) error) {
+ tk := time.NewTicker(t.interval)
+ defer tk.Stop()
+ log.Infof("MDM policy reload ticker started (interval=%s)", t.interval)
+ for {
+ select {
+ case <-ctx.Done():
+ log.Info("MDM policy reload ticker stopped")
+ return
+ case <-tk.C:
+ curr := policyLoader()
+ if policiesEqual(t.prev, curr) {
+ continue
+ }
+ added, removed, changed := diffPolicies(t.prev, curr)
+ log.Infof("MDM policy changed: added=%v removed=%v changed=%v",
+ added, removed, changed)
+ prev := t.prev
+ if err := onChange(prev, curr); err != nil {
+ log.Errorf("MDM policy change handler failed (retrying in 1 minute): %v", err)
+ continue
+ }
+ t.prev = curr
+ }
+ }
+}
+
+// policiesEqual reports whether two Policy instances carry the same
+// managed key set with identical values. Nil and empty policies
+// compare equal; one-nil/one-non-empty compare not equal; otherwise
+// the underlying values maps are compared with reflect.DeepEqual.
+func policiesEqual(a, b *Policy) bool {
+ if a.IsEmpty() && b.IsEmpty() {
+ return true
+ }
+ if a == nil || b == nil {
+ return false
+ }
+ return reflect.DeepEqual(a.values, b.values)
+}
+
+// diffPolicies returns the keys added in curr, removed from prev, and
+// whose values changed between prev and curr. Each slice is sorted
+// lexicographically for stable log output; value differences are
+// determined with reflect.DeepEqual.
+func diffPolicies(prev, curr *Policy) (added, removed, changed []string) {
+ prevKVs := mapOf(prev)
+ currKVs := mapOf(curr)
+ for k := range currKVs {
+ if _, ok := prevKVs[k]; !ok {
+ added = append(added, k)
+ } else if !reflect.DeepEqual(prevKVs[k], currKVs[k]) {
+ changed = append(changed, k)
+ }
+ }
+ for k := range prevKVs {
+ if _, ok := currKVs[k]; !ok {
+ removed = append(removed, k)
+ }
+ }
+ sort.Strings(added)
+ sort.Strings(removed)
+ sort.Strings(changed)
+ return added, removed, changed
+}
+
+// mapOf returns a (possibly empty, never nil) copy of the underlying
+// values map of a Policy so callers outside this package can compare
+// keys/values across the type boundary. Returns an empty map on nil p.
+func mapOf(p *Policy) map[string]any {
+ if p == nil {
+ return map[string]any{}
+ }
+ out := make(map[string]any, len(p.values))
+ for k, v := range p.values {
+ out[k] = v
+ }
+ return out
+}
diff --git a/client/mdm/ticker_test.go b/client/mdm/ticker_test.go
new file mode 100644
index 000000000..17f3cfc2f
--- /dev/null
+++ b/client/mdm/ticker_test.go
@@ -0,0 +1,100 @@
+package mdm
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// testReloadInterval for speeding up the ticker cadence under `go test`
+const testReloadInterval = 1 * time.Second
+
+// withPolicyLoader overrides the package-level policyLoader for the duration
+// of the test so the ticker observes a scripted policy instead of the real
+// OS-native store. The original loader is restored on cleanup.
+func withPolicyLoader(t *testing.T, fn func() *Policy) {
+ t.Helper()
+ prev := policyLoader
+ policyLoader = fn
+ t.Cleanup(func() { policyLoader = prev })
+}
+
+func TestTicker_FiresOnChangeWithDelta(t *testing.T) {
+ var mu sync.Mutex
+ current := NewPolicy(nil) // initial observation: empty (no enforcement)
+ withPolicyLoader(t, func() *Policy {
+ mu.Lock()
+ defer mu.Unlock()
+ return current
+ })
+
+ type change struct{ prev, curr *Policy }
+ changes := make(chan change, 1)
+ tk := NewTicker(testReloadInterval)
+ require.Equal(t, testReloadInterval, tk.interval)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() {
+ tk.Run(ctx, func(prev, curr *Policy) error {
+ select {
+ case changes <- change{prev, curr}:
+ default:
+ }
+ return nil
+ })
+ close(done)
+ }()
+ // Stop Run and wait for it to exit before returning, so the policyLoader
+ // restore in t.Cleanup can't race the ticker goroutine still reading it.
+ defer func() { cancel(); <-done }()
+
+ // Flip the OS-observed policy from empty to one managed key. The next
+ // tick must detect the diff and invoke onChange.
+ mu.Lock()
+ current = NewPolicy(map[string]any{KeyManagementURL: "https://mdm.example.com:443"})
+ mu.Unlock()
+
+ select {
+ case c := <-changes:
+ assert.True(t, c.prev.IsEmpty(), "prev should be the initial empty policy")
+ assert.True(t, c.curr.HasKey(KeyManagementURL), "curr should carry the newly-pushed managed key")
+ case <-time.After(5 * time.Second):
+ t.Fatal("onChange not invoked within 5s; ticker should fire every 1s under test")
+ }
+}
+
+func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) {
+ withPolicyLoader(t, func() *Policy {
+ return NewPolicy(map[string]any{KeyBlockInbound: true})
+ })
+
+ fired := make(chan struct{}, 1)
+ tk := NewTicker(testReloadInterval)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() {
+ tk.Run(ctx, func(_, _ *Policy) error {
+ select {
+ case fired <- struct{}{}:
+ default:
+ }
+ return nil
+ })
+ close(done)
+ }()
+ defer func() { cancel(); <-done }()
+
+ // Over ~2 ticks at the 1s test cadence the policy never changes, so the
+ // diff guard must suppress the callback entirely.
+ select {
+ case <-fired:
+ t.Fatal("onChange fired despite an unchanged policy")
+ case <-time.After(2500 * time.Millisecond):
+ }
+}
diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go
index 415c3b140..1f3a385a1 100644
--- a/client/proto/daemon.pb.go
+++ b/client/proto/daemon.pb.go
@@ -1191,8 +1191,14 @@ type GetConfigResponse struct {
DisableSSHAuth bool `protobuf:"varint,25,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"`
SshJWTCacheTTL int32 `protobuf:"varint,26,opt,name=sshJWTCacheTTL,proto3" json:"sshJWTCacheTTL,omitempty"`
DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ // mDMManagedFields lists the names of configuration keys whose value is
+ // currently enforced by an MDM policy. Names match mdm.Key* constants
+ // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should
+ // render the corresponding inputs as read-only and display a "managed
+ // by MDM" indicator.
+ MDMManagedFields []string `protobuf:"bytes,28,rep,name=mDMManagedFields,proto3" json:"mDMManagedFields,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *GetConfigResponse) Reset() {
@@ -1414,6 +1420,13 @@ func (x *GetConfigResponse) GetDisableIpv6() bool {
return false
}
+func (x *GetConfigResponse) GetMDMManagedFields() []string {
+ if x != nil {
+ return x.MDMManagedFields
+ }
+ return nil
+}
+
// PeerState contains the latest state of a peer
type PeerState struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -5121,6 +5134,55 @@ func (x *GetFeaturesResponse) GetDisableNetworks() bool {
return false
}
+// MDMManagedFieldsViolation is attached as a gRPC error detail on a
+// FailedPrecondition status returned from SetConfig (and similar mutating
+// RPCs) when the caller tries to modify one or more MDM-enforced fields.
+// The fields list contains the offending key names; the entire request is
+// rejected (no partial apply).
+type MDMManagedFieldsViolation struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Fields []string `protobuf:"bytes,1,rep,name=fields,proto3" json:"fields,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *MDMManagedFieldsViolation) Reset() {
+ *x = MDMManagedFieldsViolation{}
+ mi := &file_daemon_proto_msgTypes[73]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *MDMManagedFieldsViolation) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*MDMManagedFieldsViolation) ProtoMessage() {}
+
+func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message {
+ mi := &file_daemon_proto_msgTypes[73]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use MDMManagedFieldsViolation.ProtoReflect.Descriptor instead.
+func (*MDMManagedFieldsViolation) Descriptor() ([]byte, []int) {
+ return file_daemon_proto_rawDescGZIP(), []int{73}
+}
+
+func (x *MDMManagedFieldsViolation) GetFields() []string {
+ if x != nil {
+ return x.Fields
+ }
+ return nil
+}
+
type TriggerUpdateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -5129,7 +5191,7 @@ type TriggerUpdateRequest struct {
func (x *TriggerUpdateRequest) Reset() {
*x = TriggerUpdateRequest{}
- mi := &file_daemon_proto_msgTypes[73]
+ mi := &file_daemon_proto_msgTypes[74]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5141,7 +5203,7 @@ func (x *TriggerUpdateRequest) String() string {
func (*TriggerUpdateRequest) ProtoMessage() {}
func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[73]
+ mi := &file_daemon_proto_msgTypes[74]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5154,7 +5216,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead.
func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{73}
+ return file_daemon_proto_rawDescGZIP(), []int{74}
}
type TriggerUpdateResponse struct {
@@ -5167,7 +5229,7 @@ type TriggerUpdateResponse struct {
func (x *TriggerUpdateResponse) Reset() {
*x = TriggerUpdateResponse{}
- mi := &file_daemon_proto_msgTypes[74]
+ mi := &file_daemon_proto_msgTypes[75]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5179,7 +5241,7 @@ func (x *TriggerUpdateResponse) String() string {
func (*TriggerUpdateResponse) ProtoMessage() {}
func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[74]
+ mi := &file_daemon_proto_msgTypes[75]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5192,7 +5254,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead.
func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{74}
+ return file_daemon_proto_rawDescGZIP(), []int{75}
}
func (x *TriggerUpdateResponse) GetSuccess() bool {
@@ -5220,7 +5282,7 @@ type GetPeerSSHHostKeyRequest struct {
func (x *GetPeerSSHHostKeyRequest) Reset() {
*x = GetPeerSSHHostKeyRequest{}
- mi := &file_daemon_proto_msgTypes[75]
+ mi := &file_daemon_proto_msgTypes[76]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5232,7 +5294,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string {
func (*GetPeerSSHHostKeyRequest) ProtoMessage() {}
func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[75]
+ mi := &file_daemon_proto_msgTypes[76]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5245,7 +5307,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead.
func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{75}
+ return file_daemon_proto_rawDescGZIP(), []int{76}
}
func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string {
@@ -5272,7 +5334,7 @@ type GetPeerSSHHostKeyResponse struct {
func (x *GetPeerSSHHostKeyResponse) Reset() {
*x = GetPeerSSHHostKeyResponse{}
- mi := &file_daemon_proto_msgTypes[76]
+ mi := &file_daemon_proto_msgTypes[77]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5284,7 +5346,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string {
func (*GetPeerSSHHostKeyResponse) ProtoMessage() {}
func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[76]
+ mi := &file_daemon_proto_msgTypes[77]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5297,7 +5359,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead.
func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{76}
+ return file_daemon_proto_rawDescGZIP(), []int{77}
}
func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte {
@@ -5339,7 +5401,7 @@ type RequestJWTAuthRequest struct {
func (x *RequestJWTAuthRequest) Reset() {
*x = RequestJWTAuthRequest{}
- mi := &file_daemon_proto_msgTypes[77]
+ mi := &file_daemon_proto_msgTypes[78]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5351,7 +5413,7 @@ func (x *RequestJWTAuthRequest) String() string {
func (*RequestJWTAuthRequest) ProtoMessage() {}
func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[77]
+ mi := &file_daemon_proto_msgTypes[78]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5364,7 +5426,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead.
func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{77}
+ return file_daemon_proto_rawDescGZIP(), []int{78}
}
func (x *RequestJWTAuthRequest) GetHint() string {
@@ -5397,7 +5459,7 @@ type RequestJWTAuthResponse struct {
func (x *RequestJWTAuthResponse) Reset() {
*x = RequestJWTAuthResponse{}
- mi := &file_daemon_proto_msgTypes[78]
+ mi := &file_daemon_proto_msgTypes[79]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5409,7 +5471,7 @@ func (x *RequestJWTAuthResponse) String() string {
func (*RequestJWTAuthResponse) ProtoMessage() {}
func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[78]
+ mi := &file_daemon_proto_msgTypes[79]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5422,7 +5484,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead.
func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{78}
+ return file_daemon_proto_rawDescGZIP(), []int{79}
}
func (x *RequestJWTAuthResponse) GetVerificationURI() string {
@@ -5487,7 +5549,7 @@ type WaitJWTTokenRequest struct {
func (x *WaitJWTTokenRequest) Reset() {
*x = WaitJWTTokenRequest{}
- mi := &file_daemon_proto_msgTypes[79]
+ mi := &file_daemon_proto_msgTypes[80]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5499,7 +5561,7 @@ func (x *WaitJWTTokenRequest) String() string {
func (*WaitJWTTokenRequest) ProtoMessage() {}
func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[79]
+ mi := &file_daemon_proto_msgTypes[80]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5512,7 +5574,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead.
func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{79}
+ return file_daemon_proto_rawDescGZIP(), []int{80}
}
func (x *WaitJWTTokenRequest) GetDeviceCode() string {
@@ -5544,7 +5606,7 @@ type WaitJWTTokenResponse struct {
func (x *WaitJWTTokenResponse) Reset() {
*x = WaitJWTTokenResponse{}
- mi := &file_daemon_proto_msgTypes[80]
+ mi := &file_daemon_proto_msgTypes[81]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5556,7 +5618,7 @@ func (x *WaitJWTTokenResponse) String() string {
func (*WaitJWTTokenResponse) ProtoMessage() {}
func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[80]
+ mi := &file_daemon_proto_msgTypes[81]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5569,7 +5631,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead.
func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{80}
+ return file_daemon_proto_rawDescGZIP(), []int{81}
}
func (x *WaitJWTTokenResponse) GetToken() string {
@@ -5602,7 +5664,7 @@ type StartCPUProfileRequest struct {
func (x *StartCPUProfileRequest) Reset() {
*x = StartCPUProfileRequest{}
- mi := &file_daemon_proto_msgTypes[81]
+ mi := &file_daemon_proto_msgTypes[82]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5614,7 +5676,7 @@ func (x *StartCPUProfileRequest) String() string {
func (*StartCPUProfileRequest) ProtoMessage() {}
func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[81]
+ mi := &file_daemon_proto_msgTypes[82]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5627,7 +5689,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead.
func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{81}
+ return file_daemon_proto_rawDescGZIP(), []int{82}
}
// StartCPUProfileResponse confirms CPU profiling has started
@@ -5639,7 +5701,7 @@ type StartCPUProfileResponse struct {
func (x *StartCPUProfileResponse) Reset() {
*x = StartCPUProfileResponse{}
- mi := &file_daemon_proto_msgTypes[82]
+ mi := &file_daemon_proto_msgTypes[83]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5651,7 +5713,7 @@ func (x *StartCPUProfileResponse) String() string {
func (*StartCPUProfileResponse) ProtoMessage() {}
func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[82]
+ mi := &file_daemon_proto_msgTypes[83]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5664,7 +5726,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead.
func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{82}
+ return file_daemon_proto_rawDescGZIP(), []int{83}
}
// StopCPUProfileRequest for stopping CPU profiling
@@ -5676,7 +5738,7 @@ type StopCPUProfileRequest struct {
func (x *StopCPUProfileRequest) Reset() {
*x = StopCPUProfileRequest{}
- mi := &file_daemon_proto_msgTypes[83]
+ mi := &file_daemon_proto_msgTypes[84]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5688,7 +5750,7 @@ func (x *StopCPUProfileRequest) String() string {
func (*StopCPUProfileRequest) ProtoMessage() {}
func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[83]
+ mi := &file_daemon_proto_msgTypes[84]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5701,7 +5763,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead.
func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{83}
+ return file_daemon_proto_rawDescGZIP(), []int{84}
}
// StopCPUProfileResponse confirms CPU profiling has stopped
@@ -5713,7 +5775,7 @@ type StopCPUProfileResponse struct {
func (x *StopCPUProfileResponse) Reset() {
*x = StopCPUProfileResponse{}
- mi := &file_daemon_proto_msgTypes[84]
+ mi := &file_daemon_proto_msgTypes[85]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5725,7 +5787,7 @@ func (x *StopCPUProfileResponse) String() string {
func (*StopCPUProfileResponse) ProtoMessage() {}
func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[84]
+ mi := &file_daemon_proto_msgTypes[85]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5738,7 +5800,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead.
func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{84}
+ return file_daemon_proto_rawDescGZIP(), []int{85}
}
type InstallerResultRequest struct {
@@ -5749,7 +5811,7 @@ type InstallerResultRequest struct {
func (x *InstallerResultRequest) Reset() {
*x = InstallerResultRequest{}
- mi := &file_daemon_proto_msgTypes[85]
+ mi := &file_daemon_proto_msgTypes[86]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5761,7 +5823,7 @@ func (x *InstallerResultRequest) String() string {
func (*InstallerResultRequest) ProtoMessage() {}
func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[85]
+ mi := &file_daemon_proto_msgTypes[86]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5774,7 +5836,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead.
func (*InstallerResultRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{85}
+ return file_daemon_proto_rawDescGZIP(), []int{86}
}
type InstallerResultResponse struct {
@@ -5787,7 +5849,7 @@ type InstallerResultResponse struct {
func (x *InstallerResultResponse) Reset() {
*x = InstallerResultResponse{}
- mi := &file_daemon_proto_msgTypes[86]
+ mi := &file_daemon_proto_msgTypes[87]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5799,7 +5861,7 @@ func (x *InstallerResultResponse) String() string {
func (*InstallerResultResponse) ProtoMessage() {}
func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[86]
+ mi := &file_daemon_proto_msgTypes[87]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5812,7 +5874,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead.
func (*InstallerResultResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{86}
+ return file_daemon_proto_rawDescGZIP(), []int{87}
}
func (x *InstallerResultResponse) GetSuccess() bool {
@@ -5845,7 +5907,7 @@ type ExposeServiceRequest struct {
func (x *ExposeServiceRequest) Reset() {
*x = ExposeServiceRequest{}
- mi := &file_daemon_proto_msgTypes[87]
+ mi := &file_daemon_proto_msgTypes[88]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5857,7 +5919,7 @@ func (x *ExposeServiceRequest) String() string {
func (*ExposeServiceRequest) ProtoMessage() {}
func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[87]
+ mi := &file_daemon_proto_msgTypes[88]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5870,7 +5932,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead.
func (*ExposeServiceRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{87}
+ return file_daemon_proto_rawDescGZIP(), []int{88}
}
func (x *ExposeServiceRequest) GetPort() uint32 {
@@ -5941,7 +6003,7 @@ type ExposeServiceEvent struct {
func (x *ExposeServiceEvent) Reset() {
*x = ExposeServiceEvent{}
- mi := &file_daemon_proto_msgTypes[88]
+ mi := &file_daemon_proto_msgTypes[89]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5953,7 +6015,7 @@ func (x *ExposeServiceEvent) String() string {
func (*ExposeServiceEvent) ProtoMessage() {}
func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[88]
+ mi := &file_daemon_proto_msgTypes[89]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -5966,7 +6028,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead.
func (*ExposeServiceEvent) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{88}
+ return file_daemon_proto_rawDescGZIP(), []int{89}
}
func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event {
@@ -6007,7 +6069,7 @@ type ExposeServiceReady struct {
func (x *ExposeServiceReady) Reset() {
*x = ExposeServiceReady{}
- mi := &file_daemon_proto_msgTypes[89]
+ mi := &file_daemon_proto_msgTypes[90]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6019,7 +6081,7 @@ func (x *ExposeServiceReady) String() string {
func (*ExposeServiceReady) ProtoMessage() {}
func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[89]
+ mi := &file_daemon_proto_msgTypes[90]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6032,7 +6094,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message {
// Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead.
func (*ExposeServiceReady) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{89}
+ return file_daemon_proto_rawDescGZIP(), []int{90}
}
func (x *ExposeServiceReady) GetServiceName() string {
@@ -6077,7 +6139,7 @@ type StartCaptureRequest struct {
func (x *StartCaptureRequest) Reset() {
*x = StartCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[90]
+ mi := &file_daemon_proto_msgTypes[91]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6089,7 +6151,7 @@ func (x *StartCaptureRequest) String() string {
func (*StartCaptureRequest) ProtoMessage() {}
func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[90]
+ mi := &file_daemon_proto_msgTypes[91]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6102,7 +6164,7 @@ func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead.
func (*StartCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{90}
+ return file_daemon_proto_rawDescGZIP(), []int{91}
}
func (x *StartCaptureRequest) GetTextOutput() bool {
@@ -6156,7 +6218,7 @@ type CapturePacket struct {
func (x *CapturePacket) Reset() {
*x = CapturePacket{}
- mi := &file_daemon_proto_msgTypes[91]
+ mi := &file_daemon_proto_msgTypes[92]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6168,7 +6230,7 @@ func (x *CapturePacket) String() string {
func (*CapturePacket) ProtoMessage() {}
func (x *CapturePacket) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[91]
+ mi := &file_daemon_proto_msgTypes[92]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6181,7 +6243,7 @@ func (x *CapturePacket) ProtoReflect() protoreflect.Message {
// Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead.
func (*CapturePacket) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{91}
+ return file_daemon_proto_rawDescGZIP(), []int{92}
}
func (x *CapturePacket) GetData() []byte {
@@ -6202,7 +6264,7 @@ type StartBundleCaptureRequest struct {
func (x *StartBundleCaptureRequest) Reset() {
*x = StartBundleCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[92]
+ mi := &file_daemon_proto_msgTypes[93]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6214,7 +6276,7 @@ func (x *StartBundleCaptureRequest) String() string {
func (*StartBundleCaptureRequest) ProtoMessage() {}
func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[92]
+ mi := &file_daemon_proto_msgTypes[93]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6227,7 +6289,7 @@ func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead.
func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{92}
+ return file_daemon_proto_rawDescGZIP(), []int{93}
}
func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration {
@@ -6245,7 +6307,7 @@ type StartBundleCaptureResponse struct {
func (x *StartBundleCaptureResponse) Reset() {
*x = StartBundleCaptureResponse{}
- mi := &file_daemon_proto_msgTypes[93]
+ mi := &file_daemon_proto_msgTypes[94]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6257,7 +6319,7 @@ func (x *StartBundleCaptureResponse) String() string {
func (*StartBundleCaptureResponse) ProtoMessage() {}
func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[93]
+ mi := &file_daemon_proto_msgTypes[94]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6270,7 +6332,7 @@ func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead.
func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{93}
+ return file_daemon_proto_rawDescGZIP(), []int{94}
}
type StopBundleCaptureRequest struct {
@@ -6281,7 +6343,7 @@ type StopBundleCaptureRequest struct {
func (x *StopBundleCaptureRequest) Reset() {
*x = StopBundleCaptureRequest{}
- mi := &file_daemon_proto_msgTypes[94]
+ mi := &file_daemon_proto_msgTypes[95]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6293,7 +6355,7 @@ func (x *StopBundleCaptureRequest) String() string {
func (*StopBundleCaptureRequest) ProtoMessage() {}
func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[94]
+ mi := &file_daemon_proto_msgTypes[95]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6306,7 +6368,7 @@ func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead.
func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{94}
+ return file_daemon_proto_rawDescGZIP(), []int{95}
}
type StopBundleCaptureResponse struct {
@@ -6317,7 +6379,7 @@ type StopBundleCaptureResponse struct {
func (x *StopBundleCaptureResponse) Reset() {
*x = StopBundleCaptureResponse{}
- mi := &file_daemon_proto_msgTypes[95]
+ mi := &file_daemon_proto_msgTypes[96]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6329,7 +6391,7 @@ func (x *StopBundleCaptureResponse) String() string {
func (*StopBundleCaptureResponse) ProtoMessage() {}
func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[95]
+ mi := &file_daemon_proto_msgTypes[96]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6342,7 +6404,7 @@ func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead.
func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) {
- return file_daemon_proto_rawDescGZIP(), []int{95}
+ return file_daemon_proto_rawDescGZIP(), []int{96}
}
type PortInfo_Range struct {
@@ -6355,7 +6417,7 @@ type PortInfo_Range struct {
func (x *PortInfo_Range) Reset() {
*x = PortInfo_Range{}
- mi := &file_daemon_proto_msgTypes[97]
+ mi := &file_daemon_proto_msgTypes[98]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -6367,7 +6429,7 @@ func (x *PortInfo_Range) String() string {
func (*PortInfo_Range) ProtoMessage() {}
func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
- mi := &file_daemon_proto_msgTypes[97]
+ mi := &file_daemon_proto_msgTypes[98]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6508,7 +6570,7 @@ const file_daemon_proto_rawDesc = "" +
"\fDownResponse\"P\n" +
"\x10GetConfigRequest\x12 \n" +
"\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" +
- "\busername\x18\x02 \x01(\tR\busername\"\xfe\b\n" +
+ "\busername\x18\x02 \x01(\tR\busername\"\xaa\t\n" +
"\x11GetConfigResponse\x12$\n" +
"\rmanagementUrl\x18\x01 \x01(\tR\rmanagementUrl\x12\x1e\n" +
"\n" +
@@ -6540,7 +6602,8 @@ const file_daemon_proto_rawDesc = "" +
"\x1denableSSHRemotePortForwarding\x18\x17 \x01(\bR\x1denableSSHRemotePortForwarding\x12&\n" +
"\x0edisableSSHAuth\x18\x19 \x01(\bR\x0edisableSSHAuth\x12&\n" +
"\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\x12!\n" +
- "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\"\x92\x06\n" +
+ "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12*\n" +
+ "\x10mDMManagedFields\x18\x1c \x03(\tR\x10mDMManagedFields\"\x92\x06\n" +
"\tPeerState\x12\x0e\n" +
"\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" +
"\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12\x1e\n" +
@@ -6866,7 +6929,9 @@ const file_daemon_proto_rawDesc = "" +
"\x13GetFeaturesResponse\x12)\n" +
"\x10disable_profiles\x18\x01 \x01(\bR\x0fdisableProfiles\x126\n" +
"\x17disable_update_settings\x18\x02 \x01(\bR\x15disableUpdateSettings\x12)\n" +
- "\x10disable_networks\x18\x03 \x01(\bR\x0fdisableNetworks\"\x16\n" +
+ "\x10disable_networks\x18\x03 \x01(\bR\x0fdisableNetworks\"3\n" +
+ "\x19MDMManagedFieldsViolation\x12\x16\n" +
+ "\x06fields\x18\x01 \x03(\tR\x06fields\"\x16\n" +
"\x14TriggerUpdateRequest\"M\n" +
"\x15TriggerUpdateResponse\x12\x18\n" +
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x1a\n" +
@@ -7023,7 +7088,7 @@ func file_daemon_proto_rawDescGZIP() []byte {
}
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
-var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 99)
+var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 100)
var file_daemon_proto_goTypes = []any{
(LogLevel)(0), // 0: daemon.LogLevel
(ExposeProtocol)(0), // 1: daemon.ExposeProtocol
@@ -7102,41 +7167,42 @@ var file_daemon_proto_goTypes = []any{
(*LogoutResponse)(nil), // 74: daemon.LogoutResponse
(*GetFeaturesRequest)(nil), // 75: daemon.GetFeaturesRequest
(*GetFeaturesResponse)(nil), // 76: daemon.GetFeaturesResponse
- (*TriggerUpdateRequest)(nil), // 77: daemon.TriggerUpdateRequest
- (*TriggerUpdateResponse)(nil), // 78: daemon.TriggerUpdateResponse
- (*GetPeerSSHHostKeyRequest)(nil), // 79: daemon.GetPeerSSHHostKeyRequest
- (*GetPeerSSHHostKeyResponse)(nil), // 80: daemon.GetPeerSSHHostKeyResponse
- (*RequestJWTAuthRequest)(nil), // 81: daemon.RequestJWTAuthRequest
- (*RequestJWTAuthResponse)(nil), // 82: daemon.RequestJWTAuthResponse
- (*WaitJWTTokenRequest)(nil), // 83: daemon.WaitJWTTokenRequest
- (*WaitJWTTokenResponse)(nil), // 84: daemon.WaitJWTTokenResponse
- (*StartCPUProfileRequest)(nil), // 85: daemon.StartCPUProfileRequest
- (*StartCPUProfileResponse)(nil), // 86: daemon.StartCPUProfileResponse
- (*StopCPUProfileRequest)(nil), // 87: daemon.StopCPUProfileRequest
- (*StopCPUProfileResponse)(nil), // 88: daemon.StopCPUProfileResponse
- (*InstallerResultRequest)(nil), // 89: daemon.InstallerResultRequest
- (*InstallerResultResponse)(nil), // 90: daemon.InstallerResultResponse
- (*ExposeServiceRequest)(nil), // 91: daemon.ExposeServiceRequest
- (*ExposeServiceEvent)(nil), // 92: daemon.ExposeServiceEvent
- (*ExposeServiceReady)(nil), // 93: daemon.ExposeServiceReady
- (*StartCaptureRequest)(nil), // 94: daemon.StartCaptureRequest
- (*CapturePacket)(nil), // 95: daemon.CapturePacket
- (*StartBundleCaptureRequest)(nil), // 96: daemon.StartBundleCaptureRequest
- (*StartBundleCaptureResponse)(nil), // 97: daemon.StartBundleCaptureResponse
- (*StopBundleCaptureRequest)(nil), // 98: daemon.StopBundleCaptureRequest
- (*StopBundleCaptureResponse)(nil), // 99: daemon.StopBundleCaptureResponse
- nil, // 100: daemon.Network.ResolvedIPsEntry
- (*PortInfo_Range)(nil), // 101: daemon.PortInfo.Range
- nil, // 102: daemon.SystemEvent.MetadataEntry
- (*durationpb.Duration)(nil), // 103: google.protobuf.Duration
- (*timestamppb.Timestamp)(nil), // 104: google.protobuf.Timestamp
+ (*MDMManagedFieldsViolation)(nil), // 77: daemon.MDMManagedFieldsViolation
+ (*TriggerUpdateRequest)(nil), // 78: daemon.TriggerUpdateRequest
+ (*TriggerUpdateResponse)(nil), // 79: daemon.TriggerUpdateResponse
+ (*GetPeerSSHHostKeyRequest)(nil), // 80: daemon.GetPeerSSHHostKeyRequest
+ (*GetPeerSSHHostKeyResponse)(nil), // 81: daemon.GetPeerSSHHostKeyResponse
+ (*RequestJWTAuthRequest)(nil), // 82: daemon.RequestJWTAuthRequest
+ (*RequestJWTAuthResponse)(nil), // 83: daemon.RequestJWTAuthResponse
+ (*WaitJWTTokenRequest)(nil), // 84: daemon.WaitJWTTokenRequest
+ (*WaitJWTTokenResponse)(nil), // 85: daemon.WaitJWTTokenResponse
+ (*StartCPUProfileRequest)(nil), // 86: daemon.StartCPUProfileRequest
+ (*StartCPUProfileResponse)(nil), // 87: daemon.StartCPUProfileResponse
+ (*StopCPUProfileRequest)(nil), // 88: daemon.StopCPUProfileRequest
+ (*StopCPUProfileResponse)(nil), // 89: daemon.StopCPUProfileResponse
+ (*InstallerResultRequest)(nil), // 90: daemon.InstallerResultRequest
+ (*InstallerResultResponse)(nil), // 91: daemon.InstallerResultResponse
+ (*ExposeServiceRequest)(nil), // 92: daemon.ExposeServiceRequest
+ (*ExposeServiceEvent)(nil), // 93: daemon.ExposeServiceEvent
+ (*ExposeServiceReady)(nil), // 94: daemon.ExposeServiceReady
+ (*StartCaptureRequest)(nil), // 95: daemon.StartCaptureRequest
+ (*CapturePacket)(nil), // 96: daemon.CapturePacket
+ (*StartBundleCaptureRequest)(nil), // 97: daemon.StartBundleCaptureRequest
+ (*StartBundleCaptureResponse)(nil), // 98: daemon.StartBundleCaptureResponse
+ (*StopBundleCaptureRequest)(nil), // 99: daemon.StopBundleCaptureRequest
+ (*StopBundleCaptureResponse)(nil), // 100: daemon.StopBundleCaptureResponse
+ nil, // 101: daemon.Network.ResolvedIPsEntry
+ (*PortInfo_Range)(nil), // 102: daemon.PortInfo.Range
+ nil, // 103: daemon.SystemEvent.MetadataEntry
+ (*durationpb.Duration)(nil), // 104: google.protobuf.Duration
+ (*timestamppb.Timestamp)(nil), // 105: google.protobuf.Timestamp
}
var file_daemon_proto_depIdxs = []int32{
- 103, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
+ 104, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
- 104, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
- 104, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
- 103, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration
+ 105, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
+ 105, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
+ 104, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration
23, // 5: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
20, // 6: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
19, // 7: daemon.FullStatus.signalState:type_name -> daemon.SignalState
@@ -7147,8 +7213,8 @@ var file_daemon_proto_depIdxs = []int32{
55, // 12: daemon.FullStatus.events:type_name -> daemon.SystemEvent
24, // 13: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState
31, // 14: daemon.ListNetworksResponse.routes:type_name -> daemon.Network
- 100, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
- 101, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
+ 101, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
+ 102, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
32, // 17: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo
32, // 18: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo
33, // 19: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule
@@ -7159,15 +7225,15 @@ var file_daemon_proto_depIdxs = []int32{
52, // 24: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
2, // 25: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
3, // 26: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
- 104, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
- 102, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
+ 105, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
+ 103, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
55, // 29: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
- 103, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
+ 104, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
70, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
1, // 32: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
- 93, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
- 103, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
- 103, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
+ 94, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
+ 104, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
+ 104, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
30, // 36: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
5, // 37: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
7, // 38: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
@@ -7187,9 +7253,9 @@ var file_daemon_proto_depIdxs = []int32{
46, // 52: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
48, // 53: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
51, // 54: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
- 94, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
- 96, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
- 98, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
+ 95, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
+ 97, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
+ 99, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
54, // 58: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
56, // 59: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
58, // 60: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
@@ -7201,14 +7267,14 @@ var file_daemon_proto_depIdxs = []int32{
71, // 66: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
73, // 67: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
75, // 68: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
- 77, // 69: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
- 79, // 70: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
- 81, // 71: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
- 83, // 72: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
- 85, // 73: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
- 87, // 74: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
- 89, // 75: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
- 91, // 76: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
+ 78, // 69: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
+ 80, // 70: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
+ 82, // 71: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
+ 84, // 72: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
+ 86, // 73: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
+ 88, // 74: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
+ 90, // 75: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
+ 92, // 76: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
6, // 77: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
8, // 78: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
10, // 79: daemon.DaemonService.Up:output_type -> daemon.UpResponse
@@ -7227,9 +7293,9 @@ var file_daemon_proto_depIdxs = []int32{
47, // 92: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
49, // 93: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
53, // 94: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
- 95, // 95: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
- 97, // 96: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
- 99, // 97: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
+ 96, // 95: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
+ 98, // 96: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
+ 100, // 97: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
55, // 98: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
57, // 99: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
59, // 100: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
@@ -7241,14 +7307,14 @@ var file_daemon_proto_depIdxs = []int32{
72, // 106: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
74, // 107: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
76, // 108: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
- 78, // 109: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
- 80, // 110: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
- 82, // 111: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
- 84, // 112: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
- 86, // 113: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
- 88, // 114: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
- 90, // 115: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
- 92, // 116: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
+ 79, // 109: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
+ 81, // 110: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
+ 83, // 111: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
+ 85, // 112: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
+ 87, // 113: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
+ 89, // 114: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
+ 91, // 115: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
+ 93, // 116: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
77, // [77:117] is the sub-list for method output_type
37, // [37:77] is the sub-list for method input_type
37, // [37:37] is the sub-list for extension type_name
@@ -7273,8 +7339,8 @@ func file_daemon_proto_init() {
file_daemon_proto_msgTypes[54].OneofWrappers = []any{}
file_daemon_proto_msgTypes[56].OneofWrappers = []any{}
file_daemon_proto_msgTypes[69].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[77].OneofWrappers = []any{}
- file_daemon_proto_msgTypes[88].OneofWrappers = []any{
+ file_daemon_proto_msgTypes[78].OneofWrappers = []any{}
+ file_daemon_proto_msgTypes[89].OneofWrappers = []any{
(*ExposeServiceEvent_Ready)(nil),
}
type x struct{}
@@ -7283,7 +7349,7 @@ func file_daemon_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)),
NumEnums: 4,
- NumMessages: 99,
+ NumMessages: 100,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
index 18a90865d..fb16b206d 100644
--- a/client/proto/daemon.proto
+++ b/client/proto/daemon.proto
@@ -316,6 +316,13 @@ message GetConfigResponse {
int32 sshJWTCacheTTL = 26;
bool disable_ipv6 = 27;
+
+ // mDMManagedFields lists the names of configuration keys whose value is
+ // currently enforced by an MDM policy. Names match mdm.Key* constants
+ // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should
+ // render the corresponding inputs as read-only and display a "managed
+ // by MDM" indicator.
+ repeated string mDMManagedFields = 28;
}
// PeerState contains the latest state of a peer
@@ -769,6 +776,15 @@ message GetFeaturesResponse{
bool disable_networks = 3;
}
+// MDMManagedFieldsViolation is attached as a gRPC error detail on a
+// FailedPrecondition status returned from SetConfig (and similar mutating
+// RPCs) when the caller tries to modify one or more MDM-enforced fields.
+// The fields list contains the offending key names; the entire request is
+// rejected (no partial apply).
+message MDMManagedFieldsViolation {
+ repeated string fields = 1;
+}
+
message TriggerUpdateRequest {}
message TriggerUpdateResponse {
diff --git a/client/server/mdm.go b/client/server/mdm.go
new file mode 100644
index 000000000..0da0ec5d1
--- /dev/null
+++ b/client/server/mdm.go
@@ -0,0 +1,419 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/mdm"
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+// preSharedKeyRedactedSentinel is the value GetConfig returns in place
+// of an actual PSK, so a UI that round-trips the field back to the
+// daemon (via SetConfig / Login) can be distinguished from a deliberate
+// override. Any incoming PSK that equals this sentinel is treated as
+// a no-op echo, never as a conflict with the policy.
+const preSharedKeyRedactedSentinel = "**********"
+
+// loadMDMPolicy is the indirection used by server handlers to read the
+// active MDM policy. Tests override this to inject a fake policy.
+var loadMDMPolicy = mdm.LoadPolicy
+
+// conflictCheck is a value-aware comparison between a single field in
+// the incoming request and the corresponding MDM-enforced value. It
+// runs only when the field was actually set in the request (presence
+// already filtered upstream); ok=true reports the policy value, ok=false
+// means the policy is silent on the key — both are treated as conflicts
+// to be safe (an MDM key declared as managed must hold a value).
+type conflictCheck struct {
+ key string
+ check func(*mdm.Policy) (match bool)
+}
+
+// onMDMPolicyChange is invoked by the MDM reload ticker every time the
+// OS-native managed-config store reports a diff vs the last observation.
+//
+// Restart sequence:
+// 1. Cancel the active engine context (terminates connectWithRetryRuns).
+// 2. Wait briefly for that goroutine to exit (giveUpChan is closed on exit).
+// 3. Re-resolve Config from disk + MDM policy (Config.apply re-runs
+// applyMDMPolicy with the freshly loaded Policy).
+// 4. Spawn a fresh connectWithRetryRuns with the new context and config.
+// 5. Broadcast a SystemEvent so any GUI / CLI subscriber (SubscribeEvents
+// RPC) can refresh its cached config view without polling.
+//
+// The callback runs in the ticker's own goroutine. Ticker has already
+// logged the per-key diff before invoking this hook.
+func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) error {
+ log.Warn("MDM policy changed; restarting engine to apply new configuration")
+
+ // Hold s.mutex for the entire restart sequence (cancel + quiescence
+ // wait + re-spawn). Any concurrent Up/Down/Status arriving while
+ // MDM is restarting blocks on the Lock until we are done — they
+ // then observe the post-restart state coherently. This is safe
+ // because the connectWithRetryRuns goroutine no longer acquires
+ // s.mutex in its defer (intent vs. goroutine-alive concerns are
+ // fully separated; see the connectionGoroutineRunning helper).
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ if !s.clientRunning {
+ // The client is not running, so there's no engine to restart.
+ return nil
+ }
+ if s.actCancel != nil {
+ s.actCancel()
+ }
+
+ // Wait for previous connectWithRetryRuns to exit so we don't end up
+ // with two goroutines fighting over the same status recorder + engine.
+ // The teardown engages a fan-out of engine goroutines (peer workers,
+ // signal handler, route manager, ...). close(clientGiveUpChan)
+ // happens in the function-scope defer of connectWithRetryRuns, on
+ // every exit path (ctx cancel, backoff exhausted, panic) — see the
+ // defer in server.go.
+ if s.clientGiveUpChan != nil {
+ select {
+ case <-s.clientGiveUpChan:
+ case <-time.After(10 * time.Second):
+ return fmt.Errorf("failed to restart the engine due to timeout")
+ }
+ }
+
+ if err := s.restartEngineForMDMLocked(); err != nil {
+ log.Errorf("MDM restart failed: %v", err)
+ return err
+ }
+
+ // publishConfigChangedEvent has already fired inside
+ // restartEngineForMDMLocked with source="mdm". Emit an MDM-specific
+ // user-visible toast so the operator knows their IT policy was
+ // applied (UserMessage != "" triggers the GUI notifier).
+ s.statusRecorder.PublishEvent(
+ proto.SystemEvent_INFO,
+ proto.SystemEvent_SYSTEM,
+ "MDM policy applied",
+ "NetBird configuration was updated by your IT policy.",
+ map[string]string{"source": "mdm", "type": "policy_applied"},
+ )
+ return nil
+}
+
+// publishConfigChangedEvent broadcasts a SystemEvent informing any active
+// SubscribeEvents subscriber (typically the GUI tray) that the daemon's
+// effective Config has been replaced and any cached client-side view
+// should be refreshed. Callers pass a stable `source` label so the GUI
+// can distinguish a startup spawn from a user-triggered Up or an
+// MDM-driven restart. Reusing the SYSTEM category keeps the proto enum
+// stable; metadata.type="config_changed" routes to the GUI's refresh
+// handler. UserMessage is left empty so the system tray does not toast
+// for every internal restart; the MDM path emits a separate
+// "policy_applied" event (with UserMessage) for that purpose.
+func (s *Server) publishConfigChangedEvent(source string) {
+ if s.statusRecorder == nil {
+ return
+ }
+ s.statusRecorder.PublishEvent(
+ proto.SystemEvent_INFO,
+ proto.SystemEvent_SYSTEM,
+ fmt.Sprintf("daemon config changed (source=%s)", source),
+ "",
+ map[string]string{
+ "source": source,
+ "type": "config_changed",
+ },
+ )
+}
+
+// restartEngineForMDMLocked re-resolves the active profile config
+// (re-running applyMDMPolicy via Config.apply) and re-spawns
+// connectWithRetryRuns. Mirrors the tail of Server.Start so a runtime
+// MDM change behaves identically to a fresh boot under the new policy.
+//
+// MUST be called with s.mutex held — onMDMPolicyChange holds the lock
+// for the entire restart sequence (cancel + quiescence wait + re-spawn)
+// so concurrent Up/Down/Status RPCs observe a coherent post-restart
+// state.
+func (s *Server) restartEngineForMDMLocked() error {
+ activeProf, err := s.profileManager.GetActiveProfileState()
+ if err != nil {
+ return fmt.Errorf("get active profile state: %w", err)
+ }
+ config, _, err := s.getConfig(activeProf)
+ if err != nil {
+ return fmt.Errorf("get active profile config: %w", err)
+ }
+
+ s.config = config
+ s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
+ s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
+ s.statusRecorder.UpdateLazyConnection(config.LazyConnectionEnabled)
+
+ ctx, cancel := context.WithCancel(s.rootCtx)
+ s.actCancel = cancel
+ s.clientRunning = true
+ s.clientRunningChan = make(chan struct{})
+ s.clientGiveUpChan = make(chan struct{})
+ log.Info("MDM restart: spawning connectWithRetryRuns with re-resolved config")
+ go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
+ s.publishConfigChangedEvent("mdm")
+ return nil
+}
+
+// conflictBool builds a conflictCheck for a boolean MDM key. If p is nil
+// the field is treated as matching (no override requested); otherwise the
+// check returns true only when the policy contains the key and its
+// boolean value equals *p.
+func conflictBool(key string, p *bool) conflictCheck {
+ return conflictCheck{
+ key: key,
+ check: func(pol *mdm.Policy) bool {
+ if p == nil {
+ return true // absent → match by definition
+ }
+ want, ok := pol.GetBool(key)
+ return ok && want == *p
+ },
+ }
+}
+
+// conflictString builds a conflictCheck for a string MDM key. An empty
+// `got` is treated as "field not set" (no override requested); otherwise
+// the check returns true only when the policy contains the key and its
+// value equals got.
+func conflictString(key, got string) conflictCheck {
+ return conflictCheck{
+ key: key,
+ check: func(pol *mdm.Policy) bool {
+ if got == "" {
+ return true
+ }
+ want, ok := pol.GetString(key)
+ return ok && want == got
+ },
+ }
+}
+
+// conflictInt64 builds a conflictCheck for an integer MDM key. If p is
+// nil the field is treated as matching; otherwise the check returns
+// true only when the policy contains the key and its int value equals *p.
+func conflictInt64(key string, p *int64) conflictCheck {
+ return conflictCheck{
+ key: key,
+ check: func(pol *mdm.Policy) bool {
+ if p == nil {
+ return true
+ }
+ want, ok := pol.GetInt(key)
+ return ok && want == *p
+ },
+ }
+}
+
+// resolveConflicts walks the per-field checks against the active MDM
+// policy and returns the names of keys whose requested value diverges
+// from the policy-enforced value. Keys not present in the policy are
+// skipped silently (the gate fires only for keys the admin has
+// actually pushed). Returns nil for an empty policy.
+func resolveConflicts(policy *mdm.Policy, checks []conflictCheck) []string {
+ if policy.IsEmpty() {
+ return nil
+ }
+ var conflicts []string
+ for _, c := range checks {
+ if !policy.HasKey(c.key) {
+ continue
+ }
+ if !c.check(policy) {
+ conflicts = append(conflicts, c.key)
+ }
+ }
+ return conflicts
+}
+
+// mdmManagedFieldConflicts returns the names of MDM-managed keys whose
+// requested value in the SetConfigRequest differs from the MDM-enforced
+// value. A field set to the same value the policy already enforces is
+// treated as a no-op echo (the GUI tray sends a full Config snapshot on
+// every toggle, so most fields in a typical request match the policy
+// exactly and must NOT be flagged as conflicts). The redacted PSK
+// sentinel ("**********") returned by GetConfig is recognised and
+// treated as no-op so the UI can safely round-trip it.
+func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) []string {
+ if msg == nil {
+ return nil
+ }
+
+ // PSK round-trip echo: collapse the sentinel to empty so the
+ // shared check treats it as "field not set".
+ pskGot := ""
+ if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != preSharedKeyRedactedSentinel {
+ pskGot = *msg.OptionalPreSharedKey
+ }
+
+ return resolveConflicts(policy, []conflictCheck{
+ conflictString(mdm.KeyManagementURL, msg.ManagementUrl),
+ conflictString(mdm.KeyPreSharedKey, pskGot),
+ conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
+ conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
+ conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
+ conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
+ conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
+ conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
+ conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
+ conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
+ })
+}
+
+// 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.NetworkMonitor != nil ||
+ msg.DisableClientRoutes != nil ||
+ msg.DisableServerRoutes != nil ||
+ msg.DisableDns != nil ||
+ msg.DisableFirewall != nil ||
+ msg.BlockLanAccess != nil ||
+ msg.DisableNotifications != nil ||
+ msg.LazyConnectionEnabled != 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
+}
+
+// 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.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.LazyConnectionEnabled != nil ||
+ msg.BlockInbound != 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
+// value is flagged. PSK has two proto fields — PreSharedKey (deprecated)
+// and OptionalPreSharedKey (current); either route trips the gate if it
+// diverges from the MDM-enforced PSK. OptionalPreSharedKey wins when
+// both are set; the redaction sentinel ("**********") is accepted as
+// a no-op echo.
+func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []string {
+ if msg == nil {
+ return nil
+ }
+
+ // Collapse the two PSK fields + the redaction sentinel down to a
+ // single "got" string the shared check can compare against the
+ // policy: OptionalPreSharedKey wins if set; PreSharedKey (deprecated)
+ // is the fallback; sentinel echo is treated as "field not set".
+ pskGot := ""
+ if msg.OptionalPreSharedKey != nil {
+ pskGot = *msg.OptionalPreSharedKey
+ } else if msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
+ pskGot = msg.PreSharedKey //nolint:staticcheck // SA1019
+ }
+ if pskGot == preSharedKeyRedactedSentinel {
+ pskGot = ""
+ }
+
+ return resolveConflicts(policy, []conflictCheck{
+ conflictString(mdm.KeyManagementURL, msg.ManagementUrl),
+ conflictString(mdm.KeyPreSharedKey, pskGot),
+ conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
+ conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
+ conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
+ conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
+ conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
+ conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
+ conflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
+ conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
+ })
+}
+
+// rejectMDMManagedFieldConflicts returns a FailedPrecondition gRPC error
+// with an MDMManagedFieldsViolation detail when any of the requested
+// fields tries to change an MDM-enforced value to something else, and
+// nil otherwise. The whole request is rejected on any conflict; non-
+// conflicting fields in the same request are not applied either (no
+// partial apply).
+func rejectMDMManagedFieldConflicts(conflicts []string) error {
+ if len(conflicts) == 0 {
+ return nil
+ }
+ log.Warnf("MDM rejected request: tried to modify %d managed key(s): %v",
+ len(conflicts), conflicts)
+ st := gstatus.New(
+ codes.FailedPrecondition,
+ fmt.Sprintf("fields managed by MDM cannot be modified: %v", conflicts),
+ )
+ detailed, err := st.WithDetails(&proto.MDMManagedFieldsViolation{Fields: conflicts})
+ if err != nil {
+ // Detail attachment is best-effort; fall back to the plain status
+ // so the caller still gets a usable FailedPrecondition.
+ return st.Err()
+ }
+ return detailed.Err()
+}
diff --git a/client/server/network.go b/client/server/network.go
index 12cefbd9c..7a3c08f2e 100644
--- a/client/server/network.go
+++ b/client/server/network.go
@@ -30,7 +30,7 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro
s.mutex.Lock()
defer s.mutex.Unlock()
- if s.networksDisabled {
+ if s.checkNetworksDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
@@ -143,7 +143,7 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
s.mutex.Lock()
defer s.mutex.Unlock()
- if s.networksDisabled {
+ if s.checkNetworksDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
@@ -195,7 +195,7 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe
s.mutex.Lock()
defer s.mutex.Unlock()
- if s.networksDisabled {
+ if s.checkNetworksDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
diff --git a/client/server/server.go b/client/server/server.go
index 503968f4b..ec53c309d 100644
--- a/client/server/server.go
+++ b/client/server/server.go
@@ -24,6 +24,7 @@ import (
"github.com/netbirdio/netbird/client/internal/expose"
"github.com/netbirdio/netbird/client/internal/profilemanager"
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
+ "github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/system"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -71,7 +72,13 @@ type Server struct {
mutex sync.Mutex
config *profilemanager.Config
proto.UnimplementedDaemonServiceServer
- clientRunning bool // protected by mutex
+ // clientRunning tracks "the daemon wants to be connected" — set true by
+ // Start / Up, cleared by Down / Logout. Persists across retry
+ // loops, signal disconnects, and ErrResetConnection cycles. NOT
+ // changed by connectWithRetryRuns goroutine exit — for that
+ // (goroutine-still-alive) check, see connectionGoroutineRunning() which
+ // derives from clientGiveUpChan close state. Protected by s.mutex.
+ clientRunning bool
clientRunningChan chan struct{}
clientGiveUpChan chan struct{} // closed when connectWithRetryRuns goroutine exits
@@ -98,6 +105,11 @@ type Server struct {
sleepHandler *sleephandler.SleepHandler
+ // mdmTicker periodically re-reads the OS-native MDM policy and triggers
+ // an engine restart when the policy changes. Launched once by Start;
+ // stopped by the rootCtx cancellation.
+ mdmTicker *mdm.Ticker
+
updateManager *updater.Manager
jwtCache *jwtCache
@@ -155,6 +167,17 @@ func (s *Server) Start() error {
s.updateManager.CheckUpdateSuccess(s.rootCtx)
}
+ // MDM policy reload ticker: every minute the desktop daemon re-reads
+ // the OS-native managed-config store and, on diff vs the previous
+ // observation, cancels the active engine context so connectWithRetry-
+ // Runs re-resolves Config (re-running profilemanager.Config.apply which
+ // applies the freshly-read MDM policy as the last layer) and brings
+ // the engine back with the new values.
+ if s.mdmTicker == nil {
+ s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval)
+ go s.mdmTicker.Run(s.rootCtx, s.onMDMPolicyChange)
+ }
+
// if current state contains any error, return it
// in all other cases we can continue execution only if status is idle and up command was
// not in the progress or already successfully established connection.
@@ -213,17 +236,27 @@ func (s *Server) Start() error {
s.clientRunningChan = make(chan struct{})
s.clientGiveUpChan = make(chan struct{})
go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
+ s.publishConfigChangedEvent("startup")
return nil
}
// connectWithRetryRuns runs the client connection with a backoff strategy where we retry the operation as additional
// mechanism to keep the client connected even when the connection is lost.
// we cancel retry if the client receive a stop or down command, or if disable auto connect is configured.
+//
+// The goroutine's exit is signalled to the daemon via close(giveUpChan)
+// — placed in the function-scope defer so every return path (panic,
+// DisableAutoConnect early-exit, backoff exhausted, ctx cancel) closes
+// it. Callers that need to observe "is the goroutine still alive?" use
+// Server.connectionGoroutineRunning() which non-blockingly checks the close state
+// of clientGiveUpChan. The defer does NOT touch s.mutex; the daemon's
+// "intent" (clientRunning) is maintained by the RPC handlers, not by this
+// goroutine.
func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}, giveUpChan chan struct{}) {
defer func() {
- s.mutex.Lock()
- s.clientRunning = false
- s.mutex.Unlock()
+ if giveUpChan != nil {
+ close(giveUpChan)
+ }
}()
if s.config.DisableAutoConnect {
@@ -269,9 +302,26 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil
if err := backoff.Retry(runOperation, backOff); err != nil {
log.Errorf("operation failed: %v", err)
}
+ // giveUpChan is closed by the function-scope defer.
+}
- if giveUpChan != nil {
- close(giveUpChan)
+// connectionGoroutineRunning reports whether the connectWithRetryRuns goroutine is
+// still running. Returns false when no goroutine has ever been started
+// AND when the most recent one has already closed clientGiveUpChan on
+// exit (whether due to ctx cancel, DisableAutoConnect single-shot
+// completion, or backoff retry exhaustion).
+//
+// MUST be called with s.mutex held — accesses s.clientGiveUpChan which
+// is written by Start/Up under the same lock.
+func (s *Server) connectionGoroutineRunning() bool {
+ if s.clientGiveUpChan == nil {
+ return false
+ }
+ select {
+ case <-s.clientGiveUpChan:
+ return false
+ default:
+ return true
}
}
@@ -304,10 +354,53 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
s.mutex.Lock()
defer s.mutex.Unlock()
- if s.checkUpdateSettingsDisabled() {
- return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled)
+ // 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)
+ }
}
+ // MDM gate: refuse the whole request if any of its fields is enforced
+ // by the active MDM policy. The error carries an MDMManagedFields-
+ // Violation detail listing the offending key names. Non-conflicting
+ // fields in the same request are not applied either.
+ policy := loadMDMPolicy()
+ if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil {
+ return nil, err
+ }
+
+ config, err := setConfigInputFromRequest(msg)
+ if err != nil {
+ return nil, err
+ }
+
+ if _, err := profilemanager.UpdateConfig(config); err != nil {
+ log.Errorf("failed to update profile config: %v", err)
+ return nil, fmt.Errorf("failed to update profile config: %w", err)
+ }
+
+ return &proto.SetConfigResponse{}, nil
+}
+
+// setConfigInputFromRequest translates a SetConfigRequest into the
+// profilemanager.ConfigInput that profilemanager.UpdateConfig consumes.
+// Pure mapping with no business logic beyond presence-aware copying of
+// optional fields and the "empty / clean" semantics for the two slice
+// fields (DNS labels, NAT external IPs). Extracted from SetConfig to
+// keep the handler's cognitive complexity below the SonarCube
+// threshold; the body is intentionally linear because each proto
+// field is its own optional case. Returns the resolved ConfigInput
+// and a non-nil error only when the active profile file path cannot
+// be determined.
+func setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) {
+ var config profilemanager.ConfigInput
+
resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username)
if err != nil {
log.Errorf("failed to resolve profile %q: %v", msg.ProfileName, err)
@@ -316,41 +409,29 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
profPath := resolved.Path
if profPath == "" {
profPath = profilemanager.DefaultConfigPath
- }
-
- var config profilemanager.ConfigInput
-
config.ConfigPath = profPath
if msg.ManagementUrl != "" {
config.ManagementURL = msg.ManagementUrl
}
-
if msg.AdminURL != "" {
config.AdminURL = msg.AdminURL
}
-
if msg.InterfaceName != nil {
config.InterfaceName = msg.InterfaceName
}
-
if msg.WireguardPort != nil {
wgPort := int(*msg.WireguardPort)
config.WireguardPort = &wgPort
}
-
- if msg.OptionalPreSharedKey != nil {
- if *msg.OptionalPreSharedKey != "" {
- config.PreSharedKey = msg.OptionalPreSharedKey
- }
+ if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != "" {
+ config.PreSharedKey = msg.OptionalPreSharedKey
}
if msg.CleanDNSLabels {
config.DNSLabels = domain.List{}
-
} else if msg.DnsLabels != nil {
- dnsLabels := domain.FromPunycodeList(msg.DnsLabels)
- config.DNSLabels = dnsLabels
+ config.DNSLabels = domain.FromPunycodeList(msg.DnsLabels)
}
if msg.CleanNATExternalIPs {
@@ -363,7 +444,6 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
if string(msg.CustomDNSAddress) == "empty" {
config.CustomDNSAddress = []byte{}
}
-
config.ExtraIFaceBlackList = msg.ExtraIFaceBlacklist
if msg.DnsRouteInterval != nil {
@@ -396,22 +476,31 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
ttl := int(*msg.SshJWTCacheTTL)
config.SSHJWTCacheTTL = &ttl
}
-
if msg.Mtu != nil {
mtu := uint16(*msg.Mtu)
config.MTU = &mtu
}
-
- if _, err := profilemanager.UpdateConfig(config); err != nil {
- log.Errorf("failed to update profile config: %v", err)
- return nil, fmt.Errorf("failed to update profile config: %w", err)
- }
-
- return &proto.SetConfigResponse{}, nil
+ return config, nil
}
// Login uses setup key to prepare configuration for the daemon.
func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*proto.LoginResponse, error) {
+ // 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 := loadMDMPolicy()
+ if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil {
+ return nil, err
+ }
+ }
+
s.mutex.Lock()
if s.actCancel != nil {
s.actCancel()
@@ -630,7 +719,13 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
// Up starts engine work in the daemon.
func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpResponse, error) {
s.mutex.Lock()
- if s.clientRunning {
+ // clientRunning is the daemon-intent flag (set by previous Up/Start, cleared
+ // by Down). connectionGoroutineRunning() reports whether the previous retry-loop
+ // goroutine is still trying. When intent is up AND goroutine is alive,
+ // the existing engine is on the job — just wait for it. When intent
+ // is up but the goroutine has given up (backoff exhausted) OR when
+ // intent is down, fall through to spawn a fresh retry loop.
+ if s.clientRunning && s.connectionGoroutineRunning() {
state := internal.CtxGetState(s.rootCtx)
status, err := state.Status()
if err != nil {
@@ -721,6 +816,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
s.clientGiveUpChan = make(chan struct{})
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
+ s.publishConfigChangedEvent("up_rpc")
s.mutex.Unlock()
return s.waitForUp(callerCtx)
@@ -875,6 +971,12 @@ func (s *Server) cleanupConnection() error {
return ErrServiceNotUp
}
+ // Daemon intent flips to "down" — all callers (Down RPC,
+ // Logout RPC handlers) tear down the connection because the user
+ // explicitly asked for it. MDM restart does NOT go through this
+ // path, so its clientRunning stays true.
+ s.clientRunning = false
+
// Capture the engine reference before cancelling the context.
// After actCancel(), the connectWithRetryRuns goroutine wakes up
// and sets connectClient.engine = nil, causing connectClient.Stop()
@@ -1078,10 +1180,14 @@ func (s *Server) Status(
msg *proto.StatusRequest,
) (*proto.StatusResponse, error) {
s.mutex.Lock()
- clientRunning := s.clientRunning
+ // Only wait if the retry-loop goroutine is alive and making
+ // progress. clientRunning=true with connectionGoroutineRunning=false means the
+ // backoff has given up — there is nothing to wait for; let the
+ // caller observe the failed status directly.
+ alive := s.connectionGoroutineRunning()
s.mutex.Unlock()
- if msg.WaitForReady != nil && *msg.WaitForReady && clientRunning {
+ if msg.WaitForReady != nil && *msg.WaitForReady && alive {
state := internal.CtxGetState(s.rootCtx)
status, err := state.Status()
if err != nil {
@@ -1551,6 +1657,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
EnableSSHRemotePortForwarding: enableSSHRemotePortForwarding,
DisableSSHAuth: disableSSHAuth,
SshJWTCacheTTL: sshJWTCacheTTL,
+ MDMManagedFields: cfg.Policy().ManagedKeys(),
}, nil
}
@@ -1702,7 +1809,7 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest)
features := &proto.GetFeaturesResponse{
DisableProfiles: s.checkProfilesDisabled(),
DisableUpdateSettings: s.checkUpdateSettingsDisabled(),
- DisableNetworks: s.networksDisabled,
+ DisableNetworks: s.checkNetworksDisabled(),
}
return features, nil
@@ -1724,22 +1831,46 @@ func (s *Server) connect(ctx context.Context, config *profilemanager.Config, sta
return nil
}
+// MDM authority: when the platform-native MDM source sets a kill switch
+// key (regardless of true/false value), that value wins. The CLI flag
+// supplied at service install time is the fallback used only when the
+// MDM source is silent on the key. This honors the "MDM decides
+// everything" semantic agreed for NET-1214 — an admin pushing
+// disableX=false via MDM explicitly re-enables the feature even on a
+// box installed with --disable-X.
func (s *Server) checkProfilesDisabled() bool {
- // Check if the environment variable is set to disable profiles
- if s.profilesDisabled {
- return true
+ if s.config != nil {
+ if v, ok := s.config.Policy().GetBool(mdm.KeyDisableProfiles); ok {
+ return v
+ }
}
+ return s.profilesDisabled
+}
- return false
+// checkNetworksDisabled reports whether the networks/exit-node feature
+// is disabled on this daemon instance. Resolved MDM-first: when the
+// active policy declares mdm.KeyDisableNetworks the policy value wins
+// (regardless of true/false), so an admin can re-enable the feature
+// via MDM even on a host that was installed with --disable-networks.
+// Falls back to the s.networksDisabled CLI flag when the policy is
+// silent on the key. Mirrors checkProfilesDisabled and
+// checkUpdateSettingsDisabled.
+func (s *Server) checkNetworksDisabled() bool {
+ if s.config != nil {
+ if v, ok := s.config.Policy().GetBool(mdm.KeyDisableNetworks); ok {
+ return v
+ }
+ }
+ return s.networksDisabled
}
func (s *Server) checkUpdateSettingsDisabled() bool {
- // Check if the environment variable is set to disable profiles
- if s.updateSettingsDisabled {
- return true
+ if s.config != nil {
+ if v, ok := s.config.Policy().GetBool(mdm.KeyDisableUpdateSettings); ok {
+ return v
+ }
}
-
- return false
+ return s.updateSettingsDisabled
}
func (s *Server) startUpdateManagerForGUI() {
diff --git a/client/server/server_connect_test.go b/client/server/server_connect_test.go
index faea7da39..0c6e03a4a 100644
--- a/client/server/server_connect_test.go
+++ b/client/server/server_connect_test.go
@@ -101,6 +101,7 @@ func TestCleanupConnection_ClearsConnectClient(t *testing.T) {
require.NoError(t, err)
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
+ assert.False(t, s.clientRunning, "clientRunning should be cleared after cleanup (intent = down)")
}
// TestCleanState_NilConnectClient validates that CleanState doesn't panic
@@ -144,17 +145,20 @@ func TestDownThenUp_StaleRunningChan(t *testing.T) {
_, cancel := context.WithCancel(context.Background())
s.actCancel = cancel
- // Simulate Down(): cleanupConnection sets connectClient = nil
+ // Simulate Down(): cleanupConnection sets connectClient = nil and
+ // flips clientRunning to false (intent = down). The connectionGoroutineRunning state
+ // remains independent of intent — derived from clientGiveUpChan.
s.mutex.Lock()
err := s.cleanupConnection()
s.mutex.Unlock()
require.NoError(t, err)
- // After cleanup: connectClient is nil, clientRunning still true
- // (goroutine hasn't exited yet)
+ // After cleanup: connectClient is nil, clientRunning is false (intent
+ // cleared by cleanupConnection), connectionGoroutineRunning may still be true
+ // (goroutine teardown is independent of the intent flag).
s.mutex.Lock()
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
- assert.True(t, s.clientRunning, "clientRunning still true until goroutine exits")
+ assert.False(t, s.clientRunning, "clientRunning should be cleared by cleanupConnection (intent = down)")
s.mutex.Unlock()
// waitForUp() returns immediately due to stale closed clientRunningChan
diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go
new file mode 100644
index 000000000..53232c70d
--- /dev/null
+++ b/client/server/setconfig_mdm_test.go
@@ -0,0 +1,198 @@
+package server
+
+import (
+ "context"
+ "os/user"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc/codes"
+ gstatus "google.golang.org/grpc/status"
+
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/client/mdm"
+ "github.com/netbirdio/netbird/client/proto"
+)
+
+// withMDMPolicy temporarily overrides the server-package loadMDMPolicy hook
+// so SetConfig observes the supplied Policy. Restores the original loader
+// at test cleanup.
+func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
+ t.Helper()
+ prev := loadMDMPolicy
+ loadMDMPolicy = func() *mdm.Policy { return policy }
+ t.Cleanup(func() { loadMDMPolicy = prev })
+}
+
+// setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved:
+// overrides profilemanager paths to a temp dir, seeds a profile, sets it
+// active, and constructs a Server instance. Returns the constructed server
+// plus context + profile name + username + cfgPath for the seeded profile.
+func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profName, username, cfgPath string) {
+ t.Helper()
+ tempDir := t.TempDir()
+
+ origDefaultProfileDir := profilemanager.DefaultConfigPathDir
+ origDefaultConfigPath := profilemanager.DefaultConfigPath
+ origActiveProfileStatePath := profilemanager.ActiveProfileStatePath
+ profilemanager.ConfigDirOverride = tempDir
+ profilemanager.DefaultConfigPathDir = tempDir
+ profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json"
+ profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json")
+ t.Cleanup(func() {
+ profilemanager.DefaultConfigPathDir = origDefaultProfileDir
+ profilemanager.ActiveProfileStatePath = origActiveProfileStatePath
+ profilemanager.DefaultConfigPath = origDefaultConfigPath
+ profilemanager.ConfigDirOverride = ""
+ })
+
+ currUser, err := user.Current()
+ require.NoError(t, err)
+
+ profName = "test-profile-mdm"
+ cfgPath = filepath.Join(tempDir, profName+".json")
+
+ _, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
+ ConfigPath: cfgPath,
+ ManagementURL: "https://api.netbird.io:443",
+ })
+ require.NoError(t, err)
+
+ pm := profilemanager.ServiceManager{}
+ require.NoError(t, pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
+ Name: profName,
+ Username: currUser.Username,
+ }))
+
+ ctx = context.Background()
+ s = New(ctx, "console", "", false, false, false, false)
+ return s, ctx, profName, currUser.Username, cfgPath
+}
+
+// extractViolation pulls the MDMManagedFieldsViolation detail from a
+// FailedPrecondition error. Fails the test if absent or malformed.
+func extractViolation(t *testing.T, err error) *proto.MDMManagedFieldsViolation {
+ t.Helper()
+ require.Error(t, err)
+ st, ok := gstatus.FromError(err)
+ require.True(t, ok, "error must be a gRPC status: %v", err)
+ require.Equal(t, codes.FailedPrecondition, st.Code(), "expected FailedPrecondition, got %s", st.Code())
+ for _, d := range st.Details() {
+ if v, ok := d.(*proto.MDMManagedFieldsViolation); ok {
+ return v
+ }
+ }
+ t.Fatalf("MDMManagedFieldsViolation detail not found on status; details: %v", st.Details())
+ return nil
+}
+
+func TestSetConfig_MDMReject_SingleField(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: "https://mdm.example.com:443",
+ }))
+
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ ManagementUrl: "https://user.tried.this.com:443",
+ })
+
+ v := extractViolation(t, err)
+ assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields())
+}
+
+func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: "https://mdm.example.com:443",
+ mdm.KeyBlockInbound: true,
+ mdm.KeyRosenpassEnabled: true,
+ }))
+
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ blockInbound := false
+ rosenpassEnabled := false
+ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ ManagementUrl: "https://user.tried.this.com:443",
+ BlockInbound: &blockInbound,
+ RosenpassEnabled: &rosenpassEnabled,
+ })
+
+ v := extractViolation(t, err)
+ assert.ElementsMatch(t, []string{
+ mdm.KeyManagementURL,
+ mdm.KeyBlockInbound,
+ mdm.KeyRosenpassEnabled,
+ }, v.GetFields())
+}
+
+func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
+ // MDM enforces ManagementURL only; user request touches both the
+ // enforced field AND a non-enforced field (RosenpassEnabled).
+ // The whole request must be rejected — non-conflicting fields are not
+ // applied either.
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: "https://mdm.example.com:443",
+ }))
+
+ s, ctx, profName, username, cfgPath := setupServerWithProfile(t)
+
+ rosenpassEnabled := true
+ _, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ ManagementUrl: "https://user.tried.this.com:443",
+ RosenpassEnabled: &rosenpassEnabled,
+ })
+
+ v := extractViolation(t, err)
+ assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields())
+
+ // 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)
+ require.NoError(t, err)
+ assert.False(t, reloaded.RosenpassEnabled, "non-conflicting field must not be applied when request is rejected")
+}
+
+func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) {
+ // MDM enforces ManagementURL but the user only writes RosenpassEnabled.
+ // Request must succeed.
+ withMDMPolicy(t, mdm.NewPolicy(map[string]any{
+ mdm.KeyManagementURL: "https://mdm.example.com:443",
+ }))
+
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ rosenpassEnabled := true
+ resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ RosenpassEnabled: &rosenpassEnabled,
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
+
+func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) {
+ // No MDM policy active: any field can be written.
+ withMDMPolicy(t, mdm.NewPolicy(nil))
+
+ s, ctx, profName, username, _ := setupServerWithProfile(t)
+
+ resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
+ ProfileName: profName,
+ Username: username,
+ ManagementUrl: "https://user.changed.url.com:443",
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, resp)
+}
diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go
index 4eac825b4..d2f38cfd7 100644
--- a/client/ui/client_ui.go
+++ b/client/ui/client_ui.go
@@ -38,6 +38,7 @@ import (
"github.com/netbirdio/netbird/client/iface"
"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"
"github.com/netbirdio/netbird/client/ui/desktop"
"github.com/netbirdio/netbird/client/ui/event"
@@ -56,8 +57,22 @@ const (
const (
censoredPreSharedKey = "**********"
maxSSHJWTCacheTTL = 86_400 // 24 hours in seconds
+ // mdmFieldSuffix is appended to plain-text Entry widgets in the
+ // advanced Settings window when the underlying field is enforced
+ // by MDM, so the user sees the lock indicator inline next to the
+ // value. Stripped before any read site that feeds the value back
+ // into a SetConfig request (saveSettings / parseNumericSettings).
+ mdmFieldSuffix = " (MDM)"
)
+// main is the entry point for the UI tray/client binary. Parses CLI
+// flags, initialises logging, builds the Fyne application and tray
+// icons, and constructs the service client (which may open a
+// requested UI window). When a window-mode flag is set the Fyne event
+// loop runs and main returns; otherwise main enforces single-instance
+// behaviour (signalling an existing instance to show its window when
+// present), sets up signal handling + default fonts, and runs the
+// system tray loop.
func main() {
flags := parseFlags()
@@ -315,9 +330,13 @@ type serviceClient struct {
isUpdateIconActive bool
isEnforcedUpdate bool
lastNotifiedVersion string
- settingsEnabled bool
profilesEnabled bool
networksEnabled bool
+ // networksMenuEnabled caches the last applied enabled-state of the
+ // mNetworks + mExitNode submenu items. Combines features.DisableNetworks
+ // AND s.connected — both must be true for the menus to be active.
+ // Zero value (false) matches the Disable() call at AddMenuItem time.
+ networksMenuEnabled bool
showNetworks bool
wNetworks fyne.Window
wProfiles fyne.Window
@@ -336,6 +355,13 @@ type serviceClient struct {
updateContextCancel context.CancelFunc
connectCancel context.CancelFunc
+
+ // mdmManagedFields caches the names of MDM-enforced policy keys
+ // surfaced by the daemon in GetConfigResponse. Each refresh of
+ // daemon config (loadSettings, getSrvConfig, config_changed event)
+ // updates this set and re-applies the lock/badge to the affected
+ // menu items and settings-form widgets.
+ mdmManagedFields map[string]bool
}
type menuHandler struct {
@@ -441,15 +467,12 @@ func (s *serviceClient) updateIcon() {
}
func (s *serviceClient) showSettingsUI() {
- // Check if update settings are disabled by daemon
- features, err := s.getFeatures()
- if err != nil {
- log.Errorf("failed to get features from daemon: %v", err)
- // Continue with default behavior if features can't be retrieved
- } else if features != nil && features.DisableUpdateSettings {
- log.Warn("Update settings are disabled by daemon")
- return
- }
+ // DisableUpdateSettings no longer gates the window from opening:
+ // the daemon blocks every actual mutation at SetConfig / Login,
+ // so the window is safe to show as a read-only view. The previous
+ // early-return also blocked Advanced Settings whenever update
+ // editing was off, which conflated two distinct kill switches
+ // (see comment in checkAndUpdateFeatures).
// add settings window UI elements.
s.wSettings = s.app.NewWindow("NetBird Settings")
@@ -532,7 +555,7 @@ func (s *serviceClient) saveSettings() {
return
}
- iMngURL := strings.TrimSpace(s.iMngURL.Text)
+ iMngURL := strings.TrimSpace(strings.TrimSuffix(s.iMngURL.Text, mdmFieldSuffix))
if s.hasSettingsChanged(iMngURL, port, mtu) {
if err := s.applySettingsChanges(iMngURL, port, mtu); err != nil {
@@ -554,7 +577,7 @@ func (s *serviceClient) validateSettings() error {
}
func (s *serviceClient) parseNumericSettings() (int64, int64, error) {
- port, err := strconv.ParseInt(s.iInterfacePort.Text, 10, 64)
+ port, err := strconv.ParseInt(strings.TrimSpace(strings.TrimSuffix(s.iInterfacePort.Text, mdmFieldSuffix)), 10, 64)
if err != nil {
return 0, 0, errors.New("invalid interface port")
}
@@ -663,7 +686,15 @@ func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) (
req.SshJWTCacheTTL = &sshJWTCacheTTL32
}
- if s.iPreSharedKey.Text != censoredPreSharedKey {
+ // Only attach the PSK when the user actually typed something:
+ // - "" means the field was left untouched (we deliberately render
+ // an empty Text + placeholder hint to avoid leaking the daemon's
+ // "**********" redaction through the password reveal toggle);
+ // sending an empty pointer would tell the daemon to clear / overwrite
+ // the on-disk or MDM-enforced PSK, which then trips the MDM
+ // conflict gate when PSK is policy-managed.
+ // - "**********" is the redacted echo (legacy non-MDM path); also a no-op.
+ if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey {
req.OptionalPreSharedKey = &s.iPreSharedKey.Text
}
@@ -1038,6 +1069,13 @@ func (s *serviceClient) onTrayReady() {
}
s.mProfile = newProfileMenu(*newProfileMenuArgs)
+ // Seed the transition cache to match the actual default menu
+ // state (visible / enabled). Without this, the first
+ // checkAndUpdateFeatures tick that observes DisableProfiles=true
+ // is a no-op (cache zero-value == desired-false) and the menu
+ // never gets hidden — symptom: MDM enforces the kill switch but
+ // the profile menu stays clickable.
+ s.profilesEnabled = true
systray.AddSeparator()
s.mUp = systray.AddMenuItem("Connect", "Connect")
@@ -1057,18 +1095,18 @@ func (s *serviceClient) onTrayReady() {
s.mCreateDebugBundle = s.mSettings.AddSubMenuItem("Create Debug Bundle", debugBundleMenuDescr)
s.loadSettings()
- // Disable settings menu if update settings are disabled by daemon
+ // Disable profile menu if profiles are disabled by daemon.
+ // DisableUpdateSettings is enforced at the daemon's SetConfig /
+ // Login gates, not by hiding the UI — so the Settings menu (and
+ // its Advanced Settings submenu, which has its own kill switch)
+ // stays visible and the user can still inspect current values.
features, err := s.getFeatures()
if err != nil {
log.Errorf("failed to get features from daemon: %v", err)
// Continue with default behavior if features can't be retrieved
- } else {
- if features != nil && features.DisableUpdateSettings {
- s.setSettingsEnabled(false)
- }
- if features != nil && features.DisableProfiles {
- s.mProfile.setEnabled(false)
- }
+ } else if features != nil && features.DisableProfiles {
+ s.mProfile.setEnabled(false)
+ s.profilesEnabled = false
}
s.exitNodeMu.Lock()
@@ -1102,13 +1140,20 @@ func (s *serviceClient) onTrayReady() {
// update exit node menu in case service is already connected
go s.updateExitNodes()
+ // Features (DisableProfiles, DisableUpdateSettings, DisableNetworks,
+ // ...) only change in two ways: at service install time (CLI flag,
+ // static) and at MDM ticker diff time. The daemon already publishes
+ // a SystemEvent{type=config_changed} on every MDM-driven engine
+ // restart, so the UI no longer needs to poll GetFeatures every 2 s.
+ // A single fetch at startup covers the static CLI-flag case; the
+ // event handler below covers MDM transitions. updateStatus stays in
+ // the 2 s loop because connection / peer state genuinely change
+ // continuously and have no event yet.
+ s.checkAndUpdateFeatures()
go func() {
s.getSrvConfig()
time.Sleep(100 * time.Millisecond) // To prevent race condition caused by systray not being fully initialized and ignoring setIcon
for {
- // Check features before status so menus respect disable flags before being enabled
- s.checkAndUpdateFeatures()
-
err := s.updateStatus()
if err != nil {
log.Errorf("error while updating status: %v", err)
@@ -1152,6 +1197,23 @@ func (s *serviceClient) onTrayReady() {
s.onUpdateAvailable(newVersion, enforced)
}
})
+ s.eventManager.AddHandler(func(event *proto.SystemEvent) {
+ // Daemon emits a config_changed event after every engine spawn
+ // (Server.Start, Server.Up, MDM ticker restart). Re-sync the
+ // tray submenu checkboxes from the fresh daemon-side config so
+ // the user does not have to restart the tray to see CLI- or
+ // MDM-driven changes.
+ if event.Category == proto.SystemEvent_SYSTEM && event.Metadata["type"] == "config_changed" {
+ log.Infof("config_changed event received (source=%s); refreshing settings + features", event.Metadata["source"])
+ s.loadSettings()
+ // MDM-driven feature kill switches (DisableProfiles /
+ // DisableUpdateSettings / DisableNetworks) ride the same
+ // config_changed signal because the daemon re-applies its
+ // MDM policy on every engine spawn. Pull them in here so
+ // the UI is up to date without a periodic GetFeatures poll.
+ s.checkAndUpdateFeatures()
+ }
+ })
go s.eventManager.Start(s.ctx)
go s.eventHandler.listen(s.ctx)
@@ -1215,18 +1277,6 @@ func (s *serviceClient) getSrvClient(timeout time.Duration) (proto.DaemonService
return s.conn, nil
}
-// setSettingsEnabled enables or disables the settings menu based on the provided state
-func (s *serviceClient) setSettingsEnabled(enabled bool) {
- if s.mSettings != nil {
- if enabled {
- s.mSettings.Enable()
- } else {
- s.mSettings.Hide()
- s.mSettings.SetTooltip("Settings are disabled by daemon")
- }
- }
-}
-
// checkAndUpdateFeatures checks the current features and updates the UI accordingly
func (s *serviceClient) checkAndUpdateFeatures() {
features, err := s.getFeatures()
@@ -1238,12 +1288,11 @@ func (s *serviceClient) checkAndUpdateFeatures() {
s.updateIndicationLock.Lock()
defer s.updateIndicationLock.Unlock()
- // Update settings menu based on current features
- settingsEnabled := features == nil || !features.DisableUpdateSettings
- if s.settingsEnabled != settingsEnabled {
- s.settingsEnabled = settingsEnabled
- s.setSettingsEnabled(settingsEnabled)
- }
+ // DisableUpdateSettings is enforced server-side by the daemon gates
+ // on SetConfig + Login: any attempt to mutate config from UI or
+ // CLI is rejected at that layer. The UI deliberately keeps the
+ // Settings menu visible so the user can still inspect current
+ // values — read-only by virtue of the daemon refusing edits.
// Update profile menu based on current features
if s.mProfile != nil {
@@ -1254,14 +1303,23 @@ func (s *serviceClient) checkAndUpdateFeatures() {
}
}
- // Update networks and exit node menus based on current features
+ // Update networks and exit node menus based on current features.
+ // `networksEnabled` is the bare feature flag (read elsewhere, e.g. at
+ // connection-status transitions). `networksMenuEnabled` is the
+ // transition-cached state actually applied to the menu items —
+ // it folds in the connection state so a Connected client with the
+ // kill switch off shows the menus active, and only flips on diff.
s.networksEnabled = features == nil || !features.DisableNetworks
- if s.networksEnabled && s.connected {
- s.mNetworks.Enable()
- s.mExitNode.Enable()
- } else {
- s.mNetworks.Disable()
- s.mExitNode.Disable()
+ desiredNetworksMenu := s.networksEnabled && s.connected
+ if desiredNetworksMenu != s.networksMenuEnabled {
+ s.networksMenuEnabled = desiredNetworksMenu
+ if desiredNetworksMenu {
+ s.mNetworks.Enable()
+ s.mExitNode.Enable()
+ } else {
+ s.mNetworks.Disable()
+ s.mExitNode.Disable()
+ }
}
}
@@ -1358,7 +1416,14 @@ func (s *serviceClient) getSrvConfig() {
if s.showAdvancedSettings {
s.iMngURL.SetText(s.managementURL)
- s.iPreSharedKey.SetText(cfg.PreSharedKey)
+ // PSK is rendered with an empty Text and a hint via the
+ // placeholder so the eye toggle never reveals literal asterisks
+ // (the daemon returns the "**********" sentinel — writing that
+ // into a PasswordEntry would surface the literal sentinel when
+ // the user unmasks the field). The placeholder communicates the
+ // configured / MDM-managed state without exposing any value.
+ s.iPreSharedKey.SetText("")
+ s.iPreSharedKey.SetPlaceHolder(preSharedKeyPlaceholder(srvCfg))
s.iInterfaceName.SetText(cfg.WgIface)
s.iInterfacePort.SetText(strconv.Itoa(cfg.WgPort))
if cfg.MTU != 0 {
@@ -1368,7 +1433,15 @@ func (s *serviceClient) getSrvConfig() {
s.iMTU.SetPlaceHolder(strconv.Itoa(int(iface.DefaultMTU)))
}
s.sRosenpassPermissive.SetChecked(cfg.RosenpassPermissive)
- if !cfg.RosenpassEnabled {
+ // Re-baseline the enabled state on every refresh: when Rosenpass
+ // is on the checkbox is editable, when it's off the field is
+ // inert. Without an explicit Enable() here the control stays
+ // stuck disabled after a previous refresh (or an MDM unlock) had
+ // turned it off — applyMDMLocksToSettingsForm below adds the
+ // MDM lock on top of this baseline.
+ if cfg.RosenpassEnabled {
+ s.sRosenpassPermissive.Enable()
+ } else {
s.sRosenpassPermissive.Disable()
}
s.sNetworkMonitor.SetChecked(*cfg.NetworkMonitor)
@@ -1397,6 +1470,13 @@ func (s *serviceClient) getSrvConfig() {
}
}
+ // MDM locks must run before the mNotifications-nil early return:
+ // the Settings window is rendered by a separate UI process launched
+ // with --settings (see handleAdvancedSettingsClick), and that child
+ // process does NOT run onReady — so its mNotifications is nil and
+ // the early return below skipped the lock pass entirely.
+ s.applyMDMLocks(srvCfg.MDMManagedFields)
+
if s.mNotifications == nil {
return
}
@@ -1581,6 +1661,129 @@ func (s *serviceClient) loadSettings() {
if s.eventManager != nil {
s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked())
}
+ s.applyMDMLocks(cfg.MDMManagedFields)
+}
+
+// applyMDMLocks disables and badges any tray submenu item or settings-
+// form widget whose underlying field is enforced by the active MDM
+// policy. Called from loadSettings (submenu refresh) and from
+// getSrvConfig (settings-window refresh). Locked items keep their value
+// already set by the surrounding refresh code — this routine only
+// flips the enabled state and the title suffix, never the value.
+func (s *serviceClient) applyMDMLocks(managed []string) {
+ set := make(map[string]bool, len(managed))
+ for _, k := range managed {
+ set[k] = true
+ }
+ s.mdmManagedFields = set
+ if len(managed) > 0 {
+ log.Infof("MDM-managed UI fields: %v", managed)
+ }
+
+ type submenuTarget struct {
+ item *systray.MenuItem
+ title string
+ key string
+ }
+ for _, t := range []submenuTarget{
+ {s.mAllowSSH, "Allow SSH", mdm.KeyAllowServerSSH},
+ {s.mAutoConnect, "Connect on Startup", mdm.KeyDisableAutoConnect},
+ {s.mEnableRosenpass, "Enable Quantum-Resistance", mdm.KeyRosenpassEnabled},
+ {s.mBlockInbound, "Block Inbound Connections", mdm.KeyBlockInbound},
+ } {
+ if t.item == nil {
+ continue
+ }
+ if set[t.key] {
+ t.item.SetTitle(t.title + " (MDM)")
+ t.item.Disable()
+ } else {
+ t.item.SetTitle(t.title)
+ t.item.Enable()
+ }
+ }
+
+ s.applyMDMLocksToSettingsForm(set)
+}
+
+// preSharedKeyPlaceholder returns the hint string shown in the PSK
+// Entry's placeholder slot. The placeholder is the only signal the
+// user gets that a PSK is configured, because the entry's Text is
+// forced to empty to keep the password reveal toggle from leaking
+// the daemon-returned "**********" redaction sentinel. Returns "" if
+// no PSK is present, "MDM-managed" if the key is enforced by MDM,
+// and "configured" otherwise.
+func preSharedKeyPlaceholder(cfg *proto.GetConfigResponse) string {
+ if cfg == nil || cfg.PreSharedKey == "" {
+ return ""
+ }
+ for _, k := range cfg.MDMManagedFields {
+ if k == mdm.KeyPreSharedKey {
+ return "MDM-managed"
+ }
+ }
+ return "configured"
+}
+
+// applyMDMLocksToSettingsForm disables the per-field input widgets in
+// the advanced Settings window when the corresponding MDM key is set.
+// For plain-text entries (Management URL, Interface Port) the visible
+// value is suffixed with " (MDM)" so the user sees the lock indicator
+// inline; for the password entry the suffix is skipped (a password
+// widget renders every char as a dot and the indicator would not be
+// readable). The widgets are created lazily by showSettingsUI, so
+// guard each ref against nil.
+func (s *serviceClient) applyMDMLocksToSettingsForm(set map[string]bool) {
+ type entryTarget struct {
+ entry *widget.Entry
+ key string
+ inlineTag bool
+ }
+ for _, t := range []entryTarget{
+ {s.iMngURL, mdm.KeyManagementURL, true},
+ {s.iPreSharedKey, mdm.KeyPreSharedKey, false},
+ {s.iInterfacePort, mdm.KeyWireguardPort, true},
+ } {
+ if t.entry == nil {
+ continue
+ }
+ if set[t.key] {
+ if t.inlineTag && t.entry.Text != "" && !strings.HasSuffix(t.entry.Text, mdmFieldSuffix) {
+ t.entry.SetText(t.entry.Text + mdmFieldSuffix)
+ }
+ t.entry.Disable()
+ } else {
+ if t.inlineTag {
+ t.entry.SetText(strings.TrimSuffix(t.entry.Text, mdmFieldSuffix))
+ }
+ t.entry.Enable()
+ }
+ }
+ type checkTarget struct {
+ check *widget.Check
+ key string
+ }
+ for _, t := range []checkTarget{
+ {s.sDisableClientRoutes, mdm.KeyDisableClientRoutes},
+ {s.sDisableServerRoutes, mdm.KeyDisableServerRoutes},
+ } {
+ if t.check == nil {
+ continue
+ }
+ if set[t.key] {
+ t.check.Disable()
+ } else {
+ t.check.Enable()
+ }
+ }
+ if s.sRosenpassPermissive != nil && set[mdm.KeyRosenpassPermissive] {
+ // MDM lock layered on top of the Rosenpass-on/off baseline
+ // applied by getSrvConfig. No Enable() branch here: when the
+ // MDM key is removed, the next getSrvConfig refresh re-baselines
+ // the control on cfg.RosenpassEnabled and brings it back if
+ // Rosenpass is on.
+ s.sRosenpassPermissive.Disable()
+ }
}
// updateConfig updates the configuration parameters
diff --git a/client/ui/profile.go b/client/ui/profile.go
index 9fef294d0..83b0ec18b 100644
--- a/client/ui/profile.go
+++ b/client/ui/profile.go
@@ -687,16 +687,48 @@ func (p *profileMenu) clear(profiles []Profile) {
}
}
-// setEnabled enables or disables the profile menu based on the provided state
+// setEnabled greys out (Disable) the profile menu and every existing
+// sub-item when the daemon reports the kill switch active, so the user
+// sees the menu but cannot enter "Manage Profiles" or switch profile.
+// Previously this used Hide() on the parent, but Fyne's systray on
+// Windows does not propagate Hide() to a parent that already has
+// children — the submenu kept popping up and accepting clicks. Disable
+// is the reliable visual lock.
func (p *profileMenu) setEnabled(enabled bool) {
- if p.profileMenuItem != nil {
- if enabled {
- p.profileMenuItem.Enable()
- p.profileMenuItem.SetTooltip("")
- } else {
- p.profileMenuItem.Hide()
- p.profileMenuItem.SetTooltip("Profiles are disabled by daemon")
+ if p.profileMenuItem == nil {
+ return
+ }
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ if enabled {
+ p.profileMenuItem.Enable()
+ p.profileMenuItem.SetTooltip("")
+ } else {
+ p.profileMenuItem.Disable()
+ p.profileMenuItem.SetTooltip("Profiles are disabled by daemon")
+ }
+
+ apply := func(item *systray.MenuItem) {
+ if item == nil {
+ return
}
+ if enabled {
+ item.Enable()
+ } else {
+ item.Disable()
+ }
+ }
+ for _, sub := range p.profileSubItems {
+ if sub != nil {
+ apply(sub.MenuItem)
+ }
+ }
+ if p.manageProfilesSubItem != nil {
+ apply(p.manageProfilesSubItem.MenuItem)
+ }
+ if p.logoutSubItem != nil {
+ apply(p.logoutSubItem.MenuItem)
}
}
diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go
index 066fe043b..4683f4033 100644
--- a/client/wasm/cmd/main.go
+++ b/client/wasm/cmd/main.go
@@ -21,6 +21,7 @@ import (
"github.com/netbirdio/netbird/client/wasm/internal/http"
"github.com/netbirdio/netbird/client/wasm/internal/rdp"
"github.com/netbirdio/netbird/client/wasm/internal/ssh"
+ nbwebsocket "github.com/netbirdio/netbird/client/wasm/internal/websocket"
"github.com/netbirdio/netbird/util"
)
@@ -30,6 +31,7 @@ const (
pingTimeout = 10 * time.Second
defaultLogLevel = "warn"
defaultSSHDetectionTimeout = 20 * time.Second
+ dialWebSocketTimeout = 30 * time.Second
icmpEchoRequest = 8
icmpCodeEcho = 0
@@ -677,6 +679,7 @@ func createClientObject(client *netbird.Client) js.Value {
obj["createSSHConnection"] = createSSHMethod(client)
obj["proxyRequest"] = createProxyRequestMethod(client)
obj["createRDPProxy"] = createRDPProxyMethod(client)
+ obj["dialWebSocket"] = createDialWebSocketMethod(client)
obj["status"] = createStatusMethod(client)
obj["statusSummary"] = createStatusSummaryMethod(client)
obj["statusDetail"] = createStatusDetailMethod(client)
@@ -691,6 +694,74 @@ func createClientObject(client *netbird.Client) js.Value {
return js.ValueOf(obj)
}
+func createDialWebSocketMethod(client *netbird.Client) js.Func {
+ return js.FuncOf(func(_ js.Value, args []js.Value) any {
+ url, protocols, timeout, errVal := parseDialWebSocketArgs(args)
+ if !errVal.IsUndefined() {
+ return errVal
+ }
+
+ return createPromise(func(resolve, reject js.Value) {
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ conn, err := nbwebsocket.Dial(ctx, client, url, protocols)
+ if err != nil {
+ reject.Invoke(js.ValueOf(fmt.Sprintf("dial websocket: %v", err)))
+ return
+ }
+
+ resolve.Invoke(nbwebsocket.NewJSInterface(conn))
+ })
+ })
+}
+
+func parseDialWebSocketArgs(args []js.Value) (url string, protocols []string, timeout time.Duration, errVal js.Value) {
+ if len(args) < 1 || args[0].Type() != js.TypeString {
+ return "", nil, 0, js.ValueOf("error: dialWebSocket requires a URL string argument")
+ }
+ url = args[0].String()
+
+ if len(args) >= 2 && !args[1].IsNull() && !args[1].IsUndefined() {
+ arr, err := jsStringArray(args[1])
+ if err != nil {
+ return "", nil, 0, js.ValueOf(fmt.Sprintf("error: protocols: %v", err))
+ }
+ protocols = arr
+ }
+
+ timeout = dialWebSocketTimeout
+ if len(args) >= 3 && !args[2].IsNull() && !args[2].IsUndefined() {
+ if args[2].Type() != js.TypeNumber {
+ return "", nil, 0, js.ValueOf("error: timeoutMs must be a number")
+ }
+ timeoutMs := args[2].Int()
+ if timeoutMs <= 0 {
+ return "", nil, 0, js.ValueOf("error: timeout must be positive")
+ }
+ timeout = time.Duration(timeoutMs) * time.Millisecond
+ }
+
+ return url, protocols, timeout, js.Undefined()
+}
+
+// jsStringArray converts a JS array of strings to a Go []string.
+func jsStringArray(v js.Value) ([]string, error) {
+ if !v.InstanceOf(js.Global().Get("Array")) {
+ return nil, fmt.Errorf("expected array")
+ }
+ n := v.Length()
+ out := make([]string, n)
+ for i := 0; i < n; i++ {
+ el := v.Index(i)
+ if el.Type() != js.TypeString {
+ return nil, fmt.Errorf("element %d is not a string", i)
+ }
+ out[i] = el.String()
+ }
+ return out, nil
+}
+
// netBirdClientConstructor acts as a JavaScript constructor function
func netBirdClientConstructor(_ js.Value, args []js.Value) any {
return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any {
diff --git a/client/wasm/internal/websocket/websocket.go b/client/wasm/internal/websocket/websocket.go
new file mode 100644
index 000000000..19ddaa38c
--- /dev/null
+++ b/client/wasm/internal/websocket/websocket.go
@@ -0,0 +1,304 @@
+//go:build js
+
+package websocket
+
+import (
+ "context"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "sync"
+ "syscall/js"
+
+ "github.com/gobwas/ws"
+ "github.com/gobwas/ws/wsutil"
+ netbird "github.com/netbirdio/netbird/client/embed"
+ log "github.com/sirupsen/logrus"
+)
+
+type closeError struct {
+ code uint16
+ reason string
+}
+
+func (e *closeError) Error() string {
+ return fmt.Sprintf("websocket closed: %d %s", e.code, e.reason)
+}
+
+// bufferedConn fronts a net.Conn with a reader that serves any bytes buffered
+// during the WebSocket handshake before falling through to the raw conn.
+type bufferedConn struct {
+ net.Conn
+ r io.Reader
+}
+
+func (c *bufferedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
+
+// Conn wraps a WebSocket connection over a NetBird TCP connection.
+type Conn struct {
+ conn net.Conn
+ mu sync.Mutex
+ closed chan struct{}
+ closeOnce sync.Once
+ closeErr error
+}
+
+// Dial establishes a WebSocket connection to the given URL through the NetBird network.
+// Optional protocols are sent via the Sec-WebSocket-Protocol header.
+func Dial(ctx context.Context, client *netbird.Client, rawURL string, protocols []string) (*Conn, error) {
+ d := ws.Dialer{
+ NetDial: client.Dial,
+ Protocols: protocols,
+ }
+
+ conn, br, _, err := d.Dial(ctx, rawURL)
+ if err != nil {
+ return nil, fmt.Errorf("websocket dial: %w", err)
+ }
+
+ // br is non-nil when the server pushed frames alongside the handshake
+ // response; those bytes live in the bufio.Reader and must be drained
+ // before reading from conn, otherwise we'd skip the first frames.
+ if br != nil {
+ if br.Buffered() > 0 {
+ conn = &bufferedConn{Conn: conn, r: io.MultiReader(br, conn)}
+ } else {
+ ws.PutReader(br)
+ }
+ }
+
+ return &Conn{
+ conn: conn,
+ closed: make(chan struct{}),
+ }, nil
+}
+
+// ReadMessage reads the next WebSocket message, handling control frames automatically.
+func (c *Conn) ReadMessage() (ws.OpCode, []byte, error) {
+ for {
+ msgs, err := wsutil.ReadServerMessage(c.conn, nil)
+ if err != nil {
+ return 0, nil, err
+ }
+
+ for _, msg := range msgs {
+ if msg.OpCode.IsControl() {
+ if err := c.handleControl(msg); err != nil {
+ return 0, nil, err
+ }
+ continue
+ }
+ return msg.OpCode, msg.Payload, nil
+ }
+ }
+}
+
+func (c *Conn) handleControl(msg wsutil.Message) error {
+ switch msg.OpCode {
+ case ws.OpPing:
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return wsutil.WriteClientMessage(c.conn, ws.OpPong, msg.Payload)
+ case ws.OpClose:
+ code, reason := parseClosePayload(msg.Payload)
+ return &closeError{code: code, reason: reason}
+ default:
+ return nil
+ }
+}
+
+// WriteText sends a text WebSocket message.
+func (c *Conn) WriteText(data []byte) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return wsutil.WriteClientMessage(c.conn, ws.OpText, data)
+}
+
+// WriteBinary sends a binary WebSocket message.
+func (c *Conn) WriteBinary(data []byte) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return wsutil.WriteClientMessage(c.conn, ws.OpBinary, data)
+}
+
+// Close sends a close frame with StatusNormalClosure and closes the underlying connection.
+func (c *Conn) Close() error {
+ return c.closeWith(ws.StatusNormalClosure, "")
+}
+
+// closeWith sends a close frame with the given code/reason and closes the underlying connection.
+// Used to echo the server's code when responding to a server-initiated close per RFC 6455 §5.5.1.
+func (c *Conn) closeWith(code ws.StatusCode, reason string) error {
+ var first bool
+ c.closeOnce.Do(func() {
+ first = true
+ close(c.closed)
+
+ c.mu.Lock()
+ _ = wsutil.WriteClientMessage(c.conn, ws.OpClose, ws.NewCloseFrameBody(code, reason))
+ c.mu.Unlock()
+
+ c.closeErr = c.conn.Close()
+ })
+
+ if !first {
+ return net.ErrClosed
+ }
+ return c.closeErr
+}
+
+// NewJSInterface creates a JavaScript object wrapping the WebSocket connection.
+// It exposes: send(string|Uint8Array), close(), and callback properties
+// onmessage, onclose, onerror.
+//
+// Callback properties may be set from the JS thread while the read loop
+// goroutine reads them. In WASM this is safe because Go and JS share a
+// single thread, but the design would need synchronization on
+// multi-threaded runtimes.
+func NewJSInterface(conn *Conn) js.Value {
+ obj := js.Global().Get("Object").Call("create", js.Null())
+
+ sendFunc := js.FuncOf(func(_ js.Value, args []js.Value) any {
+ if len(args) < 1 {
+ log.Errorf("websocket send requires a data argument")
+ return js.ValueOf(false)
+ }
+
+ data := args[0]
+ switch data.Type() {
+ case js.TypeString:
+ if err := conn.WriteText([]byte(data.String())); err != nil {
+ log.Errorf("failed to send websocket text: %v", err)
+ return js.ValueOf(false)
+ }
+ default:
+ buf, err := jsToBytes(data)
+ if err != nil {
+ log.Errorf("failed to convert js value to bytes: %v", err)
+ return js.ValueOf(false)
+ }
+ if err := conn.WriteBinary(buf); err != nil {
+ log.Errorf("failed to send websocket binary: %v", err)
+ return js.ValueOf(false)
+ }
+ }
+ return js.ValueOf(true)
+ })
+ obj.Set("send", sendFunc)
+
+ closeFunc := js.FuncOf(func(_ js.Value, _ []js.Value) any {
+ if err := conn.Close(); err != nil {
+ log.Debugf("failed to close websocket: %v", err)
+ }
+ return js.Undefined()
+ })
+ obj.Set("close", closeFunc)
+
+ go func() {
+ defer func() {
+ if err := conn.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
+ log.Debugf("close websocket on readLoop exit: %v", err)
+ }
+ }()
+ readLoop(conn, obj)
+ // Undefining before Release turns post-close JS calls into TypeError
+ // instead of a silent "call to released function".
+ obj.Set("send", js.Undefined())
+ obj.Set("close", js.Undefined())
+ sendFunc.Release()
+ closeFunc.Release()
+ }()
+
+ return obj
+}
+
+func jsToBytes(data js.Value) ([]byte, error) {
+ var uint8Array js.Value
+ switch {
+ case data.InstanceOf(js.Global().Get("Uint8Array")):
+ uint8Array = data
+ case data.InstanceOf(js.Global().Get("ArrayBuffer")):
+ uint8Array = js.Global().Get("Uint8Array").New(data)
+ default:
+ return nil, fmt.Errorf("send: unsupported data type, use string, Uint8Array, or ArrayBuffer")
+ }
+
+ buf := make([]byte, uint8Array.Get("length").Int())
+ js.CopyBytesToGo(buf, uint8Array)
+ return buf, nil
+}
+
+func readLoop(conn *Conn, obj js.Value) {
+ var ce *closeError
+ defer func() { invokeOnClose(obj, ce) }()
+
+ for {
+ select {
+ case <-conn.closed:
+ return
+ default:
+ }
+
+ op, payload, err := conn.ReadMessage()
+ if err != nil {
+ ce = handleReadError(conn, obj, err)
+ return
+ }
+
+ dispatchMessage(obj, op, payload)
+ }
+}
+
+func handleReadError(conn *Conn, obj js.Value, err error) *closeError {
+ var ce *closeError
+ if errors.As(err, &ce) {
+ if cerr := conn.closeWith(ws.StatusCode(ce.code), ce.reason); cerr != nil {
+ log.Debugf("failed to close websocket after server close frame: %v", cerr)
+ }
+ return ce
+ }
+ if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
+ return nil
+ }
+ if onerror := obj.Get("onerror"); onerror.Truthy() {
+ onerror.Invoke(js.ValueOf(err.Error()))
+ }
+ return nil
+}
+
+func invokeOnClose(obj js.Value, ce *closeError) {
+ onclose := obj.Get("onclose")
+ if !onclose.Truthy() {
+ return
+ }
+ if ce != nil {
+ onclose.Invoke(js.ValueOf(int(ce.code)), js.ValueOf(ce.reason))
+ return
+ }
+ onclose.Invoke()
+}
+
+func dispatchMessage(obj js.Value, op ws.OpCode, payload []byte) {
+ onmessage := obj.Get("onmessage")
+ if !onmessage.Truthy() {
+ return
+ }
+ switch op {
+ case ws.OpText:
+ onmessage.Invoke(js.ValueOf(string(payload)))
+ case ws.OpBinary:
+ uint8Array := js.Global().Get("Uint8Array").New(len(payload))
+ js.CopyBytesToJS(uint8Array, payload)
+ onmessage.Invoke(uint8Array)
+ }
+}
+
+func parseClosePayload(payload []byte) (uint16, string) {
+ if len(payload) < 2 {
+ return 1005, "" // RFC 6455: No Status Rcvd
+ }
+ code := binary.BigEndian.Uint16(payload[:2])
+ return code, string(payload[2:])
+}
diff --git a/docs/io.netbird.client.plist b/docs/io.netbird.client.plist
new file mode 100644
index 000000000..f42b6b3d2
--- /dev/null
+++ b/docs/io.netbird.client.plist
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+ managementURL
+ https://api.netbird.io:443
+
+
+
+
+
+
+ allowServerSSH
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/netbird-macos.mobileconfig b/docs/netbird-macos.mobileconfig
new file mode 100644
index 000000000..53453db5c
--- /dev/null
+++ b/docs/netbird-macos.mobileconfig
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+ PayloadType
+ Configuration
+ PayloadVersion
+ 1
+ PayloadIdentifier
+ io.netbird.client.mdm
+ PayloadUUID
+ 11111111-1111-1111-1111-111111111111
+ PayloadDisplayName
+ NetBird MDM Policy
+ PayloadDescription
+ Enforces NetBird client configuration. Values written here override any local user / CLI / on-disk setting and are re-applied at every daemon boot and on every 1-minute MDM reload tick.
+ PayloadOrganization
+ NetBird
+ PayloadScope
+ System
+ PayloadRemovalDisallowed
+
+
+ PayloadContent
+
+
+
+ PayloadType
+ com.apple.ManagedClient.preferences
+ PayloadVersion
+ 1
+ PayloadIdentifier
+ io.netbird.client.mdm.preferences
+ PayloadUUID
+ 22222222-2222-2222-2222-222222222222
+ PayloadDisplayName
+ NetBird Managed Preferences
+ PayloadEnabled
+
+
+ PayloadContent
+
+ io.netbird.client
+
+ Forced
+
+
+ mcx_preference_settings
+
+
+
+ managementURL
+ https://api.netbird.io:443
+
+
+
+
+
+
+ allowServerSSH
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/netbird-macos.sh b/docs/netbird-macos.sh
new file mode 100644
index 000000000..a2f5ff5e8
--- /dev/null
+++ b/docs/netbird-macos.sh
@@ -0,0 +1,189 @@
+#!/bin/bash
+#
+# SYNOPSIS
+# Push the NetBird MDM policy to a macOS device via JumpCloud Commands.
+#
+# DESCRIPTION
+# This is the macOS counterpart of docs/netbird-policy.reg.ps1.
+# It writes the values declared in the "POLICY VALUES" block below to
+# the managed-preferences plist that the NetBird daemon's
+# client/mdm/policy_darwin.go loader reads on every 1-minute MDM
+# reload tick:
+#
+# /Library/Managed Preferences/io.netbird.client.plist
+#
+# Once the plist lands, the daemon picks up the new values without
+# restart (the ticker calls Config.apply() → applyMDMPolicy() and
+# restarts the engine on diff).
+#
+# DEPLOYMENT (JumpCloud)
+# 1. Admin Console -> Device Management -> Commands -> +.
+# 2. Type: Mac, Shell, Run as: root.
+# 3. Paste this file verbatim into the command body.
+# 4. Bind to the target system group, save, run.
+#
+# IMPORTANT: PERSISTENCE
+# macOS wipes /Library/Managed Preferences/ at every boot on devices
+# that are NOT MDM-enrolled. For a persistent fleet rollout, push the
+# companion docs/netbird-macos.mobileconfig as a Custom Configuration
+# Profile (Admin Console -> MDM -> Mac Custom Configuration Profiles)
+# instead of this script. Use this script when:
+# - the device is MDM-enrolled (file survives reboots), or
+# - you need a one-shot test push before reboot, or
+# - you orchestrate via JumpCloud Commands and want the same
+# variable-driven workflow as the Windows .ps1 sibling.
+#
+# IDEMPOTENCY: re-running with the same values is a no-op from the
+# daemon's point of view (the 1-minute reload ticker diff returns empty).
+#
+# SECURITY: PreSharedKey is redacted in this script's log output.
+
+set -euo pipefail
+
+### POLICY VALUES — EDIT THIS BLOCK ###########################################
+#
+# Set each variable below to the desired value. Set to empty string ""
+# or to NULL to omit a key entirely (the daemon treats an absent key
+# as "no enforcement" for that field). Booleans use "true"/"false"
+# (lowercase). Integers as decimal.
+#
+# Reference for key names + accepted values:
+# client/mdm/policy.go (Key* constants)
+# docs/netbird-macos.mobileconfig (sample profile)
+# docs/netbird.admx + .adml (Windows ADMX schema)
+#
+NULL='__UNSET__'
+managementURL='https://api.netbird.io:443'
+preSharedKey="$NULL" # secret; redacted in log
+allowServerSSH='true'
+blockInbound="$NULL"
+disableAutoConnect="$NULL"
+disableClientRoutes="$NULL"
+disableServerRoutes="$NULL"
+disableMetricsCollection="$NULL"
+disableUpdateSettings="$NULL"
+disableProfiles="$NULL"
+disableNetworks="$NULL"
+rosenpassEnabled="$NULL"
+rosenpassPermissive="$NULL"
+wireguardPort='51820'
+splitTunnelMode="$NULL" # "allow" or "disallow", Android-only at the daemon level
+splitTunnelApps="$NULL" # comma-separated app IDs, Android-only
+##############################################################################
+
+readonly PLIST_DIR='/Library/Managed Preferences'
+readonly PLIST_PATH="$PLIST_DIR/io.netbird.client.plist"
+readonly LOG_TAG='netbird-mdm'
+
+# log sends a message to the system logger using the configured tag and echoes the message to stdout prefixed by an ISO 8601 UTC timestamp and the tag.
+log() {
+ /usr/bin/logger -t "$LOG_TAG" "$*"
+ printf '%s [%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$LOG_TAG" "$*"
+}
+
+# is_set returns success if the provided value is non-empty and is not equal to the special NULL marker.
+is_set() {
+ local value="$1"
+ [[ -n "$value" && "$value" != "$NULL" ]]
+}
+
+# start_plist creates the temporary plist file at "$PLIST_PATH.tmp" containing the XML plist header and opening `` for the policy plist.
+start_plist() {
+ cat > "$PLIST_PATH.tmp" <<'EOF'
+
+
+
+
+EOF
+}
+
+# end_plist appends the closing `` and `` tags to the temporary plist file.
+end_plist() {
+ cat >> "$PLIST_PATH.tmp" <<'EOF'
+
+
+EOF
+}
+
+# emit_string appends a plist ``/`` entry for the given key and value to "$PLIST_PATH.tmp", XML-escaping `&`, `<`, and `>`, and logs the assignment (masking the logged value as `********** (secret)` when the key is `preSharedKey`).
+emit_string() {
+ local key="$1" value="$2" log_value="$2"
+ # Escape XML entities in the value
+ local escaped
+ escaped="$(printf '%s' "$value" | sed -e 's/&/\&/g' -e 's/\</g' -e 's/>/\>/g')"
+ printf ' %s\n %s\n' "$key" "$escaped" >> "$PLIST_PATH.tmp"
+ if [[ "$key" == "preSharedKey" ]]; then
+ log_value='********** (secret)'
+ fi
+ log "set $key = $log_value"
+}
+
+# emit_bool writes a boolean plist entry for a given key into the temporary plist file.
+# emit_bool writes a boolean plist entry for a key when the provided value matches an accepted boolean token; logs an error and skips the key on invalid input.
+emit_bool() {
+ local key="$1" value="$2"
+ local xml_bool
+ case "$value" in
+ true|True|TRUE|1|yes) xml_bool='' ; value='true' ;;
+ false|False|FALSE|0|no) xml_bool='' ; value='false' ;;
+ *) log "invalid boolean for $key: $value (must be true/false); skipping"; return ;;
+ esac
+ printf ' %s\n %s\n' "$key" "$xml_bool" >> "$PLIST_PATH.tmp"
+ log "set $key = $value"
+}
+
+# emit_int validates that VALUE contains only decimal digits and, if valid, appends an `` plist entry for KEY to the temporary plist (`$PLIST_PATH.tmp`) and logs the assignment; on invalid input it logs a skip and does not emit the key.
+emit_int() {
+ local key="$1" value="$2"
+ if ! [[ "$value" =~ ^[0-9]+$ ]]; then
+ log "invalid integer for $key: $value (must be decimal); skipping"
+ return
+ fi
+ printf ' %s\n %s\n' "$key" "$value" >> "$PLIST_PATH.tmp"
+ log "set $key = $value"
+}
+
+# main builds the NetBird MDM plist from configured policy variables, validates and installs it to /Library/Managed Preferences/io.netbird.client.plist (root:wheel, 644) and optionally triggers the NetBird daemon to reload.
+main() {
+ log "applying NetBird MDM policy to $PLIST_PATH"
+ /bin/mkdir -p "$PLIST_DIR"
+ start_plist
+
+ is_set "$managementURL" && emit_string managementURL "$managementURL"
+ is_set "$preSharedKey" && emit_string preSharedKey "$preSharedKey"
+ is_set "$allowServerSSH" && emit_bool allowServerSSH "$allowServerSSH"
+ is_set "$blockInbound" && emit_bool blockInbound "$blockInbound"
+ is_set "$disableAutoConnect" && emit_bool disableAutoConnect "$disableAutoConnect"
+ is_set "$disableClientRoutes" && emit_bool disableClientRoutes "$disableClientRoutes"
+ is_set "$disableServerRoutes" && emit_bool disableServerRoutes "$disableServerRoutes"
+ is_set "$disableMetricsCollection" && emit_bool disableMetricsCollection "$disableMetricsCollection"
+ is_set "$disableUpdateSettings" && emit_bool disableUpdateSettings "$disableUpdateSettings"
+ is_set "$disableProfiles" && emit_bool disableProfiles "$disableProfiles"
+ is_set "$disableNetworks" && emit_bool disableNetworks "$disableNetworks"
+ is_set "$rosenpassEnabled" && emit_bool rosenpassEnabled "$rosenpassEnabled"
+ is_set "$rosenpassPermissive" && emit_bool rosenpassPermissive "$rosenpassPermissive"
+ is_set "$wireguardPort" && emit_int wireguardPort "$wireguardPort"
+ is_set "$splitTunnelMode" && emit_string splitTunnelMode "$splitTunnelMode"
+ is_set "$splitTunnelApps" && emit_string splitTunnelApps "$splitTunnelApps"
+
+ end_plist
+
+ if ! /usr/bin/plutil -lint "$PLIST_PATH.tmp" >/dev/null 2>&1; then
+ log "ERROR: generated plist failed plutil lint; not installing"
+ /usr/bin/plutil -lint "$PLIST_PATH.tmp" >&2 || true
+ /bin/rm -f "$PLIST_PATH.tmp"
+ exit 1
+ fi
+
+ /bin/mv -f "$PLIST_PATH.tmp" "$PLIST_PATH"
+ /usr/sbin/chown root:wheel "$PLIST_PATH"
+ /bin/chmod 644 "$PLIST_PATH"
+
+ log "policy installed; NetBird daemon will pick it up within the next 1-minute reload tick"
+
+ # Optional: kick the daemon for an immediate apply. Safe — does
+ # nothing on a host where NetBird is not yet installed.
+ /bin/launchctl kickstart -k system/io.netbird.client 2>/dev/null || true
+}
+
+main "$@"
diff --git a/docs/netbird-policy.reg b/docs/netbird-policy.reg
new file mode 100644
index 000000000..ba4402e50
Binary files /dev/null and b/docs/netbird-policy.reg differ
diff --git a/docs/netbird-policy.reg.ps1 b/docs/netbird-policy.reg.ps1
new file mode 100644
index 000000000..011d706dc
--- /dev/null
+++ b/docs/netbird-policy.reg.ps1
@@ -0,0 +1,94 @@
+#requires -Version 5.1
+<#
+.SYNOPSIS
+ Push the NetBird MDM policy to a Windows device via JumpCloud Commands
+ by importing a sidecar netbird-policy.reg file.
+
+.DESCRIPTION
+ Windows counterpart of docs/netbird-macos.sh. Outcome:
+ HKLM\Software\Policies\NetBird populated from the attached
+ netbird-policy.reg file, daemon picks up the change via the
+ 1-minute MDM reload ticker.
+
+ Deployment:
+ 1. Admin Console -> Device Management -> Commands -> +.
+ 2. Type: Windows PowerShell. Run as: SYSTEM.
+ 3. Paste this file verbatim into the command body.
+ 4. In the same command, attach `netbird-policy.reg` as a file.
+ JumpCloud copies attached files into the command's working
+ directory before invoking the script, so `$PSScriptRoot` or
+ Get-Location resolves to where the .reg lives.
+ 5. Bind to the target system group, save, run.
+
+ Producing the .reg file:
+ On a reference machine, after configuring the policy values either
+ via gpedit (GPO) or manual `reg add`, export with:
+
+ reg export "HKLM\Software\Policies\NetBird" netbird-policy.reg /y
+
+ Then attach the resulting file to the JumpCloud command.
+
+ Semantics:
+ - The script nukes the existing HKLM\Software\Policies\NetBird key
+ before importing the .reg, so the .reg is the SINGLE SOURCE OF
+ TRUTH. Any value present in the registry but absent from the .reg
+ is removed. This is what an MDM admin almost always wants.
+ - Setting the .reg to an empty (header-only) file effectively unsets
+ the policy.
+
+ Idempotency: re-running the script with the same .reg is a no-op from
+ the daemon's perspective (values identical → 1-min ticker sees no
+ diff → engine not restarted).
+
+ Exit codes: 0 = success; 1 = .reg missing or reg.exe error.
+#>
+
+$ErrorActionPreference = "Stop"
+
+$RegFileName = "netbird-policy.reg"
+$RegKey = "HKLM\Software\Policies\NetBird"
+
+# Resolve the attached .reg file: JumpCloud copies command attachments
+# into C:\Windows\Temp\ before invoking the script. Cwd / $PSScriptRoot
+# fallbacks cover the local-dev case where you might dot-source this
+# from elsewhere.
+$candidates = @(
+ (Join-Path "$env:WINDIR\Temp" $RegFileName)
+ (Join-Path (Get-Location) $RegFileName)
+ (Join-Path $PSScriptRoot $RegFileName)
+) | Where-Object { Test-Path $_ }
+
+if ($candidates.Count -eq 0) {
+ Write-Error "[netbird-mdm] $RegFileName not found in working directory or `$PSScriptRoot. Attach the file to the JumpCloud command."
+ exit 1
+}
+$regFile = $candidates[0]
+Write-Host "[netbird-mdm] using $regFile"
+
+# Wipe the existing policy key so the .reg is authoritative.
+$existed = Test-Path "Registry::HKEY_LOCAL_MACHINE\Software\Policies\NetBird"
+if ($existed) {
+ & reg.exe delete $RegKey /f | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error "[netbird-mdm] failed to clear $RegKey before import (exit $LASTEXITCODE)"
+ exit 1
+ }
+ Write-Host "[netbird-mdm] cleared previous values under $RegKey"
+}
+
+# Import. reg.exe writes both data and (re-)creates the key if needed.
+& reg.exe import $regFile
+if ($LASTEXITCODE -ne 0) {
+ Write-Error "[netbird-mdm] reg import failed (exit $LASTEXITCODE)"
+ exit 1
+}
+
+# Audit dump so the JumpCloud per-execution log captures the applied state.
+Write-Host "[netbird-mdm] final policy state under $RegKey :"
+& reg.exe query $RegKey /s
+
+# Daemon's 1-min reload ticker picks up the change automatically.
+# Uncomment to force immediate convergence (skips the ticker wait):
+# Restart-Service netbird -Force -ErrorAction SilentlyContinue
+
+exit 0
diff --git a/docs/netbird.adml b/docs/netbird.adml
new file mode 100644
index 000000000..d49b05022
--- /dev/null
+++ b/docs/netbird.adml
@@ -0,0 +1,95 @@
+
+
+ NetBird Client Policies
+ Group Policy template for NetBird client MDM-managed settings. Values are written under HKLM\Software\Policies\NetBird and consumed by the netbird daemon at startup and every 1-minute reload tick.
+
+
+
+
+ NetBird
+ NetBird Client 0.40+
+
+
+ Management URL
+ URL of the NetBird management server. Format: https://host[:port]. When set, users cannot override this value via UI or CLI.
+
+ Pre-shared key
+ WireGuard pre-shared key used as an additional symmetric secret on every peer-to-peer tunnel. Secret value.
+
+
+ Disable auto-connect
+ When enabled, the NetBird tunnel does not auto-connect at daemon startup. Equivalent to --disable-auto-connect.
+
+ Disable client routes
+ When enabled, this client will not consume routes advertised by routing peers. Equivalent to --disable-client-routes.
+
+ Disable server routes
+ When enabled, this client will not act as a routing peer for other clients. Equivalent to --disable-server-routes.
+
+ Block inbound
+ When enabled, the client firewall blocks all inbound peer traffic on the WireGuard interface. Equivalent to --block-inbound.
+
+
+
+
+ Enable Rosenpass
+ Enables Rosenpass post-quantum key exchange on WireGuard tunnels. Both peers must support it.
+
+ Rosenpass permissive
+ When enabled, the client falls back to plain WireGuard if a peer does not support Rosenpass; otherwise it refuses the connection.
+
+ WireGuard port
+ UDP port used by the local WireGuard interface. Allowed range: 1-65535.
+
+ Split tunnel
+ Restrict the NetBird tunnel to or from a chosen list of application package names. Choose either the allow mode (only the listed apps route through NetBird) or the disallow mode (the listed apps bypass NetBird; everything else routes through). The mode is mutually exclusive — only one can be active at a time. Android-only at the daemon level; Windows/macOS/iOS clients ignore this policy.
+ Allow only listed apps (everything else bypasses)
+ Disallow listed apps (everything else routes)
+
+
+ Disable update settings
+ When enabled, blocks every configuration change from the client UI and from the CLI (netbird up / login / setconfig). The Settings view stays viewable but read-only. Equivalent to --disable-update-settings.
+
+ Disable profiles
+ When enabled, the client UI/CLI cannot list, create, switch or remove NetBird connection profiles. Equivalent to --disable-profiles.
+
+ Disable networks
+ When enabled, the client UI/CLI cannot list, select or deselect NetBird networks (the corresponding daemon RPCs return Unavailable). Equivalent to --disable-networks.
+
+ Disable metrics collection
+ When enabled, the client does not collect or report local usage metrics.
+
+
+
+
+
+
+
+ https://api.netbird.io:443
+
+
+
+
+
+
+
+
+
+
+ WireGuard UDP port:
+
+
+
+ Mode:
+
+
+
+
+
+
+
+
diff --git a/docs/netbird.admx b/docs/netbird.admx
new file mode 100644
index 000000000..2f7645d63
--- /dev/null
+++ b/docs/netbird.admx
@@ -0,0 +1,223 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - allow
+ - disallow
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/formatter/hook/hook.go b/formatter/hook/hook.go
index f0ee509f8..69758566d 100644
--- a/formatter/hook/hook.go
+++ b/formatter/hook/hook.go
@@ -99,6 +99,9 @@ func addFields(entry *logrus.Entry) {
if ctxAccountID, ok := entry.Context.Value(context.AccountIDKey).(string); ok {
entry.Data[context.AccountIDKey] = ctxAccountID
}
+ if ctxUserAgent, ok := entry.Context.Value(context.UserAgentKey).(string); ok {
+ entry.Data[context.UserAgentKey] = ctxUserAgent
+ }
if ctxInitiatorID, ok := entry.Context.Value(context.UserIDKey).(string); ok {
entry.Data[context.UserIDKey] = ctxInitiatorID
}
diff --git a/go.mod b/go.mod
index bafdeaf86..2858d2044 100644
--- a/go.mod
+++ b/go.mod
@@ -2,6 +2,8 @@ module github.com/netbirdio/netbird
go 1.25.5
+toolchain go1.25.11
+
require (
cunicu.li/go-rosenpass v0.5.42
github.com/cenkalti/backoff/v4 v4.3.0
@@ -54,6 +56,8 @@ require (
github.com/fsnotify/fsnotify v1.9.0
github.com/gliderlabs/ssh v0.3.8
github.com/go-jose/go-jose/v4 v4.1.4
+ github.com/gobwas/ws v1.4.0
+ github.com/goccy/go-yaml v1.18.0
github.com/godbus/dbus/v5 v5.1.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang/mock v1.6.0
@@ -131,6 +135,7 @@ require (
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89
+ howett.net/plist v1.0.1
)
require (
@@ -211,10 +216,11 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/webauthn v0.16.4 // indirect
github.com/go-webauthn/x v0.2.3 // indirect
- github.com/goccy/go-yaml v1.18.0 // indirect
+ github.com/gobwas/httphead v0.1.0 // indirect
+ github.com/gobwas/pool v0.2.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
- github.com/google/btree v1.1.2 // indirect
+ github.com/google/btree v1.1.3 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/s2a-go v0.1.9 // indirect
diff --git a/go.sum b/go.sum
index 2f42f96b1..1768ee069 100644
--- a/go.sum
+++ b/go.sum
@@ -249,6 +249,12 @@ github.com/go-webauthn/webauthn v0.16.4 h1:R9jqR/cYZa7hRquFF7Za/8qoH/K/TIs1/Q/4C
github.com/go-webauthn/webauthn v0.16.4/go.mod h1:SU2ljAgToTV/YLPI0C05QS4qn+e04WpB5g1RMfcZfS4=
github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA=
github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
@@ -275,8 +281,8 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
-github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
-github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
+github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
+github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -380,6 +386,7 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
+github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -844,6 +851,7 @@ golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -946,6 +954,7 @@ gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -968,5 +977,7 @@ gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 h1:mGJaeA61P8dEHTqdvAgc70ZIV3QoUoJcXCRyyjO26OA=
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
+howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
+howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
diff --git a/idp/dex/provider.go b/idp/dex/provider.go
index 526d6a17a..67aeb995f 100644
--- a/idp/dex/provider.go
+++ b/idp/dex/provider.go
@@ -41,6 +41,8 @@ type Config struct {
GRPCAddr string
}
+const localConnectorID = "local"
+
// Provider wraps a Dex server
type Provider struct {
config *Config
@@ -544,7 +546,7 @@ func (p *Provider) CreateUser(ctx context.Context, email, username, password str
// Encode the user ID in Dex's format: base64(protobuf{user_id, connector_id})
// This matches the format Dex uses in JWT tokens
- encodedID := EncodeDexUserID(userID, "local")
+ encodedID := EncodeDexUserID(userID, localConnectorID)
return encodedID, nil
}
@@ -619,6 +621,13 @@ func DecodeDexUserID(encodedID string) (userID, connectorID string, err error) {
return userID, connectorID, nil
}
+// IsLocalUserID reports whether encodedID is a Dex subject for the built-in
+// local password connector.
+func IsLocalUserID(encodedID string) bool {
+ _, connectorID, err := DecodeDexUserID(encodedID)
+ return err == nil && connectorID == localConnectorID
+}
+
// GetUser returns a user by email
func (p *Provider) GetUser(ctx context.Context, email string) (storage.Password, error) {
return p.storage.GetPassword(ctx, email)
diff --git a/idp/dex/provider_test.go b/idp/dex/provider_test.go
index 88828fbbb..3eb29db97 100644
--- a/idp/dex/provider_test.go
+++ b/idp/dex/provider_test.go
@@ -115,6 +115,26 @@ func TestDecodeDexUserID(t *testing.T) {
}
}
+func TestIsLocalUserID(t *testing.T) {
+ tests := []struct {
+ name string
+ encodedID string
+ want bool
+ }{
+ {name: "local connector", encodedID: EncodeDexUserID("7aad8c05-3287-473f-b42a-365504bf25e7", "local"), want: true},
+ {name: "federated connector", encodedID: EncodeDexUserID("entra-user", "entra"), want: false},
+ {name: "non-dex external IdP id", encodedID: "google-oauth2|1234567890", want: false},
+ {name: "invalid base64", encodedID: "not-valid-base64!!!", want: false},
+ {name: "empty", encodedID: "", want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, IsLocalUserID(tt.encodedID))
+ })
+ }
+}
+
func TestEncodeDexUserID(t *testing.T) {
userID := "7aad8c05-3287-473f-b42a-365504bf25e7"
connectorID := "local"
diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh
index 910cea095..770cecc44 100755
--- a/infrastructure_files/getting-started.sh
+++ b/infrastructure_files/getting-started.sh
@@ -19,6 +19,46 @@ readonly MSG_SEPARATOR="=========================================="
# Utility Functions
############################################
+check_docker_sock_perms() {
+ local sock="${DOCKER_HOST:-unix:///var/run/docker.sock}"
+ sock="${sock#unix://}"
+
+ if [[ ! -S "$sock" ]]; then
+ return 0
+ fi
+
+ if [[ ! -r "$sock" ]] || [[ ! -w "$sock" ]]; then
+ local group
+ if [[ "${OSTYPE}" == "darwin"* ]]; then
+ group="$(stat -f '%Sg' "$sock")"
+ else
+ group="$(stat -c '%G' "$sock")"
+ fi
+
+ echo "Cannot access Docker socket: $sock" > /dev/stderr
+ echo "" > /dev/stderr
+ echo "Socket permissions:" > /dev/stderr
+ ls -l "$sock" > /dev/stderr
+ echo "" > /dev/stderr
+
+ if [[ "$group" == "docker" ]]; then
+ echo "Your user may need to be added to the '$group' group:" > /dev/stderr
+ echo " sudo usermod -aG $group \"$USER\"" > /dev/stderr
+ echo "Then log out and back in, or run this for the current shell:" > /dev/stderr
+ echo " newgrp $group" > /dev/stderr
+ echo "Note: newgrp is temporary; usermod is the permanent group change." > /dev/stderr
+ else
+ echo "The Docker socket is owned by the '$group' group, which is not the standard 'docker' group." > /dev/stderr
+ echo "For safety, this script will not suggest adding your user to '$group'." > /dev/stderr
+ echo "Instead, either run this script with appropriate privileges (for example, via sudo) or follow Docker's post-install steps to configure access via the 'docker' group:" > /dev/stderr
+ echo " https://docs.docker.com/engine/install/linux-postinstall/" > /dev/stderr
+ fi
+
+ exit 1
+ fi
+ return 0
+}
+
check_docker_compose() {
if command -v docker-compose &> /dev/null
then
@@ -581,12 +621,15 @@ start_services_and_show_instructions() {
}
init_environment() {
+ # Check if docker compose is installed using check_docker_compose function
+ DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
+ check_docker_sock_perms
+
initialize_default_values
configure_domain
configure_reverse_proxy
check_jq
- DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
check_existing_installation
generate_configuration_files
diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go
index 2b81cd6e5..9adf594cd 100644
--- a/management/internals/controllers/network_map/controller/controller.go
+++ b/management/internals/controllers/network_map/controller/controller.go
@@ -45,7 +45,7 @@ type Controller struct {
EphemeralPeersManager ephemeral.Manager
accountUpdateLocks sync.Map
- sendAccountUpdateLocks sync.Map
+ affectedPeerUpdateLocks sync.Map
updateAccountPeersBufferInterval atomic.Int64
// dnsDomain is used for peer resolution. This is appended to the peer's name
dnsDomain string
@@ -64,6 +64,13 @@ type bufferUpdate struct {
update atomic.Bool
}
+type bufferAffectedUpdate struct {
+ sendMu sync.Mutex
+ dataMu sync.Mutex
+ next *time.Timer
+ peerIDs map[string]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) *Controller {
@@ -201,7 +208,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
- proxyNetworkMap, ok := proxyNetworkMaps[peer.ID]
+ proxyNetworkMap, ok := proxyNetworkMaps[p.ID]
if ok {
remotePeerNetworkMap.Merge(proxyNetworkMap)
}
@@ -226,44 +233,6 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
return nil
}
-func (c *Controller) bufferSendUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
- log.WithContext(ctx).Tracef("buffer sending update peers for account %s from %s", accountID, util.GetCallerName())
-
- if c.accountManagerMetrics != nil {
- c.accountManagerMetrics.CountUpdateAccountPeersTriggered(string(reason.Resource), string(reason.Operation))
- }
-
- bufUpd, _ := c.sendAccountUpdateLocks.LoadOrStore(accountID, &bufferUpdate{})
- b := bufUpd.(*bufferUpdate)
-
- if !b.mu.TryLock() {
- b.update.Store(true)
- return nil
- }
-
- if b.next != nil {
- b.next.Stop()
- }
-
- go func() {
- defer b.mu.Unlock()
- _ = c.sendUpdateAccountPeers(ctx, accountID, reason)
- if !b.update.Load() {
- return
- }
- b.update.Store(false)
- if b.next == nil {
- b.next = time.AfterFunc(time.Duration(c.updateAccountPeersBufferInterval.Load()), func() {
- _ = c.sendUpdateAccountPeers(ctx, accountID, reason)
- })
- return
- }
- b.next.Reset(time.Duration(c.updateAccountPeersBufferInterval.Load()))
- }()
-
- return nil
-}
-
// UpdatePeers updates all peers that belong to an account.
// Should be called when changes have to be synced to peers.
func (c *Controller) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error {
@@ -273,6 +242,143 @@ func (c *Controller) UpdateAccountPeers(ctx context.Context, accountID string, r
return c.sendUpdateAccountPeers(ctx, accountID, reason)
}
+// UpdateAffectedPeers updates only the specified peers that belong to an account.
+func (c *Controller) UpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string) error {
+ if len(peerIDs) == 0 {
+ return nil
+ }
+ return c.sendUpdateForAffectedPeers(ctx, accountID, peerIDs)
+}
+
+func (c *Controller) sendUpdateForAffectedPeers(ctx context.Context, accountID string, peerIDs []string) error {
+ log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: account %s, %d affected peers: %v (caller: %s)", accountID, len(peerIDs), peerIDs, util.GetCallerName())
+
+ if !c.hasConnectedPeers(peerIDs) {
+ log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: no connected peers among %v, skipping", peerIDs)
+ return nil
+ }
+
+ account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
+ if err != nil {
+ return fmt.Errorf("failed to get account: %v", err)
+ }
+
+ globalStart := time.Now()
+
+ peersToUpdate := c.filterConnectedAffectedPeers(account, peerIDs)
+ if len(peersToUpdate) == 0 {
+ log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: no peers to update (affected peers not found in account or no channels)")
+ return nil
+ }
+
+ log.WithContext(ctx).Tracef("sendUpdateForAffectedPeers: sending network map to %d connected peers", len(peersToUpdate))
+
+ approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra)
+ if err != nil {
+ return fmt.Errorf("failed to get validate peers: %v", err)
+ }
+
+ var wg sync.WaitGroup
+ semaphore := make(chan struct{}, 10)
+
+ account.InjectProxyPolicies(ctx)
+ dnsCache := &cache.DNSConfigCache{}
+ dnsDomain := c.GetDNSDomain(account.Settings)
+ peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain)
+ resourcePolicies := account.GetResourcePoliciesMap()
+ 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)
+ }
+
+ dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion)
+
+ accountZones, err := c.repo.GetAccountZones(ctx, account.Id)
+ if err != nil {
+ log.WithContext(ctx).Errorf("failed to get account zones: %v", err)
+ return fmt.Errorf("failed to get account zones: %v", err)
+ }
+
+ for _, peer := range peersToUpdate {
+ wg.Add(1)
+ semaphore <- struct{}{}
+ go func(p *nbpeer.Peer) {
+ defer wg.Done()
+ defer func() { <-semaphore }()
+
+ start := time.Now()
+
+ postureChecks, err := c.getPeerPostureChecks(account, p.ID)
+ if err != nil {
+ log.WithContext(ctx).Debugf("failed to get posture checks for peer %s: %v", p.ID, err)
+ return
+ }
+
+ c.metrics.CountCalcPostureChecksDuration(time.Since(start))
+ start = time.Now()
+
+ remotePeerNetworkMap := account.GetPeerNetworkMapFromComponents(ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
+
+ c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
+
+ proxyNetworkMap, ok := proxyNetworkMaps[p.ID]
+ if ok {
+ remotePeerNetworkMap.Merge(proxyNetworkMap)
+ }
+
+ peerGroups := account.GetPeerGroups(p.ID)
+ start = time.Now()
+ update := grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
+ c.metrics.CountToSyncResponseDuration(time.Since(start))
+
+ c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
+ Update: update,
+ MessageType: network_map.MessageTypeNetworkMap,
+ })
+ }(peer)
+ }
+
+ wg.Wait()
+ if c.accountManagerMetrics != nil {
+ c.accountManagerMetrics.CountUpdateAccountPeersDuration(time.Since(globalStart))
+ }
+
+ return nil
+}
+
+func (c *Controller) hasConnectedPeers(peerIDs []string) bool {
+ for _, id := range peerIDs {
+ if c.peersUpdateManager.HasChannel(id) {
+ return true
+ }
+ }
+ return false
+}
+
+func (c *Controller) filterConnectedAffectedPeers(account *types.Account, peerIDs []string) []*nbpeer.Peer {
+ affected := make(map[string]struct{}, len(peerIDs))
+ for _, id := range peerIDs {
+ affected[id] = struct{}{}
+ }
+
+ var result []*nbpeer.Peer
+ for _, peer := range account.Peers {
+ if _, ok := affected[peer.ID]; ok && c.peersUpdateManager.HasChannel(peer.ID) {
+ result = append(result, peer)
+ }
+ }
+ return result
+}
+
func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, peerId string) error {
if !c.peersUpdateManager.HasChannel(peerId) {
return fmt.Errorf("peer %s doesn't have a channel, skipping network map update", peerId)
@@ -381,6 +487,104 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str
return nil
}
+// BufferUpdateAffectedPeers accumulates peer IDs and flushes them after the buffer interval.
+func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error {
+ if len(peerIDs) == 0 {
+ return nil
+ }
+
+ if c.accountManagerMetrics != nil {
+ c.accountManagerMetrics.CountUpdateAccountPeersTriggered(string(reason.Resource), string(reason.Operation))
+ }
+
+ log.WithContext(ctx).Tracef("buffer updating %d affected peers for account %s from %s", len(peerIDs), accountID, util.GetCallerName())
+
+ bufUpd, _ := c.affectedPeerUpdateLocks.LoadOrStore(accountID, &bufferAffectedUpdate{
+ peerIDs: make(map[string]struct{}),
+ })
+ b := bufUpd.(*bufferAffectedUpdate)
+
+ b.addPeerIDs(peerIDs)
+
+ if !b.sendMu.TryLock() {
+ // Another goroutine is already sending; it will pick up our IDs on its next drain.
+ return nil
+ }
+
+ b.stopTimer()
+
+ // The send and the debounced timer outlive the calling request, so detach from
+ // its context to avoid sending with a cancelled context once the handler returns.
+ bgCtx := context.WithoutCancel(ctx)
+
+ collected := b.drainPeerIDs()
+ go func() {
+ defer b.sendMu.Unlock()
+ _ = c.sendUpdateForAffectedPeers(bgCtx, accountID, collected)
+
+ // Check if more peer IDs accumulated while we were sending.
+ if !b.hasPending() {
+ return
+ }
+
+ // Schedule a debounced flush for the newly accumulated IDs.
+ b.setTimer(time.Duration(c.updateAccountPeersBufferInterval.Load()), func() {
+ ids := b.drainPeerIDs()
+ if len(ids) > 0 {
+ _ = c.sendUpdateForAffectedPeers(bgCtx, accountID, ids)
+ }
+ })
+ }()
+
+ return nil
+}
+
+func (b *bufferAffectedUpdate) addPeerIDs(ids []string) {
+ b.dataMu.Lock()
+ for _, id := range ids {
+ b.peerIDs[id] = struct{}{}
+ }
+ b.dataMu.Unlock()
+}
+
+func (b *bufferAffectedUpdate) drainPeerIDs() []string {
+ b.dataMu.Lock()
+ defer b.dataMu.Unlock()
+ if len(b.peerIDs) == 0 {
+ return nil
+ }
+ ids := make([]string, 0, len(b.peerIDs))
+ for id := range b.peerIDs {
+ ids = append(ids, id)
+ }
+ b.peerIDs = make(map[string]struct{})
+ return ids
+}
+
+func (b *bufferAffectedUpdate) hasPending() bool {
+ b.dataMu.Lock()
+ defer b.dataMu.Unlock()
+ return len(b.peerIDs) > 0
+}
+
+func (b *bufferAffectedUpdate) stopTimer() {
+ b.dataMu.Lock()
+ defer b.dataMu.Unlock()
+ if b.next != nil {
+ b.next.Stop()
+ }
+}
+
+func (b *bufferAffectedUpdate) setTimer(d time.Duration, f func()) {
+ b.dataMu.Lock()
+ defer b.dataMu.Unlock()
+ if b.next == nil {
+ b.next = time.AfterFunc(d, f)
+ return
+ }
+ b.next.Reset(d)
+}
+
func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresApproval bool, accountID string, peer *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
if isRequiresApproval {
network, err := c.repo.GetAccountNetwork(ctx, accountID)
@@ -578,21 +782,24 @@ func isPeerInPolicySourceGroups(account *types.Account, peerID string, policy *t
return false, nil
}
-func (c *Controller) OnPeersUpdated(ctx context.Context, accountID string, peerIDs []string) error {
- err := c.bufferSendUpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationUpdate})
- if err != nil {
- log.WithContext(ctx).Errorf("failed to buffer update account peers for peer update in account %s: %v", accountID, err)
+func (c *Controller) OnPeersUpdated(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error {
+ if len(affectedPeerIDs) == 0 {
+ log.WithContext(ctx).Tracef("no affected peers for peer update in account %s, skipping", accountID)
+ return nil
}
-
- return nil
+ return c.BufferUpdateAffectedPeers(ctx, accountID, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationUpdate})
}
-func (c *Controller) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string) error {
+func (c *Controller) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error {
log.WithContext(ctx).Debugf("OnPeersAdded call to add peers: %v", peerIDs)
- return c.bufferSendUpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationCreate})
+ if len(affectedPeerIDs) == 0 {
+ log.WithContext(ctx).Tracef("no affected peers for peer add in account %s, skipping", accountID)
+ return nil
+ }
+ return c.BufferUpdateAffectedPeers(ctx, accountID, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationCreate})
}
-func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string) error {
+func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error {
network, err := c.repo.GetAccountNetwork(ctx, accountID)
if err != nil {
return err
@@ -625,7 +832,11 @@ func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerI
c.peersUpdateManager.CloseChannel(ctx, peerID)
}
- return c.bufferSendUpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationDelete})
+ if len(affectedPeerIDs) == 0 {
+ log.WithContext(ctx).Tracef("no affected peers for peer delete in account %s, skipping", accountID)
+ return nil
+ }
+ return c.BufferUpdateAffectedPeers(ctx, accountID, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationDelete})
}
// GetNetworkMap returns Network map for a given peer (omits original peer from the Peers result)
diff --git a/management/internals/controllers/network_map/interface.go b/management/internals/controllers/network_map/interface.go
index 44d8f7d72..dbdd87708 100644
--- a/management/internals/controllers/network_map/interface.go
+++ b/management/internals/controllers/network_map/interface.go
@@ -19,6 +19,8 @@ const (
type Controller interface {
UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) error
+ UpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string) error
+ BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error
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, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
@@ -27,9 +29,9 @@ type Controller interface {
GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error)
CountStreams() int
- OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string) error
- OnPeersAdded(ctx context.Context, accountID string, peerIDs []string) error
- OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string) error
+ OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string, affectedPeerIDs []string) error
+ OnPeersAdded(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error
+ OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error
DisconnectPeers(ctx context.Context, accountId string, peerIDs []string)
OnPeerConnected(ctx context.Context, accountID string, peerID string) (chan *UpdateMessage, error)
OnPeerDisconnected(ctx context.Context, accountID string, peerID string)
diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go
index 073a75d3b..a67156719 100644
--- a/management/internals/controllers/network_map/interface_mock.go
+++ b/management/internals/controllers/network_map/interface_mock.go
@@ -57,6 +57,20 @@ func (mr *MockControllerMockRecorder) BufferUpdateAccountPeers(ctx, accountID, r
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAccountPeers", reflect.TypeOf((*MockController)(nil).BufferUpdateAccountPeers), ctx, accountID, reason)
}
+// BufferUpdateAffectedPeers mocks base method.
+func (m *MockController) BufferUpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string, reason types.UpdateReason) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "BufferUpdateAffectedPeers", ctx, accountID, peerIDs, reason)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// BufferUpdateAffectedPeers indicates an expected call of BufferUpdateAffectedPeers.
+func (mr *MockControllerMockRecorder) BufferUpdateAffectedPeers(ctx, accountID, peerIDs, reason any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAffectedPeers", reflect.TypeOf((*MockController)(nil).BufferUpdateAffectedPeers), ctx, accountID, peerIDs, reason)
+}
+
// CountStreams mocks base method.
func (m *MockController) CountStreams() int {
m.ctrl.T.Helper()
@@ -158,45 +172,45 @@ func (mr *MockControllerMockRecorder) OnPeerDisconnected(ctx, accountID, peerID
}
// OnPeersAdded mocks base method.
-func (m *MockController) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string) error {
+func (m *MockController) OnPeersAdded(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "OnPeersAdded", ctx, accountID, peerIDs)
+ ret := m.ctrl.Call(m, "OnPeersAdded", ctx, accountID, peerIDs, affectedPeerIDs)
ret0, _ := ret[0].(error)
return ret0
}
// OnPeersAdded indicates an expected call of OnPeersAdded.
-func (mr *MockControllerMockRecorder) OnPeersAdded(ctx, accountID, peerIDs any) *gomock.Call {
+func (mr *MockControllerMockRecorder) OnPeersAdded(ctx, accountID, peerIDs, affectedPeerIDs any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersAdded", reflect.TypeOf((*MockController)(nil).OnPeersAdded), ctx, accountID, peerIDs)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersAdded", reflect.TypeOf((*MockController)(nil).OnPeersAdded), ctx, accountID, peerIDs, affectedPeerIDs)
}
// OnPeersDeleted mocks base method.
-func (m *MockController) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string) error {
+func (m *MockController) OnPeersDeleted(ctx context.Context, accountID string, peerIDs []string, affectedPeerIDs []string) error {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "OnPeersDeleted", ctx, accountID, peerIDs)
+ ret := m.ctrl.Call(m, "OnPeersDeleted", ctx, accountID, peerIDs, affectedPeerIDs)
ret0, _ := ret[0].(error)
return ret0
}
// OnPeersDeleted indicates an expected call of OnPeersDeleted.
-func (mr *MockControllerMockRecorder) OnPeersDeleted(ctx, accountID, peerIDs any) *gomock.Call {
+func (mr *MockControllerMockRecorder) OnPeersDeleted(ctx, accountID, peerIDs, affectedPeerIDs any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersDeleted", reflect.TypeOf((*MockController)(nil).OnPeersDeleted), ctx, accountID, peerIDs)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersDeleted", reflect.TypeOf((*MockController)(nil).OnPeersDeleted), ctx, accountID, peerIDs, affectedPeerIDs)
}
// OnPeersUpdated mocks base method.
-func (m *MockController) OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string) error {
+func (m *MockController) OnPeersUpdated(ctx context.Context, accountId string, peerIDs []string, affectedPeerIDs []string) error {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "OnPeersUpdated", ctx, accountId, peerIDs)
+ ret := m.ctrl.Call(m, "OnPeersUpdated", ctx, accountId, peerIDs, affectedPeerIDs)
ret0, _ := ret[0].(error)
return ret0
}
// OnPeersUpdated indicates an expected call of OnPeersUpdated.
-func (mr *MockControllerMockRecorder) OnPeersUpdated(ctx, accountId, peerIDs any) *gomock.Call {
+func (mr *MockControllerMockRecorder) OnPeersUpdated(ctx, accountId, peerIDs, affectedPeerIDs any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersUpdated", reflect.TypeOf((*MockController)(nil).OnPeersUpdated), ctx, accountId, peerIDs)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersUpdated", reflect.TypeOf((*MockController)(nil).OnPeersUpdated), ctx, accountId, peerIDs, affectedPeerIDs)
}
// StartWarmup mocks base method.
@@ -250,3 +264,17 @@ func (mr *MockControllerMockRecorder) UpdateAccountPeers(ctx, accountID, reason
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountPeers", reflect.TypeOf((*MockController)(nil).UpdateAccountPeers), ctx, accountID, reason)
}
+
+// UpdateAffectedPeers mocks base method.
+func (m *MockController) UpdateAffectedPeers(ctx context.Context, accountID string, peerIDs []string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateAffectedPeers", ctx, accountID, peerIDs)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UpdateAffectedPeers indicates an expected call of UpdateAffectedPeers.
+func (mr *MockControllerMockRecorder) UpdateAffectedPeers(ctx, accountID, peerIDs any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAffectedPeers", reflect.TypeOf((*MockController)(nil).UpdateAffectedPeers), ctx, accountID, peerIDs)
+}
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 3485d51fe..c218291ef 100644
--- a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go
+++ b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go
@@ -488,6 +488,195 @@ func TestUpdate_AllowsPortChange(t *testing.T) {
assert.Equal(t, uint16(54321), updated.ListenPort, "explicit port change should be applied")
}
+func TestUpdate_PreservesPortWhenCustomPortsNotSupported(t *testing.T) {
+ mgr, testStore, _ := setupL4Test(t, boolPtr(false))
+ ctx := context.Background()
+
+ existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345)
+
+ updated := &rpservice.Service{
+ ID: existing.ID,
+ AccountID: testAccountID,
+ Name: "tcp-svc-renamed",
+ Mode: "tcp",
+ Domain: testCluster,
+ ProxyCluster: testCluster,
+ ListenPort: 0,
+ Enabled: true,
+ Targets: []*rpservice.Target{
+ {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true},
+ },
+ }
+
+ _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated)
+ require.NoError(t, err, "update must not be rejected by the custom-port capability check")
+ assert.Equal(t, uint16(12345), updated.ListenPort, "existing listen port should be preserved on unsupported cluster")
+}
+
+func TestUpdate_PreservesPortWhenCustomPortsUnknown(t *testing.T) {
+ mgr, testStore, _ := setupL4Test(t, nil)
+ ctx := context.Background()
+
+ existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345)
+
+ updated := &rpservice.Service{
+ ID: existing.ID,
+ AccountID: testAccountID,
+ Name: "tcp-svc-renamed",
+ Mode: "tcp",
+ Domain: testCluster,
+ ProxyCluster: testCluster,
+ ListenPort: 0,
+ Enabled: true,
+ Targets: []*rpservice.Target{
+ {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true},
+ },
+ }
+
+ _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated)
+ require.NoError(t, err, "update must not be rejected when cluster capability is unknown")
+ assert.Equal(t, uint16(12345), updated.ListenPort, "existing listen port should be preserved when capability is unknown")
+}
+
+func TestUpdate_RejectsPortChangeWhenCustomPortsNotSupported(t *testing.T) {
+ mgr, testStore, _ := setupL4Test(t, boolPtr(false))
+ ctx := context.Background()
+
+ existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345)
+
+ updated := &rpservice.Service{
+ ID: existing.ID,
+ AccountID: testAccountID,
+ Name: "tcp-svc",
+ Mode: "tcp",
+ Domain: testCluster,
+ ProxyCluster: testCluster,
+ ListenPort: 54321,
+ Enabled: true,
+ Targets: []*rpservice.Target{
+ {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true},
+ },
+ }
+
+ _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated)
+ require.Error(t, err, "explicit port change on update must be rejected on unsupported clusters")
+ assert.Contains(t, err.Error(), "custom ports not supported on target cluster")
+}
+
+func TestUpdate_TLSPortChangeAllowedWhenNotSupported(t *testing.T) {
+ mgr, testStore, _ := setupL4Test(t, boolPtr(false))
+ ctx := context.Background()
+
+ existing := seedService(t, testStore, "tls-svc", "tls", "app.example.com", testCluster, 443)
+
+ updated := &rpservice.Service{
+ ID: existing.ID,
+ AccountID: testAccountID,
+ Name: "tls-svc",
+ Mode: "tls",
+ Domain: "app.example.com",
+ ProxyCluster: testCluster,
+ ListenPort: 9999,
+ Enabled: true,
+ Targets: []*rpservice.Target{
+ {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8443, Enabled: true},
+ },
+ }
+
+ _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated)
+ require.NoError(t, err, "TLS port change uses SNI routing and is exempt from the custom-port check")
+ assert.Equal(t, uint16(9999), updated.ListenPort, "TLS port change should be applied")
+}
+
+func TestValidateL4PortDiffOnClusterDiff(t *testing.T) {
+ tests := []struct {
+ name string
+ mode string
+ customPorts *bool
+ newPort uint16
+ oldPort uint16
+ wantErr bool
+ }{
+ {"tcp port change unsupported", "tcp", boolPtr(false), 54321, 12345, true},
+ {"tcp port change unknown capability", "tcp", nil, 54321, 12345, true},
+ {"udp port change unsupported", "udp", boolPtr(false), 54321, 12345, true},
+ {"tcp first port assignment unsupported", "tcp", boolPtr(false), 54321, 0, true},
+ {"tcp port change supported", "tcp", boolPtr(true), 54321, 12345, false},
+ {"tcp port unchanged unsupported", "tcp", boolPtr(false), 12345, 12345, false},
+ {"tcp zero port unsupported", "tcp", boolPtr(false), 0, 12345, false},
+ {"tls port change unsupported", "tls", boolPtr(false), 9999, 443, false},
+ {"http mode ignored", "http", boolPtr(false), 54321, 12345, false},
+ {"empty mode ignored", "", boolPtr(false), 54321, 12345, false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ newSvc := &rpservice.Service{Mode: tc.mode, ListenPort: tc.newPort, ProxyCluster: testCluster}
+ oldSvc := &rpservice.Service{Mode: tc.mode, ListenPort: tc.oldPort, ProxyCluster: testCluster}
+
+ err := validateL4PortDiffOnClusterDiff(tc.customPorts, newSvc, oldSvc)
+ if tc.wantErr {
+ assert.Error(t, err, "port diff should be rejected for %s", tc.name)
+ } else {
+ assert.NoError(t, err, "port diff should be allowed for %s", tc.name)
+ }
+ })
+ }
+}
+
+func TestUpdate_PortConflictRejected(t *testing.T) {
+ mgr, testStore, _ := setupL4Test(t, boolPtr(true))
+ ctx := context.Background()
+
+ seedService(t, testStore, "tcp-a", "tcp", "tcp-a."+testCluster, testCluster, 5432)
+ svcB := seedService(t, testStore, "tcp-b", "tcp", "tcp-b."+testCluster, testCluster, 6543)
+
+ updated := &rpservice.Service{
+ ID: svcB.ID,
+ AccountID: testAccountID,
+ Name: "tcp-b",
+ Mode: "tcp",
+ Domain: "tcp-b." + testCluster,
+ ProxyCluster: testCluster,
+ ListenPort: 5432,
+ Enabled: true,
+ Targets: []*rpservice.Target{
+ {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true},
+ },
+ }
+
+ _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated)
+ require.Error(t, err, "updating to a port held by another service should be rejected")
+ assert.Contains(t, err.Error(), "already in use")
+}
+
+func TestUpdate_AutoAssignsWhenNoPort(t *testing.T) {
+ mgr, testStore, _ := setupL4Test(t, boolPtr(false))
+ ctx := context.Background()
+
+ existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 0)
+
+ updated := &rpservice.Service{
+ ID: existing.ID,
+ AccountID: testAccountID,
+ Name: "tcp-svc",
+ Mode: "tcp",
+ Domain: testCluster,
+ ProxyCluster: testCluster,
+ ListenPort: 0,
+ Enabled: true,
+ Targets: []*rpservice.Target{
+ {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true},
+ },
+ }
+
+ _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated)
+ require.NoError(t, err)
+ assert.True(t, updated.ListenPort >= autoAssignPortMin && updated.ListenPort <= autoAssignPortMax,
+ "auto-assigned port %d should be in range [%d, %d]", updated.ListenPort, autoAssignPortMin, autoAssignPortMax)
+ assert.True(t, updated.PortAutoAssigned, "PortAutoAssigned should be set when update triggers auto-assignment")
+}
+
func TestCreateServiceFromPeer_TCP(t *testing.T) {
mgr, _, _ := setupL4Test(t, boolPtr(false))
ctx := context.Background()
diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go
index c8ab4f955..365fbab40 100644
--- a/management/internals/modules/reverseproxy/service/manager/manager.go
+++ b/management/internals/modules/reverseproxy/service/manager/manager.go
@@ -338,7 +338,7 @@ func (m *Manager) persistNewService(ctx context.Context, accountID string, svc *
}
}
- if err := m.ensureL4Port(ctx, transaction, svc, customPorts); err != nil {
+ if err := m.ensureL4Port(ctx, transaction, svc, customPorts, false); err != nil {
return err
}
@@ -367,11 +367,11 @@ func (m *Manager) clusterCustomPorts(ctx context.Context, svc *service.Service)
// 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) error {
+func (m *Manager) ensureL4Port(ctx context.Context, tx store.Store, svc *service.Service, customPorts *bool, serviceUpdate bool) error {
if !service.IsL4Protocol(svc.Mode) {
return nil
}
- if service.IsPortBasedProtocol(svc.Mode) && svc.ListenPort > 0 && (customPorts == nil || !*customPorts) {
+ if service.IsPortBasedProtocol(svc.Mode) && svc.ListenPort > 0 && !serviceUpdate && (customPorts == nil || !*customPorts) {
if svc.Source != service.SourceEphemeral {
return status.Errorf(status.InvalidArgument, "custom ports not supported on cluster %s", svc.ProxyCluster)
}
@@ -465,7 +465,7 @@ func (m *Manager) persistNewEphemeralService(ctx context.Context, accountID, pee
return err
}
- if err := m.ensureL4Port(ctx, transaction, svc, customPorts); err != nil {
+ if err := m.ensureL4Port(ctx, transaction, svc, customPorts, false); err != nil {
return err
}
@@ -651,12 +651,22 @@ func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.St
m.preserveListenPort(service, existingService)
updateInfo.serviceEnabledChanged = existingService.Enabled != service.Enabled
- if err := m.ensureL4Port(ctx, transaction, service, customPorts); err != nil {
+ // if the service is being updated, and we decide in the future to allow mode update,
+ // we should reconsider the currently assigned port if not 0 for clusters that don't support custom ports
+ if err := validateL4PortDiffOnClusterDiff(customPorts, service, existingService); err != nil {
return err
}
+
+ if err := m.ensureL4Port(ctx, transaction, service, customPorts, true); err != nil {
+ return err
+ }
+
+ // we can try carrying the previous service port into a new cluster, if this becomes a problem for multiple users,
+ // we should reconsider adding another check
if err := m.checkPortConflict(ctx, transaction, service); err != nil {
return err
}
+
if err := transaction.UpdateService(ctx, service); err != nil {
return fmt.Errorf("update service: %w", err)
}
@@ -664,6 +674,21 @@ func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.St
return nil
}
+// validateL4PortDiffOnClusterDiff checks if custom L4 ports are configured and validates port changes across clusters.
+// It ensures no port changes if custom ports are unsupported for a given cluster and protocol mode.
+// Returns an error if validation fails, otherwise returns nil.
+func validateL4PortDiffOnClusterDiff(customPorts *bool, newSVC, oldSVC *service.Service) error {
+ if !service.IsPortBasedProtocol(newSVC.Mode) || (customPorts != nil && *customPorts) {
+ return nil
+ }
+
+ if newSVC.ListenPort != 0 && newSVC.ListenPort != oldSVC.ListenPort {
+ return status.Errorf(status.InvalidArgument, "custom ports not supported on target cluster %s", newSVC.ProxyCluster)
+ }
+
+ return nil
+}
+
// handleDomainChange validates the new domain is free inside the transaction
// and applies the pre-resolved cluster (computed outside the tx by
// resolveEffectiveCluster). It must NOT call clusterDeriver here: that talks
@@ -893,6 +918,10 @@ func (m *Manager) DeleteAllServices(ctx context.Context, accountID, userID strin
}
for _, svc := range services {
+ if err = transaction.DeleteServiceTargets(ctx, accountID, svc.ID); err != nil {
+ return fmt.Errorf("failed to delete service targets: %w", err)
+ }
+
if err = transaction.DeleteService(ctx, accountID, svc.ID); err != nil {
return fmt.Errorf("failed to delete service: %w", err)
}
@@ -1245,6 +1274,10 @@ func (m *Manager) deletePeerService(ctx context.Context, accountID, peerID, serv
return status.Errorf(status.PermissionDenied, "cannot delete service exposed by another peer")
}
+ if err = transaction.DeleteServiceTargets(ctx, accountID, serviceID); err != nil {
+ return fmt.Errorf("delete service targets: %w", err)
+ }
+
if err = transaction.DeleteService(ctx, accountID, serviceID); err != nil {
return fmt.Errorf("delete service: %w", err)
}
@@ -1294,6 +1327,10 @@ func (m *Manager) deleteExpiredPeerService(ctx context.Context, accountID, peerI
return nil
}
+ if err = transaction.DeleteServiceTargets(ctx, accountID, serviceID); err != nil {
+ return fmt.Errorf("delete service targets: %w", err)
+ }
+
if err = transaction.DeleteService(ctx, accountID, serviceID); err != nil {
return fmt.Errorf("delete service: %w", err)
}
diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go
index 0497415b7..ace105b31 100644
--- a/management/internals/modules/reverseproxy/service/manager/manager_test.go
+++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go
@@ -458,6 +458,9 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) {
txMock.EXPECT().
GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID).
Return(newEphemeralService(), nil)
+ txMock.EXPECT().
+ DeleteServiceTargets(ctx, accountID, serviceID).
+ Return(nil)
txMock.EXPECT().
DeleteService(ctx, accountID, serviceID).
Return(nil)
@@ -560,6 +563,9 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) {
txMock.EXPECT().
GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID).
Return(newEphemeralService(), nil)
+ txMock.EXPECT().
+ DeleteServiceTargets(ctx, accountID, serviceID).
+ Return(nil)
txMock.EXPECT().
DeleteService(ctx, accountID, serviceID).
Return(nil)
@@ -604,6 +610,9 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) {
txMock.EXPECT().
GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID).
Return(newEphemeralService(), nil)
+ txMock.EXPECT().
+ DeleteServiceTargets(ctx, accountID, serviceID).
+ Return(nil)
txMock.EXPECT().
DeleteService(ctx, accountID, serviceID).
Return(nil)
@@ -1192,6 +1201,67 @@ func TestDeleteService_DeletesTargets(t *testing.T) {
assert.Len(t, targets, 0, "All targets should be deleted when service is deleted")
}
+func TestDeleteExpiredPeerService_DeletesTargets(t *testing.T) {
+ ctx := context.Background()
+ mgr, testStore := setupIntegrationTest(t)
+
+ resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
+ Port: 8080,
+ Mode: "http",
+ })
+ require.NoError(t, err)
+
+ svcID := resolveServiceIDByDomain(t, testStore, resp.Domain)
+
+ targets, err := testStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, testAccountID, svcID)
+ require.NoError(t, err)
+ require.Len(t, targets, 1, "ephemeral peer-exposed service should have exactly one persisted target before reaping")
+
+ expireEphemeralService(t, testStore, testAccountID, resp.Domain)
+ err = mgr.deleteExpiredPeerService(ctx, testAccountID, testPeerID, svcID)
+ require.NoError(t, err)
+
+ _, err = testStore.GetServiceByDomain(ctx, resp.Domain)
+ require.Error(t, err, "expired peer-exposed service should be deleted")
+ s, ok := status.FromError(err)
+ require.True(t, ok)
+ assert.Equal(t, status.NotFound, s.Type())
+
+ targets, err = testStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, testAccountID, svcID)
+ require.NoError(t, err)
+ assert.Len(t, targets, 0, "orphaned target rows must be deleted when an expired peer-exposed service is reaped")
+}
+
+func TestDeleteServiceFromPeer_DeletesTargets(t *testing.T) {
+ ctx := context.Background()
+ mgr, testStore := setupIntegrationTest(t)
+
+ resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{
+ Port: 8080,
+ Mode: "http",
+ })
+ require.NoError(t, err)
+
+ svcID := resolveServiceIDByDomain(t, testStore, resp.Domain)
+
+ targets, err := testStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, testAccountID, svcID)
+ require.NoError(t, err)
+ require.Len(t, targets, 1, "ephemeral peer-exposed service should have exactly one persisted target before stopping")
+
+ err = mgr.StopServiceFromPeer(ctx, testAccountID, testPeerID, svcID)
+ require.NoError(t, err)
+
+ _, err = testStore.GetServiceByDomain(ctx, resp.Domain)
+ require.Error(t, err, "stopped peer-exposed service should be deleted")
+ s, ok := status.FromError(err)
+ require.True(t, ok)
+ assert.Equal(t, status.NotFound, s.Type())
+
+ targets, err = testStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, testAccountID, svcID)
+ require.NoError(t, err)
+ assert.Len(t, targets, 0, "orphaned target rows must be deleted when a peer stops its exposed service")
+}
+
func TestValidateProtocolChange(t *testing.T) {
tests := []struct {
name string
diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go
index b4a0d8b28..ced982a30 100644
--- a/management/internals/shared/grpc/conversion.go
+++ b/management/internals/shared/grpc/conversion.go
@@ -8,6 +8,8 @@ import (
"strings"
"time"
+ "github.com/hashicorp/go-version"
+ nbversion "github.com/netbirdio/netbird/version"
log "github.com/sirupsen/logrus"
goproto "google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -28,6 +30,23 @@ import (
"github.com/netbirdio/netbird/shared/sshauth"
)
+const (
+ // deprecatedRemotePeersVersion is the version of Netbird that introduced the NetworkMap.RemotePeers field, deprecated in favor of RemotePeers.
+ deprecatedRemotePeersVersion = "0.29.3"
+)
+
+// precomputedDeprecatedRemotePeersConstraint is the parsed ">= 0.29.3" constraint,
+// built once at init since the bound is a compile-time constant.
+var precomputedDeprecatedRemotePeersConstraint version.Constraints
+
+func init() {
+ constraint, err := version.NewConstraint(">= " + deprecatedRemotePeersVersion)
+ if err != nil {
+ panic("parse deprecated remote peers version constraint: " + err.Error())
+ }
+ precomputedDeprecatedRemotePeersConstraint = constraint
+}
+
func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings) *proto.NetbirdConfig {
if config == nil {
return nil
@@ -155,7 +174,11 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb
remotePeers := make([]*proto.RemotePeerConfig, 0, len(networkMap.Peers)+len(networkMap.OfflinePeers))
remotePeers = appendRemotePeerConfig(remotePeers, networkMap.Peers, dnsName, includeIPv6)
- response.RemotePeers = remotePeers
+
+ if !shouldSkipSendingDeprecatedRemotePeers(peer.Meta.WtVersion) {
+ response.RemotePeers = remotePeers
+ }
+
response.NetworkMap.RemotePeers = remotePeers
response.RemotePeersIsEmpty = len(remotePeers) == 0
response.NetworkMap.RemotePeersIsEmpty = response.RemotePeersIsEmpty
@@ -246,6 +269,19 @@ func buildAuthorizedUsersProto(ctx context.Context, authorizedUsers map[string]m
return hashedUsers, machineUsers
}
+func shouldSkipSendingDeprecatedRemotePeers(peerVersion string) bool {
+ if nbversion.IsDevelopmentVersion(peerVersion) {
+ return true
+ }
+
+ peerNBVersion, err := version.NewVersion(peerVersion)
+ if err != nil {
+ return false
+ }
+
+ return precomputedDeprecatedRemotePeersConstraint.Check(peerNBVersion)
+}
+
func appendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig {
for _, rPeer := range peers {
allowedIPs := []string{rPeer.IP.String() + "/32"}
@@ -363,7 +399,6 @@ func toProtocolFirewallRules(rules []*types.FirewallRule, includeIPv6, useSource
return result
}
-
// populateSourcePrefixes sets SourcePrefixes on fwRule and returns any
// additional rules needed (e.g. a v6 wildcard clone when the peer IP is unspecified).
func populateSourcePrefixes(fwRule *proto.FirewallRule, rule *types.FirewallRule, includeIPv6 bool) []*proto.FirewallRule {
diff --git a/management/internals/shared/grpc/conversion_test.go b/management/internals/shared/grpc/conversion_test.go
index 5efb24319..01a67e4fa 100644
--- a/management/internals/shared/grpc/conversion_test.go
+++ b/management/internals/shared/grpc/conversion_test.go
@@ -202,6 +202,42 @@ func TestBuildJWTConfig_Audiences(t *testing.T) {
}
}
+// TestShouldSkipSendingDeprecatedRemotePeers covers the version gate that
+// stops populating the deprecated top-level SyncResponse.RemotePeers field for
+// peers new enough to read RemotePeers off the NetworkMap. Development builds
+// are treated as latest and skip the field. The gate otherwise fails safe: a
+// release version older than the boundary, or one that can't be parsed (empty,
+// garbage, prereleases of the boundary) still receives the deprecated field so
+// older/unknown clients keep working.
+func TestShouldSkipSendingDeprecatedRemotePeers(t *testing.T) {
+ tests := []struct {
+ name string
+ peerVersion string
+ wantSkip bool
+ }{
+ {"exact boundary skips", "0.29.3", true},
+ {"newer patch skips", "0.29.4", true},
+ {"newer minor skips", "0.30.0", true},
+ {"newer major skips", "1.0.0", true},
+ {"v-prefixed newer skips", "v0.30.0", true},
+ {"development build skips", "development", true},
+ {"development build with commit skips", "development-abc123def456-dirty", true},
+ {"older patch keeps field", "0.29.2", false},
+ {"older minor keeps field", "0.28.0", false},
+ {"prerelease of boundary keeps field", "0.29.3-SNAPSHOT", false},
+ {"tagged dev prerelease keeps field", "v0.31.1-dev", false},
+ {"empty version keeps field", "", false},
+ {"garbage version keeps field", "not-a-version", false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := shouldSkipSendingDeprecatedRemotePeers(tc.peerVersion)
+ assert.Equal(t, tc.wantSkip, got, "skip decision for peer version %q", tc.peerVersion)
+ })
+ }
+}
+
// TestEncodeSessionExpiresAt pins the wire encoding the client's
// applySessionDeadline depends on:
//
diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go
index 72735b210..0feb807f6 100644
--- a/management/internals/shared/grpc/proxy.go
+++ b/management/internals/shared/grpc/proxy.go
@@ -666,8 +666,10 @@ func (s *ProxyServiceServer) sender(conn *proxyConnection, errChan chan<- error)
case resp := <-conn.sendChan:
if err := conn.sendResponse(resp); err != nil {
errChan <- err
+ log.WithContext(conn.ctx).Tracef("Failed to send response to proxy %s: %v", conn.proxyID, err)
return
}
+ log.WithContext(conn.ctx).Tracef("Send response to proxy %s", conn.proxyID)
case <-conn.ctx.Done():
return
}
diff --git a/management/server/account.go b/management/server/account.go
index f16717857..f58c797b7 100644
--- a/management/server/account.go
+++ b/management/server/account.go
@@ -28,6 +28,7 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/formatter/hook"
+ "github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/account"
@@ -1588,7 +1589,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 {
- if userAuth.IsChild || userAuth.IsPAT {
+ // Child accounts and 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) {
return nil
}
@@ -1890,7 +1894,7 @@ func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID
return nil, nil, nil, 0, fmt.Errorf("error syncing peer: %w", err)
}
- if err := am.MarkPeerConnected(ctx, peerPubKey, realIP, accountID, syncTime.UnixNano()); err != nil {
+ if err := am.MarkPeerConnected(ctx, peerPubKey, realIP, accountID, syncTime.UnixNano(), netMap); err != nil {
log.WithContext(ctx).Warnf("failed marking peer as connected %s %v", peerPubKey, err)
}
@@ -2573,7 +2577,9 @@ func (am *DefaultAccountManager) UpdatePeerIP(ctx context.Context, accountID, us
if err != nil {
return err
}
- err = am.networkMapController.OnPeersUpdated(ctx, peer.AccountID, []string{peerID})
+ changedPeerIDs := []string{peerID}
+ affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs)
+ err = am.networkMapController.OnPeersUpdated(ctx, peer.AccountID, changedPeerIDs, affectedPeerIDs)
if err != nil {
return fmt.Errorf("notify network map controller of peer update: %w", err)
}
@@ -2664,7 +2670,9 @@ func (am *DefaultAccountManager) UpdatePeerIPv6(ctx context.Context, accountID,
}
if updateNetworkMap {
- if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peerID}); err != nil {
+ changedPeerIDs := []string{peerID}
+ affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs)
+ if err := am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil {
return fmt.Errorf("notify network map controller: %w", err)
}
}
diff --git a/management/server/account/manager.go b/management/server/account/manager.go
index b7b159915..2fdfdba5a 100644
--- a/management/server/account/manager.go
+++ b/management/server/account/manager.go
@@ -13,6 +13,7 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
nbcache "github.com/netbirdio/netbird/management/server/cache"
"github.com/netbirdio/netbird/management/server/idp"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
@@ -61,7 +62,7 @@ type Manager interface {
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)
- MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error
+ MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, 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
UpdatePeer(ctx context.Context, accountID, userID string, p *nbpeer.Peer) (*nbpeer.Peer, error)
@@ -109,7 +110,7 @@ type Manager interface {
UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error)
UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error)
LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) // used by peer gRPC API
- ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) // used by peer gRPC API for ExtendAuthSession
+ ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) // used by peer gRPC API for ExtendAuthSession
SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) // used by peer gRPC API
GetExternalCacheManager() ExternalCacheManager
GetPostureChecks(ctx context.Context, accountID, postureChecksID, userID string) (*posture.Checks, error)
@@ -128,6 +129,7 @@ type Manager interface {
GetAccountSettings(ctx context.Context, accountID string, userID string) (*types.Settings, error)
DeleteSetupKey(ctx context.Context, accountID, userID, keyID string) error
UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason)
+ ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change)
BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason)
BuildUserInfosForAccount(ctx context.Context, accountID, initiatorUserID string, accountUsers []*types.User) (map[string]*types.UserInfo, error)
SyncUserJWTGroups(ctx context.Context, userAuth auth.UserAuth) error
diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go
index 81127a6b4..0e06ebf91 100644
--- a/management/server/account/manager_mock.go
+++ b/management/server/account/manager_mock.go
@@ -15,6 +15,7 @@ import (
dns "github.com/netbirdio/netbird/dns"
service "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
activity "github.com/netbirdio/netbird/management/server/activity"
+ affectedpeers "github.com/netbirdio/netbird/management/server/affectedpeers"
idp "github.com/netbirdio/netbird/management/server/idp"
peer "github.com/netbirdio/netbird/management/server/peer"
posture "github.com/netbirdio/netbird/management/server/posture"
@@ -1320,17 +1321,17 @@ func (mr *MockManagerMockRecorder) ExtendPeerSession(ctx, peerPubKey, userID int
}
// MarkPeerConnected mocks base method.
-func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error {
+func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "MarkPeerConnected", ctx, peerKey, realIP, accountID, sessionStartedAt)
+ ret := m.ctrl.Call(m, "MarkPeerConnected", ctx, peerKey, realIP, accountID, sessionStartedAt, nmap)
ret0, _ := ret[0].(error)
return ret0
}
// MarkPeerConnected indicates an expected call of MarkPeerConnected.
-func (mr *MockManagerMockRecorder) MarkPeerConnected(ctx, peerKey, realIP, accountID, sessionStartedAt interface{}) *gomock.Call {
+func (mr *MockManagerMockRecorder) MarkPeerConnected(ctx, peerKey, realIP, accountID, sessionStartedAt, nmap interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnected", reflect.TypeOf((*MockManager)(nil).MarkPeerConnected), ctx, peerKey, realIP, accountID, sessionStartedAt)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnected", reflect.TypeOf((*MockManager)(nil).MarkPeerConnected), ctx, peerKey, realIP, accountID, sessionStartedAt, nmap)
}
// MarkPeerDisconnected mocks base method.
@@ -1637,6 +1638,18 @@ func (mr *MockManagerMockRecorder) UpdateAccountPeers(ctx, accountID, reason int
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountPeers", reflect.TypeOf((*MockManager)(nil).UpdateAccountPeers), ctx, accountID, reason)
}
+// ExpandAndUpdateAffected mocks base method.
+func (m *MockManager) ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) {
+ m.ctrl.T.Helper()
+ m.ctrl.Call(m, "ExpandAndUpdateAffected", ctx, accountID, snap, change)
+}
+
+// ExpandAndUpdateAffected indicates an expected call of ExpandAndUpdateAffected.
+func (mr *MockManagerMockRecorder) ExpandAndUpdateAffected(ctx, accountID, snap, change interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExpandAndUpdateAffected", reflect.TypeOf((*MockManager)(nil).ExpandAndUpdateAffected), ctx, accountID, snap, change)
+}
+
// UpdateAccountSettings mocks base method.
func (m *MockManager) UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error) {
m.ctrl.T.Helper()
diff --git a/management/server/account_test.go b/management/server/account_test.go
index ba621030c..51f079a57 100644
--- a/management/server/account_test.go
+++ b/management/server/account_test.go
@@ -26,6 +26,7 @@ import (
"github.com/netbirdio/netbird/shared/management/status"
nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
@@ -723,6 +724,28 @@ func TestDefaultAccountManager_SyncUserJWTGroups(t *testing.T) {
require.Equal(t, g2.Name, "group2", "group2 name should match")
require.Equal(t, g2.Issued, types.GroupIssuedJWT, "group2 issued should match")
})
+ t.Run("local embedded-Dex user is skipped", func(t *testing.T) {
+ initAccount.Settings.JWTGroupsEnabled = true
+ initAccount.Settings.JWTGroupsClaimName = "idp-groups"
+ err := manager.Store.SaveAccount(context.Background(), initAccount)
+ require.NoError(t, err, "save account failed")
+
+ localClaims := auth.UserAuth{
+ AccountId: accountID,
+ Domain: domain,
+ UserId: dex.EncodeDexUserID("local-owner", "local"),
+ Groups: []string{"group3", "group4"},
+ }
+ err = manager.SyncUserJWTGroups(context.Background(), localClaims)
+ require.NoError(t, err, "sync should be a no-op for local users")
+
+ account, err := manager.Store.GetAccount(context.Background(), accountID)
+ require.NoError(t, err, "get account failed")
+ for _, g := range account.Groups {
+ require.NotEqual(t, "group3", g.Name, "local user JWT groups must not be synced")
+ require.NotEqual(t, "group4", g.Name, "local user JWT groups must not be synced")
+ }
+ })
}
func TestAccountManager_PrivateAccount(t *testing.T) {
@@ -1813,7 +1836,7 @@ func TestDefaultAccountManager_UpdatePeer_PeerLoginExpiration(t *testing.T) {
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
require.NoError(t, err, "unable to get the account")
- err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano())
+ err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano(), nil)
require.NoError(t, err, "unable to mark peer connected")
_, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{
@@ -1884,7 +1907,7 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing.
require.NoError(t, err, "unable to get the account")
// when we mark peer as connected, the peer login expiration routine should trigger
- err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano())
+ err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano(), nil)
require.NoError(t, err, "unable to mark peer connected")
failed := waitTimeout(wg, time.Second)
@@ -1912,7 +1935,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) {
t.Run("disconnect peer when session token matches", func(t *testing.T) {
streamStartTime := time.Now().UTC()
- err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, streamStartTime.UnixNano())
+ err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, streamStartTime.UnixNano(), nil)
require.NoError(t, err, "unable to mark peer connected")
peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey)
@@ -1933,7 +1956,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) {
t.Run("skip disconnect when stored session is newer (zombie stream protection)", func(t *testing.T) {
// Newer stream wins on connect (sets SessionStartedAt = now ns).
streamStartTime := time.Now().UTC()
- err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, streamStartTime.UnixNano())
+ err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, streamStartTime.UnixNano(), nil)
require.NoError(t, err, "unable to mark peer connected")
peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey)
@@ -1957,7 +1980,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) {
t.Run("skip stale connect when stored session is newer (blocked goroutine protection)", func(t *testing.T) {
node2SyncTime := time.Now().UTC()
- err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, node2SyncTime.UnixNano())
+ err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, node2SyncTime.UnixNano(), nil)
require.NoError(t, err, "node 2 should connect peer")
peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey)
@@ -1967,7 +1990,7 @@ func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) {
"SessionStartedAt should equal node2SyncTime token")
node1StaleSyncTime := node2SyncTime.Add(-1 * time.Minute)
- err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, node1StaleSyncTime.UnixNano())
+ err = manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, node1StaleSyncTime.UnixNano(), nil)
require.NoError(t, err, "stale connect should not return error")
peer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey)
@@ -2029,7 +2052,7 @@ func TestDefaultAccountManager_MarkPeerConnected_ConcurrentRace(t *testing.T) {
defer done.Done()
ready.Done()
start.Wait()
- errs <- manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, token)
+ errs <- manager.MarkPeerConnected(context.Background(), peerPubKey, nil, accountID, token, nil)
}()
}
@@ -2070,7 +2093,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *test
account, err := manager.Store.GetAccount(context.Background(), accountID)
require.NoError(t, err, "unable to get the account")
- err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano())
+ err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), nil, accountID, time.Now().UTC().UnixNano(), nil)
require.NoError(t, err, "unable to mark peer connected")
wg := &sync.WaitGroup{}
@@ -3282,6 +3305,19 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel.
// when the channel delivers.
const peerUpdateTimeout = 5 * time.Second
+func drainPeerUpdates(ch <-chan *network_map.UpdateMessage) {
+ for {
+ select {
+ case _, ok := <-ch:
+ if !ok {
+ return
+ }
+ case <-time.After(200 * time.Millisecond):
+ return
+ }
+ }
+}
+
func peerShouldNotReceiveUpdate(t *testing.T, updateMessage <-chan *network_map.UpdateMessage) {
t.Helper()
select {
diff --git a/management/server/affected_peers_coverage_test.go b/management/server/affected_peers_coverage_test.go
new file mode 100644
index 000000000..56917905f
--- /dev/null
+++ b/management/server/affected_peers_coverage_test.go
@@ -0,0 +1,117 @@
+package server
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
+ resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+ networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
+ "github.com/netbirdio/netbird/management/server/posture"
+ "github.com/netbirdio/netbird/management/server/types"
+)
+
+// TestAffectedPeers_DependencyCoverageMatrix enumerates each network-map
+// dependency crossed with the change-type that can alter it, asserting the
+// resolver folds in exactly the peers whose map changes. A new dependency that
+// the resolver fails to walk should fail one of these rows; a new change-type
+// without a row is a coverage gap to add here.
+func TestAffectedPeers_DependencyCoverageMatrix(t *testing.T) {
+ type row struct {
+ name string
+ build func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string)
+ }
+
+ rows := []row{
+ {
+ name: "policy-groups/source-group-change refreshes source+routing, excludes unrelated",
+ build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) {
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+ return affectedpeers.Change{ChangedGroupIDs: []string{s.sourceGroupID}},
+ []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID}
+ },
+ },
+ {
+ name: "resource-routing-bridge/router-peer-change refreshes policy sources",
+ build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) {
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+ return affectedpeers.Change{ChangedPeerIDs: []string{s.routerPeerID}},
+ []string{s.sourcePeerID}, []string{s.unrelatedPeerID}
+ },
+ },
+ {
+ name: "policy-change/explicit-policy refreshes source+routing",
+ build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) {
+ policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+ return affectedpeers.Change{Policies: []*types.Policy{policy}},
+ []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID}
+ },
+ },
+ {
+ name: "policy-destinationresource/explicit-policy bridges to routing peer",
+ build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) {
+ policy := peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID)
+ return affectedpeers.Change{Policies: []*types.Policy{policy}},
+ []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID}
+ },
+ },
+ {
+ name: "resource-change refreshes source+routing on its network",
+ build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) {
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+ return affectedpeers.Change{Resources: []*resourceTypes.NetworkResource{
+ {ID: s.resourceID, NetworkID: s.networkID, GroupIDs: []string{s.resourceGroupID}},
+ }},
+ []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID}
+ },
+ },
+ {
+ name: "network-change refreshes source+routing on that network",
+ build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) {
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+ return affectedpeers.Change{Networks: []*networkTypes.Network{{ID: s.networkID}}},
+ []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID}
+ },
+ },
+ {
+ name: "posture-check-change refreshes source+routing of gated policy",
+ build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) {
+ check, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{
+ Name: "cov-min-version",
+ Checks: posture.ChecksDefinition{NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.30.0"}},
+ }, true)
+ require.NoError(t, err)
+ policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+ policy.SourcePostureChecks = []string{check.ID}
+ _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, true)
+ require.NoError(t, err)
+ return affectedpeers.Change{PostureCheckIDs: []string{check.ID}},
+ []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID}
+ },
+ },
+ }
+
+ for _, r := range rows {
+ t.Run(r.name, func(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ change, mustContain, mustExclude := r.build(t, s, ctx)
+ affected := resolveAffected(t, s.manager.Store, s.accountID, change)
+
+ for _, id := range mustContain {
+ assert.Contains(t, affected, id, "expected peer to be affected")
+ }
+ for _, id := range mustExclude {
+ assert.NotContains(t, affected, id, "peer must not be affected")
+ }
+ })
+ }
+}
diff --git a/management/server/affected_peers_oldstate_test.go b/management/server/affected_peers_oldstate_test.go
new file mode 100644
index 000000000..bcb78a660
--- /dev/null
+++ b/management/server/affected_peers_oldstate_test.go
@@ -0,0 +1,143 @@
+package server
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+ routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/types"
+)
+
+// An update spans an old and a new state. The affected set must be the UNION of
+// peers reachable before and after the change; resolving only against the final
+// state drops peers that were reachable but no longer are. These tests pin the
+// two paths where the old state is reachable only by the changed object's
+// previous references: detaching a resource group, and re-pointing a router peer.
+
+// TestAffectedPeers_E2E_UpdateResource_DetachGroup_RefreshesOldGroupSources:
+// a resource is reachable by a source group via two destination resource groups;
+// 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()
+
+ // 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},
+ }))
+
+ 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,
+ })
+ 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()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ _, 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)
+
+ // 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)
+
+ 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,
+ })
+ 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_property_test.go b/management/server/affected_peers_property_test.go
new file mode 100644
index 000000000..f393465bc
--- /dev/null
+++ b/management/server/affected_peers_property_test.go
@@ -0,0 +1,255 @@
+package server
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math/rand"
+ "sort"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "golang.org/x/exp/maps"
+
+ nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/types"
+)
+
+// allPeerMaps computes the serialized per-peer network map for every peer in the
+// account, mirroring the controller's compute path so the property test compares
+// against real output.
+func allPeerMaps(t *testing.T, manager *DefaultAccountManager, accountID string) map[string]string {
+ t.Helper()
+ ctx := context.Background()
+
+ account, err := manager.Store.GetAccount(ctx, accountID)
+ require.NoError(t, err)
+
+ account.InjectProxyPolicies(ctx)
+
+ validated := make(map[string]struct{}, len(account.Peers))
+ for id := range account.Peers {
+ validated[id] = struct{}{}
+ }
+ resourcePolicies := account.GetResourcePoliciesMap()
+ routers := account.GetResourceRoutersMap()
+ groupIDToUserIDs := account.GetActiveGroupUsers()
+
+ out := make(map[string]string, len(account.Peers))
+ for peerID := range account.Peers {
+ nm := account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validated, resourcePolicies, routers, nil, groupIDToUserIDs)
+ // Network.Serial is an account-global counter bumped on every change; it
+ // is not a per-peer dependency, so normalize it out of the comparison.
+ if nm.Network != nil {
+ nm.Network.Serial = 0
+ }
+ out[peerID] = canonicalJSON(t, nm)
+ }
+ return out
+}
+
+// canonicalJSON marshals v and returns an order-insensitive string form: every
+// JSON array is sorted by the canonical form of its elements. The network map's
+// Peers/Routes/FirewallRules/SourceRanges slices have nondeterministic order, so
+// a raw JSON compare would report spurious changes.
+func canonicalJSON(t *testing.T, v interface{}) string {
+ t.Helper()
+ b, err := json.Marshal(v)
+ require.NoError(t, err)
+ var parsed interface{}
+ require.NoError(t, json.Unmarshal(b, &parsed))
+ canonicalized, err := json.Marshal(sortAny(parsed))
+ require.NoError(t, err)
+ return string(canonicalized)
+}
+
+func sortAny(v interface{}) interface{} {
+ switch val := v.(type) {
+ case []interface{}:
+ for i := range val {
+ val[i] = sortAny(val[i])
+ }
+ sort.Slice(val, func(i, j int) bool {
+ bi, _ := json.Marshal(val[i])
+ bj, _ := json.Marshal(val[j])
+ return string(bi) < string(bj)
+ })
+ return val
+ case map[string]interface{}:
+ for k := range val {
+ val[k] = sortAny(val[k])
+ }
+ return val
+ default:
+ return v
+ }
+}
+
+// changedPeers returns the peer IDs whose serialized map differs between before
+// and after.
+func changedPeers(before, after map[string]string) []string {
+ var changed []string
+ for id, b := range before {
+ a, ok := after[id]
+ if !ok || a != b {
+ changed = append(changed, id)
+ }
+ }
+ for id := range after {
+ if _, ok := before[id]; !ok {
+ changed = append(changed, id)
+ }
+ }
+ return changed
+}
+
+// TestAffectedPeers_Property_ResolverSupersetsRealChanges builds a topology,
+// applies random changes, and asserts that the resolver's affected set is a
+// superset of the peers whose real network map actually changed. If the resolver
+// ever misses a dependency, a change will alter a peer's map without that peer
+// appearing in the affected set, failing here.
+func TestAffectedPeers_Property_ResolverSupersetsRealChanges(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ // A pre-existing peer->resource policy so the resource/router bridge is live.
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ // Extra peers and groups to give mutations room to move membership around.
+ setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "prop-key", types.SetupKeyReusable, 0, nil, 999, userID, false, false)
+ require.NoError(t, err)
+ extraPeers := make([]string, 0, 4)
+ for i := 0; i < 4; i++ {
+ p := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
+ extraPeers = append(extraPeers, p.ID)
+ }
+ extraGroups := []string{"prop-grp-0", "prop-grp-1"}
+ for _, g := range extraGroups {
+ require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ID: g, Name: g}))
+ }
+
+ rng := rand.New(rand.NewSource(1))
+ allGroups := append([]string{s.sourceGroupID, s.resourceGroupID, s.routerPeerGroupID}, extraGroups...)
+ allPeers := append([]string{s.sourcePeerID, s.routerPeerID, s.routerGroupPeerID, s.unrelatedPeerID}, extraPeers...)
+
+ for iter := 0; iter < 60; iter++ {
+ change, apply := s.randomMutation(t, rng, allGroups, allPeers)
+ if apply == nil {
+ continue
+ }
+
+ before := allPeerMaps(t, s.manager, s.accountID)
+
+ resolvedSet := make(map[string]struct{})
+ resolve := func() {
+ require.NoError(t, s.manager.Store.ExecuteInTransaction(ctx, func(tx store.Store) error {
+ snap, err := affectedpeers.Load(ctx, tx, s.accountID, change)
+ if err != nil {
+ return err
+ }
+ for _, id := range snap.Expand(ctx, s.accountID, change) {
+ resolvedSet[id] = struct{}{}
+ }
+ return nil
+ }))
+ }
+
+ // Resolve on both sides of the mutation and union: removals are visible
+ // only pre-apply (the leaving peer is still a member), additions only
+ // post-apply (the joining peer is now a member). Production captures both
+ // via per-path handling (e.g. UpdateGroup passes peersToRemove); the union
+ // models that without coupling the test to each path's ordering.
+ resolve()
+ changedIDs := change.ChangedPeerIDs
+ apply()
+ resolve()
+
+ after := allPeerMaps(t, s.manager, s.accountID)
+
+ // The explicitly-changed peer's own map refresh is the caller's
+ // responsibility (the resolver returns the peers to propagate to), so it
+ // is allowed to be absent from the resolved set.
+ changedExplicitly := make(map[string]struct{}, len(changedIDs))
+ for _, id := range changedIDs {
+ changedExplicitly[id] = struct{}{}
+ }
+
+ for _, id := range changedPeers(before, after) {
+ if _, stillExists := after[id]; !stillExists {
+ continue
+ }
+ if _, isExplicit := changedExplicitly[id]; isExplicit {
+ continue
+ }
+ _, ok := resolvedSet[id]
+ require.Truef(t, ok,
+ "iter %d: peer %s network map changed but was not in the resolver's affected set %v (change=%+v)",
+ iter, id, maps.Keys(resolvedSet), change)
+ }
+ }
+}
+
+// randomMutation picks a random change, returns the Change to resolve and a
+// function that applies the underlying store mutation. apply is nil when the
+// drawn mutation is a no-op for the current state.
+func (s *routerScenario) randomMutation(t *testing.T, rng *rand.Rand, allGroups, allPeers []string) (affectedpeers.Change, func()) {
+ t.Helper()
+ ctx := context.Background()
+
+ switch rng.Intn(3) {
+ case 0:
+ groupID := allGroups[rng.Intn(len(allGroups))]
+ peerID := allPeers[rng.Intn(len(allPeers))]
+ grp, err := s.manager.Store.GetGroupByID(ctx, store.LockingStrengthNone, s.accountID, groupID)
+ require.NoError(t, err)
+ if slicesContains(grp.Peers, peerID) {
+ return affectedpeers.Change{}, nil
+ }
+ return affectedpeers.Change{ChangedGroupIDs: []string{groupID}, ChangedPeerIDs: []string{peerID}},
+ func() {
+ require.NoError(t, s.manager.GroupAddPeer(ctx, s.accountID, groupID, peerID))
+ }
+ case 1:
+ groupID := allGroups[rng.Intn(len(allGroups))]
+ grp, err := s.manager.Store.GetGroupByID(ctx, store.LockingStrengthNone, s.accountID, groupID)
+ require.NoError(t, err)
+ if len(grp.Peers) == 0 {
+ return affectedpeers.Change{}, nil
+ }
+ peerID := grp.Peers[rng.Intn(len(grp.Peers))]
+ return affectedpeers.Change{ChangedGroupIDs: []string{groupID}, ChangedPeerIDs: []string{peerID}},
+ func() {
+ require.NoError(t, s.manager.GroupDeletePeer(ctx, s.accountID, groupID, peerID))
+ }
+ default:
+ src := allGroups[rng.Intn(len(allGroups))]
+ dst := allGroups[rng.Intn(len(allGroups))]
+ policy := &types.Policy{
+ Enabled: true,
+ Name: fmt.Sprintf("prop-policy-%d", rng.Int()),
+ Rules: []*types.PolicyRule{{
+ Enabled: true,
+ Sources: []string{src},
+ Destinations: []string{dst},
+ Action: types.PolicyTrafficActionAccept,
+ }},
+ }
+ return affectedpeers.Change{Policies: []*types.Policy{policy}},
+ func() {
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, policy, true)
+ require.NoError(t, err)
+ }
+ }
+}
+
+func slicesContains(s []string, v string) bool {
+ for _, x := range s {
+ if x == v {
+ return true
+ }
+ }
+ return false
+}
diff --git a/management/server/affected_peers_querycount_test.go b/management/server/affected_peers_querycount_test.go
new file mode 100644
index 000000000..d451a0a29
--- /dev/null
+++ b/management/server/affected_peers_querycount_test.go
@@ -0,0 +1,164 @@
+package server
+
+import (
+ "context"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ nbdns "github.com/netbirdio/netbird/dns"
+ rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
+ 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"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/types"
+ "github.com/netbirdio/netbird/route"
+)
+
+// countingStore wraps a real store and counts the per-account collection loads
+// the resolver performs, so a test can assert each is read at most once and that
+// irrelevant collections are skipped entirely.
+type countingStore struct {
+ store.Store
+ mu sync.Mutex
+ counts map[string]int
+}
+
+func newCountingStore(s store.Store) *countingStore {
+ return &countingStore{Store: s, counts: map[string]int{}}
+}
+
+func (c *countingStore) bump(name string) {
+ c.mu.Lock()
+ c.counts[name]++
+ c.mu.Unlock()
+}
+
+func (c *countingStore) count(name string) int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.counts[name]
+}
+
+func (c *countingStore) total() int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ n := 0
+ for _, v := range c.counts {
+ n += v
+ }
+ return n
+}
+
+func (c *countingStore) GetAccountPolicies(ctx context.Context, ls store.LockingStrength, accountID string) ([]*types.Policy, error) {
+ c.bump("policies")
+ return c.Store.GetAccountPolicies(ctx, ls, accountID)
+}
+
+func (c *countingStore) GetAccountRoutes(ctx context.Context, ls store.LockingStrength, accountID string) ([]*route.Route, error) {
+ c.bump("routes")
+ return c.Store.GetAccountRoutes(ctx, ls, accountID)
+}
+
+func (c *countingStore) GetAccountNameServerGroups(ctx context.Context, ls store.LockingStrength, accountID string) ([]*nbdns.NameServerGroup, error) {
+ c.bump("nameservers")
+ return c.Store.GetAccountNameServerGroups(ctx, ls, accountID)
+}
+
+func (c *countingStore) GetAccountDNSSettings(ctx context.Context, ls store.LockingStrength, accountID string) (*types.DNSSettings, error) {
+ c.bump("dnssettings")
+ return c.Store.GetAccountDNSSettings(ctx, ls, accountID)
+}
+
+func (c *countingStore) GetNetworkRoutersByAccountID(ctx context.Context, ls store.LockingStrength, accountID string) ([]*routerTypes.NetworkRouter, error) {
+ c.bump("routers")
+ return c.Store.GetNetworkRoutersByAccountID(ctx, ls, accountID)
+}
+
+func (c *countingStore) GetNetworkResourcesByAccountID(ctx context.Context, ls store.LockingStrength, accountID string) ([]*resourceTypes.NetworkResource, error) {
+ c.bump("resources")
+ return c.Store.GetNetworkResourcesByAccountID(ctx, ls, accountID)
+}
+
+func (c *countingStore) GetAccountServices(ctx context.Context, ls store.LockingStrength, accountID string) ([]*rpservice.Service, error) {
+ c.bump("services")
+ return c.Store.GetAccountServices(ctx, ls, accountID)
+}
+
+// TestAffectedPeers_QueryCount_NoRedundantFullTableLoads asserts the resolver
+// loads each per-account collection at most once per Resolve (memoization) even
+// on a change that drives every bridge, and skips the services table when the
+// account has no embedded proxy peers.
+func TestAffectedPeers_QueryCount_NoRedundantFullTableLoads(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)
+
+ cs := newCountingStore(s.manager.Store)
+
+ // A group change that exercises policies, routers, resources and the bridge.
+ change := affectedpeers.Change{ChangedGroupIDs: []string{s.sourceGroupID}}
+ snap, err := affectedpeers.Load(ctx, cs, s.accountID, change)
+ require.NoError(t, err)
+ affected := snap.Expand(ctx, s.accountID, change)
+ assert.Contains(t, affected, s.routerPeerID, "bridge must still resolve the routing peer")
+
+ for _, name := range []string{"policies", "routes", "nameservers", "dnssettings", "routers", "resources"} {
+ assert.LessOrEqualf(t, cs.count(name), 1,
+ "%s must be loaded at most once per Resolve, got %d", name, cs.count(name))
+ }
+ assert.Equal(t, 0, cs.count("services"),
+ "services must not be loaded when the account has no embedded proxy peers")
+}
+
+// TestAffectedPeers_QueryCount_NarrowChangeSkipsLoads asserts that a change with
+// no group/peer signal touches no per-account collections beyond what its inputs
+// require.
+func TestAffectedPeers_QueryCount_NarrowChangeSkipsLoads(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ cs := newCountingStore(s.manager.Store)
+
+ // A bare network change drives only the router->source bridge: routers and
+ // resources are needed, but routes/nameservers/dnssettings/services are not.
+ _, err := affectedpeers.Load(ctx, cs, s.accountID, affectedpeers.Change{Networks: []*networkTypes.Network{{ID: s.networkID}}})
+ require.NoError(t, err)
+
+ assert.Equal(t, 0, cs.count("routes"), "routes must not be loaded for a network-only change")
+ assert.Equal(t, 0, cs.count("nameservers"), "nameservers must not be loaded for a network-only change")
+ assert.Equal(t, 0, cs.count("dnssettings"), "dnssettings must not be loaded for a network-only change")
+ assert.Equal(t, 0, cs.count("services"), "services must not be loaded for a network-only change")
+}
+
+// TestAffectedPeers_QueryCount_ExpandReadsNothing is the core invariant of the
+// Load/Expand split: Load (run inside the transaction) does all store reads;
+// Expand (run after commit) must touch the store ZERO times, so it never holds
+// the write lock and never reads post-commit state.
+func TestAffectedPeers_QueryCount_ExpandReadsNothing(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)
+
+ change := affectedpeers.Change{ChangedGroupIDs: []string{s.sourceGroupID}}
+
+ cs := newCountingStore(s.manager.Store)
+ snap, err := affectedpeers.Load(ctx, cs, s.accountID, change)
+ require.NoError(t, err)
+ require.Greater(t, cs.total(), 0, "Load must read the store")
+
+ // Any store access during Expand would increment the same counter. Expand
+ // operates purely on the snapshot, so the count must not move.
+ readsAfterLoad := cs.total()
+ affected := snap.Expand(ctx, s.accountID, change)
+ assert.Contains(t, affected, s.routerPeerID, "Expand must still produce the affected peers from the snapshot")
+ assert.Equal(t, readsAfterLoad, cs.total(), "Expand must perform zero store reads — it operates purely on the loaded snapshot")
+}
diff --git a/management/server/affected_peers_router_paths_test.go b/management/server/affected_peers_router_paths_test.go
new file mode 100644
index 000000000..11313c387
--- /dev/null
+++ b/management/server/affected_peers_router_paths_test.go
@@ -0,0 +1,333 @@
+package server
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
+ resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+ routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+ "github.com/netbirdio/netbird/management/server/posture"
+ "github.com/netbirdio/netbird/management/server/types"
+)
+
+func (s *routerScenario) resolveGroupChangeAffected(ctx context.Context, changedGroupIDs []string) []string {
+ change := affectedpeers.Change{ChangedGroupIDs: changedGroupIDs}
+ snap, err := affectedpeers.Load(ctx, s.manager.Store, s.accountID, change)
+ if err != nil {
+ return nil
+ }
+ return snap.Expand(ctx, s.accountID, change)
+}
+
+func (s *routerScenario) resolvePeerChangeAffected(ctx context.Context, changedPeerIDs []string) []string {
+ change := affectedpeers.Change{ChangedPeerIDs: changedPeerIDs}
+ snap, err := affectedpeers.Load(ctx, s.manager.Store, s.accountID, change)
+ if err != nil {
+ return nil
+ }
+ return snap.Expand(ctx, s.accountID, change)
+}
+
+func TestAffectedPeers_GroupChange_SourceGroupMembership_RefreshesRoutingPeer_DirectRouter(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)
+
+ affected := s.resolveGroupChangeAffected(ctx, []string{s.sourceGroupID})
+
+ assert.Contains(t, affected, s.sourcePeerID, "source group member must be affected")
+ assert.Contains(t, affected, s.routerPeerID,
+ "changing the source group of a peer->resource policy must refresh the resource's routing peer")
+ assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
+}
+
+func TestAffectedPeers_GroupChange_SourceGroupMembership_RefreshesRoutingPeer_RouterPeerGroups(t *testing.T) {
+ s := setupRouterScenario(t, false)
+ ctx := context.Background()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ affected := s.resolveGroupChangeAffected(ctx, []string{s.sourceGroupID})
+
+ assert.Contains(t, affected, s.routerGroupPeerID,
+ "changing the source group must refresh the routing peer defined via router.PeerGroups")
+ assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
+}
+
+func TestAffectedPeers_GroupChange_RouterPeerGroupMembership_RefreshesPolicySources(t *testing.T) {
+ s := setupRouterScenario(t, false)
+ ctx := context.Background()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ affected := s.resolveGroupChangeAffected(ctx, []string{s.routerPeerGroupID})
+
+ assert.Contains(t, affected, s.routerGroupPeerID, "the routing peer itself must be affected")
+ assert.Contains(t, affected, s.sourcePeerID,
+ "changing the router's PeerGroups must refresh the source peers of policies serving the resource")
+ assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
+}
+
+func TestAffectedPeers_PeerChange_SourcePeer_RefreshesRoutingPeer(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)
+
+ affected := s.resolvePeerChangeAffected(ctx, []string{s.sourcePeerID})
+
+ assert.Contains(t, affected, s.routerPeerID,
+ "a status change on a source peer must refresh the resource's routing peer that serves it")
+ assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
+}
+
+func TestAffectedPeers_PeerChange_SourcePeer_ByDestinationResource_RefreshesRoutingPeer(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)
+
+ affected := s.resolvePeerChangeAffected(ctx, []string{s.sourcePeerID})
+
+ assert.Contains(t, affected, s.routerPeerID,
+ "DestinationResource-targeted policy must still bridge a source-peer change to the routing peer")
+ assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
+}
+
+func TestAffectedPeers_E2E_DeleteGroup_ResolvesAffectedPeers(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ const memberOnlyGroupID = "rs-memberonly-grp"
+ require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{
+ ID: memberOnlyGroupID, Name: "rs-memberonly", Peers: []string{s.sourcePeerID},
+ }))
+
+ affected := s.resolveGroupChangeAffected(ctx, []string{memberOnlyGroupID})
+ assert.Empty(t, affected, "an unlinked group has no network-map impact, so no peer is affected")
+
+ require.NoError(t, s.manager.DeleteGroup(ctx, s.accountID, userID, memberOnlyGroupID))
+}
+
+func TestAffectedPeers_GroupAddResource_RefreshesRoutingPeer(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ const extraResourceGroupID = "rs-resource-grp-extra"
+ require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{
+ ID: extraResourceGroupID, Name: "rs-resource-extra",
+ }))
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, extraResourceGroupID), true)
+ require.NoError(t, err)
+
+ require.NoError(t, s.manager.GroupAddResource(ctx, s.accountID, extraResourceGroupID, types.Resource{
+ ID: s.resourceID,
+ Type: types.ResourceTypeHost,
+ }))
+
+ affected := s.resolveGroupChangeAffected(ctx, []string{extraResourceGroupID})
+
+ assert.Contains(t, affected, s.routerPeerID,
+ "attaching a resource to a policy destination group must refresh the resource's routing peer")
+ assert.Contains(t, affected, s.sourcePeerID, "policy source peers must refresh")
+ assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
+}
+
+func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context.Context) string {
+ t.Helper()
+
+ check, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{
+ Name: "rs-min-version",
+ Checks: posture.ChecksDefinition{
+ NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.30.0"},
+ },
+ }, true)
+ require.NoError(t, err)
+
+ policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+ policy.SourcePostureChecks = []string{check.ID}
+ _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, true)
+ require.NoError(t, err)
+
+ return check.ID
+}
+
+func TestAffectedPeers_E2E_SavePostureCheck_RefreshesRoutingPeer(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ checkID := s.createPostureCheckGatedPolicy(t, ctx)
+
+ 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")
+ }
+}
+
+func TestAffectedPeers_E2E_UpdateResource_DestinationResourcePolicy_RefreshesSourcePeer(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)
+
+ 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)
+ })
+
+ 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")
+ }
+}
+
+func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouter_StillBridged(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)
+
+ 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,
+ })
+ require.NoError(t, err)
+
+ disabledCh := s.updateManager.CreateChannel(ctx, disabledRouterPeer.ID)
+ t.Cleanup(func() { s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID) })
+
+ settleAffectedUpdates(disabledCh)
+
+ done := make(chan struct{})
+ go func() {
+ peerShouldReceiveUpdate(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: resource update did not refresh the disabled sibling router's peer")
+ }
+}
+
+func TestAffectedPeers_GroupChange_RouterInOtherNetworkNotAffected(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ second := s.addSecondTopology(t, "groupiso")
+ ctx := context.Background()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ affected := s.resolveGroupChangeAffected(ctx, []string{s.sourceGroupID})
+
+ assert.Contains(t, affected, s.routerPeerID, "network A's routing peer must be affected")
+ assert.NotContains(t, affected, second.routerPeerID,
+ "a router in an unrelated network must not be affected by a source-group change for another resource")
+}
+
+func TestAffectedPeers_PeerChange_RouterInOtherNetworkNotAffected(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ second := s.addSecondTopology(t, "peeriso")
+ ctx := context.Background()
+
+ _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true)
+ require.NoError(t, err)
+
+ affected := s.resolvePeerChangeAffected(ctx, []string{s.sourcePeerID})
+
+ assert.Contains(t, affected, s.routerPeerID, "network A's routing peer must be affected")
+ assert.NotContains(t, affected, second.routerPeerID,
+ "a router in an unrelated network must not be affected by a source-peer change for another resource")
+}
diff --git a/management/server/affected_peers_router_test.go b/management/server/affected_peers_router_test.go
new file mode 100644
index 000000000..dc064e787
--- /dev/null
+++ b/management/server/affected_peers_router_test.go
@@ -0,0 +1,771 @@
+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/controllers/network_map/update_channel"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
+ "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"
+ "github.com/netbirdio/netbird/management/server/permissions"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/types"
+)
+
+// routerScenario captures the topology from the bug report:
+//
+// network ── router (routing peer) ── resource (in resourceGroup)
+// independent peer ──(policy: source -> resource)──> resource
+//
+// The routing peer must be refreshed when a policy grants a source peer access
+// to the resource, because the network map connects the source peer to the
+// routing peer at compute time (Account.GetPoliciesForNetworkResource +
+// addNetworksRoutingPeers). The routing peer is NOT a member of the resource
+// group, so static group/peer resolution alone cannot find it.
+type routerScenario struct {
+ manager *DefaultAccountManager
+ updateManager *update_channel.PeersUpdateManager
+ accountID string
+ networkID string
+
+ sourcePeerID string // independent peer that the policy grants access from
+ sourceGroupID string // group containing the source peer
+
+ routerPeerID string // peer acting as the routing peer (direct router.Peer)
+ routerGroupPeerID string // peer that is a member of routerPeerGroup
+ routerPeerGroupID string // group used for router.PeerGroups
+
+ resourceID string // network resource
+ resourceGroupID string // group whose member is the resource (no peers)
+
+ unrelatedPeerID string // peer in no relevant entity
+}
+
+// setupRouterScenario builds the topology above with the default policy removed
+// and channels NOT yet created, so callers control exactly when updates can flow.
+func setupRouterScenario(t *testing.T, directRouterPeer bool) *routerScenario {
+ t.Helper()
+
+ manager, updateManager, err := createManager(t)
+ require.NoError(t, err)
+
+ ctx := context.Background()
+
+ account, err := createAccount(manager, "router_scenario", userID, "")
+ require.NoError(t, err)
+ accountID := account.Id
+
+ // Remove the default policy so AddPeer/CreateGroup don't schedule unrelated updates.
+ 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))
+ }
+
+ setupKey, err := manager.CreateSetupKey(ctx, accountID, "rs-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
+ require.NoError(t, err)
+
+ sourcePeer := addPeerToAccount(t, manager, accountID, setupKey.Key)
+ routerPeer := addPeerToAccount(t, manager, accountID, setupKey.Key)
+ routerGroupPeer := addPeerToAccount(t, manager, accountID, setupKey.Key)
+ unrelatedPeer := addPeerToAccount(t, manager, accountID, setupKey.Key)
+
+ const (
+ sourceGroupID = "rs-source-grp"
+ routerPeerGroupID = "rs-router-grp"
+ resourceGroupID = "rs-resource-grp"
+ )
+
+ for _, g := range []*types.Group{
+ {ID: sourceGroupID, Name: "rs-source", Peers: []string{sourcePeer.ID}},
+ {ID: routerPeerGroupID, Name: "rs-router", Peers: []string{routerGroupPeer.ID}},
+ {ID: resourceGroupID, Name: "rs-resource"}, // intentionally peerless; the resource is its only member
+ } {
+ require.NoError(t, manager.CreateGroup(ctx, accountID, userID, g))
+ }
+
+ 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)
+
+ network, err := networksManager.CreateNetwork(ctx, userID, &networkTypes.Network{
+ ID: "rs-network",
+ AccountID: accountID,
+ Name: "rs-network",
+ })
+ require.NoError(t, err)
+
+ resource, err := resourcesManager.CreateResource(ctx, userID, &resourceTypes.NetworkResource{
+ AccountID: accountID,
+ NetworkID: network.ID,
+ Name: "rs-resource-host",
+ Address: "10.20.30.0/24",
+ GroupIDs: []string{resourceGroupID},
+ Enabled: true,
+ })
+ require.NoError(t, err)
+
+ router := &routerTypes.NetworkRouter{
+ ID: "rs-router",
+ NetworkID: network.ID,
+ AccountID: accountID,
+ Masquerade: true,
+ Metric: 9999,
+ Enabled: true,
+ }
+ if directRouterPeer {
+ router.Peer = routerPeer.ID
+ } else {
+ router.PeerGroups = []string{routerPeerGroupID}
+ }
+ _, err = routersManager.CreateRouter(ctx, userID, router)
+ require.NoError(t, err)
+
+ return &routerScenario{
+ manager: manager,
+ updateManager: updateManager,
+ accountID: accountID,
+ networkID: network.ID,
+ sourcePeerID: sourcePeer.ID,
+ sourceGroupID: sourceGroupID,
+ routerPeerID: routerPeer.ID,
+ routerGroupPeerID: routerGroupPeer.ID,
+ routerPeerGroupID: routerPeerGroupID,
+ resourceID: resource.ID,
+ resourceGroupID: resourceGroupID,
+ unrelatedPeerID: unrelatedPeer.ID,
+ }
+}
+
+// peerToResourcePolicy builds a policy granting the source group access to the
+// resource, referencing the resource by its group in the rule destination.
+func peerToResourcePolicyByGroup(sourceGroupID, resourceGroupID string) *types.Policy {
+ return &types.Policy{
+ Enabled: true,
+ Name: "peer-to-resource-by-group",
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{sourceGroupID},
+ Destinations: []string{resourceGroupID},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }
+}
+
+// peerToResourcePolicyByResource builds a policy referencing the resource
+// directly via DestinationResource rather than its group.
+func peerToResourcePolicyByResource(sourceGroupID, resourceID string) *types.Policy {
+ return &types.Policy{
+ Enabled: true,
+ Name: "peer-to-resource-by-resource",
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{sourceGroupID},
+ DestinationResource: types.Resource{ID: resourceID, Type: types.ResourceTypeHost},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }
+}
+
+// resolvePolicyAffected mirrors SavePolicy's resolution: resolve the affected
+// peers for the given policy.
+func (s *routerScenario) resolvePolicyAffected(ctx context.Context, policy *types.Policy) []string {
+ change := affectedpeers.Change{Policies: []*types.Policy{policy}}
+ snap, err := affectedpeers.Load(ctx, s.manager.Store, s.accountID, change)
+ if err != nil {
+ return nil
+ }
+ return snap.Expand(ctx, s.accountID, change)
+}
+
+func TestAffectedPeers_SourcePeer_DirectRouter(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ assert.Contains(t, affected, s.sourcePeerID, "source peer must be affected")
+}
+
+func TestAffectedPeers_RoutingPeer_DirectRouter(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ // BUG: the direct routing peer serves the resource's subnet to the source
+ // peer, so it must be refreshed when the policy is created. The policy path
+ // only resolves the literal rule groups (source group + resource group);
+ // the resource group has no peer members and the router peer is reachable
+ // only through the network, so it is dropped.
+ assert.Contains(t, affected, s.routerPeerID,
+ "routing peer (router.Peer) serving the resource must be affected by a policy granting access to it")
+}
+
+func TestAffectedPeers_RoutingPeer_RouterPeerGroups(t *testing.T) {
+ s := setupRouterScenario(t, false)
+ ctx := context.Background()
+
+ policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ // Router defined via PeerGroups instead of a direct peer.
+ assert.Contains(t, affected, s.routerGroupPeerID,
+ "routing peer (router.PeerGroups member) serving the resource must be affected")
+}
+
+func TestAffectedPeers_DestResource_RoutingPeer_DirectRouter(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ policy := peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID)
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ // When the resource is referenced via DestinationResource, RuleGroups()
+ // returns only the source group and the resource ID is not a peer, so
+ // collectPolicyAffectedGroupsAndPeers yields nothing for the destination at
+ // all. The routing peer is dropped here too.
+ assert.Contains(t, affected, s.routerPeerID,
+ "routing peer must be affected when the resource is referenced via DestinationResource")
+}
+
+func TestAffectedPeers_DestResource_RoutingPeer_RouterPeerGroups(t *testing.T) {
+ s := setupRouterScenario(t, false)
+ ctx := context.Background()
+
+ policy := peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID)
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ assert.Contains(t, affected, s.routerGroupPeerID,
+ "routing peer (PeerGroups) must be affected when the resource is referenced via DestinationResource")
+}
+
+func TestAffectedPeers_SourceResourcePeer_RoutingPeer(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ // Source expressed as a direct peer (SourceResource), destination as resource group.
+ policy := &types.Policy{
+ Enabled: true,
+ Name: "sourceResource-peer-to-resource",
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ SourceResource: types.Resource{ID: s.sourcePeerID, Type: types.ResourceTypePeer},
+ Destinations: []string{s.resourceGroupID},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ // The direct source peer IS picked up (collectPolicyAffectedGroupsAndPeers
+ // handles SourceResource peers), but the routing peer is still missing.
+ assert.Contains(t, affected, s.sourcePeerID, "direct source peer must be affected")
+ assert.Contains(t, affected, s.routerPeerID, "routing peer must be affected")
+}
+
+func TestAffectedPeers_PolicyToResource_UnrelatedPeerNotAffected(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ // Guard against an over-broad fix: a peer in no relevant entity must never
+ // be pulled in.
+ assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected")
+}
+
+func TestAffectedPeers_ResourceSideBridgesToRoutingPeer_DirectRouter(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)
+
+ // 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)
+ })
+
+ 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()
+
+ 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")
+ }
+}
+
+func TestAffectedPeers_E2E_CreatePolicy_RoutingPeer_RouterPeerGroups(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)
+ })
+
+ 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()
+
+ 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")
+ }
+}
+
+func TestAffectedPeers_E2E_DeletePolicy_RoutingPeer(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)
+
+ 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")
+ }
+}
+
+func (s *routerScenario) managers() (resources.Manager, routers.Manager, networks.Manager) {
+ permissionsManager := permissions.NewManager(s.manager.Store)
+ groupsManager := groups.NewManager(s.manager.Store, permissionsManager, s.manager)
+ resourcesManager := resources.NewManager(s.manager.Store, permissionsManager, groupsManager, s.manager, s.manager.serviceManager)
+ routersManager := routers.NewManager(s.manager.Store, permissionsManager, s.manager)
+ networksManager := networks.NewManager(s.manager.Store, permissionsManager, resourcesManager, routersManager, s.manager)
+ return resourcesManager, routersManager, networksManager
+}
+
+type secondTopology struct {
+ networkID string
+ resourceID string
+ resourceGroupID string
+ routerPeerID string
+}
+
+func (s *routerScenario) addSecondTopology(t *testing.T, suffix string) secondTopology {
+ t.Helper()
+ ctx := context.Background()
+ resourcesManager, routersManager, networksManager := s.managers()
+
+ setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-"+suffix, types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
+ require.NoError(t, err)
+ routerPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
+
+ resourceGroupID := "rs-resource-grp-" + suffix
+ require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{
+ ID: resourceGroupID, Name: "rs-resource-" + suffix,
+ }))
+
+ network, err := networksManager.CreateNetwork(ctx, userID, &networkTypes.Network{
+ ID: "rs-network-" + suffix,
+ AccountID: s.accountID,
+ Name: "rs-network-" + suffix,
+ })
+ require.NoError(t, err)
+
+ resource, err := resourcesManager.CreateResource(ctx, userID, &resourceTypes.NetworkResource{
+ AccountID: s.accountID,
+ NetworkID: network.ID,
+ Name: "rs-resource-host-" + suffix,
+ Address: "10.40.50.0/24",
+ GroupIDs: []string{resourceGroupID},
+ Enabled: true,
+ })
+ require.NoError(t, err)
+
+ _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{
+ NetworkID: network.ID,
+ AccountID: s.accountID,
+ Peer: routerPeer.ID,
+ Masquerade: true,
+ Metric: 9999,
+ Enabled: true,
+ })
+ require.NoError(t, err)
+
+ return secondTopology{
+ networkID: network.ID,
+ resourceID: resource.ID,
+ resourceGroupID: resourceGroupID,
+ routerPeerID: routerPeer.ID,
+ }
+}
+
+func TestAffectedPeers_E2E_UpdatePolicy_BothRoutingPeers(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)
+
+ 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")
+ }
+}
+
+func TestAffectedPeers_E2E_UpdatePolicy_AddSource(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},
+ }))
+
+ 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)
+ })
+
+ 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()
+
+ 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")
+ }
+}
+
+func TestAffectedPeers_AllRoutingPeers_Network(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ _, routersManager, _ := s.managers()
+ setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-r2", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false)
+ require.NoError(t, err)
+ secondRouterPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key)
+ _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{
+ NetworkID: s.networkID,
+ AccountID: s.accountID,
+ Peer: secondRouterPeer.ID,
+ Masquerade: true,
+ Metric: 9998,
+ Enabled: true,
+ })
+ require.NoError(t, err)
+
+ affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID))
+
+ assert.Contains(t, affected, s.routerPeerID, "first routing peer must be affected")
+ assert.Contains(t, affected, secondRouterPeer.ID, "second routing peer on the same network must also be affected")
+}
+
+func TestAffectedPeers_DisabledRouter(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ routers, err := s.manager.Store.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, s.accountID, s.networkID)
+ require.NoError(t, err)
+ require.Len(t, routers, 1)
+ routers[0].Enabled = false
+ require.NoError(t, s.manager.Store.UpdateNetworkRouter(ctx, routers[0]))
+
+ affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID))
+
+ assert.Contains(t, affected, s.sourcePeerID, "source peer must be affected")
+ assert.Contains(t, affected, s.routerPeerID,
+ "disabled router's peer must still be affected: Enabled must not gate affected-peers")
+}
+
+func TestAffectedPeers_DisabledResource(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ res, err := s.manager.Store.GetNetworkResourceByID(ctx, store.LockingStrengthNone, s.accountID, s.resourceID)
+ require.NoError(t, err)
+ res.Enabled = false
+ require.NoError(t, s.manager.Store.SaveNetworkResource(ctx, res))
+
+ affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID))
+
+ assert.Contains(t, affected, s.sourcePeerID, "source peer must be affected")
+ assert.Contains(t, affected, s.routerPeerID,
+ "disabled resource must still resolve the routing peer: Enabled must not gate affected-peers")
+}
+
+func TestAffectedPeers_DisabledRule(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ ctx := context.Background()
+
+ policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)
+ policy.Rules[0].Enabled = false
+
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ assert.Contains(t, affected, s.routerPeerID,
+ "disabled rule must still resolve the routing peer: Enabled must not gate affected-peers")
+}
+
+func TestAffectedPeers_MultiRule(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ second := s.addSecondTopology(t, "c")
+ ctx := context.Background()
+
+ policy := &types.Policy{
+ Enabled: true,
+ Name: "multi-rule-two-resources",
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{s.sourceGroupID},
+ Destinations: []string{s.resourceGroupID},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ {
+ Enabled: true,
+ Sources: []string{s.sourceGroupID},
+ Destinations: []string{second.resourceGroupID},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }
+
+ affected := s.resolvePolicyAffected(ctx, policy)
+
+ assert.Contains(t, affected, s.routerPeerID, "routing peer for resource A must be affected")
+ assert.Contains(t, affected, second.routerPeerID, "routing peer for resource B must be affected")
+}
+
+func TestAffectedPeers_RouterOtherNetwork(t *testing.T) {
+ s := setupRouterScenario(t, true)
+ second := s.addSecondTopology(t, "d")
+ ctx := context.Background()
+
+ affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID))
+
+ assert.Contains(t, affected, s.routerPeerID, "network A's routing peer must be affected")
+ assert.NotContains(t, affected, second.routerPeerID,
+ "a router in an unrelated network must not be affected by a policy that does not target its resource")
+}
diff --git a/management/server/affected_peers_test.go b/management/server/affected_peers_test.go
new file mode 100644
index 000000000..b66eeb3b5
--- /dev/null
+++ b/management/server/affected_peers_test.go
@@ -0,0 +1,1802 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "net/netip"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+
+ nbdns "github.com/netbirdio/netbird/dns"
+ rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
+ routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
+ networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
+ nbpeer "github.com/netbirdio/netbird/management/server/peer"
+ "github.com/netbirdio/netbird/management/server/posture"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/types"
+ "github.com/netbirdio/netbird/route"
+)
+
+// resolveAffected is a test helper for the resolver's Load+Expand, used where a
+// test asserts on the fully expanded affected peer set.
+func resolveAffected(t *testing.T, s store.Store, accountID string, change affectedpeers.Change) []string {
+ t.Helper()
+ ctx := context.Background()
+ snap, err := affectedpeers.Load(ctx, s, accountID, change)
+ require.NoError(t, err)
+ return snap.Expand(ctx, accountID, change)
+}
+
+// Thin test adapters over affectedpeers.Collect, preserving the (groups, peers)
+// shape these tests assert on after the resolver was unified.
+func collectGroupChangeAffectedGroups(ctx context.Context, s store.Store, accountID string, changedGroupIDs []string) ([]string, []string) {
+ return affectedpeers.Collect(ctx, s, accountID, affectedpeers.Change{ChangedGroupIDs: changedGroupIDs})
+}
+
+func collectPeerChangeAffectedGroups(ctx context.Context, s store.Store, accountID string, changedGroupIDs, changedPeerIDs []string) ([]string, []string) {
+ return affectedpeers.Collect(ctx, s, accountID, affectedpeers.Change{ChangedGroupIDs: changedGroupIDs, ChangedPeerIDs: changedPeerIDs})
+}
+
+func collectPostureCheckAffectedGroupsAndPeers(ctx context.Context, s store.Store, accountID, postureCheckID string) ([]string, []string) {
+ return affectedpeers.Collect(ctx, s, accountID, affectedpeers.Change{PostureCheckIDs: []string{postureCheckID}})
+}
+
+// setupAffectedPeersTest creates a manager with a clean account (default policy deleted)
+// and 5 peers, each in its own group: peer0->group0, peer1->group1, ..., peer4->group4.
+func setupAffectedPeersTest(t *testing.T) (*DefaultAccountManager, store.Store, string, []string, []string) {
+ t.Helper()
+
+ manager, _, err := createManager(t)
+ require.NoError(t, err)
+
+ account, err := createAccount(manager, "affected_test", userID, "")
+ require.NoError(t, err)
+
+ 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)
+ 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)
+
+ peerIDs := make([]string, 5)
+ for i := 0; i < 5; i++ {
+ peer := addPeerToAccount(t, manager, accountID, setupKey.Key)
+ peerIDs[i] = peer.ID
+ }
+
+ groupIDs := make([]string, 5)
+ for i := 0; i < 5; i++ {
+ g := &types.Group{
+ ID: affectedGroupID(i),
+ Name: affectedGroupName(i),
+ Peers: []string{peerIDs[i]},
+ }
+ err := manager.CreateGroup(ctx, accountID, userID, g)
+ require.NoError(t, err)
+ groupIDs[i] = g.ID
+ }
+
+ return manager, manager.Store, accountID, peerIDs, groupIDs
+}
+
+func affectedGroupID(i int) string { return fmt.Sprintf("affected-grp-%d", i) }
+func affectedGroupName(i int) string { return fmt.Sprintf("AffectedGroup%d", i) }
+
+func TestCollectGroupChange_PolicyLinked(t *testing.T) {
+ manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, groups, groupIDs[1])
+
+ groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, groups, groupIDs[1])
+
+ groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]})
+ assert.Empty(t, groups)
+}
+
+func TestCollectGroupChange_PolicyWithDirectPeerResource(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ SourceResource: types.Resource{ID: peerIDs[3], Type: types.ResourceTypePeer},
+ Destinations: []string{groupIDs[1]},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, groups, groupIDs[1])
+ assert.Contains(t, directPeers, peerIDs[3])
+}
+
+func TestCollectGroupChange_PolicyWithNonPeerResource_NoDirectPeers(t *testing.T) {
+ manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ SourceResource: types.Resource{ID: "some-domain", Type: types.ResourceTypeDomain},
+ Destinations: []string{groupIDs[1]},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, groups, groupIDs[1])
+ assert.Empty(t, directPeers, "non-peer resources should not produce direct peer IDs")
+}
+
+func TestCollectGroupChange_RouteLinked(t *testing.T) {
+ manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.0.0.0/24"),
+ route.IPv4Network,
+ nil,
+ "",
+ []string{groupIDs[0]},
+ "test route",
+ "testnet",
+ false,
+ 9999,
+ []string{groupIDs[1]},
+ []string{groupIDs[2]},
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, groups, groupIDs[1])
+ assert.Contains(t, groups, groupIDs[2])
+
+ groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, groups, groupIDs[1])
+ assert.Contains(t, groups, groupIDs[2])
+
+ groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[3]})
+ assert.Empty(t, groups)
+}
+
+func TestCollectGroupChange_RouteWithDirectPeer(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.1.0.0/24"),
+ route.IPv4Network,
+ nil,
+ peerIDs[4],
+ nil,
+ "test route peer",
+ "testnet2",
+ false,
+ 9999,
+ []string{groupIDs[1]},
+ nil,
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
+ assert.Contains(t, groups, groupIDs[1])
+ assert.Contains(t, directPeers, peerIDs[4])
+}
+
+func TestCollectGroupChange_NameServerGroupLinked(t *testing.T) {
+ manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.CreateNameServerGroup(ctx, accountID, "ns1", "NS Group 1",
+ []nbdns.NameServer{{
+ IP: netip.MustParseAddr("1.1.1.1"),
+ NSType: nbdns.UDPNameServerType,
+ Port: nbdns.DefaultDNSPort,
+ }},
+ []string{groupIDs[0]},
+ true, nil, true, userID, false,
+ )
+ require.NoError(t, err)
+
+ groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Contains(t, groups, groupIDs[0])
+
+ groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
+ assert.Empty(t, groups)
+}
+
+func TestCollectGroupChange_DNSSettingsLinked(t *testing.T) {
+ manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{
+ DisabledManagementGroups: []string{groupIDs[2]},
+ })
+ require.NoError(t, err)
+
+ groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]})
+ assert.Contains(t, groups, groupIDs[2])
+
+ groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Empty(t, groups)
+}
+
+func TestCollectGroupChange_NetworkRouterLinked(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ net1 := &networkTypes.Network{
+ ID: "net-test-1",
+ AccountID: accountID,
+ Name: "test-network",
+ }
+ err := manager.Store.SaveNetwork(ctx, net1)
+ require.NoError(t, err)
+
+ err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{
+ ID: "router1",
+ NetworkID: net1.ID,
+ AccountID: accountID,
+ PeerGroups: []string{groupIDs[0]},
+ Peer: peerIDs[3],
+ })
+ require.NoError(t, err)
+
+ groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, directPeers, peerIDs[3])
+
+ groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]})
+ assert.Empty(t, groups)
+ assert.Empty(t, directPeers)
+}
+
+func TestCollectGroupChange_NetworkRouterPeerOnlyNoGroups(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ net1 := &networkTypes.Network{
+ ID: "net-peer-only",
+ AccountID: accountID,
+ Name: "peer-only-network",
+ }
+ err := manager.Store.SaveNetwork(ctx, net1)
+ require.NoError(t, err)
+
+ // Router with only a direct peer, no PeerGroups
+ err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{
+ ID: "router-peer-only",
+ NetworkID: net1.ID,
+ AccountID: accountID,
+ Peer: peerIDs[4],
+ })
+ require.NoError(t, err)
+
+ // None of the groups should match since router has no PeerGroups
+ for i := 0; i < 5; i++ {
+ groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[i]})
+ assert.Empty(t, groups, "group%d should not match router with only direct peer", i)
+ assert.Empty(t, directPeers, "group%d should not produce direct peers", i)
+ }
+}
+
+func TestCollectGroupChange_MultipleEntities(t *testing.T) {
+ manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ _, err = manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.2.0.0/24"),
+ route.IPv4Network,
+ nil,
+ "",
+ []string{groupIDs[2]},
+ "multi route",
+ "multinet",
+ false,
+ 9999,
+ []string{groupIDs[3]},
+ nil,
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, groups, groupIDs[1])
+ assert.NotContains(t, groups, groupIDs[2])
+ assert.NotContains(t, groups, groupIDs[3])
+ assert.Empty(t, directPeers)
+
+ groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[3]})
+ assert.Contains(t, groups, groupIDs[2])
+ assert.Contains(t, groups, groupIDs[3])
+ assert.NotContains(t, groups, groupIDs[0])
+ assert.NotContains(t, groups, groupIDs[1])
+ assert.Empty(t, directPeers)
+}
+
+func TestCollectGroupChange_MultipleNameServerGroups_OnlyLinkedAffected(t *testing.T) {
+ manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ // Create two nameserver groups using different groups
+ _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-a", "NS-A",
+ []nbdns.NameServer{{
+ IP: netip.MustParseAddr("1.1.1.1"),
+ NSType: nbdns.UDPNameServerType,
+ Port: nbdns.DefaultDNSPort,
+ }},
+ []string{groupIDs[0]},
+ true, nil, true, userID, false,
+ )
+ require.NoError(t, err)
+
+ _, err = manager.CreateNameServerGroup(ctx, accountID, "ns-b", "NS-B",
+ []nbdns.NameServer{{
+ IP: netip.MustParseAddr("8.8.8.8"),
+ NSType: nbdns.UDPNameServerType,
+ Port: nbdns.DefaultDNSPort,
+ }},
+ []string{groupIDs[2]},
+ true, nil, true, userID, false,
+ )
+ require.NoError(t, err)
+
+ // Changing group0 should only find group0 (from ns-a), not group2 (from ns-b)
+ groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]})
+ assert.Contains(t, groups, groupIDs[0])
+ assert.NotContains(t, groups, groupIDs[2])
+
+ groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]})
+ assert.Contains(t, groups, groupIDs[2])
+ assert.NotContains(t, groups, groupIDs[0])
+
+ // Unrelated group
+ groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[4]})
+ assert.Empty(t, groups)
+}
+
+func TestResolveAffectedPeers_PolicyBetweenTwoGroups(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)
+
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)
+
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
+ assert.Empty(t, result)
+}
+
+func TestResolveAffectedPeers_PolicyThreeGroups(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0], groupIDs[1]},
+ Destinations: []string{groupIDs[2]},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result)
+}
+
+func TestResolveAffectedPeers_RoutePeerGroups(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.3.0.0/24"),
+ route.IPv4Network,
+ nil,
+ "",
+ []string{groupIDs[0]},
+ "test route",
+ "routenet",
+ false,
+ 9999,
+ []string{groupIDs[1]},
+ nil,
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)
+
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)
+
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
+ assert.Empty(t, result)
+}
+
+func TestResolveAffectedPeers_RouteWithDirectPeer(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.4.0.0/24"),
+ route.IPv4Network,
+ nil,
+ peerIDs[4],
+ nil,
+ "route with peer",
+ "routenet2",
+ false,
+ 9999,
+ []string{groupIDs[1]},
+ nil,
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]})
+ assert.ElementsMatch(t, []string{peerIDs[1], peerIDs[4]}, result)
+}
+
+func TestResolveAffectedPeers_RouteWithAccessControlGroups(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.7.0.0/24"),
+ route.IPv4Network,
+ nil,
+ "",
+ []string{groupIDs[0]},
+ "acl route",
+ "aclnet",
+ false,
+ 9999,
+ []string{groupIDs[1]},
+ []string{groupIDs[2]},
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ // peer2 is only in AccessControlGroups, still should be affected
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result)
+
+ // peer3 is unrelated
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[3]})
+ assert.Empty(t, result)
+}
+
+func TestResolveAffectedPeers_NetworkRouter(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ net1 := &networkTypes.Network{
+ ID: "net-test-2",
+ AccountID: accountID,
+ Name: "test-net",
+ }
+ err := manager.Store.SaveNetwork(ctx, net1)
+ require.NoError(t, err)
+
+ err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{
+ ID: "router-test",
+ NetworkID: net1.ID,
+ AccountID: accountID,
+ PeerGroups: []string{groupIDs[0]},
+ Peer: peerIDs[3],
+ })
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[3]}, result)
+}
+
+func TestResolveAffectedPeers_NameServerGroup(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-test", "NS Test",
+ []nbdns.NameServer{{
+ IP: netip.MustParseAddr("8.8.8.8"),
+ NSType: nbdns.UDPNameServerType,
+ Port: nbdns.DefaultDNSPort,
+ }},
+ []string{groupIDs[0]},
+ true, nil, true, userID, false,
+ )
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.Contains(t, result, peerIDs[0])
+}
+
+func TestResolveAffectedPeers_DNSSettings(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{
+ DisabledManagementGroups: []string{groupIDs[0]},
+ })
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.Contains(t, result, peerIDs[0])
+}
+
+func TestResolveAffectedPeers_PeerInMultipleGroups(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ err := manager.GroupAddPeer(ctx, accountID, groupIDs[1], peerIDs[0])
+ require.NoError(t, err)
+
+ _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[2]},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[1]},
+ Destinations: []string{groupIDs[3]},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ // peer0 is in group0 AND group1, so both policies apply
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2], peerIDs[3]}, result)
+}
+
+func TestResolveAffectedPeers_MultipleChangedPeers(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[2]},
+ Destinations: []string{groupIDs[3]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0], peerIDs[2]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2], peerIDs[3]}, result)
+}
+
+func TestResolveAffectedPeers_SharedGroupAcrossPolicyAndRoute(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ _, err = manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.5.0.0/24"),
+ route.IPv4Network,
+ nil,
+ "",
+ []string{groupIDs[2]},
+ "shared group route",
+ "sharednet",
+ false,
+ 9999,
+ []string{groupIDs[0]},
+ nil,
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ // group0 is shared: policy gives peer0+peer1, route gives peer0+peer2
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result)
+}
+
+func TestResolveAffectedPeers_NoDuplicates(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ err := manager.GroupAddPeer(ctx, accountID, groupIDs[1], peerIDs[0])
+ require.NoError(t, err)
+ err = manager.GroupAddPeer(ctx, accountID, groupIDs[2], peerIDs[0])
+ require.NoError(t, err)
+
+ _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0], groupIDs[1]},
+ Destinations: []string{groupIDs[2]},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ count := 0
+ for _, id := range result {
+ if id == peerIDs[0] {
+ count++
+ }
+ }
+ assert.Equal(t, 1, count, "peer0 should appear exactly once")
+}
+
+func TestCollectPostureCheckAffected_LinkedToPolicy(t *testing.T) {
+ manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ // Create the posture check in the store so the policy validation keeps the reference.
+ err := s.SavePostureChecks(ctx, &posture.Checks{
+ ID: "pc-1",
+ Name: "test-posture-check",
+ AccountID: accountID,
+ })
+ require.NoError(t, err)
+
+ policy, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ SourcePostureChecks: []string{"pc-1"},
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+ _ = policy
+
+ groups, directPeers := collectPostureCheckAffectedGroupsAndPeers(ctx, s, accountID, "pc-1")
+ assert.Contains(t, groups, groupIDs[0])
+ assert.Contains(t, groups, groupIDs[1])
+ assert.Empty(t, directPeers)
+
+ // Different posture check ID should not match
+ groups, directPeers = collectPostureCheckAffectedGroupsAndPeers(ctx, s, accountID, "pc-other")
+ assert.Empty(t, groups)
+ assert.Empty(t, directPeers)
+}
+
+func TestAffectedPeers_IsolatedPolicies(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[2]},
+ Destinations: []string{groupIDs[3]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)
+ assert.NotContains(t, result, peerIDs[2])
+ assert.NotContains(t, result, peerIDs[3])
+
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
+ assert.ElementsMatch(t, []string{peerIDs[2], peerIDs[3]}, result)
+ assert.NotContains(t, result, peerIDs[0])
+ assert.NotContains(t, result, peerIDs[1])
+
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[4]})
+ assert.Empty(t, result)
+}
+
+func TestAffectedPeers_IsolatedRouteAndPolicy(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{
+ Enabled: true,
+ Rules: []*types.PolicyRule{
+ {
+ Enabled: true,
+ Sources: []string{groupIDs[0]},
+ Destinations: []string{groupIDs[1]},
+ Bidirectional: true,
+ Action: types.PolicyTrafficActionAccept,
+ },
+ },
+ }, true)
+ require.NoError(t, err)
+
+ _, err = manager.CreateRoute(ctx, accountID,
+ netip.MustParsePrefix("10.6.0.0/24"),
+ route.IPv4Network,
+ nil,
+ "",
+ []string{groupIDs[2]},
+ "isolated route",
+ "isonet",
+ false,
+ 9999,
+ []string{groupIDs[3]},
+ nil,
+ true,
+ userID,
+ false,
+ false,
+ )
+ require.NoError(t, err)
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result)
+ assert.NotContains(t, result, peerIDs[2])
+ assert.NotContains(t, result, peerIDs[3])
+
+ result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]})
+ assert.ElementsMatch(t, []string{peerIDs[2], peerIDs[3]}, result)
+ assert.NotContains(t, result, peerIDs[0])
+ assert.NotContains(t, result, peerIDs[1])
+}
+
+func TestAffectedPeers_GroupUpdateOnlyAffectsLinkedPeers(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)
+ 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")
+ }
+ })
+}
+
+func TestAffectedPeers_UnlinkedGroupChange_NoUpdates(t *testing.T) {
+ manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]})
+ assert.Empty(t, result)
+}
+
+// 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
+
+ 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)
+ }
+
+ 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")
+ }
+ })
+}
+
+// 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
+
+ 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)
+ }
+
+ 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")
+ }
+ })
+}
+
+// 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
+
+ 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)
+ }
+
+ 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")
+ }
+ })
+}
+
+// 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
+
+ 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)
+ }
+
+ 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")
+ }
+ })
+}
+
+// TestAffectedPeers_UnlinkedGroupChange_NoUpdateIntegration tests the full integration:
+// 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
+
+ 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{
+ 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)
+ 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"},
+ })
+ 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")
+ }
+ })
+}
+
+// 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
+
+ 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)
+ }
+
+ 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")
+ }
+ })
+}
+
+// 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
+
+ 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)
+ }
+
+ 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")
+ }
+ })
+}
+
+// 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
+
+ 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)
+ }
+
+ 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")
+ }
+ })
+}
+
+// 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
+
+ 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: "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")
+ }
+ })
+}
+
+func addPeerToAccount(t *testing.T, manager *DefaultAccountManager, _, setupKeyKey string) *nbpeer.Peer {
+ t.Helper()
+
+ key, err := wgtypes.GeneratePrivateKey()
+ require.NoError(t, err)
+
+ peer, _, _, err := manager.AddPeer(context.Background(), "", setupKeyKey, "", &nbpeer.Peer{
+ Key: key.PublicKey().String(),
+ Meta: nbpeer.PeerSystemMeta{Hostname: key.PublicKey().String()},
+ }, false)
+ require.NoError(t, err)
+ return peer
+}
+
+// markPeerAsProxy flips an existing peer's ProxyMeta to mark it as an embedded
+// proxy peer in the given cluster.
+func markPeerAsProxy(t *testing.T, s store.Store, accountID, peerID, cluster string) {
+ t.Helper()
+ ctx := context.Background()
+ peer, err := s.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
+ require.NoError(t, err)
+ peer.ProxyMeta = nbpeer.ProxyMeta{Embedded: true, Cluster: cluster}
+ require.NoError(t, s.SavePeer(ctx, accountID, peer))
+}
+
+// createServiceWithTargets persists a service with the given cluster and targets
+// directly in the store, bypassing the proxy-service manager (which would also
+// run cluster derivation and trigger UpdateAccountPeers).
+func createServiceWithTargets(t *testing.T, s store.Store, accountID, cluster string, targets []*rpservice.Target) *rpservice.Service {
+ t.Helper()
+ svc := &rpservice.Service{
+ AccountID: accountID,
+ Name: fmt.Sprintf("svc-%s", cluster),
+ Domain: fmt.Sprintf("%s.example.com", cluster),
+ ProxyCluster: cluster,
+ Enabled: true,
+ Mode: "tcp",
+ Targets: targets,
+ }
+ svc.InitNewRecord()
+ for _, target := range targets {
+ target.AccountID = accountID
+ target.ServiceID = svc.ID
+ }
+ require.NoError(t, s.CreateService(context.Background(), svc))
+ return svc
+}
+
+func TestCollectAffectedFromProxyServices_TargetPeerChanged(t *testing.T) {
+ manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ cluster := "cluster-a"
+ markPeerAsProxy(t, s, accountID, peerIDs[0], cluster)
+
+ createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{
+ {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: true, Port: 80, Protocol: "tcp"},
+ })
+
+ _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[1]})
+ assert.Contains(t, directPeers, peerIDs[0], "proxy peer must be refreshed when its target peer changes")
+ assert.Contains(t, directPeers, peerIDs[1], "target peer must be refreshed")
+}
+
+func TestCollectAffectedFromProxyServices_ProxyPeerChanged(t *testing.T) {
+ manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ cluster := "cluster-a"
+ markPeerAsProxy(t, s, accountID, peerIDs[0], cluster)
+
+ createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{
+ {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: true, Port: 80, Protocol: "tcp"},
+ {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[2], Enabled: true, Port: 80, Protocol: "tcp"},
+ })
+
+ _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[0]})
+ assert.Contains(t, directPeers, peerIDs[0], "changed proxy peer is itself refreshed")
+ assert.Contains(t, directPeers, peerIDs[1], "target peer 1 must be refreshed when proxy peer changes")
+ assert.Contains(t, directPeers, peerIDs[2], "target peer 2 must be refreshed when proxy peer changes")
+}
+
+func TestCollectAffectedFromProxyServices_GroupContainingTargetPeerChanged(t *testing.T) {
+ manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ cluster := "cluster-a"
+ markPeerAsProxy(t, s, accountID, peerIDs[0], cluster)
+
+ createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{
+ {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: true, Port: 80, Protocol: "tcp"},
+ })
+
+ _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, []string{groupIDs[1]}, nil)
+ assert.Contains(t, directPeers, peerIDs[0], "proxy peer must be refreshed when a group containing its target peer changes")
+ assert.Contains(t, directPeers, peerIDs[1], "target peer must be refreshed")
+}
+
+func TestCollectAffectedFromProxyServices_DisabledServiceStillMatches(t *testing.T) {
+ manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ cluster := "cluster-a"
+ markPeerAsProxy(t, s, accountID, peerIDs[0], cluster)
+
+ svc := &rpservice.Service{
+ AccountID: accountID,
+ Name: "disabled-svc",
+ Domain: "disabled.example.com",
+ ProxyCluster: cluster,
+ Enabled: false,
+ Mode: "tcp",
+ Targets: []*rpservice.Target{
+ {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: false, Port: 80, Protocol: "tcp"},
+ },
+ }
+ svc.InitNewRecord()
+ for _, target := range svc.Targets {
+ target.AccountID = accountID
+ target.ServiceID = svc.ID
+ }
+ require.NoError(t, s.CreateService(ctx, svc))
+
+ _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[1]})
+ assert.Contains(t, directPeers, peerIDs[0], "disabled service should still trigger a refresh so peers are ready when re-enabled")
+ assert.Contains(t, directPeers, peerIDs[1], "disabled target should still trigger a refresh")
+}
+
+func TestCollectAffectedFromProxyServices_NonPeerTargetType(t *testing.T) {
+ manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t)
+ ctx := context.Background()
+
+ cluster := "cluster-a"
+ markPeerAsProxy(t, s, accountID, peerIDs[0], cluster)
+
+ createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{
+ {TargetType: rpservice.TargetTypeHost, TargetId: "10.0.0.1", Host: "10.0.0.1", Enabled: true, Port: 80, Protocol: "tcp"},
+ })
+
+ _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[0]})
+ assert.Contains(t, directPeers, peerIDs[0], "host target service still refreshes its proxy peer when the proxy peer changes")
+ assert.NotContains(t, directPeers, "10.0.0.1", "non-peer target ids must not appear as affected peer IDs")
+}
diff --git a/management/server/affectedpeers/resolver.go b/management/server/affectedpeers/resolver.go
new file mode 100644
index 000000000..4ef986345
--- /dev/null
+++ b/management/server/affectedpeers/resolver.go
@@ -0,0 +1,825 @@
+// Package affectedpeers computes which peers' network maps a change touches, so
+// only those peers are refreshed instead of the whole account.
+//
+// Two phases keep the dependency walk off the write transaction:
+// - Load: reads the needed collections. Call INSIDE the mutating tx (consistent,
+// and before a delete/removal severs the old state).
+// - Snapshot.Expand: in-memory walk, no store access. Run AFTER the tx commits.
+//
+// Enabled is never consulted: toggling it is itself an observable change.
+package affectedpeers
+
+import (
+ "context"
+
+ log "github.com/sirupsen/logrus"
+
+ nbdns "github.com/netbirdio/netbird/dns"
+ rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
+ 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"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/types"
+ "github.com/netbirdio/netbird/route"
+)
+
+// Snapshot is an in-memory view of the collections needed to expand a Change.
+// Loaded in-tx, walked by Expand after commit. Only the collections the Change
+// can touch are loaded; the rest stay nil (see Load).
+type Snapshot struct {
+ policies []*types.Policy
+ routes []*route.Route
+ nsGroups []*nbdns.NameServerGroup
+ dnsSettings *types.DNSSettings
+ routers []*routerTypes.NetworkRouter
+ resources []*resourceTypes.NetworkResource
+ services []*rpservice.Service
+ proxyByCluster map[string][]string
+ groups map[string]*types.Group
+ groupPeers map[string]map[string]struct{} // groupID -> member peer IDs
+}
+
+// Load reads the collections a Change requires, inside the caller's tx. It mirrors
+// Expand's walker preconditions, loading only what the change can touch.
+func Load(ctx context.Context, s store.Store, accountID string, c Change) (*Snapshot, error) {
+ snap := &Snapshot{}
+ if c.isEmpty() {
+ return snap, nil
+ }
+
+ if err := snap.loadCollections(ctx, s, accountID, c); err != nil {
+ return nil, err
+ }
+ if err := snap.loadGroupIndex(ctx, s, accountID); err != nil {
+ return nil, err
+ }
+
+ return snap, nil
+}
+
+// loadCollections reads the policy/route/nameserver/dns/router/resource/proxy
+// collections a Change can touch, gated to what the walk needs.
+func (snap *Snapshot) loadCollections(ctx context.Context, s store.Store, accountID string, c Change) error {
+ hasGroupOrPeerChange := len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.Resources) > 0
+ hasNetworkObject := len(c.Routers) > 0 || len(c.Resources) > 0 || len(c.Networks) > 0
+ // the resource<->router bridge can fire for any of these
+ needsRoutersResources := hasGroupOrPeerChange || len(c.PostureCheckIDs) > 0 || len(c.Policies) > 0 || hasNetworkObject
+
+ if needsRoutersResources {
+ if err := snap.loadPolicyRoutersResources(ctx, s, accountID); err != nil {
+ return err
+ }
+ }
+ if hasGroupOrPeerChange {
+ if err := snap.loadRoutesAndProxy(ctx, s, accountID); err != nil {
+ return err
+ }
+ }
+ if len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 {
+ if err := snap.loadDNS(ctx, s, accountID); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// loadPolicyRoutersResources loads the policies plus the routers and resources
+// the resource<->router bridge walks.
+func (snap *Snapshot) loadPolicyRoutersResources(ctx context.Context, s store.Store, accountID string) error {
+ var err error
+ if snap.policies, err = s.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID); err != nil {
+ return err
+ }
+ if snap.routers, err = s.GetNetworkRoutersByAccountID(ctx, store.LockingStrengthNone, accountID); err != nil {
+ return err
+ }
+ snap.resources, err = s.GetNetworkResourcesByAccountID(ctx, store.LockingStrengthNone, accountID)
+ return err
+}
+
+// loadRoutesAndProxy loads the routes and the embedded-proxy services index.
+func (snap *Snapshot) loadRoutesAndProxy(ctx context.Context, s store.Store, accountID string) error {
+ var err error
+ if snap.routes, err = s.GetAccountRoutes(ctx, store.LockingStrengthNone, accountID); err != nil {
+ return err
+ }
+ return snap.loadProxyServices(ctx, s, accountID)
+}
+
+// loadDNS loads the nameserver groups 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
+ }
+ snap.dnsSettings, err = s.GetAccountDNSSettings(ctx, store.LockingStrengthNone, accountID)
+ return err
+}
+
+// loadProxyServices loads the embedded-proxy cluster index, and the services only
+// when the account actually has embedded proxy peers.
+func (snap *Snapshot) loadProxyServices(ctx context.Context, s store.Store, accountID string) error {
+ var err error
+ if snap.proxyByCluster, err = s.GetEmbeddedProxyPeerIDsByCluster(ctx, accountID); err != nil {
+ return err
+ }
+ if len(snap.proxyByCluster) == 0 {
+ return nil
+ }
+ snap.services, err = s.GetAccountServices(ctx, store.LockingStrengthNone, accountID)
+ return err
+}
+
+// loadGroupIndex loads all groups (for group.Resources) and builds the
+// group->member-peers index. Always needed: the bridge resolves group.Resources
+// and Expand maps groups to member peers.
+func (snap *Snapshot) loadGroupIndex(ctx context.Context, s store.Store, accountID string) error {
+ groups, err := s.GetAccountGroups(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return err
+ }
+ snap.groups = make(map[string]*types.Group, len(groups))
+ snap.groupPeers = make(map[string]map[string]struct{}, len(groups))
+ for _, g := range groups {
+ snap.groups[g.ID] = g
+ members := make(map[string]struct{}, len(g.Peers))
+ for _, pID := range g.Peers {
+ members[pID] = struct{}{}
+ }
+ snap.groupPeers[g.ID] = members
+ }
+ return nil
+}
+
+// Change describes what changed in an account.
+type Change struct {
+ ChangedGroupIDs []string
+ ChangedPeerIDs []string
+ Policies []*types.Policy
+ Routes []*route.Route
+ Routers []*routerTypes.NetworkRouter
+ Resources []*resourceTypes.NetworkResource
+ Networks []*networkTypes.Network
+ PostureCheckIDs []string
+
+ // DistributionGroupIDs are groups whose members are directly affected, with no
+ // dependency walk — the change distributes config to the groups' member peers
+ // only (nameserver groups, DNS DisabledManagementGroups), not through the
+ // policy/route reachability graph. Pass old∪new so both states refresh.
+ DistributionGroupIDs []string
+
+ // RemovedPeersByGroup: peers that left a group, keyed by that group. They are no
+ // longer in the group's member index but still lose its reachability, so they are
+ // folded in — but only when the group is linked (an unlinked group has no map
+ // impact), matching how current members are handled.
+ RemovedPeersByGroup map[string][]string
+}
+
+func (c Change) isEmpty() bool {
+ return len(c.ChangedGroupIDs) == 0 &&
+ len(c.ChangedPeerIDs) == 0 &&
+ len(c.Policies) == 0 &&
+ len(c.Routes) == 0 &&
+ len(c.Routers) == 0 &&
+ len(c.Resources) == 0 &&
+ len(c.Networks) == 0 &&
+ len(c.PostureCheckIDs) == 0 &&
+ len(c.DistributionGroupIDs) == 0 &&
+ len(c.RemovedPeersByGroup) == 0
+}
+
+// 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.
+func (snap *Snapshot) Expand(ctx context.Context, accountID string, c Change) []string {
+ if c.isEmpty() {
+ return nil
+ }
+ r := newResolver(ctx, snap, accountID, c)
+ log.WithContext(ctx).Tracef("affectedpeers expand start: account=%s changedGroups=%v changedPeers=%v policies=%d routes=%d routers=%d resources=%d networks=%d postureChecks=%v distributionGroups=%v",
+ accountID, c.ChangedGroupIDs, c.ChangedPeerIDs, len(c.Policies), len(c.Routes), len(c.Routers), len(c.Resources), len(c.Networks), c.PostureCheckIDs, c.DistributionGroupIDs)
+ r.walk()
+ return r.expand()
+}
+
+// Collect returns the affected group and direct-peer IDs without expanding groups
+// to members. Test-only introspection; use Resolve otherwise.
+func Collect(ctx context.Context, s store.Store, accountID string, c Change) (groupIDs []string, directPeerIDs []string) {
+ if c.isEmpty() {
+ return nil, nil
+ }
+ snap, err := Load(ctx, s, accountID, c)
+ if err != nil {
+ log.WithContext(ctx).Errorf("failed to load snapshot for affected peers collect: %v", err)
+ return nil, nil
+ }
+ r := newResolver(ctx, snap, accountID, c)
+ r.walk()
+ return setToSlice(r.groupSet), setToSlice(r.peerSet)
+}
+
+func newResolver(ctx context.Context, snap *Snapshot, accountID string, c Change) *resolver {
+ r := &resolver{
+ ctx: ctx,
+ snap: snap,
+ accountID: accountID,
+ change: c,
+ changedGroupSet: toSet(c.ChangedGroupIDs),
+ changedPeerSet: toSet(c.ChangedPeerIDs),
+ groupSet: make(map[string]struct{}),
+ peerSet: make(map[string]struct{}),
+ networkIDs: make(map[string]struct{}),
+ }
+ // Resolve each changed peer to its groups here so callers pass only ChangedPeerIDs.
+ r.seedChangedGroupsFromPeers()
+ r.matchedPolicies = append(r.matchedPolicies, c.Policies...)
+ return r
+}
+
+// seedChangedGroupsFromPeers adds each changed peer's groups to changedGroupSet so
+// the group-driven walkers fire for memberships, not just direct peer references.
+func (r *resolver) seedChangedGroupsFromPeers() {
+ if len(r.changedPeerSet) == 0 {
+ return
+ }
+ for groupID, members := range r.snap.groupPeers {
+ for pID := range r.changedPeerSet {
+ if _, ok := members[pID]; ok {
+ r.changedGroupSet[groupID] = struct{}{}
+ break
+ }
+ }
+ }
+}
+
+func (r *resolver) walk() {
+ r.collectFromExplicitPolicies()
+ r.collectFromExplicitRoutes(r.change.Routes)
+ r.collectFromExplicitRouters(r.change.Routers)
+ r.collectFromExplicitResources(r.change.Resources)
+ r.collectFromExplicitNetworks(r.change.Networks)
+ r.collectFromPostureChecks(r.change.PostureCheckIDs)
+
+ // Distribution groups (nameserver/DNS) affect only their member peers: fold them
+ // straight into groupSet so expand() maps them to members, without the policy/
+ // route walk that changedGroupSet would trigger.
+ addAll(r.groupSet, r.change.DistributionGroupIDs)
+
+ if len(r.changedGroupSet) > 0 || len(r.changedPeerSet) > 0 {
+ r.collectFromPolicies()
+ r.collectFromRoutes()
+ r.collectFromNameServers()
+ r.collectFromDNSSettings()
+ r.collectFromNetworkRouters()
+ r.collectFromProxyServices()
+ }
+
+ r.collectResourceRouterBridge()
+}
+
+type resolver struct {
+ ctx context.Context
+ snap *Snapshot
+ accountID string
+ change Change
+
+ changedGroupSet map[string]struct{}
+ changedPeerSet map[string]struct{}
+
+ groupSet map[string]struct{}
+ peerSet map[string]struct{}
+
+ matchedPolicies []*types.Policy
+ networkIDs map[string]struct{}
+}
+
+func (r *resolver) policies() []*types.Policy { return r.snap.policies }
+
+func (r *resolver) networkResources() []*resourceTypes.NetworkResource { return r.snap.resources }
+
+func (r *resolver) networkRouters() []*routerTypes.NetworkRouter { return r.snap.routers }
+
+// peerIDsForGroups maps a group set to its member peer IDs via the preloaded index.
+func (r *resolver) peerIDsForGroups(groupSet map[string]struct{}) []string {
+ seen := make(map[string]struct{})
+ var ids []string
+ for gID := range groupSet {
+ for pID := range r.snap.groupPeers[gID] {
+ if _, ok := seen[pID]; ok {
+ continue
+ }
+ seen[pID] = struct{}{}
+ ids = append(ids, pID)
+ }
+ }
+ return ids
+}
+
+func (r *resolver) expand() []string {
+ peerIDs := r.peerIDsForGroups(r.groupSet)
+
+ log.WithContext(r.ctx).Tracef("affectedpeers expand: account=%s affectedGroups=%v -> %d group-member peers; direct peers=%v",
+ r.accountID, setToSlice(r.groupSet), len(peerIDs), setToSlice(r.peerSet))
+
+ seen := make(map[string]struct{}, len(peerIDs))
+ for _, id := range peerIDs {
+ seen[id] = struct{}{}
+ }
+ for id := range r.peerSet {
+ if _, ok := seen[id]; !ok {
+ peerIDs = append(peerIDs, id)
+ seen[id] = struct{}{}
+ }
+ }
+
+ // Fold in removed peers only when their group is linked (in groupSet).
+ for groupID, removed := range r.change.RemovedPeersByGroup {
+ if _, linked := r.groupSet[groupID]; !linked {
+ continue
+ }
+ for _, id := range removed {
+ if _, ok := seen[id]; !ok {
+ peerIDs = append(peerIDs, id)
+ seen[id] = struct{}{}
+ log.WithContext(r.ctx).Tracef("affectedpeers expand: removed peer %s from linked group %s -> affected", id, groupID)
+ }
+ }
+ }
+
+ log.WithContext(r.ctx).Tracef("affectedpeers expand done: account=%s -> %d affected peers: %v", r.accountID, len(peerIDs), peerIDs)
+ return peerIDs
+}
+
+func (r *resolver) collectFromExplicitPolicies() {
+ for _, policy := range r.matchedPolicies {
+ if policy == nil {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromExplicitPolicies: changed policy %s (%s) -> folding rule groups %v + direct peers",
+ policy.ID, policy.Name, policy.RuleGroups())
+ addAll(r.groupSet, policy.RuleGroups())
+ collectPolicyDirectPeers(policy, r.peerSet)
+ }
+}
+
+func (r *resolver) collectFromExplicitRoutes(routes []*route.Route) {
+ for _, rt := range routes {
+ if rt == nil {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromExplicitRoutes: changed route %s -> folding groups=%v peerGroups=%v accessControlGroups=%v peer=%q",
+ rt.ID, rt.Groups, rt.PeerGroups, rt.AccessControlGroups, rt.Peer)
+ addAll(r.groupSet, rt.Groups, rt.PeerGroups, rt.AccessControlGroups)
+ if rt.Peer != "" {
+ r.peerSet[rt.Peer] = struct{}{}
+ }
+ }
+}
+
+// collectFromExplicitRouters folds changed routers' peers and marks their networks
+// for the bridge. Passing the old router keeps a repointed router's previous peers
+// affected without a post-commit read.
+func (r *resolver) collectFromExplicitRouters(routers []*routerTypes.NetworkRouter) {
+ for _, router := range routers {
+ if router == nil {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromExplicitRouters: changed router %s on network %s -> folding peerGroups=%v peer=%q and marking network for source bridge",
+ router.ID, router.NetworkID, router.PeerGroups, router.Peer)
+ addAll(r.groupSet, router.PeerGroups)
+ if router.Peer != "" {
+ r.peerSet[router.Peer] = struct{}{}
+ }
+ if router.NetworkID != "" {
+ r.networkIDs[router.NetworkID] = struct{}{}
+ }
+ }
+}
+
+// collectFromExplicitResources marks changed resources' networks for the bridge and
+// treats their group IDs as changed, so policies targeting the resource via a
+// now-detached (old) group still refresh.
+func (r *resolver) collectFromExplicitResources(resources []*resourceTypes.NetworkResource) {
+ for _, resource := range resources {
+ if resource == nil {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromExplicitResources: changed resource %s on network %s -> marking network for bridge and treating groups %v as changed",
+ resource.ID, resource.NetworkID, resource.GroupIDs)
+ addAll(r.changedGroupSet, resource.GroupIDs)
+ if resource.NetworkID != "" {
+ r.networkIDs[resource.NetworkID] = struct{}{}
+ }
+ }
+}
+
+// collectFromExplicitNetworks marks changed networks for the bridge. A network has
+// no groups/peers of its own.
+func (r *resolver) collectFromExplicitNetworks(networks []*networkTypes.Network) {
+ for _, network := range networks {
+ if network == nil {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromExplicitNetworks: changed network %s -> marking for bridge", network.ID)
+ if network.ID != "" {
+ r.networkIDs[network.ID] = struct{}{}
+ }
+ }
+}
+
+func (r *resolver) collectFromPostureChecks(postureCheckIDs []string) {
+ if len(postureCheckIDs) == 0 {
+ return
+ }
+ ids := toSet(postureCheckIDs)
+ for _, policy := range r.policies() {
+ if !policyReferencesPostureChecks(policy, ids) {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromPostureChecks: policy %s (%s) references changed posture checks %v -> folding rule groups %v + direct peers",
+ policy.ID, policy.Name, postureCheckIDs, policy.RuleGroups())
+ addAll(r.groupSet, policy.RuleGroups())
+ collectPolicyDirectPeers(policy, r.peerSet)
+ r.matchedPolicies = append(r.matchedPolicies, policy)
+ }
+}
+
+func (r *resolver) collectFromPolicies() {
+ for _, policy := range r.policies() {
+ matchedByGroup := policyReferencesGroups(policy, r.changedGroupSet)
+ matchedByPeer := len(r.changedPeerSet) > 0 && policyReferencesDirectPeers(policy, r.changedPeerSet)
+ if !matchedByGroup && !matchedByPeer {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromPolicies: policy %s (%s) matched (byGroup=%t byPeer=%t) -> folding rule groups %v + direct peers",
+ policy.ID, policy.Name, matchedByGroup, matchedByPeer, policy.RuleGroups())
+ addAll(r.groupSet, policy.RuleGroups())
+ collectPolicyDirectPeers(policy, r.peerSet)
+ r.matchedPolicies = append(r.matchedPolicies, policy)
+ }
+}
+
+func (r *resolver) collectFromRoutes() {
+ for _, rt := range r.snap.routes {
+ matchedByGroup := anyInSet(rt.Groups, r.changedGroupSet) || anyInSet(rt.PeerGroups, r.changedGroupSet) || anyInSet(rt.AccessControlGroups, r.changedGroupSet)
+ matchedByPeer := rt.Peer != "" && len(r.changedPeerSet) > 0 && isInSet(rt.Peer, r.changedPeerSet)
+ if !matchedByGroup && !matchedByPeer {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromRoutes: route %s matched (byGroup=%t byPeer=%t) -> folding groups=%v peerGroups=%v accessControlGroups=%v peer=%q",
+ rt.ID, matchedByGroup, matchedByPeer, rt.Groups, rt.PeerGroups, rt.AccessControlGroups, rt.Peer)
+ addAll(r.groupSet, rt.Groups, rt.PeerGroups, rt.AccessControlGroups)
+ if rt.Peer != "" {
+ r.peerSet[rt.Peer] = struct{}{}
+ }
+ }
+}
+
+func (r *resolver) collectFromNameServers() {
+ if len(r.changedGroupSet) == 0 {
+ return
+ }
+ for _, ns := range r.snap.nsGroups {
+ if anyInSet(ns.Groups, r.changedGroupSet) {
+ log.WithContext(r.ctx).Tracef("collectFromNameServers: nameserver group %s references a changed group -> folding its groups %v", ns.ID, ns.Groups)
+ addAll(r.groupSet, ns.Groups)
+ }
+ }
+}
+
+func (r *resolver) collectFromDNSSettings() {
+ if len(r.changedGroupSet) == 0 || r.snap.dnsSettings == nil {
+ return
+ }
+ for _, gID := range r.snap.dnsSettings.DisabledManagementGroups {
+ if _, ok := r.changedGroupSet[gID]; ok {
+ log.WithContext(r.ctx).Tracef("collectFromDNSSettings: changed group %s is in DisabledManagementGroups -> folding it", gID)
+ r.groupSet[gID] = struct{}{}
+ }
+ }
+}
+
+func (r *resolver) collectFromNetworkRouters() {
+ for _, router := range r.networkRouters() {
+ matchedByGroup := anyInSet(router.PeerGroups, r.changedGroupSet)
+ matchedByPeer := router.Peer != "" && len(r.changedPeerSet) > 0 && isInSet(router.Peer, r.changedPeerSet)
+ if !matchedByGroup && !matchedByPeer {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromNetworkRouters: router %s on network %s matched (byGroup=%t byPeer=%t) -> folding peerGroups=%v peer=%q and marking network for source bridge",
+ router.ID, router.NetworkID, matchedByGroup, matchedByPeer, router.PeerGroups, router.Peer)
+ addAll(r.groupSet, router.PeerGroups)
+ if router.Peer != "" {
+ r.peerSet[router.Peer] = struct{}{}
+ }
+ r.networkIDs[router.NetworkID] = struct{}{}
+ }
+}
+
+func (r *resolver) collectFromProxyServices() {
+ if len(r.snap.proxyByCluster) == 0 || len(r.snap.services) == 0 {
+ return
+ }
+ services, proxyByCluster := r.snap.services, r.snap.proxyByCluster
+
+ expanded := r.expandChangedPeersWithGroups()
+
+ for _, svc := range services {
+ if svc == nil {
+ continue
+ }
+ proxyPeers := proxyByCluster[svc.ProxyCluster]
+ if len(proxyPeers) == 0 {
+ continue
+ }
+ matchedByPeer := serviceMatchesChangedPeers(svc, proxyPeers, expanded)
+ matchedByAccessGroup := anyInSet(svc.AccessGroups, r.changedGroupSet)
+ if !matchedByPeer && !matchedByAccessGroup {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("collectFromProxyServices: service %s (cluster=%s) matched (byProxyOrTargetPeer=%t byAccessGroup=%t) -> folding %d proxy peers, peer targets and access groups %v",
+ svc.ID, svc.ProxyCluster, matchedByPeer, matchedByAccessGroup, len(proxyPeers), svc.AccessGroups)
+ for _, pid := range proxyPeers {
+ r.peerSet[pid] = struct{}{}
+ }
+ for _, target := range svc.Targets {
+ if target.TargetType == rpservice.TargetTypePeer && target.TargetId != "" {
+ r.peerSet[target.TargetId] = struct{}{}
+ }
+ }
+ addAll(r.groupSet, svc.AccessGroups)
+ }
+}
+
+func (r *resolver) expandChangedPeersWithGroups() map[string]struct{} {
+ if len(r.changedGroupSet) == 0 {
+ return r.changedPeerSet
+ }
+ ids := r.peerIDsForGroups(r.changedGroupSet)
+ if len(ids) == 0 {
+ return r.changedPeerSet
+ }
+ merged := make(map[string]struct{}, len(r.changedPeerSet)+len(ids))
+ for id := range r.changedPeerSet {
+ merged[id] = struct{}{}
+ }
+ for _, id := range ids {
+ merged[id] = struct{}{}
+ }
+ return merged
+}
+
+// collectResourceRouterBridge crosses between source peers and routing peers, which
+// are reachable only via resource -> network -> router, not through the policy's own
+// groups: source -> router (targeted resources' networks), then router -> source.
+func (r *resolver) collectResourceRouterBridge() {
+ r.bridgeSourceToRouters()
+ r.bridgeRoutersToSources()
+}
+
+func (r *resolver) bridgeSourceToRouters() {
+ resourceIDs := r.policyDestinationResourceIDs(r.matchedPolicies...)
+ if len(resourceIDs) == 0 {
+ return
+ }
+
+ networkIDs := r.resourceNetworkIDs(resourceIDs)
+ log.WithContext(r.ctx).Tracef("bridgeSourceToRouters: targeted resources %v -> networks %v (their routers become affected via the router->source pass)",
+ setToSlice(resourceIDs), setToSlice(networkIDs))
+ for id := range networkIDs {
+ r.networkIDs[id] = struct{}{}
+ }
+}
+
+func (r *resolver) bridgeRoutersToSources() {
+ if len(r.networkIDs) == 0 {
+ return
+ }
+
+ log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: affected networks %v -> folding their routing peers and the source peers of policies targeting their resources",
+ setToSlice(r.networkIDs))
+
+ r.foldRoutersOnNetworks(r.networkIDs)
+
+ resourceIDs := make(map[string]struct{})
+ for _, resource := range r.networkResources() {
+ if _, ok := r.networkIDs[resource.NetworkID]; ok {
+ resourceIDs[resource.ID] = struct{}{}
+ }
+ }
+ if len(resourceIDs) == 0 {
+ return
+ }
+
+ for _, policy := range r.policies() {
+ if r.policyTargetsResources(policy, resourceIDs) {
+ log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: policy %s (%s) targets an affected-network resource -> folding its source groups/peers", policy.ID, policy.Name)
+ collectPolicySources(policy, r.groupSet, r.peerSet)
+ }
+ }
+}
+
+func (r *resolver) foldRoutersOnNetworks(networkIDs map[string]struct{}) {
+ for _, router := range r.networkRouters() {
+ if _, ok := networkIDs[router.NetworkID]; !ok {
+ continue
+ }
+ log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: router %s serves affected network %s -> folding peerGroups=%v peer=%q",
+ router.ID, router.NetworkID, router.PeerGroups, router.Peer)
+ addAll(r.groupSet, router.PeerGroups)
+ if router.Peer != "" {
+ r.peerSet[router.Peer] = struct{}{}
+ }
+ }
+}
+
+func (r *resolver) resourceNetworkIDs(resourceIDs map[string]struct{}) map[string]struct{} {
+ networkIDs := make(map[string]struct{})
+ for _, resource := range r.networkResources() {
+ if _, ok := resourceIDs[resource.ID]; ok {
+ networkIDs[resource.NetworkID] = struct{}{}
+ }
+ }
+ return networkIDs
+}
+
+func (r *resolver) policyTargetsResources(policy *types.Policy, resourceIDs map[string]struct{}) bool {
+ if policy == nil {
+ return false
+ }
+ destGroupSet := make(map[string]struct{})
+ for _, rule := range policy.Rules {
+ if rule.DestinationResource.Type != types.ResourceTypePeer && isInSet(rule.DestinationResource.ID, resourceIDs) {
+ return true
+ }
+ for _, gID := range rule.Destinations {
+ destGroupSet[gID] = struct{}{}
+ }
+ }
+ if len(destGroupSet) == 0 {
+ return false
+ }
+ for gID := range destGroupSet {
+ group := r.snap.groups[gID]
+ if group == nil {
+ continue
+ }
+ for _, res := range group.Resources {
+ if isInSet(res.ID, resourceIDs) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func (r *resolver) policyDestinationResourceIDs(policies ...*types.Policy) map[string]struct{} {
+ resourceIDs := make(map[string]struct{})
+ destGroupSet := collectPolicyDestinations(resourceIDs, policies...)
+ r.addGroupResourceIDs(destGroupSet, resourceIDs)
+ return resourceIDs
+}
+
+// collectPolicyDestinations adds direct destination resource IDs to resourceIDs and
+// returns the referenced destination group IDs.
+func collectPolicyDestinations(resourceIDs map[string]struct{}, policies ...*types.Policy) map[string]struct{} {
+ destGroupSet := make(map[string]struct{})
+ for _, policy := range policies {
+ if policy == nil {
+ continue
+ }
+ for _, rule := range policy.Rules {
+ addAll(destGroupSet, rule.Destinations)
+ if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID != "" {
+ resourceIDs[rule.DestinationResource.ID] = struct{}{}
+ }
+ }
+ }
+ return destGroupSet
+}
+
+// addGroupResourceIDs folds the resource IDs of the given groups into resourceIDs.
+func (r *resolver) addGroupResourceIDs(groupIDs map[string]struct{}, resourceIDs map[string]struct{}) {
+ for gID := range groupIDs {
+ group := r.snap.groups[gID]
+ if group == nil {
+ continue
+ }
+ for _, res := range group.Resources {
+ if res.ID != "" {
+ resourceIDs[res.ID] = struct{}{}
+ }
+ }
+ }
+}
+
+func collectPolicyDirectPeers(policy *types.Policy, peerSet map[string]struct{}) {
+ for _, rule := range policy.Rules {
+ if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" {
+ peerSet[rule.SourceResource.ID] = struct{}{}
+ }
+ if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" {
+ peerSet[rule.DestinationResource.ID] = struct{}{}
+ }
+ }
+}
+
+func collectPolicySources(policy *types.Policy, groupSet, peerSet map[string]struct{}) {
+ for _, rule := range policy.Rules {
+ addAll(groupSet, rule.Sources)
+ if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" {
+ peerSet[rule.SourceResource.ID] = struct{}{}
+ }
+ }
+}
+
+func policyReferencesGroups(policy *types.Policy, groupSet map[string]struct{}) bool {
+ for _, rule := range policy.Rules {
+ if anyInSet(rule.Sources, groupSet) || anyInSet(rule.Destinations, groupSet) {
+ return true
+ }
+ }
+ return false
+}
+
+func policyReferencesDirectPeers(policy *types.Policy, changedSet map[string]struct{}) bool {
+ for _, rule := range policy.Rules {
+ if isDirectPeerInSet(rule.SourceResource, changedSet) || isDirectPeerInSet(rule.DestinationResource, changedSet) {
+ return true
+ }
+ }
+ return false
+}
+
+func policyReferencesPostureChecks(policy *types.Policy, ids map[string]struct{}) bool {
+ for _, id := range policy.SourcePostureChecks {
+ if _, ok := ids[id]; ok {
+ return true
+ }
+ }
+ return false
+}
+
+func isDirectPeerInSet(res types.Resource, set map[string]struct{}) bool {
+ if res.Type != types.ResourceTypePeer || res.ID == "" {
+ return false
+ }
+ _, ok := set[res.ID]
+ return ok
+}
+
+func serviceMatchesChangedPeers(svc *rpservice.Service, proxyPeers []string, changedPeers map[string]struct{}) bool {
+ for _, pid := range proxyPeers {
+ if _, ok := changedPeers[pid]; ok {
+ return true
+ }
+ }
+ for _, target := range svc.Targets {
+ if target.TargetType != rpservice.TargetTypePeer || target.TargetId == "" {
+ continue
+ }
+ if _, ok := changedPeers[target.TargetId]; ok {
+ return true
+ }
+ }
+ return false
+}
+
+func anyInSet(ids []string, set map[string]struct{}) bool {
+ for _, id := range ids {
+ if _, ok := set[id]; ok {
+ return true
+ }
+ }
+ return false
+}
+
+func isInSet(id string, set map[string]struct{}) bool {
+ _, ok := set[id]
+ return ok
+}
+
+func addAll(set map[string]struct{}, slices ...[]string) {
+ for _, s := range slices {
+ for _, id := range s {
+ set[id] = struct{}{}
+ }
+ }
+}
+
+func toSet(ids []string) map[string]struct{} {
+ set := make(map[string]struct{}, len(ids))
+ for _, id := range ids {
+ set[id] = struct{}{}
+ }
+ return set
+}
+
+func setToSlice(set map[string]struct{}) []string {
+ s := make([]string, 0, len(set))
+ for id := range set {
+ s = append(s, id)
+ }
+ return s
+}
diff --git a/management/server/affectedpeers/resolver_test.go b/management/server/affectedpeers/resolver_test.go
new file mode 100644
index 000000000..dcd304a56
--- /dev/null
+++ b/management/server/affectedpeers/resolver_test.go
@@ -0,0 +1,140 @@
+package affectedpeers
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
+ networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
+ "github.com/netbirdio/netbird/management/server/types"
+)
+
+// policyGroupsAndPeers mirrors the explicit-policy extraction (RuleGroups +
+// direct peers) the resolver folds in, for asserting the pure logic.
+func policyGroupsAndPeers(policies ...*types.Policy) (groups []string, peers []string) {
+ peerSet := map[string]struct{}{}
+ for _, p := range policies {
+ if p == nil {
+ continue
+ }
+ groups = append(groups, p.RuleGroups()...)
+ collectPolicyDirectPeers(p, peerSet)
+ }
+ for id := range peerSet {
+ peers = append(peers, id)
+ }
+ return groups, peers
+}
+
+func TestPolicyGroupsAndPeers_Basic(t *testing.T) {
+ policy := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g1", "g2"}, Destinations: []string{"g3"}}}}
+ groups, peers := policyGroupsAndPeers(policy)
+ assert.ElementsMatch(t, []string{"g1", "g2", "g3"}, groups)
+ assert.Empty(t, peers)
+}
+
+func TestPolicyGroupsAndPeers_WithPeerResources(t *testing.T) {
+ policy := &types.Policy{Rules: []*types.PolicyRule{{
+ Sources: []string{"g1"},
+ SourceResource: types.Resource{ID: "p1", Type: types.ResourceTypePeer},
+ Destinations: []string{"g2"},
+ DestinationResource: types.Resource{ID: "p2", Type: types.ResourceTypePeer},
+ }}}
+ groups, peers := policyGroupsAndPeers(policy)
+ assert.ElementsMatch(t, []string{"g1", "g2"}, groups)
+ assert.ElementsMatch(t, []string{"p1", "p2"}, peers)
+}
+
+func TestPolicyGroupsAndPeers_NilPolicy(t *testing.T) {
+ groups, peers := policyGroupsAndPeers(nil)
+ assert.Nil(t, groups)
+ assert.Nil(t, peers)
+}
+
+func TestPolicyGroupsAndPeers_MultiplePolicies(t *testing.T) {
+ old := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g1"}, Destinations: []string{"g2"}}}}
+ updated := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g3"}, Destinations: []string{"g4"}}}}
+ groups, _ := policyGroupsAndPeers(updated, old)
+ assert.ElementsMatch(t, []string{"g1", "g2", "g3", "g4"}, groups)
+}
+
+func TestPolicyGroupsAndPeers_NonPeerResource(t *testing.T) {
+ policy := &types.Policy{Rules: []*types.PolicyRule{{
+ Sources: []string{"g1"},
+ SourceResource: types.Resource{ID: "domain-1", Type: types.ResourceTypeDomain},
+ Destinations: []string{"g2"},
+ }}}
+ groups, peers := policyGroupsAndPeers(policy)
+ assert.ElementsMatch(t, []string{"g1", "g2"}, groups)
+ assert.Empty(t, peers, "domain resource type should not produce direct peer IDs")
+}
+
+func TestChangeIsEmpty(t *testing.T) {
+ assert.True(t, Change{}.isEmpty())
+ assert.False(t, Change{ChangedGroupIDs: []string{"g"}}.isEmpty())
+ assert.False(t, Change{ChangedPeerIDs: []string{"p"}}.isEmpty())
+ assert.False(t, Change{Policies: []*types.Policy{{}}}.isEmpty())
+ assert.False(t, Change{Resources: []*resourceTypes.NetworkResource{{ID: "r"}}}.isEmpty())
+ assert.False(t, Change{Networks: []*networkTypes.Network{{ID: "n"}}}.isEmpty())
+ assert.False(t, Change{PostureCheckIDs: []string{"pc"}}.isEmpty())
+}
+
+func TestPolicyReferencesGroups(t *testing.T) {
+ policy := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g1", "g2"}, Destinations: []string{"g3"}}}}
+
+ assert.True(t, policyReferencesGroups(policy, map[string]struct{}{"g1": {}}))
+ assert.True(t, policyReferencesGroups(policy, map[string]struct{}{"g3": {}}))
+ assert.False(t, policyReferencesGroups(policy, map[string]struct{}{"g4": {}}))
+ assert.False(t, policyReferencesGroups(policy, map[string]struct{}{}))
+}
+
+func TestPolicyReferencesDirectPeers(t *testing.T) {
+ policy := &types.Policy{Rules: []*types.PolicyRule{{
+ SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"},
+ DestinationResource: types.Resource{Type: types.ResourceTypeHost, ID: "r1"},
+ }}}
+
+ assert.True(t, policyReferencesDirectPeers(policy, map[string]struct{}{"p1": {}}))
+ assert.False(t, policyReferencesDirectPeers(policy, map[string]struct{}{"r1": {}}))
+ assert.False(t, policyReferencesDirectPeers(policy, map[string]struct{}{"p2": {}}))
+}
+
+func TestPolicyReferencesPostureChecks(t *testing.T) {
+ policy := &types.Policy{SourcePostureChecks: []string{"pc1", "pc2"}}
+
+ assert.True(t, policyReferencesPostureChecks(policy, map[string]struct{}{"pc1": {}}))
+ assert.False(t, policyReferencesPostureChecks(policy, map[string]struct{}{"pc3": {}}))
+}
+
+func TestCollectPolicyDirectPeers(t *testing.T) {
+ policy := &types.Policy{Rules: []*types.PolicyRule{{
+ SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"},
+ DestinationResource: types.Resource{Type: types.ResourceTypePeer, ID: "p2"},
+ }, {
+ DestinationResource: types.Resource{Type: types.ResourceTypeHost, ID: "r1"},
+ }}}
+
+ peerSet := map[string]struct{}{}
+ collectPolicyDirectPeers(policy, peerSet)
+
+ assert.Contains(t, peerSet, "p1")
+ assert.Contains(t, peerSet, "p2")
+ assert.NotContains(t, peerSet, "r1")
+}
+
+func TestCollectPolicySources(t *testing.T) {
+ policy := &types.Policy{Rules: []*types.PolicyRule{{
+ Sources: []string{"g1"},
+ SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"},
+ Destinations: []string{"g2"},
+ }}}
+
+ groupSet := map[string]struct{}{}
+ peerSet := map[string]struct{}{}
+ collectPolicySources(policy, groupSet, peerSet)
+
+ assert.Contains(t, groupSet, "g1")
+ assert.NotContains(t, groupSet, "g2", "destination groups must not be collected as sources")
+ assert.Contains(t, peerSet, "p1")
+}
diff --git a/management/server/auth/manager.go b/management/server/auth/manager.go
index 27346a604..9498789f2 100644
--- a/management/server/auth/manager.go
+++ b/management/server/auth/manager.go
@@ -12,6 +12,7 @@ import (
"github.com/netbirdio/netbird/shared/auth"
"github.com/netbirdio/netbird/base62"
+ "github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
@@ -74,7 +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) {
- if userAuth.IsChild || userAuth.IsPAT {
+ // Child accounts and 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) {
return userAuth, nil
}
diff --git a/management/server/auth/manager_test.go b/management/server/auth/manager_test.go
index 469737f47..af8a30ef1 100644
--- a/management/server/auth/manager_test.go
+++ b/management/server/auth/manager_test.go
@@ -16,6 +16,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/netbirdio/netbird/idp/dex"
"github.com/netbirdio/netbird/management/server/auth"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/types"
@@ -206,6 +207,43 @@ func TestAuthManager_EnsureUserAccessByJWTGroups(t *testing.T) {
_, err = manager.EnsureUserAccessByJWTGroups(context.Background(), userAuth, token)
require.Error(t, err, "ensure user access is not in allowed groups")
})
+
+ t.Run("Local embedded-Dex user is exempt from JWT allow-groups", func(t *testing.T) {
+ account.Settings.JWTGroupsEnabled = true
+ account.Settings.JWTGroupsClaimName = "idp-groups"
+ account.Settings.JWTAllowGroups = []string{"not-a-group"}
+ err := store.SaveAccount(context.Background(), account)
+ require.NoError(t, err, "save account failed")
+
+ // Local Dex users have a "local" connector encoded in their user ID.
+ localUserAuth := nbauth.UserAuth{
+ AccountId: account.Id,
+ Domain: domain,
+ UserId: dex.EncodeDexUserID("local-owner", "local"),
+ }
+
+ localUserAuth, err = manager.EnsureUserAccessByJWTGroups(context.Background(), localUserAuth, token)
+ require.NoError(t, err, "local user must not be locked out by JWT allow-groups (issue #5337)")
+ require.Len(t, localUserAuth.Groups, 0, "JWT groups must not be evaluated for local users")
+ })
+
+ t.Run("Federated embedded-Dex user is still subject to JWT allow-groups", func(t *testing.T) {
+ account.Settings.JWTGroupsEnabled = true
+ account.Settings.JWTGroupsClaimName = "idp-groups"
+ account.Settings.JWTAllowGroups = []string{"not-a-group"}
+ err := store.SaveAccount(context.Background(), account)
+ require.NoError(t, err, "save account failed")
+
+ // A federated user (non-"local" connector) must remain restricted.
+ fedUserAuth := nbauth.UserAuth{
+ AccountId: account.Id,
+ Domain: domain,
+ UserId: dex.EncodeDexUserID("entra-user", "entra"),
+ }
+
+ _, err = manager.EnsureUserAccessByJWTGroups(context.Background(), fedUserAuth, token)
+ require.Error(t, err, "federated user must still be restricted by JWT allow-groups")
+ })
}
func TestAuthManager_ValidateAndParseToken(t *testing.T) {
diff --git a/management/server/context/keys.go b/management/server/context/keys.go
index 7a65afbbd..aa534c5d9 100644
--- a/management/server/context/keys.go
+++ b/management/server/context/keys.go
@@ -12,6 +12,7 @@ const (
RoleKey = nbcontext.RoleKey
UserIDKey = nbcontext.UserIDKey
PeerIDKey = nbcontext.PeerIDKey
+ UserAgentKey = nbcontext.UserAgentKey
)
// RoleFromContext returns the role stored in ctx, or empty string and false if absent.
diff --git a/management/server/dns.go b/management/server/dns.go
index dcc3f21c7..612c8ecba 100644
--- a/management/server/dns.go
+++ b/management/server/dns.go
@@ -8,6 +8,7 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
@@ -47,8 +48,9 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID
return status.NewPermissionDeniedError()
}
- var updateAccountPeers bool
var eventsToStore []func()
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err = validateDNSSettings(ctx, transaction, accountID, dnsSettingsToSave); err != nil {
@@ -63,11 +65,6 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID
addedGroups := util.Difference(dnsSettingsToSave.DisabledManagementGroups, oldSettings.DisabledManagementGroups)
removedGroups := util.Difference(oldSettings.DisabledManagementGroups, dnsSettingsToSave.DisabledManagementGroups)
- updateAccountPeers, err = areDNSSettingChangesAffectPeers(ctx, transaction, accountID, addedGroups, removedGroups)
- if err != nil {
- return err
- }
-
events := am.prepareDNSSettingsEvents(ctx, transaction, accountID, userID, addedGroups, removedGroups)
eventsToStore = append(eventsToStore, events...)
@@ -75,6 +72,11 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID
return err
}
+ change = affectedpeers.Change{DistributionGroupIDs: slices.Concat(addedGroups, removedGroups)}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {
@@ -85,9 +87,7 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID
storeEvent()
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceDNSSettings, Operation: types.UpdateOperationUpdate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
@@ -133,20 +133,6 @@ func (am *DefaultAccountManager) prepareDNSSettingsEvents(ctx context.Context, t
return eventsToStore
}
-// areDNSSettingChangesAffectPeers checks if the DNS settings changes affect any peers.
-func areDNSSettingChangesAffectPeers(ctx context.Context, transaction store.Store, accountID string, addedGroups, removedGroups []string) (bool, error) {
- hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, accountID, addedGroups)
- if err != nil {
- return false, err
- }
-
- if hasPeers {
- return true, nil
- }
-
- return anyGroupHasPeersOrResources(ctx, transaction, accountID, removedGroups)
-}
-
// validateDNSSettings validates the DNS settings.
func validateDNSSettings(ctx context.Context, transaction store.Store, accountID string, settings *types.DNSSettings) error {
if len(settings.DisabledManagementGroups) == 0 {
diff --git a/management/server/group.go b/management/server/group.go
index 7e02af245..070344c61 100644
--- a/management/server/group.go
+++ b/management/server/group.go
@@ -11,6 +11,7 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
@@ -79,7 +80,8 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use
}
var eventsToStore []func()
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil {
@@ -91,11 +93,6 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use
events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup)
eventsToStore = append(eventsToStore, events...)
- updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{newGroup.ID})
- if err != nil {
- return err
- }
-
if err := transaction.CreateGroup(ctx, newGroup); err != nil {
return status.Errorf(status.Internal, "failed to create group: %v", err)
}
@@ -106,6 +103,11 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use
}
}
+ snap, err = affectedpeers.Load(ctx, transaction, accountID, change)
+ if err != nil {
+ return err
+ }
+
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {
@@ -116,9 +118,7 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use
storeEvent()
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationCreate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
@@ -134,7 +134,8 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use
}
var eventsToStore []func()
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil {
@@ -153,20 +154,7 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use
peersToAdd := util.Difference(newGroup.Peers, oldGroup.Peers)
peersToRemove := util.Difference(oldGroup.Peers, newGroup.Peers)
-
- for _, peerID := range peersToAdd {
- 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)
- }
- }
- for _, peerID := range peersToRemove {
- if err := transaction.RemovePeerFromGroup(ctx, peerID, newGroup.ID); err != nil {
- return status.Errorf(status.Internal, "failed to remove peer %s from group %s: %v", peerID, newGroup.ID, err)
- }
- }
-
- updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{newGroup.ID})
- if err != nil {
+ if err = syncGroupMembership(ctx, transaction, accountID, newGroup.ID, peersToAdd, peersToRemove); err != nil {
return err
}
@@ -178,6 +166,17 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use
return err
}
+ // A membership change does not alter which entities reference the group, so
+ // the dependency walk runs once against the post-change snapshot. The new
+ // members are already in the snapshot's index; the removed members are
+ // carried separately and folded in only when the group is linked.
+ if len(peersToRemove) > 0 {
+ change.RemovedPeersByGroup = map[string][]string{newGroup.ID: peersToRemove}
+ }
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {
@@ -188,13 +187,26 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use
storeEvent()
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
+// 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 {
+ 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)
+ }
+ }
+ for _, peerID := range peersToRemove {
+ if err := transaction.RemovePeerFromGroup(ctx, peerID, groupID); err != nil {
+ return status.Errorf(status.Internal, "failed to remove peer %s from group %s: %v", peerID, groupID, err)
+ }
+ }
+ 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.
@@ -209,11 +221,14 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us
}
var eventsToStore []func()
- var updateAccountPeers bool
+ var snaps []*affectedpeers.Snapshot
+ var changes []affectedpeers.Change
var globalErr error
- groupIDs := make([]string, 0, len(groups))
+ createdCount := 0
for _, newGroup := range groups {
+ change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}}
+ var snap *affectedpeers.Snapshot
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil {
return err
@@ -230,35 +245,31 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us
return err
}
- groupIDs = append(groupIDs, newGroup.ID)
-
events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup)
eventsToStore = append(eventsToStore, events...)
- return nil
+ snap, err = affectedpeers.Load(ctx, transaction, accountID, change)
+ return err
})
if err != nil {
log.WithContext(ctx).Errorf("failed to update group %s: %v", newGroup.ID, err)
- if len(groupIDs) == 1 {
+ if createdCount == 0 {
return err
}
globalErr = errors.Join(globalErr, err)
// continue updating other groups
+ continue
}
- }
-
- updateAccountPeers, err = areGroupChangesAffectPeers(ctx, am.Store, accountID, groupIDs)
- if err != nil {
- return err
+ createdCount++
+ snaps = append(snaps, snap)
+ changes = append(changes, change)
}
for _, storeEvent := range eventsToStore {
storeEvent()
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationCreate})
- }
+ go am.dispatchAffected(ctx, accountID, snaps, changes)
return globalErr
}
@@ -277,12 +288,13 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us
}
var eventsToStore []func()
- var updateAccountPeers bool
+ var snaps []*affectedpeers.Snapshot
+ var changes []affectedpeers.Change
var globalErr error
- groupIDs := make([]string, 0, len(groups))
for _, newGroup := range groups {
- events, err := am.updateSingleGroup(ctx, accountID, userID, newGroup)
+ change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}}
+ events, snap, 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 {
@@ -292,27 +304,22 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us
continue
}
eventsToStore = append(eventsToStore, events...)
- groupIDs = append(groupIDs, newGroup.ID)
- }
-
- updateAccountPeers, err = areGroupChangesAffectPeers(ctx, am.Store, accountID, groupIDs)
- if err != nil {
- return err
+ snaps = append(snaps, snap)
+ changes = append(changes, change)
}
for _, storeEvent := range eventsToStore {
storeEvent()
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate})
- }
+ go am.dispatchAffected(ctx, accountID, snaps, changes)
return globalErr
}
-func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountID, userID string, newGroup *types.Group) ([]func(), error) {
+func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountID, userID string, newGroup *types.Group, change affectedpeers.Change) ([]func(), *affectedpeers.Snapshot, error) {
var events []func()
+ var snap *affectedpeers.Snapshot
err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err := validateNewGroup(ctx, transaction, accountID, newGroup); err != nil {
return err
@@ -333,9 +340,12 @@ func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountI
}
events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup)
- return nil
+
+ var err error
+ snap, err = affectedpeers.Load(ctx, transaction, accountID, change)
+ return err
})
- return events, err
+ return events, snap, err
}
// prepareGroupEvents prepares a list of event functions to be stored.
@@ -438,6 +448,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
extraSettings, err := am.settingsManager.GetExtraSettings(ctx, accountID)
if err != nil {
@@ -445,26 +457,23 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us
}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- for _, groupID := range groupIDs {
- group, err := transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID)
- if err != nil {
- allErrors = errors.Join(allErrors, err)
- continue
- }
-
- if err = validateDeleteGroup(ctx, transaction, group, userID, extraSettings.FlowGroups); err != nil {
- allErrors = errors.Join(allErrors, err)
- continue
- }
-
- groupIDsToDelete = append(groupIDsToDelete, groupID)
- deletedGroups = append(deletedGroups, group)
+ deletedGroups, allErrors = collectDeletableGroups(ctx, transaction, accountID, userID, groupIDs, extraSettings.FlowGroups)
+ for _, group := range deletedGroups {
+ groupIDsToDelete = append(groupIDsToDelete, group.ID)
}
if len(groupIDsToDelete) == 0 {
return allErrors
}
+ // Delete: compute affected peers from the PRE-delete state. The groups,
+ // their members and the entities referencing them still exist, so a plain
+ // Load+Expand captures everyone — no removed-peer folding needed.
+ change = affectedpeers.Change{ChangedGroupIDs: groupIDsToDelete}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
if err = transaction.DeleteGroups(ctx, accountID, groupIDsToDelete); err != nil {
return err
}
@@ -483,25 +492,47 @@ 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)
+
return allErrors
}
+// collectDeletableGroups loads and validates each group for deletion, returning
+// the groups that may be deleted and the joined validation errors for the rest.
+func collectDeletableGroups(ctx context.Context, transaction store.Store, accountID, userID string, groupIDs, flowGroups []string) ([]*types.Group, error) {
+ var deletable []*types.Group
+ var allErrors error
+ for _, groupID := range groupIDs {
+ group, err := transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID)
+ if err != nil {
+ allErrors = errors.Join(allErrors, err)
+ continue
+ }
+ if err = validateDeleteGroup(ctx, transaction, group, userID, flowGroups); err != nil {
+ allErrors = errors.Join(allErrors, err)
+ continue
+ }
+ deletable = append(deletable, group)
+ }
+ return deletable, allErrors
+}
+
// GroupAddPeer appends peer to the group
func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, groupID, peerID string) error {
- var updateAccountPeers bool
- var err error
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{ChangedGroupIDs: []string{groupID}}
- err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{groupID})
- if err != nil {
+ err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
+ if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil {
return err
}
- if err = transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil {
+ if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil {
return err
}
- if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil {
+ var err error
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -511,9 +542,7 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr
return err
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
@@ -521,8 +550,9 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr
// GroupAddResource appends resource to the group
func (am *DefaultAccountManager) GroupAddResource(ctx context.Context, accountID, groupID string, resource types.Resource) error {
var group *types.Group
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
var err error
+ change := affectedpeers.Change{ChangedGroupIDs: []string{groupID}}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
group, err = transaction.GetGroupByID(context.Background(), store.LockingStrengthUpdate, accountID, groupID)
@@ -534,12 +564,11 @@ func (am *DefaultAccountManager) GroupAddResource(ctx context.Context, accountID
return nil
}
- updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{groupID})
- if err != nil {
+ if err = transaction.UpdateGroup(ctx, group); err != nil {
return err
}
- if err = transaction.UpdateGroup(ctx, group); err != nil {
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -549,29 +578,32 @@ func (am *DefaultAccountManager) GroupAddResource(ctx context.Context, accountID
return err
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
// GroupDeletePeer removes peer from the group
func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, groupID, peerID string) error {
- var updateAccountPeers bool
- var err error
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{
+ ChangedGroupIDs: []string{groupID},
+ RemovedPeersByGroup: map[string][]string{groupID: {peerID}},
+ }
- err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{groupID})
- if err != nil {
+ err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
+ if err := transaction.RemovePeerFromGroup(ctx, peerID, groupID); err != nil {
return err
}
- if err = transaction.RemovePeerFromGroup(ctx, peerID, groupID); err != nil {
+ if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil {
return err
}
- if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil {
+ // The removed peer is carried in change.RemovedPeersByGroup and folded in
+ // only when the group is linked, so loading post-removal is correct.
+ var err error
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -581,9 +613,7 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID,
return err
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
@@ -591,8 +621,9 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID,
// GroupDeleteResource removes resource from the group
func (am *DefaultAccountManager) GroupDeleteResource(ctx context.Context, accountID, groupID string, resource types.Resource) error {
var group *types.Group
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
var err error
+ change := affectedpeers.Change{ChangedGroupIDs: []string{groupID}}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
group, err = transaction.GetGroupByID(context.Background(), store.LockingStrengthUpdate, accountID, groupID)
@@ -604,8 +635,9 @@ func (am *DefaultAccountManager) GroupDeleteResource(ctx context.Context, accoun
return nil
}
- updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{groupID})
- if err != nil {
+ // Load before persisting the removal, so the snapshot still maps the group
+ // to the resource and the bridge can reach its routing peers.
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -619,9 +651,7 @@ func (am *DefaultAccountManager) GroupDeleteResource(ctx context.Context, accoun
return err
}
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceGroup, Operation: types.UpdateOperationUpdate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
@@ -832,49 +862,103 @@ func isGroupLinkedToNetworkRouter(ctx context.Context, transaction store.Store,
}
// areGroupChangesAffectPeers checks if any changes to the specified groups will affect peers.
+// It fetches each collection once and checks all groupIDs against them in memory.
func areGroupChangesAffectPeers(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) (bool, error) {
if len(groupIDs) == 0 {
return false, nil
}
+ groupSet := make(map[string]struct{}, len(groupIDs))
+ for _, id := range groupIDs {
+ groupSet[id] = struct{}{}
+ }
+
+ if affected, err := dnsSettingsReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil {
+ return affected, err
+ }
+ if affected, err := nameServersReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil {
+ return affected, err
+ }
+ if affected, err := policiesReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil {
+ return affected, err
+ }
+ if affected, err := routesReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil {
+ return affected, err
+ }
+ if affected, err := networkRoutersReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil {
+ return affected, err
+ }
+
+ return false, nil
+}
+
+func dnsSettingsReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) {
dnsSettings, err := transaction.GetAccountDNSSettings(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return false, err
}
-
- for _, groupID := range groupIDs {
- if slices.Contains(dnsSettings.DisabledManagementGroups, groupID) {
- return true, nil
- }
- if linked, _ := isGroupLinkedToDns(ctx, transaction, accountID, groupID); linked {
- return true, nil
- }
- if linked, _ := isGroupLinkedToPolicy(ctx, transaction, accountID, groupID); linked {
- return true, nil
- }
- if linked, _ := isGroupLinkedToRoute(ctx, transaction, accountID, groupID); linked {
- return true, nil
- }
- if linked, _ := isGroupLinkedToNetworkRouter(ctx, transaction, accountID, groupID); linked {
- return true, nil
- }
- }
-
- return false, nil
+ return anyInSet(dnsSettings.DisabledManagementGroups, groupSet), nil
}
-// anyGroupHasPeersOrResources checks if any of the given groups in the account have peers or resources.
-func anyGroupHasPeersOrResources(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) (bool, error) {
- groups, err := transaction.GetGroupsByIDs(ctx, store.LockingStrengthNone, accountID, groupIDs)
+func nameServersReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) {
+ nameServerGroups, err := transaction.GetAccountNameServerGroups(ctx, store.LockingStrengthNone, accountID)
if err != nil {
return false, err
}
-
- for _, group := range groups {
- if group.HasPeers() || group.HasResources() {
+ for _, ns := range nameServerGroups {
+ if anyInSet(ns.Groups, groupSet) {
return true, nil
}
}
-
return false, nil
}
+
+func policiesReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) {
+ policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return false, err
+ }
+ for _, policy := range policies {
+ for _, rule := range policy.Rules {
+ if anyInSet(rule.Sources, groupSet) || anyInSet(rule.Destinations, groupSet) {
+ return true, nil
+ }
+ }
+ }
+ return false, nil
+}
+
+func routesReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) {
+ routes, err := transaction.GetAccountRoutes(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return false, err
+ }
+ for _, r := range routes {
+ if anyInSet(r.Groups, groupSet) || anyInSet(r.PeerGroups, groupSet) || anyInSet(r.AccessControlGroups, groupSet) {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func networkRoutersReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) {
+ routers, err := transaction.GetNetworkRoutersByAccountID(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return false, err
+ }
+ for _, router := range routers {
+ if anyInSet(router.PeerGroups, groupSet) {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func anyInSet(ids []string, set map[string]struct{}) bool {
+ for _, id := range ids {
+ if _, ok := set[id]; ok {
+ return true
+ }
+ }
+ return false
+}
diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go
index 32549a521..15eb9b190 100644
--- a/management/server/mock_server/account_mock.go
+++ b/management/server/mock_server/account_mock.go
@@ -15,6 +15,7 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"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/idp"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/posture"
@@ -38,7 +39,7 @@ type MockAccountManager struct {
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)
- MarkPeerConnectedFunc func(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error
+ MarkPeerConnectedFunc func(ctx context.Context, peerKey string, realIP net.IP, 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, []*posture.Checks, int64, error)
DeletePeerFunc func(ctx context.Context, accountID, peerKey, userID string) error
@@ -132,6 +133,7 @@ type MockAccountManager struct {
AllowSyncFunc func(string, uint64) bool
UpdateAccountPeersFunc func(ctx context.Context, accountID string, reason types.UpdateReason)
+ ExpandAndUpdateAffectedFunc func(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change)
BufferUpdateAccountPeersFunc func(ctx context.Context, accountID string, reason types.UpdateReason)
RecalculateNetworkMapCacheFunc func(ctx context.Context, accountId string) error
@@ -209,6 +211,12 @@ func (am *MockAccountManager) UpdateAccountPeers(ctx context.Context, accountID
}
}
+func (am *MockAccountManager) ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) {
+ if am.ExpandAndUpdateAffectedFunc != nil {
+ am.ExpandAndUpdateAffectedFunc(ctx, accountID, snap, change)
+ }
+}
+
func (am *MockAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) {
if am.BufferUpdateAccountPeersFunc != nil {
am.BufferUpdateAccountPeersFunc(ctx, accountID, reason)
@@ -337,9 +345,9 @@ func (am *MockAccountManager) GetAccountIDByUserID(ctx context.Context, userAuth
}
// MarkPeerConnected mock implementation of MarkPeerConnected from server.AccountManager interface
-func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64) error {
+func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error {
if am.MarkPeerConnectedFunc != nil {
- return am.MarkPeerConnectedFunc(ctx, peerKey, realIP, accountID, sessionStartedAt)
+ return am.MarkPeerConnectedFunc(ctx, peerKey, realIP, accountID, sessionStartedAt, nmap)
}
return status.Errorf(codes.Unimplemented, "method MarkPeerConnected is not implemented")
}
diff --git a/management/server/nameserver.go b/management/server/nameserver.go
index c836fefeb..b9cebf726 100644
--- a/management/server/nameserver.go
+++ b/management/server/nameserver.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "slices"
"strings"
"unicode/utf8"
@@ -11,6 +12,7 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
@@ -57,19 +59,19 @@ func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, acco
SearchDomainsEnabled: searchDomainEnabled,
}
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{DistributionGroupIDs: newNSGroup.Groups}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err = validateNameServerGroup(ctx, transaction, accountID, newNSGroup); err != nil {
return err
}
- updateAccountPeers, err = anyGroupHasPeersOrResources(ctx, transaction, accountID, newNSGroup.Groups)
- if err != nil {
+ if err = transaction.SaveNameServerGroup(ctx, newNSGroup); err != nil {
return err
}
- if err = transaction.SaveNameServerGroup(ctx, newNSGroup); err != nil {
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -81,9 +83,7 @@ func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, acco
am.StoreEvent(ctx, userID, newNSGroup.ID, accountID, activity.NameserverGroupCreated, newNSGroup.EventMeta())
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceNameServerGroup, Operation: types.UpdateOperationCreate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return newNSGroup.Copy(), nil
}
@@ -102,7 +102,8 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun
return status.NewPermissionDeniedError()
}
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
oldNSGroup, err := transaction.GetNameServerGroupByID(ctx, store.LockingStrengthNone, accountID, nsGroupToSave.ID)
@@ -115,12 +116,12 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun
return err
}
- updateAccountPeers, err = areNameServerGroupChangesAffectPeers(ctx, transaction, nsGroupToSave, oldNSGroup)
- if err != nil {
+ if err = transaction.SaveNameServerGroup(ctx, nsGroupToSave); err != nil {
return err
}
- if err = transaction.SaveNameServerGroup(ctx, nsGroupToSave); err != nil {
+ change = affectedpeers.Change{DistributionGroupIDs: slices.Concat(nsGroupToSave.Groups, oldNSGroup.Groups)}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -132,9 +133,7 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun
am.StoreEvent(ctx, userID, nsGroupToSave.ID, accountID, activity.NameserverGroupUpdated, nsGroupToSave.EventMeta())
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceNameServerGroup, Operation: types.UpdateOperationUpdate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
@@ -150,7 +149,8 @@ func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, acco
}
var nsGroup *nbdns.NameServerGroup
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
nsGroup, err = transaction.GetNameServerGroupByID(ctx, store.LockingStrengthUpdate, accountID, nsGroupID)
@@ -158,8 +158,9 @@ func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, acco
return err
}
- updateAccountPeers, err = anyGroupHasPeersOrResources(ctx, transaction, accountID, nsGroup.Groups)
- if err != nil {
+ // Load before delete: the post-delete state no longer references the groups.
+ change = affectedpeers.Change{DistributionGroupIDs: nsGroup.Groups}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -175,9 +176,7 @@ func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, acco
am.StoreEvent(ctx, userID, nsGroup.ID, accountID, activity.NameserverGroupDeleted, nsGroup.EventMeta())
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceNameServerGroup, Operation: types.UpdateOperationDelete})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
@@ -224,24 +223,6 @@ func validateNameServerGroup(ctx context.Context, transaction store.Store, accou
return validateGroups(nameserverGroup.Groups, groups)
}
-// areNameServerGroupChangesAffectPeers checks if the changes in the nameserver group affect the peers.
-func areNameServerGroupChangesAffectPeers(ctx context.Context, transaction store.Store, newNSGroup, oldNSGroup *nbdns.NameServerGroup) (bool, error) {
- if !newNSGroup.Enabled && !oldNSGroup.Enabled {
- return false, nil
- }
-
- hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, newNSGroup.AccountID, newNSGroup.Groups)
- if err != nil {
- return false, err
- }
-
- if hasPeers {
- return true, nil
- }
-
- return anyGroupHasPeersOrResources(ctx, transaction, oldNSGroup.AccountID, oldNSGroup.Groups)
-}
-
func validateDomainInput(primary bool, domains []string, searchDomainsEnabled bool) error {
if !primary && len(domains) == 0 {
return status.Errorf(status.InvalidArgument, "nameserver group primary status is false and domains are empty,"+
diff --git a/management/server/networks/manager.go b/management/server/networks/manager.go
index f825ae015..d572502fd 100644
--- a/management/server/networks/manager.go
+++ b/management/server/networks/manager.go
@@ -8,6 +8,7 @@ import (
"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/networks/resources"
"github.com/netbirdio/netbird/management/server/networks/routers"
"github.com/netbirdio/netbird/management/server/networks/types"
@@ -15,7 +16,6 @@ import (
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
- serverTypes "github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
@@ -127,30 +127,39 @@ func (m *managerImpl) DeleteNetwork(ctx context.Context, accountID, userID, netw
}
var eventsToStore []func()
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{Networks: []*types.Network{network}}
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
resources, err := transaction.GetNetworkResourcesByNetID(ctx, store.LockingStrengthUpdate, accountID, networkID)
if err != nil {
return fmt.Errorf("failed to get resources in network: %w", err)
}
- for _, resource := range resources {
- event, err := m.resourcesManager.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resource.ID)
- if err != nil {
- return fmt.Errorf("failed to delete resource: %w", err)
- }
- eventsToStore = append(eventsToStore, event...)
- }
-
- routers, err := transaction.GetNetworkRoutersByNetID(ctx, store.LockingStrengthUpdate, accountID, networkID)
+ netRouters, err := transaction.GetNetworkRoutersByNetID(ctx, store.LockingStrengthUpdate, accountID, networkID)
if err != nil {
return fmt.Errorf("failed to get routers in network: %w", err)
}
- for _, router := range routers {
- event, err := m.routersManager.DeleteRouterInTransaction(ctx, transaction, accountID, userID, networkID, router.ID)
+ var lerr error
+ if snap, lerr = affectedpeers.Load(ctx, transaction, accountID, change); lerr != nil {
+ return lerr
+ }
+
+ for _, resource := range resources {
+ deleted, event, err := m.resourcesManager.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resource.ID)
+ if err != nil {
+ return fmt.Errorf("failed to delete resource: %w", err)
+ }
+ change.Resources = append(change.Resources, deleted)
+ eventsToStore = append(eventsToStore, event...)
+ }
+
+ for _, router := range netRouters {
+ deleted, event, err := m.routersManager.DeleteRouterInTransaction(ctx, transaction, accountID, userID, networkID, router.ID)
if err != nil {
return fmt.Errorf("failed to delete router: %w", err)
}
+ change.Routers = append(change.Routers, deleted)
eventsToStore = append(eventsToStore, event)
}
@@ -178,7 +187,7 @@ func (m *managerImpl) DeleteNetwork(ctx context.Context, accountID, userID, netw
event()
}
- go m.accountManager.UpdateAccountPeers(ctx, accountID, serverTypes.UpdateReason{Resource: serverTypes.UpdateResourceNetwork, Operation: serverTypes.UpdateOperationDelete})
+ m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
diff --git a/management/server/networks/resources/manager.go b/management/server/networks/resources/manager.go
index 51a269163..6c427ce62 100644
--- a/management/server/networks/resources/manager.go
+++ b/management/server/networks/resources/manager.go
@@ -10,6 +10,7 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"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/groups"
"github.com/netbirdio/netbird/management/server/networks/resources/types"
"github.com/netbirdio/netbird/management/server/permissions"
@@ -29,7 +30,7 @@ type Manager interface {
GetResource(ctx context.Context, accountID, userID, networkID, resourceID string) (*types.NetworkResource, error)
UpdateResource(ctx context.Context, userID string, resource *types.NetworkResource) (*types.NetworkResource, error)
DeleteResource(ctx context.Context, accountID, userID, networkID, resourceID string) error
- DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) ([]func(), error)
+ DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) (*types.NetworkResource, []func(), error)
}
type managerImpl struct {
@@ -114,45 +115,12 @@ func (m *managerImpl) CreateResource(ctx context.Context, userID string, resourc
}
var eventsToStore []func()
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{Resources: []*types.NetworkResource{resource}}
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- _, err = transaction.GetNetworkResourceByName(ctx, store.LockingStrengthNone, resource.AccountID, resource.Name)
- if err == nil {
- return status.Errorf(status.InvalidArgument, "resource with name %s already exists", resource.Name)
- }
-
- network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, resource.AccountID, resource.NetworkID)
- if err != nil {
- return fmt.Errorf("failed to get network: %w", err)
- }
-
- err = transaction.SaveNetworkResource(ctx, resource)
- if err != nil {
- return fmt.Errorf("failed to save network resource: %w", err)
- }
-
- event := func() {
- m.accountManager.StoreEvent(ctx, userID, resource.ID, resource.AccountID, activity.NetworkResourceCreated, resource.EventMeta(network))
- }
- eventsToStore = append(eventsToStore, event)
-
- res := nbtypes.Resource{
- ID: resource.ID,
- Type: nbtypes.ResourceType(resource.Type.String()),
- }
- for _, groupID := range resource.GroupIDs {
- event, err := m.groupsManager.AddResourceToGroupInTransaction(ctx, transaction, resource.AccountID, userID, groupID, &res)
- if err != nil {
- return fmt.Errorf("failed to add resource to group: %w", err)
- }
- eventsToStore = append(eventsToStore, event)
- }
-
- err = transaction.IncrementNetworkSerial(ctx, resource.AccountID)
- if err != nil {
- return fmt.Errorf("failed to increment network serial: %w", err)
- }
-
- return nil
+ var txErr error
+ eventsToStore, snap, txErr = m.createResourceInTransaction(ctx, transaction, userID, resource, change)
+ return txErr
})
if err != nil {
return nil, fmt.Errorf("failed to create network resource: %w", err)
@@ -162,11 +130,55 @@ func (m *managerImpl) CreateResource(ctx context.Context, userID string, resourc
event()
}
- go m.accountManager.UpdateAccountPeers(ctx, resource.AccountID, nbtypes.UpdateReason{Resource: nbtypes.UpdateResourceNetworkResource, Operation: nbtypes.UpdateOperationCreate})
+ m.accountManager.ExpandAndUpdateAffected(ctx, resource.AccountID, snap, change)
return resource, nil
}
+func (m *managerImpl) createResourceInTransaction(ctx context.Context, transaction store.Store, userID string, resource *types.NetworkResource, change affectedpeers.Change) ([]func(), *affectedpeers.Snapshot, error) {
+ _, err := transaction.GetNetworkResourceByName(ctx, store.LockingStrengthNone, resource.AccountID, resource.Name)
+ if err == nil {
+ return nil, nil, status.Errorf(status.InvalidArgument, "resource with name %s already exists", resource.Name)
+ }
+
+ network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, resource.AccountID, resource.NetworkID)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to get network: %w", err)
+ }
+
+ if err = transaction.SaveNetworkResource(ctx, resource); err != nil {
+ return nil, nil, fmt.Errorf("failed to save network resource: %w", err)
+ }
+
+ var eventsToStore []func()
+ eventsToStore = append(eventsToStore, func() {
+ m.accountManager.StoreEvent(ctx, userID, resource.ID, resource.AccountID, activity.NetworkResourceCreated, resource.EventMeta(network))
+ })
+
+ res := nbtypes.Resource{
+ ID: resource.ID,
+ Type: nbtypes.ResourceType(resource.Type.String()),
+ }
+ for _, groupID := range resource.GroupIDs {
+ event, err := m.groupsManager.AddResourceToGroupInTransaction(ctx, transaction, resource.AccountID, userID, groupID, &res)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to add resource to group: %w", err)
+ }
+ eventsToStore = append(eventsToStore, event)
+ }
+
+ if err = transaction.IncrementNetworkSerial(ctx, resource.AccountID); err != nil {
+ return nil, nil, fmt.Errorf("failed to increment network serial: %w", err)
+ }
+
+ snap, err := affectedpeers.Load(ctx, transaction, resource.AccountID, change)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return eventsToStore, snap, nil
+}
+
func (m *managerImpl) GetResource(ctx context.Context, accountID, userID, networkID, resourceID string) (*types.NetworkResource, error) {
ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read)
if err != nil {
@@ -207,6 +219,8 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc
resource.Prefix = prefix
var eventsToStore []func()
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, resource.AccountID, resource.NetworkID)
if err != nil {
@@ -232,6 +246,14 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc
return fmt.Errorf("failed to get network resource: %w", err)
}
+ oldGroups, err := m.groupsManager.GetResourceGroupsInTransaction(ctx, transaction, store.LockingStrengthNone, resource.AccountID, resource.ID)
+ if err != nil {
+ return fmt.Errorf("failed to get old resource groups: %w", err)
+ }
+ for _, g := range oldGroups {
+ oldResource.GroupIDs = append(oldResource.GroupIDs, g.ID)
+ }
+
err = transaction.SaveNetworkResource(ctx, resource)
if err != nil {
return fmt.Errorf("failed to save network resource: %w", err)
@@ -247,6 +269,11 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc
m.accountManager.StoreEvent(ctx, userID, resource.ID, resource.AccountID, activity.NetworkResourceUpdated, resource.EventMeta(network))
})
+ change = affectedpeers.Change{Resources: []*types.NetworkResource{oldResource, resource}}
+ if snap, err = affectedpeers.Load(ctx, transaction, resource.AccountID, change); err != nil {
+ return err
+ }
+
err = transaction.IncrementNetworkSerial(ctx, resource.AccountID)
if err != nil {
return fmt.Errorf("failed to increment network serial: %w", err)
@@ -270,7 +297,7 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc
}
}()
- go m.accountManager.UpdateAccountPeers(ctx, resource.AccountID, nbtypes.UpdateReason{Resource: nbtypes.UpdateResourceNetworkResource, Operation: nbtypes.UpdateOperationUpdate})
+ m.accountManager.ExpandAndUpdateAffected(ctx, resource.AccountID, snap, change)
return resource, nil
}
@@ -331,8 +358,26 @@ func (m *managerImpl) DeleteResource(ctx context.Context, accountID, userID, net
}
var events []func()
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- events, err = m.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resourceID)
+ existing, err := transaction.GetNetworkResourceByID(ctx, store.LockingStrengthUpdate, accountID, resourceID)
+ if err != nil {
+ return fmt.Errorf("failed to get network resource: %w", err)
+ }
+ oldGroups, err := m.groupsManager.GetResourceGroupsInTransaction(ctx, transaction, store.LockingStrengthNone, accountID, resourceID)
+ if err != nil {
+ return fmt.Errorf("failed to get resource groups: %w", err)
+ }
+ for _, g := range oldGroups {
+ existing.GroupIDs = append(existing.GroupIDs, g.ID)
+ }
+ change = affectedpeers.Change{Resources: []*types.NetworkResource{existing}}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
+ _, events, err = m.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resourceID)
if err != nil {
return fmt.Errorf("failed to delete resource: %w", err)
}
@@ -352,51 +397,53 @@ func (m *managerImpl) DeleteResource(ctx context.Context, accountID, userID, net
event()
}
- go m.accountManager.UpdateAccountPeers(ctx, accountID, nbtypes.UpdateReason{Resource: nbtypes.UpdateResourceNetworkResource, Operation: nbtypes.UpdateOperationDelete})
+ m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
-func (m *managerImpl) DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) ([]func(), error) {
+func (m *managerImpl) DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) (*types.NetworkResource, []func(), error) {
resource, err := transaction.GetNetworkResourceByID(ctx, store.LockingStrengthUpdate, accountID, resourceID)
if err != nil {
- return nil, fmt.Errorf("failed to get network resource: %w", err)
+ return nil, nil, fmt.Errorf("failed to get network resource: %w", err)
}
network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, accountID, networkID)
if err != nil {
- return nil, fmt.Errorf("failed to get network: %w", err)
+ return nil, nil, fmt.Errorf("failed to get network: %w", err)
}
if resource.NetworkID != networkID {
- return nil, errors.New("resource not part of network")
+ return nil, nil, errors.New("resource not part of network")
}
groups, err := m.groupsManager.GetResourceGroupsInTransaction(ctx, transaction, store.LockingStrengthUpdate, accountID, resourceID)
if err != nil {
- return nil, fmt.Errorf("failed to get resource groups: %w", err)
+ return nil, nil, fmt.Errorf("failed to get resource groups: %w", err)
}
var eventsToStore []func()
for _, group := range groups {
+ resource.GroupIDs = append(resource.GroupIDs, group.ID)
+
event, err := m.groupsManager.RemoveResourceFromGroupInTransaction(ctx, transaction, accountID, userID, group.ID, resourceID)
if err != nil {
- return nil, fmt.Errorf("failed to remove resource from group: %w", err)
+ return nil, nil, fmt.Errorf("failed to remove resource from group: %w", err)
}
eventsToStore = append(eventsToStore, event)
}
err = transaction.DeleteNetworkResource(ctx, accountID, resourceID)
if err != nil {
- return nil, fmt.Errorf("failed to delete network resource: %w", err)
+ return nil, nil, fmt.Errorf("failed to delete network resource: %w", err)
}
eventsToStore = append(eventsToStore, func() {
m.accountManager.StoreEvent(ctx, userID, resourceID, accountID, activity.NetworkResourceDeleted, resource.EventMeta(network))
})
- return eventsToStore, nil
+ return resource, eventsToStore, nil
}
func NewManagerMock() Manager {
@@ -431,6 +478,6 @@ func (m *mockManager) DeleteResource(ctx context.Context, accountID, userID, net
return nil
}
-func (m *mockManager) DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) ([]func(), error) {
- return []func(){}, nil
+func (m *mockManager) DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) (*types.NetworkResource, []func(), error) {
+ return nil, []func(){}, nil
}
diff --git a/management/server/networks/routers/manager.go b/management/server/networks/routers/manager.go
index 9fa2b95f7..cff387a7c 100644
--- a/management/server/networks/routers/manager.go
+++ b/management/server/networks/routers/manager.go
@@ -9,13 +9,13 @@ import (
"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/networks/routers/types"
networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
"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"
- serverTypes "github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
@@ -26,7 +26,7 @@ type Manager interface {
GetRouter(ctx context.Context, accountID, userID, networkID, routerID string) (*types.NetworkRouter, error)
UpdateRouter(ctx context.Context, userID string, router *types.NetworkRouter) (*types.NetworkRouter, error)
DeleteRouter(ctx context.Context, accountID, userID, networkID, routerID string) error
- DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (func(), error)
+ DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (*types.NetworkRouter, func(), error)
}
type managerImpl struct {
@@ -90,6 +90,8 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t
}
var network *networkTypes.Network
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{Routers: []*types.NetworkRouter{router}}
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
network, err = transaction.GetNetworkByID(ctx, store.LockingStrengthNone, router.AccountID, router.NetworkID)
if err != nil {
@@ -112,6 +114,10 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t
return fmt.Errorf("failed to increment network serial: %w", err)
}
+ if snap, err = affectedpeers.Load(ctx, transaction, router.AccountID, change); err != nil {
+ return err
+ }
+
return nil
})
if err != nil {
@@ -120,7 +126,7 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t
m.accountManager.StoreEvent(ctx, userID, router.ID, router.AccountID, activity.NetworkRouterCreated, router.EventMeta(network))
- go m.accountManager.UpdateAccountPeers(ctx, router.AccountID, serverTypes.UpdateReason{Resource: serverTypes.UpdateResourceNetworkRouter, Operation: serverTypes.UpdateOperationCreate})
+ m.accountManager.ExpandAndUpdateAffected(ctx, router.AccountID, snap, change)
return router, nil
}
@@ -156,36 +162,12 @@ func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *t
}
var network *networkTypes.Network
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- network, err = transaction.GetNetworkByID(ctx, store.LockingStrengthNone, router.AccountID, router.NetworkID)
- if err != nil {
- return fmt.Errorf("failed to get network: %w", err)
- }
-
- existing, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, router.AccountID, router.ID)
- if err != nil {
- return fmt.Errorf("failed to get network router: %w", err)
- }
-
- if existing.AccountID != router.AccountID {
- return status.NewNetworkRouterNotFoundError(router.ID)
- }
-
- if existing.NetworkID != router.NetworkID {
- return status.NewRouterNotPartOfNetworkError(router.ID, router.NetworkID)
- }
-
- err = transaction.UpdateNetworkRouter(ctx, router)
- if err != nil {
- return fmt.Errorf("failed to update network router: %w", err)
- }
-
- err = transaction.IncrementNetworkSerial(ctx, router.AccountID)
- if err != nil {
- return fmt.Errorf("failed to increment network serial: %w", err)
- }
-
- return nil
+ var txErr error
+ network, snap, change, txErr = m.updateRouterInTransaction(ctx, transaction, router)
+ return txErr
})
if err != nil {
return nil, err
@@ -193,11 +175,47 @@ func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *t
m.accountManager.StoreEvent(ctx, userID, router.ID, router.AccountID, activity.NetworkRouterUpdated, router.EventMeta(network))
- go m.accountManager.UpdateAccountPeers(ctx, router.AccountID, serverTypes.UpdateReason{Resource: serverTypes.UpdateResourceNetworkRouter, Operation: serverTypes.UpdateOperationUpdate})
+ m.accountManager.ExpandAndUpdateAffected(ctx, router.AccountID, snap, change)
return router, nil
}
+func (m *managerImpl) updateRouterInTransaction(ctx context.Context, transaction store.Store, router *types.NetworkRouter) (*networkTypes.Network, *affectedpeers.Snapshot, affectedpeers.Change, error) {
+ network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthNone, router.AccountID, router.NetworkID)
+ if err != nil {
+ return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to get network: %w", err)
+ }
+
+ existing, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, router.AccountID, router.ID)
+ if err != nil {
+ return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to get network router: %w", err)
+ }
+
+ if existing.AccountID != router.AccountID {
+ return nil, nil, affectedpeers.Change{}, status.NewNetworkRouterNotFoundError(router.ID)
+ }
+
+ if existing.NetworkID != router.NetworkID {
+ return nil, nil, affectedpeers.Change{}, status.NewRouterNotPartOfNetworkError(router.ID, router.NetworkID)
+ }
+
+ if err = transaction.UpdateNetworkRouter(ctx, router); err != nil {
+ return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to update network router: %w", err)
+ }
+
+ if err = transaction.IncrementNetworkSerial(ctx, router.AccountID); err != nil {
+ return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to increment network serial: %w", err)
+ }
+
+ change := affectedpeers.Change{Routers: []*types.NetworkRouter{existing, router}}
+ snap, err := affectedpeers.Load(ctx, transaction, router.AccountID, change)
+ if err != nil {
+ return nil, nil, affectedpeers.Change{}, err
+ }
+
+ return network, snap, change, nil
+}
+
func (m *managerImpl) DeleteRouter(ctx context.Context, accountID, userID, networkID, routerID string) error {
ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete)
if err != nil {
@@ -208,8 +226,19 @@ func (m *managerImpl) DeleteRouter(ctx context.Context, accountID, userID, netwo
}
var event func()
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- event, err = m.DeleteRouterInTransaction(ctx, transaction, accountID, userID, networkID, routerID)
+ existing, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, accountID, routerID)
+ if err != nil {
+ return fmt.Errorf("failed to get network router: %w", err)
+ }
+ change = affectedpeers.Change{Routers: []*types.NetworkRouter{existing}}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
+ _, event, err = m.DeleteRouterInTransaction(ctx, transaction, accountID, userID, networkID, routerID)
if err != nil {
return fmt.Errorf("failed to delete network router: %w", err)
}
@@ -227,36 +256,36 @@ func (m *managerImpl) DeleteRouter(ctx context.Context, accountID, userID, netwo
event()
- go m.accountManager.UpdateAccountPeers(ctx, accountID, serverTypes.UpdateReason{Resource: serverTypes.UpdateResourceNetworkRouter, Operation: serverTypes.UpdateOperationDelete})
+ m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
-func (m *managerImpl) DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (func(), error) {
+func (m *managerImpl) DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (*types.NetworkRouter, func(), error) {
network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthNone, accountID, networkID)
if err != nil {
- return nil, fmt.Errorf("failed to get network: %w", err)
+ return nil, nil, fmt.Errorf("failed to get network: %w", err)
}
router, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, accountID, routerID)
if err != nil {
- return nil, fmt.Errorf("failed to get network router: %w", err)
+ return nil, nil, fmt.Errorf("failed to get network router: %w", err)
}
if router.NetworkID != networkID {
- return nil, status.NewRouterNotPartOfNetworkError(routerID, networkID)
+ return nil, nil, status.NewRouterNotPartOfNetworkError(routerID, networkID)
}
err = transaction.DeleteNetworkRouter(ctx, accountID, routerID)
if err != nil {
- return nil, fmt.Errorf("failed to delete network router: %w", err)
+ return nil, nil, fmt.Errorf("failed to delete network router: %w", err)
}
event := func() {
m.accountManager.StoreEvent(ctx, userID, routerID, accountID, activity.NetworkRouterDeleted, router.EventMeta(network))
}
- return event, nil
+ return router, event, nil
}
func NewManagerMock() Manager {
@@ -287,6 +316,9 @@ func (m *mockManager) DeleteRouter(ctx context.Context, accountID, userID, netwo
return nil
}
-func (m *mockManager) DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (func(), error) {
- return func() {}, nil
+func (m *mockManager) DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (*types.NetworkRouter, func(), error) {
+ return nil, func() {
+ // no-op mock: returns zero values so tests that don't exercise router deletion
+ // can satisfy the Manager interface without a real store.
+ }, nil
}
diff --git a/management/server/peer.go b/management/server/peer.go
index d4e3ebb49..baf62a7eb 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -27,6 +27,7 @@ import (
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/telemetry"
"github.com/netbirdio/netbird/shared/management/status"
@@ -73,7 +74,7 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID
//
// Disconnects use MarkPeerDisconnected and require the session to match
// exactly; see PeerStatus.SessionStartedAt for the protocol.
-func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, realIP net.IP, accountID string, sessionStartedAt int64) error {
+func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, realIP net.IP, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error {
start := time.Now()
defer func() {
am.metrics.AccountManagerMetrics().RecordPeerStatusUpdateDuration(telemetry.PeerStatusConnect, time.Since(start))
@@ -105,35 +106,22 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK
am.updatePeerLocationIfChanged(ctx, accountID, peer, realIP)
}
- expired := peer.Status != nil && peer.Status.LoginExpired
-
- if peer.AddedWithSSOLogin() {
- settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
- if err != nil {
- return err
- }
- if peer.LoginExpirationEnabled && settings.PeerLoginExpirationEnabled {
- am.schedulePeerLoginExpiration(ctx, accountID)
- }
- if peer.InactivityExpirationEnabled && settings.PeerInactivityExpirationEnabled {
- am.checkAndSchedulePeerInactivityExpiration(ctx, accountID)
- }
+ if err = am.schedulePeerExpirations(ctx, accountID, peer); err != nil {
+ return err
}
- if expired {
- if err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}); err != nil {
+ // A login-expired peer reconnecting, or an embedded proxy peer flipping to
+ // connected (which triggers SynthesizePrivateServiceZones), must refresh the
+ // peers reachable from it. The embedded-proxy fan-out tolerates a dispatch error.
+ if peer.Status != nil && peer.Status.LoginExpired {
+ affectedPeerIDs := am.markConnectedAffectedPeers(ctx, accountID, peer.ID, nmap)
+ if err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}, affectedPeerIDs); err != nil {
return fmt.Errorf("notify network map controller of peer update: %w", err)
}
}
-
- // An embedded proxy peer flipping to connected is the trigger for
- // SynthesizePrivateServiceZones to emit DNS A records pointing at its
- // tunnel IP. Without an account-wide netmap recompute, user peers keep
- // the stale synth (or no synth at all on first connect) until some
- // other change pokes the controller. Fire OnPeersUpdated so the
- // buffered recompute fans the new state out to every peer.
if peer.ProxyMeta.Embedded {
- if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}); err != nil {
+ affectedPeerIDs := am.markConnectedAffectedPeers(ctx, accountID, peer.ID, nmap)
+ if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}, affectedPeerIDs); err != nil {
log.WithContext(ctx).Warnf("notify network map controller of embedded proxy %s connect: %v", peer.ID, err)
}
}
@@ -141,6 +129,25 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK
return nil
}
+// schedulePeerExpirations reschedules 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() {
+ return nil
+ }
+ settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID)
+ if err != nil {
+ return err
+ }
+ if peer.LoginExpirationEnabled && settings.PeerLoginExpirationEnabled {
+ am.schedulePeerLoginExpiration(ctx, accountID)
+ }
+ if peer.InactivityExpirationEnabled && settings.PeerInactivityExpirationEnabled {
+ am.checkAndSchedulePeerInactivityExpiration(ctx, accountID)
+ }
+ return nil
+}
+
// MarkPeerDisconnected marks a peer as disconnected, but only when the
// stored session token matches the one passed in. A mismatch means a
// newer stream has already taken ownership of the peer — disconnects from
@@ -175,11 +182,12 @@ func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerP
am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusApplied)
// Symmetric with MarkPeerConnected: when an embedded proxy peer goes
- // offline, drive an account-wide netmap recompute so the synthesized
- // DNS records that pointed at it are pulled. Without this the records
- // linger client-side at TTL until something else triggers a refresh.
+ // offline, refresh the peers that had synthesized records pointing at
+ // it so they pull the stale entries instead of waiting out TTL.
if peer.ProxyMeta.Embedded {
- if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}); err != nil {
+ changedPeerIDs := []string{peer.ID}
+ affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs)
+ if err := am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil {
log.WithContext(ctx).Warnf("notify network map controller of embedded proxy %s disconnect: %v", peer.ID, err)
}
}
@@ -346,7 +354,10 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user
}
}
- err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID})
+ changedPeerIDs := []string{peer.ID}
+ affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs)
+ affectedPeerIDs = append(affectedPeerIDs, peer.ID)
+ err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs)
if err != nil {
return nil, fmt.Errorf("notify network map controller of peer update: %w", err)
}
@@ -501,10 +512,6 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer
return status.NewPeerNotPartOfAccountError()
}
- var peer *nbpeer.Peer
- var settings *types.Settings
- var eventsToStore []func()
-
serviceID, err := am.serviceManager.GetServiceIDByTargetID(ctx, accountID, peerID)
if err != nil {
return fmt.Errorf("failed to check if resource is used by service: %w", err)
@@ -513,8 +520,38 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer
return status.NewPeerInUseError(peerID, serviceID)
}
- err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- peer, err = transaction.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
+ change := affectedpeers.Change{ChangedPeerIDs: []string{peerID}}
+ settings, eventsToStore, snap, err := am.deletePeerInTransaction(ctx, accountID, userID, peerID, change)
+ if err != nil {
+ return err
+ }
+
+ for _, storeEvent := range eventsToStore {
+ storeEvent()
+ }
+
+ if err = am.integratedPeerValidator.PeerDeleted(ctx, accountID, peerID, settings.Extra); err != nil {
+ log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peerID, err)
+ }
+
+ affectedPeerIDs := snap.Expand(ctx, accountID, change)
+ if err = am.networkMapController.OnPeersDeleted(ctx, accountID, []string{peerID}, affectedPeerIDs); err != nil {
+ log.WithContext(ctx).Errorf("failed to delete peer %s from network map: %v", peerID, err)
+ }
+
+ return nil
+}
+
+// deletePeerInTransaction loads the peer + settings, captures the affected-peers
+// snapshot (before the delete, while the peer's group memberships still exist),
+// then deletes the peer and bumps the network serial — all in one transaction.
+func (am *DefaultAccountManager) deletePeerInTransaction(ctx context.Context, accountID, userID, peerID string, change affectedpeers.Change) (*types.Settings, []func(), *affectedpeers.Snapshot, error) {
+ var settings *types.Settings
+ var eventsToStore []func()
+ var snap *affectedpeers.Snapshot
+
+ err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
+ peer, err := transaction.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID)
if err != nil {
return err
}
@@ -528,8 +565,11 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer
return err
}
- eventsToStore, err = deletePeers(ctx, am, transaction, accountID, userID, []*nbpeer.Peer{peer}, settings)
- if err != nil {
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
+ if eventsToStore, err = deletePeers(ctx, am, transaction, accountID, userID, []*nbpeer.Peer{peer}, settings); err != nil {
return fmt.Errorf("failed to delete peer: %w", err)
}
@@ -539,23 +579,7 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer
return nil
})
- if err != nil {
- return err
- }
-
- for _, storeEvent := range eventsToStore {
- storeEvent()
- }
-
- if err = am.integratedPeerValidator.PeerDeleted(ctx, accountID, peerID, settings.Extra); err != nil {
- log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peerID, err)
- }
-
- if err = am.networkMapController.OnPeersDeleted(ctx, accountID, []string{peerID}); err != nil {
- log.WithContext(ctx).Errorf("failed to delete peer %s from network map: %v", peerID, err)
- }
-
- return nil
+ return settings, eventsToStore, snap, err
}
// GetNetworkMap returns Network map for a given peer (omits original peer from the Peers result)
@@ -924,12 +948,18 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
am.StoreEvent(ctx, opEvent.InitiatorID, opEvent.TargetID, opEvent.AccountID, opEvent.Activity, opEvent.Meta)
}
- if err := am.networkMapController.OnPeersAdded(ctx, accountID, []string{newPeer.ID}); err != nil {
+ p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, false, accountID, newPeer)
+ if err != nil {
+ return p, nmap, pc, err
+ }
+
+ changedPeerIDs := []string{newPeer.ID}
+ affectedPeerIDs := affectedPeerIDsFromNetworkMap(nmap, newPeer.ID)
+ if err := am.networkMapController.OnPeersAdded(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil {
log.WithContext(ctx).Errorf("failed to update network map cache for peer %s: %v", newPeer.ID, err)
}
- p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, false, accountID, newPeer)
- return p, nmap, pc, err
+ return p, nmap, pc, nil
}
func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) {
@@ -1011,14 +1041,48 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy
return nil, nil, nil, 0, err
}
+ resPeer, nmap, resPostureChecks, dnsFwdPort, err := am.networkMapController.GetValidatedPeerWithMap(ctx, peerNotValid, accountID, peer)
+ if err != nil {
+ return nil, nil, nil, 0, err
+ }
+
if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || (updated && (len(postureChecks) > 0 || versionChanged)) {
- err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID})
- if err != nil {
+ changedPeerIDs := []string{peer.ID}
+ affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, updated, len(postureChecks) > 0)
+ if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil {
return nil, nil, nil, 0, fmt.Errorf("notify network map controller of peer update: %w", err)
}
}
- return am.networkMapController.GetValidatedPeerWithMap(ctx, peerNotValid, accountID, peer)
+ return resPeer, nmap, resPostureChecks, dnsFwdPort, nil
+}
+
+// syncPeerAffectedPeers resolves the peers affected by a SyncPeer change. The
+// peer's own validated network map is bidirectional for policy and routing
+// reachability, so when the peer stays valid and no source-posture gate is in
+// play it already lists every affected peer — reuse it and skip the full
+// dependency walk. Posture checks gate the source side of a policy only, so a
+// metadata change that flips a posture result removes this peer from others'
+// maps asymmetrically; that case (and an invalid peer, whose map is empty) falls
+// back to the resolver.
+func (am *DefaultAccountManager) syncPeerAffectedPeers(ctx context.Context, accountID, peerID string, nmap *types.NetworkMap, peerNotValid, metaUpdated, hasPostureChecks bool) []string {
+ if peerNotValid || (metaUpdated && hasPostureChecks) {
+ return am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, []string{peerID})
+ }
+ return affectedPeerIDsFromNetworkMap(nmap, peerID)
+}
+
+// markConnectedAffectedPeers resolves the peers affected when a peer connects
+// (login-expiry reconnect or embedded-proxy connect). The connecting peer's
+// network map already lists them bidirectionally — the synthesized
+// private-service policy puts proxy access-group members in the proxy peer's own
+// map, and these edges carry no source-posture gate. An invalid peer has an
+// empty map, so fall back to the resolver in that case.
+func (am *DefaultAccountManager) markConnectedAffectedPeers(ctx context.Context, accountID, peerID string, nmap *types.NetworkMap) []string {
+ if nmap == nil || len(nmap.Peers)+len(nmap.OfflinePeers) == 0 {
+ return am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, []string{peerID})
+ }
+ return affectedPeerIDsFromNetworkMap(nmap, peerID)
}
func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) {
@@ -1141,15 +1205,20 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer
return nil, nil, nil, err
}
+ p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, peer)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+
if updateRemotePeers || isStatusChanged || ipv6CapabilityChanged || (isPeerUpdated && len(postureChecks) > 0) {
- err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID})
- if err != nil {
+ changedPeerIDs := []string{peer.ID}
+ affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, isRequiresApproval, isPeerUpdated, len(postureChecks) > 0)
+ if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil {
return nil, nil, nil, fmt.Errorf("notify network map controller of peer update: %w", err)
}
}
- p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, peer)
- return p, nmap, pc, err
+ return p, nmap, pc, nil
}
// ExtendPeerSession refreshes the peer's SSO session deadline by updating
@@ -1407,6 +1476,100 @@ func (am *DefaultAccountManager) UpdateAccountPeers(ctx context.Context, account
_ = am.networkMapController.UpdateAccountPeers(ctx, accountID, reason)
}
+// 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.
+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})
+}
+
+// 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) {
+ 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
+ }
+
+ log.WithContext(ctx).Debugf("updating %d affected peers for account %s: %v", len(affectedPeerIDs), accountID, affectedPeerIDs)
+ _ = 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)
+ }
+ }
+ return out
+}
+
+// affectedPeerIDsFromNetworkMap returns the peer IDs referenced by a peer's
+// network map (its connected and offline peers, which include routing and proxy
+// peers), excluding the peer itself. For a freshly added peer these are, by ACL
+// symmetry, exactly the peers its addition affects.
+func affectedPeerIDsFromNetworkMap(nmap *types.NetworkMap, selfPeerID string) []string {
+ if nmap == nil {
+ return nil
+ }
+ seen := make(map[string]struct{}, len(nmap.Peers)+len(nmap.OfflinePeers))
+ ids := make([]string, 0, len(nmap.Peers)+len(nmap.OfflinePeers))
+ add := func(peers []*nbpeer.Peer) {
+ for _, p := range peers {
+ if p == nil || p.ID == "" || p.ID == selfPeerID {
+ continue
+ }
+ if _, ok := seen[p.ID]; ok {
+ continue
+ }
+ seen[p.ID] = struct{}{}
+ ids = append(ids, p.ID)
+ }
+ }
+ add(nmap.Peers)
+ add(nmap.OfflinePeers)
+ return ids
+}
+
+// resolveAffectedPeersForPeerChanges loads a snapshot and expands it for a peer
+// change. The graph is unchanged by these paths, so it runs out of the mutating
+// transaction (after commit); the resolver derives the peers' group memberships
+// during the walk, so the caller passes only the changed peer IDs.
+func (am *DefaultAccountManager) resolveAffectedPeersForPeerChanges(ctx context.Context, s store.Store, accountID string, changedPeerIDs []string) []string {
+ change := affectedpeers.Change{ChangedPeerIDs: changedPeerIDs}
+ snap, err := affectedpeers.Load(ctx, s, accountID, change)
+ if err != nil {
+ log.WithContext(ctx).Errorf("failed to load snapshot for affected peers: %v", err)
+ return nil
+ }
+ return snap.Expand(ctx, accountID, change)
+}
+
func (am *DefaultAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) {
_ = am.networkMapController.BufferUpdateAccountPeers(ctx, accountID, reason)
}
diff --git a/management/server/peer_test.go b/management/server/peer_test.go
index 9d6856740..ee1b33da2 100644
--- a/management/server/peer_test.go
+++ b/management/server/peer_test.go
@@ -1855,7 +1855,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
t.Run("adding peer to unlinked group", func(t *testing.T) {
done := make(chan struct{})
go func() {
- peerShouldReceiveUpdate(t, updMsg) //
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -1880,7 +1880,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
t.Run("deleting peer with unlinked group", func(t *testing.T) {
done := make(chan struct{})
go func() {
- peerShouldReceiveUpdate(t, updMsg)
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -2018,7 +2018,10 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
}
})
- // Adding peer to group linked with route should update account peers and send peer update
+ // 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
t.Run("adding peer to group linked with route", func(t *testing.T) {
route := nbroute.Route{
ID: "testingRoute1",
@@ -2042,7 +2045,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
done := make(chan struct{})
go func() {
- peerShouldReceiveUpdate(t, updMsg)
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -2059,16 +2062,16 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
select {
case <-done:
- case <-time.After(peerUpdateTimeout):
- t.Error("timeout waiting for peerShouldReceiveUpdate")
+ case <-time.After(time.Second):
+ t.Error("timeout waiting for peerShouldNotReceiveUpdate")
}
})
- // Deleting peer with linked group to route should update account peers and send peer update
+ // 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() {
- peerShouldReceiveUpdate(t, updMsg)
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -2077,12 +2080,12 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
select {
case <-done:
- case <-time.After(peerUpdateTimeout):
- t.Error("timeout waiting for peerShouldReceiveUpdate")
+ case <-time.After(time.Second):
+ t.Error("timeout waiting for peerShouldNotReceiveUpdate")
}
})
- // Adding peer to group linked with name server group should update account peers and send peer update
+ // 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{{
@@ -2097,7 +2100,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
done := make(chan struct{})
go func() {
- peerShouldReceiveUpdate(t, updMsg)
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -2114,16 +2117,16 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
select {
case <-done:
- case <-time.After(peerUpdateTimeout):
- t.Error("timeout waiting for peerShouldReceiveUpdate")
+ case <-time.After(time.Second):
+ t.Error("timeout waiting for peerShouldNotReceiveUpdate")
}
})
- // Deleting peer with linked group to name server group should update account peers and send peer update
+ // 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() {
- peerShouldReceiveUpdate(t, updMsg)
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -2132,8 +2135,8 @@ func TestPeerAccountPeersUpdate(t *testing.T) {
select {
case <-done:
- case <-time.After(peerUpdateTimeout):
- t.Error("timeout waiting for peerShouldReceiveUpdate")
+ case <-time.After(time.Second):
+ t.Error("timeout waiting for peerShouldNotReceiveUpdate")
}
})
}
diff --git a/management/server/policy.go b/management/server/policy.go
index d67b3206e..187c879cb 100644
--- a/management/server/policy.go
+++ b/management/server/policy.go
@@ -5,7 +5,7 @@ import (
_ "embed"
"github.com/rs/xid"
- "github.com/sirupsen/logrus"
+ log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
@@ -13,6 +13,7 @@ import (
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
"github.com/netbirdio/netbird/management/server/posture"
"github.com/netbirdio/netbird/shared/management/status"
)
@@ -45,44 +46,47 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user
}
var isUpdate = policy.ID != ""
- var updateAccountPeers bool
+ var existingPolicy *types.Policy
var action = activity.PolicyAdded
var unchanged bool
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- existingPolicy, err := validatePolicy(ctx, transaction, accountID, policy)
+ existingPolicy, err = validatePolicy(ctx, transaction, accountID, policy)
if err != nil {
return err
}
if isUpdate {
if policy.Equal(existingPolicy) {
- logrus.WithContext(ctx).Tracef("policy update skipped because equal to stored one - policy id %s", policy.ID)
+ log.WithContext(ctx).Tracef("policy update skipped because equal to stored one - policy id %s", policy.ID)
unchanged = true
return nil
}
action = activity.PolicyUpdated
- updateAccountPeers, err = arePolicyChangesAffectPeersWithExisting(ctx, transaction, policy, existingPolicy)
- if err != nil {
- return err
- }
-
if err = transaction.SavePolicy(ctx, policy); err != nil {
return err
}
} else {
- updateAccountPeers, err = arePolicyChangesAffectPeers(ctx, transaction, policy)
- if err != nil {
- return err
- }
-
if err = transaction.CreatePolicy(ctx, policy); err != nil {
return err
}
}
+ // On update carry both the old and new policy so peers losing access via a
+ // removed rule still refresh; on create there is no prior policy.
+ if isUpdate {
+ change = affectedpeers.Change{Policies: []*types.Policy{existingPolicy, policy}}
+ } else {
+ change = affectedpeers.Change{Policies: []*types.Policy{policy}}
+ }
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {
@@ -95,13 +99,7 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user
am.StoreEvent(ctx, userID, policy.ID, accountID, action, policy.EventMeta())
- if updateAccountPeers {
- policyOp := types.UpdateOperationCreate
- if isUpdate {
- policyOp = types.UpdateOperationUpdate
- }
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePolicy, Operation: policyOp})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return policy, nil
}
@@ -117,7 +115,8 @@ func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, po
}
var policy *types.Policy
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
policy, err = transaction.GetPolicyByID(ctx, store.LockingStrengthUpdate, accountID, policyID)
@@ -125,8 +124,9 @@ func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, po
return err
}
- updateAccountPeers, err = arePolicyChangesAffectPeers(ctx, transaction, policy)
- if err != nil {
+ // Load before delete: pre-state still references the policy.
+ change = affectedpeers.Change{Policies: []*types.Policy{policy}}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -142,9 +142,7 @@ func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, po
am.StoreEvent(ctx, userID, policyID, accountID, activity.PolicyRemoved, policy.EventMeta())
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePolicy, Operation: types.UpdateOperationDelete})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
@@ -162,46 +160,6 @@ func (am *DefaultAccountManager) ListPolicies(ctx context.Context, accountID, us
return am.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
}
-// arePolicyChangesAffectPeers checks if a policy (being created or deleted) will affect any associated peers.
-func arePolicyChangesAffectPeers(ctx context.Context, transaction store.Store, policy *types.Policy) (bool, error) {
- for _, rule := range policy.Rules {
- if rule.SourceResource.Type != "" || rule.DestinationResource.Type != "" {
- return true, nil
- }
- }
-
- return anyGroupHasPeersOrResources(ctx, transaction, policy.AccountID, policy.RuleGroups())
-}
-
-func arePolicyChangesAffectPeersWithExisting(ctx context.Context, transaction store.Store, policy *types.Policy, existingPolicy *types.Policy) (bool, error) {
- if !policy.Enabled && !existingPolicy.Enabled {
- return false, nil
- }
-
- for _, rule := range existingPolicy.Rules {
- if rule.SourceResource.Type != "" || rule.DestinationResource.Type != "" {
- return true, nil
- }
- }
-
- hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, policy.AccountID, existingPolicy.RuleGroups())
- if err != nil {
- return false, err
- }
-
- if hasPeers {
- return true, nil
- }
-
- for _, rule := range policy.Rules {
- if rule.SourceResource.Type != "" || rule.DestinationResource.Type != "" {
- return true, nil
- }
- }
-
- return anyGroupHasPeersOrResources(ctx, transaction, policy.AccountID, policy.RuleGroups())
-}
-
// validatePolicy validates the policy and its rules. For updates it returns
// the existing policy loaded from the store so callers can avoid a second read.
func validatePolicy(ctx context.Context, transaction store.Store, accountID string, policy *types.Policy) (*types.Policy, error) {
diff --git a/management/server/policy_test.go b/management/server/policy_test.go
index 1eae07e79..6fb573b9e 100644
--- a/management/server/policy_test.go
+++ b/management/server/policy_test.go
@@ -1319,12 +1319,14 @@ func TestPolicyAccountPeersUpdate(t *testing.T) {
}
})
- // Updating disabled policy with destination and source groups containing peers should not update account's peers
- // or send peer update
+ // 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() {
- peerShouldNotReceiveUpdate(t, updMsg)
+ peerShouldReceiveUpdate(t, updMsg)
close(done)
}()
@@ -1335,8 +1337,8 @@ func TestPolicyAccountPeersUpdate(t *testing.T) {
select {
case <-done:
- case <-time.After(time.Second):
- t.Error("timeout waiting for peerShouldNotReceiveUpdate")
+ case <-time.After(peerUpdateTimeout):
+ t.Error("timeout waiting for peerShouldReceiveUpdate")
}
})
diff --git a/management/server/posture_checks.go b/management/server/posture_checks.go
index 56a732bf5..1d962438c 100644
--- a/management/server/posture_checks.go
+++ b/management/server/posture_checks.go
@@ -7,11 +7,11 @@ import (
"github.com/rs/xid"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/posture"
"github.com/netbirdio/netbird/management/server/store"
- "github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/status"
)
@@ -41,9 +41,10 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI
return nil, status.NewPermissionDeniedError()
}
- var updateAccountPeers bool
var isUpdate = postureChecks.ID != ""
var action = activity.PostureCheckCreated
+ var snap *affectedpeers.Snapshot
+ change := affectedpeers.Change{PostureCheckIDs: []string{postureChecks.ID}}
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err = validatePostureChecks(ctx, transaction, accountID, postureChecks); err != nil {
@@ -51,11 +52,6 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI
}
if isUpdate {
- updateAccountPeers, err = arePostureCheckChangesAffectPeers(ctx, transaction, accountID, postureChecks.ID)
- if err != nil {
- return err
- }
-
action = activity.PostureCheckUpdated
}
@@ -65,6 +61,11 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI
}
if isUpdate {
+ // Editing a posture check does not change which policies reference it,
+ // so loading after the save is fine.
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
return transaction.IncrementNetworkSerial(ctx, accountID)
}
@@ -76,13 +77,7 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI
am.StoreEvent(ctx, userID, postureChecks.ID, accountID, action, postureChecks.EventMeta())
- if updateAccountPeers {
- postureOp := types.UpdateOperationCreate
- if isUpdate {
- postureOp = types.UpdateOperationUpdate
- }
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePostureCheck, Operation: postureOp})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return postureChecks, nil
}
@@ -137,29 +132,6 @@ func (am *DefaultAccountManager) ListPostureChecks(ctx context.Context, accountI
return am.Store.GetAccountPostureChecks(ctx, store.LockingStrengthNone, accountID)
}
-// arePostureCheckChangesAffectPeers checks if the changes in posture checks are affecting peers.
-func arePostureCheckChangesAffectPeers(ctx context.Context, transaction store.Store, accountID, postureCheckID string) (bool, error) {
- policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID)
- if err != nil {
- return false, err
- }
-
- for _, policy := range policies {
- if slices.Contains(policy.SourcePostureChecks, postureCheckID) {
- hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, accountID, policy.RuleGroups())
- if err != nil {
- return false, err
- }
-
- if hasPeers {
- return true, nil
- }
- }
- }
-
- return false, nil
-}
-
// validatePostureChecks validates the posture checks.
func validatePostureChecks(ctx context.Context, transaction store.Store, accountID string, postureChecks *posture.Checks) error {
if err := postureChecks.Validate(); err != nil {
diff --git a/management/server/posture_checks_test.go b/management/server/posture_checks_test.go
index 394f0d896..14bc2c45a 100644
--- a/management/server/posture_checks_test.go
+++ b/management/server/posture_checks_test.go
@@ -503,21 +503,20 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) {
require.NoError(t, err, "failed to save policy")
t.Run("posture check exists and is linked to policy with peers", func(t *testing.T) {
- result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
- require.NoError(t, err)
- assert.True(t, result)
+ groupIDs, _ := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
+ assert.NotEmpty(t, groupIDs)
})
t.Run("posture check exists but is not linked to any policy", func(t *testing.T) {
- result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckB.ID)
- require.NoError(t, err)
- assert.False(t, result)
+ groupIDs, directPeerIDs := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckB.ID)
+ assert.Empty(t, groupIDs)
+ assert.Empty(t, directPeerIDs)
})
t.Run("posture check does not exist", func(t *testing.T) {
- result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, "unknown")
- require.NoError(t, err)
- assert.False(t, result)
+ groupIDs, directPeerIDs := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, "unknown")
+ assert.Empty(t, groupIDs)
+ assert.Empty(t, directPeerIDs)
})
t.Run("posture check is linked to policy with no peers in source groups", func(t *testing.T) {
@@ -526,9 +525,8 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) {
_, err = manager.SavePolicy(context.Background(), account.Id, adminUserID, policy, true)
require.NoError(t, err, "failed to update policy")
- result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
- require.NoError(t, err)
- assert.True(t, result)
+ groupIDs, _ := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
+ assert.NotEmpty(t, groupIDs)
})
t.Run("posture check is linked to policy with no peers in destination groups", func(t *testing.T) {
@@ -537,9 +535,8 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) {
_, err = manager.SavePolicy(context.Background(), account.Id, adminUserID, policy, true)
require.NoError(t, err, "failed to update policy")
- result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
- require.NoError(t, err)
- assert.True(t, result)
+ groupIDs, _ := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
+ assert.NotEmpty(t, groupIDs)
})
t.Run("posture check is linked to policy but no peers in groups", func(t *testing.T) {
@@ -547,9 +544,9 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) {
err = manager.UpdateGroup(context.Background(), account.Id, adminUserID, groupA)
require.NoError(t, err, "failed to save groups")
- result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
- require.NoError(t, err)
- assert.False(t, result)
+ // The collector returns groups even if they have no peers — the groups are still referenced
+ groupIDs, _ := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
+ assert.NotEmpty(t, groupIDs)
})
t.Run("posture check is linked to policy with non-existent group", func(t *testing.T) {
@@ -558,8 +555,10 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) {
_, err = manager.SavePolicy(context.Background(), account.Id, adminUserID, policy, true)
require.NoError(t, err, "failed to update policy")
- result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
- require.NoError(t, err)
- assert.False(t, result)
+ // Non-existent groups are filtered out during SavePolicy validation,
+ // so the saved policy has empty Sources/Destinations
+ groupIDs, directPeerIDs := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID)
+ assert.Empty(t, groupIDs)
+ assert.Empty(t, directPeerIDs)
})
}
diff --git a/management/server/route.go b/management/server/route.go
index 8fd1cb02a..08e1489b2 100644
--- a/management/server/route.go
+++ b/management/server/route.go
@@ -10,6 +10,7 @@ import (
"github.com/rs/xid"
"github.com/netbirdio/netbird/management/server/activity"
+ "github.com/netbirdio/netbird/management/server/affectedpeers"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/management/server/store"
@@ -147,7 +148,8 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri
}
var newRoute *route.Route
- var updateAccountPeers bool
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
newRoute = &route.Route{
@@ -173,12 +175,12 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri
return err
}
- updateAccountPeers, err = areRouteChangesAffectPeers(ctx, transaction, newRoute)
- if err != nil {
+ if err = transaction.SaveRoute(ctx, newRoute); err != nil {
return err
}
- if err = transaction.SaveRoute(ctx, newRoute); err != nil {
+ change = affectedpeers.Change{Routes: []*route.Route{newRoute}}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -190,9 +192,7 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri
am.StoreEvent(ctx, userID, string(newRoute.ID), accountID, activity.RouteCreated, newRoute.EventMeta())
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceRoute, Operation: types.UpdateOperationCreate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return newRoute, nil
}
@@ -208,8 +208,8 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI
}
var oldRoute *route.Route
- var oldRouteAffectsPeers bool
- var newRouteAffectsPeers bool
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
if err = validateRoute(ctx, transaction, accountID, routeToSave); err != nil {
@@ -221,21 +221,17 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI
return err
}
- oldRouteAffectsPeers, err = areRouteChangesAffectPeers(ctx, transaction, oldRoute)
- if err != nil {
- return err
- }
-
- newRouteAffectsPeers, err = areRouteChangesAffectPeers(ctx, transaction, routeToSave)
- if err != nil {
- return err
- }
routeToSave.AccountID = accountID
if err = transaction.SaveRoute(ctx, routeToSave); err != nil {
return err
}
+ change = affectedpeers.Change{Routes: []*route.Route{routeToSave, oldRoute}}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
return transaction.IncrementNetworkSerial(ctx, accountID)
})
if err != nil {
@@ -244,9 +240,7 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI
am.StoreEvent(ctx, userID, string(routeToSave.ID), accountID, activity.RouteUpdated, routeToSave.EventMeta())
- if oldRouteAffectsPeers || newRouteAffectsPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceRoute, Operation: types.UpdateOperationUpdate})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
@@ -261,17 +255,19 @@ func (am *DefaultAccountManager) DeleteRoute(ctx context.Context, accountID stri
return status.NewPermissionDeniedError()
}
- var route *route.Route
- var updateAccountPeers bool
+ var rt *route.Route
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
- route, err = transaction.GetRouteByID(ctx, store.LockingStrengthUpdate, accountID, string(routeID))
+ rt, err = transaction.GetRouteByID(ctx, store.LockingStrengthUpdate, accountID, string(routeID))
if err != nil {
return err
}
- updateAccountPeers, err = areRouteChangesAffectPeers(ctx, transaction, route)
- if err != nil {
+ // Load before delete: pre-state captures everyone referencing the route.
+ change = affectedpeers.Change{Routes: []*route.Route{rt}}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
return err
}
@@ -285,11 +281,9 @@ func (am *DefaultAccountManager) DeleteRoute(ctx context.Context, accountID stri
return fmt.Errorf("failed to delete route %s: %w", routeID, err)
}
- am.StoreEvent(ctx, userID, string(route.ID), accountID, activity.RouteRemoved, route.EventMeta())
+ am.StoreEvent(ctx, userID, string(rt.ID), accountID, activity.RouteRemoved, rt.EventMeta())
- if updateAccountPeers {
- am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceRoute, Operation: types.UpdateOperationDelete})
- }
+ am.ExpandAndUpdateAffected(ctx, accountID, snap, change)
return nil
}
@@ -377,25 +371,6 @@ func getPlaceholderIP() netip.Prefix {
return netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 0, 2, 0}), 32)
}
-// areRouteChangesAffectPeers checks if a given route affects peers by determining
-// if it has a routing peer, distribution, or peer groups that include peers.
-func areRouteChangesAffectPeers(ctx context.Context, transaction store.Store, route *route.Route) (bool, error) {
- if route.Peer != "" {
- return true, nil
- }
-
- hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, route.AccountID, route.Groups)
- if err != nil {
- return false, err
- }
-
- if hasPeers {
- return true, nil
- }
-
- return anyGroupHasPeersOrResources(ctx, transaction, route.AccountID, route.PeerGroups)
-}
-
// GetRoutesByPrefixOrDomains return list of routes by account and route prefix
func getRoutesByPrefixOrDomains(ctx context.Context, transaction store.Store, accountID string, prefix netip.Prefix, domains domain.List) ([]*route.Route, error) {
accountRoutes, err := transaction.GetAccountRoutes(ctx, store.LockingStrengthNone, accountID)
diff --git a/management/server/route_test.go b/management/server/route_test.go
index 79014790f..5ae18c253 100644
--- a/management/server/route_test.go
+++ b/management/server/route_test.go
@@ -1962,8 +1962,10 @@ func TestRouteAccountPeersUpdate(t *testing.T) {
})
- // Creating a route with no routing peer and having peers in groups should update account peers and send peer update
+ // 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"),
@@ -1979,7 +1981,7 @@ func TestRouteAccountPeersUpdate(t *testing.T) {
done := make(chan struct{})
go func() {
- peerShouldReceiveUpdate(t, updMsg)
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -1992,8 +1994,8 @@ func TestRouteAccountPeersUpdate(t *testing.T) {
select {
case <-done:
- case <-time.After(peerUpdateTimeout):
- t.Error("timeout waiting for peerShouldReceiveUpdate")
+ case <-time.After(time.Second):
+ t.Error("timeout waiting for peerShouldNotReceiveUpdate")
}
})
diff --git a/management/server/setupkey_test.go b/management/server/setupkey_test.go
index 6eca27efd..2d43ea28b 100644
--- a/management/server/setupkey_test.go
+++ b/management/server/setupkey_test.go
@@ -426,6 +426,10 @@ func TestSetupKeyAccountPeersUpdate(t *testing.T) {
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
diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go
index c6ced2642..7d22905dd 100644
--- a/management/server/store/sql_store.go
+++ b/management/server/store/sql_store.go
@@ -265,7 +265,8 @@ func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) {
return unlock
}
-// Deprecated: Full account operations are no longer supported
+// Deprecated: Full
+// account operations are no longer supported
func (s *SqlStore) SaveAccount(ctx context.Context, account *types.Account) error {
start := time.Now()
defer func() {
@@ -4912,6 +4913,64 @@ func (s *SqlStore) GetPeersByGroupIDs(ctx context.Context, accountID string, gro
return peers, nil
}
+func (s *SqlStore) GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) {
+ if len(groupIDs) == 0 {
+ return nil, nil
+ }
+
+ var peerIDs []string
+ result := s.db.Model(&types.GroupPeer{}).
+ Select("DISTINCT peer_id").
+ Where("account_id = ? AND group_id IN ?", accountID, groupIDs).
+ Pluck("peer_id", &peerIDs)
+ if result.Error != nil {
+ return nil, status.Errorf(status.Internal, "failed to get peer IDs by groups: %s", result.Error)
+ }
+
+ return peerIDs, nil
+}
+
+func (s *SqlStore) GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) {
+ if len(peerIDs) == 0 {
+ return nil, nil
+ }
+
+ var groupIDs []string
+ result := s.db.Model(&types.GroupPeer{}).
+ Select("DISTINCT group_id").
+ Where("account_id = ? AND peer_id IN ?", accountID, peerIDs).
+ Pluck("group_id", &groupIDs)
+ if result.Error != nil {
+ return nil, status.Errorf(status.Internal, "failed to get group IDs by peers: %s", result.Error)
+ }
+
+ return groupIDs, nil
+}
+
+// GetEmbeddedProxyPeerIDsByCluster returns peer IDs of all embedded proxy peers
+// in the account, grouped by their ProxyCluster. The map is nil when no embedded
+// proxy peers exist.
+func (s *SqlStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) {
+ type row struct {
+ ID string
+ Cluster string
+ }
+ var rows []row
+ result := s.db.Model(&nbpeer.Peer{}).
+ Select("id, proxy_meta_cluster AS cluster").
+ Where("account_id = ? AND proxy_meta_embedded = ?", accountID, true).
+ Scan(&rows)
+ if result.Error != nil {
+ return nil, status.Errorf(status.Internal, "failed to get embedded proxy peers: %s", result.Error)
+ }
+
+ out := make(map[string][]string, len(rows))
+ for _, r := range rows {
+ out[r.Cluster] = append(out[r.Cluster], r.ID)
+ }
+ return out, nil
+}
+
func (s *SqlStore) GetUserIDByPeerKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (string, error) {
tx := s.db
if lockStrength != LockingStrengthNone {
diff --git a/management/server/store/store.go b/management/server/store/store.go
index 746207f27..31f1fea86 100644
--- a/management/server/store/store.go
+++ b/management/server/store/store.go
@@ -162,6 +162,9 @@ type Store interface {
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)
GetPeersByGroupIDs(ctx context.Context, accountID string, groupIDs []string) ([]*nbpeer.Peer, error)
+ GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error)
+ GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error)
+ GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error)
GetAccountPeersWithExpiration(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*nbpeer.Peer, error)
GetAccountPeersWithInactivity(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*nbpeer.Peer, error)
GetAllEphemeralPeers(ctx context.Context, lockStrength LockingStrength) ([]*nbpeer.Peer, error)
diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go
index dfd5af78d..706c03f1b 100644
--- a/management/server/store/store_mock.go
+++ b/management/server/store/store_mock.go
@@ -1925,6 +1925,51 @@ func (mr *MockStoreMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupIDs int
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockStore)(nil).GetPeersByGroupIDs), ctx, accountID, groupIDs)
}
+// GetPeerIDsByGroups mocks base method.
+func (m *MockStore) GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetPeerIDsByGroups", ctx, accountID, groupIDs)
+ ret0, _ := ret[0].([]string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetPeerIDsByGroups indicates an expected call of GetPeerIDsByGroups.
+func (mr *MockStoreMockRecorder) GetPeerIDsByGroups(ctx, accountID, groupIDs interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIDsByGroups", reflect.TypeOf((*MockStore)(nil).GetPeerIDsByGroups), ctx, accountID, groupIDs)
+}
+
+// GetGroupIDsByPeerIDs mocks base method.
+func (m *MockStore) GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetGroupIDsByPeerIDs", ctx, accountID, peerIDs)
+ ret0, _ := ret[0].([]string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetGroupIDsByPeerIDs indicates an expected call of GetGroupIDsByPeerIDs.
+func (mr *MockStoreMockRecorder) GetGroupIDsByPeerIDs(ctx, accountID, peerIDs interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupIDsByPeerIDs", reflect.TypeOf((*MockStore)(nil).GetGroupIDsByPeerIDs), ctx, accountID, peerIDs)
+}
+
+// GetEmbeddedProxyPeerIDsByCluster mocks base method.
+func (m *MockStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetEmbeddedProxyPeerIDsByCluster", ctx, accountID)
+ ret0, _ := ret[0].(map[string][]string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetEmbeddedProxyPeerIDsByCluster indicates an expected call of GetEmbeddedProxyPeerIDsByCluster.
+func (mr *MockStoreMockRecorder) GetEmbeddedProxyPeerIDsByCluster(ctx, accountID interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedProxyPeerIDsByCluster", reflect.TypeOf((*MockStore)(nil).GetEmbeddedProxyPeerIDsByCluster), ctx, accountID)
+}
+
// GetPeersByIDs mocks base method.
func (m *MockStore) GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*peer.Peer, error) {
m.ctrl.T.Helper()
diff --git a/management/server/telemetry/http_api_metrics.go b/management/server/telemetry/http_api_metrics.go
index e48e6d64a..360d36949 100644
--- a/management/server/telemetry/http_api_metrics.go
+++ b/management/server/telemetry/http_api_metrics.go
@@ -21,6 +21,8 @@ const (
httpRequestCounterPrefix = "management.http.request.counter"
httpResponseCounterPrefix = "management.http.response.counter"
httpRequestDurationPrefix = "management.http.request.duration.ms"
+
+ RequestIDHeader = "X-Request-Id"
)
// WrappedResponseWriter is a wrapper for http.ResponseWriter that allows the
@@ -172,6 +174,10 @@ func (m *HTTPMiddleware) Handler(h http.Handler) http.Handler {
reqID := xid.New().String()
//nolint
ctx = context.WithValue(ctx, nbContext.RequestIDKey, reqID)
+ //nolint
+ ctx = context.WithValue(ctx, nbContext.UserAgentKey, r.UserAgent())
+
+ rw.Header().Set(RequestIDHeader, reqID)
log.WithContext(ctx).Tracef("HTTP request %v: %v %v", reqID, r.Method, r.URL)
diff --git a/management/server/types/networkmap_components.go b/management/server/types/networkmap_components.go
index 3a7e20ec5..b5514e19b 100644
--- a/management/server/types/networkmap_components.go
+++ b/management/server/types/networkmap_components.go
@@ -557,7 +557,6 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute
return enabledRoutes, disabledRoutes
}
-
func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route {
var filteredRoutes []*route.Route
for _, r := range routes {
@@ -628,9 +627,14 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool
rules := []*RouteFirewallRule{&rule}
- if includeIPv6 && r.IsDynamic() {
+ isDefaultV4 := r.Network.Addr().Is4() && r.Network.Bits() == 0
+ if includeIPv6 && (r.IsDynamic() || isDefaultV4) {
ruleV6 := rule
ruleV6.SourceRanges = []string{"::/0"}
+ if isDefaultV4 {
+ ruleV6.Destination = "::/0"
+ ruleV6.RouteID = r.ID + "-v6-default"
+ }
rules = append(rules, &ruleV6)
}
diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go
index bcfb6fdf9..3785a7399 100644
--- a/management/server/types/networkmap_components_correctness_test.go
+++ b/management/server/types/networkmap_components_correctness_test.go
@@ -5,6 +5,7 @@ import (
"fmt"
"net"
"net/netip"
+ "slices"
"testing"
"time"
@@ -1029,6 +1030,48 @@ func TestComponents_RouteDefaultPermit(t *testing.T) {
assert.True(t, hasDefaultPermit, "route without ACG should have default permit rule with 0.0.0.0/0 source")
}
+// TestComponents_ExitNodeDefaultPermitIPv6 verifies that a default exit node route
+// (0.0.0.0/0) without AccessControlGroups also emits an IPv6 default permit rule
+// (::/0 source and destination) for peers that support IPv6, mirroring the route
+// the client installs. Without it, IPv6 traffic is routed to the exit node but
+// dropped at the forward chain.
+func TestComponents_ExitNodeDefaultPermitIPv6(t *testing.T) {
+ account, validatedPeers := scalableTestAccount(20, 2)
+
+ routingPeerID := "peer-5"
+ routingPeer := account.Peers[routingPeerID]
+ routingPeer.IPv6 = netip.MustParseAddr("fd00::5")
+ routingPeer.Meta.Capabilities = append(routingPeer.Meta.Capabilities, nbpeer.PeerCapabilityIPv6Overlay)
+
+ account.Routes["route-exit"] = &route.Route{
+ ID: "route-exit", Network: netip.MustParsePrefix("0.0.0.0/0"),
+ PeerID: routingPeerID, Peer: routingPeer.Key,
+ Enabled: true, Groups: []string{"group-all"}, PeerGroups: []string{"group-0"},
+ AccessControlGroups: []string{},
+ AccountID: "test-account",
+ }
+
+ nm := componentsNetworkMap(account, routingPeerID, validatedPeers)
+ require.NotNil(t, nm)
+
+ hasV4 := false
+ hasV6 := false
+ for _, rfr := range nm.RoutesFirewallRules {
+ switch rfr.Destination {
+ case "0.0.0.0/0":
+ if slices.Contains(rfr.SourceRanges, "0.0.0.0/0") {
+ hasV4 = true
+ }
+ case "::/0":
+ if slices.Contains(rfr.SourceRanges, "::/0") {
+ hasV6 = true
+ }
+ }
+ }
+ assert.True(t, hasV4, "exit node route should have an IPv4 default permit rule (0.0.0.0/0)")
+ assert.True(t, hasV6, "exit node route should have an IPv6 default permit rule (::/0)")
+}
+
// ──────────────────────────────────────────────────────────────────────────────
// 15. MULTIPLE ROUTERS PER NETWORK
// ──────────────────────────────────────────────────────────────────────────────
diff --git a/management/server/user.go b/management/server/user.go
index 7cd955000..412f15ce7 100644
--- a/management/server/user.go
+++ b/management/server/user.go
@@ -18,6 +18,7 @@ import (
"github.com/netbirdio/netbird/idp/dex"
"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/idp"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/permissions/modules"
@@ -1157,7 +1158,8 @@ func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accou
}
}
- err = am.networkMapController.OnPeersUpdated(ctx, accountID, peerIDs)
+ affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, peerIDs)
+ err = am.networkMapController.OnPeersUpdated(ctx, accountID, peerIDs, affectedPeerIDs)
if err != nil {
return fmt.Errorf("notify network map controller of peer update: %w", err)
}
@@ -1273,6 +1275,8 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI
var userPeers []*nbpeer.Peer
var targetUser *types.User
var settings *types.Settings
+ var snap *affectedpeers.Snapshot
+ var change affectedpeers.Change
var err error
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
@@ -1293,6 +1297,18 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI
if len(userPeers) > 0 {
updateAccountPeers = true
+
+ var peerIDs []string
+ for _, peer := range userPeers {
+ peerIDs = append(peerIDs, peer.ID)
+ }
+ // Load before delete so the snapshot still has the peers' group
+ // memberships; the resolver derives them from the peer IDs during the walk.
+ change = affectedpeers.Change{ChangedPeerIDs: peerIDs}
+ if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil {
+ return err
+ }
+
addPeerRemovedEvents, err = deletePeers(ctx, am, transaction, accountID, targetUserInfo.ID, userPeers, settings)
if err != nil {
return fmt.Errorf("failed to delete user peers: %w", err)
@@ -1316,7 +1332,8 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI
log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peer.ID, err)
}
}
- if err := am.networkMapController.OnPeersDeleted(ctx, accountID, peerIDs); err != nil {
+ affectedPeerIDs := snap.Expand(ctx, accountID, change)
+ if err := am.networkMapController.OnPeersDeleted(ctx, accountID, peerIDs, affectedPeerIDs); err != nil {
log.WithContext(ctx).Errorf("failed to delete peers %s from network map: %v", peerIDs, err)
}
diff --git a/management/server/user_test.go b/management/server/user_test.go
index 2a2d7857d..d46519396 100644
--- a/management/server/user_test.go
+++ b/management/server/user_test.go
@@ -846,7 +846,7 @@ func TestUser_DeleteUser_regularUser(t *testing.T) {
ctrl := gomock.NewController(t)
networkMapControllerMock := network_map.NewMockController(ctrl)
networkMapControllerMock.EXPECT().
- OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any()).
+ OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil)
permissionsManager := permissions.NewManager(store)
@@ -962,7 +962,7 @@ func TestUser_DeleteUser_RegularUsers(t *testing.T) {
ctrl := gomock.NewController(t)
networkMapControllerMock := network_map.NewMockController(ctrl)
networkMapControllerMock.EXPECT().
- OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any()).
+ OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil).
AnyTimes()
@@ -1531,11 +1531,14 @@ func TestUserAccountPeersUpdate(t *testing.T) {
}
})
+ // drain any buffered updates from previous subtests
+ drainPeerUpdates(updMsg)
+
// 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() {
- peerShouldReceiveUpdate(t, updMsg)
+ peerShouldNotReceiveUpdate(t, updMsg)
close(done)
}()
@@ -2022,7 +2025,7 @@ func TestUser_Operations_WithEmbeddedIDP(t *testing.T) {
ctrl := gomock.NewController(t)
networkMapControllerMock := network_map.NewMockController(ctrl)
networkMapControllerMock.EXPECT().
- OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any()).
+ OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil).
AnyTimes()
diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go
index d0e11517e..ad8e1b7c0 100644
--- a/proxy/cmd/proxy/cmd/root.go
+++ b/proxy/cmd/proxy/cmd/root.go
@@ -249,6 +249,7 @@ func runServer(cmd *cobra.Command, args []string) error {
Private: private,
MaxDialTimeout: maxDialTimeout,
MaxSessionIdleTimeout: maxSessionIdleTimeout,
+ MappingBatchWatchdog: envDurationOrDefault("NB_PROXY_MAPPING_BATCH_WATCHDOG", 0),
GeoDataDir: geoDataDir,
CrowdSecAPIURL: crowdsecAPIURL,
CrowdSecAPIKey: crowdsecAPIKey,
diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go
index 1d1e68f4a..13d386da2 100644
--- a/proxy/internal/roundtrip/netbird.go
+++ b/proxy/internal/roundtrip/netbird.go
@@ -28,6 +28,10 @@ import (
const deviceNamePrefix = "ingress-proxy-"
+const clientStopTimeout = 30 * time.Second
+
+const createProxyPeerTimeout = 30 * time.Second
+
// backendKey identifies a backend by its host:port from the target URL.
type backendKey string
@@ -162,6 +166,7 @@ type NetBird struct {
clientsMux sync.RWMutex
clients map[types.AccountID]*clientEntry
+ lifecycleMu sync.Map
initLogOnce sync.Once
statusNotifier statusNotifier
// readyHandler runs after the embedded client for an account reports
@@ -177,6 +182,10 @@ type NetBird struct {
// (i.e. when a new client was actually created, not when an existing one
// was reused). The duration covers keygen + gRPC CreateProxyPeer + embed.New.
OnAddPeer func(d time.Duration, err error)
+
+ // startClient runs the post-create client startup. Nil uses runClientStartup;
+ // tests override it to avoid a real embed client.Start.
+ startClient func(accountID types.AccountID, client *embed.Client)
}
// ClientDebugInfo contains debug information about a client.
@@ -200,31 +209,20 @@ type skipTLSVerifyContextKey struct{}
func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, serviceID types.ServiceID) error {
si := serviceInfo{serviceID: serviceID}
- n.clientsMux.Lock()
+ if n.registerExistingClient(accountID, key, si) {
+ return nil
+ }
- entry, exists := n.clients[accountID]
- if exists {
- entry.services[key] = si
- started := entry.started
- n.clientsMux.Unlock()
-
- n.logger.WithFields(log.Fields{
- "account_id": accountID,
- "service_key": key,
- }).Debug("registered service with existing client")
-
- if started && n.statusNotifier != nil {
- // Use a background context, not the caller's: the management
- // connection notification must land even if the request /
- // stream that triggered this registration is cancelled.
- // Mirrors the async runClientStartup path.
- if err := n.statusNotifier.NotifyStatus(context.Background(), accountID, serviceID, true); err != nil {
- n.logger.WithFields(log.Fields{
- "account_id": accountID,
- "service_key": key,
- }).WithError(err).Warn("failed to notify status for existing client")
- }
+ lifecycle := n.accountLifecycle(accountID)
+ lifecycle.Lock()
+ transferred := false
+ defer func() {
+ if !transferred {
+ lifecycle.Unlock()
}
+ }()
+
+ if n.registerExistingClient(accountID, key, si) {
return nil
}
@@ -234,10 +232,10 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se
n.OnAddPeer(time.Since(createStart), err)
}
if err != nil {
- n.clientsMux.Unlock()
return err
}
+ n.clientsMux.Lock()
n.clients[accountID] = entry
n.clientsMux.Unlock()
@@ -246,17 +244,64 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se
"service_key": key,
}).Info("created new client for account")
- // Attempt to start the client in the background; if this fails we will
- // retry on the first request via RoundTrip. runClientStartup uses its
- // own background context so the caller's request-scoped ctx can't
- // cancel the inbound bring-up.
- go n.runClientStartup(accountID, entry.client)
+ transferred = true
+ go func() {
+ defer lifecycle.Unlock()
+ n.startClientStartup(accountID, entry.client)
+ }()
return nil
}
+func (n *NetBird) startClientStartup(accountID types.AccountID, client *embed.Client) {
+ if n.startClient != nil {
+ n.startClient(accountID, client)
+ return
+ }
+ n.runClientStartup(accountID, client)
+}
+
+// registerExistingClient registers the service against an already-present
+// client for the account and returns true when it did. It notifies management
+// of the new service when the client is already started.
+func (n *NetBird) registerExistingClient(accountID types.AccountID, key ServiceKey, si serviceInfo) bool {
+ n.clientsMux.Lock()
+ entry, exists := n.clients[accountID]
+ if !exists {
+ n.clientsMux.Unlock()
+ return false
+ }
+ entry.services[key] = si
+ started := entry.started
+ n.clientsMux.Unlock()
+
+ n.logger.WithFields(log.Fields{
+ "account_id": accountID,
+ "service_key": key,
+ }).Debug("registered service with existing client")
+
+ if started && n.statusNotifier != nil {
+ if err := n.statusNotifier.NotifyStatus(context.Background(), accountID, si.serviceID, true); err != nil {
+ n.logger.WithFields(log.Fields{
+ "account_id": accountID,
+ "service_key": key,
+ }).WithError(err).Warn("failed to notify status for existing client")
+ }
+ }
+ return true
+}
+
+// accountLifecycle returns the per-account lifecycle mutex, serialising client
+// creation against teardown so a slow client.Stop cannot race a new
+// client.Start for the same account, without blocking clientsMux.
+func (n *NetBird) accountLifecycle(accountID types.AccountID) *sync.Mutex {
+ mu, _ := n.lifecycleMu.LoadOrStore(accountID, &sync.Mutex{})
+ return mu.(*sync.Mutex)
+}
+
// createClientEntry generates a WireGuard keypair, authenticates with management,
-// and creates an embedded NetBird client. Must be called with clientsMux held.
+// and creates an embedded NetBird client. Must be called with the account's
+// lifecycle mutex held.
func (n *NetBird) createClientEntry(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, si serviceInfo) (*clientEntry, error) {
serviceID := si.serviceID
n.logger.WithFields(log.Fields{
@@ -276,7 +321,9 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account
"public_key": publicKey.String(),
}).Debug("authenticating new proxy peer with management")
- resp, err := n.mgmtClient.CreateProxyPeer(ctx, &proto.CreateProxyPeerRequest{
+ createCtx, cancel := context.WithTimeout(ctx, createProxyPeerTimeout)
+ defer cancel()
+ resp, err := n.mgmtClient.CreateProxyPeer(createCtx, &proto.CreateProxyPeerRequest{
ServiceId: string(serviceID),
AccountId: string(accountID),
Token: authToken,
@@ -444,6 +491,15 @@ func (n *NetBird) notifyClientReady(accountID types.AccountID, client *embed.Cli
// RemovePeer unregisters a service from an account. The client is only stopped
// when no services are using it anymore.
func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key ServiceKey) error {
+ lifecycle := n.accountLifecycle(accountID)
+ lifecycle.Lock()
+ transferred := false
+ defer func() {
+ if !transferred {
+ lifecycle.Unlock()
+ }
+ }()
+
n.clientsMux.Lock()
entry, exists := n.clients[accountID]
@@ -466,17 +522,8 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key
delete(entry.services, key)
stopClient := len(entry.services) == 0
- var client *embed.Client
- var transport, insecureTransport *http.Transport
- var inbound any
- var stopHandler func(types.AccountID, any)
if stopClient {
n.logger.WithField("account_id", accountID).Info("stopping client, no more services")
- client = entry.client
- transport = entry.transport
- insecureTransport = entry.insecureTransport
- inbound = entry.inbound
- stopHandler = n.stopHandler
delete(n.clients, accountID)
} else {
n.logger.WithFields(log.Fields{
@@ -490,19 +537,40 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key
n.notifyDisconnect(ctx, accountID, key, si.serviceID)
if stopClient {
- if inbound != nil && stopHandler != nil {
- stopHandler(accountID, inbound)
- }
- transport.CloseIdleConnections()
- insecureTransport.CloseIdleConnections()
- if err := client.Stop(ctx); err != nil {
- n.logger.WithField("account_id", accountID).WithError(err).Warn("failed to stop netbird client")
- }
+ transferred = true
+ go n.stopClientLocked(accountID, lifecycle, entry)
}
return nil
}
+// stopClientLocked releases a client's resources off the caller's goroutine so a
+// slow client.Stop cannot wedge the mapping receive loop (which calls RemovePeer
+// synchronously). It unlocks lifecycle when done so a new client.Start for the
+// same account waits for this teardown.
+func (n *NetBird) stopClientLocked(accountID types.AccountID, lifecycle *sync.Mutex, entry *clientEntry) {
+ defer lifecycle.Unlock()
+
+ if entry.inbound != nil && n.stopHandler != nil {
+ n.stopHandler(accountID, entry.inbound)
+ }
+ if entry.transport != nil {
+ entry.transport.CloseIdleConnections()
+ }
+ if entry.insecureTransport != nil {
+ entry.insecureTransport.CloseIdleConnections()
+ }
+ if entry.client == nil {
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), clientStopTimeout)
+ defer cancel()
+ if err := entry.client.Stop(ctx); err != nil {
+ n.logger.WithField("account_id", accountID).WithError(err).Warn("failed to stop netbird client")
+ }
+}
+
func (n *NetBird) notifyDisconnect(ctx context.Context, accountID types.AccountID, key ServiceKey, serviceID types.ServiceID) {
if n.statusNotifier == nil {
return
diff --git a/proxy/internal/roundtrip/netbird_test.go b/proxy/internal/roundtrip/netbird_test.go
index b1c36b465..700cca83e 100644
--- a/proxy/internal/roundtrip/netbird_test.go
+++ b/proxy/internal/roundtrip/netbird_test.go
@@ -6,6 +6,7 @@ import (
"net/netip"
"sync"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -22,6 +23,18 @@ func (m *mockMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxy
return &proto.CreateProxyPeerResponse{Success: true}, nil
}
+// signalMgmtClient closes entered the first time CreateProxyPeer is called, so
+// tests can detect AddPeer reaching client creation.
+type signalMgmtClient struct {
+ entered chan struct{}
+ once sync.Once
+}
+
+func (m *signalMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) {
+ m.once.Do(func() { close(m.entered) })
+ return &proto.CreateProxyPeerResponse{Success: true}, nil
+}
+
type mockStatusNotifier struct {
mu sync.Mutex
statuses []statusCall
@@ -52,11 +65,15 @@ func (m *mockStatusNotifier) calls() []statusCall {
// mockNetBird creates a NetBird instance for testing without actually connecting.
// It uses an invalid management URL to prevent real connections.
func mockNetBird() *NetBird {
- return NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
MgmtAddr: "http://invalid.test:9999",
WGPort: 0,
PreSharedKey: "",
}, nil, nil, &mockMgmtClient{})
+ // Skip the real embed client.Start, which would hang against the unreachable
+ // mgmt URL and (now that the lifecycle lock spans startup) serialise removes.
+ nb.startClient = func(types.AccountID, *embed.Client) {}
+ return nb
}
func TestNetBird_AddPeer_CreatesClientForNewAccount(t *testing.T) {
@@ -288,6 +305,7 @@ func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) {
WGPort: 0,
PreSharedKey: "",
}, nil, notifier, &mockMgmtClient{})
+ nb.startClient = func(types.AccountID, *embed.Client) {}
accountID := types.AccountID("account-1")
// Add first service — creates a new client entry.
@@ -372,6 +390,117 @@ func TestNetBird_RemovePeer_NotifiesDisconnection(t *testing.T) {
assert.False(t, calls[0].connected)
}
+// TestNetBird_RemovePeer_TeardownIsAsync proves the fix for the receive-loop
+// stall: RemovePeer must return promptly even when the client teardown blocks,
+// because teardown runs off the caller's goroutine. The receive loop calls
+// RemovePeer synchronously, so a blocking teardown inline would wedge it.
+func TestNetBird_RemovePeer_TeardownIsAsync(t *testing.T) {
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
+ MgmtAddr: "http://invalid.test:9999",
+ }, nil, &mockStatusNotifier{}, &mockMgmtClient{})
+
+ accountID := types.AccountID("acct-async-teardown")
+ key := DomainServiceKey("svc.example")
+
+ teardownEntered := make(chan struct{})
+ releaseTeardown := make(chan struct{})
+ nb.SetClientLifecycle(nil, func(types.AccountID, any) {
+ close(teardownEntered)
+ <-releaseTeardown
+ })
+
+ nb.clientsMux.Lock()
+ nb.clients[accountID] = &clientEntry{
+ services: map[ServiceKey]serviceInfo{key: {serviceID: types.ServiceID("svc-1")}},
+ started: true,
+ inbound: struct{}{},
+ }
+ nb.clientsMux.Unlock()
+
+ done := make(chan error, 1)
+ go func() { done <- nb.RemovePeer(context.Background(), accountID, key) }()
+
+ select {
+ case err := <-done:
+ require.NoError(t, err)
+ case <-time.After(2 * time.Second):
+ t.Fatal("RemovePeer did not return while teardown was blocked — teardown is not async")
+ }
+
+ select {
+ case <-teardownEntered:
+ case <-time.After(2 * time.Second):
+ t.Fatal("teardown never ran")
+ }
+
+ close(releaseTeardown)
+}
+
+// TestNetBird_AddPeer_WaitsForTeardown proves the lifecycle lock serialises a
+// new client bringup behind an in-flight teardown for the same account, so a
+// slow client.Stop can never race a new client.Start for that account.
+//
+// It targets the handoff race specifically: AddPeer is launched immediately
+// after RemovePeer returns, WITHOUT waiting for the teardown goroutine to start.
+// This only passes if RemovePeer acquires the lifecycle lock synchronously
+// (before returning) and hands it to the teardown goroutine — if the goroutine
+// acquired the lock itself, AddPeer could win the lock in this window and start
+// a replacement client while the old teardown is still pending.
+func TestNetBird_AddPeer_WaitsForTeardown(t *testing.T) {
+ nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{
+ MgmtAddr: "http://invalid.test:9999",
+ }, nil, &mockStatusNotifier{}, &mockMgmtClient{})
+ nb.startClient = func(types.AccountID, *embed.Client) {}
+
+ accountID := types.AccountID("acct-serialize")
+ key := DomainServiceKey("svc.example")
+
+ addEntered := make(chan struct{})
+ releaseTeardown := make(chan struct{})
+ nb.SetClientLifecycle(nil, func(types.AccountID, any) {
+ // Block teardown until released. If AddPeer ever reaches createClientEntry
+ // (signalled via the mgmt client below) while we hold the lock, the lock
+ // failed to serialise and the test fails before we release.
+ <-releaseTeardown
+ })
+
+ nb.clientsMux.Lock()
+ nb.clients[accountID] = &clientEntry{
+ services: map[ServiceKey]serviceInfo{key: {serviceID: types.ServiceID("svc-1")}},
+ started: true,
+ inbound: struct{}{},
+ }
+ nb.clientsMux.Unlock()
+
+ // createClientEntry calls CreateProxyPeer; closing addEntered there tells us
+ // AddPeer got past the lifecycle lock and into client creation.
+ nb.mgmtClient = &signalMgmtClient{entered: addEntered}
+
+ require.NoError(t, nb.RemovePeer(context.Background(), accountID, key))
+
+ // Launch AddPeer with NO synchronisation against the teardown goroutine.
+ addReturned := make(chan struct{})
+ go func() {
+ _ = nb.AddPeer(context.Background(), accountID, DomainServiceKey("svc2.example"), "key-2", types.ServiceID("svc-2"))
+ close(addReturned)
+ }()
+
+ select {
+ case <-addEntered:
+ t.Fatal("AddPeer entered client creation while teardown held the lifecycle lock — handoff race not closed")
+ case <-addReturned:
+ t.Fatal("AddPeer completed while teardown held the lifecycle lock — not serialised")
+ case <-time.After(300 * time.Millisecond):
+ }
+
+ close(releaseTeardown)
+ select {
+ case <-addReturned:
+ case <-time.After(2 * time.Second):
+ t.Fatal("AddPeer never completed after teardown released the lifecycle lock")
+ }
+}
+
// TestNotifyClientReady_UsesBackgroundCtx pins the contract that the
// post-Start hooks (readyHandler + statusNotifier.NotifyStatus) run on
// a fresh context.Background() rather than inheriting the AddPeer
diff --git a/proxy/lifecycle.go b/proxy/lifecycle.go
index 41d4bc496..0d4aded9c 100644
--- a/proxy/lifecycle.go
+++ b/proxy/lifecycle.go
@@ -114,6 +114,10 @@ type Config struct {
MaxDialTimeout time.Duration
// MaxSessionIdleTimeout caps the per-service session idle timeout.
MaxSessionIdleTimeout time.Duration
+ // MappingBatchWatchdog bounds how long a single mapping batch may spend
+ // being applied before the receive loop reconnects to resync. Zero falls
+ // back to the internal default.
+ MappingBatchWatchdog time.Duration
// GeoDataDir is the directory containing GeoLite2 MMDB files.
GeoDataDir string
@@ -164,6 +168,7 @@ func New(ctx context.Context, cfg Config) *Server {
Private: cfg.Private,
MaxDialTimeout: cfg.MaxDialTimeout,
MaxSessionIdleTimeout: cfg.MaxSessionIdleTimeout,
+ MappingBatchWatchdog: cfg.MappingBatchWatchdog,
GeoDataDir: cfg.GeoDataDir,
CrowdSecAPIURL: cfg.CrowdSecAPIURL,
CrowdSecAPIKey: cfg.CrowdSecAPIKey,
diff --git a/proxy/mapping_stall_test.go b/proxy/mapping_stall_test.go
new file mode 100644
index 000000000..acf313d19
--- /dev/null
+++ b/proxy/mapping_stall_test.go
@@ -0,0 +1,282 @@
+package proxy
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/metadata"
+
+ "github.com/netbirdio/netbird/proxy/internal/roundtrip"
+ "github.com/netbirdio/netbird/proxy/internal/types"
+ "github.com/netbirdio/netbird/shared/management/proto"
+)
+
+// blockingMgmtClient implements roundtrip's managementClient interface.
+// CreateProxyPeer parks until release is closed, signalling entry on entered.
+// This reproduces the confirmed real-world stall: createClientEntry calls
+// CreateProxyPeer synchronously while holding clientsMux, and the proxy's
+// receive loop calls that path synchronously inside processMappings.
+type blockingMgmtClient struct {
+ entered chan struct{}
+ once sync.Once
+}
+
+func (b *blockingMgmtClient) CreateProxyPeer(ctx context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) {
+ b.once.Do(func() { close(b.entered) })
+ // Park until the caller's context is cancelled. In production this ctx is
+ // the gRPC mapping-stream context with no per-call timeout, so a slow or
+ // unresponsive CreateProxyPeer parks the receive loop here indefinitely.
+ <-ctx.Done()
+ return nil, ctx.Err()
+}
+
+// gatedMappingStream is a mock GetMappingUpdate client stream that hands out a
+// pre-seeded list of messages, then records how many times Recv advanced. It
+// lets the test observe whether the single-threaded receive loop ever gets
+// past the first (blocking) batch to pull the second message.
+type gatedMappingStream struct {
+ grpc.ClientStream
+ messages []*proto.GetMappingUpdateResponse
+ idx int32
+}
+
+func (g *gatedMappingStream) Recv() (*proto.GetMappingUpdateResponse, error) {
+ i := int(atomic.LoadInt32(&g.idx))
+ if i >= len(g.messages) {
+ // Block instead of returning EOF so the loop doesn't exit; we only
+ // care whether the loop ever reaches this second Recv at all.
+ select {}
+ }
+ msg := g.messages[i]
+ atomic.AddInt32(&g.idx, 1)
+ return msg, nil
+}
+
+func (g *gatedMappingStream) deliveredCount() int32 { return atomic.LoadInt32(&g.idx) }
+
+func (g *gatedMappingStream) Header() (metadata.MD, error) { return nil, nil } //nolint:nilnil
+func (g *gatedMappingStream) Trailer() metadata.MD { return nil }
+func (g *gatedMappingStream) CloseSend() error { return nil }
+func (g *gatedMappingStream) Context() context.Context { return context.Background() }
+func (g *gatedMappingStream) SendMsg(any) error { return nil }
+func (g *gatedMappingStream) RecvMsg(any) error { return nil }
+
+// noopNotifier satisfies roundtrip's statusNotifier interface.
+type noopNotifier struct{}
+
+func (noopNotifier) NotifyStatus(context.Context, types.AccountID, types.ServiceID, bool) error {
+ return nil
+}
+
+// noopProxyClient is a proto.ProxyServiceClient that no-ops the one method the
+// teardown unwind reaches (SendStatusUpdate, via notifyError when the parked
+// AddPeer is cancelled). The embedded nil interface satisfies the rest at
+// compile time; none of those methods are called by this test.
+type noopProxyClient struct {
+ proto.ProxyServiceClient
+}
+
+func (noopProxyClient) SendStatusUpdate(context.Context, *proto.SendStatusUpdateRequest, ...grpc.CallOption) (*proto.SendStatusUpdateResponse, error) {
+ return &proto.SendStatusUpdateResponse{}, nil
+}
+
+// TestMappingStream_StallsWhenApplyBlocks proves the deadlock: the proxy's
+// mapping receive loop processes batches strictly serially, so when applying
+// one batch blocks (here: createClientEntry parked on a synchronous
+// CreateProxyPeer call, exactly as observed in production), the loop never
+// advances to Recv the next batch. Management can keep sending updates onto
+// the stream with no error and no channel overflow, yet the proxy applies
+// nothing further — it is stuck.
+func TestMappingStream_StallsWhenApplyBlocks(t *testing.T) {
+ logger := log.New()
+ logger.SetLevel(log.PanicLevel)
+
+ mgmt := &blockingMgmtClient{
+ entered: make(chan struct{}),
+ }
+
+ nb := roundtrip.NewNetBird(
+ context.Background(),
+ "proxy-test",
+ "proxy.example.com",
+ roundtrip.ClientConfig{},
+ logger,
+ noopNotifier{},
+ mgmt,
+ )
+
+ s := &Server{
+ Logger: logger,
+ netbird: nb,
+ mgmtClient: noopProxyClient{},
+ routerReady: closedChan(),
+ lastMappings: make(map[types.ServiceID]*proto.ProxyMapping),
+ }
+
+ // First batch: a CREATED mapping for a brand-new account. addMapping ->
+ // netbird.AddPeer -> createClientEntry -> CreateProxyPeer, which blocks.
+ // Empty Path keeps setupHTTPMapping a no-op (it returns early), so the
+ // ONLY blocking point is the synchronous CreateProxyPeer in AddPeer —
+ // no routers/auth need wiring. The second batch exists only to detect
+ // whether the loop ever advances past the blocked first batch.
+ stream := &gatedMappingStream{
+ messages: []*proto.GetMappingUpdateResponse{
+ {
+ Mapping: []*proto.ProxyMapping{
+ {
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
+ Id: "svc-1",
+ AccountId: "acct-1",
+ AuthToken: "token-1",
+ },
+ },
+ },
+ {
+ Mapping: []*proto.ProxyMapping{
+ {
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
+ Id: "svc-2",
+ AccountId: "acct-2",
+ AuthToken: "token-2",
+ },
+ },
+ },
+ },
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ // Unblock the parked apply on teardown via ctx (CreateProxyPeer returns
+ // ctx.Err()), so the wedged loop goroutine unwinds before embed.New —
+ // avoiding any dependency on collaborators this test deliberately leaves
+ // nil. The deadlock is fully proven before this fires.
+ t.Cleanup(cancel)
+
+ loopDone := make(chan struct{})
+ syncDone := false
+ go func() {
+ defer close(loopDone)
+ _ = s.handleMappingStream(ctx, stream, &syncDone, time.Time{})
+ }()
+
+ // The loop must reach the blocking apply for the first batch.
+ select {
+ case <-mgmt.entered:
+ case <-time.After(2 * time.Second):
+ t.Fatal("receive loop never reached CreateProxyPeer for the first batch")
+ }
+
+ // THE DEADLOCK: while the first batch is parked in CreateProxyPeer, the
+ // single-threaded loop cannot advance. The second batch is never pulled,
+ // even though it is already available on the stream. Give it ample time.
+ // deliveredCount is atomic; syncDone is intentionally not read here because
+ // the loop goroutine owns it (reading it from the test would race).
+ time.Sleep(500 * time.Millisecond)
+ assert.Equal(t, int32(1), stream.deliveredCount(),
+ "loop must NOT consume the second batch while the first is blocked in apply — proxy is stuck")
+
+ select {
+ case <-loopDone:
+ t.Fatal("receive loop returned while it should be wedged in apply")
+ default:
+ // Still wedged, as expected.
+ }
+}
+
+// TestMappingStream_StallsWhenRemoveBlocks proves the deadlock for the REMOVE
+// path observed in production: a mapping remove tears down the account's last
+// embedded client via netbird.RemovePeer -> client.Stop -> Engine.Stop, whose
+// jobExecutorWG.Wait() is unbounded. Because the receive loop is single-
+// threaded, a blocked remove wedges the loop: no further mapping updates of any
+// kind (create/modify/remove) are applied, while management keeps sending them
+// successfully (no send error, no channel-full). Matches the reported symptom:
+// the last log line is a remove that stops a client, then silence.
+func TestMappingStream_StallsWhenRemoveBlocks(t *testing.T) {
+ logger := log.New()
+ logger.SetLevel(log.PanicLevel)
+
+ enteredRemove := make(chan struct{})
+ blockRemove := make(chan struct{})
+ var once sync.Once
+
+ s := &Server{
+ Logger: logger,
+ mgmtClient: noopProxyClient{},
+ routerReady: closedChan(),
+ lastMappings: make(map[types.ServiceID]*proto.ProxyMapping),
+ // Stand in for netbird.RemovePeer -> client.Stop hanging on
+ // Engine.Stop's unbounded jobExecutorWG.Wait(). Only the first remove
+ // blocks; later removes return immediately so the recovery assertion
+ // can observe the loop advancing.
+ removePeer: func(ctx context.Context, _ types.AccountID, _ roundtrip.ServiceKey) error {
+ first := false
+ once.Do(func() {
+ first = true
+ close(enteredRemove)
+ })
+ if !first {
+ return nil
+ }
+ select {
+ case <-blockRemove:
+ case <-ctx.Done():
+ }
+ return nil
+ },
+ }
+
+ // Batch 1 removes a service (blocks in teardown). Batch 2 is a later update
+ // that must never be applied while the remove is wedged.
+ stream := &gatedMappingStream{
+ messages: []*proto.GetMappingUpdateResponse{
+ {
+ Mapping: []*proto.ProxyMapping{
+ {Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, Id: "svc-1", AccountId: "acct-1"},
+ },
+ },
+ {
+ Mapping: []*proto.ProxyMapping{
+ {Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, Id: "svc-2", AccountId: "acct-1"},
+ },
+ },
+ },
+ }
+
+ loopDone := make(chan struct{})
+ syncDone := false
+ go func() {
+ defer close(loopDone)
+ _ = s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{})
+ }()
+
+ select {
+ case <-enteredRemove:
+ case <-time.After(2 * time.Second):
+ t.Fatal("receive loop never reached the blocking remove for the first batch")
+ }
+
+ // THE DEADLOCK: the loop is parked in the blocked remove and cannot advance.
+ // syncDone is owned by the loop goroutine, so it is not read here.
+ time.Sleep(500 * time.Millisecond)
+ assert.Equal(t, int32(1), stream.deliveredCount(),
+ "loop must NOT consume the second batch while the first remove is blocked — proxy is stuck")
+
+ select {
+ case <-loopDone:
+ t.Fatal("receive loop returned while it should be wedged on the remove")
+ default:
+ }
+
+ // Unblock and confirm the wedge was solely the blocked remove: the loop
+ // then advances and consumes the next batch.
+ close(blockRemove)
+ assert.Eventually(t, func() bool {
+ return stream.deliveredCount() >= 2
+ }, 2*time.Second, 5*time.Millisecond,
+ "once the remove unblocks, the loop must advance and consume the next batch")
+}
diff --git a/proxy/server.go b/proxy/server.go
index 1f5e0abd6..1d8a2451b 100644
--- a/proxy/server.go
+++ b/proxy/server.go
@@ -24,6 +24,7 @@ import (
"time"
"github.com/cenkalti/backoff/v4"
+ "github.com/google/uuid"
"github.com/pires/go-proxyproto"
prometheus2 "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -75,29 +76,30 @@ type portRouter struct {
}
type Server struct {
- ctx context.Context
- mgmtClient proto.ProxyServiceClient
- proxy *proxy.ReverseProxy
- netbird *roundtrip.NetBird
- acme *acme.Manager
- auth *auth.Middleware
- http *http.Server
- https *http.Server
- debug *http.Server
- healthServer *health.Server
- healthChecker *health.Checker
- meter *proxymetrics.Metrics
- accessLog *accesslog.Logger
- mainRouter *nbtcp.Router
- mainPort uint16
- udpMu sync.Mutex
- udpRelays map[types.ServiceID]*udprelay.Relay
- udpRelayWg sync.WaitGroup
- portMu sync.RWMutex
- portRouters map[uint16]*portRouter
- svcPorts map[types.ServiceID][]uint16
- lastMappings map[types.ServiceID]*proto.ProxyMapping
- portRouterWg sync.WaitGroup
+ ctx context.Context
+ mgmtClient proto.ProxyServiceClient
+ proxy *proxy.ReverseProxy
+ netbird *roundtrip.NetBird
+ acme *acme.Manager
+ staticCertWatcher *certwatch.Watcher
+ auth *auth.Middleware
+ http *http.Server
+ https *http.Server
+ debug *http.Server
+ healthServer *health.Server
+ healthChecker *health.Checker
+ meter *proxymetrics.Metrics
+ accessLog *accesslog.Logger
+ mainRouter *nbtcp.Router
+ mainPort uint16
+ udpMu sync.Mutex
+ udpRelays map[types.ServiceID]*udprelay.Relay
+ udpRelayWg sync.WaitGroup
+ portMu sync.RWMutex
+ portRouters map[uint16]*portRouter
+ svcPorts map[types.ServiceID][]uint16
+ lastMappings map[types.ServiceID]*proto.ProxyMapping
+ portRouterWg sync.WaitGroup
// hijackTracker tracks hijacked connections (e.g. WebSocket upgrades)
// so they can be closed during graceful shutdown, since http.Server.Shutdown
@@ -118,6 +120,9 @@ type Server struct {
// The mapping worker waits on this before processing updates.
routerReady chan struct{}
+ // removePeer defaults to netbird.RemovePeer; overridable in tests.
+ removePeer func(ctx context.Context, accountID types.AccountID, key roundtrip.ServiceKey) error
+
// inbound, when non-nil, manages per-account inbound listeners. Set by
// initPrivateInbound only when Private is true so the standalone
// proxy keeps its zero-overhead default path.
@@ -227,6 +232,10 @@ type Server struct {
// Zero means no cap (the proxy honors whatever management sends).
// Set via NB_PROXY_MAX_SESSION_IDLE_TIMEOUT for shared deployments.
MaxSessionIdleTimeout time.Duration
+ // MappingBatchWatchdog bounds how long a single mapping batch may spend
+ // in processMappings before the receive loop reconnects to resync.
+ // Zero uses defaultMappingBatchWatchdog.
+ MappingBatchWatchdog time.Duration
}
// clampIdleTimeout returns d capped to MaxSessionIdleTimeout when configured.
@@ -607,7 +616,7 @@ func (s *Server) initDefaults() {
// If no ID is set then one can be generated.
if s.ID == "" {
- s.ID = "netbird-proxy-" + s.startTime.Format("20060102150405")
+ s.ID = fmt.Sprintf("netbird-proxy-%s", uuid.NewString())
}
// Fallback version option in case it is not set.
if s.Version == "" {
@@ -785,6 +794,7 @@ func (s *Server) configureTLS(ctx context.Context) (*tls.Config, error) {
return nil, fmt.Errorf("initialize certificate watcher: %w", err)
}
go certWatcher.Watch(ctx)
+ s.staticCertWatcher = certWatcher
tlsConfig.GetCertificate = certWatcher.GetCertificate
return tlsConfig, nil
}
@@ -1095,7 +1105,7 @@ func (s *Server) getOrCreatePortRouter(ctx context.Context, port uint16) (*nbtcp
router := nbtcp.NewPortRouter(s.Logger, s.resolveDialFunc)
router.SetObserver(s.meter)
router.SetAccessLogger(s.accessLog)
- portCtx, cancel := context.WithCancel(ctx)
+ portCtx, cancel := context.WithCancel(s.portRouterContext(ctx))
s.portRouters[port] = &portRouter{
router: router,
@@ -1111,10 +1121,26 @@ func (s *Server) getOrCreatePortRouter(ctx context.Context, port uint16) (*nbtcp
}
}()
- s.Logger.Debugf("started per-port router on %s", listenAddr)
+ s.Logger.WithFields(log.Fields{
+ "port": port,
+ "listen_addr": listenAddr,
+ "bound_addr": ln.Addr().String(),
+ "proxy_protocol": s.ProxyProtocol,
+ }).Info("custom TCP listener started")
return router, nil
}
+// portRouterContext returns the server-lifetime context for custom TCP
+// listeners. Mapping-batch contexts are cancelled after a batch is applied; a
+// per-port listener must outlive that batch and only stop on service removal or
+// server shutdown.
+func (s *Server) portRouterContext(ctx context.Context) context.Context {
+ if s.ctx != nil {
+ return s.ctx
+ }
+ return ctx
+}
+
// cleanupPortIfEmpty tears down a per-port router if it has no remaining
// routes or fallback. The main port is never cleaned up. Active relay
// connections are drained before the listener is closed.
@@ -1172,24 +1198,30 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr
s.healthChecker.SetManagementConnected(false)
}
+ connected := false
+ onConnected := func() { connected = true }
+
var streamErr error
if syncSupported {
- streamErr = s.trySyncMappings(ctx, client, &initialSyncDone)
+ streamErr = s.trySyncMappings(ctx, client, &initialSyncDone, onConnected)
if isSyncUnimplemented(streamErr) {
syncSupported = false
s.Logger.Info("management does not support SyncMappings, falling back to GetMappingUpdate")
- streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone)
+ streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone, onConnected)
}
} else {
- streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone)
+ streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone, onConnected)
}
if s.healthChecker != nil {
s.healthChecker.SetManagementConnected(false)
}
- // Stream established — reset backoff so the next failure retries quickly.
- bo.Reset()
+ // Reset backoff only when a stream actually connected, so immediate
+ // connect failures still back off instead of spinning.
+ if connected {
+ bo.Reset()
+ }
if streamErr == nil {
return fmt.Errorf("stream closed by server")
@@ -1221,7 +1253,7 @@ func (s *Server) proxyCapabilities() *proto.ProxyCapabilities {
}
}
-func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool) error {
+func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool, onConnected func()) error {
connectTime := time.Now()
mappingClient, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{
ProxyId: s.ID,
@@ -1234,6 +1266,7 @@ func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServ
return fmt.Errorf("create mapping stream: %w", err)
}
+ onConnected()
if s.healthChecker != nil {
s.healthChecker.SetManagementConnected(true)
}
@@ -1242,7 +1275,7 @@ func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServ
return s.handleMappingStream(ctx, mappingClient, initialSyncDone, connectTime)
}
-func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool) error {
+func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool, onConnected func()) error {
connectTime := time.Now()
stream, err := client.SyncMappings(ctx)
if err != nil {
@@ -1263,6 +1296,7 @@ func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceC
return fmt.Errorf("send sync init: %w", err)
}
+ onConnected()
if s.healthChecker != nil {
s.healthChecker.SetManagementConnected(true)
}
@@ -1307,7 +1341,9 @@ func (s *Server) handleSyncMappingsStream(ctx context.Context, stream proto.Prox
batchStart := time.Now()
s.Logger.Debug("Received mapping update, starting processing")
- s.processMappings(ctx, msg.GetMapping())
+ if err := s.processMappingsGuarded(ctx, msg.GetMapping()); err != nil {
+ return err
+ }
s.Logger.Debug("Processing mapping update completed")
tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart)
@@ -1391,7 +1427,9 @@ func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.Pr
batchStart := time.Now()
s.Logger.Debug("Received mapping update, starting processing")
- s.processMappings(ctx, msg.GetMapping())
+ if err := s.processMappingsGuarded(ctx, msg.GetMapping()); err != nil {
+ return err
+ }
s.Logger.Debug("Processing mapping update completed")
tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart)
}
@@ -1456,6 +1494,44 @@ func redactMappingForLog(m *proto.ProxyMapping) *proto.ProxyMapping {
return c
}
+const defaultMappingBatchWatchdog = 2 * time.Minute
+
+// mappingBatchWatchdog returns the configured batch watchdog or the default.
+func (s *Server) mappingBatchWatchdog() time.Duration {
+ if s.MappingBatchWatchdog > 0 {
+ return s.MappingBatchWatchdog
+ }
+ return defaultMappingBatchWatchdog
+}
+
+// processMappingsGuarded applies a batch under a watchdog, returning an error
+// if processing exceeds the watchdog so the caller reconnects and resyncs
+// instead of wedging silently.
+func (s *Server) processMappingsGuarded(ctx context.Context, mappings []*proto.ProxyMapping) error {
+ batchCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ s.processMappings(batchCtx, mappings)
+ }()
+
+ watchdog := s.mappingBatchWatchdog()
+ timer := time.NewTimer(watchdog)
+ defer timer.Stop()
+
+ select {
+ case <-done:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-timer.C:
+ s.Logger.Errorf("processing mapping batch exceeded %s, cancelling and reconnecting to resync", watchdog)
+ return fmt.Errorf("mapping batch processing stalled after %s", watchdog)
+ }
+}
+
func (s *Server) processMappings(ctx context.Context, mappings []*proto.ProxyMapping) {
debug := s.Logger != nil && s.Logger.IsLevelEnabled(log.DebugLevel)
for _, mapping := range mappings {
@@ -1566,6 +1642,8 @@ func (s *Server) setupHTTPMapping(ctx context.Context, mapping *proto.ProxyMappi
var wildcardHit bool
if s.acme != nil {
wildcardHit = s.acme.AddDomain(d, accountID, svcID)
+ } else {
+ wildcardHit = s.staticCertCovers(d)
}
httpRoute := nbtcp.Route{
Type: nbtcp.RouteHTTP,
@@ -1590,6 +1668,26 @@ func (s *Server) setupHTTPMapping(ctx context.Context, mapping *proto.ProxyMappi
return nil
}
+// staticCertCovers reports whether the static certificate loaded when ACME is
+// disabled covers the given domain, making it certificate-ready immediately —
+// the equivalent of a wildcard hit in the ACME path. Domains the certificate
+// does not cover are logged: clients connecting to them will get TLS errors.
+func (s *Server) staticCertCovers(d domain.Domain) bool {
+ if s.staticCertWatcher == nil {
+ return false
+ }
+ leaf := s.staticCertWatcher.Leaf()
+ if leaf == nil {
+ return false
+ }
+ name := d.PunycodeString()
+ if err := leaf.VerifyHostname(name); err != nil {
+ s.Logger.Warnf("static certificate (SANs %v) does not cover domain %q: %v", leaf.DNSNames, name, err)
+ return false
+ }
+ return true
+}
+
// setupTCPMapping sets up a TCP port-forwarding fallback route on the listen port.
func (s *Server) setupTCPMapping(ctx context.Context, mapping *proto.ProxyMapping) error {
svcID := types.ServiceID(mapping.GetId())
@@ -1636,6 +1734,13 @@ func (s *Server) setupTCPMapping(ctx context.Context, mapping *proto.ProxyMappin
s.meter.L4ServiceAdded(types.ServiceModeTCP)
s.sendStatusUpdate(ctx, accountID, svcID, proto.ProxyStatus_PROXY_STATUS_ACTIVE, nil)
+
+ s.Logger.WithFields(log.Fields{
+ "domain": mapping.GetDomain(),
+ "target": targetAddr,
+ "port": port,
+ "service": svcID,
+ }).Info("TCP mapping added")
return nil
}
@@ -1884,7 +1989,7 @@ func (s *Server) addUDPRelay(ctx context.Context, mapping *proto.ProxyMapping, t
"service_id": svcID,
})
- relay := udprelay.New(ctx, udprelay.RelayConfig{
+ relay := udprelay.New(s.portRouterContext(ctx), udprelay.RelayConfig{
Logger: entry,
Listener: listener,
Target: targetAddress,
@@ -1951,7 +2056,11 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
func (s *Server) removeMapping(ctx context.Context, mapping *proto.ProxyMapping) {
accountID := types.AccountID(mapping.GetAccountId())
svcKey := s.serviceKeyForMapping(mapping)
- if err := s.netbird.RemovePeer(ctx, accountID, svcKey); err != nil {
+ removePeer := s.removePeer
+ if removePeer == nil {
+ removePeer = s.netbird.RemovePeer
+ }
+ if err := removePeer(ctx, accountID, svcKey); err != nil {
s.Logger.WithFields(log.Fields{
"account_id": accountID,
"service_id": mapping.GetId(),
diff --git a/proxy/server_test.go b/proxy/server_test.go
index aa4892201..f0c4765db 100644
--- a/proxy/server_test.go
+++ b/proxy/server_test.go
@@ -3,14 +3,20 @@ package proxy
import (
"context"
"errors"
+ "fmt"
"io"
+ "net"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel/metric/noop"
+ "google.golang.org/grpc"
+ proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics"
+ "github.com/netbirdio/netbird/proxy/internal/types"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -202,3 +208,117 @@ func TestRedactMappingForLog_HandlesEmptyOrNilFields(t *testing.T) {
assert.Nil(t, redacted.Auth, "nil Auth must remain nil")
assert.Empty(t, redacted.Path, "empty Path must remain empty")
}
+
+type statusUpdateOnlyClient struct {
+ proto.ProxyServiceClient
+}
+
+func (statusUpdateOnlyClient) SendStatusUpdate(context.Context, *proto.SendStatusUpdateRequest, ...grpc.CallOption) (*proto.SendStatusUpdateResponse, error) {
+ return &proto.SendStatusUpdateResponse{}, nil
+}
+
+func TestSetupTCPMappingBindsCustomListenPort(t *testing.T) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ port := uint16(ln.Addr().(*net.TCPAddr).Port) //nolint:gosec // test port allocated by the OS
+ require.NoError(t, ln.Close())
+
+ meter, err := proxymetrics.New(context.Background(), noop.Meter{})
+ require.NoError(t, err)
+
+ srv := &Server{
+ Logger: quietLifecycleLogger(),
+ mgmtClient: statusUpdateOnlyClient{},
+ meter: meter,
+ mainPort: 8443,
+ portRouters: make(map[uint16]*portRouter),
+ svcPorts: make(map[types.ServiceID][]uint16),
+ }
+ t.Cleanup(func() {
+ srv.portMu.Lock()
+ for p, pr := range srv.portRouters {
+ pr.cancel()
+ require.NoError(t, pr.listener.Close())
+ delete(srv.portRouters, p)
+ }
+ srv.portMu.Unlock()
+ srv.portRouterWg.Wait()
+ })
+
+ mapping := &proto.ProxyMapping{
+ Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
+ Id: "svc-tcp",
+ AccountId: "acct-1",
+ Domain: "ssh.example.com",
+ Mode: "tcp",
+ ListenPort: int32(port),
+ Path: []*proto.PathMapping{
+ {Target: "10.0.0.5:22"},
+ },
+ }
+
+ require.NoError(t, srv.setupTCPMapping(context.Background(), mapping))
+
+ srv.portMu.RLock()
+ pr := srv.portRouters[port]
+ ports := append([]uint16(nil), srv.svcPorts[types.ServiceID("svc-tcp")]...)
+ srv.portMu.RUnlock()
+
+ require.NotNil(t, pr, "custom TCP mapping must create a per-port router")
+ assert.Equal(t, []uint16{port}, ports, "service must track the custom listen port for cleanup")
+
+ second, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
+ if err == nil {
+ _ = second.Close()
+ }
+ require.Error(t, err, "custom TCP listen port must be bound after setup")
+}
+
+func TestCustomTCPPortRouterOutlivesMappingBatchContext(t *testing.T) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ port := uint16(ln.Addr().(*net.TCPAddr).Port) //nolint:gosec // test port allocated by the OS
+ require.NoError(t, ln.Close())
+
+ meter, err := proxymetrics.New(context.Background(), noop.Meter{})
+ require.NoError(t, err)
+
+ srvCtx, srvCancel := context.WithCancel(context.Background())
+ t.Cleanup(srvCancel)
+
+ srv := &Server{
+ ctx: srvCtx,
+ Logger: quietLifecycleLogger(),
+ meter: meter,
+ mainPort: 8443,
+ portRouters: make(map[uint16]*portRouter),
+ svcPorts: make(map[types.ServiceID][]uint16),
+ }
+ t.Cleanup(func() {
+ srv.portMu.Lock()
+ for p, pr := range srv.portRouters {
+ pr.cancel()
+ if err := pr.listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
+ require.NoError(t, err)
+ }
+ delete(srv.portRouters, p)
+ }
+ srv.portMu.Unlock()
+ srv.portRouterWg.Wait()
+ })
+
+ batchCtx, cancelBatch := context.WithCancel(context.Background())
+ _, err = srv.getOrCreatePortRouter(batchCtx, port)
+ require.NoError(t, err)
+
+ cancelBatch()
+
+ assert.Never(t, func() bool {
+ second, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
+ if err == nil {
+ _ = second.Close()
+ return true
+ }
+ return false
+ }, 200*time.Millisecond, 10*time.Millisecond, "custom TCP listener must outlive mapping-batch context cancellation")
+}
diff --git a/proxy/static_cert_test.go b/proxy/static_cert_test.go
new file mode 100644
index 000000000..54d2b6485
--- /dev/null
+++ b/proxy/static_cert_test.go
@@ -0,0 +1,89 @@
+package proxy
+
+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/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/proxy/internal/certwatch"
+ "github.com/netbirdio/netbird/shared/management/domain"
+)
+
+func generateCertWithSANs(t *testing.T, dnsNames []string) (certPEM, keyPEM []byte) {
+ 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: dnsNames[0]},
+ DNSNames: dnsNames,
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(24 * time.Hour),
+ }
+
+ certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
+ require.NoError(t, err)
+ certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
+
+ keyDER, err := x509.MarshalECPrivateKey(key)
+ require.NoError(t, err)
+ keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
+
+ return certPEM, keyPEM
+}
+
+func newStaticWatcher(t *testing.T, dnsNames []string) *certwatch.Watcher {
+ t.Helper()
+
+ dir := t.TempDir()
+ certPEM, keyPEM := generateCertWithSANs(t, dnsNames)
+ certPath := filepath.Join(dir, "tls.crt")
+ keyPath := filepath.Join(dir, "tls.key")
+ require.NoError(t, os.WriteFile(certPath, certPEM, 0o600))
+ require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600))
+
+ w, err := certwatch.NewWatcher(certPath, keyPath, quietLifecycleLogger())
+ require.NoError(t, err)
+ return w
+}
+
+func TestStaticCertCovers(t *testing.T) {
+ s := &Server{
+ Logger: quietLifecycleLogger(),
+ staticCertWatcher: newStaticWatcher(t, []string{"*.p.example.com", "exact.example.com"}),
+ }
+
+ cases := []struct {
+ domain string
+ covered bool
+ }{
+ {"svc.p.example.com", true},
+ {"exact.example.com", true},
+ {"a.b.p.example.com", false}, // wildcard does not span labels
+ {"p.example.com", false},
+ {"other.example.com", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.domain, func(t *testing.T) {
+ assert.Equal(t, tc.covered, s.staticCertCovers(domain.Domain(tc.domain)))
+ })
+ }
+}
+
+func TestStaticCertCoversNoWatcher(t *testing.T) {
+ s := &Server{Logger: quietLifecycleLogger()}
+ assert.False(t, s.staticCertCovers(domain.Domain("svc.p.example.com")))
+}
diff --git a/proxy/sync_mappings_test.go b/proxy/sync_mappings_test.go
index 801587e4c..c9c0dad03 100644
--- a/proxy/sync_mappings_test.go
+++ b/proxy/sync_mappings_test.go
@@ -81,6 +81,95 @@ func TestIntegration_SyncMappings_HappyPath(t *testing.T) {
assert.Equal(t, "app2.test.proxy.io", rp2.GetDomain())
}
+func TestIntegration_SyncMappings_CustomTCPMappingDeliveredWithCapabilities(t *testing.T) {
+ setup := setupIntegrationTest(t)
+ defer setup.cleanup()
+
+ ctx := context.Background()
+ tcpSvc := &service.Service{
+ ID: "tcp-custom",
+ AccountID: "test-account-1",
+ Name: "Custom TCP",
+ Domain: "ssh.test.proxy.io",
+ ProxyCluster: "test.proxy.io",
+ Mode: "tcp",
+ ListenPort: 10001,
+ Enabled: true,
+ Targets: []*service.Target{{
+ Host: "10.0.0.5",
+ Port: 22,
+ Protocol: "tcp",
+ TargetId: "peer-ssh",
+ TargetType: "peer",
+ Enabled: true,
+ }},
+ }
+ require.NoError(t, setup.store.CreateService(ctx, tcpSvc))
+
+ conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+
+ client := proto.NewProxyServiceClient(conn)
+ receiveSnapshot := func(proxyID string, caps *proto.ProxyCapabilities) map[string]*proto.ProxyMapping {
+ t.Helper()
+
+ streamCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ stream, err := client.SyncMappings(streamCtx)
+ require.NoError(t, err)
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Init{
+ Init: &proto.SyncMappingsInit{
+ ProxyId: proxyID,
+ Version: "test-v1",
+ Address: "test.proxy.io",
+ Capabilities: caps,
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ mappingsByID := make(map[string]*proto.ProxyMapping)
+ for {
+ msg, err := stream.Recv()
+ require.NoError(t, err)
+ for _, m := range msg.GetMapping() {
+ mappingsByID[m.GetId()] = m
+ }
+
+ err = stream.Send(&proto.SyncMappingsRequest{
+ Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}},
+ })
+ require.NoError(t, err)
+
+ if msg.GetInitialSyncComplete() {
+ break
+ }
+ }
+ return mappingsByID
+ }
+
+ legacyMappings := receiveSnapshot("sync-proxy-no-capabilities", nil)
+ assert.NotContains(t, legacyMappings, "tcp-custom",
+ "legacy proxies that do not report capabilities must not receive TCP custom-port mappings")
+
+ supportsCustomPorts := true
+ modernMappings := receiveSnapshot("sync-proxy-custom-ports", &proto.ProxyCapabilities{
+ SupportsCustomPorts: &supportsCustomPorts,
+ })
+
+ tcpMapping := modernMappings["tcp-custom"]
+ require.NotNil(t, tcpMapping, "capability-aware proxy must receive TCP custom-port mapping")
+ assert.Equal(t, "tcp", tcpMapping.GetMode())
+ assert.Equal(t, int32(10001), tcpMapping.GetListenPort())
+ require.Len(t, tcpMapping.GetPath(), 1)
+ assert.Equal(t, "10.0.0.5:22", tcpMapping.GetPath()[0].GetTarget())
+ assert.NotEmpty(t, tcpMapping.GetAuthToken(), "snapshot mapping must include per-proxy auth token")
+}
+
func TestIntegration_SyncMappings_BackPressure(t *testing.T) {
setup := setupIntegrationTest(t)
defer setup.cleanup()
diff --git a/release_files/install.sh b/release_files/install.sh
index 1e71936f3..a002de472 100755
--- a/release_files/install.sh
+++ b/release_files/install.sh
@@ -417,15 +417,30 @@ if type uname >/dev/null 2>&1; then
# Check the availability of a compatible package manager
if check_use_bin_variable; then
PACKAGE_MANAGER="bin"
+ elif [ -e /run/ostree-booted ]; then
+ if [ -x "$(command -v rpm-ostree)" ]; then
+ PACKAGE_MANAGER="rpm-ostree"
+ echo "The installation will be performed using rpm-ostree package manager"
+ elif [ -x "$(command -v bootc)" ]; then
+ echo "Detected bootc system without rpm-ostree." >&2
+ echo "NetBird cannot be installed via package manager on this system." >&2
+ echo "Options:" >&2
+ echo " 1. Install via Distrobox (instructions in the installation docs)" >&2
+ echo " 2. Rebuild your base image with rpm-ostree included" >&2
+ echo " 3. Bake NetBird into your Containerfile" >&2
+ exit 1
+ else
+ echo "Detected ostree-booted system without rpm-ostree or bootc." >&2
+ echo "NetBird cannot be installed automatically on this atomic system." >&2
+ echo "Please install NetBird by rebuilding your base image or use a supported package manager." >&2
+ exit 1
+ fi
elif [ -x "$(command -v apt-get)" ]; then
PACKAGE_MANAGER="apt"
echo "The installation will be performed using apt package manager"
elif [ -x "$(command -v dnf)" ]; then
PACKAGE_MANAGER="dnf"
echo "The installation will be performed using dnf package manager"
- elif [ -x "$(command -v rpm-ostree)" ]; then
- PACKAGE_MANAGER="rpm-ostree"
- echo "The installation will be performed using rpm-ostree package manager"
elif [ -x "$(command -v yum)" ]; then
PACKAGE_MANAGER="yum"
echo "The installation will be performed using yum package manager"
diff --git a/shared/context/keys.go b/shared/context/keys.go
index ca56be67e..3287a6366 100644
--- a/shared/context/keys.go
+++ b/shared/context/keys.go
@@ -6,4 +6,5 @@ const (
RoleKey = "role"
UserIDKey = "userID"
PeerIDKey = "peerID"
+ UserAgentKey = "userAgent"
)
diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go
index 53f3a262d..b62317775 100644
--- a/shared/management/client/client_test.go
+++ b/shared/management/client/client_test.go
@@ -322,15 +322,21 @@ func TestClient_Sync(t *testing.T) {
if resp.GetNetbirdConfig() == nil {
t.Error("expecting non nil NetbirdConfig got nil")
}
- if len(resp.GetRemotePeers()) != 1 {
- t.Errorf("expecting RemotePeers size %d got %d", 1, len(resp.GetRemotePeers()))
+ // we test network map peers from 0.29.3 and dev builds
+ if len(resp.GetRemotePeers()) != 0 {
+ t.Error("expecting top-level RemotePeers to be empty for v0.29.3+ clients")
+ }
+ networkMap := resp.GetNetworkMap()
+ if len(networkMap.GetRemotePeers()) != 1 {
+ t.Errorf("expecting RemotePeers size %d got %d", 1, len(networkMap.GetRemotePeers()))
return
}
- if resp.GetRemotePeersIsEmpty() == true {
+
+ if networkMap.GetRemotePeersIsEmpty() {
t.Error("expecting RemotePeers property to be false, got true")
}
- if resp.GetRemotePeers()[0].GetWgPubKey() != remoteKey.PublicKey().String() {
- t.Errorf("expecting RemotePeer public key %s got %s", remoteKey.PublicKey().String(), resp.GetRemotePeers()[0].GetWgPubKey())
+ if networkMap.GetRemotePeers()[0].GetWgPubKey() != remoteKey.PublicKey().String() {
+ t.Errorf("expecting RemotePeer public key %s got %s", remoteKey.PublicKey().String(), networkMap.GetRemotePeers()[0].GetWgPubKey())
}
case <-time.After(3 * time.Second):
t.Error("timeout waiting for test to finish")
diff --git a/shared/management/http/api/generate.sh b/shared/management/http/api/generate.sh
index 3770ea90f..ba29a6905 100755
--- a/shared/management/http/api/generate.sh
+++ b/shared/management/http/api/generate.sh
@@ -11,6 +11,6 @@ fi
old_pwd=$(pwd)
script_path=$(dirname $(realpath "$0"))
cd "$script_path"
-go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
+go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.1
oapi-codegen --config cfg.yaml openapi.yml
cd "$old_pwd"
diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml
index 03e30e6b7..196a0c6b1 100644
--- a/shared/management/http/api/openapi.yml
+++ b/shared/management/http/api/openapi.yml
@@ -3086,24 +3086,6 @@ components:
- enabled
- auth
- meta
- allOf:
- # When private=true, access_groups must be present and non-empty,
- # and the service mode must be "http". The bearer-auth mutex is
- # enforced at the service-validation layer
- # (validatePrivateRequirements) because it sits in a nested
- # ServiceAuthConfig and isn't cleanly expressible here.
- - if:
- required: [private]
- properties:
- private:
- const: true
- then:
- required: [access_groups]
- properties:
- access_groups:
- minItems: 1
- mode:
- const: http
ServiceMeta:
type: object
properties:
@@ -3191,23 +3173,6 @@ components:
- name
- domain
- enabled
- allOf:
- # Mirror of the Service conditional: when private=true the
- # request must carry a non-empty access_groups list and the
- # mode must be "http". The bearer-auth mutex is enforced at the
- # service-validation layer (validatePrivateRequirements).
- - if:
- required: [private]
- properties:
- private:
- const: true
- then:
- required: [access_groups]
- properties:
- access_groups:
- minItems: 1
- mode:
- const: http
ServiceTargetOptions:
type: object
properties:
@@ -5107,31 +5072,63 @@ components:
responses:
not_found:
description: Resource not found
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
validation_failed_simple:
description: Validation failed
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
bad_request:
description: Bad Request
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
internal_error:
description: Internal Server Error
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
validation_failed:
description: Validation failed
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
forbidden:
description: Forbidden
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
requires_authentication:
description: Requires authentication
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content: { }
conflict:
description: Conflict
+ headers:
+ X-Request-Id:
+ $ref: '#/components/headers/X-Request-Id'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
+ headers:
+ X-Request-Id:
+ description: |
+ Unique identifier assigned to the request by the server and set on every
+ response. Useful for correlating client requests with server-side logs.
+ schema:
+ type: string
+ example: cot7r4n3l3vh3qj4qveg
securitySchemes:
BearerAuth:
type: http
diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go
index d7945e448..ed5060a86 100644
--- a/shared/management/http/api/types.gen.go
+++ b/shared/management/http/api/types.gen.go
@@ -1,6 +1,6 @@
// Package api provides primitives to interact with the openapi HTTP API.
//
-// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.0 DO NOT EDIT.
+// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.1 DO NOT EDIT.
package api
import (
diff --git a/shared/relay/client/client.go b/shared/relay/client/client.go
index 1800bddb2..002b8d134 100644
--- a/shared/relay/client/client.go
+++ b/shared/relay/client/client.go
@@ -9,12 +9,14 @@ import (
"net/url"
"strings"
"sync"
+ "sync/atomic"
"time"
log "github.com/sirupsen/logrus"
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
"github.com/netbirdio/netbird/shared/relay/client/dialer"
+ netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
"github.com/netbirdio/netbird/shared/relay/healthcheck"
"github.com/netbirdio/netbird/shared/relay/messages"
)
@@ -172,6 +174,19 @@ type Client struct {
stateSubscription *PeersStateSubscription
mtu uint16
+
+ // transportFallback, when set, records datagram-too-large failures so a
+ // datagram-sized transport is avoided on subsequent connects. Shared via
+ // the manager.
+ transportFallback *transportFallback
+ // datagramFallbackTriggered guards a single fallback per connection so a
+ // burst of oversized datagrams triggers one reconnect, not many.
+ datagramFallbackTriggered atomic.Bool
+}
+
+// SetTransportFallback wires the shared datagram-transport fallback tracker.
+func (c *Client) SetTransportFallback(tf *transportFallback) {
+ c.transportFallback = tf
}
// NewClient creates a new client for the relay server. The client is not connected to the server until the Connect
@@ -361,12 +376,13 @@ func (c *Client) Close() error {
}
func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
- dialers := c.getDialers()
+ mode := transportModeFromEnv()
+ dialers := c.getDialers(mode)
var conn net.Conn
if c.serverIP.IsValid() {
var err error
- conn, err = c.dialRaceDirect(ctx, dialers)
+ conn, err = c.dialRaceDirect(ctx, mode, dialers)
if err != nil {
c.log.Infof("dial via server IP %s failed, falling back to FQDN: %v", c.serverIP, err)
conn = nil
@@ -375,6 +391,9 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
if conn == nil {
rd := dialer.NewRaceDial(c.log, dialer.DefaultConnectionTimeout, c.connectionURL, dialers...)
+ if mode.sequential() {
+ rd.WithSequential()
+ }
var err error
conn, err = rd.Dial(ctx)
if err != nil {
@@ -382,6 +401,7 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
}
}
c.relayConn = conn
+ c.datagramFallbackTriggered.Store(false)
instanceURL, err := c.handShake(ctx)
if err != nil {
@@ -396,7 +416,7 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) {
}
// dialRaceDirect dials c.serverIP, preserving the original FQDN as the TLS ServerName for SNI.
-func (c *Client) dialRaceDirect(ctx context.Context, dialers []dialer.DialeFn) (net.Conn, error) {
+func (c *Client) dialRaceDirect(ctx context.Context, mode TransportMode, dialers []dialer.DialeFn) (net.Conn, error) {
directURL, serverName, err := substituteHost(c.connectionURL, c.serverIP)
if err != nil {
return nil, fmt.Errorf("substitute host: %w", err)
@@ -406,6 +426,9 @@ func (c *Client) dialRaceDirect(ctx context.Context, dialers []dialer.DialeFn) (
rd := dialer.NewRaceDial(c.log, dialer.DefaultConnectionTimeout, directURL, dialers...).
WithServerName(serverName)
+ if mode.sequential() {
+ rd.WithSequential()
+ }
return rd.Dial(ctx)
}
@@ -631,13 +654,53 @@ func (c *Client) writeTo(containerRef *connContainer, dstID messages.PeerID, pay
}
// the write always return with 0 length because the underling does not support the size feedback.
- _, err = c.relayConn.Write(msg)
+ conn := c.relayConn
+ _, err = conn.Write(msg)
if err != nil {
- c.log.Errorf("failed to write transport message: %s", err)
+ if errors.Is(err, netErr.ErrDatagramTooLarge) {
+ c.onDatagramTooLarge(conn, err)
+ } else {
+ c.log.Errorf("failed to write transport message: %s", err)
+ }
}
return len(payload), err
}
+// onDatagramTooLarge reacts to a datagram rejected as too large for the path.
+// When a non-datagram transport is available, it records a fallback for this
+// server and closes the connection so the reconnect avoids datagram-sized
+// transports. A single fallback is triggered per connection regardless of how
+// many oversized datagrams arrive. cause carries the datagram size and budget.
+func (c *Client) onDatagramTooLarge(conn net.Conn, cause error) {
+ // Handle one oversized datagram per connection; a burst triggers a single
+ // fallback (and a single log line), not many.
+ if !c.datagramFallbackTriggered.CompareAndSwap(false, true) {
+ return
+ }
+
+ // If the selected mode offers no non-datagram transport (e.g. pinned to a
+ // datagram-sized transport), reconnecting would just re-fail, so leave the
+ // connection up rather than loop.
+ if len(nonDatagramSized(c.baseDialers(transportModeFromEnv()))) == 0 {
+ c.log.Warnf("%s, but no non-datagram transport is available, not falling back", cause)
+ return
+ }
+
+ // Without the shared tracker a reconnect would just select the same
+ // transport again and re-fail, so leave the connection up rather than loop.
+ if c.transportFallback == nil {
+ c.log.Debugf("%s, but no transport fallback configured, leaving connection up", cause)
+ return
+ }
+
+ window := c.transportFallback.recordFailure(c.connectionURL)
+ c.log.Warnf("%s, avoiding datagram-sized transport for %s", cause, window)
+
+ if err := conn.Close(); err != nil {
+ c.log.Debugf("close relay connection for transport fallback: %s", err)
+ }
+}
+
func (c *Client) listenForStopEvents(ctx context.Context, hc *healthcheck.Receiver, conn net.Conn, internalStopFlag *internalStopFlag) {
for {
select {
diff --git a/shared/relay/client/dialer/capability.go b/shared/relay/client/dialer/capability.go
new file mode 100644
index 000000000..511cb2ac7
--- /dev/null
+++ b/shared/relay/client/dialer/capability.go
@@ -0,0 +1,18 @@
+package dialer
+
+// DatagramSized is implemented by dialers whose connections carry each write in
+// a single datagram, so a write can be rejected when it exceeds the path's
+// datagram budget (e.g. QUIC). Transports without this capability (e.g.
+// WebSocket over TCP) impose no per-write size limit, so the relay client can
+// fall back to them when a datagram-sized transport rejects a write as too
+// large. The capability is advertised per dialer rather than hardcoded, so a
+// new transport only needs to declare whether it is datagram-sized.
+type DatagramSized interface {
+ DatagramSized()
+}
+
+// IsDatagramSized reports whether d produces datagram-sized connections.
+func IsDatagramSized(d DialeFn) bool {
+ _, ok := d.(DatagramSized)
+ return ok
+}
diff --git a/shared/relay/client/dialer/net/err.go b/shared/relay/client/dialer/net/err.go
index fee844963..c622420dc 100644
--- a/shared/relay/client/dialer/net/err.go
+++ b/shared/relay/client/dialer/net/err.go
@@ -4,4 +4,9 @@ import "errors"
var (
ErrClosedByServer = errors.New("closed by server")
+
+ // ErrDatagramTooLarge is returned when a transport message exceeds the
+ // QUIC datagram size the path to the relay can carry. The relay client
+ // treats it as a signal to fall back to a non-datagram transport.
+ ErrDatagramTooLarge = errors.New("datagram frame too large")
)
diff --git a/shared/relay/client/dialer/quic/conn.go b/shared/relay/client/dialer/quic/conn.go
index 1d90d7139..a5c982551 100644
--- a/shared/relay/client/dialer/quic/conn.go
+++ b/shared/relay/client/dialer/quic/conn.go
@@ -8,7 +8,6 @@ import (
"time"
"github.com/quic-go/quic-go"
- log "github.com/sirupsen/logrus"
netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
)
@@ -52,11 +51,8 @@ func (c *Conn) Read(b []byte) (n int, err error) {
}
func (c *Conn) Write(b []byte) (int, error) {
- err := c.session.SendDatagram(b)
- if err != nil {
- err = c.remoteCloseErrHandling(err)
- log.Errorf("failed to write to QUIC stream: %v", err)
- return 0, err
+ if err := c.session.SendDatagram(b); err != nil {
+ return 0, c.writeErrHandling(err, len(b))
}
return len(b), nil
}
@@ -95,3 +91,15 @@ func (c *Conn) remoteCloseErrHandling(err error) error {
}
return err
}
+
+// writeErrHandling normalizes SendDatagram errors. A datagram that exceeds the
+// path's QUIC packet budget is mapped to ErrDatagramTooLarge (annotated with the
+// datagram size and path budget) so the relay client can fall back to a
+// non-datagram transport.
+func (c *Conn) writeErrHandling(err error, size int) error {
+ var tooLarge *quic.DatagramTooLargeError
+ if errors.As(err, &tooLarge) {
+ return fmt.Errorf("%w: %d byte datagram over path budget %d", netErr.ErrDatagramTooLarge, size, tooLarge.MaxDatagramPayloadSize)
+ }
+ return c.remoteCloseErrHandling(err)
+}
diff --git a/shared/relay/client/dialer/quic/quic.go b/shared/relay/client/dialer/quic/quic.go
index 86f6f178d..5e1758a1c 100644
--- a/shared/relay/client/dialer/quic/quic.go
+++ b/shared/relay/client/dialer/quic/quic.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/quic-go/quic-go"
+ "github.com/quic-go/quic-go/logging"
log "github.com/sirupsen/logrus"
nbnet "github.com/netbirdio/netbird/client/net"
@@ -23,6 +24,12 @@ func (d Dialer) Protocol() string {
return Network
}
+// DatagramSized marks QUIC as a datagram-sized transport: relay traffic is
+// carried in QUIC DATAGRAM frames, which must fit a single packet.
+func (d Dialer) DatagramSized() {
+ // Intentional marker method; presence is the capability signal.
+}
+
func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, error) {
quicURL, err := prepareURL(address)
if err != nil {
@@ -47,6 +54,7 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn,
MaxIdleTimeout: 4 * time.Minute,
EnableDatagrams: true,
InitialPacketSize: nbRelay.QUICInitialPacketSize,
+ Tracer: connectionTracer(quicURL),
}
udpConn, err := nbnet.ListenUDP("udp", &net.UDPAddr{Port: 0})
@@ -74,6 +82,28 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn,
return conn, nil
}
+// connectionTracer returns a QUIC tracer that logs the DPLPMTUD result and the
+// reason a relay connection closed, so the path MTU settled on and teardown
+// cause are visible in logs. Lines carry the relay address as a structured
+// field, matching the rest of the relay client logging.
+func connectionTracer(addr string) func(context.Context, logging.Perspective, quic.ConnectionID) *logging.ConnectionTracer {
+ relayLog := log.WithField("relay", addr)
+ return func(context.Context, logging.Perspective, quic.ConnectionID) *logging.ConnectionTracer {
+ return &logging.ConnectionTracer{
+ UpdatedMTU: func(mtu logging.ByteCount, done bool) {
+ if done {
+ relayLog.Infof("QUIC path MTU settled at %d", mtu)
+ return
+ }
+ relayLog.Debugf("QUIC path MTU probing at %d", mtu)
+ },
+ ClosedConnection: func(err error) {
+ relayLog.Debugf("QUIC connection closed: %v", err)
+ },
+ }
+ }
+}
+
func prepareURL(address string) (string, error) {
var host string
var defaultPort string
diff --git a/shared/relay/client/dialer/race_dialer.go b/shared/relay/client/dialer/race_dialer.go
index 15208b858..aef1ef464 100644
--- a/shared/relay/client/dialer/race_dialer.go
+++ b/shared/relay/client/dialer/race_dialer.go
@@ -32,6 +32,7 @@ type RaceDial struct {
serverName string
dialerFns []DialeFn
connectionTimeout time.Duration
+ sequential bool
}
func NewRaceDial(log *log.Entry, connectionTimeout time.Duration, serverURL string, dialerFns ...DialeFn) *RaceDial {
@@ -53,7 +54,21 @@ func (r *RaceDial) WithServerName(serverName string) *RaceDial {
return r
}
+// WithSequential makes Dial try the dialers in order, falling back to the next
+// only when one fails to connect, instead of racing them concurrently.
+//
+// Mutates the receiver and is not safe for concurrent reconfiguration; a
+// RaceDial is intended to be constructed per dial and discarded.
+func (r *RaceDial) WithSequential() *RaceDial {
+ r.sequential = true
+ return r
+}
+
func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) {
+ if r.sequential {
+ return r.dialSequential(ctx)
+ }
+
connChan := make(chan dialResult, len(r.dialerFns))
winnerConn := make(chan net.Conn, 1)
abortCtx, abort := context.WithCancel(ctx)
@@ -72,6 +87,30 @@ func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) {
return conn, nil
}
+// dialSequential tries each dialer in order, returning the first connection and
+// falling back to the next on failure.
+func (r *RaceDial) dialSequential(ctx context.Context) (net.Conn, error) {
+ for _, dfn := range r.dialerFns {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ attemptCtx, cancel := context.WithTimeout(ctx, r.connectionTimeout)
+ r.log.Infof("dialing Relay server via %s", dfn.Protocol())
+ conn, err := dfn.Dial(attemptCtx, r.serverURL, r.serverName)
+ cancel()
+ if err != nil {
+ if errors.Is(err, context.Canceled) {
+ return nil, err
+ }
+ r.log.Errorf("failed to dial via %s: %s", dfn.Protocol(), err)
+ continue
+ }
+ r.log.Infof("successfully dialed via: %s", dfn.Protocol())
+ return conn, nil
+ }
+ return nil, errors.New("failed to dial to Relay server on any protocol")
+}
+
func (r *RaceDial) dial(dfn DialeFn, abortCtx context.Context, connChan chan dialResult) {
ctx, cancel := context.WithTimeout(abortCtx, r.connectionTimeout)
defer cancel()
diff --git a/shared/relay/client/dialer/race_dialer_test.go b/shared/relay/client/dialer/race_dialer_test.go
index a53edc00e..bd2f4bb85 100644
--- a/shared/relay/client/dialer/race_dialer_test.go
+++ b/shared/relay/client/dialer/race_dialer_test.go
@@ -250,3 +250,66 @@ func TestRaceDialFirstSuccessfulDialerWins(t *testing.T) {
}
}
}
+
+func TestRaceDialSequentialFallback(t *testing.T) {
+ logger := logrus.NewEntry(logrus.New())
+ serverURL := "test.server.com"
+
+ var firstDialed, secondDialed bool
+ preferred := &MockDialer{
+ protocolStr: "quic",
+ dialFunc: func(ctx context.Context, address string) (net.Conn, error) {
+ firstDialed = true
+ return nil, errors.New("quic unreachable")
+ },
+ }
+ fallbackConn := &MockConn{remoteAddr: &MockAddr{network: "ws"}}
+ fallback := &MockDialer{
+ protocolStr: "ws",
+ dialFunc: func(ctx context.Context, address string) (net.Conn, error) {
+ secondDialed = true
+ return fallbackConn, nil
+ },
+ }
+
+ rd := NewRaceDial(logger, DefaultConnectionTimeout, serverURL, preferred, fallback).WithSequential()
+ conn, err := rd.Dial(context.Background())
+ if err != nil {
+ t.Fatalf("expected fallback to succeed, got %v", err)
+ }
+ if conn != fallbackConn {
+ t.Errorf("expected fallback connection, got %v", conn)
+ }
+ if !firstDialed || !secondDialed {
+ t.Errorf("expected both dialers attempted in order, first=%v second=%v", firstDialed, secondDialed)
+ }
+}
+
+func TestRaceDialSequentialPreferredWins(t *testing.T) {
+ logger := logrus.NewEntry(logrus.New())
+ serverURL := "test.server.com"
+
+ preferredConn := &MockConn{remoteAddr: &MockAddr{network: "quic"}}
+ preferred := &MockDialer{
+ protocolStr: "quic",
+ dialFunc: func(ctx context.Context, address string) (net.Conn, error) {
+ return preferredConn, nil
+ },
+ }
+ fallback := &MockDialer{
+ protocolStr: "ws",
+ dialFunc: func(ctx context.Context, address string) (net.Conn, error) {
+ t.Errorf("fallback dialer must not be tried when preferred succeeds")
+ return nil, errors.New("should not happen")
+ },
+ }
+
+ rd := NewRaceDial(logger, DefaultConnectionTimeout, serverURL, preferred, fallback).WithSequential()
+ conn, err := rd.Dial(context.Background())
+ if err != nil {
+ t.Fatalf("expected preferred to succeed, got %v", err)
+ }
+ if conn != preferredConn {
+ t.Errorf("expected preferred connection, got %v", conn)
+ }
+}
diff --git a/shared/relay/client/dialers_generic.go b/shared/relay/client/dialers_generic.go
index a8ed79961..95e319338 100644
--- a/shared/relay/client/dialers_generic.go
+++ b/shared/relay/client/dialers_generic.go
@@ -9,11 +9,42 @@ import (
"github.com/netbirdio/netbird/shared/relay/client/dialer/ws"
)
-// getDialers returns the list of dialers to use for connecting to the relay server.
-func (c *Client) getDialers() []dialer.DialeFn {
- if c.mtu > 0 && c.mtu > iface.DefaultMTU {
- c.log.Infof("MTU %d exceeds default (%d), forcing WebSocket transport to avoid DATAGRAM frame size issues", c.mtu, iface.DefaultMTU)
- return []dialer.DialeFn{ws.Dialer{}}
+// getDialers returns the ordered dialers for connecting to the relay server. It
+// applies the datagram fallback generically: if this server recently rejected a
+// datagram-sized transport, those dialers are dropped, leaving the rest.
+func (c *Client) getDialers(mode TransportMode) []dialer.DialeFn {
+ dialers := c.baseDialers(mode)
+
+ if c.transportFallback != nil && c.transportFallback.avoidDatagramSized(c.connectionURL) {
+ if filtered := nonDatagramSized(dialers); len(filtered) > 0 {
+ c.log.Infof("relay recently rejected a datagram-sized transport, avoiding it")
+ return filtered
+ }
}
- return []dialer.DialeFn{quic.Dialer{}, ws.Dialer{}}
+ return dialers
+}
+
+// baseDialers returns the ordered dialers for the mode, before any datagram
+// fallback filtering. For racing modes (auto) the order is irrelevant; for
+// prefer modes the first entry is tried before falling back to the second.
+func (c *Client) baseDialers(mode TransportMode) []dialer.DialeFn {
+ switch mode {
+ case TransportModeWS:
+ c.log.Infof("%s=ws, using WebSocket transport", EnvRelayTransport)
+ return []dialer.DialeFn{ws.Dialer{}}
+ case TransportModeQUIC:
+ c.log.Infof("%s=quic, using QUIC transport", EnvRelayTransport)
+ return []dialer.DialeFn{quic.Dialer{}}
+ }
+
+ all := []dialer.DialeFn{quic.Dialer{}, ws.Dialer{}}
+ if mode == TransportModePreferWS {
+ all = []dialer.DialeFn{ws.Dialer{}, quic.Dialer{}}
+ }
+
+ if c.mtu > 0 && c.mtu > iface.DefaultMTU {
+ c.log.Infof("MTU %d exceeds default (%d), avoiding datagram-sized transports", c.mtu, iface.DefaultMTU)
+ return nonDatagramSized(all)
+ }
+ return all
}
diff --git a/shared/relay/client/dialers_generic_test.go b/shared/relay/client/dialers_generic_test.go
new file mode 100644
index 000000000..c4ef9cc59
--- /dev/null
+++ b/shared/relay/client/dialers_generic_test.go
@@ -0,0 +1,101 @@
+//go:build !js
+
+package client
+
+import (
+ "os"
+ "testing"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+
+ "github.com/netbirdio/netbird/client/iface"
+ "github.com/netbirdio/netbird/shared/relay/client/dialer"
+ netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
+ "github.com/netbirdio/netbird/shared/relay/client/dialer/quic"
+ "github.com/netbirdio/netbird/shared/relay/client/dialer/ws"
+)
+
+// TestDatagramSizedCapability locks the capability the generic fallback relies
+// on: QUIC is datagram-sized, WebSocket is not.
+func TestDatagramSizedCapability(t *testing.T) {
+ assert.True(t, dialer.IsDatagramSized(quic.Dialer{}), "QUIC must advertise datagram-sized")
+ assert.False(t, dialer.IsDatagramSized(ws.Dialer{}), "WebSocket must not advertise datagram-sized")
+}
+
+func protocols(dialers []dialer.DialeFn) []string {
+ out := make([]string, len(dialers))
+ for i, d := range dialers {
+ out[i] = d.Protocol()
+ }
+ return out
+}
+
+func TestGetDialers(t *testing.T) {
+ const url = "rels://relay.example:443"
+
+ tests := []struct {
+ name string
+ mode string
+ mtu uint16
+ preferWS bool
+ want []string
+ }{
+ {name: "auto races quic and ws", mode: "auto", mtu: iface.DefaultMTU, want: []string{"quic", "WS"}},
+ {name: "ws pinned", mode: "ws", mtu: iface.DefaultMTU, want: []string{"WS"}},
+ {name: "quic pinned", mode: "quic", mtu: iface.DefaultMTU, want: []string{"quic"}},
+ {name: "prefer-quic orders quic first", mode: "prefer-quic", mtu: iface.DefaultMTU, want: []string{"quic", "WS"}},
+ {name: "prefer-ws orders ws first", mode: "prefer-ws", mtu: iface.DefaultMTU, want: []string{"WS", "quic"}},
+ {name: "mtu above default forces ws", mode: "auto", mtu: iface.DefaultMTU + 100, want: []string{"WS"}},
+ {name: "sticky fallback forces ws in auto", mode: "auto", mtu: iface.DefaultMTU, preferWS: true, want: []string{"WS"}},
+ {name: "sticky fallback forces ws in prefer-quic", mode: "prefer-quic", mtu: iface.DefaultMTU, preferWS: true, want: []string{"WS"}},
+ {name: "quic pin overrides sticky fallback", mode: "quic", mtu: iface.DefaultMTU, preferWS: true, want: []string{"quic"}},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv(EnvRelayTransport, tc.mode)
+ if tc.mode == "" {
+ os.Unsetenv(EnvRelayTransport)
+ }
+
+ tf := newTransportFallback()
+ if tc.preferWS {
+ tf.recordFailure(url)
+ }
+
+ c := &Client{
+ log: log.WithField("test", t.Name()),
+ connectionURL: url,
+ mtu: tc.mtu,
+ transportFallback: tf,
+ }
+
+ assert.Equal(t, tc.want, protocols(c.getDialers(transportModeFromEnv())))
+ })
+ }
+}
+
+// TestStickyFallbackAfterDatagramTooLarge verifies the full chain: an oversized
+// datagram records a fallback that makes the next dial pick WebSocket, the way a
+// reconnect would after the connection is closed.
+func TestStickyFallbackAfterDatagramTooLarge(t *testing.T) {
+ const url = "rels://relay.example:443"
+ t.Setenv(EnvRelayTransport, string(TransportModeAuto))
+
+ c := &Client{
+ log: log.WithField("test", t.Name()),
+ connectionURL: url,
+ mtu: iface.DefaultMTU,
+ transportFallback: newTransportFallback(),
+ }
+
+ // First dial races both transports.
+ assert.Equal(t, []string{"quic", "WS"}, protocols(c.getDialers(transportModeFromEnv())))
+
+ // An oversized datagram records the fallback for this server.
+ c.onDatagramTooLarge(&closeTrackingConn{}, netErr.ErrDatagramTooLarge)
+
+ // The reconnect now sticks to WebSocket.
+ assert.Equal(t, []string{"WS"}, protocols(c.getDialers(transportModeFromEnv())))
+}
diff --git a/shared/relay/client/dialers_js.go b/shared/relay/client/dialers_js.go
index 6bd0e6696..c93787729 100644
--- a/shared/relay/client/dialers_js.go
+++ b/shared/relay/client/dialers_js.go
@@ -7,7 +7,11 @@ import (
"github.com/netbirdio/netbird/shared/relay/client/dialer/ws"
)
-func (c *Client) getDialers() []dialer.DialeFn {
+func (c *Client) getDialers(_ TransportMode) []dialer.DialeFn {
// JS/WASM build only uses WebSocket transport
return []dialer.DialeFn{ws.Dialer{}}
}
+
+func (c *Client) baseDialers(_ TransportMode) []dialer.DialeFn {
+ return []dialer.DialeFn{ws.Dialer{}}
+}
diff --git a/shared/relay/client/manager.go b/shared/relay/client/manager.go
index 3858b3c83..f87da15de 100644
--- a/shared/relay/client/manager.go
+++ b/shared/relay/client/manager.go
@@ -79,23 +79,30 @@ type Manager struct {
cleanupInterval time.Duration
keepUnusedServerTime time.Duration
+
+ // transportFallback is shared across home and foreign relay clients so a
+ // datagram-too-large failure makes that server avoid datagram-sized transports across reconnects.
+ transportFallback *transportFallback
}
// NewManager creates a new manager instance.
// The serverURL address can be empty. In this case, the manager will not serve.
func NewManager(ctx context.Context, serverURLs []string, peerID string, mtu uint16, opts ...ManagerOption) *Manager {
tokenStore := &relayAuth.TokenStore{}
+ tf := newTransportFallback()
m := &Manager{
- ctx: ctx,
- peerID: peerID,
- tokenStore: tokenStore,
- mtu: mtu,
+ ctx: ctx,
+ peerID: peerID,
+ tokenStore: tokenStore,
+ mtu: mtu,
+ transportFallback: tf,
serverPicker: &ServerPicker{
TokenStore: tokenStore,
PeerID: peerID,
MTU: mtu,
ConnectionTimeout: defaultConnectionTimeout,
+ TransportFallback: tf,
},
relayClients: make(map[string]*RelayTrack),
onDisconnectedListeners: make(map[string]*list.List),
@@ -287,6 +294,7 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string
m.relayClientsMutex.Unlock()
relayClient := NewClientWithServerIP(serverAddress, serverIP, m.tokenStore, m.peerID, m.mtu)
+ relayClient.SetTransportFallback(m.transportFallback)
err := relayClient.Connect(m.ctx)
if err != nil {
rt.err = err
diff --git a/shared/relay/client/picker.go b/shared/relay/client/picker.go
index 39d0ba072..992e48114 100644
--- a/shared/relay/client/picker.go
+++ b/shared/relay/client/picker.go
@@ -29,6 +29,7 @@ type ServerPicker struct {
PeerID string
MTU uint16
ConnectionTimeout time.Duration
+ TransportFallback *transportFallback
}
func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) {
@@ -70,6 +71,7 @@ func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) {
func (sp *ServerPicker) startConnection(ctx context.Context, resultChan chan connResult, url string) {
log.Infof("try to connecting to relay server: %s", url)
relayClient := NewClient(url, sp.TokenStore, sp.PeerID, sp.MTU)
+ relayClient.SetTransportFallback(sp.TransportFallback)
err := relayClient.Connect(ctx)
resultChan <- connResult{
RelayClient: relayClient,
diff --git a/shared/relay/client/transport.go b/shared/relay/client/transport.go
new file mode 100644
index 000000000..002707401
--- /dev/null
+++ b/shared/relay/client/transport.go
@@ -0,0 +1,129 @@
+package client
+
+import (
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/netbirdio/netbird/shared/relay/client/dialer"
+)
+
+// EnvRelayTransport pins the relay transport. Valid values: "auto" (default,
+// race QUIC and WebSocket), "quic" (QUIC only), "ws" (WebSocket only),
+// "prefer-quic" / "prefer-ws" (try the preferred transport first, fall back to
+// the other only if it fails to connect; no race). The prefer modes trade a
+// slower connect when the preferred transport is blackholed for deterministic
+// transport selection.
+const EnvRelayTransport = "NB_RELAY_TRANSPORT"
+
+const (
+ // transportFallbackBase is the initial window a relay server avoids
+ // datagram-sized transports after a datagram is rejected as too large.
+ transportFallbackBase = 10 * time.Minute
+ // transportFallbackMax caps the pinned window when failures repeat.
+ transportFallbackMax = 60 * time.Minute
+)
+
+// TransportMode selects which relay dialers are used.
+type TransportMode string
+
+const (
+ TransportModeAuto TransportMode = "auto"
+ TransportModeQUIC TransportMode = "quic"
+ TransportModeWS TransportMode = "ws"
+ TransportModePreferQUIC TransportMode = "prefer-quic"
+ TransportModePreferWS TransportMode = "prefer-ws"
+)
+
+// transportModeFromEnv reads EnvRelayTransport, defaulting to auto for an empty
+// or unrecognized value.
+func transportModeFromEnv() TransportMode {
+ switch TransportMode(strings.ToLower(strings.TrimSpace(os.Getenv(EnvRelayTransport)))) {
+ case "", TransportModeAuto:
+ return TransportModeAuto
+ case TransportModeQUIC:
+ return TransportModeQUIC
+ case TransportModeWS:
+ return TransportModeWS
+ case TransportModePreferQUIC:
+ return TransportModePreferQUIC
+ case TransportModePreferWS:
+ return TransportModePreferWS
+ default:
+ log.Warnf("invalid %s value %q, using %q", EnvRelayTransport, os.Getenv(EnvRelayTransport), TransportModeAuto)
+ return TransportModeAuto
+ }
+}
+
+// sequential reports whether the mode tries dialers in order with fallback
+// instead of racing them concurrently.
+func (m TransportMode) sequential() bool {
+ return m == TransportModePreferQUIC || m == TransportModePreferWS
+}
+
+// transportFallback tracks relay servers that have rejected a datagram-sized
+// transport (a write too large for the path) and should temporarily avoid such
+// transports. It is shared across the relay manager so the preference survives
+// client recreation (foreign relay clients are evicted and rebuilt on
+// disconnect). Entries are keyed by server URL and expire after a window that
+// grows on repeated failures.
+type transportFallback struct {
+ mu sync.Mutex
+ entries map[string]*fallbackEntry
+}
+
+type fallbackEntry struct {
+ until time.Time
+ duration time.Duration
+}
+
+func newTransportFallback() *transportFallback {
+ return &transportFallback{entries: make(map[string]*fallbackEntry)}
+}
+
+// avoidDatagramSized reports whether serverURL is currently within a window
+// where datagram-sized transports should be avoided.
+func (f *transportFallback) avoidDatagramSized(serverURL string) bool {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ e := f.entries[serverURL]
+ return e != nil && time.Now().Before(e.until)
+}
+
+// recordFailure makes serverURL avoid datagram-sized transports for a window:
+// transportFallbackBase on the first failure, doubling up to transportFallbackMax
+// when a datagram transport fails again after a previous window expired. It
+// returns the active window duration.
+func (f *transportFallback) recordFailure(serverURL string) time.Duration {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ now := time.Now()
+ e := f.entries[serverURL]
+ switch {
+ case e == nil:
+ e = &fallbackEntry{duration: transportFallbackBase}
+ f.entries[serverURL] = e
+ case now.Before(e.until):
+ return time.Until(e.until)
+ default:
+ e.duration = min(e.duration*2, transportFallbackMax)
+ }
+ e.until = now.Add(e.duration)
+ return e.duration
+}
+
+// nonDatagramSized returns the dialers from in that are not datagram-sized,
+// preserving order.
+func nonDatagramSized(in []dialer.DialeFn) []dialer.DialeFn {
+ out := make([]dialer.DialeFn, 0, len(in))
+ for _, d := range in {
+ if !dialer.IsDatagramSized(d) {
+ out = append(out, d)
+ }
+ }
+ return out
+}
diff --git a/shared/relay/client/transport_test.go b/shared/relay/client/transport_test.go
new file mode 100644
index 000000000..8e10c8d42
--- /dev/null
+++ b/shared/relay/client/transport_test.go
@@ -0,0 +1,140 @@
+package client
+
+import (
+ "net"
+ "os"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net"
+)
+
+// closeTrackingConn records whether Close was called; only Close is exercised.
+type closeTrackingConn struct {
+ net.Conn
+ closed bool
+}
+
+func (c *closeTrackingConn) Close() error {
+ c.closed = true
+ return nil
+}
+
+func TestTransportModeFromEnv(t *testing.T) {
+ tests := []struct {
+ value string
+ want TransportMode
+ }{
+ {"", TransportModeAuto},
+ {"auto", TransportModeAuto},
+ {"quic", TransportModeQUIC},
+ {"QUIC", TransportModeQUIC},
+ {"ws", TransportModeWS},
+ {" Ws ", TransportModeWS},
+ {"prefer-quic", TransportModePreferQUIC},
+ {"prefer-ws", TransportModePreferWS},
+ {"garbage", TransportModeAuto},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.value, func(t *testing.T) {
+ t.Setenv(EnvRelayTransport, tc.value)
+ if tc.value == "" {
+ os.Unsetenv(EnvRelayTransport)
+ }
+ assert.Equal(t, tc.want, transportModeFromEnv())
+ })
+ }
+}
+
+func TestTransportFallbackRecordAndExpiry(t *testing.T) {
+ const url = "rels://relay.example:443"
+ f := newTransportFallback()
+
+ assert.False(t, f.avoidDatagramSized(url), "no fallback recorded yet")
+
+ d := f.recordFailure(url)
+ assert.Equal(t, transportFallbackBase, d, "first failure pins for the base window")
+ assert.True(t, f.avoidDatagramSized(url), "datagram-sized transport avoided within the window")
+
+ // A second failure while still inside the window must not grow the window.
+ d = f.recordFailure(url)
+ assert.LessOrEqual(t, d, transportFallbackBase, "still within the active window")
+ require.NotNil(t, f.entries[url])
+ assert.Equal(t, transportFallbackBase, f.entries[url].duration, "duration unchanged inside window")
+
+ // Expire the window: datagram-sized transport allowed again.
+ f.entries[url].until = time.Now().Add(-time.Second)
+ assert.False(t, f.avoidDatagramSized(url), "window expired, datagram-sized transport allowed")
+}
+
+func TestTransportFallbackGrowsOnRepeat(t *testing.T) {
+ const url = "rels://relay.example:443"
+ f := newTransportFallback()
+
+ want := transportFallbackBase
+ for i := range 6 {
+ d := f.recordFailure(url)
+ assert.Equal(t, want, d, "window after %d expiries", i)
+
+ // expire the window so the next failure is treated as a repeat
+ f.entries[url].until = time.Now().Add(-time.Second)
+
+ want = min(want*2, transportFallbackMax)
+ }
+
+ assert.Equal(t, transportFallbackMax, f.entries[url].duration, "window caps at the max")
+}
+
+func TestOnDatagramTooLargeAuto(t *testing.T) {
+ const url = "rels://relay.example:443"
+ t.Setenv(EnvRelayTransport, string(TransportModeAuto))
+
+ tf := newTransportFallback()
+ c := &Client{
+ log: log.WithField("test", t.Name()),
+ connectionURL: url,
+ transportFallback: tf,
+ }
+ conn := &closeTrackingConn{}
+
+ c.onDatagramTooLarge(conn, netErr.ErrDatagramTooLarge)
+
+ assert.True(t, conn.closed, "connection closed to force reconnect")
+ assert.True(t, tf.avoidDatagramSized(url), "fallback recorded for the server")
+
+ // A second oversized datagram on the same connection must not re-close.
+ conn.closed = false
+ c.onDatagramTooLarge(conn, netErr.ErrDatagramTooLarge)
+ assert.False(t, conn.closed, "single fallback per connection")
+}
+
+func TestOnDatagramTooLargeQUICPinned(t *testing.T) {
+ const url = "rels://relay.example:443"
+ t.Setenv(EnvRelayTransport, string(TransportModeQUIC))
+
+ tf := newTransportFallback()
+ c := &Client{
+ log: log.WithField("test", t.Name()),
+ connectionURL: url,
+ transportFallback: tf,
+ }
+ conn := &closeTrackingConn{}
+
+ c.onDatagramTooLarge(conn, netErr.ErrDatagramTooLarge)
+
+ assert.False(t, conn.closed, "QUIC pin keeps the connection, no fallback redial")
+ assert.False(t, tf.avoidDatagramSized(url), "QUIC pin records no fallback")
+}
+
+func TestTransportFallbackPerServer(t *testing.T) {
+ f := newTransportFallback()
+ f.recordFailure("rels://a.example:443")
+
+ assert.True(t, f.avoidDatagramSized("rels://a.example:443"))
+ assert.False(t, f.avoidDatagramSized("rels://b.example:443"), "fallback is scoped to one server")
+}
diff --git a/version/version.go b/version/version.go
index f33ff133c..074305bd6 100644
--- a/version/version.go
+++ b/version/version.go
@@ -13,6 +13,14 @@ import (
// string, so it must not change without coordinating those consumers.
const DevelopmentVersion = "development"
+// CIVersionPrefix marks CI snapshot builds (e.g. "ci-7470fbdd"). Such builds
+// are treated as development versions by IsDevelopmentVersion.
+const CIVersionPrefix = "ci-"
+
+// DevVersionPrefix marks dev snapshot builds (e.g. "dev-7470fbdd"). Such builds
+// are treated as development versions by IsDevelopmentVersion.
+const DevVersionPrefix = "dev-"
+
// will be replaced with the release version when using goreleaser
var version = DevelopmentVersion
@@ -69,8 +77,11 @@ func NetbirdCommit() string {
// comparing against the "development" literal or ad-hoc substring checks.
//
// Matches the bare DevelopmentVersion constant as well as any future
-// extension such as "development-" or "development--dirty",
-// while excluding tagged prereleases like "v0.31.1-dev".
+// extension such as "development-" or "development--dirty", and
+// CI/dev snapshot builds prefixed with "ci-" or "dev-", while excluding
+// tagged prereleases like "v0.31.1-dev".
func IsDevelopmentVersion(v string) bool {
- return strings.HasPrefix(v, DevelopmentVersion)
+ return strings.HasPrefix(v, DevelopmentVersion) ||
+ strings.HasPrefix(v, CIVersionPrefix) ||
+ strings.HasPrefix(v, DevVersionPrefix)
}
diff --git a/version/version_test.go b/version/version_test.go
index 47b77b50d..cdba6b804 100644
--- a/version/version_test.go
+++ b/version/version_test.go
@@ -10,6 +10,8 @@ func TestIsDevelopmentVersion(t *testing.T) {
{"development", true},
{"development-0823f3ff9ab1", true},
{"development-0823f3ff9ab1-dirty", true},
+ {"ci-7470fbdd", true},
+ {"dev-7470fbdd", true},
{"0.50.0", false},
{"v0.31.1-dev", false},
{"1.0.0-dev", false},