mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-16 19:49:56 +00:00
191 lines
7.4 KiB
Go
191 lines
7.4 KiB
Go
package acl
|
|
|
|
import (
|
|
"net/netip"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/golang/mock/gomock"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/client/firewall"
|
|
fwmgr "github.com/netbirdio/netbird/client/firewall/manager"
|
|
"github.com/netbirdio/netbird/client/iface"
|
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
|
"github.com/netbirdio/netbird/client/internal/acl/mocks"
|
|
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
|
)
|
|
|
|
// TestNetworkZeroPrefixIsRoute guards the route-vs-peer dispatch
|
|
// invariant: the backends classify a rule as a peer rule purely by the
|
|
// absence of a destination (neither prefix nor set). A default route
|
|
// (0.0.0.0/0 or ::/0) is a valid prefix and must therefore classify as
|
|
// a route, not collapse into the peer path.
|
|
func TestNetworkZeroPrefixIsRoute(t *testing.T) {
|
|
for _, p := range []string{"0.0.0.0/0", "::/0", "10.0.0.0/8"} {
|
|
n := fwmgr.Network{Prefix: netip.MustParsePrefix(p)}
|
|
assert.True(t, n.IsPrefix(), "%s must report IsPrefix", p)
|
|
assert.True(t, n.IsPrefix() || n.IsSet(), "%s must classify as a route", p)
|
|
}
|
|
|
|
// A zero-value Network is the only peer-rule shape.
|
|
var empty fwmgr.Network
|
|
assert.False(t, empty.IsPrefix(), "zero Network must not be a prefix")
|
|
assert.False(t, empty.IsSet(), "zero Network must not be a set")
|
|
}
|
|
|
|
// TestDetermineDestinationAlwaysRoute verifies determineDestination
|
|
// never yields an empty Network for a valid route rule: every branch
|
|
// (static prefix, default route, dynamic with/without domains, with and
|
|
// without a local resolver) produces a destination that classifies as a
|
|
// route. If this regresses, a route rule would be dispatched down the
|
|
// peer path, which matches on source only.
|
|
func TestDetermineDestinationAlwaysRoute(t *testing.T) {
|
|
v4 := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")}
|
|
v6 := []netip.Prefix{netip.MustParsePrefix("2001:db8::/48")}
|
|
|
|
cases := []struct {
|
|
name string
|
|
rule *mgmProto.RouteFirewallRule
|
|
resolver bool
|
|
sources []netip.Prefix
|
|
}{
|
|
{"static prefix", &mgmProto.RouteFirewallRule{Destination: "192.168.0.0/16"}, false, v4},
|
|
{"static default route", &mgmProto.RouteFirewallRule{Destination: "0.0.0.0/0"}, false, v4},
|
|
{"dynamic with domains + resolver", &mgmProto.RouteFirewallRule{IsDynamic: true, Domains: []string{"example.com"}}, true, v4},
|
|
{"dynamic no domains + resolver (v4)", &mgmProto.RouteFirewallRule{IsDynamic: true}, true, v4},
|
|
{"dynamic no domains + resolver (v6)", &mgmProto.RouteFirewallRule{IsDynamic: true}, true, v6},
|
|
{"dynamic + no local resolver (v4)", &mgmProto.RouteFirewallRule{IsDynamic: true}, false, v4},
|
|
{"dynamic + no local resolver (v6)", &mgmProto.RouteFirewallRule{IsDynamic: true}, false, v6},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dest, err := determineDestination(tc.rule, tc.resolver, tc.sources)
|
|
require.NoError(t, err)
|
|
assert.True(t, dest.IsPrefix() || dest.IsSet(),
|
|
"destination must classify as a route, got empty Network")
|
|
})
|
|
}
|
|
}
|
|
|
|
// countingFirewall wraps a real firewall.Manager and counts filter-rule
|
|
// add/delete calls so a test can assert how many backing rules the acl
|
|
// manager actually creates and tears down.
|
|
type countingFirewall struct {
|
|
fwmgr.Manager
|
|
mu sync.Mutex
|
|
addCalls int
|
|
dels int
|
|
ruleIDs map[fwmgr.RuleID]struct{}
|
|
}
|
|
|
|
// distinctRules returns the number of distinct backing rules the
|
|
// backend produced. Because the backend dedups identical content,
|
|
// repeated AddFilterRule calls for the same rule resolve to one id.
|
|
func (f *countingFirewall) distinctRules() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.ruleIDs)
|
|
}
|
|
|
|
func (f *countingFirewall) AddFilterRule(id []byte, sources []netip.Prefix, destination fwmgr.Network, proto fwmgr.Protocol, sPort, dPort *fwmgr.Port, action fwmgr.Action) (fwmgr.Rule, error) {
|
|
rule, err := f.Manager.AddFilterRule(id, sources, destination, proto, sPort, dPort, action)
|
|
if err == nil {
|
|
f.mu.Lock()
|
|
f.addCalls++
|
|
if f.ruleIDs == nil {
|
|
f.ruleIDs = make(map[fwmgr.RuleID]struct{})
|
|
}
|
|
if rule != nil {
|
|
f.ruleIDs[rule.ID()] = struct{}{}
|
|
}
|
|
f.mu.Unlock()
|
|
}
|
|
return rule, err
|
|
}
|
|
|
|
func (f *countingFirewall) DeleteFilterRule(r fwmgr.Rule) error {
|
|
err := f.Manager.DeleteFilterRule(r)
|
|
if err == nil {
|
|
f.mu.Lock()
|
|
f.dels++
|
|
delete(f.ruleIDs, r.ID())
|
|
f.mu.Unlock()
|
|
}
|
|
return err
|
|
}
|
|
|
|
func newCountingACL(t *testing.T) (*DefaultManager, *countingFirewall, func()) {
|
|
t.Helper()
|
|
t.Setenv("NB_WG_KERNEL_DISABLED", "true")
|
|
t.Setenv(firewall.EnvForceUserspaceFirewall, "true")
|
|
|
|
ctrl := gomock.NewController(t)
|
|
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()
|
|
|
|
realFW, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU)
|
|
require.NoError(t, err)
|
|
|
|
fw := &countingFirewall{Manager: realFW}
|
|
cleanup := func() {
|
|
require.NoError(t, realFW.Close(nil))
|
|
ctrl.Finish()
|
|
}
|
|
return NewDefaultManager(fw), fw, cleanup
|
|
}
|
|
|
|
// TestDuplicateContentPoliciesShareOneRule verifies the dedup contract
|
|
// the backends rely on: two policies that authorize an identical flow
|
|
// (same selector and sources) collapse to a single backing firewall
|
|
// rule, and that rule survives until BOTH policies are gone. This is
|
|
// why the backend can dedup on add without refcounting on delete: the
|
|
// acl manager's pair key matches the backend's content key, so add and
|
|
// delete stay balanced per content key across full-state reapplies.
|
|
func TestDuplicateContentPoliciesShareOneRule(t *testing.T) {
|
|
acl, fw, cleanup := newCountingACL(t)
|
|
defer cleanup()
|
|
|
|
ruleA := &mgmProto.FirewallRule{
|
|
PolicyID: []byte("policy-A"),
|
|
PeerIP: "10.0.0.1",
|
|
Direction: mgmProto.RuleDirection_IN,
|
|
Action: mgmProto.RuleAction_ACCEPT,
|
|
Protocol: mgmProto.RuleProtocol_TCP,
|
|
Port: "443",
|
|
}
|
|
ruleB := &mgmProto.FirewallRule{
|
|
PolicyID: []byte("policy-B"),
|
|
PeerIP: "10.0.0.1",
|
|
Direction: mgmProto.RuleDirection_IN,
|
|
Action: mgmProto.RuleAction_ACCEPT,
|
|
Protocol: mgmProto.RuleProtocol_TCP,
|
|
Port: "443",
|
|
}
|
|
|
|
// Both policies present: identical content collapses to one rule.
|
|
acl.ApplyFiltering(&mgmProto.NetworkMap{FirewallRules: []*mgmProto.FirewallRule{ruleA, ruleB}, FirewallRulesIsEmpty: false}, false)
|
|
assert.Equal(t, 1, fw.distinctRules(), "identical-content policies must produce one backing rule")
|
|
assert.Equal(t, 1, len(acl.peerRulesPairs), "one content key, one pair")
|
|
|
|
// Drop policy A only: the shared rule is still authorized by B, so
|
|
// nothing is deleted.
|
|
acl.ApplyFiltering(&mgmProto.NetworkMap{FirewallRules: []*mgmProto.FirewallRule{ruleB}, FirewallRulesIsEmpty: false}, false)
|
|
assert.Equal(t, 1, fw.distinctRules(), "no new backing rule on reapply")
|
|
assert.Equal(t, 0, fw.dels, "rule must survive while any policy still authorizes it")
|
|
assert.Equal(t, 1, len(acl.peerRulesPairs))
|
|
|
|
// Drop policy B too: now the content key has no authorizer and the
|
|
// single backing rule is removed exactly once.
|
|
acl.ApplyFiltering(&mgmProto.NetworkMap{FirewallRules: nil, FirewallRulesIsEmpty: true}, false)
|
|
assert.Equal(t, 1, fw.dels, "rule removed once when last policy is gone")
|
|
assert.Equal(t, 0, len(acl.peerRulesPairs))
|
|
}
|