[management] Fix agent-network proxy-peer fan-out on affected-peer recompute

The affected-peers resolver loaded only persisted reverse-proxy services, but
agent-network services are synthesized on demand and never persisted. As a
result the embedded proxy peer was never folded into the affected set when a
client's group changed, so the proxy received no network-map update for a newly
authorised client and rejected its handshake until a full resync (restart).

loadProxyServices now merges the synthesized agent-network services (injected
via a registration hook to avoid an import cycle), so proxy peers learn newly
authorised clients immediately.
This commit is contained in:
mlsmaycon
2026-06-27 00:43:07 +02:00
parent 769e12840d
commit 6613d194ef
4 changed files with 175 additions and 8 deletions
@@ -0,0 +1,95 @@
package affectedpeers
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/server/store"
)
// fakeProxyStore implements only the two store methods loadProxyServices calls;
// the embedded nil store.Store panics if anything else is invoked, which keeps
// the test honest about the surface under test.
type fakeProxyStore struct {
store.Store
proxyByCluster map[string][]string
persisted []*rpservice.Service
}
func (f *fakeProxyStore) GetEmbeddedProxyPeerIDsByCluster(_ context.Context, _ string) (map[string][]string, error) {
return f.proxyByCluster, nil
}
func (f *fakeProxyStore) GetAccountServices(_ context.Context, _ store.LockingStrength, _ string) ([]*rpservice.Service, error) {
return f.persisted, nil
}
func serviceIDs(svcs []*rpservice.Service) []string {
ids := make([]string, 0, len(svcs))
for _, s := range svcs {
ids = append(ids, s.ID)
}
return ids
}
// loadProxyServices must merge the synthesised agent-network services (which are
// never persisted) with the persisted ones, so the proxy-affected expansion can
// see agent-network AccessGroups. Without this the embedded proxy peer is never
// flagged on a client group change and only a full resync (restart) recovers.
func TestLoadProxyServices_MergesSynthesizedAgentNetworkServices(t *testing.T) {
prev := agentNetworkSynthesizer
t.Cleanup(func() { agentNetworkSynthesizer = prev })
SetAgentNetworkSynthesizer(func(_ context.Context, _ store.Store, _ string) ([]*rpservice.Service, error) {
return []*rpservice.Service{
{ID: "agent-net-svc-acc", ProxyCluster: "proxy.netbird.local", Private: true, AccessGroups: []string{"gB"}},
}, nil
})
s := &fakeProxyStore{
proxyByCluster: map[string][]string{"proxy.netbird.local": {"proxy-peer-1"}},
persisted: []*rpservice.Service{{ID: "persisted-rp-svc", ProxyCluster: "proxy.netbird.local"}},
}
snap := &Snapshot{}
require.NoError(t, snap.loadProxyServices(context.Background(), s, "acc"))
ids := serviceIDs(snap.services)
assert.Contains(t, ids, "persisted-rp-svc", "persisted services must be kept")
assert.Contains(t, ids, "agent-net-svc-acc", "synthesised agent-network service must be merged in")
}
// With no synthesiser registered, loadProxyServices falls back to persisted
// services only (no panic, no behaviour change for non-agent-network builds).
func TestLoadProxyServices_NoSynthesizerRegistered(t *testing.T) {
prev := agentNetworkSynthesizer
t.Cleanup(func() { agentNetworkSynthesizer = prev })
agentNetworkSynthesizer = nil
s := &fakeProxyStore{
proxyByCluster: map[string][]string{"c": {"proxy-1"}},
persisted: []*rpservice.Service{{ID: "persisted"}},
}
snap := &Snapshot{}
require.NoError(t, snap.loadProxyServices(context.Background(), s, "acc"))
assert.Equal(t, []string{"persisted"}, serviceIDs(snap.services))
}
// No embedded proxy peers → skip entirely (don't even call the synthesiser).
func TestLoadProxyServices_NoEmbeddedProxyPeersSkips(t *testing.T) {
prev := agentNetworkSynthesizer
t.Cleanup(func() { agentNetworkSynthesizer = prev })
called := false
SetAgentNetworkSynthesizer(func(_ context.Context, _ store.Store, _ string) ([]*rpservice.Service, error) {
called = true
return nil, nil
})
s := &fakeProxyStore{proxyByCluster: map[string][]string{}}
snap := &Snapshot{}
require.NoError(t, snap.loadProxyServices(context.Background(), s, "acc"))
assert.False(t, called, "synthesiser must not run for accounts without embedded proxy peers")
assert.Empty(t, snap.services)
}
+34 -3
View File
@@ -29,6 +29,19 @@ import (
"github.com/netbirdio/netbird/route"
)
// agentNetworkSynthesizer returns the account's synthesised (never-persisted)
// agent-network reverse-proxy services. It is registered at boot via
// SetAgentNetworkSynthesizer to avoid an import cycle (agentnetwork → account →
// affectedpeers). nil when agent-network is not wired, in which case only
// persisted services are considered.
var agentNetworkSynthesizer func(ctx context.Context, s store.Store, accountID string) ([]*rpservice.Service, error)
// SetAgentNetworkSynthesizer registers the agent-network service synthesiser.
// Called once during boot, before any request is served.
func SetAgentNetworkSynthesizer(fn func(ctx context.Context, s store.Store, accountID string) ([]*rpservice.Service, error)) {
agentNetworkSynthesizer = fn
}
// 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).
@@ -124,7 +137,12 @@ func (snap *Snapshot) loadDNS(ctx context.Context, s store.Store, accountID stri
}
// loadProxyServices loads the embedded-proxy cluster index, and the services only
// when the account actually has embedded proxy peers.
// when the account actually has embedded proxy peers. Both the persisted
// reverse-proxy services and the synthesised agent-network services are loaded:
// agent-network services are never persisted, so without synthesising them here
// collectFromProxyServices can't fold the embedded proxy peer into the affected
// set when a client's group changes, and the proxy never learns a newly
// authorised client until it reconnects (full network-map resync).
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 {
@@ -133,8 +151,21 @@ func (snap *Snapshot) loadProxyServices(ctx context.Context, s store.Store, acco
if len(snap.proxyByCluster) == 0 {
return nil
}
snap.services, err = s.GetAccountServices(ctx, store.LockingStrengthNone, accountID)
return err
if snap.services, err = s.GetAccountServices(ctx, store.LockingStrengthNone, accountID); err != nil {
return err
}
if agentNetworkSynthesizer == nil {
return nil
}
synth, serr := agentNetworkSynthesizer(ctx, s, accountID)
if serr != nil {
// Non-fatal: fall back to persisted services. The next full
// network-map resync still converges the proxy.
log.WithContext(ctx).Warnf("affectedpeers: synthesise agent-network services for account %s: %v", accountID, serr)
return nil
}
snap.services = append(snap.services, synth...)
return nil
}
// loadGroupIndex loads all groups (for group.Resources) and builds the
@@ -0,0 +1,15 @@
package agentnetwork
import "github.com/netbirdio/netbird/management/server/affectedpeers"
// init registers the agent-network service synthesiser with the affectedpeers
// resolver. Agent-network reverse-proxy services are synthesised on demand and
// never persisted, so the resolver can't load them from the store; without them
// it can't fold the embedded proxy peer into the affected set on a client
// group/peer change, and the proxy never learns a newly authorised client until
// it reconnects. Registered here (rather than via a direct
// affectedpeers→agentnetwork import) to avoid an import cycle
// (agentnetwork → account → affectedpeers).
func init() {
affectedpeers.SetAgentNetworkSynthesizer(SynthesizeServices)
}