diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index 0554811db..55c1fb875 100644 --- a/management/internals/controllers/network_map/controller/controller.go +++ b/management/internals/controllers/network_map/controller/controller.go @@ -489,9 +489,8 @@ func (c *Controller) getNetworkMapData(ctx context.Context, accountID string) *n // fetchNetworkMapData reads the twin once per buffer window. Its result is // shared by every waiter of that window, so the mutating steps run here, before -// it is handed out: the twin the callers see is read-only. Injected proxy -// policies carry no posture checks, so precomputing after the injection yields -// the same validation as precomputing before it. +// it is handed out: the twin the callers see is read-only. Posture checks are +// evaluated lazily by the components calculation. func (c *Controller) fetchNetworkMapData(ctx context.Context, accountID string) (*networkmap.NetworkMapData, error) { nmData, err := c.nmdataStore.GetNetworkMapData(ctx, accountID) if err != nil { @@ -501,7 +500,6 @@ func (c *Controller) fetchNetworkMapData(ctx context.Context, accountID string) nmData.Services = c.proxyServicesFromRepo(ctx, accountID) nmData.BuildPrivateServiceCandidates() nmData.InjectProxyPolicies() - nmData.PrecomputePostureValidation() return nmData, nil } diff --git a/management/internals/controllers/network_map/nmaptest/runner.go b/management/internals/controllers/network_map/nmaptest/runner.go index b6fc81df6..8c0f87ece 100644 --- a/management/internals/controllers/network_map/nmaptest/runner.go +++ b/management/internals/controllers/network_map/nmaptest/runner.go @@ -184,7 +184,6 @@ func RunCase(t *testing.T, c Case) { nmData := c.Data applyFixtureDefaults(nmData) nmData.BuildPrivateServiceCandidates() - nmData.PrecomputePostureValidation() dnsDomain := c.DNSDomain if nmData.AccountSettings.DNSDomain != "" { diff --git a/shared/management/certposture/certposture_test.go b/shared/management/certposture/certposture_test.go index e8f3d20c1..7f12409f7 100644 --- a/shared/management/certposture/certposture_test.go +++ b/shared/management/certposture/certposture_test.go @@ -175,6 +175,24 @@ func TestChainMatchesCAs(t *testing.T) { assert.False(t, ChainMatchesCAs("not a pem", []string{ca.PEM}, now)) } +func TestAnyChainMatchesCAs(t *testing.T) { + ca := certtest.NewCA(t, "root") + otherCA := certtest.NewCA(t, "other-root") + chainPEM := EncodeChainPEM([]*x509.Certificate{ca.Issue(t, certtest.ECDSAKey(t), "device")}) + otherPEM := EncodeChainPEM([]*x509.Certificate{otherCA.Issue(t, certtest.ECDSAKey(t), "device")}) + + assert.True(t, AnyChainMatchesCAs([]string{otherPEM, chainPEM}, []string{ca.PEM}, now)) + assert.False(t, AnyChainMatchesCAs([]string{otherPEM}, []string{ca.PEM}, now)) + assert.False(t, AnyChainMatchesCAs(nil, []string{ca.PEM}, now)) + assert.False(t, AnyChainMatchesCAs([]string{chainPEM}, []string{"not a pem"}, now)) + + roots, err := ParseCAs([]string{ca.PEM}) + require.NoError(t, err) + assert.True(t, AnyChainMatchesPool([]string{otherPEM, chainPEM}, roots, now)) + assert.False(t, AnyChainMatchesPool([]string{otherPEM}, roots, now)) + assert.False(t, AnyChainMatchesPool(nil, roots, now)) +} + func TestChainPEM_RoundTrip(t *testing.T) { ca := certtest.NewCA(t, "root") leaf := ca.Issue(t, certtest.ECDSAKey(t), "device") diff --git a/shared/management/certposture/chain.go b/shared/management/certposture/chain.go index 80fb46b4b..6c550ed96 100644 --- a/shared/management/certposture/chain.go +++ b/shared/management/certposture/chain.go @@ -68,10 +68,19 @@ func ChainMatchesCAs(chainPEM string, caPEMs []string, now time.Time) bool { // AnyChainMatchesCAs reports whether at least one of the peer's verified chains // is anchored in one of the configured CAs. func AnyChainMatchesCAs(chainPEMs, caPEMs []string, now time.Time) bool { + if len(chainPEMs) == 0 { + return false + } roots, err := ParseCAs(caPEMs) if err != nil { return false } + return AnyChainMatchesPool(chainPEMs, roots, now) +} + +// AnyChainMatchesPool is AnyChainMatchesCAs over an already parsed root pool, for +// callers that verify many peers against the same CAs. +func AnyChainMatchesPool(chainPEMs []string, roots *x509.CertPool, now time.Time) bool { for _, chainPEM := range chainPEMs { if chainMatchesPool(chainPEM, roots, now) { return true diff --git a/shared/management/networkmap/admission.go b/shared/management/networkmap/admission.go new file mode 100644 index 000000000..5409524fe --- /dev/null +++ b/shared/management/networkmap/admission.go @@ -0,0 +1,239 @@ +package networkmap + +import ( + "slices" + "sync" + + "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" + "github.com/netbirdio/netbird/shared/management/types" +) + +// admissionCache memoises the peer-independent parts of the components calculation: +// the posture-check verdicts and the admitted peers of every policy side. Its index is +// built once per NetworkMapData and every entry is filled on first use, so lookups +// take no lock and nothing is computed for checks or policies no peer needs. The +// NetworkMapData must not change once the index is built. +type admissionCache struct { + policies map[*nmdata.Policy]*policyAdmission + verdicts map[string]*postureVerdicts + + ruleFailuresOnce sync.Once + ruleFailures map[string]map[string]struct{} +} + +// policyAdmission holds the admissions of one policy: the union of its source sides, +// used by the network resource loop, and the sides of each rule, in rule order. +type policyAdmission struct { + sources admittedPeers + rules []ruleAdmission +} + +type ruleAdmission struct { + sources admittedPeers + destinations admittedPeers +} + +// admittedPeers is the admission of one policy side: the peers that exist, are +// validated and pass the side's posture checks and, keyed by posture check ID, the +// peers a check rejected. Both are shared and must not be mutated. +type admittedPeers struct { + once sync.Once + peers []string + failed map[string]map[string]struct{} +} + +// postureVerdicts holds the results of one posture check keyed by peer ID. A nil +// map marks a check ID that resolves to no posture check, which passes. +type postureVerdicts struct { + once sync.Once + results map[string]bool +} + +func (nmd *NetworkMapData) admissions() *admissionCache { + nmd.admissionsOnce.Do(func() { + nmd.InjectProxyPolicies() + c := &admissionCache{ + policies: make(map[*nmdata.Policy]*policyAdmission, len(nmd.Policies)), + verdicts: make(map[string]*postureVerdicts, len(nmd.PostureChecks)), + } + for _, policy := range nmd.Policies { + c.addPolicy(policy) + } + for _, policies := range nmd.ResourcePolicies { + for _, policy := range policies { + c.addPolicy(policy) + } + } + for id := range nmd.PostureChecks { + c.verdicts[id] = &postureVerdicts{} + } + nmd.admissionsIdx = c + }) + return nmd.admissionsIdx +} + +func (c *admissionCache) addPolicy(policy *nmdata.Policy) { + if policy == nil { + return + } + if _, ok := c.policies[policy]; ok { + return + } + c.policies[policy] = &policyAdmission{rules: make([]ruleAdmission, len(policy.Rules))} +} + +// ruleAdmissions returns the admitted source and destination peers of the policy's +// rule at index i. +func (nmd *NetworkMapData) ruleAdmissions(policy *nmdata.Policy, i int) (*admittedPeers, *admittedPeers) { + rule := policy.Rules[i] + ra := &nmd.admissions().policies[policy].rules[i] + src := ra.sources.fill(func(failed *map[string]map[string]struct{}) []string { + return nmd.admitSide(rule.Sources, rule.SourceResource, policy.SourcePostureChecks, failed) + }) + dst := ra.destinations.fill(func(failed *map[string]map[string]struct{}) []string { + return nmd.admitSide(rule.Destinations, rule.DestinationResource, nil, failed) + }) + return src, dst +} + +// resourcePolicySources returns the admitted source peers of a resource policy across +// all its rules, shared by every routing peer of the policy's resources. +func (nmd *NetworkMapData) resourcePolicySources(policy *nmdata.Policy) *admittedPeers { + pa := nmd.admissions().policies[policy] + return pa.sources.fill(func(failed *map[string]map[string]struct{}) []string { + if resource := policy.Rules[0].SourceResource; isPeerResource(resource) { + return nmd.getPeerFromResource(resource, policy.SourcePostureChecks, failed) + } + var admitted []string + for _, pid := range nmd.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) { + if nmd.admitPolicyPeer(pid, policy.SourcePostureChecks, failed) { + admitted = append(admitted, pid) + } + } + return admitted + }) +} + +func (a *admittedPeers) fill(admit func(failed *map[string]map[string]struct{}) []string) *admittedPeers { + a.once.Do(func() { + a.failed = make(map[string]map[string]struct{}) + a.peers = admit(&a.failed) + }) + return a +} + +func (nmd *NetworkMapData) admitSide(groups []string, resource nmdata.Resource, postureChecksIDs []string, failed *map[string]map[string]struct{}) []string { + if isPeerResource(resource) { + return nmd.getPeerFromResource(resource, postureChecksIDs, failed) + } + return nmd.getPeersFromGroups(groups, postureChecksIDs, failed) +} + +// ruleFailures returns the posture failures of every enabled rule side. Every peer's +// rule loop walks all of them, so they are merged once per NetworkMapData. +func (nmd *NetworkMapData) ruleFailures() map[string]map[string]struct{} { + c := nmd.admissions() + c.ruleFailuresOnce.Do(func() { + failures := make(map[string]map[string]struct{}) + for _, policy := range nmd.Policies { + if policy == nil || !policy.Enabled { + continue + } + for i, rule := range policy.Rules { + if rule == nil || !rule.Enabled { + continue + } + sources, destinations := nmd.ruleAdmissions(policy, i) + mergePostureFailures(&failures, sources.failed) + mergePostureFailures(&failures, destinations.failed) + } + } + c.ruleFailures = failures + }) + return c.ruleFailures +} + +// sideAdmitsPeer reports whether the peer is an admitted member of a rule side, +// resolving membership like getPeersFromGroups without walking the group members. +func (nmd *NetworkMapData) sideAdmitsPeer(groups []string, resource nmdata.Resource, postureChecksIDs []string, peerID string, peerGroups map[string]struct{}) bool { + member := false + if isPeerResource(resource) { + member = resource.ID == peerID + } else { + for _, gid := range groups { + group := nmd.Groups[gid] + if group == nil { + continue + } + _, inGroup := peerGroups[gid] + if group.IsGroupAll() || len(groups) == 1 { + member = inGroup + break + } + member = member || inGroup + } + } + if !member { + return false + } + admitted, _ := nmd.admitsPeer(peerID, postureChecksIDs) + return admitted +} + +// postureVerdict returns the cached result of a posture check for the peer and +// whether one is cached. The results of a check are evaluated on first use for the +// source peers of every enabled policy using it, or taken from PostureValidation +// when it was precomputed. +func (nmd *NetworkMapData) postureVerdict(postureChecksID, peerID string) (bool, bool) { + v, ok := nmd.admissions().verdicts[postureChecksID] + if !ok { + return false, false + } + v.once.Do(func() { + if results, seeded := nmd.PostureValidation[postureChecksID]; seeded { + v.results = results + return + } + v.results = nmd.evaluatePostureChecksForPeers(postureChecksID, nmd.postureCheckPeerIDs(postureChecksID)) + }) + if v.results == nil { + return true, true + } + valid, found := v.results[peerID] + return valid, found +} + +// postureCheckPeerIDs collects the source peers of every enabled policy using the +// posture check. +func (nmd *NetworkMapData) postureCheckPeerIDs(postureChecksID string) map[string]struct{} { + peerIDs := make(map[string]struct{}) + for _, policy := range nmd.Policies { + if policy == nil || !policy.Enabled || !slices.Contains(policy.SourcePostureChecks, postureChecksID) { + continue + } + for _, pid := range nmd.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) { + peerIDs[pid] = struct{}{} + } + for _, rule := range policy.Rules { + if rule != nil && isPeerResource(rule.SourceResource) { + peerIDs[rule.SourceResource.ID] = struct{}{} + } + } + } + return peerIDs +} + +func isPeerResource(resource nmdata.Resource) bool { + return resource.Type == string(types.ResourceTypePeer) && resource.ID != "" +} + +func mergePostureFailures(dst *map[string]map[string]struct{}, failed map[string]map[string]struct{}) { + for checkID, peerIDs := range failed { + if _, ok := (*dst)[checkID]; !ok { + (*dst)[checkID] = make(map[string]struct{}, len(peerIDs)) + } + for pid := range peerIDs { + (*dst)[checkID][pid] = struct{}{} + } + } +} diff --git a/shared/management/networkmap/networkmapcompute.go b/shared/management/networkmap/networkmapcompute.go index 65e76d097..dfab621b2 100644 --- a/shared/management/networkmap/networkmapcompute.go +++ b/shared/management/networkmap/networkmapcompute.go @@ -1,8 +1,6 @@ package networkmap import ( - "slices" - "github.com/netbirdio/netbird/shared/management/networkmap/nmdata" "github.com/netbirdio/netbird/shared/management/types" ) @@ -57,7 +55,7 @@ func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCusto ForceRoutingPeerDNSResolution: forceRoutingPeerDNS, } - relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := nmd.getPeersGroupsPoliciesRoutes(peerID, peer.SSHEnabled, &components.PostureFailedPeers) + relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := nmd.getPeersGroupsPoliciesRoutes(peerID, peer.SSHEnabled) if len(sshReqs.neededGroupIDs) > 0 { components.GroupIDToUserIDs = filterGroupIDToUserIDs(nmd.GroupIDToUserIDs, sshReqs.neededGroupIDs) @@ -87,6 +85,8 @@ func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCusto } } + seenSourcePolicies := make(map[*nmdata.Policy]struct{}) + var sourceFailures []map[string]map[string]struct{} for _, resource := range nmd.NetworkResources { if resource == nil || !resource.Enabled { continue @@ -111,16 +111,11 @@ func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCusto continue } if addSourcePeers { - var peers []string - if policy.Rules[0].SourceResource.Type == string(types.ResourceTypePeer) && policy.Rules[0].SourceResource.ID != "" { - peers = []string{policy.Rules[0].SourceResource.ID} - } else { - peers = nmd.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) - } - for _, pID := range nmd.getPostureValidPeersSaveFailed(peers, policy.SourcePostureChecks, &components.PostureFailedPeers) { - if _, exists := components.Peers[pID]; !exists { - components.Peers[pID] = nmd.Peers[pID] - } + if _, seen := seenSourcePolicies[policy]; !seen { + seenSourcePolicies[policy] = struct{}{} + sources := nmd.resourcePolicySources(policy) + nmd.mergeSourcePeers(components, sources) + sourceFailures = append(sourceFailures, sources.failed) } } else { peerInSources := false @@ -128,7 +123,7 @@ func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCusto peerInSources = policy.Rules[0].SourceResource.ID == peerID } else { for _, groupID := range policy.SourceGroups() { - if group := nmd.Groups[groupID]; group != nil && slices.Contains(group.Peers, peerID) { + if _, ok := peerGroups[groupID]; ok { peerInSources = true break } @@ -195,7 +190,7 @@ func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCusto } filterGroupPeers(&components.Groups, components.Peers) - filterPostureFailedPeers(&components.PostureFailedPeers, components.Policies, components.ResourcePoliciesMap, components.Peers) + nmd.collectPostureFailedPeers(components, sourceFailures) return components } @@ -203,7 +198,6 @@ func (nmd *NetworkMapData) GetPeerNetworkMapComponents(peerID string, peersCusto func (nmd *NetworkMapData) getPeersGroupsPoliciesRoutes( peerID string, peerSSHEnabled bool, - postureFailedPeers *map[string]map[string]struct{}, ) (map[string]*nmdata.Peer, map[string]*nmdata.Group, []*nmdata.Policy, []*nmdata.Route, sshRequirements) { relevantPeerIDs := make(map[string]*nmdata.Peer, len(nmd.Peers)/4) relevantGroupIDs := make(map[string]*nmdata.Group, len(nmd.Groups)/4) @@ -296,7 +290,7 @@ func (nmd *NetworkMapData) getPeersGroupsPoliciesRoutes( } policyRelevant := false - for _, rule := range policy.Rules { + for i, rule := range policy.Rules { if rule == nil || !rule.Enabled { continue } @@ -320,24 +314,13 @@ func (nmd *NetworkMapData) getPeersGroupsPoliciesRoutes( } } - var sourcePeers, destinationPeers []string - var peerInSources, peerInDestinations bool - - if rule.SourceResource.Type == string(types.ResourceTypePeer) && rule.SourceResource.ID != "" { - sourcePeers, peerInSources = nmd.getPeerFromResource(rule.SourceResource, peerID, policy.SourcePostureChecks, postureFailedPeers) - } else { - sourcePeers, peerInSources = nmd.getPeersFromGroups(rule.Sources, peerID, policy.SourcePostureChecks, postureFailedPeers) - } - - if rule.DestinationResource.Type == string(types.ResourceTypePeer) && rule.DestinationResource.ID != "" { - destinationPeers, peerInDestinations = nmd.getPeerFromResource(rule.DestinationResource, peerID, nil, postureFailedPeers) - } else { - destinationPeers, peerInDestinations = nmd.getPeersFromGroups(rule.Destinations, peerID, nil, postureFailedPeers) - } + sources, destinations := nmd.ruleAdmissions(policy, i) + peerInSources := nmd.sideAdmitsPeer(rule.Sources, rule.SourceResource, policy.SourcePostureChecks, peerID, peerGroupSet) + peerInDestinations := nmd.sideAdmitsPeer(rule.Destinations, rule.DestinationResource, nil, peerID, peerGroupSet) if peerInSources { policyRelevant = true - for _, pid := range destinationPeers { + for _, pid := range destinations.peers { relevantPeerIDs[pid] = nmd.Peers[pid] } for _, dstGroupID := range rule.Destinations { @@ -349,7 +332,7 @@ func (nmd *NetworkMapData) getPeersGroupsPoliciesRoutes( if peerInDestinations { policyRelevant = true - for _, pid := range sourcePeers { + for _, pid := range sources.peers { relevantPeerIDs[pid] = nmd.Peers[pid] } for _, srcGroupID := range rule.Sources { @@ -381,9 +364,11 @@ func (nmd *NetworkMapData) getPeersGroupsPoliciesRoutes( return relevantPeerIDs, relevantGroupIDs, relevantPolicies, relevantRoutes, sshReqs } -func (nmd *NetworkMapData) getPeersFromGroups(groups []string, peerID string, sourcePostureChecksIDs []string, - postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) { - peerInGroups := false +// getPeersFromGroups returns the admitted members of a rule side given by groups. +// The All group, or a single group, alone decides the side. Posture failures are +// recorded in postureFailedPeers. +func (nmd *NetworkMapData) getPeersFromGroups(groups []string, sourcePostureChecksIDs []string, + postureFailedPeers *map[string]map[string]struct{}) []string { filteredPeerIDs := make([]string, 0, len(groups)) seenPeerIds := make(map[string]struct{}, len(groups)) @@ -395,20 +380,12 @@ func (nmd *NetworkMapData) getPeersFromGroups(groups []string, peerID string, so if group.IsGroupAll() || len(groups) == 1 { filteredPeerIDs = make([]string, 0, len(group.Peers)) - peerInGroups = false for _, pid := range group.Peers { - if !nmd.admitPolicyPeer(pid, sourcePostureChecksIDs, postureFailedPeers) { - continue + if nmd.admitPolicyPeer(pid, sourcePostureChecksIDs, postureFailedPeers) { + filteredPeerIDs = append(filteredPeerIDs, pid) } - - if pid == peerID { - peerInGroups = true - continue - } - - filteredPeerIDs = append(filteredPeerIDs, pid) } - return filteredPeerIDs, peerInGroups + return filteredPeerIDs } for _, pid := range group.Peers { @@ -416,57 +393,55 @@ func (nmd *NetworkMapData) getPeersFromGroups(groups []string, peerID string, so continue } seenPeerIds[pid] = struct{}{} - if !nmd.admitPolicyPeer(pid, sourcePostureChecksIDs, postureFailedPeers) { - continue + if nmd.admitPolicyPeer(pid, sourcePostureChecksIDs, postureFailedPeers) { + filteredPeerIDs = append(filteredPeerIDs, pid) } - - if pid == peerID { - peerInGroups = true - continue - } - - filteredPeerIDs = append(filteredPeerIDs, pid) } } - return filteredPeerIDs, peerInGroups + return filteredPeerIDs } // getPeerFromResource resolves a rule side that names a peer directly, admitting it // like a member of a group holding only that peer. -func (nmd *NetworkMapData) getPeerFromResource(resource nmdata.Resource, peerID string, sourcePostureChecksIDs []string, - postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) { +func (nmd *NetworkMapData) getPeerFromResource(resource nmdata.Resource, sourcePostureChecksIDs []string, + postureFailedPeers *map[string]map[string]struct{}) []string { if !nmd.admitPolicyPeer(resource.ID, sourcePostureChecksIDs, postureFailedPeers) { - return nil, false + return nil } - if resource.ID == peerID { - return nil, true - } - return []string{resource.ID}, false + return []string{resource.ID} } -// admitPolicyPeer applies the per-peer admission of a rule side: the peer must exist, -// be validated and pass the rule's posture checks. A failed check is recorded in -// postureFailedPeers. +// admitPolicyPeer is admitsPeer that records a failed posture check in postureFailedPeers. func (nmd *NetworkMapData) admitPolicyPeer(pid string, sourcePostureChecksIDs []string, postureFailedPeers *map[string]map[string]struct{}) bool { + admitted, failedCheck := nmd.admitsPeer(pid, sourcePostureChecksIDs) + if failedCheck != "" { + if _, ok := (*postureFailedPeers)[failedCheck]; !ok { + (*postureFailedPeers)[failedCheck] = make(map[string]struct{}) + } + (*postureFailedPeers)[failedCheck][pid] = struct{}{} + } + return admitted +} + +// admitsPeer applies the per-peer admission of a rule side: the peer must exist, be +// validated and pass the rule's posture checks. It returns the first failed posture +// check ID, if any. +func (nmd *NetworkMapData) admitsPeer(pid string, sourcePostureChecksIDs []string) (bool, string) { peer, ok := nmd.Peers[pid] if !ok || peer == nil { - return false + return false, "" } if _, ok := nmd.ValidatedPeers[pid]; !ok { - return false + return false, "" } isValid, pname := nmd.validatePostureChecksOnPeerGetFailed(sourcePostureChecksIDs, pid) if !isValid && len(pname) > 0 { - if _, ok := (*postureFailedPeers)[pname]; !ok { - (*postureFailedPeers)[pname] = make(map[string]struct{}) - } - (*postureFailedPeers)[pname][pid] = struct{}{} - return false + return false, pname } - return true + return true, "" } func (nmd *NetworkMapData) validatePostureChecksOnPeerGetFailed(sourcePostureChecksID []string, peerID string) (bool, string) { @@ -476,7 +451,7 @@ func (nmd *NetworkMapData) validatePostureChecksOnPeerGetFailed(sourcePostureChe } for _, postureChecksID := range sourcePostureChecksID { - if valid, cached := nmd.cachedPostureCheckResult(postureChecksID, peerID); cached { + if valid, cached := nmd.postureVerdict(postureChecksID, peerID); cached { if !valid { return false, postureChecksID } @@ -494,43 +469,25 @@ func (nmd *NetworkMapData) validatePostureChecksOnPeerGetFailed(sourcePostureChe return true, "" } +// PrecomputePostureValidation evaluates every posture check used by an enabled policy +// for the policy's source peers and stores the results in PostureValidation. func (nmd *NetworkMapData) PrecomputePostureValidation() { if len(nmd.PostureChecks) == 0 { nmd.PostureValidation = nil return } - checkPeerIDs := make(map[string]map[string]struct{}) + results := make(map[string]map[string]bool) for _, policy := range nmd.Policies { - if policy == nil || !policy.Enabled || len(policy.SourcePostureChecks) == 0 { + if policy == nil || !policy.Enabled { continue } - - groupPeerIDs := nmd.getUniquePeerIDsFromGroupsIDs(policy.SourceGroups()) for _, postureChecksID := range policy.SourcePostureChecks { - set := checkPeerIDs[postureChecksID] - if set == nil { - set = make(map[string]struct{}, len(groupPeerIDs)) - checkPeerIDs[postureChecksID] = set - } - for _, pid := range groupPeerIDs { - set[pid] = struct{}{} - } - for _, rule := range policy.Rules { - if rule == nil { - continue - } - if rule.SourceResource.Type == string(types.ResourceTypePeer) && rule.SourceResource.ID != "" { - set[rule.SourceResource.ID] = struct{}{} - } + if _, done := results[postureChecksID]; !done { + results[postureChecksID] = nmd.evaluatePostureChecksForPeers(postureChecksID, nmd.postureCheckPeerIDs(postureChecksID)) } } } - - results := make(map[string]map[string]bool, len(checkPeerIDs)) - for postureChecksID, peerIDs := range checkPeerIDs { - results[postureChecksID] = nmd.evaluatePostureChecksForPeers(postureChecksID, peerIDs) - } nmd.PostureValidation = results } @@ -552,38 +509,77 @@ func (nmd *NetworkMapData) evaluatePostureChecksForPeers(postureChecksID string, return results } -func (nmd *NetworkMapData) cachedPostureCheckResult(postureChecksID, peerID string) (bool, bool) { - results, ok := nmd.PostureValidation[postureChecksID] - if !ok { - return false, false +// mergeSourcePeers adds the admitted source peers of a resource policy to the components. +func (nmd *NetworkMapData) mergeSourcePeers(components *types.NetworkMapComponents, sources *admittedPeers) { + for _, pID := range sources.peers { + if _, exists := components.Peers[pID]; !exists { + components.Peers[pID] = nmd.Peers[pID] + } } - if results == nil { - return true, true - } - valid, found := results[peerID] - return valid, found } -func (nmd *NetworkMapData) getPostureValidPeersSaveFailed(inputPeers []string, postureChecksIDs []string, postureFailedPeers *map[string]map[string]struct{}) []string { - var dest []string - for _, peerID := range inputPeers { - if _, validated := nmd.ValidatedPeers[peerID]; !validated { - continue +// collectPostureFailedPeers sets the components' posture failures to those of every +// enabled rule side, of the given resource-policy sources and the ones already +// recorded, limited to the checks the components' policies reference and to the peers +// in the components. Each failure set is intersected with the components' peers from +// its smaller side, so the cost follows the peer's own map, not the account size. +func (nmd *NetworkMapData) collectPostureFailedPeers(components *types.NetworkMapComponents, sourceFailures []map[string]map[string]struct{}) { + failures := append([]map[string]map[string]struct{}{nmd.ruleFailures(), components.PostureFailedPeers}, sourceFailures...) + + referenced := make(map[string]struct{}) + addReferenced := func(policy *nmdata.Policy) { + if policy == nil { + return } - valid, pname := nmd.validatePostureChecksOnPeerGetFailed(postureChecksIDs, peerID) - if valid { - dest = append(dest, peerID) - continue + for _, checkID := range policy.SourcePostureChecks { + referenced[checkID] = struct{}{} } - if pname == "" { - continue - } - if _, ok := (*postureFailedPeers)[pname]; !ok { - (*postureFailedPeers)[pname] = make(map[string]struct{}) - } - (*postureFailedPeers)[pname][peerID] = struct{}{} } - return dest + for _, policy := range components.Policies { + addReferenced(policy) + } + for _, policies := range components.ResourcePoliciesMap { + for _, policy := range policies { + addReferenced(policy) + } + } + + result := make(map[string]map[string]struct{}) + for checkID := range referenced { + var kept map[string]struct{} + for _, failed := range failures { + kept = addFailedPeersInMap(kept, failed[checkID], components.Peers) + } + if len(kept) > 0 { + result[checkID] = kept + } + } + components.PostureFailedPeers = result +} + +// addFailedPeersInMap adds the failed peers that are in peers to kept, allocating kept +// on the first match, and returns it. +func addFailedPeersInMap(kept, failed map[string]struct{}, peers map[string]*nmdata.Peer) map[string]struct{} { + add := func(pid string) { + if kept == nil { + kept = make(map[string]struct{}) + } + kept[pid] = struct{}{} + } + if len(failed) <= len(peers) { + for pid := range failed { + if _, ok := peers[pid]; ok { + add(pid) + } + } + return kept + } + for pid := range peers { + if _, ok := failed[pid]; ok { + add(pid) + } + } + return kept } // forcesRoutingPeerDNSResolution reports whether the given peer must run @@ -739,41 +735,6 @@ func filterGroupPeers(groups *map[string]*nmdata.Group, peers map[string]*nmdata } } -func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*nmdata.Policy, resourcePoliciesMap map[string][]*nmdata.Policy, peers map[string]*nmdata.Peer) { - if len(*postureFailedPeers) == 0 { - return - } - - referencedPostureChecks := make(map[string]struct{}) - for _, policy := range policies { - for _, checkID := range policy.SourcePostureChecks { - referencedPostureChecks[checkID] = struct{}{} - } - } - for _, resPolicies := range resourcePoliciesMap { - for _, policy := range resPolicies { - for _, checkID := range policy.SourcePostureChecks { - referencedPostureChecks[checkID] = struct{}{} - } - } - } - - for checkID, failedPeers := range *postureFailedPeers { - if _, referenced := referencedPostureChecks[checkID]; !referenced { - delete(*postureFailedPeers, checkID) - continue - } - for peerID := range failedPeers { - if _, exists := peers[peerID]; !exists { - delete(failedPeers, peerID) - } - } - if len(failedPeers) == 0 { - delete(*postureFailedPeers, checkID) - } - } -} - func filterDNSRecordsByPeers(records []nmdata.SimpleRecord, peers map[string]*nmdata.Peer, includeIPv6 bool) []nmdata.SimpleRecord { if len(records) == 0 || len(peers) == 0 { return nil diff --git a/shared/management/networkmap/networkmapcompute_test.go b/shared/management/networkmap/networkmapcompute_test.go index ad21fd70f..97f1bd1ee 100644 --- a/shared/management/networkmap/networkmapcompute_test.go +++ b/shared/management/networkmap/networkmapcompute_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/netip" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -1662,3 +1663,243 @@ func TestNetworkMapData_GetPeerGroups(t *testing.T) { assert.Equal(t, map[string]struct{}{"g-1": {}, "g-2": {}}, nmd.GetPeerGroups(targetID)) assert.Empty(t, nmd.GetPeerGroups("missing")) } + +func TestGetPeerNetworkMapComponents_NetworkResources_SharedPolicyAcrossResourcesAndRouters(t *testing.T) { + target := newPeer(targetID, 1) + router2 := newPeer("peer-router-2", 2) + clientOK := newPeer("peer-client-ok", 3) + clientFail := newPeer("peer-client-fail", 4) + clientFail.Meta.WtVersion = failingVersion + nmd := newNMD(target, router2, clientOK, clientFail) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addGroup(nmd, "g-clients-a", clientOK.ID) + addGroup(nmd, "g-clients-b", clientFail.ID) + addGroup(nmd, "g-routers", targetID, router2.ID) + rp := newPolicy("rp-1", newRule([]string{"g-clients-a", "g-clients-b"}, []string{"g-resource"})) + rp.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = []*nmdata.Policy{newPolicy("p-acl", newRule([]string{"g-clients-b"}, []string{"g-routers"}))} + nmd.ResourcePolicies = map[string][]*nmdata.Policy{} + for i := range 3 { + res := &nmdata.NetworkResource{ID: fmt.Sprintf("res-%d", i), NetworkID: "net-1", Enabled: true} + nmd.NetworkResources = append(nmd.NetworkResources, res) + nmd.ResourcePolicies[res.ID] = []*nmdata.Policy{rp} + } + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {targetID: {}, router2.ID: {}}} + nmd.PrecomputePostureValidation() + + first := compute(nmd, targetID) + first.PostureFailedPeers["pc-1"]["mutated"] = struct{}{} + second := compute(nmd, router2.ID) + + for _, c := range []*nbtypes.NetworkMapComponents{first, second} { + assert.Contains(t, peerIDSet(c.Peers), clientOK.ID) + assert.Len(t, c.ResourcePoliciesMap, 3) + } + assert.Equal(t, map[string]map[string]struct{}{"pc-1": {clientFail.ID: {}}}, second.PostureFailedPeers, + "a component's failure set must not alias the shared per-policy result") +} + +func BenchmarkGetPeerNetworkMapComponents_RouterManyResources(b *testing.B) { + const ( + peerCount = 2000 + groupCount = 4 + resourceCount = 200 + routerCount = 10 + ) + peers := make([]*nmdata.Peer, 0, peerCount) + for i := range peerCount { + p := newPeer(fmt.Sprintf("peer-%d", i), byte(i)) + if i%3 == 0 { + p.Meta.WtVersion = failingVersion + } + peers = append(peers, p) + } + nmd := newNMD(peers...) + addVersionCheck(nmd, "pc-1", postureMinVersion) + sourceGroups := make([]string, 0, groupCount) + for g := range groupCount { + id := fmt.Sprintf("g-src-%d", g) + var members []string + for i := g; i < peerCount; i += groupCount { + members = append(members, peers[i].ID) + } + addGroup(nmd, id, members...) + sourceGroups = append(sourceGroups, id) + } + rp := newPolicy("rp-1", newRule(sourceGroups, []string{"g-resource"})) + rp.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = []*nmdata.Policy{rp} + nmd.ResourcePolicies = map[string][]*nmdata.Policy{} + for i := range resourceCount { + res := &nmdata.NetworkResource{ID: fmt.Sprintf("res-%d", i), NetworkID: "net-1", Enabled: true} + nmd.NetworkResources = append(nmd.NetworkResources, res) + nmd.ResourcePolicies[res.ID] = []*nmdata.Policy{rp} + } + routers := make(map[string]*nmdata.NetworkRouter, routerCount) + for i := range routerCount { + routers[peers[i].ID] = &nmdata.NetworkRouter{} + } + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": routers} + nmd.PrecomputePostureValidation() + + b.ReportAllocs() + for b.Loop() { + for routerID := range routers { + compute(nmd, routerID) + } + } +} + +// newPolicyFixture builds an account with ACL policies over overlapping groups, the +// All group, peer resources and posture checks, plus a routed resource policy. +func newPolicyFixture(peerCount, policyCount int) *networkmap.NetworkMapData { + peers := make([]*nmdata.Peer, 0, peerCount) + allIDs := make([]string, 0, peerCount) + for i := range peerCount { + p := newPeer(fmt.Sprintf("peer-%d", i), byte(i)) + if i%5 == 0 { + p.Meta.WtVersion = failingVersion + } + peers = append(peers, p) + allIDs = append(allIDs, p.ID) + } + nmd := newNMD(peers...) + delete(nmd.ValidatedPeers, peers[peerCount-1].ID) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addVersionCheck(nmd, "pc-2", "0.0.1") + addGroup(nmd, "g-all", allIDs...).Name = nmdata.GroupAllName + for g := range 8 { + var members []string + for i := g; i < peerCount; i += 3 + g { + members = append(members, peers[i].ID) + } + addGroup(nmd, fmt.Sprintf("g-%d", g), members...) + } + for i := range policyCount { + rule := newRule([]string{fmt.Sprintf("g-%d", i%8), fmt.Sprintf("g-%d", (i+3)%8)}, []string{fmt.Sprintf("g-%d", (i+1)%8)}) + switch i % 4 { + case 1: + rule.Sources = []string{fmt.Sprintf("g-%d", i%8), "g-all"} + case 2: + rule.SourceResource = nmdata.Resource{ID: peers[i%peerCount].ID, Type: string(nbtypes.ResourceTypePeer)} + } + policy := newPolicy(fmt.Sprintf("p-%d", i), rule) + if i%2 == 0 { + policy.SourcePostureChecks = []string{"pc-2", "pc-1"} + } + nmd.Policies = append(nmd.Policies, policy) + } + rp := newPolicy("rp-1", newRule([]string{"g-1", "g-2"}, []string{"g-resource"})) + rp.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = append(nmd.Policies, rp) + nmd.NetworkResources = []*nmdata.NetworkResource{{ID: "res-1", NetworkID: "net-1", Enabled: true}} + nmd.ResourcePolicies = map[string][]*nmdata.Policy{"res-1": {rp}} + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {peers[0].ID: {}, peers[1].ID: {}}} + return nmd +} + +func TestGetPeerNetworkMapComponents_SharedAdmissionsMatchFreshData(t *testing.T) { + const peerCount = 60 + expected := make(map[string]*nbtypes.NetworkMapComponents, peerCount) + for i := range peerCount { + peerID := fmt.Sprintf("peer-%d", i) + expected[peerID] = compute(newPolicyFixture(peerCount, 24), peerID) + } + + shared := newPolicyFixture(peerCount, 24) + got := make([]*nbtypes.NetworkMapComponents, peerCount) + var wg sync.WaitGroup + for i := peerCount - 1; i >= 0; i-- { + wg.Go(func() { + got[i] = compute(shared, fmt.Sprintf("peer-%d", i)) + }) + } + wg.Wait() + + for i, c := range got { + peerID := fmt.Sprintf("peer-%d", i) + want := expected[peerID] + assert.ElementsMatch(t, peerIDSet(want.Peers), peerIDSet(c.Peers), peerID) + assert.ElementsMatch(t, groupIDSet(want.Groups), groupIDSet(c.Groups), peerID) + assert.ElementsMatch(t, policyIDs(want.Policies), policyIDs(c.Policies), peerID) + assert.Equal(t, want.PostureFailedPeers, c.PostureFailedPeers, peerID) + } +} + +func BenchmarkGetPeerNetworkMapComponents_AllPeersPosturePolicies(b *testing.B) { + const peerCount = 2000 + b.ReportAllocs() + for b.Loop() { + b.StopTimer() + nmd := newPolicyFixture(peerCount, 50) + b.StartTimer() + for i := range peerCount { + compute(nmd, fmt.Sprintf("peer-%d", i)) + } + } +} + +func TestGetPeerNetworkMapComponents_SeededPostureValidation(t *testing.T) { + target := newPeer(targetID, 1) + src := newPeer("peer-src", 2) + src.Meta.WtVersion = failingVersion + nmd := newNMD(target, src) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addGroup(nmd, "g-src", src.ID) + addGroup(nmd, "g-dst", targetID) + policy := newPolicy("p-1", newRule([]string{"g-src"}, []string{"g-dst"})) + policy.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = []*nmdata.Policy{policy} + nmd.PostureValidation = map[string]map[string]bool{"pc-1": {src.ID: true}} + + c := compute(nmd, targetID) + + assert.Contains(t, peerIDSet(c.Peers), src.ID, "the seeded verdict wins over evaluating the check") + assert.Empty(t, c.PostureFailedPeers) +} + +// newClientsBehindRouterFixture builds clients that reach a routed network resource +// and the routing peer through policies with posture checks a fifth of them fail. +func newClientsBehindRouterFixture(clientCount int) (*networkmap.NetworkMapData, []string) { + router := newPeer("peer-router", 1) + peers := []*nmdata.Peer{router} + clientIDs := make([]string, 0, clientCount) + for i := range clientCount { + p := newPeer(fmt.Sprintf("peer-client-%d", i), byte(i)) + if i%5 == 0 { + p.Meta.WtVersion = failingVersion + } + peers = append(peers, p) + clientIDs = append(clientIDs, p.ID) + } + nmd := newNMD(peers...) + addVersionCheck(nmd, "pc-1", postureMinVersion) + addGroup(nmd, "g-clients", clientIDs...) + addGroup(nmd, "g-routers", router.ID) + addGroup(nmd, "g-resource") + acl := newPolicy("p-acl", newRule([]string{"g-clients"}, []string{"g-routers"})) + acl.SourcePostureChecks = []string{"pc-1"} + rp := newPolicy("rp-1", newRule([]string{"g-clients"}, []string{"g-resource"})) + rp.SourcePostureChecks = []string{"pc-1"} + nmd.Policies = []*nmdata.Policy{acl, rp} + nmd.ResourcePolicies = map[string][]*nmdata.Policy{} + for i := range 5 { + res := &nmdata.NetworkResource{ID: fmt.Sprintf("res-%d", i), NetworkID: "net-1", Enabled: true} + nmd.NetworkResources = append(nmd.NetworkResources, res) + nmd.ResourcePolicies[res.ID] = []*nmdata.Policy{rp} + } + nmd.Routers = map[string]map[string]*nmdata.NetworkRouter{"net-1": {router.ID: {}}} + return nmd, append(clientIDs, router.ID) +} + +func BenchmarkGetPeerNetworkMapComponents_ClientsBehindOneRouter(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + b.StopTimer() + nmd, peerIDs := newClientsBehindRouterFixture(10000) + b.StartTimer() + for _, peerID := range peerIDs { + compute(nmd, peerID) + } + } +} diff --git a/shared/management/networkmap/networkmapdata.go b/shared/management/networkmap/networkmapdata.go index 4e0e1c420..cf96de3a0 100644 --- a/shared/management/networkmap/networkmapdata.go +++ b/shared/management/networkmap/networkmapdata.go @@ -25,10 +25,11 @@ type NetworkMapData struct { //nolint:revive // established name across the code PostureChecks map[string]*nmdata.PostureChecks - // PostureValidation holds the precomputed posture-check results, keyed by - // posture check ID then peer ID. Filled by PrecomputePostureValidation; a + // PostureValidation optionally seeds the posture-check results, keyed by + // posture check ID then peer ID. May be filled by PrecomputePostureValidation; a // present but nil inner map marks a check ID that resolves to no posture - // check, which the calc treats as passing. + // check, which the calc treats as passing. Checks it lacks are evaluated + // on first use. PostureValidation map[string]map[string]bool AllowedUserIDs map[string]struct{} @@ -62,6 +63,9 @@ type NetworkMapData struct { //nolint:revive // established name across the code peerGroupsIdx map[string]map[string]struct{} proxyPoliciesOnce sync.Once + + admissionsOnce sync.Once + admissionsIdx *admissionCache } // AppliedZoneCandidate is an account-level custom DNS zone reduced to the diff --git a/shared/management/networkmap/nmdata/posture_certificate.go b/shared/management/networkmap/nmdata/posture_certificate.go index 37cfd4b5b..854783c08 100644 --- a/shared/management/networkmap/nmdata/posture_certificate.go +++ b/shared/management/networkmap/nmdata/posture_certificate.go @@ -1,6 +1,8 @@ package nmdata import ( + "crypto/x509" + "sync" "time" "github.com/netbirdio/netbird/shared/management/certposture" @@ -9,8 +11,21 @@ import ( // CertificateCheck is the slim twin of posture.CertificateCheck. type CertificateCheck struct { CACertificates []string + + rootsOnce sync.Once + roots *x509.CertPool + rootsErr error } func (c *CertificateCheck) check(peer *Peer) (bool, error) { - return certposture.AnyChainMatchesCAs(peer.Meta.Certificates, c.CACertificates, time.Now()), nil + if len(peer.Meta.Certificates) == 0 { + return false, nil + } + c.rootsOnce.Do(func() { + c.roots, c.rootsErr = certposture.ParseCAs(c.CACertificates) + }) + if c.rootsErr != nil { + return false, c.rootsErr + } + return certposture.AnyChainMatchesPool(peer.Meta.Certificates, c.roots, time.Now()), nil } diff --git a/shared/management/networkmap/nmdata/posture_certificate_test.go b/shared/management/networkmap/nmdata/posture_certificate_test.go index aa67dab77..3d808c331 100644 --- a/shared/management/networkmap/nmdata/posture_certificate_test.go +++ b/shared/management/networkmap/nmdata/posture_certificate_test.go @@ -26,3 +26,27 @@ func TestCertificateCheck_Check(t *testing.T) { otherOnly := bundle(ChecksDefinition{CertificateCheck: &CertificateCheck{CACertificates: []string{otherCA.PEM}}}) assert.False(t, otherOnly[0].Passes(with)) } + +func TestCertificateCheck_ReusesParsedCAs(t *testing.T) { + ca := certtest.NewCA(t, "corp-root") + chain := certposture.EncodeChainPEM([]*x509.Certificate{ca.Issue(t, certtest.ECDSAKey(t), "device")}) + with := &Peer{Meta: PeerSystemMeta{Certificates: []string{chain}}} + + check := &CertificateCheck{CACertificates: []string{ca.PEM}} + for range 3 { + valid, err := check.check(with) + assert.NoError(t, err) + assert.True(t, valid) + } + + invalid := &CertificateCheck{CACertificates: []string{"not a pem"}} + for range 2 { + valid, err := invalid.check(with) + assert.ErrorIs(t, err, certposture.ErrNoCertificateInPEM) + assert.False(t, valid) + } + + valid, err := invalid.check(&Peer{}) + assert.NoError(t, err) + assert.False(t, valid, "a peer without certificates fails before the CAs are parsed") +} diff --git a/shared/management/networkmap/nmdata/posture_nb_version.go b/shared/management/networkmap/nmdata/posture_nb_version.go index 3d82a4c80..c958666ff 100644 --- a/shared/management/networkmap/nmdata/posture_nb_version.go +++ b/shared/management/networkmap/nmdata/posture_nb_version.go @@ -2,6 +2,7 @@ package nmdata import ( "strings" + "sync" "github.com/hashicorp/go-version" ) @@ -9,27 +10,26 @@ import ( // NBVersionCheck is the slim twin of posture.NBVersionCheck. type NBVersionCheck struct { MinVersion string + + constraintOnce sync.Once + constraint version.Constraints + constraintErr error } func (n *NBVersionCheck) check(peer *Peer) (bool, error) { - return meetsMinVersion(n.MinVersion, peer.Meta.WtVersion) -} - -func meetsMinVersion(minVer, peerVer string) (bool, error) { - peerVer = sanitizeVersion(peerVer) - minVer = sanitizeVersion(minVer) - - peerNBVer, err := version.NewVersion(peerVer) + peerNBVer, err := version.NewVersion(sanitizeVersion(peer.Meta.WtVersion)) if err != nil { return false, err } - constraints, err := version.NewConstraint(">= " + minVer) - if err != nil { - return false, err + n.constraintOnce.Do(func() { + n.constraint, n.constraintErr = version.NewConstraint(">= " + sanitizeVersion(n.MinVersion)) + }) + if n.constraintErr != nil { + return false, n.constraintErr } - return constraints.Check(peerNBVer), nil + return n.constraint.Check(peerNBVer), nil } func sanitizeVersion(v string) string {