[client] Pull fresh TUN settings on Android rebuild instead of pushing state

The Android TUN rebuild consumed state pushed through notifications and
a Java-side snapshot, and both sources were unreliable. The DNS
search-domain notifier fired OnNetworkChanged with an empty string,
which the rebuild handler treated as the new route list, so any search
domain change rebuilt the TUN with zero routes and cut all tunnel
traffic. The rebuild also reused the search domains cached at the last
establish, so search domain updates never reached the TUN at runtime.

Make the notification a pure trigger and let the Java side pull a fresh
snapshot instead. Expose GetTunSettings on the Android SDK client: it
returns the current TUN route ranges, derived on demand by the route
manager from the client routes, the exit-node selection and the fake IP
blocks, together with the DNS search domains. The route notifier keeps
only its last-announced baseline to suppress triggers for unchanged
syncs; the TUN route state is owned by the route manager. SearchDomains
now locks the DNS server mutex since the pull arrives from a Java
thread.

Requires the matching android-client change that switches recreateTUN
to the pull API.
This commit is contained in:
Zoltán Papp
2026-07-30 13:58:46 +02:00
parent a7c874ae08
commit 2b5df88662
8 changed files with 127 additions and 41 deletions

View File

@@ -57,6 +57,12 @@ type DnsReadyListener interface {
dns.ReadyListener
}
// TunSettings is a snapshot of the settings the TUN device is rebuilt with
type TunSettings struct {
Routes string
SearchDomains string
}
func init() {
formatter.SetLogcatFormatter(log.StandardLogger())
}
@@ -240,6 +246,24 @@ func (c *Client) RenewTun(fd int) error {
return e.RenewTun(fd)
}
func (c *Client) GetTunSettings() (*TunSettings, error) {
cc := c.getConnectClient()
if cc == nil {
return nil, fmt.Errorf("engine not running")
}
e := cc.Engine()
if e == nil {
return nil, fmt.Errorf("engine not initialized")
}
routes, searchDomains := e.TunSettings()
return &TunSettings{
Routes: strings.Join(routes, ";"),
SearchDomains: strings.Join(searchDomains, ";"),
}, nil
}
// 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) {

View File

