diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index e16796471..0d8fb3c47 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -497,7 +497,7 @@ func (c *Controller) BufferUpdateAffectedPeers(ctx context.Context, accountID st 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()) + log.WithContext(ctx).Tracef("buffer updating %d affected peers for account %s from %s with reason %s/%s", len(peerIDs), accountID, util.GetCallerName(), reason.Operation, reason.Resource) bufUpd, _ := c.affectedPeerUpdateLocks.LoadOrStore(accountID, &bufferAffectedUpdate{ peerIDs: make(map[string]struct{}), diff --git a/management/server/peer.go b/management/server/peer.go index 5596e45c2..440e90044 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -209,14 +209,14 @@ func (am *DefaultAccountManager) resolvePeerLocation(ctx context.Context, peer * if am.geo == nil || realIP == nil { return nil } - if peer.Location.ConnectionIP != nil && peer.Location.ConnectionIP.Equal(realIP) { - return nil - } location, err := am.geo.Lookup(realIP) if err != nil { log.WithContext(ctx).Warnf("failed to get location for peer %s realip: [%s]: %v", peer.ID, realIP.String(), err) return nil } + if peer.Location.ConnectionIP != nil && peer.Location.ConnectionIP.Equal(realIP) && peer.Location.GeoNameID == location.City.GeonameID { + return nil + } return &nbpeer.Location{ ConnectionIP: realIP, CountryCode: location.Country.ISOCode, @@ -1052,7 +1052,7 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy } metaDiffAffectsPosture := posture.AffectsPosture(ctx, &metaDiff, resPostureChecks) - if isStatusChanged || sync.UpdateAccountPeers || ipv6CapabilityChanged || metaDiffAffectsPosture || metaDiff.VersionChanged() || metaDiff.HostnameChanged() { + if requiresPeerUpdate(ctx, isStatusChanged, sync.UpdateAccountPeers, ipv6CapabilityChanged, metaDiffAffectsPosture, metaDiff.VersionChanged(), metaDiff.HostnameChanged()) { changedPeerIDs := []string{peer.ID} affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, metaDiffAffectsPosture) if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { @@ -1063,6 +1063,29 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return peer, nmap, resPostureChecks, dnsFwdPort, nil } +func requiresPeerUpdate(ctx context.Context, isStatusChanged, updateAccountPeers, ipv6CapabilityChanged, metaDiffAffectsPosture, versionChanged, hostname bool) bool { + var reason string + switch { + case isStatusChanged: + reason = "status changed" + case updateAccountPeers: + reason = "update account peers" + case ipv6CapabilityChanged: + reason = "ipv6 capability changed" + case metaDiffAffectsPosture: + reason = "meta diff affects posture" + case versionChanged: + reason = "version changed" + case hostname: + reason = "hostname changed" + default: + return false + } + + log.WithContext(ctx).Tracef("peer update required: %s", reason) + return true +} + // 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 diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 98cf10acf..6f139e43f 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -49,6 +49,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/geolocation" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/store" @@ -2893,3 +2894,141 @@ func TestUpdatePeer_DnsLabelUniqueName(t *testing.T) { require.NoError(t, err, "renaming to unique FQDN should succeed") assert.Equal(t, "api-server", updated.DNSLabel, "DNS label should be first label of FQDN") } + +// fakeGeo is a configurable geolocation.Geolocation implementation for tests. It +// returns a record built from the configured city geoname id, or an error when set. +type fakeGeo struct { + geoNameID uint + isoCode string + cityName string + err error +} + +func (g *fakeGeo) Lookup(net.IP) (*geolocation.Record, error) { + if g.err != nil { + return nil, g.err + } + record := &geolocation.Record{} + record.City.GeonameID = g.geoNameID + record.City.Names.En = g.cityName + record.Country.ISOCode = g.isoCode + return record, nil +} + +func (g *fakeGeo) GetAllCountries() ([]geolocation.Country, error) { return nil, nil } + +func (g *fakeGeo) GetCitiesByCountry(string) ([]geolocation.City, error) { return nil, nil } + +func (g *fakeGeo) Stop() error { return nil } + +func TestResolvePeerLocation(t *testing.T) { + realIP := net.ParseIP("203.0.113.10") + + tests := []struct { + name string + geo geolocation.Geolocation + peer *nbpeer.Peer + realIP net.IP + want *nbpeer.Location + wantNil bool + }{ + { + name: "no geo configured returns nil", + geo: nil, + peer: &nbpeer.Peer{ID: "p1"}, + realIP: realIP, + wantNil: true, + }, + { + name: "nil real IP returns nil", + geo: &fakeGeo{geoNameID: 100}, + peer: &nbpeer.Peer{ID: "p1"}, + realIP: nil, + wantNil: true, + }, + { + name: "lookup error returns nil", + geo: &fakeGeo{err: fmt.Errorf("lookup boom")}, + peer: &nbpeer.Peer{ID: "p1"}, + realIP: realIP, + wantNil: true, + }, + { + name: "same IP and same geoname returns nil", + geo: &fakeGeo{geoNameID: 100, isoCode: "US", cityName: "City A"}, + peer: &nbpeer.Peer{ + ID: "p1", + Location: nbpeer.Location{ + ConnectionIP: realIP, + GeoNameID: 100, + }, + }, + realIP: realIP, + wantNil: true, + }, + { + name: "same IP but changed geoname returns location", + geo: &fakeGeo{geoNameID: 200, isoCode: "US", cityName: "City B"}, + peer: &nbpeer.Peer{ + ID: "p1", + Location: nbpeer.Location{ + ConnectionIP: realIP, + GeoNameID: 100, + }, + }, + realIP: realIP, + want: &nbpeer.Location{ + ConnectionIP: realIP, + CountryCode: "US", + CityName: "City B", + GeoNameID: 200, + }, + }, + { + name: "different IP returns location", + geo: &fakeGeo{geoNameID: 100, isoCode: "US", cityName: "City A"}, + peer: &nbpeer.Peer{ + ID: "p1", + Location: nbpeer.Location{ + ConnectionIP: net.ParseIP("198.51.100.7"), + GeoNameID: 100, + }, + }, + realIP: realIP, + want: &nbpeer.Location{ + ConnectionIP: realIP, + CountryCode: "US", + CityName: "City A", + GeoNameID: 100, + }, + }, + { + name: "no prior location returns location", + geo: &fakeGeo{geoNameID: 100, isoCode: "US", cityName: "City A"}, + peer: &nbpeer.Peer{ID: "p1"}, + realIP: realIP, + want: &nbpeer.Location{ + ConnectionIP: realIP, + CountryCode: "US", + CityName: "City A", + GeoNameID: 100, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + am := &DefaultAccountManager{geo: tt.geo} + got := am.resolvePeerLocation(context.Background(), tt.peer, tt.realIP) + if tt.wantNil { + assert.Nil(t, got, "resolved location should be nil") + return + } + require.NotNil(t, got, "resolved location should not be nil") + assert.True(t, tt.want.ConnectionIP.Equal(got.ConnectionIP), "connection IP should match") + assert.Equal(t, tt.want.CountryCode, got.CountryCode, "country code should match") + assert.Equal(t, tt.want.CityName, got.CityName, "city name should match") + assert.Equal(t, tt.want.GeoNameID, got.GeoNameID, "geoname id should match") + }) + } +}