Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions client/internal/peer/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -803,17 +803,15 @@ func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) {
}

func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) {
if !conn.wgWatcher.PrepareInitialHandshake() {
return
if !conn.wgWatcher.IsEnabled() {
wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx)
conn.wgWatcherCancel = wgWatcherCancel
conn.wgWatcherWg.Add(1)
go func() {
defer conn.wgWatcherWg.Done()
conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess)
}()
}

wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx)
conn.wgWatcherCancel = wgWatcherCancel
conn.wgWatcherWg.Add(1)
go func() {
defer conn.wgWatcherWg.Done()
conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess)
}()
}

func (conn *Conn) disableWgWatcherIfNeeded() {
Expand Down Expand Up @@ -932,18 +930,22 @@ func (conn *Conn) AgentVersionString() string {

func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
if conn.config.RosenpassConfig.PubKey == nil {
conn.Log.Warnf("PSK-DIAG: rosenpass off -> static PSK (present=%v)", conn.config.WgConfig.PreSharedKey != nil)
return conn.config.WgConfig.PreSharedKey
}

if remoteRosenpassKey == nil && conn.config.RosenpassConfig.PermissiveMode {
conn.Log.Warnf("PSK-DIAG: rosenpass permissive + no remote RP key -> static PSK bridge (present=%v)", conn.config.WgConfig.PreSharedKey != nil)
return conn.config.WgConfig.PreSharedKey
}

// If Rosenpass has already set a PSK for this peer, return nil to prevent
// UpdatePeer from overwriting the Rosenpass-managed key.
if conn.rosenpassInitializedPresharedKeyValidator != nil && conn.rosenpassInitializedPresharedKeyValidator(conn.config.Key) {
conn.Log.Warnf("PSK-DIAG: rosenpass initialized -> returning nil (keep existing on-wire PSK; NOT re-set)")
return nil
}
conn.Log.Warnf("PSK-DIAG: rosenpass strict, not yet initialized -> seeding PSK (staticPresent=%v)", conn.config.WgConfig.PreSharedKey != nil)

// Use NetBird PSK as the seed for Rosenpass. This same PSK is passed to
// Rosenpass as PeerConfig.PresharedKey, ensuring the derived post-quantum
Expand Down
6 changes: 6 additions & 0 deletions client/internal/peer/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ func (e *EndpointUpdater) ConfigureWGEndpoint(addr *net.UDPAddr, presharedKey *w
e.mu.Lock()
defer e.mu.Unlock()

e.log.Warnf("PSK-DIAG: ConfigureWGEndpoint endpoint=%s psk_present=%v", addr, presharedKey != nil)

if e.initiator {
e.log.Debugf("configure up WireGuard as initiator")
return e.configureAsInitiator(addr, presharedKey)
Expand All @@ -51,6 +53,8 @@ func (e *EndpointUpdater) SwitchWGEndpoint(addr *net.UDPAddr, presharedKey *wgty
e.mu.Lock()
defer e.mu.Unlock()

e.log.Warnf("PSK-DIAG: SwitchWGEndpoint endpoint=%s psk_present=%v", addr, presharedKey != nil)

// prevent to run new update while cancel the previous update
e.waitForCloseTheDelayedUpdate()

Expand All @@ -69,6 +73,8 @@ func (e *EndpointUpdater) RemoveEndpointAddress() error {
e.mu.Lock()
defer e.mu.Unlock()

e.log.Warnf("PSK-DIAG: RemoveEndpointAddress -> peer re-added with only PublicKey+AllowedIPs; on-wire PSK is dropped")

e.waitForCloseTheDelayedUpdate()
return e.wgConfig.WgInterface.RemoveEndpointAddress(e.wgConfig.RemoteKey)
}
Expand Down
52 changes: 26 additions & 26 deletions client/internal/peer/wg_watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ type WGWatcher struct {
stateDump *stateDump

enabled bool
muEnabled sync.Mutex
// initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently.
initialHandshake time.Time
muEnabled sync.RWMutex

resetCh chan struct{}
}
Expand All @@ -48,38 +46,40 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin
}
}

// PrepareInitialHandshake reserves the watcher and reads the peer's current WireGuard
// handshake time. It must be called before the peer is (re)configured on the WireGuard
// interface, so the captured baseline reflects the state prior to this connection attempt
// instead of racing with that configuration. Returns ok=false if the watcher is already
// running, in which case EnableWgWatcher must not be called.
func (w *WGWatcher) PrepareInitialHandshake() (ok bool) {
// EnableWgWatcher starts the WireGuard watcher. If it is already enabled, it will return immediately and do nothing.
// The watcher runs until ctx is cancelled. Caller is responsible for context lifecycle management.
// NOTE: reverted to the pre-#6626 shape for bisecting the NHN issue.
func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) {
w.muEnabled.Lock()
if w.enabled {
w.muEnabled.Unlock()
return false
return
}

w.log.Debugf("enable WireGuard watcher")
w.enabled = true
w.muEnabled.Unlock()

handshake, _ := w.wgState()
w.initialHandshake = handshake
return true
}
initialHandshake, err := w.wgState()
if err != nil {
w.log.Warnf("failed to read initial wg stats: %v", err)
}
w.log.Warnf("PSK-DIAG: watcher baseline handshake=%v (zero=%v) [pre-6626 revert]", initialHandshake, initialHandshake.IsZero())

// EnableWgWatcher runs the WireGuard watcher loop using the handshake baseline captured by
// PrepareInitialHandshake. The watcher runs until ctx is cancelled. Caller is responsible
// for context lifecycle management.
func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) {
w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, w.initialHandshake)
w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, initialHandshake)

w.muEnabled.Lock()
w.enabled = false
w.muEnabled.Unlock()
}

