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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@ infrastructure_files/setup-*.env
vendor/
/netbird
client/netbird-electron/
build/
docs/superpowers/
34 changes: 34 additions & 0 deletions client/android/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
//nolint
ctxWithValues = context.WithValue(ctxWithValues, system.UiVersionCtxKey, c.uiVersion)

// Inject the host-supplied IFaceDiscover so system.GetInfo can collect
// network addresses on Android (net.Interfaces() is broken under SELinux
// since Android 11). Used by login, posture-check meta resync, and the
// engine's network-change detection.
if c.iFaceDiscover != nil {
ctxWithValues = system.WithIFaceDiscover(ctxWithValues, func() (string, error) {
return c.iFaceDiscover.IFaces()
})
}

c.ctxCancelLock.Lock()
ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
defer c.ctxCancel()
Expand Down Expand Up @@ -179,6 +189,14 @@ func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsR
var ctx context.Context
//nolint
ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)

// See Run() above for rationale.
if c.iFaceDiscover != nil {
ctxWithValues = system.WithIFaceDiscover(ctxWithValues, func() (string, error) {
return c.iFaceDiscover.IFaces()
})
}

c.ctxCancelLock.Lock()
ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
defer c.ctxCancel()
Expand Down Expand Up @@ -216,6 +234,22 @@ func (c *Client) RenewTun(fd int) error {
return e.RenewTun(fd)
}

// OnUnderlyingNetworkChanged should be called by the Android layer when the
// underlying network changes (e.g., WiFi to cellular). It triggers a re-sync
// of NetworkAddresses with the management server so posture checks evaluate
// the current network state immediately, without waiting for the next
// periodic sync cycle.
func (c *Client) OnUnderlyingNetworkChanged() {
if c.connectClient == nil {
return
}
e := c.connectClient.Engine()
if e == nil {
return
}
e.ResyncNetworkAddresses()
}

// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
// It works both with and without a running engine.
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
Expand Down
176 changes: 168 additions & 8 deletions client/internal/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,11 @@ import (
// if not successful then it will retry the connection attempt.
// Todo pass timeout at EnginConfig
const (
PeerConnectionTimeoutMax = 45000 // ms
PeerConnectionTimeoutMin = 30000 // ms
disableAutoUpdate = "disabled"
PeerConnectionTimeoutMax = 45000 // ms
PeerConnectionTimeoutMin = 30000 // ms
disableAutoUpdate = "disabled"
networkAddressWatchInterval = 10 * time.Second
networkAddressResyncDebounce = 30 * time.Second
)

var ErrResetConnection = fmt.Errorf("reset connection")
Expand Down Expand Up @@ -213,6 +215,13 @@ type Engine struct {
// checks are the client-applied posture checks that need to be evaluated on the client
checks []*mgmProto.Checks

// lastNetworkAddresses tracks reported addresses for change detection.
// Protected by networkAddrMu; always acquire networkAddrMu before
// reading or writing these fields.
networkAddrMu sync.Mutex
lastNetworkAddresses []system.NetworkAddress
lastNetworkAddressSync time.Time

relayManager *relayClient.Manager
stateManager *statemanager.Manager
portForwardManager *portforward.Manager
Expand Down Expand Up @@ -577,6 +586,10 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
e.receiveManagementEvents()
e.receiveJobEvents()

// watch for network address changes (WiFi ↔ mobile) for posture checks
e.shutdownWg.Add(1)
go e.startNetworkAddressWatcher()

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// starting network monitor at the very last to avoid disruptions
e.startNetworkMonitor()

Expand Down Expand Up @@ -903,6 +916,10 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return err
}

// Fallback: detect network address changes during periodic sync in case
// platform-specific callbacks (e.g., Android NetworkCallback) were missed.
e.resyncMetaIfNetworkChanged(false)

nm := update.GetNetworkMap()
if nm == nil {
return nil
Expand Down Expand Up @@ -992,10 +1009,10 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
}
e.checks = checks

info, err := system.GetInfoWithChecks(e.ctx, checks)
info, err := system.GetInfoWithChecks(e.systemCtx(), checks)
if err != nil {
log.Warnf("failed to get system info with checks: %v", err)
info = system.GetInfo(e.ctx)
info = system.GetInfo(e.systemCtx())
}
info.SetFlags(
e.config.RosenpassEnabled,
Expand All @@ -1022,6 +1039,149 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
return nil
}

// ResyncNetworkAddresses can be called externally (e.g., from Android
// network change callbacks) to immediately re-sync NetworkAddresses.
// It bypasses the debounce window because platform callbacks indicate a
// real network change that should not be suppressed.
func (e *Engine) ResyncNetworkAddresses() {
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
e.resyncMetaIfNetworkChanged(true)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// startNetworkAddressWatcher polls for network address changes every 10s.
// This catches cases where platform callbacks are missed or delayed,
// ensuring posture checks always evaluate the current network state.
func (e *Engine) startNetworkAddressWatcher() {
defer e.shutdownWg.Done()
ticker := time.NewTicker(networkAddressWatchInterval)
defer ticker.Stop()

for {
select {
case <-e.ctx.Done():
return
case <-ticker.C:
e.syncMsgMux.Lock()
e.resyncMetaIfNetworkChanged(false)
e.syncMsgMux.Unlock()
}
}
}

// systemCtx returns a context for use with system.GetInfo / GetInfoWithChecks.
// On mobile platforms it injects the host-supplied ExternalIFaceDiscover so
// that the system package can collect network interfaces via the Android Java
// bridge instead of relying on net.Interfaces() (which is broken on Android
// 11+ due to SELinux restrictions on NETLINK_ROUTE sockets). On other
// platforms the discoverer is nil and the wrapper is a no-op.
func (e *Engine) systemCtx() context.Context {
if e.mobileDep.IFaceDiscover == nil {
return e.ctx
}
discoverer := e.mobileDep.IFaceDiscover
return system.WithIFaceDiscover(e.ctx, func() (string, error) {
return discoverer.IFaces()
})
}

// resyncMetaIfNetworkChanged detects changes in local network addresses
// (e.g., WiFi reconnect on mobile) and re-syncs meta with the management
// server so that posture checks evaluate the current network state.
// Must be called while holding syncMsgMux.
//
// When force is true (explicit platform callbacks such as Android
// NetworkCallback), the debounce window is bypassed because the OS already
// told us the network changed. When force is false (periodic polling /
// handleSync fallback), we debounce to avoid flapping during VPN tunnel
// setup.
func (e *Engine) resyncMetaIfNetworkChanged(force bool) {
e.networkAddrMu.Lock()
defer e.networkAddrMu.Unlock()

// Debounce: don't re-sync more than once per 30 seconds to avoid
// flapping during VPN tunnel setup when interfaces are in flux.
// Explicit platform callbacks (force=true) bypass this.
if !force && time.Since(e.lastNetworkAddressSync) < networkAddressResyncDebounce {
return
}

// Lightweight address-only check first: avoid running posture-check
// file/process scans (GetInfoWithChecks) on every 10s watcher tick
// when the address set has not changed.
current, err := system.NetworkAddresses(e.systemCtx())
if err != nil {
log.Warnf("failed to collect network addresses during resync check: %v", err)
return
}

if networkAddressesEqual(e.lastNetworkAddresses, current) {
return
}

log.Infof("network addresses changed (%d -> %d addrs), re-syncing meta with management server",
len(e.lastNetworkAddresses), len(current))

// Addresses actually changed — now gather full system info including
// posture-check file/process data so the management server can
// evaluate posture checks with up-to-date context.
info, err := system.GetInfoWithChecks(e.systemCtx(), e.checks)
if err != nil {
log.Warnf("failed to collect system info during network change resync: %v", err)
return
}
if info == nil {
return
}

info.SetFlags(
e.config.RosenpassEnabled,
e.config.RosenpassPermissive,
&e.config.ServerSSHAllowed,
e.config.DisableClientRoutes,
e.config.DisableServerRoutes,
e.config.DisableDNS,
e.config.DisableFirewall,
e.config.BlockLANAccess,
e.config.BlockInbound,
e.config.LazyConnectionEnabled,
e.config.EnableSSHRoot,
e.config.EnableSSHSFTP,
e.config.EnableSSHLocalPortForwarding,
e.config.EnableSSHRemotePortForwarding,
e.config.DisableSSHAuth,
)

// Only advance lastNetworkAddresses after a successful SyncMeta so a
// failed sync during a network handoff is retried on the next poll.
if err := e.mgmClient.SyncMeta(info); err != nil {
log.Warnf("failed to re-sync meta after network change: %v", err)
return
}
// Defensive clone so we keep our own slice independent of the one
// inside info: future changes to networkAddresses() must not be able
// to mutate our last-known state through a shared backing array.
e.lastNetworkAddresses = slices.Clone(current)
e.lastNetworkAddressSync = time.Now()
}

func networkAddressesEqual(a, b []system.NetworkAddress) bool {
if len(a) != len(b) {
return false
}
// Order-independent comparison: check that all IPs from a are present in b
bSet := make(map[string]struct{}, len(b))
for _, addr := range b {
bSet[addr.NetIP.String()] = struct{}{}
}
for _, addr := range a {
if _, ok := bSet[addr.NetIP.String()]; !ok {
return false
}
}
return true
}

func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
if e.wgInterface == nil {
return errors.New("wireguard interface is not initialized")
Expand Down Expand Up @@ -1133,10 +1293,10 @@ func (e *Engine) receiveManagementEvents() {
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
info, err := system.GetInfoWithChecks(e.ctx, e.checks)
info, err := system.GetInfoWithChecks(e.systemCtx(), e.checks)
if err != nil {
log.Warnf("failed to get system info with checks: %v", err)
info = system.GetInfo(e.ctx)
info = system.GetInfo(e.systemCtx())
}
info.SetFlags(
e.config.RosenpassEnabled,
Expand Down Expand Up @@ -1732,7 +1892,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
return nil, nil, false, nil
}

info := system.GetInfo(e.ctx)
info := system.GetInfo(e.systemCtx())
info.SetFlags(
e.config.RosenpassEnabled,
e.config.RosenpassPermissive,
Expand Down
102 changes: 102 additions & 0 deletions client/internal/network_address_sync_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package internal

import (
"net/netip"
"testing"

"github.com/stretchr/testify/assert"

"github.com/netbirdio/netbird/client/system"
)

func TestNetworkAddressesEqual(t *testing.T) {
tests := []struct {
name string
a []system.NetworkAddress
b []system.NetworkAddress
want bool
}{
{
name: "both nil",
a: nil,
b: nil,
want: true,
},
{
name: "both empty",
a: []system.NetworkAddress{},
b: []system.NetworkAddress{},
want: true,
},
{
name: "nil vs empty",
a: nil,
b: []system.NetworkAddress{},
want: true,
},
{
name: "same addresses same order",
a: []system.NetworkAddress{
{NetIP: netip.MustParsePrefix("192.168.1.10/24")},
{NetIP: netip.MustParsePrefix("10.0.0.1/8")},
},
b: []system.NetworkAddress{
{NetIP: netip.MustParsePrefix("192.168.1.10/24")},
{NetIP: netip.MustParsePrefix("10.0.0.1/8")},
},
want: true,
},
{
name: "same addresses different order",
a: []system.NetworkAddress{
{NetIP: netip.MustParsePrefix("10.0.0.1/8")},
{NetIP: netip.MustParsePrefix("192.168.1.10/24")},
},
b: []system.NetworkAddress{
{NetIP: netip.MustParsePrefix("192.168.1.10/24")},
{NetIP: netip.MustParsePrefix("10.0.0.1/8")},
},
want: true,
},
{
name: "different lengths",
a: []system.NetworkAddress{
{NetIP: netip.MustParsePrefix("192.168.1.10/24")},
},
b: []system.NetworkAddress{
{NetIP: netip.MustParsePrefix("192.168.1.10/24")},
{NetIP: netip.MustParsePrefix("10.0.0.1/8")},
},
want: false,
},
{
name: "different addresses",
a: []system.NetworkAddress{
{NetIP: netip.MustParsePrefix("192.168.1.10/24")},
},
b: []system.NetworkAddress{
{NetIP: netip.MustParsePrefix("172.16.0.1/12")},
},
want: false,
},
{
name: "wifi to mobile switch",
a: []system.NetworkAddress{
{NetIP: netip.MustParsePrefix("192.168.91.167/24")},
{NetIP: netip.MustParsePrefix("100.87.143.60/16")},
},
b: []system.NetworkAddress{
{NetIP: netip.MustParsePrefix("93.111.154.63/24")},
{NetIP: netip.MustParsePrefix("100.87.143.60/16")},
},
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := networkAddressesEqual(tt.a, tt.b)
assert.Equal(t, tt.want, got)
})
}
}
Loading
Loading