@@ -252,7 +252,7 @@ func NewDefaultServerPermanentUpstream(
ds.hostsDNSHolder.set(hostsDnsList)
ds.permanent = true
ds.currentConfig = dnsConfigToHostDNSConfig(config, ds.service.RuntimeIP(), ds.service.RuntimePort())
ds.searchDomainNotifier = newNotifier(ds.SearchDomains())
ds.searchDomainNotifier = newNotifier(ds.searchDomains())
ds.searchDomainNotifier.setListener(listener)
setServerDns(ds)
return ds
@@ -602,6 +602,12 @@ func (s *DefaultServer) UpdateDNSServer(serial uint64, update nbdns.Config) erro
}
func (s *DefaultServer) SearchDomains() []string {
s.mux.Lock()
defer s.mux.Unlock()
return s.searchDomains()
}
func (s *DefaultServer) searchDomains() []string {
var searchDomains []string
for _, dConf := range s.currentConfig.Domains {
@@ -686,7 +692,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error {
}()
if s.searchDomainNotifier != nil {
s.searchDomainNotifier.onNewSearchDomains(s.SearchDomains())
s.searchDomainNotifier.onNewSearchDomains(s.searchDomains())
}
s.updateNSGroupStates(update.NameServerGroups)

View File

@@ -0,0 +1,20 @@
package internal
func (e *Engine) TunSettings() ([]string, []string) {
e.syncMsgMux.Lock()
routeManager := e.routeManager
dnsServer := e.dnsServer
e.syncMsgMux.Unlock()
var routes []string
if routeManager != nil {
routes = routeManager.CurrentRouteRange()
}
var searchDomains []string
if dnsServer != nil {
searchDomains = dnsServer.SearchDomains()
}
return routes, searchDomains
}

View File

@@ -9,6 +9,7 @@ import (
"net/url"
"runtime"
"slices"
"sort"
"strings"
"sync"
"sync/atomic"
@@ -63,6 +64,7 @@ type Manager interface {
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
SetRouteChangeListener(listener listener.NetworkChangeListener)
InitialRouteRange() []string
CurrentRouteRange() []string
SetFirewall(firewall.Manager) error
SetDNSForwarderPort(port uint16)
ReconcilePeerAllowedIPs(peerKey string) error
@@ -191,7 +193,7 @@ func (m *DefaultManager) enableFakeIPRoutes() []*route.Route {
NetworkType: route.IPv6Network,
}
fakeRoutes := []*route.Route{fakeIPRoute, fakeIPv6Route}
m.notifier.SetFakeIPRoutes(fakeRoutes)
m.notifier.NotifyRouteChange()
return fakeRoutes
}
@@ -513,6 +515,34 @@ func (m *DefaultManager) InitialRouteRange() []string {
return m.notifier.GetInitialRouteRanges()
}
// CurrentRouteRange returns the current TUN route list. It is used by mobile systems
func (m *DefaultManager) CurrentRouteRange() []string {
m.mux.Lock()
defer m.mux.Unlock()
if m.disableClientRoutes {
return nil
}
filtered := m.routeSelector.FilterSelectedExitNodes(m.clientRoutes)
var nets []string
for _, routes := range filtered {
for _, r := range routes {
if r.IsDynamic() {
continue
}
nets = append(nets, r.NetString())
}
}
if m.fakeIPManager != nil {
nets = append(nets, m.fakeIPManager.GetFakeIPBlock().String(), m.fakeIPManager.GetFakeIPv6Block().String())
}
sort.Strings(nets)
return nets
}
// GetRouteSelector returns the route selector
func (m *DefaultManager) GetRouteSelector() *routeselector.RouteSelector {
return m.routeSelector

View File

@@ -35,6 +35,11 @@ func (m *MockManager) InitialRouteRange() []string {
return nil
}
// CurrentRouteRange mock implementation of CurrentRouteRange from Manager interface
func (m *MockManager) CurrentRouteRange() []string {
return nil
}
// UpdateRoutes mock implementation of UpdateRoutes from Manager interface
func (m *MockManager) UpdateRoutes(updateSerial uint64, newRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error {
if m.UpdateRoutesFunc != nil {

View File

@@ -6,7 +6,6 @@ import (
"net/netip"
"slices"
"sort"
"strings"
"sync"
"github.com/netbirdio/netbird/client/internal/listener"
@@ -14,12 +13,16 @@ import (
)
type Notifier struct {
initialRoutes []*route.Route
currentRoutes []*route.Route
fakeIPRoutes []*route.Route
mu sync.Mutex
listener listener.NetworkChangeListener
listenerMux sync.Mutex
initialRoutes []*route.Route
// currentRoutes is the last announced route set. It exists only to
// suppress noise: without it every network map sync would trigger the
// Java side, even when the routes did not change. The actual TUN route
// state is owned by the route manager and pulled from there.
currentRoutes []*route.Route
listener listener.NetworkChangeListener
}
func NewNotifier() *Notifier {
@@ -27,21 +30,23 @@ func NewNotifier() *Notifier {
}
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
n.listenerMux.Lock()
defer n.listenerMux.Unlock()
n.mu.Lock()
defer n.mu.Unlock()
n.listener = listener
}
// SetInitialClientRoutes stores the initial route sets for TUN configuration.
func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) {
n.mu.Lock()
defer n.mu.Unlock()
n.initialRoutes = filterStatic(initialRoutes)
n.currentRoutes = filterStatic(routesForComparison)
}
// SetFakeIPRoutes stores the fake IP routes to be included in every TUN rebuild.
func (n *Notifier) SetFakeIPRoutes(routes []*route.Route) {
n.fakeIPRoutes = routes
n.notify()
func (n *Notifier) NotifyRouteChange() {
n.mu.Lock()
defer n.mu.Unlock()
n.notifyLocked()
}
func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
@@ -55,31 +60,37 @@ func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
}
}
if !n.hasRouteDiff(n.currentRoutes, newRoutes) {
n.mu.Lock()
defer n.mu.Unlock()
if !hasRouteDiff(n.currentRoutes, newRoutes) {
return
}
n.currentRoutes = newRoutes
n.notify()
n.notifyLocked()
}
func (n *Notifier) OnNewPrefixes([]netip.Prefix) {
// Not used on Android
}
func (n *Notifier) notify() {
n.listenerMux.Lock()
defer n.listenerMux.Unlock()
func (n *Notifier) notifyLocked() {
if n.listener == nil {
return
}
n.listener.OnNetworkChanged("")
}
allRoutes := slices.Clone(n.currentRoutes)
allRoutes = append(allRoutes, n.fakeIPRoutes...)
func (n *Notifier) GetInitialRouteRanges() []string {
n.mu.Lock()
defer n.mu.Unlock()
initialStrings := routesToStrings(n.initialRoutes)
sort.Strings(initialStrings)
return initialStrings
}
routeStrings := n.routesToStrings(allRoutes)
sort.Strings(routeStrings)
n.listener.OnNetworkChanged(strings.Join(routeStrings, ","))
func (n *Notifier) Close() {
// unused
}
func filterStatic(routes []*route.Route) []*route.Route {
@@ -92,7 +103,7 @@ func filterStatic(routes []*route.Route) []*route.Route {
return out
}
func (n *Notifier) routesToStrings(routes []*route.Route) []string {
func routesToStrings(routes []*route.Route) []string {
nets := make([]string, 0, len(routes))
for _, r := range routes {
nets = append(nets, r.NetString())
@@ -100,20 +111,10 @@ func (n *Notifier) routesToStrings(routes []*route.Route) []string {
return nets
}
func (n *Notifier) hasRouteDiff(a []*route.Route, b []*route.Route) bool {
as := n.routesToStrings(a)
bs := n.routesToStrings(b)
func hasRouteDiff(a []*route.Route, b []*route.Route) bool {
as := routesToStrings(a)
bs := routesToStrings(b)
sort.Strings(as)
sort.Strings(bs)
return !slices.Equal(as, bs)
}
func (n *Notifier) GetInitialRouteRanges() []string {
initialStrings := n.routesToStrings(n.initialRoutes)
sort.Strings(initialStrings)
return initialStrings
}
func (n *Notifier) Close() {
// unused
}

View File

@@ -33,7 +33,7 @@ func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
// iOS doesn't care about initial routes
}
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
func (n *Notifier) NotifyRouteChange() {
// Not used on iOS
}

View File

@@ -23,7 +23,7 @@ func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
// Not used on non-mobile platforms
}
func (n *Notifier) SetFakeIPRoutes([]*route.Route) {
func (n *Notifier) NotifyRouteChange() {
// Not used on non-mobile platforms
}