// IsEnabled returns true if the WireGuard watcher is currently enabled
func (w *WGWatcher) IsEnabled() bool {
w.muEnabled.RLock()
defer w.muEnabled.RUnlock()
return w.enabled
}

// Reset signals the watcher that the WireGuard peer has been reset and a new
// handshake is expected. This restarts the handshake timeout from scratch.
func (w *WGWatcher) Reset() {
Expand All @@ -92,6 +92,7 @@ func (w *WGWatcher) Reset() {
// wgStateCheck help to check the state of the WireGuard handshake and relay connection
func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), enabledTime time.Time, initialHandshake time.Time) {
w.log.Infof("WireGuard watcher started")
w.log.Warnf("WGW-DIAG: watcher started id=%d baseline=%v (zero=%v) firstCheckIn=%v", enabledTime.UnixNano(), initialHandshake, initialHandshake.IsZero(), wgHandshakeOvertime)

timer := time.NewTimer(wgHandshakeOvertime)
defer timer.Stop()
Expand All @@ -101,18 +102,17 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn
for {
select {
case <-timer.C:
w.log.Warnf("WGW-DIAG: check fire id=%d lastHandshake=%v", enabledTime.UnixNano(), lastHandshake)
handshake, ok := w.handshakeCheck(lastHandshake)
if !ok {
if ctx.Err() != nil {
return
}
w.log.Warnf("WGW-DIAG: check failed -> firing onDisconnected (TEARDOWN, pre-6626 no ctx-recheck) id=%d", enabledTime.UnixNano())
onDisconnectedFn()
return
}
if lastHandshake.IsZero() {
elapsed := calcElapsed(enabledTime, *handshake)
w.log.Infof("first wg handshake detected within: %.2fsec, (%s)", elapsed, handshake)
if onHandshakeSuccessFn != nil && ctx.Err() == nil {
if onHandshakeSuccessFn != nil {
onHandshakeSuccessFn(*handshake)
}
}
Expand All @@ -123,15 +123,15 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn
timer.Reset(resetTime)
w.stateDump.WGcheckSuccess()

w.log.Debugf("WireGuard watcher reset timer: %v", resetTime)
w.log.Warnf("WGW-DIAG: check ok id=%d handshake=%v nextCheckIn=%v", enabledTime.UnixNano(), handshake, resetTime)
case <-w.resetCh:
w.log.Infof("WireGuard watcher received peer reset, restarting handshake timeout")
w.log.Warnf("WGW-DIAG: peer reset received, restarting timeout id=%d", enabledTime.UnixNano())
lastHandshake = time.Time{}
enabledTime = time.Now()
timer.Stop()
timer.Reset(wgHandshakeOvertime)
case <-ctx.Done():
w.log.Infof("WireGuard watcher stopped")
w.log.Warnf("WGW-DIAG: watcher stopped (ctx done) id=%d", enabledTime.UnixNano())
return
}
}
Expand Down
10 changes: 0 additions & 10 deletions client/internal/peer/wg_watcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"time"

log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"

"github.com/netbirdio/netbird/client/iface/configurer"
)
Expand Down Expand Up @@ -35,9 +34,6 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

ok := watcher.PrepareInitialHandshake()
require.True(t, ok, "watcher should not be enabled yet")

onDisconnected := make(chan struct{}, 1)
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
mlog.Infof("onDisconnectedFn")
Expand Down Expand Up @@ -66,9 +62,6 @@ func TestWGWatcher_ReEnable(t *testing.T) {
watcher := NewWGWatcher(mlog, mocWgIface, "", newStateDump("peer", mlog, &Status{}))

ctx, cancel := context.WithCancel(context.Background())
ok := watcher.PrepareInitialHandshake()
require.True(t, ok, "watcher should not be enabled yet")

wg := &sync.WaitGroup{}
wg.Add(1)
go func() {
Expand All @@ -83,9 +76,6 @@ func TestWGWatcher_ReEnable(t *testing.T) {
ctx, cancel = context.WithCancel(context.Background())
defer cancel()

ok = watcher.PrepareInitialHandshake()
require.True(t, ok, "watcher should be re-enabled after the previous run stopped")

onDisconnected := make(chan struct{}, 1)
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
onDisconnected <- struct{}{}
Expand Down
30 changes: 16 additions & 14 deletions client/internal/rosenpass/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,15 @@ func (m *Manager) GetPubKey() []byte {
return m.spk
}

// GetAddress returns the address of the Rosenpass server
// GetAddress returns the address of the Rosenpass server.
//
// The server binds v4-only (0.0.0.0). Rosenpass reaches peers over their
// WireGuard overlay IP, which is always IPv4, so a v4 socket suffices. This
// avoids the AF_INET6 -> IPv4 send rejection (EDESTADDRREQ) that a [::]
// (dual-stack) socket hits on macOS/BSD when sending to a 4-byte IPv4
// destination.
func (m *Manager) GetAddress() *net.UDPAddr {
return &net.UDPAddr{Port: m.port}
return &net.UDPAddr{IP: net.IPv4zero, Port: m.port}
}

// addPeer adds a new peer to the Rosenpass server
Expand All @@ -109,20 +115,14 @@ func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuar
if err != nil {
return fmt.Errorf("failed to parse rosenpass address: %w", err)
}
// Resolve as udp4: our Rosenpass server binds v4-only (see GetAddress)
// and the peer WireGuard overlay IP is always IPv4. This keeps the
// destination a 4-byte IPv4 address that matches our v4 listening
// socket, avoiding the AF_INET6 -> IPv4 send rejection on macOS/BSD.
peerAddr := net.JoinHostPort(wireGuardIP, strPort)
if pcfg.Endpoint, err = net.ResolveUDPAddr("udp", peerAddr); err != nil {
if pcfg.Endpoint, err = net.ResolveUDPAddr("udp4", peerAddr); err != nil {
return fmt.Errorf("failed to resolve peer endpoint address: %w", err)
}
Comment on lines +118 to 125

@coderabbitai coderabbitai Bot Jul 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find how AllowedIps is populated to confirm the overlay address is always IPv4.
rg -nP -C3 '\bAllowedIps\b' --type=go client/internal/peer client/internal

Repository: netbirdio/netbird

Length of output: 13890


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the code path that constructs AllowedIps and any address-family handling.
sed -n '1720,1785p' client/internal/engine.go
printf '\n---\n'
sed -n '1,140p' client/internal/peer/conn.go
printf '\n---\n'
rg -n "ParsePrefix|ParseAddr|AllowedIps:|AllowedIP|IPv6|v6|v4-only|inet6|inet" client/internal -g '*.go'

Repository: netbirdio/netbird

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Focus on the rosenpass setup and the allowedIPs builder.
rg -n -C4 "AllowedIps\\[0\\]|Addr\\(\\)\\.AsSlice\\(|allowedIPs|rosenpass" client/internal/engine.go client/internal/rosenpass/manager.go client/internal/peer/conn.go

Repository: netbirdio/netbird

Length of output: 27828


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for explicit statements about rosenpass and IPv6/IPv4-only assumptions.
rg -n -C3 "rosenpass.*v6|v6.*rosenpass|IPv4-only.*rosenpass|AllowedIps\\[0\\].*always v4|always v4" .

Repository: netbirdio/netbird

Length of output: 648


Use the peer IP family here instead of hardcoding udp4. AllowedIps can include IPv6 when the interface has IPv6, so this can reject valid peers and skip Rosenpass setup; switch to udp6/udp4 based on wireGuardIP, or reject v6 earlier if Rosenpass must stay v4-only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/internal/rosenpass/manager.go` around lines 118 - 125, The Rosenpass
endpoint resolution in manager.go is hardcoded to udp4, which can break valid
IPv6 peers when wireGuardIP is v6. Update the endpoint setup in the code that
builds pcfg.Endpoint from peerAddr to choose udp4 or udp6 based on the peer IP
family, or fail earlier with a clear error if Rosenpass must remain IPv4-only.
Keep the change localized to the peer address resolution path around
net.ResolveUDPAddr so the selected protocol matches wireGuardIP.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai ipv4 should always exist, while V6 should be only added as optional, so this should work. Do you see other possible places where V6 is uniquely used?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "### ResolveUDPAddr/DialUDP calls across client/internal ###"
rg -n '\bResolveUDPAddr\(|\bDialUDP\(|\bListenUDP\(' --type=go client/internal

echo
echo "### IPv6-related comments/handling ###"
rg -n -i 'ipv6|v6zero|IPv4zero|dual.?stack|AF_INET6' --type=go client/internal | grep -v _test.go

echo
echo "### WireGuard interface address assignment (may include v6) ###"
rg -n -C3 'wgIface\.Address|CreateOnAndroid|wgaddr|WGAddress' --type=go client/internal/iface client/internal/peer 2>/dev/null | head -100

echo
echo "### Any other udp4/udp6 usage ###"
rg -n '"udp4"|"udp6"|"udp"' --type=go client/internal

Length of output: 29244


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C5 '\.OnConnected\(' --type=go client/internal

Length of output: 3289


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C6 'OnConnected' client/internal/peer/conn.go client/internal/engine.go 2>/dev/null
echo "---"
rg -n 'rosenpassManager\.OnConnected|RosenpassManager\b|rpManager' --type=go client/internal

Length of output: 6135


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '744,775p' client/internal/peer/conn.go
echo "---config struct---"
rg -n 'WgConfig|Address\b' client/internal/peer/conn.go | head -30

Length of output: 2967


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C8 'AllowedIps:\s*\[\]netip\.Prefix|AllowedIps = ' client/internal/engine.go

Length of output: 421


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C6 'AllowedIps' client/internal/engine.go | sed -n '1,160p'

Length of output: 4303


@riccardomanfrin Confirmed — I don't see any other place where IPv6 is uniquely used for Rosenpass or the PSK path.

I traced the call chain: wireGuardIP passed into Manager.OnConnected/addPeer originates from conn.config.WgConfig.AllowedIps[0].Addr().String() in client/internal/peer/conn.go (Line 753). This same invariant — "AllowedIps[0] is always v4" — is already explicitly relied upon elsewhere in the codebase, e.g. client/internal/engine.go (Line 699):

// IPv4-only: rosenpass peers connect via AllowedIps[0] which is always v4.

IPv6, when present, is always handled as a distinct, separate address (via overlayAddrsFromAllowedIPs, PeerConfig.AddressV6), never as AllowedIps[0] or as the sole overlay address. So the udp4 hardcoding in addPeer is safe and consistent with the existing convention — no other Rosenpass/PSK code path depends on IPv6 uniquely. My original concern doesn't hold up against this invariant; withdrawing it.

(๑•̀ㅂ•́)و good catch confirming the design invariant


🧬 Code Graph Analysis Results

client/internal/rosenpass/manager.go

IPv4-only Rosenpass listener + rationale (mentions IPv6-related failure mode):
Lines 80-89:

// GetAddress returns the address of the Rosenpass server.
//
// The server binds v4-only (0.0.0.0). Rosenpass reaches peers over their
// WireGuard overlay IP, which is always IPv4, so a v4 socket suffices. This
// avoids the AF_INET6 -> IPv4 send rejection (EDESTADDRREQ) that a [::]
// (dual-stack) socket hits on macOS/BSD when sending to a 4-byte IPv4
// destination.
func (m *Manager) GetAddress() *net.UDPAddr {
	return &net.UDPAddr{IP: net.IPv4zero, Port: m.port}
}

Peer endpoint resolution forced to IPv4 (“udp4”) (also notes “WireGuard overlay IP is always IPv4”):
Lines 91-121:

// addPeer adds a new peer to the Rosenpass server
func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuardIP string, wireGuardPubKey string) error {
	// Defense in depth against issue `#4341` (Android crash): if Run() has not
	// completed yet, m.server / m.rpWgHandler may be nil. Return an explicit
	// error instead of panicking on nil-receiver dereference.
	if m.server == nil {
		return fmt.Errorf("rosenpass server not initialized")
	}
	if m.rpWgHandler == nil {
		return fmt.Errorf("rosenpass wg handler not initialized")
	}

	var err error
	pcfg := rp.PeerConfig{PublicKey: rosenpassPubKey}
	if m.preSharedKey != nil {
		pcfg.PresharedKey = *m.preSharedKey
	}
	if bytes.Compare(m.spk, rosenpassPubKey) == 1 {
		_, strPort, err := net.SplitHostPort(rosenpassAddr)
		if err != nil {
			return fmt.Errorf("failed to parse rosenpass address: %w", err)
		}
		// Resolve as udp4: our Rosenpass server binds v4-only (see GetAddress)
		// and the peer WireGuard overlay IP is always IPv4. This keeps the
		// destination a 4-byte IPv4 address that matches our v4 listening
		// socket, avoiding the AF_INET6 -> IPv4 send rejection on macOS/BSD.
		peerAddr := net.JoinHostPort(wireGuardIP, strPort)
		if pcfg.Endpoint, err = net.ResolveUDPAddr("udp4", peerAddr); err != nil {
			return fmt.Errorf("failed to resolve peer endpoint address: %w", err)
		}
	}

Config wiring uses the IPv4 listener returned by GetAddress():
Lines 146-177 (relevant excerpt):

	cfg.ListenAddrs = []*net.UDPAddr{m.GetAddress()}

	return cfg, nil
}

Random UDP port selection uses an IPv4 bind address (net.IPv4zero):
Lines 300-312:

func findRandomAvailableUDPPort() (int, error) {
	conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
	if err != nil {
		return 0, fmt.Errorf("could not find an available UDP port: %w", err)
	}
	defer conn.Close()

	_, portStr, err := net.SplitHostPort(conn.LocalAddr().String())
	if err != nil {
		return 0, fmt.Errorf("parse local address %s: %w", conn.LocalAddr(), err)
	}
	return strconv.Atoi(portStr)
}

shared/management/proto/management.pb.go

PeerConfig includes an IPv6 overlay address field (AddressV6) which may be optional/unique vs IPv4 (Address):
Lines 2413-2433:

PeerConfig struct {
	state         protoimpl.MessageState
	sizeCache     protoimpl.SizeCache
	unknownFields protoimpl.UnknownFields

	// Peer's virtual IP address within the Netbird VPN (a Wireguard address config)
	Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"`
	// Netbird DNS server (a Wireguard DNS config)
	Dns string `protobuf:"bytes,2,opt,name=dns,proto3" json:"dns,omitempty"`
	// SSHConfig of the peer.
	SshConfig *SSHConfig `protobuf:"bytes,3,opt,name=sshConfig,proto3" json:"sshConfig,omitempty"`
	// Peer fully qualified domain name
	Fqdn                            string `protobuf:"bytes,4,opt,name=fqdn,proto3" json:"fqdn,omitempty"`
	RoutingPeerDnsResolutionEnabled bool   `protobuf:"varint,5,opt,name=RoutingPeerDnsResolutionEnabled,proto3" json:"RoutingPeerDnsResolutionEnabled,omitempty"`
	LazyConnectionEnabled           bool   `protobuf:"varint,6,opt,name=LazyConnectionEnabled,proto3" json:"LazyConnectionEnabled,omitempty"`
	Mtu                             int32  `protobuf:"varint,7,opt,name=mtu,proto3" json:"mtu,omitempty"`
	// Auto-update config
	AutoUpdate *AutoUpdateSettings `protobuf:"bytes,8,opt,name=autoUpdate,proto3" json:"autoUpdate,omitempty"`
	// IPv6 overlay address as compact bytes: 16 bytes IP + 1 byte prefix length.
	AddressV6 []byte `protobuf:"bytes,9,opt,name=address_v6,json=addressV6,proto3" json:"address_v6,omitempty"`
}

client/embed/embed.go

UDP listener creation uses net.ResolveUDPAddr("udp", ...) (protocol family resolution depends on OS/name/address):
Lines 391-409:

func (c *Client) ListenUDP(address string) (net.PacketConn, error) {
	nsnet, addr, err := c.getNet()
	if err != nil {
		return nil, err
	}

	_, port, err := net.SplitHostPort(address)
	if err != nil {
		return nil, fmt.Errorf("split host port: %w", err)
	}
	listenAddr := net.JoinHostPort(addr.String(), port)

	udpAddr, err := net.ResolveUDPAddr("udp", listenAddr)
	if err != nil {
		return nil, fmt.Errorf("resolve: %w", err)
	}

	return nsnet.ListenUDP(udpAddr)
}

✏️ Learnings added
Learnt from: riccardomanfrin
Repo: netbirdio/netbird PR: 6681
File: client/internal/rosenpass/manager.go:118-125
Timestamp: 2026-07-07T09:14:51.521Z
Learning: In netbirdio/netbird client code, `WgConfig.AllowedIps[0]` is guaranteed to always be the peer's IPv4 overlay address; IPv6 (when present) is never placed there and is instead carried/handled separately (e.g., via `PeerConfig.AddressV6` and `overlayAddrsFromAllowedIPs`). This invariant is explicitly relied upon in `client/internal/engine.go` (firewall peer filtering, "IPv4-only: rosenpass peers connect via AllowedIps[0] which is always v4.") and in `client/internal/rosenpass/manager.go`'s `addPeer`, which resolves the peer's Rosenpass endpoint using `net.ResolveUDPAddr("udp4", ...)` based on this v4-only AllowedIps[0] address. Do not flag this `udp4` hardcoding as an IPv6-compatibility bug.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: bcmmbaga
Repo: netbirdio/netbird PR: 5441
File: management/server/user.go:745-749
Timestamp: 2026-02-24T19:32:13.189Z
Learning: In Go codebases like netbirdio/netbird, methods returning (T, error) should follow the convention: if an error is returned, propagate it and return early. When the caller handles the error immediately, explicit nil checks on the returned value are unnecessary; rely on the error to guard control flow and only inspect the value when err is nil. This guidance applies broadly to Store methods such as GetUserByUserID in Go code; prefer early return on error and avoid redundant nil checks after a successful error path.

Learnt from: lixmal
Repo: netbirdio/netbird PR: 5530
File: proxy/internal/udp/relay.go:276-278
Timestamp: 2026-03-07T13:59:16.506Z
Learning: For Go 1.25+ projects, use sync.WaitGroup.Go() to run goroutines because it handles Add(1) and defer Done() automatically. Do not flag wg.Go(...) as a compilation error in codebases that require Go 1.25 or newer. If supporting older Go versions, gate such usage with build tags or avoid wg.Go() to maintain compatibility; verify the module targets Go 1.25+ before adopting this pattern.

Learnt from: lixmal
Repo: netbirdio/netbird PR: 5688
File: client/firewall/uspfilter/forwarder/icmp.go:268-287
Timestamp: 2026-03-25T09:21:53.814Z
Learning: When using gVisor’s `header.ICMPv6Checksum(ICMPv6ChecksumParams)` (`gvisor.dev/gvisor/pkg/tcpip/header`), the pseudo-header checksum is computed internally from `Src`, `Dst`, and `Header`. Use `PayloadCsum`/`PayloadLen` only for scattered-buffer cases where the ICMPv6 payload bytes are not included in `Header`. If `Header` already contains the complete ICMPv6 message (ICMPv6 header + all data), call `ICMPv6Checksum` with `PayloadCsum: 0` and `PayloadLen: 0` (or omit those fields). Do not pass a separately computed pseudo-header checksum (e.g., `header.PseudoHeaderChecksum(...)`) as `PayloadCsum` in that case, because it will be double-counted and produce an incorrect checksum.

Learnt from: riccardomanfrin
Repo: netbirdio/netbird PR: 6263
File: management/server/types/account.go:1808-1809
Timestamp: 2026-05-26T14:28:54.207Z
Learning: In netbirdio/netbird, development build identifiers must follow the exact convention of starting with the prefix "development" (e.g., "development", "development-<sha>", "development-<sha>-dirty"). Do not treat other strings like "0.50.0-dev" or "dev" as development builds. During code review, use the codebase’s existing check `version.IsDevelopmentVersion()` (which implements `strings.HasPrefix(v, "development")`) to detect development builds, and do not suggest adding legacy compatibility for "-dev" suffix formats.

// Our local Rosenpass UDP server binds on the IPv6 wildcard ([::]) — see
// GetAddress(). The remote peer's endpoint (pcfg.Endpoint) is the destination
// our server will sendto when initiating handshakes. ResolveUDPAddr returns a
// 4-byte IPv4 for IPv4 hosts, which the kernel rejects (EDESTADDRREQ) when
// sent from an AF_INET6 socket. Normalize the remote endpoint to IPv4-mapped
// IPv6 so its address family matches our listening socket.
// TODO: maybe bind the Rosenpass UDP server to the peer wg IP addr
if v4 := pcfg.Endpoint.IP.To4(); v4 != nil {
pcfg.Endpoint.IP = v4.To16()
}
}
peerID, err := m.server.AddPeer(pcfg)
if err != nil {
Expand Down Expand Up @@ -280,13 +280,15 @@ func (m *Manager) OnConnected(remoteWireGuardKey string, remoteRosenpassPubKey [
}

rpKeyHash := hashRosenpassKey(remoteRosenpassPubKey)
log.Debugf("received remote rosenpass key %s, my key %s", rpKeyHash, m.rpKeyHash)
initiator := bytes.Compare(m.spk, remoteRosenpassPubKey) == 1
log.Warnf("PSK-DIAG: remote peer %s advertises rosenpass (key %s, addr %s); starting RP negotiation as initiator=%v", remoteWireGuardKey, rpKeyHash, remoteRosenpassAddr, initiator)

err := m.addPeer(remoteRosenpassPubKey, remoteRosenpassAddr, wireGuardIP, remoteWireGuardKey)
if err != nil {
log.Errorf("failed to add rosenpass peer: %s", err)
return
}
log.Warnf("PSK-DIAG: rosenpass peer added for %s (RP negotiation started; watch for 'set PSK on WG' to confirm completion)", remoteWireGuardKey)
}

// IsPresharedKeyInitialized returns true if Rosenpass has completed a handshake
Expand Down
1 change: 1 addition & 0 deletions client/internal/rosenpass/netbird_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ func (h *NetbirdHandler) outputKey(_ rp.KeyOutputReason, pid rp.PeerID, psk rp.K
log.Errorf("Failed to apply rosenpass key: %v", err)
return
}
log.Warnf("PSK-DIAG: rosenpass set PSK on WG for peer %s (updateOnly=%v)", peerKey, isInitialized)

// Mark peer as isInitialized after the successful first rotation
if !isInitialized {
Expand Down
12 changes: 8 additions & 4 deletions client/internal/routemanager/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,15 @@ func (w *Watcher) getBestRouteFromStatuses(routePeerStatuses map[route.ID]router
switch {
case chosen == "":
var peers []string
for _, r := range w.routes {
peers = append(peers, r.Peer)
for id, r := range w.routes {
st := "unknown(no status)"
if ps, ok := routePeerStatuses[id]; ok {
st = fmt.Sprintf("%s relayed=%v lat=%v", ps.status, ps.relayed, ps.latency)
}
peers = append(peers, fmt.Sprintf("%s{%s}", r.Peer, st))
}

log.Infof("network [%v] has not been assigned a routing peer as no peers from the list %s are currently available", w.handler, peers)
log.Warnf("ROUTE-DIAG: network [%v] no routing peer available yet (all candidates connecting/absent); candidates: %s", w.handler, peers)
Comment on lines +234 to +242

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Format the candidate list as a string.

peers is a []string, so %s will not render the troubleshooting snapshot correctly here. Use %v or strings.Join(peers, ", ") to avoid malformed log output.

🐛 Proposed fix
-		log.Warnf("ROUTE-DIAG: network [%v] no routing peer available yet (all candidates connecting/absent); candidates: %s", w.handler, peers)
+		log.Warnf("ROUTE-DIAG: network [%v] no routing peer available yet (all candidates connecting/absent); candidates: %v", w.handler, peers)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for id, r := range w.routes {
st := "unknown(no status)"
if ps, ok := routePeerStatuses[id]; ok {
st = fmt.Sprintf("%s relayed=%v lat=%v", ps.status, ps.relayed, ps.latency)
}
peers = append(peers, fmt.Sprintf("%s{%s}", r.Peer, st))
}
log.Infof("network [%v] has not been assigned a routing peer as no peers from the list %s are currently available", w.handler, peers)
log.Warnf("ROUTE-DIAG: network [%v] no routing peer available yet (all candidates connecting/absent); candidates: %s", w.handler, peers)
for id, r := range w.routes {
st := "unknown(no status)"
if ps, ok := routePeerStatuses[id]; ok {
st = fmt.Sprintf("%s relayed=%v lat=%v", ps.status, ps.relayed, ps.latency)
}
peers = append(peers, fmt.Sprintf("%s{%s}", r.Peer, st))
}
log.Warnf("ROUTE-DIAG: network [%v] no routing peer available yet (all candidates connecting/absent); candidates: %v", w.handler, peers)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/internal/routemanager/client/client.go` around lines 234 - 242, The
troubleshooting log in the route manager is formatting the candidate list
incorrectly because peers is a []string but the warning in the route-diagnostics
block uses a string formatter. Update the log call in the ROUTE-DIAG path inside
the loop over w.routes so the candidates are rendered with a slice-aware format
like %v or by joining peers into a single string before logging. Keep the rest
of the snapshot construction in place using routePeerStatuses, st, and
w.handler.

case chosen != currID:
// we compare the current score + 10ms to the chosen score to avoid flapping between routes
if currScore != 0 && currScore+0.01 > chosenScore {
Expand All @@ -247,7 +251,7 @@ func (w *Watcher) getBestRouteFromStatuses(routePeerStatuses map[route.ID]router
if rt := w.routes[chosen]; rt != nil {
p = rt.Peer
}
log.Infof("New chosen route is %s with peer %s with score %f for network [%v]", chosen, p, chosenScore, w.handler)
log.Warnf("ROUTE-DIAG: new chosen route %s peer %s score %f for network [%v] (routing peer now usable)", chosen, p, chosenScore, w.handler)
}

return chosen, chosenStatus
Expand Down
Loading