mirror of
https://github.com/netbirdio/netbird.git
synced 2026-06-29 19:29:56 +00:00
Compare commits
2 Commits
feature/pe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04c3d19032 | ||
|
|
3f1fb3b52d |
@@ -11,6 +11,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"
|
||||
@@ -30,11 +31,13 @@ type Manager interface {
|
||||
|
||||
// DefaultManager uses firewall manager to handle
|
||||
type DefaultManager struct {
|
||||
firewall firewall.Manager
|
||||
ipsetCounter int
|
||||
peerRulesPairs map[id.RuleID][]firewall.Rule
|
||||
routeRules map[id.RuleID]struct{}
|
||||
mutex sync.Mutex
|
||||
firewall firewall.Manager
|
||||
ipsetCounter int
|
||||
peerRulesPairs map[id.RuleID][]firewall.Rule
|
||||
routeRules map[id.RuleID]struct{}
|
||||
previousConfigHash uint64
|
||||
hasAppliedConfig bool
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func NewDefaultManager(fm firewall.Manager) *DefaultManager {
|
||||
@@ -57,6 +60,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
|
||||
@@ -70,13 +90,49 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout
|
||||
|
||||
d.applyPeerACLs(networkMap)
|
||||
|
||||
if err := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag); err != nil {
|
||||
log.Errorf("Failed to apply route ACLs: %v", err)
|
||||
routeErr := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag)
|
||||
if routeErr != nil {
|
||||
log.Errorf("Failed to 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 && 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) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package acl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
@@ -485,3 +486,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")
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const (
|
||||
defaultListenAddr = ":8087"
|
||||
defaultInfluxDBURL = "http://influxdb:8086/api/v2/write?org=netbird&bucket=metrics&precision=ns"
|
||||
maxBodySize = 50 * 1024 * 1024 // 50 MB max request body
|
||||
maxDurationSeconds = 300.0 // reject any duration field > 5 minutes
|
||||
maxDurationSeconds = 86400.0 // reject any duration field > 24 hours
|
||||
peerIDLength = 16 // truncated SHA-256: 8 bytes = 16 hex chars
|
||||
maxTagValueLength = 64 // reject tag values longer than this
|
||||
)
|
||||
|
||||
@@ -53,14 +53,14 @@ func TestValidateLine_NegativeValue(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidateLine_DurationTooLarge(t *testing.T) {
|
||||
line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=999 1234567890`
|
||||
line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=100000 1234567890`
|
||||
err := validateLine(line)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "too large")
|
||||
}
|
||||
|
||||
func TestValidateLine_TotalSecondsTooLarge(t *testing.T) {
|
||||
line := `netbird_peer_connection,deployment_type=cloud,connection_type=ice,attempt_type=initial,version=1.0.0,os=linux,arch=amd64,peer_id=abc,connection_pair_id=pair total_seconds=500 1234567890`
|
||||
line := `netbird_peer_connection,deployment_type=cloud,connection_type=ice,attempt_type=initial,version=1.0.0,os=linux,arch=amd64,peer_id=abc,connection_pair_id=pair total_seconds=100000 1234567890`
|
||||
err := validateLine(line)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "too large")
|
||||
|
||||
Reference in New Issue
Block a user