Skip to content

Commit aaecc62

Browse files
committed
android: rework NetworkChangeCallback to track all networks
Instead of just tracking our default network, track all of them and decide upon each change which is the "best" option. Updates tailscale/tailscale#13173 Signed-off-by: Andrew Dunham <[email protected]>
1 parent 33f79de commit aaecc62

File tree

1 file changed

+120
-52
lines changed

1 file changed

+120
-52
lines changed

android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt

Lines changed: 120 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -6,94 +6,162 @@ import android.net.ConnectivityManager
66
import android.net.LinkProperties
77
import android.net.Network
88
import android.net.NetworkCapabilities
9+
import android.net.NetworkRequest
910
import android.util.Log
1011
import libtailscale.Libtailscale
1112
import java.net.InetAddress
13+
import java.util.concurrent.locks.ReentrantLock
14+
import kotlin.concurrent.withLock
1215

1316
object NetworkChangeCallback {
1417

1518
private const val TAG = "NetworkChangeCallback"
1619

17-
// Cache LinkProperties and NetworkCapabilities since synchronous ConnectivityManager calls are
18-
// prone to races.
19-
// Since there is no guarantee for which update might come first, maybe update DNS configs on
20-
// both.
21-
val networkCapabilitiesCache = mutableMapOf<Network, NetworkCapabilities>()
22-
val linkPropertiesCache = mutableMapOf<Network, LinkProperties>()
23-
24-
// requestDefaultNetworkCallback receives notifications about the default network. Listen for
25-
// changes to the capabilities, which are guaranteed to come after a network becomes available per
26-
// https://developer.android.com/reference/android/net/ConnectivityManager.NetworkCallback#onAvailable(android.net.Network),
27-
// in order to filter on non-VPN networks.
20+
private data class NetworkInfo(
21+
var caps: NetworkCapabilities,
22+
var linkProps: LinkProperties
23+
)
24+
25+
private val lock = ReentrantLock()
26+
private val activeNetworks = mutableMapOf<Network, NetworkInfo>() // keyed by Network
27+
28+
// monitorDnsChanges sets up a network callback to monitor changes to the
29+
// system's network state and update the DNS configuration when interfaces
30+
// become available or properties of those interfaces change.
2831
fun monitorDnsChanges(connectivityManager: ConnectivityManager, dns: DnsConfig) {
32+
val networkConnectivityRequest =
33+
NetworkRequest.Builder()
34+
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
35+
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
36+
.build()
2937

30-
connectivityManager.registerDefaultNetworkCallback(
38+
// Use registerNetworkCallback to listen for updates from all networks, and
39+
// then update DNS configs for the best network.
40+
//
41+
// Note that we can't use registerDefaultNetworkCallback because the
42+
// default network used by Tailscale will always show up with capability
43+
// NOT_VPN=false, and we must filter out NOT_VPN networks to avoid routing
44+
// loops.
45+
connectivityManager.registerNetworkCallback(
46+
networkConnectivityRequest,
3147
object : ConnectivityManager.NetworkCallback() {
48+
override fun onAvailable(network: Network) {
49+
super.onAvailable(network)
50+
51+
Log.d(TAG, "onAvailable: network ${network}")
52+
lock.withLock {
53+
activeNetworks[network] = NetworkInfo(NetworkCapabilities(), LinkProperties())
54+
maybeUpdateDNSConfigLocked("onAvailable", dns)
55+
}
56+
}
57+
3258
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
3359
super.onCapabilitiesChanged(network, capabilities)
34-
networkCapabilitiesCache[network] = capabilities
35-
val linkProperties = linkPropertiesCache[network]
36-
if (linkProperties != null &&
37-
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) &&
38-
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
39-
maybeUpdateDNSConfig(linkProperties, dns)
40-
} else {
41-
if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) ||
42-
!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
43-
Log.d(
44-
TAG,
45-
"Capabilities changed for network $network.toString(), but not updating DNS config because either this is a VPN network or non-internet network")
46-
} else {
47-
Log.d(
48-
TAG,
49-
"Capabilities changed for network $network.toString(), but not updating DNS config, because the LinkProperties hasn't been gotten yet")
50-
}
60+
lock.withLock {
61+
activeNetworks[network]?.caps = capabilities
62+
maybeUpdateDNSConfigLocked("onCapabilitiesChanged", dns)
5163
}
5264
}
5365

5466
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
5567
super.onLinkPropertiesChanged(network, linkProperties)
56-
linkPropertiesCache[network] = linkProperties
57-
val capabilities = networkCapabilitiesCache[network]
58-
if (capabilities != null &&
59-
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) &&
60-
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
61-
maybeUpdateDNSConfig(linkProperties, dns)
62-
} else {
63-
if (capabilities == null) {
64-
Log.d(
65-
TAG,
66-
"Capabilities changed for network $network.toString(), but not updating DNS config because capabilities haven't been gotten for this network yet")
67-
} else {
68-
Log.d(
69-
TAG,
70-
"Capabilities changed for network $network.toString(), but not updating DNS config, because this is a VPN network or non-Internet network")
71-
}
68+
lock.withLock {
69+
activeNetworks[network]?.linkProps = linkProperties
70+
maybeUpdateDNSConfigLocked("onLinkPropertiesChanged", dns)
7271
}
7372
}
7473

7574
override fun onLost(network: Network) {
7675
super.onLost(network)
77-
if (dns.updateDNSFromNetwork("")) {
78-
Libtailscale.onDNSConfigChanged("")
76+
77+
Log.d(TAG, "onLost: network ${network}")
78+
lock.withLock {
79+
activeNetworks.remove(network)
80+
maybeUpdateDNSConfigLocked("onLost", dns)
7981
}
8082
}
8183
})
8284
}
8385

