mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-19 04:59:56 +00:00
Two related fixes for the embedded netbird client and the per-account
inbound listeners that ride on its gVisor netstack.
client/internal/engine.go — replace hasIPv6Changed with reconcileIPv6:
- First v6 assignment (current had no v6, conf carries one) is applied
in place via WGIface.UpdateAddr instead of returning ErrResetConnection.
Pre-fix, every embedded client whose account had IPv6 enabled would
reset on its first NetworkMap sync — boot config has no v6, the sync
introduces one, the engine tore itself down to "apply" it. That
teardown destroys the gVisor netstack and orphans every listener
bound on it, which is what made the proxy's per-account :80/:443
silently stop accepting traffic.
- v6 removed clears in place.
- v6 swapped to a different non-empty value still resets (gVisor
netstack can't safely swap its address at runtime).
- Mutates e.config.WgAddr to match the applied state so subsequent
PeerConfig comparisons are stable.
proxy/internal/tcp/accept.go (new) + proxy/inbound.go +
proxy/internal/tcp/router.go — harden the two Accept() loops on
netstack-backed listeners:
- IsClosedListenerErr recognises net.ErrClosed AND gVisor's
"endpoint is in invalid state" — the latter survives gonet's
*net.OpError wrapping in a way errors.Is(.., net.ErrClosed) does
not. Without this the loop spins CPU-hot after the underlying
netstack is destroyed (peer rekey, embedded-client reset, account
churn), emitting one log line per iteration.
- AcceptBackoff implements the exponential backoff that
net/http.Server.Serve uses on transient Accept errors: 5ms doubling
up to 1s. Defence-in-depth so an unknown sticky error cannot burn
a CPU core even if IsClosedListenerErr misses its signature.
proxy/internal/roundtrip/netbird.go — emit a single structured INFO
line summarising every embed.Options flag (account_id, service_id,
public_key, management_url, wg_port, block_inbound, block_lan_access,
disable_ipv6, no_userspace, presence of credentials) when each
per-account embedded client is created. Secrets reduced to a "present"
boolean — never logged verbatim. Diagnostic-only; no behavior change,
but it makes the "why is this embedded peer misbehaving" loop a single
log read instead of a code dive.
Tests (real listeners, scripted errors, no mocks of production code):
- engine_reconcileipv6_test.go: 8 cases for every transition (first
assignment, no change, removed, prefix-length changed, value
changed, invalid bytes, UpdateAddr error) plus a updateConfig
integration check that the fix actually fires on a v6-added
PeerConfig.
- accept_test.go: IsClosedListenerErr matrix + AcceptBackoff
progression / cap / reset / cancel-during-wait / cancel-before-call.
- router_test.go, inbound_test.go: scriptedAcceptListener +
TestRouter_Serve_ExitsOnGVisorInvalidEndpoint and
TestFeedRouterFromListener_ExitsOnGVisorInvalidEndpoint —
regression guards that fail in 2 s if the loop ever spins.
306 lines
10 KiB
Go
306 lines
10 KiB
Go
package internal
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/netip"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
|
"github.com/netbirdio/netbird/client/internal/peer"
|
|
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
|
"github.com/netbirdio/netbird/shared/netiputil"
|
|
)
|
|
|
|
// reconcileIPv6 / updateConfig regression suite. Locks down the behavior that
|
|
// PR #5631 (main-side IPv6 overlay support) accidentally broke for embedded
|
|
// netstack clients: any first NetworkMap update that brings an IPv6 address
|
|
// used to trigger ErrResetConnection, which destroys the netstack and orphans
|
|
// every listener bound on it (proxy-side inbound listeners in particular).
|
|
// The fix in reconcileIPv6 distinguishes "v6 first-assigned" (apply in place)
|
|
// from "v6 swapped value" (must reset).
|
|
|
|
func mustEncodeV6Prefix(t *testing.T, p netip.Prefix) []byte {
|
|
t.Helper()
|
|
b, err := netiputil.EncodePrefix(p)
|
|
require.NoError(t, err, "encode v6 prefix %s", p)
|
|
return b
|
|
}
|
|
|
|
// reconcileIPv6Fixture builds the smallest Engine the function under test
|
|
// needs: a config (with WgAddr being the load-bearing field) and a wgInterface
|
|
// whose UpdateAddr call we can observe.
|
|
func reconcileIPv6Fixture(t *testing.T, initial wgaddr.Address) (*Engine, *MockWGIface, *wgaddr.Address) {
|
|
t.Helper()
|
|
var applied wgaddr.Address
|
|
mock := &MockWGIface{
|
|
AddressFunc: func() wgaddr.Address { return initial },
|
|
UpdateAddrFunc: func(a wgaddr.Address) error {
|
|
applied = a
|
|
return nil
|
|
},
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
e := &Engine{
|
|
ctx: ctx,
|
|
clientCtx: ctx,
|
|
clientCancel: cancel,
|
|
config: &EngineConfig{WgAddr: initial},
|
|
wgInterface: mock,
|
|
syncMsgMux: &sync.Mutex{},
|
|
}
|
|
return e, mock, &applied
|
|
}
|
|
|
|
func TestReconcileIPv6_FirstAssignment_AppliesInPlace(t *testing.T) {
|
|
// Embedded clients boot v4-only; management later assigns a v6 overlay.
|
|
// The fix: apply v6 in place, return reset=false. Pre-fix this case
|
|
// fell through to the "v6 changed" branch and reset the engine.
|
|
v4 := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
|
e, mock, applied := reconcileIPv6Fixture(t, v4)
|
|
|
|
v6Prefix := netip.MustParsePrefix("fd00::1/64")
|
|
conf := &mgmtProto.PeerConfig{
|
|
Address: v4.String(),
|
|
AddressV6: mustEncodeV6Prefix(t, v6Prefix),
|
|
}
|
|
|
|
reset, err := e.reconcileIPv6(conf)
|
|
require.NoError(t, err)
|
|
assert.False(t, reset, "first v6 assignment must NOT request an engine reset")
|
|
|
|
require.True(t, e.config.WgAddr.HasIPv6(), "engine config must record the new v6")
|
|
assert.Equal(t, v6Prefix.Addr(), e.config.WgAddr.IPv6, "engine config v6 address must match")
|
|
assert.Equal(t, v6Prefix.Masked(), e.config.WgAddr.IPv6Net, "engine config v6 prefix must match")
|
|
|
|
require.True(t, applied.HasIPv6(), "WGIface.UpdateAddr must be called with v6 populated")
|
|
assert.Equal(t, v6Prefix.Addr(), applied.IPv6, "UpdateAddr must carry the new v6")
|
|
_ = mock
|
|
}
|
|
|
|
func TestReconcileIPv6_NoChange_NoOp(t *testing.T) {
|
|
// Steady state: management redelivers the same PeerConfig. No interface
|
|
// mutation, no reset. Guards against an infinite reset loop if the
|
|
// comparison ever drifts (e.g. address-vs-prefix masking bugs).
|
|
v6Prefix := netip.MustParsePrefix("fd00::1/64")
|
|
addr := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
|
require.NoError(t, addr.SetIPv6FromCompact(mustEncodeV6Prefix(t, v6Prefix)))
|
|
|
|
updateAddrCalled := false
|
|
mock := &MockWGIface{
|
|
AddressFunc: func() wgaddr.Address { return addr },
|
|
UpdateAddrFunc: func(a wgaddr.Address) error {
|
|
updateAddrCalled = true
|
|
return nil
|
|
},
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
e := &Engine{
|
|
ctx: ctx,
|
|
clientCtx: ctx,
|
|
clientCancel: cancel,
|
|
config: &EngineConfig{WgAddr: addr},
|
|
wgInterface: mock,
|
|
syncMsgMux: &sync.Mutex{},
|
|
}
|
|
|
|
conf := &mgmtProto.PeerConfig{
|
|
Address: addr.String(),
|
|
AddressV6: mustEncodeV6Prefix(t, v6Prefix),
|
|
}
|
|
reset, err := e.reconcileIPv6(conf)
|
|
require.NoError(t, err)
|
|
assert.False(t, reset, "unchanged v6 must NOT trigger reset")
|
|
assert.False(t, updateAddrCalled, "unchanged v6 must NOT call UpdateAddr")
|
|
}
|
|
|
|
func TestReconcileIPv6_Removed_AppliesInPlace(t *testing.T) {
|
|
// Management withdraws v6 (e.g. account toggled off the v6 group).
|
|
// Cleared in place, no reset.
|
|
v6Prefix := netip.MustParsePrefix("fd00::1/64")
|
|
addr := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
|
require.NoError(t, addr.SetIPv6FromCompact(mustEncodeV6Prefix(t, v6Prefix)))
|
|
|
|
e, _, applied := reconcileIPv6Fixture(t, addr)
|
|
e.config.WgAddr = addr
|
|
|
|
conf := &mgmtProto.PeerConfig{
|
|
Address: addr.String(),
|
|
AddressV6: nil,
|
|
}
|
|
reset, err := e.reconcileIPv6(conf)
|
|
require.NoError(t, err)
|
|
assert.False(t, reset, "v6 removed must NOT trigger reset")
|
|
|
|
assert.False(t, e.config.WgAddr.HasIPv6(), "engine config must reflect v6 cleared")
|
|
assert.False(t, applied.HasIPv6(), "UpdateAddr must receive cleared v6")
|
|
}
|
|
|
|
func TestReconcileIPv6_PrefixLengthChanged_RequestsReset(t *testing.T) {
|
|
// Same v6 host, different mask (e.g. /64 → /80). Treated like a value
|
|
// change because the new netmask redefines the broadcast/scope.
|
|
oldPrefix := netip.MustParsePrefix("fd00::1/64")
|
|
newPrefix := netip.MustParsePrefix("fd00::1/80")
|
|
|
|
addr := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
|
require.NoError(t, addr.SetIPv6FromCompact(mustEncodeV6Prefix(t, oldPrefix)))
|
|
|
|
updateAddrCalled := false
|
|
mock := &MockWGIface{
|
|
AddressFunc: func() wgaddr.Address { return addr },
|
|
UpdateAddrFunc: func(a wgaddr.Address) error {
|
|
updateAddrCalled = true
|
|
return nil
|
|
},
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
e := &Engine{
|
|
ctx: ctx,
|
|
clientCtx: ctx,
|
|
clientCancel: cancel,
|
|
config: &EngineConfig{WgAddr: addr},
|
|
wgInterface: mock,
|
|
syncMsgMux: &sync.Mutex{},
|
|
}
|
|
|
|
conf := &mgmtProto.PeerConfig{
|
|
Address: addr.String(),
|
|
AddressV6: mustEncodeV6Prefix(t, newPrefix),
|
|
}
|
|
reset, err := e.reconcileIPv6(conf)
|
|
require.NoError(t, err)
|
|
assert.True(t, reset, "v6 prefix length change must request a reset")
|
|
assert.False(t, updateAddrCalled, "v6 prefix length change must NOT touch the interface")
|
|
}
|
|
|
|
func TestReconcileIPv6_ValueChanged_RequestsReset(t *testing.T) {
|
|
// v6 was X, now Y. The netstack backend can't safely swap an existing
|
|
// address in place — fall back to the engine recreate path.
|
|
oldPrefix := netip.MustParsePrefix("fd00::1/64")
|
|
newPrefix := netip.MustParsePrefix("fd00::2/64")
|
|
|
|
addr := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
|
require.NoError(t, addr.SetIPv6FromCompact(mustEncodeV6Prefix(t, oldPrefix)))
|
|
|
|
updateAddrCalled := false
|
|
mock := &MockWGIface{
|
|
AddressFunc: func() wgaddr.Address { return addr },
|
|
UpdateAddrFunc: func(a wgaddr.Address) error {
|
|
updateAddrCalled = true
|
|
return nil
|
|
},
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
e := &Engine{
|
|
ctx: ctx,
|
|
clientCtx: ctx,
|
|
clientCancel: cancel,
|
|
config: &EngineConfig{WgAddr: addr},
|
|
wgInterface: mock,
|
|
syncMsgMux: &sync.Mutex{},
|
|
}
|
|
|
|
conf := &mgmtProto.PeerConfig{
|
|
Address: addr.String(),
|
|
AddressV6: mustEncodeV6Prefix(t, newPrefix),
|
|
}
|
|
reset, err := e.reconcileIPv6(conf)
|
|
require.NoError(t, err)
|
|
assert.True(t, reset, "v6 value change must request a reset")
|
|
assert.False(t, updateAddrCalled,
|
|
"v6 value change must NOT call UpdateAddr — caller will recreate the interface")
|
|
}
|
|
|
|
func TestReconcileIPv6_InvalidBytes_ReturnsError(t *testing.T) {
|
|
// Corrupt PeerConfig.AddressV6 must not crash the engine and must not
|
|
// trigger a spurious reset.
|
|
v4 := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
|
e, _, applied := reconcileIPv6Fixture(t, v4)
|
|
|
|
conf := &mgmtProto.PeerConfig{
|
|
Address: v4.String(),
|
|
AddressV6: []byte{0x00}, // truncated, definitely not a valid prefix
|
|
}
|
|
reset, err := e.reconcileIPv6(conf)
|
|
require.Error(t, err, "malformed v6 bytes must surface an error")
|
|
assert.False(t, reset, "decode error must NOT request a reset")
|
|
assert.False(t, applied.HasIPv6(), "decode error must NOT touch the interface")
|
|
}
|
|
|
|
func TestReconcileIPv6_UpdateAddrError_DoesNotPropagateReset(t *testing.T) {
|
|
// If WGIface.UpdateAddr fails (e.g. OS-side assignment error on a
|
|
// kernel device), reconcileIPv6 returns the error to the caller for
|
|
// logging — but it must NOT request a reset. The whole point of the
|
|
// fix is to AVOID the reset cascade on v6 transitions.
|
|
v4 := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
|
mock := &MockWGIface{
|
|
AddressFunc: func() wgaddr.Address { return v4 },
|
|
UpdateAddrFunc: func(_ wgaddr.Address) error { return errors.New("os refused address") },
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
e := &Engine{
|
|
ctx: ctx,
|
|
clientCtx: ctx,
|
|
clientCancel: cancel,
|
|
config: &EngineConfig{WgAddr: v4},
|
|
wgInterface: mock,
|
|
syncMsgMux: &sync.Mutex{},
|
|
}
|
|
|
|
v6Prefix := netip.MustParsePrefix("fd00::1/64")
|
|
conf := &mgmtProto.PeerConfig{
|
|
Address: v4.String(),
|
|
AddressV6: mustEncodeV6Prefix(t, v6Prefix),
|
|
}
|
|
reset, err := e.reconcileIPv6(conf)
|
|
require.Error(t, err, "UpdateAddr failure must surface")
|
|
assert.False(t, reset, "UpdateAddr failure must NOT request a reset")
|
|
}
|
|
|
|
func TestUpdateConfig_V6FirstAssignment_DoesNotResetEngine(t *testing.T) {
|
|
// The integration check: updateConfig must not return ErrResetConnection
|
|
// when the only change between current state and the new PeerConfig is
|
|
// "v6 added". Pre-fix this returned ErrResetConnection, tearing down
|
|
// every listener bound on the engine's netstack.
|
|
v4 := wgaddr.MustParseWGAddress("100.64.0.1/16")
|
|
mock := &MockWGIface{
|
|
AddressFunc: func() wgaddr.Address { return v4 },
|
|
UpdateAddrFunc: func(_ wgaddr.Address) error { return nil },
|
|
IsUserspaceBindFunc: func() bool { return true },
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
e := &Engine{
|
|
ctx: ctx,
|
|
clientCtx: ctx,
|
|
clientCancel: cancel,
|
|
config: &EngineConfig{WgAddr: v4, WgPort: 51820},
|
|
wgInterface: mock,
|
|
syncMsgMux: &sync.Mutex{},
|
|
statusRecorder: peer.NewRecorder("https://mgm.test"),
|
|
}
|
|
|
|
v6Prefix := netip.MustParsePrefix("fd00::1/64")
|
|
conf := &mgmtProto.PeerConfig{
|
|
Address: v4.String(),
|
|
AddressV6: mustEncodeV6Prefix(t, v6Prefix),
|
|
}
|
|
|
|
err := e.updateConfig(conf)
|
|
assert.NoError(t, err,
|
|
"updateConfig MUST NOT return ErrResetConnection when v6 is added for the first time — that's the bug fix")
|
|
assert.NotErrorIs(t, err, ErrResetConnection)
|
|
|
|
require.True(t, e.config.WgAddr.HasIPv6(), "engine config must record the assigned v6 after updateConfig")
|
|
assert.Equal(t, v6Prefix.Addr(), e.config.WgAddr.IPv6)
|
|
}
|