//go:build privileged package nftables import ( "net/netip" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" fw "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/wgaddr" ) func nftRefcountIfaceV4() *iFaceMock { return &iFaceMock{ NameFunc: func() string { return "wt-refcount" }, AddressFunc: func() wgaddr.Address { return wgaddr.Address{ IP: netip.MustParseAddr("100.96.0.1"), Network: netip.MustParsePrefix("100.96.0.0/16"), } }, } } func nftRefcountIfaceDual() *iFaceMock { return &iFaceMock{ NameFunc: func() string { return "wt-refcount" }, AddressFunc: func() wgaddr.Address { return wgaddr.Address{ IP: netip.MustParseAddr("100.96.0.1"), Network: netip.MustParsePrefix("100.96.0.0/16"), IPv6: netip.MustParseAddr("fd00::1"), IPv6Net: netip.MustParsePrefix("fd00::/64"), } }, } } func newNftRefcountManager(t *testing.T, dual bool) *Manager { t.Helper() if check() != NFTABLES { t.Skip("nftables not supported on this system") } var ifMock *iFaceMock if dual { ifMock = nftRefcountIfaceDual() } else { ifMock = nftRefcountIfaceV4() } m, err := Create(ifMock, iface.DefaultMTU) require.NoError(t, err, "create manager") require.NoError(t, m.Init(nil), "init manager") t.Cleanup(func() { require.NoError(t, m.Close(nil), "close manager") }) return m } func dnatV4(port uint16) fw.ForwardRule { return fw.ForwardRule{ Protocol: fw.ProtocolTCP, DestinationPort: fw.Port{Values: []uint16{port}}, TranslatedAddress: netip.MustParseAddr("100.96.0.2"), TranslatedPort: fw.Port{Values: []uint16{80}}, } } func dnatV6(port uint16) fw.ForwardRule { return fw.ForwardRule{ Protocol: fw.ProtocolTCP, DestinationPort: fw.Port{Values: []uint16{port}}, TranslatedAddress: netip.MustParseAddr("fd00::2"), TranslatedPort: fw.Port{Values: []uint16{80}}, } } // TestNftablesDNAT_RefcountBalancedV4 verifies that Add/Delete pairs leave the // v4 refcount at zero. func TestNftablesDNAT_RefcountBalancedV4(t *testing.T) { m := newNftRefcountManager(t, false) state := m.router.ipFwdState r1, err := m.AddDNATRule(dnatV4(8081)) require.NoError(t, err, "add v4 dnat 1") v4, v6 := state.Counts() assert.Equal(t, 1, v4, "v4 refcount after first add") assert.Equal(t, 0, v6, "v6 refcount unchanged") r2, err := m.AddDNATRule(dnatV4(8082)) require.NoError(t, err, "add v4 dnat 2") v4, v6 = state.Counts() assert.Equal(t, 2, v4, "v4 refcount after second add") assert.Equal(t, 0, v6, "v6 refcount unchanged") require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat 1") v4, v6 = state.Counts() assert.Equal(t, 1, v4, "v4 refcount after first delete") assert.Equal(t, 0, v6, "v6 refcount unchanged") require.NoError(t, m.DeleteDNATRule(r2), "delete v4 dnat 2") v4, v6 = state.Counts() assert.Equal(t, 0, v4, "v4 refcount after second delete") assert.Equal(t, 0, v6, "v6 refcount unchanged") } // TestNftablesDNAT_RefcountBalancedV6 verifies the v6 path increments v6 only // and decrements back to zero on Delete. func TestNftablesDNAT_RefcountBalancedV6(t *testing.T) { m := newNftRefcountManager(t, true) require.NotNil(t, m.router6, "v6 router") require.Same(t, m.router.ipFwdState, m.router6.ipFwdState, "shared state") state := m.router.ipFwdState r1, err := m.AddDNATRule(dnatV6(9091)) require.NoError(t, err, "add v6 dnat 1") v4, v6 := state.Counts() assert.Equal(t, 0, v4, "v4 refcount unchanged") assert.Equal(t, 1, v6, "v6 refcount after first add") r2, err := m.AddDNATRule(dnatV6(9092)) require.NoError(t, err, "add v6 dnat 2") v4, v6 = state.Counts() assert.Equal(t, 0, v4) assert.Equal(t, 2, v6, "v6 refcount after second add") require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat 1") v4, v6 = state.Counts() assert.Equal(t, 0, v4, "v4 refcount unchanged") assert.Equal(t, 1, v6, "v6 refcount after first delete") require.NoError(t, m.DeleteDNATRule(r2), "delete v6 dnat 2") v4, v6 = state.Counts() assert.Equal(t, 0, v4) assert.Equal(t, 0, v6, "v6 refcount after second delete") } // TestNftablesDNAT_DuplicateAddNoLeak verifies that a duplicate Add (same // ForwardRule) does not double-increment the refcount. func TestNftablesDNAT_DuplicateAddNoLeak(t *testing.T) { m := newNftRefcountManager(t, true) state := m.router.ipFwdState rule := dnatV4(8083) r1, err := m.AddDNATRule(rule) require.NoError(t, err, "add v4 dnat") v4, _ := state.Counts() assert.Equal(t, 1, v4) // duplicate add: same rule ID, must be a no-op for the refcount. _, err = m.AddDNATRule(rule) require.NoError(t, err, "duplicate add") v4, _ = state.Counts() assert.Equal(t, 1, v4, "duplicate add must not increment") require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat") v4, _ = state.Counts() assert.Equal(t, 0, v4, "single delete must drop to zero") } // TestNftablesDNAT_DeleteMissingNoUnderflow verifies deleting a rule that was // never added does not underflow the refcount. func TestNftablesDNAT_DeleteMissingNoUnderflow(t *testing.T) { m := newNftRefcountManager(t, true) state := m.router.ipFwdState // Construct a Rule reference for something never added. The router stores // rules by ID(), and DeleteDNATRule looks them up in r.rules; a missing // entry must be a no-op rather than calling Release. phantom := dnatV4(8099) require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4 dnat") v4, v6 := state.Counts() assert.Equal(t, 0, v4, "v4 refcount unaffected by missing delete") assert.Equal(t, 0, v6, "v6 refcount unaffected") phantom6 := dnatV6(9099) require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6 dnat") v4, v6 = state.Counts() assert.Equal(t, 0, v4) assert.Equal(t, 0, v6, "v6 refcount unaffected by missing delete") // And after a phantom delete, a real add still results in count=1. r1, err := m.AddDNATRule(dnatV4(8100)) require.NoError(t, err, "add v4 dnat after phantom delete") v4, _ = state.Counts() assert.Equal(t, 1, v4, "real add still increments after phantom delete") require.NoError(t, m.DeleteDNATRule(r1)) } // TestNftablesRouting_RepeatedEnableSingleReference verifies that EnableRouting // (called on every network-map update) holds at most one reference per family // and a single DisableRouting drops both back to zero. func TestNftablesRouting_RepeatedEnableSingleReference(t *testing.T) { m := newNftRefcountManager(t, true) state := m.router.ipFwdState require.NoError(t, m.EnableRouting(), "first enable") require.NoError(t, m.EnableRouting(), "second enable") require.NoError(t, m.EnableRouting(), "third enable") v4, v6 := state.Counts() assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference") assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference") require.NoError(t, m.DisableRouting(), "disable") v4, v6 = state.Counts() assert.Equal(t, 0, v4, "single disable releases the v4 reference") assert.Equal(t, 0, v6, "single disable releases the v6 reference") } // TestNftablesRouting_DisableKeepsDNATReference verifies that an unpaired // DisableRouting does not release references held by active DNAT rules. func TestNftablesRouting_DisableKeepsDNATReference(t *testing.T) { m := newNftRefcountManager(t, true) state := m.router.ipFwdState r1, err := m.AddDNATRule(dnatV6(9095)) require.NoError(t, err, "add v6 dnat") require.NoError(t, m.DisableRouting(), "unpaired disable") _, v6 := state.Counts() assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting") require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat") _, v6 = state.Counts() assert.Equal(t, 0, v6, "delete releases the DNAT reference") } // TestNftablesDNAT_DoubleDeleteNoUnderflow verifies that deleting the same rule // twice does not underflow the refcount (the second delete is a no-op). func TestNftablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) { m := newNftRefcountManager(t, true) state := m.router.ipFwdState r1, err := m.AddDNATRule(dnatV6(9093)) require.NoError(t, err) _, v6 := state.Counts() assert.Equal(t, 1, v6) require.NoError(t, m.DeleteDNATRule(r1), "first delete") _, v6 = state.Counts() assert.Equal(t, 0, v6) require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op") _, v6 = state.Counts() assert.Equal(t, 0, v6, "double delete must not underflow") }