Initial support for stacked links

This commit is contained in:
Mygod
2020-09-11 15:14:46 -04:00
parent bd176bc6f0
commit b675bdda09
9 changed files with 165 additions and 227 deletions

View File

@@ -2,6 +2,7 @@ package be.mygod.vpnhotspot.net
import android.annotation.TargetApi
import android.net.LinkProperties
import android.net.RouteInfo
import android.os.Build
import androidx.annotation.RequiresApi
import be.mygod.vpnhotspot.App.Companion.app
@@ -13,9 +14,7 @@ import be.mygod.vpnhotspot.net.monitor.UpstreamMonitor
import be.mygod.vpnhotspot.room.AppDatabase
import be.mygod.vpnhotspot.root.RootManager
import be.mygod.vpnhotspot.root.RoutingCommands
import be.mygod.vpnhotspot.util.RootSession
import be.mygod.vpnhotspot.util.if_nametoindex
import be.mygod.vpnhotspot.util.parseNumericAddress
import be.mygod.vpnhotspot.util.*
import be.mygod.vpnhotspot.widget.SmartSnackbar
import kotlinx.coroutines.CancellationException
import timber.log.Timber
@@ -180,52 +179,65 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh
}
}
var subrouting: Subrouting? = null
var dns: List<InetAddress> = emptyList()
var subrouting = mutableMapOf<String, Subrouting>()
var dns = emptyList<Pair<InetAddress, String?>>()
override fun onAvailable(ifname: String, properties: LinkProperties) = synchronized(this@Routing) {
override fun onAvailable(properties: LinkProperties?) = synchronized(this@Routing) {
if (stopped) return
val subrouting = subrouting
when {
subrouting != null -> check(subrouting.upstream == ifname) { "${subrouting.upstream} != $ifname" }
!upstreams.add(ifname) -> return
else -> this.subrouting = try {
Subrouting(priority, ifname)
val toRemove = subrouting.keys.toMutableSet()
for (link in properties?.allStackedLinks ?: emptySequence()) {
val ifname = link.interfaceName
if (ifname == null || toRemove.remove(ifname) || !upstreams.add(ifname)) continue
try {
subrouting[ifname] = Subrouting(priority, ifname)
} catch (e: Exception) {
SmartSnackbar.make(e).show()
if (e !is CancellationException) Timber.w(e)
null
}
}
dns = properties.dnsServers
updateDnsRoute()
}
override fun onLost() = synchronized(this@Routing) {
if (stopped) return
val subrouting = subrouting ?: return
// we could be removing fallback subrouting which no collision could ever happen, check before removing
subrouting.upstream?.let { check(upstreams.remove(it)) }
subrouting.transaction.revert()
this.subrouting = null
dns = emptyList()
for (ifname in toRemove) {
subrouting.remove(ifname)?.transaction?.revert()
check(upstreams.remove(ifname))
}
val routes = properties?.allRoutes
dns = properties?.dnsServers?.map { dest ->
// based on:
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/packages/Tethering/src/com/android/networkstack/tethering/TetheringInterfaceUtils.java;l=88;drc=master
// https://cs.android.com/android/platform/superproject/+/master:frameworks/libs/net/common/framework/android/net/util/NetUtils.java;l=44;drc=de5905fe0407a1f5e115423d56c948ee2400683d
val size = dest.address.size
var bestRoute: RouteInfo? = null
for (route in routes!!) {
if (route.destination.rawAddress.size == size &&
(bestRoute == null ||
bestRoute.destination.prefixLength < route.destination.prefixLength) &&
route.matches(dest)) {
bestRoute = route
}
}
dest to bestRoute?.`interface`
} ?: emptyList()
updateDnsRoute()
}
}
private val fallbackUpstream = object : Upstream(RULE_PRIORITY_UPSTREAM_FALLBACK) {
var fallbackInactive = true
override fun onAvailable(properties: LinkProperties?) {
check(fallbackInactive)
super.onAvailable(properties)
}
override fun onFallback() = synchronized(this@Routing) {
if (stopped) return
fallbackInactive = false
check(subrouting == null)
subrouting = try {
Subrouting(priority)
check(subrouting.isEmpty() && upstreams.add(""))
try {
subrouting[""] = Subrouting(priority)
} catch (e: Exception) {
SmartSnackbar.make(e).show()
if (e !is CancellationException) Timber.w(e)
null
}
dns = listOf(parseNumericAddress("8.8.8.8"))
dns = listOf(parseNumericAddress("8.8.8.8") to null)
updateDnsRoute()
}
}
@@ -334,8 +346,9 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh
private var currentDns: DnsRoute? = null
private fun updateDnsRoute() {
val selected = sequenceOf(upstream, fallbackUpstream).flatMap { upstream ->
val ifindex = upstream.subrouting?.ifindex ?: 0
if (ifindex == 0) emptySequence() else upstream.dns.asSequence().map { ifindex to it }
upstream.dns.asSequence().map { (server, iface) ->
((if (iface != null) upstream.subrouting[iface]?.ifindex else null) ?: 0) to server
}
}.firstOrNull { it.second is Inet4Address }
val ifindex = selected?.first ?: 0
var dns = selected?.second?.hostAddress
@@ -378,8 +391,8 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh
synchronized(this) { clients.values.forEach { it.close() } }
currentDns?.transaction?.revert()
disableSystem?.revert()
fallbackUpstream.subrouting?.transaction?.revert()
upstream.subrouting?.transaction?.revert()
fallbackUpstream.subrouting.values.forEach { it.transaction.revert() }
upstream.subrouting.values.forEach { it.transaction.revert() }
transaction.revert()
}
}

View File

@@ -9,11 +9,9 @@ import android.os.Build
import be.mygod.vpnhotspot.util.Services
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
object DefaultNetworkMonitor : UpstreamMonitor() {
private var registered = false
private var currentNetwork: Network? = null
override var currentLinkProperties: LinkProperties? = null
private set
/**
@@ -27,62 +25,30 @@ object DefaultNetworkMonitor : UpstreamMonitor() {
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
val properties = Services.connectivity.getLinkProperties(network)
val ifname = properties?.interfaceName ?: return
var switching = false
synchronized(this@DefaultNetworkMonitor) {
val oldProperties = currentLinkProperties
if (currentNetwork != network || ifname != oldProperties?.interfaceName) {
switching = true // we are using the other default network now
currentNetwork = network
}
currentLinkProperties = properties
callbacks.toList()
}.forEach {
if (switching) it.onLost()
it.onAvailable(ifname, properties)
}
}.forEach { it.onAvailable(properties) }
}
override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) {
var losing = true
var ifname: String?
synchronized(this@DefaultNetworkMonitor) {
if (currentNetwork == null) {
onAvailable(network)
return
}
if (currentNetwork != network) return
val oldProperties = currentLinkProperties!!
currentLinkProperties = properties
ifname = properties.interfaceName
when (ifname) {
null -> Timber.w("interfaceName became null: $oldProperties -> $properties")
oldProperties.interfaceName -> losing = false
else -> Timber.w(RuntimeException("interfaceName changed: $oldProperties -> $properties"))
}
callbacks.toList()
}.forEach {
if (losing) {
if (ifname == null) return onLost(network)
it.onLost()
}
ifname?.let { ifname -> it.onAvailable(ifname, properties) }
}
}.forEach { it.onAvailable(properties) }
}
override fun onLost(network: Network) = synchronized(this@DefaultNetworkMonitor) {
if (currentNetwork != network) return
currentNetwork = null
currentLinkProperties = null
callbacks.toList()
}.forEach { it.onLost() }
}.forEach { it.onAvailable() }
}
override fun registerCallbackLocked(callback: Callback) {
if (registered) {
val currentLinkProperties = currentLinkProperties
if (currentLinkProperties != null) GlobalScope.launch {
callback.onAvailable(currentLinkProperties.interfaceName!!, currentLinkProperties)
callback.onAvailable(currentLinkProperties)
}
} else {
if (Build.VERSION.SDK_INT in 24..27) @TargetApi(24) {
@@ -103,7 +69,6 @@ object DefaultNetworkMonitor : UpstreamMonitor() {
if (!registered) return
Services.connectivity.unregisterNetworkCallback(networkCallback)
registered = false
currentNetwork = null
currentLinkProperties = null
}
}

View File

@@ -34,10 +34,7 @@ abstract class FallbackUpstreamMonitor private constructor() : UpstreamMonitor()
}
val new = generateMonitor()
monitor = new
for (callback in callbacks) {
callback.onLost()
new.registerCallback(callback)
}
for (callback in callbacks) new.registerCallback(callback)
}
}
}

View File

@@ -1,49 +1,84 @@
package be.mygod.vpnhotspot.net.monitor
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import be.mygod.vpnhotspot.util.Services
import be.mygod.vpnhotspot.util.allInterfaceNames
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
class InterfaceMonitor(val iface: String) : UpstreamMonitor() {
private fun setPresent(present: Boolean) {
var available: Pair<String, LinkProperties>? = null
synchronized(this) {
val old = currentIface != null
if (present == old) return
currentIface = if (present) iface else null
if (present) available = iface to (currentLinkProperties ?: return)
callbacks.toList()
}.forEach {
@Suppress("NAME_SHADOWING")
val available = available
if (available != null) {
val (iface, lp) = available
it.onAvailable(iface, lp)
} else it.onLost()
private val request = networkRequestBuilder().apply {
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
removeCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED)
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
}.build()
private var registered = false
private val available = HashMap<Network, LinkProperties?>()
private var currentNetwork: Network? = null
override val currentLinkProperties: LinkProperties? get() = currentNetwork?.let { available[it] }
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
val properties = Services.connectivity.getLinkProperties(network)
if (properties?.allInterfaceNames?.contains(iface) != true) return
synchronized(this@InterfaceMonitor) {
available[network] = properties
currentNetwork = network
callbacks.toList()
}.forEach { it.onAvailable(properties) }
}
override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) {
val matched = properties.allInterfaceNames.contains(iface)
synchronized(this@InterfaceMonitor) {
if (!matched) {
if (currentNetwork == network) currentNetwork = null
available.remove(network)
return
}
available[network] = properties
if (currentNetwork == null) currentNetwork = network
else if (currentNetwork != network) return
callbacks.toList()
}.forEach { it.onAvailable(properties) }
}
override fun onLost(network: Network) {
var properties: LinkProperties? = null
synchronized(this@InterfaceMonitor) {
if (available.remove(network) == null || currentNetwork != network) return
if (available.isNotEmpty()) {
val next = available.entries.first()
currentNetwork = next.key
Timber.d("Switching to ${next.value} for $iface")
properties = next.value
} else currentNetwork = null
callbacks.toList()
}.forEach { it.onAvailable(properties) }
}
}
private var registered = false
override var currentIface: String? = null
private set
override val currentLinkProperties get() = Services.connectivity.allNetworks
.map { Services.connectivity.getLinkProperties(it) }
.singleOrNull { it?.allInterfaceNames?.contains(iface) == true }
override fun registerCallbackLocked(callback: Callback) {
if (!registered) {
IpLinkMonitor.registerCallback(this, iface, this::setPresent)
if (registered) {
val currentLinkProperties = currentLinkProperties
if (currentLinkProperties != null) GlobalScope.launch {
callback.onAvailable(currentLinkProperties)
}
} else {
Services.connectivity.registerNetworkCallback(request, networkCallback)
registered = true
} else if (currentIface != null) GlobalScope.launch {
callback.onAvailable(iface, currentLinkProperties ?: return@launch)
}
}
override fun destroyLocked() {
IpLinkMonitor.unregisterCallback(this)
currentIface = null
if (!registered) return
Services.connectivity.unregisterNetworkCallback(networkCallback)
registered = false
available.clear()
currentNetwork = null
}
}

View File

@@ -1,44 +0,0 @@
package be.mygod.vpnhotspot.net.monitor
class IpLinkMonitor private constructor() : IpMonitor() {
companion object {
/**
* Based on: https://android.googlesource.com/platform/external/iproute2/+/70556c1/ip/ipaddress.c#1053
*/
private val parser = "^(Deleted )?-?\\d+: ([^:@]+)".toRegex()
private val callbacks = HashMap<Any, Pair<String, (Boolean) -> Unit>>()
private var instance: IpLinkMonitor? = null
fun registerCallback(owner: Any, iface: String, callback: (Boolean) -> Unit) = synchronized(this) {
check(callbacks.put(owner, Pair(iface, callback)) == null)
var monitor = instance
if (monitor == null) {
monitor = IpLinkMonitor()
instance = monitor
} else monitor.flushAsync()
}
fun unregisterCallback(owner: Any) = synchronized(this) {
if (callbacks.remove(owner) == null || callbacks.isNotEmpty()) return@synchronized
instance?.destroy()
instance = null
}
}
override val monitoredObject: String get() = "link"
override fun processLine(line: String) {
val match = parser.find(line) ?: return
val iface = match.groupValues[2]
val present = match.groupValues[1].isEmpty()
synchronized(IpLinkMonitor) {
for ((target, callback) in callbacks.values) if (target == iface) callback(present)
}
}
override fun processLines(lines: Sequence<String>) {
val present = HashSet<String>()
for (it in lines) present.add((parser.find(it) ?: continue).groupValues[2])
synchronized(IpLinkMonitor) { for ((iface, callback) in callbacks.values) callback(present.contains(iface)) }
}
}

View File

@@ -37,18 +37,15 @@ abstract class UpstreamMonitor {
if (key == KEY) GlobalScope.launch { // prevent callback called in main
synchronized(this) {
val old = monitor
val (active, callbacks) = synchronized(old) {
(old.currentIface != null) to old.callbacks.toList().also {
val callbacks = synchronized(old) {
old.callbacks.toList().also {
old.callbacks.clear()
old.destroyLocked()
}
}
val new = generateMonitor()
monitor = new
for (callback in callbacks) {
if (active) callback.onLost()
new.registerCallback(callback)
}
for (callback in callbacks) new.registerCallback(callback)
}
}
}
@@ -56,14 +53,9 @@ abstract class UpstreamMonitor {
interface Callback {
/**
* Called if some interface is available. This might be called on different ifname without having called onLost.
* This might also be called on the same ifname but with updated link properties.
* Called if some possibly stacked interface is available
*/
fun onAvailable(ifname: String, properties: LinkProperties)
/**
* Called if no interface is available.
*/
fun onLost()
fun onAvailable(properties: LinkProperties? = null)
/**
* Called on API 23- from DefaultNetworkMonitor. This indicates that there isn't a good way of telling the
* default network (see DefaultNetworkMonitor) and we are using rules at priority 22000
@@ -77,7 +69,6 @@ abstract class UpstreamMonitor {
val callbacks = mutableSetOf<Callback>()
protected abstract val currentLinkProperties: LinkProperties?
open val currentIface: String? get() = currentLinkProperties?.interfaceName
protected abstract fun registerCallbackLocked(callback: Callback)
abstract fun destroyLocked()

View File

@@ -16,71 +16,40 @@ object VpnMonitor : UpstreamMonitor() {
.build()
private var registered = false
private val available = HashMap<Network, LinkProperties>()
private val available = HashMap<Network, LinkProperties?>()
private var currentNetwork: Network? = null
override val currentLinkProperties: LinkProperties? get() {
val currentNetwork = currentNetwork
return if (currentNetwork == null) null else available[currentNetwork]
}
override val currentLinkProperties: LinkProperties? get() = currentNetwork?.let { available[it] }
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
val properties = Services.connectivity.getLinkProperties(network)
val ifname = properties?.interfaceName ?: return
var switching = false
synchronized(this@VpnMonitor) {
val oldProperties = available.put(network, properties)
if (currentNetwork != network || ifname != oldProperties?.interfaceName) {
if (currentNetwork != null) switching = true
currentNetwork = network
}
available[network] = properties
currentNetwork = network
callbacks.toList()
}.forEach {
if (switching) it.onLost()
it.onAvailable(ifname, properties)
}
}.forEach { it.onAvailable(properties) }
}
override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) {
var losing = true
var ifname: String?
synchronized(this@VpnMonitor) {
if (currentNetwork == null) {
onAvailable(network)
return
}
if (currentNetwork != network) return
val oldProperties = available.put(network, properties)!!
ifname = properties.interfaceName
when (ifname) {
null -> Timber.w("interfaceName became null: $oldProperties -> $properties")
oldProperties.interfaceName -> losing = false
else -> Timber.w("interfaceName changed: $oldProperties -> $properties")
}
available[network] = properties
if (currentNetwork == null) currentNetwork = network
else if (currentNetwork != network) return
callbacks.toList()
}.forEach {
if (losing) {
if (ifname == null) return onLost(network)
it.onLost()
}
ifname?.let { ifname -> it.onAvailable(ifname, properties) }
}
}.forEach { it.onAvailable(properties) }
}
override fun onLost(network: Network) {
var newProperties: LinkProperties? = null
var properties: LinkProperties? = null
synchronized(this@VpnMonitor) {
if (available.remove(network) == null || currentNetwork != network) return
if (available.isNotEmpty()) {
val next = available.entries.first()
currentNetwork = next.key
Timber.d("Switching to ${next.value.interfaceName} as VPN interface")
newProperties = next.value
Timber.d("Switching to ${next.value} as VPN interface")
properties = next.value
} else currentNetwork = null
callbacks.toList()
}.forEach {
it.onLost()
newProperties?.let { prop -> it.onAvailable(prop.interfaceName!!, prop) }
}
}.forEach { it.onAvailable(properties) }
}
}
@@ -88,7 +57,7 @@ object VpnMonitor : UpstreamMonitor() {
if (registered) {
val currentLinkProperties = currentLinkProperties
if (currentLinkProperties != null) GlobalScope.launch {
callback.onAvailable(currentLinkProperties.interfaceName!!, currentLinkProperties)
callback.onAvailable(currentLinkProperties)
}
} else {
Services.connectivity.registerNetworkCallback(request, networkCallback)

View File

@@ -14,39 +14,40 @@ import be.mygod.vpnhotspot.R
import be.mygod.vpnhotspot.net.monitor.FallbackUpstreamMonitor
import be.mygod.vpnhotspot.net.monitor.UpstreamMonitor
import be.mygod.vpnhotspot.util.SpanFormatter
import be.mygod.vpnhotspot.util.allRoutes
import be.mygod.vpnhotspot.util.allStackedLinks
import be.mygod.vpnhotspot.util.parseNumericAddress
import timber.log.Timber
class UpstreamsPreference(context: Context, attrs: AttributeSet) : Preference(context, attrs),
DefaultLifecycleObserver {
companion object {
private val internetAddress = parseNumericAddress("8.8.8.8")
private val internetV4Address = parseNumericAddress("8.8.8.8")
private val internetV6Address = parseNumericAddress("2001:4860:4860::8888")
}
private data class Interface(val ifname: String, val internet: Boolean = true)
private open inner class Monitor : UpstreamMonitor.Callback {
protected var currentInterface: Interface? = null
val charSequence get() = currentInterface?.run {
protected var currentInterfaces = emptyList<Interface>()
val charSequence get() = currentInterfaces.map { (ifname, internet) ->
if (internet) SpannableStringBuilder(ifname).apply {
setSpan(StyleSpan(Typeface.BOLD), 0, length, 0)
} else ifname
} ?: ""
}.joinToString().let { if (it.isEmpty()) "" else it }
override fun onAvailable(ifname: String, properties: LinkProperties) {
currentInterface = Interface(ifname, properties.allRoutes.any {
try {
it.matches(internetAddress)
} catch (e: RuntimeException) {
Timber.w(e)
false
override fun onAvailable(properties: LinkProperties?) {
currentInterfaces = properties?.allStackedLinks?.mapNotNull { prop ->
prop.interfaceName?.let { ifname ->
Interface(ifname, prop.routes.any {
try {
it.matches(internetV4Address) || it.matches(internetV6Address)
} catch (e: RuntimeException) {
Timber.w(e)
false
}
})
}
})
onUpdate()
}
override fun onLost() {
currentInterface = null
}?.toList() ?: emptyList()
onUpdate()
}
}
@@ -54,7 +55,7 @@ class UpstreamsPreference(context: Context, attrs: AttributeSet) : Preference(co
private val primary = Monitor()
private val fallback: Monitor = object : Monitor() {
override fun onFallback() {
currentInterface = Interface("<default>")
currentInterfaces = listOf(Interface("<default>"))
onUpdate()
}
}

View File

@@ -117,6 +117,17 @@ val LinkProperties.allInterfaceNames get() = getAllInterfaceNames.invoke(this) a
private val getAllRoutes by lazy { LinkProperties::class.java.getDeclaredMethod("getAllRoutes") }
@Suppress("UNCHECKED_CAST")
val LinkProperties.allRoutes get() = getAllRoutes.invoke(this) as List<RouteInfo>
private val getStackedLinks by lazy { LinkProperties::class.java.getDeclaredMethod("getStackedLinks") }
@Suppress("UNCHECKED_CAST")
private val LinkProperties.stackedLinks get() = getStackedLinks.invoke(this) as List<LinkProperties>
private suspend fun SequenceScope<LinkProperties>.yieldRec(prop: LinkProperties) {
yield(prop)
for (link in prop.stackedLinks) yieldRec(link)
}
val LinkProperties.allStackedLinks get() = let {
sequence { yieldRec(it) }
}
fun Context.launchUrl(url: String) {
if (app.hasTouch) try {