84-
fun maybeUpdateDNSConfig(linkProperties: LinkProperties, dns: DnsConfig) {
86+
// pickNonMetered returns the first non-metered network in the list of
87+
// networks, or the first network if none are non-metered.
88+
private fun pickNonMetered(networks: Map<Network, NetworkInfo>): Network? {
89+
for ((network, info) in networks) {
90+
if (info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) {
91+
return network
92+
}
93+
}
94+
return networks.keys.firstOrNull()
95+
}
96+
97+
// pickDefaultNetwork returns a non-VPN network to use as the 'default'
98+
// network; one that is used as a gateway to the internet and from which we
99+
// obtain our DNS servers.
100+
private fun pickDefaultNetwork(): Network? {
101+
// Filter the list of all networks to those that have the INTERNET
102+
// capability, are not VPNs, and have a non-zero number of DNS servers
103+
// available.
104+
val networks = activeNetworks.filter { (_, info) ->
105+
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
106+
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) &&
107+
info.linkProps.dnsServers.isNotEmpty() == true
108+
}
109+
110+
// If we have one; just return it; otherwise, prefer networks that are also
111+
// not metered (i.e. cell modems).
112+
val nonMeteredNetwork = pickNonMetered(networks)
113+
if (nonMeteredNetwork != null) {
114+
return nonMeteredNetwork
115+
}
116+
117+
// Okay, less good; just return the first network that has the INTERNET and
118+
// NOT_VPN capabilities; even though this interface doesn't have any DNS
119+
// servers set, we'll use our DNS fallback servers to make queries. It's
120+
// strictly better to return an interface + use the DNS fallback servers
121+
// than to return nothing and not be able to route traffic.
122+
for ((network, info) in activeNetworks) {
123+
if (info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
124+
info.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) {
125+
Log.w(TAG, "no networks available that also have DNS servers set; falling back to first network ${network}")
126+
return network
127+
}
128+
}
129+
130+
// Otherwise, return nothing; we don't want to return a VPN network since
131+
// it could result in a routing loop, and a non-INTERNET network isn't
132+
// helpful.
133+
Log.w(TAG, "no networks available to pick a default network")
134+
return null
135+
}
136+
137+
// maybeUpdateDNSConfig will maybe update our DNS configuration based on the
138+
// current set of active Networks.
139+
//
140+
// 'lock' must be held.
141+
private fun maybeUpdateDNSConfigLocked(why: String, dns: DnsConfig) {
142+
val defaultNetwork = pickDefaultNetwork()
143+
if (defaultNetwork == null) {
144+
Log.d(TAG, "${why}: no default network available; not updating DNS config")
145+
return
146+
}
147+
val info = activeNetworks[defaultNetwork]
148+
if (info == null) {
149+
Log.w(TAG, "${why}: [unexpected] no info available for default network; not updating DNS config")
150+
return
151+
}
152+
85153
val sb = StringBuilder()
86-
val dnsList: MutableList<InetAddress> = linkProperties.dnsServers ?: mutableListOf()
87-
for (ip in dnsList) {
154+
for (ip in info.linkProps.dnsServers) {
88155
sb.append(ip.hostAddress).append(" ")
89156
}
90-
val searchDomains: String? = linkProperties.domains
157+
val searchDomains: String? = info.linkProps.domains
91158
if (searchDomains != null) {
92159
sb.append("\n")
93160
sb.append(searchDomains)
94161
}
95162
if (dns.updateDNSFromNetwork(sb.toString())) {
96-
Libtailscale.onDNSConfigChanged(linkProperties.interfaceName)
163+
Log.d(TAG, "${why}: updated DNS config for network ${defaultNetwork} (${info.linkProps.interfaceName})")
164+
Libtailscale.onDNSConfigChanged(info.linkProps.interfaceName)
97165
}
98166
}
99167
}

0 commit comments

Comments
 (0)