mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-10 03:56:01 -04:00
## Describe your changes The AllowedIPs reference counter ([refcounter/types.go#L9](https://github.com/netbirdio/netbird/blob/e1a24376a/client/internal/routemanager/refcounter/types.go#L9)) was keyed only by prefix and stored a single active peer set by the first registrar, never swapped. When two networks advertised the same prefix via different routing peers, removing the one whose peer was installed in WireGuard left the prefix pointing at the removed peer instead of the surviving one — traffic kept flowing to the old peer until a manual `netbird down/up`. Made the AllowedIPs counter peer-aware: it tracks a per-peer reference count per prefix plus the installed peer, and swaps WireGuard to a surviving peer when the active one releases its last reference (removes the prefix when none remain). `Decrement` now takes the peer key so the exact incremented peer is released; the static handler records its selected routing peer like the dynamic and DNS handlers already did. The generic `Counter` (routes, exclusion, ipset) is unchanged. ## Issue ticket number and link No public issue — reported internally (routes not updating without `netbird down/up` when two networks share a subnet). Root cause is the prefix-only key at [refcounter/types.go#L9](https://github.com/netbirdio/netbird/blob/e1a24376a/client/internal/routemanager/refcounter/types.go#L9). ## Stack <!-- branch-stack --> ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (if possible) > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Internal client-side routing fix. No public API, CLI, or config change — only the WireGuard AllowedIPs hand-off when overlapping-prefix networks are removed. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: N/A <!-- codesmith:footer --> --- <a href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6799"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img alt="View with Codesmith" src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a> <a href="https://backend.blacksmith.sh/track/enable-autofix?expires=1786796229&installation_id=146802194&pr_number=6799&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6799&signature=322d6950b4f664b1cb3421f2efa039fbbb7946de9b79f2a1b1b131dc182ada2c"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img alt="Autofix with Codesmith" src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a> <sup>Need help on this PR? Tag <code>/codesmith</code> with what you need. Autofix is disabled.</sup> <!-- codesmith:autofix:disabled --> <!-- /codesmith:footer --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved routing behavior when multiple peers share the same Allowed IP by making Allowed IP reference tracking peer-aware. * Allowed IPs now correctly decrement using the active peer key and transfer to another surviving active peer when the current peer is removed. * Prevented stale routing and incorrect reference cleanup during route and DNS-driven teardown. * **Tests** * Added/extended coverage for peer handoffs, repeated references, non-active peer removal, flushing behavior, and self-healing after swap add/remove failures. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
242 lines
7.2 KiB
Go
242 lines
7.2 KiB
Go
package refcounter
|
|
|
|
import (
|
|
"errors"
|
|
"net/netip"
|
|
"testing"
|
|
)
|
|
|
|
// fakeWG models WireGuard's cryptokey routing: a prefix can be installed on exactly one peer.
|
|
// failAdd/failRemove make the next add/remove fail once, to exercise the self-healing error paths.
|
|
type fakeWG struct {
|
|
installed map[netip.Prefix]string
|
|
adds int
|
|
removes int
|
|
failAdd bool
|
|
failRemove bool
|
|
}
|
|
|
|
func newFakeWG() *fakeWG {
|
|
return &fakeWG{installed: map[netip.Prefix]string{}}
|
|
}
|
|
|
|
func (f *fakeWG) counter() *AllowedIPsRefCounter {
|
|
return NewAllowedIPs(
|
|
func(prefix netip.Prefix, peerKey string) (string, error) {
|
|
if f.failAdd {
|
|
f.failAdd = false
|
|
return "", errors.New("add failed")
|
|
}
|
|
f.adds++
|
|
f.installed[prefix] = peerKey
|
|
return peerKey, nil
|
|
},
|
|
func(prefix netip.Prefix, peerKey string) error {
|
|
if f.failRemove {
|
|
f.failRemove = false
|
|
return errors.New("remove failed")
|
|
}
|
|
f.removes++
|
|
// only clear if this peer is the one installed, mirroring wg semantics
|
|
if f.installed[prefix] == peerKey {
|
|
delete(f.installed, prefix)
|
|
}
|
|
return nil
|
|
},
|
|
)
|
|
}
|
|
|
|
func mustPrefix(t *testing.T, s string) netip.Prefix {
|
|
t.Helper()
|
|
p, err := netip.ParsePrefix(s)
|
|
if err != nil {
|
|
t.Fatalf("parse prefix %q: %v", s, err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func mustIncrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] {
|
|
t.Helper()
|
|
ref, err := c.Increment(p, peer)
|
|
if err != nil {
|
|
t.Fatalf("Increment(%v, %s): %v", p, peer, err)
|
|
}
|
|
return ref
|
|
}
|
|
|
|
func mustDecrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] {
|
|
t.Helper()
|
|
ref, err := c.Decrement(p, peer)
|
|
if err != nil {
|
|
t.Fatalf("Decrement(%v, %s): %v", p, peer, err)
|
|
}
|
|
return ref
|
|
}
|
|
|
|
// TestAllowedIPs_SwapOnActivePeerRemoval reproduces the reported bug: two networks with the same
|
|
// prefix routed by different peers. Removing the network whose peer is installed must hand the
|
|
// prefix over to the surviving peer instead of leaving it on the removed one.
|
|
func TestAllowedIPs_SwapOnActivePeerRemoval(t *testing.T) {
|
|
f := newFakeWG()
|
|
c := f.counter()
|
|
p := mustPrefix(t, "10.44.8.0/24")
|
|
|
|
mustIncrement(t, c, p, "peerA")
|
|
mustIncrement(t, c, p, "peerB")
|
|
// First peer wins while both are present.
|
|
if got := f.installed[p]; got != "peerA" {
|
|
t.Fatalf("expected peerA installed, got %q", got)
|
|
}
|
|
|
|
// Remove the active peer's network -> must swap to peerB.
|
|
mustDecrement(t, c, p, "peerA")
|
|
if got := f.installed[p]; got != "peerB" {
|
|
t.Fatalf("BUG: prefix stuck on removed peer, want peerB got %q", got)
|
|
}
|
|
|
|
// Remove the last one -> prefix gone.
|
|
mustDecrement(t, c, p, "peerB")
|
|
if _, ok := f.installed[p]; ok {
|
|
t.Fatalf("expected prefix removed, still installed on %q", f.installed[p])
|
|
}
|
|
}
|
|
|
|
// TestAllowedIPs_RemoveNonActivePeer removing a non-installed peer must not touch WireGuard.
|
|
func TestAllowedIPs_RemoveNonActivePeer(t *testing.T) {
|
|
f := newFakeWG()
|
|
c := f.counter()
|
|
p := mustPrefix(t, "10.44.8.0/24")
|
|
|
|
mustIncrement(t, c, p, "peerA")
|
|
mustIncrement(t, c, p, "peerB")
|
|
removesBefore := f.removes
|
|
|
|
mustDecrement(t, c, p, "peerB")
|
|
if f.installed[p] != "peerA" {
|
|
t.Fatalf("active peer must stay peerA, got %q", f.installed[p])
|
|
}
|
|
if f.removes != removesBefore {
|
|
t.Fatalf("removing a non-active peer must not call wg remove")
|
|
}
|
|
}
|
|
|
|
// TestAllowedIPs_SamePeerMultipleRefs two references via the same peer must keep the prefix until
|
|
// the last reference is released (the reason the per-peer count must be an int, not a set).
|
|
func TestAllowedIPs_SamePeerMultipleRefs(t *testing.T) {
|
|
f := newFakeWG()
|
|
c := f.counter()
|
|
p := mustPrefix(t, "10.44.8.0/24")
|
|
|
|
mustIncrement(t, c, p, "peerA")
|
|
mustIncrement(t, c, p, "peerA")
|
|
if f.adds != 1 {
|
|
t.Fatalf("expected a single wg add for the same peer, got %d", f.adds)
|
|
}
|
|
|
|
mustDecrement(t, c, p, "peerA")
|
|
if f.installed[p] != "peerA" {
|
|
t.Fatalf("prefix must stay while a reference remains, got %q", f.installed[p])
|
|
}
|
|
if f.removes != 0 {
|
|
t.Fatalf("no wg remove expected while a reference remains, got %d", f.removes)
|
|
}
|
|
|
|
mustDecrement(t, c, p, "peerA")
|
|
if _, ok := f.installed[p]; ok {
|
|
t.Fatalf("prefix must be removed after last reference")
|
|
}
|
|
}
|
|
|
|
// TestAllowedIPs_RefCountAndActive checks the Ref returned to callers (used for the HA-disabled log).
|
|
func TestAllowedIPs_RefCountAndActive(t *testing.T) {
|
|
f := newFakeWG()
|
|
c := f.counter()
|
|
p := mustPrefix(t, "10.44.8.0/24")
|
|
|
|
ref := mustIncrement(t, c, p, "peerA")
|
|
if ref.Count != 1 || ref.Out != "peerA" {
|
|
t.Fatalf("want {1, peerA}, got {%d, %q}", ref.Count, ref.Out)
|
|
}
|
|
ref = mustIncrement(t, c, p, "peerB")
|
|
if ref.Count != 2 || ref.Out != "peerA" {
|
|
t.Fatalf("want {2, peerA}, got {%d, %q}", ref.Count, ref.Out)
|
|
}
|
|
}
|
|
|
|
// TestAllowedIPs_Flush removes everything installed and clears the counter.
|
|
func TestAllowedIPs_Flush(t *testing.T) {
|
|
f := newFakeWG()
|
|
c := f.counter()
|
|
p1 := mustPrefix(t, "10.44.8.0/24")
|
|
p2 := mustPrefix(t, "10.44.9.0/24")
|
|
|
|
mustIncrement(t, c, p1, "peerA")
|
|
mustIncrement(t, c, p2, "peerB")
|
|
|
|
if err := c.Flush(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(f.installed) != 0 {
|
|
t.Fatalf("expected all prefixes removed, got %v", f.installed)
|
|
}
|
|
// After flush, a fresh increment must add again.
|
|
mustIncrement(t, c, p1, "peerC")
|
|
if f.installed[p1] != "peerC" {
|
|
t.Fatalf("counter not reset after flush")
|
|
}
|
|
}
|
|
|
|
// TestAllowedIPs_SelfHealAfterSwapAddError ensures a failed add during a swap does not permanently
|
|
// strand the prefix: the next Decrement (or Increment) must retry and install a surviving peer.
|
|
func TestAllowedIPs_SelfHealAfterSwapAddError(t *testing.T) {
|
|
f := newFakeWG()
|
|
c := f.counter()
|
|
p := mustPrefix(t, "10.44.8.0/24")
|
|
|
|
mustIncrement(t, c, p, "peerA")
|
|
mustIncrement(t, c, p, "peerB")
|
|
mustIncrement(t, c, p, "peerC")
|
|
|
|
// Removing the active peerA triggers a swap to a survivor; make the add fail once.
|
|
f.failAdd = true
|
|
if _, err := c.Decrement(p, "peerA"); err == nil {
|
|
t.Fatalf("expected error from failed swap add")
|
|
}
|
|
if _, ok := f.installed[p]; ok {
|
|
t.Fatalf("nothing should be installed after a failed swap add, got %q", f.installed[p])
|
|
}
|
|
|
|
// A later Decrement of a non-active survivor must retry the hand-off (self-heal), not stay stuck.
|
|
ref := mustDecrement(t, c, p, "peerC")
|
|
if got := f.installed[p]; got == "" {
|
|
t.Fatalf("self-heal failed: prefix left unrouted after add recovered")
|
|
}
|
|
if ref.Out == "" {
|
|
t.Fatalf("expected an active peer after self-heal, got empty")
|
|
}
|
|
}
|
|
|
|
// TestAllowedIPs_SelfHealAfterRemoveError ensures a failed remove during a swap is retried instead
|
|
// of leaving e.active stuck on a peer that no longer holds references.
|
|
func TestAllowedIPs_SelfHealAfterRemoveError(t *testing.T) {
|
|
f := newFakeWG()
|
|
c := f.counter()
|
|
p := mustPrefix(t, "10.44.8.0/24")
|
|
|
|
mustIncrement(t, c, p, "peerA")
|
|
mustIncrement(t, c, p, "peerB")
|
|
|
|
// Releasing active peerA must detach it (remove) then add peerB; fail the remove once.
|
|
f.failRemove = true
|
|
if _, err := c.Decrement(p, "peerA"); err == nil {
|
|
t.Fatalf("expected error from failed remove")
|
|
}
|
|
|
|
// Next Decrement of the non-active survivor retries: removes stale peerA, installs peerB.
|
|
mustDecrement(t, c, p, "peerB")
|
|
// peerB had only one ref, so after retry the prefix is fully released.
|
|
if _, ok := f.installed[p]; ok {
|
|
t.Fatalf("expected prefix released after self-heal, still on %q", f.installed[p])
|
|
}
|
|
}
|