mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-28 01:29:05 +02:00
Merge remote-tracking branch 'origin/main' into peer-acl-multi-source
# Conflicts: # client/internal/acl/manager.go # client/ssh/server/test.go
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/mitchellh/hashstructure/v2"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
@@ -32,10 +33,12 @@ type Manager interface {
|
||||
|
||||
// DefaultManager uses firewall manager to handle
|
||||
type DefaultManager struct {
|
||||
firewall firewall.Manager
|
||||
peerRulesPairs map[id.RuleID][]firewall.Rule
|
||||
routeRules map[id.RuleID]firewall.Rule
|
||||
mutex sync.Mutex
|
||||
firewall firewall.Manager
|
||||
peerRulesPairs map[id.RuleID][]firewall.Rule
|
||||
routeRules map[id.RuleID]firewall.Rule
|
||||
previousConfigHash uint64
|
||||
hasAppliedConfig bool
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
// peerRuleGroup collapses a set of single-source FirewallRules sharing
|
||||
@@ -88,6 +91,23 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout
|
||||
return
|
||||
}
|
||||
|
||||
// Skip the full rebuild + flush when the inputs that drive the firewall
|
||||
// state are byte-for-byte identical to the last successfully applied
|
||||
// update. Management re-sends the same network map far more often than it
|
||||
// actually changes (account-wide updates, peer meta churn), and rebuilding
|
||||
// every peer/route ACL and flushing the firewall on every such sync is the
|
||||
// dominant client-side cost when nothing changed. Mirrors the same guard the
|
||||
// DNS server already uses (previousConfigHash). Only the fields ApplyFiltering
|
||||
// consumes participate in the hash, so an unrelated map change cannot mask a
|
||||
// real ACL change.
|
||||
hash, err := d.firewallConfigHash(networkMap, dnsRouteFeatureFlag)
|
||||
if err != nil {
|
||||
log.Errorf("unable to hash firewall configuration, applying unconditionally: %v", err)
|
||||
} else if d.hasAppliedConfig && d.previousConfigHash == hash {
|
||||
log.Debugf("not applying the firewall configuration update as there is nothing new (hash: %d)", hash)
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
total := 0
|
||||
@@ -99,17 +119,54 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout
|
||||
time.Since(start), total)
|
||||
}()
|
||||
|
||||
if err := d.applyPeerACLs(networkMap); err != nil {
|
||||
log.Errorf("apply peer ACLs: %v", err)
|
||||
peerErr := d.applyPeerACLs(networkMap)
|
||||
if peerErr != nil {
|
||||
log.Errorf("apply peer ACLs: %v", peerErr)
|
||||
}
|
||||
|
||||
if err := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag); err != nil {
|
||||
log.Errorf("apply route ACLs: %v", err)
|
||||
routeErr := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag)
|
||||
if routeErr != nil {
|
||||
log.Errorf("apply route ACLs: %v", routeErr)
|
||||
}
|
||||
|
||||
if err := d.firewall.Flush(); err != nil {
|
||||
log.Error("failed to flush firewall rules: ", err)
|
||||
flushErr := d.firewall.Flush()
|
||||
if flushErr != nil {
|
||||
log.Error("failed to flush firewall rules: ", flushErr)
|
||||
}
|
||||
|
||||
// Only remember the hash once the firewall actually reflects this config.
|
||||
// If applying or flushing failed, leave the previous hash untouched so the
|
||||
// next (possibly identical) update is not skipped and gets a chance to
|
||||
// reconcile the firewall state.
|
||||
if err == nil && peerErr == nil && routeErr == nil && flushErr == nil {
|
||||
d.previousConfigHash = hash
|
||||
d.hasAppliedConfig = true
|
||||
} else {
|
||||
d.hasAppliedConfig = false
|
||||
}
|
||||
}
|
||||
|
||||
// firewallConfigHash hashes exactly the inputs ApplyFiltering uses to build the
|
||||
// firewall state, so an identical hash means an identical resulting ruleset.
|
||||
func (d *DefaultManager) firewallConfigHash(networkMap *mgmProto.NetworkMap, dnsRouteFeatureFlag bool) (uint64, error) {
|
||||
return hashstructure.Hash(struct {
|
||||
PeerRules []*mgmProto.FirewallRule
|
||||
PeerRulesIsEmpty bool
|
||||
RouteRules []*mgmProto.RouteFirewallRule
|
||||
RouteRulesIsEmpty bool
|
||||
DNSRouteFeatureFlag bool
|
||||
}{
|
||||
PeerRules: networkMap.GetFirewallRules(),
|
||||
PeerRulesIsEmpty: networkMap.GetFirewallRulesIsEmpty(),
|
||||
RouteRules: networkMap.GetRoutesFirewallRules(),
|
||||
RouteRulesIsEmpty: networkMap.GetRoutesFirewallRulesIsEmpty(),
|
||||
DNSRouteFeatureFlag: dnsRouteFeatureFlag,
|
||||
}, hashstructure.FormatV2, &hashstructure.HashOptions{
|
||||
ZeroNil: true,
|
||||
IgnoreZeroValue: true,
|
||||
SlicesAsSets: true,
|
||||
UseStringer: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) error {
|
||||
@@ -177,7 +234,7 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) error {
|
||||
var remaining []firewall.Rule
|
||||
for _, rule := range rules {
|
||||
if err := d.firewall.DeleteFilterRule(rule); err != nil {
|
||||
log.Errorf("failed to delete peer firewall rule, will retry: %v", err)
|
||||
merr = multierror.Append(merr, fmt.Errorf("delete peer rule, will retry: %w", err))
|
||||
remaining = append(remaining, rule)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package acl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
@@ -486,3 +487,149 @@ func TestPortInfoEmpty(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyFilteringSkipsUnchangedConfig verifies that an identical network map
|
||||
// re-applied is recognized as a no-op (hash unchanged), while a real change to
|
||||
// any firewall-relevant input forces a re-apply (hash changes). This is the
|
||||
// guard that prevents a full ruleset rebuild + flush on every redundant sync.
|
||||
func TestApplyFilteringSkipsUnchangedConfig(t *testing.T) {
|
||||
t.Setenv("NB_WG_KERNEL_DISABLED", "true")
|
||||
t.Setenv(firewall.EnvForceUserspaceFirewall, "true")
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
ifaceMock := mocks.NewMockIFaceMapper(ctrl)
|
||||
ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes()
|
||||
ifaceMock.EXPECT().SetFilter(gomock.Any())
|
||||
network := netip.MustParsePrefix("172.0.0.1/32")
|
||||
ifaceMock.EXPECT().Name().Return("lo").AnyTimes()
|
||||
ifaceMock.EXPECT().Address().Return(wgaddr.Address{
|
||||
IP: network.Addr(),
|
||||
Network: network,
|
||||
}).AnyTimes()
|
||||
ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes()
|
||||
|
||||
fw, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
require.NoError(t, fw.Close(nil))
|
||||
}()
|
||||
|
||||
acl := NewDefaultManager(fw)
|
||||
|
||||
networkMap := &mgmProto.NetworkMap{
|
||||
FirewallRules: []*mgmProto.FirewallRule{
|
||||
{
|
||||
PeerIP: "10.93.0.1",
|
||||
Direction: mgmProto.RuleDirection_IN,
|
||||
Action: mgmProto.RuleAction_ACCEPT,
|
||||
Protocol: mgmProto.RuleProtocol_TCP,
|
||||
Port: "22",
|
||||
},
|
||||
},
|
||||
FirewallRulesIsEmpty: false,
|
||||
}
|
||||
|
||||
acl.ApplyFiltering(networkMap, false)
|
||||
require.True(t, acl.hasAppliedConfig, "config should be marked applied after first apply")
|
||||
firstHash := acl.previousConfigHash
|
||||
require.NotZero(t, firstHash)
|
||||
|
||||
// Re-applying the identical map must not change the recorded hash: the
|
||||
// expensive rebuild path was skipped.
|
||||
acl.ApplyFiltering(networkMap, false)
|
||||
assert.Equal(t, firstHash, acl.previousConfigHash,
|
||||
"identical re-apply must be a no-op (hash unchanged)")
|
||||
|
||||
// A real change must produce a different hash and re-apply.
|
||||
networkMap.FirewallRules[0].Action = mgmProto.RuleAction_DROP
|
||||
acl.ApplyFiltering(networkMap, false)
|
||||
assert.NotEqual(t, firstHash, acl.previousConfigHash,
|
||||
"changing a rule's action must force a re-apply (hash changed)")
|
||||
|
||||
// The dnsRouteFeatureFlag also participates in the hash.
|
||||
changedHash := acl.previousConfigHash
|
||||
acl.ApplyFiltering(networkMap, true)
|
||||
assert.NotEqual(t, changedHash, acl.previousConfigHash,
|
||||
"flipping dnsRouteFeatureFlag must force a re-apply (hash changed)")
|
||||
}
|
||||
|
||||
func buildNetworkMap(peerRules, routeRules int) *mgmProto.NetworkMap {
|
||||
nm := &mgmProto.NetworkMap{
|
||||
FirewallRulesIsEmpty: peerRules == 0,
|
||||
RoutesFirewallRulesIsEmpty: routeRules == 0,
|
||||
}
|
||||
for i := range peerRules {
|
||||
nm.FirewallRules = append(nm.FirewallRules, &mgmProto.FirewallRule{
|
||||
PeerIP: fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff),
|
||||
Direction: mgmProto.RuleDirection_IN,
|
||||
Action: mgmProto.RuleAction_ACCEPT,
|
||||
Protocol: mgmProto.RuleProtocol_TCP,
|
||||
Port: fmt.Sprintf("%d", 1024+i%64511),
|
||||
})
|
||||
}
|
||||
for i := range routeRules {
|
||||
nm.RoutesFirewallRules = append(nm.RoutesFirewallRules, &mgmProto.RouteFirewallRule{
|
||||
Destination: fmt.Sprintf("192.168.%d.0/24", i%256),
|
||||
SourceRanges: []string{fmt.Sprintf("10.0.%d.0/24", i%256)},
|
||||
Action: mgmProto.RuleAction_ACCEPT,
|
||||
Protocol: mgmProto.RuleProtocol_ALL,
|
||||
})
|
||||
}
|
||||
return nm
|
||||
}
|
||||
|
||||
func BenchmarkFirewallConfigHash_Small(b *testing.B) {
|
||||
d := &DefaultManager{}
|
||||
nm := buildNetworkMap(10, 5)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_, _ = d.firewallConfigHash(nm, false)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFirewallConfigHash_Medium(b *testing.B) {
|
||||
d := &DefaultManager{}
|
||||
nm := buildNetworkMap(100, 50)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_, _ = d.firewallConfigHash(nm, false)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFirewallConfigHash_Large(b *testing.B) {
|
||||
d := &DefaultManager{}
|
||||
nm := buildNetworkMap(1000, 200)
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
_, _ = d.firewallConfigHash(nm, false)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFirewallConfigHashDeterministic verifies the hash is stable for equal
|
||||
// inputs and order-independent for the rule slices (management does not
|
||||
// guarantee rule order).
|
||||
func TestFirewallConfigHashDeterministic(t *testing.T) {
|
||||
d := &DefaultManager{}
|
||||
|
||||
nm1 := &mgmProto.NetworkMap{
|
||||
FirewallRules: []*mgmProto.FirewallRule{
|
||||
{PeerIP: "10.0.0.1", Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_TCP, Port: "22"},
|
||||
{PeerIP: "10.0.0.2", Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_DROP, Protocol: mgmProto.RuleProtocol_TCP, Port: "80"},
|
||||
},
|
||||
}
|
||||
// Same rules, reversed order.
|
||||
nm2 := &mgmProto.NetworkMap{
|
||||
FirewallRules: []*mgmProto.FirewallRule{
|
||||
nm1.FirewallRules[1],
|
||||
nm1.FirewallRules[0],
|
||||
},
|
||||
}
|
||||
|
||||
h1, err := d.firewallConfigHash(nm1, false)
|
||||
require.NoError(t, err)
|
||||
h2, err := d.firewallConfigHash(nm2, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, h1, h2, "hash must be order-independent for rule slices")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user