diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/App.kt b/mobile/src/main/java/be/mygod/vpnhotspot/App.kt index 5096b3dc..c32bd001 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/App.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/App.kt @@ -56,7 +56,6 @@ class App : Application() { return if (result in 1..165) result else 0 } val masquerade get() = pref.getBoolean("service.masquerade", true) - val strict get() = app.pref.getBoolean("service.repeater.strict", false) val dhcpWorkaround get() = pref.getBoolean("service.dhcpWorkaround", false) val cleanRoutings = Event0() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/IpNeighbourMonitoringService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/IpNeighbourMonitoringService.kt index 2e0f7826..f7def4f1 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/IpNeighbourMonitoringService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/IpNeighbourMonitoringService.kt @@ -1,7 +1,7 @@ package be.mygod.vpnhotspot import android.app.Service -import be.mygod.vpnhotspot.net.monitor.IpNeighbour +import be.mygod.vpnhotspot.net.IpNeighbour import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor abstract class IpNeighbourMonitoringService : Service(), IpNeighbourMonitor.Callback { diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt index 035a1246..f2bcf090 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt @@ -44,7 +44,7 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService() { } else { val routingManager = routingManager if (routingManager == null) { - this.routingManager = LocalOnlyInterfaceManager(this, iface) + this.routingManager = LocalOnlyInterfaceManager(iface) IpNeighbourMonitor.registerCallback(this) } else check(iface == routingManager.downstream) } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyInterfaceManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyInterfaceManager.kt index dc4c5e9b..61f6dba5 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyInterfaceManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyInterfaceManager.kt @@ -1,54 +1,33 @@ package be.mygod.vpnhotspot -import android.content.Context import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.net.Routing -import be.mygod.vpnhotspot.net.monitor.UpstreamMonitor import be.mygod.vpnhotspot.widget.SmartSnackbar import timber.log.Timber -import java.net.InetAddress import java.net.InterfaceAddress -class LocalOnlyInterfaceManager(private val owner: Context, val downstream: String) : UpstreamMonitor.Callback { +class LocalOnlyInterfaceManager(val downstream: String) { private var routing: Routing? = null - private var dns = emptyList() init { app.cleanRoutings[this] = this::clean - UpstreamMonitor.registerCallback(this) { initRouting() } - } - - override fun onAvailable(ifname: String, dns: List) { - val routing = routing - initRouting(ifname, if (routing == null) null else { - routing.revert() - routing.hostAddress - }, dns) - } - override fun onLost() { - val routing = routing ?: return - routing.revert() - initRouting(null, routing.hostAddress, emptyList()) + initRouting() } private fun clean() { val routing = routing ?: return routing.stop() - initRouting(routing.upstream, routing.hostAddress, dns) + initRouting(routing.hostAddress) } - private fun initRouting(upstream: String? = null, owner: InterfaceAddress? = null, - dns: List = this.dns) { - this.dns = dns - try { - routing = Routing(this.owner, upstream, downstream, owner, app.strict).apply { + private fun initRouting(owner: InterfaceAddress? = null) { + routing = try { + Routing(downstream, owner).apply { try { if (app.dhcpWorkaround) dhcpWorkaround() ipForward() // local only interfaces need to enable ip_forward - rule() forward() if (app.masquerade) masquerade() - dnsRedirect(dns) commit() } catch (e: Exception) { revert() @@ -58,12 +37,11 @@ class LocalOnlyInterfaceManager(private val owner: Context, val downstream: Stri } catch (e: Exception) { SmartSnackbar.make(e.localizedMessage).show() Timber.w(e) - routing = null + null } } fun stop() { - UpstreamMonitor.unregisterCallback(this) app.cleanRoutings -= this routing?.revert() } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt index beeed933..3570c9a8 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt @@ -232,7 +232,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere private fun doStart(group: WifiP2pGroup) { this.group = group check(routingManager == null) - routingManager = LocalOnlyInterfaceManager(this, group.`interface`!!) + routingManager = LocalOnlyInterfaceManager(group.`interface`!!) status = Status.ACTIVE showNotification(group) } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt index ac74c114..fadb0be0 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt @@ -12,6 +12,7 @@ import androidx.preference.Preference import androidx.preference.SwitchPreference import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.net.Routing +import be.mygod.vpnhotspot.net.monitor.FallbackUpstreamMonitor import be.mygod.vpnhotspot.net.monitor.UpstreamMonitor import be.mygod.vpnhotspot.preference.AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat import be.mygod.vpnhotspot.preference.SharedPreferenceDataStore @@ -120,8 +121,8 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { } override fun onDisplayPreferenceDialog(preference: Preference) = when (preference.key) { - UpstreamMonitor.KEY -> displayPreferenceDialog( - AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat(), UpstreamMonitor.KEY, + UpstreamMonitor.KEY, FallbackUpstreamMonitor.KEY -> displayPreferenceDialog( + AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat(), preference.key, bundleOf(Pair(AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat.KEY_SUGGESTIONS, try { NetworkInterface.getNetworkInterfaces().asSequence() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt index 740a343f..31b3c580 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt @@ -4,16 +4,14 @@ import android.content.Intent import android.content.IntentFilter import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.manage.TetheringFragment -import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor import be.mygod.vpnhotspot.net.Routing import be.mygod.vpnhotspot.net.TetheringManager -import be.mygod.vpnhotspot.net.monitor.UpstreamMonitor +import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor import be.mygod.vpnhotspot.util.broadcastReceiver import be.mygod.vpnhotspot.widget.SmartSnackbar import timber.log.Timber -import java.net.InetAddress -class TetheringService : IpNeighbourMonitoringService(), UpstreamMonitor.Callback { +class TetheringService : IpNeighbourMonitoringService() { companion object { const val EXTRA_ADD_INTERFACE = "interface.add" const val EXTRA_REMOVE_INTERFACE = "interface.remove" @@ -27,8 +25,6 @@ class TetheringService : IpNeighbourMonitoringService(), UpstreamMonitor.Callbac private val binder = Binder() private val routings = HashMap() - private var upstream: String? = null - private var dns: List = emptyList() private var receiverRegistered = false private val receiver = broadcastReceiver { _, intent -> val extras = intent.extras ?: return@broadcastReceiver @@ -51,43 +47,36 @@ class TetheringService : IpNeighbourMonitoringService(), UpstreamMonitor.Callbac registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED)) app.cleanRoutings[this] = { synchronized(routings) { - for (iface in routings.keys) routings[iface] = null + for (iface in routings.keys) routings.put(iface, null)?.stop() updateRoutingsLocked() } } IpNeighbourMonitor.registerCallback(this) - UpstreamMonitor.registerCallback(this) } - val upstream = upstream val disableIpv6 = app.pref.getBoolean("service.disableIpv6", false) - if (upstream != null || app.strict || disableIpv6) { - val iterator = routings.iterator() - while (iterator.hasNext()) { - val (downstream, value) = iterator.next() - if (value != null) if (value.upstream == upstream) continue else value.revert() - try { - routings[downstream] = Routing(this, upstream, downstream).apply { - try { - if (app.dhcpWorkaround) dhcpWorkaround() - // system tethering already has working forwarding rules - // so it doesn't make sense to add additional forwarding rules - rule() - // here we always enforce strict mode as fallback is handled by system which we disable - forward() - if (app.masquerade) masquerade() - if (upstream != null) dnsRedirect(dns) - if (disableIpv6) disableIpv6() - commit() - } catch (e: Exception) { - revert() - throw e - } + val iterator = routings.iterator() + while (iterator.hasNext()) { + val (downstream, value) = iterator.next() + if (value != null) continue + try { + routings[downstream] = Routing(downstream).apply { + try { + if (app.dhcpWorkaround) dhcpWorkaround() + // system tethering already has working forwarding rules + // so it doesn't make sense to add additional forwarding rules + forward() + if (app.masquerade) masquerade() + if (disableIpv6) disableIpv6() + commit() + } catch (e: Exception) { + revert() + throw e } - } catch (e: Exception) { - Timber.w(e) - SmartSnackbar.make(e.localizedMessage).show() - iterator.remove() } + } catch (e: Exception) { + Timber.w(e) + SmartSnackbar.make(e.localizedMessage).show() + iterator.remove() } } if (routings.isEmpty()) { @@ -113,24 +102,6 @@ class TetheringService : IpNeighbourMonitoringService(), UpstreamMonitor.Callbac return START_NOT_STICKY } - override fun onAvailable(ifname: String, dns: List) { - if (upstream == ifname) return - upstream = ifname - this.dns = dns - synchronized(routings) { updateRoutingsLocked() } - } - - override fun onLost() { - upstream = null - this.dns = emptyList() - synchronized(routings) { - for ((iface, routing) in routings) { - routing?.revert() - routings[iface] = null - } - } - } - override fun onDestroy() { unregisterReceiver() super.onDestroy() @@ -141,8 +112,6 @@ class TetheringService : IpNeighbourMonitoringService(), UpstreamMonitor.Callbac unregisterReceiver(receiver) app.cleanRoutings -= this IpNeighbourMonitor.unregisterCallback(this) - UpstreamMonitor.unregisterCallback(this) - upstream = null receiverRegistered = false } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/Client.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/Client.kt index 07153997..aa75ddf4 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/Client.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/Client.kt @@ -10,7 +10,7 @@ import androidx.recyclerview.widget.DiffUtil import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.R import be.mygod.vpnhotspot.net.InetAddressComparator -import be.mygod.vpnhotspot.net.monitor.IpNeighbour +import be.mygod.vpnhotspot.net.IpNeighbour import be.mygod.vpnhotspot.net.TetherType import be.mygod.vpnhotspot.room.AppDatabase import be.mygod.vpnhotspot.room.lookup diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientMonitorService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientMonitorService.kt index 5637d7eb..71051ea5 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientMonitorService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientMonitorService.kt @@ -5,7 +5,7 @@ import android.content.* import android.net.wifi.p2p.WifiP2pDevice import android.os.IBinder import be.mygod.vpnhotspot.RepeaterService -import be.mygod.vpnhotspot.net.monitor.IpNeighbour +import be.mygod.vpnhotspot.net.IpNeighbour import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor import be.mygod.vpnhotspot.net.TetheringManager import be.mygod.vpnhotspot.util.StickyEvent1 diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/TetheringClient.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/TetheringClient.kt index d88d6fa1..c6d65092 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/TetheringClient.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/TetheringClient.kt @@ -1,6 +1,6 @@ package be.mygod.vpnhotspot.client -import be.mygod.vpnhotspot.net.monitor.IpNeighbour +import be.mygod.vpnhotspot.net.IpNeighbour class TetheringClient(private val neighbour: IpNeighbour) : Client() { override val iface get() = neighbour.dev diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpNeighbour.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt similarity index 98% rename from mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpNeighbour.kt rename to mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt index b8b15a1f..57877e3d 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpNeighbour.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt @@ -1,4 +1,4 @@ -package be.mygod.vpnhotspot.net.monitor +package be.mygod.vpnhotspot.net import be.mygod.vpnhotspot.util.parseNumericAddressNoThrow import timber.log.Timber diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt index 308b9a60..514009e0 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt @@ -1,22 +1,15 @@ package be.mygod.vpnhotspot.net -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection import android.os.Build -import android.os.IBinder import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.R -import be.mygod.vpnhotspot.client.Client -import be.mygod.vpnhotspot.client.ClientMonitorService -import be.mygod.vpnhotspot.debugLog +import be.mygod.vpnhotspot.net.monitor.FallbackUpstreamMonitor import be.mygod.vpnhotspot.net.monitor.TrafficRecorder +import be.mygod.vpnhotspot.net.monitor.UpstreamMonitor import be.mygod.vpnhotspot.util.RootSession -import be.mygod.vpnhotspot.util.computeIfAbsentCompat -import be.mygod.vpnhotspot.util.stopAndUnbind import be.mygod.vpnhotspot.widget.SmartSnackbar import timber.log.Timber +import java.lang.RuntimeException import java.net.* /** @@ -24,9 +17,17 @@ import java.net.* * * Once revert is called, this object no longer serves any purpose. */ -class Routing(private val owner: Context, val upstream: String?, private val downstream: String, - ownerAddress: InterfaceAddress? = null, private val strict: Boolean = true) : ServiceConnection { +class Routing(val downstream: String, ownerAddress: InterfaceAddress? = null) { companion object { + /** + * Since Android 5.0, RULE_PRIORITY_TETHERING = 18000. + * This also works for Wi-Fi direct where there's no rule at 18000. + * + * Source: https://android.googlesource.com/platform/system/netd/+/b9baf26/server/RouteController.cpp#65 + */ + private const val RULE_PRIORITY_UPSTREAM = 17800 + private const val RULE_PRIORITY_UPSTREAM_FALLBACK = 17900 + /** * -w is not supported on 7.1-. * Fortunately there also isn't a time limit for starting a foreground service back in 7.1-. @@ -45,7 +46,8 @@ class Routing(private val owner: Context, val upstream: String?, private val dow it.submit("while $IPTABLES -t nat -D POSTROUTING -j vpnhotspot_masquerade; do done") it.submit("$IPTABLES -t nat -F vpnhotspot_masquerade") it.submit("$IPTABLES -t nat -X vpnhotspot_masquerade") - it.submit("while ip rule del priority 17900; do done") + it.submit("while ip rule del priority $RULE_PRIORITY_UPSTREAM; do done") + it.submit("while ip rule del priority $RULE_PRIORITY_UPSTREAM_FALLBACK; do done") it.submit("while ip rule del iif lo uidrange 0-0 lookup local_network priority 11000; do done") } } @@ -62,56 +64,107 @@ class Routing(private val owner: Context, val upstream: String?, private val dow val hostAddress = ownerAddress ?: NetworkInterface.getByName(downstream)?.interfaceAddresses?.asSequence() ?.singleOrNull { it.address is Inet4Address } ?: throw InterfaceNotFoundException() + val hostSubnet = "${hostAddress.address.hostAddress}/${hostAddress.networkPrefixLength}" private val transaction = RootSession.beginTransaction() - private val subroutes = HashMap() - private var clients: ClientMonitorService.Binder? = null + + var hasMasquerade = false + + private abstract inner class Upstream : UpstreamMonitor.Callback { + var subrouting: Subrouting? = null + var dns: List = emptyList() + + override fun onAvailable(ifname: String, dns: List) { + this.dns = dns + updateDnsRoute() + } + + override fun onLost() { + val subrouting = subrouting ?: return + subrouting.close() + TrafficRecorder.update() // record stats before removing rules to prevent stats losing + subrouting.revert() + this.subrouting = null + } + } + private val fallbackUpstream = object : Upstream() { + override fun onAvailable(ifname: String, dns: List) { + val subrouting = subrouting + if (subrouting == null) this.subrouting = try { + Subrouting(this@Routing, RULE_PRIORITY_UPSTREAM_FALLBACK, ifname) + } catch (e: Exception) { + SmartSnackbar.make(e.localizedMessage).show() + Timber.w(e) + null + } else check(subrouting.upstream == ifname) + super.onAvailable(ifname, dns) + } + + override fun onFallback() { + check(subrouting == null) + subrouting = try { + Subrouting(this@Routing, RULE_PRIORITY_UPSTREAM_FALLBACK) + } catch (e: Exception) { + SmartSnackbar.make(e.localizedMessage).show() + Timber.w(e) + null + } + updateDnsRoute() + } + } + private val upstream = object : Upstream() { + override fun onAvailable(ifname: String, dns: List) { + val subrouting = subrouting + if (subrouting == null) this.subrouting = try { + Subrouting(this@Routing, RULE_PRIORITY_UPSTREAM, ifname) + } catch (e: Exception) { + SmartSnackbar.make(e.localizedMessage).show() + Timber.w(e) + null + } else check(subrouting.upstream == ifname) + super.onAvailable(ifname, dns) + } + } fun ipForward() = transaction.exec("echo 1 >/proc/sys/net/ipv4/ip_forward") fun disableIpv6() = transaction.exec("echo 1 >/proc/sys/net/ipv6/conf/$downstream/disable_ipv6", "echo 0 >/proc/sys/net/ipv6/conf/$downstream/disable_ipv6") - /** - * Since Android 5.0, RULE_PRIORITY_TETHERING = 18000. - * This also works for Wi-Fi direct where there's no rule at 18000. - * - * Source: https://android.googlesource.com/platform/system/netd/+/b9baf26/server/RouteController.cpp#65 - */ - fun rule() { - if (upstream != null) { - transaction.exec("ip rule add from all iif $downstream lookup $upstream priority 17900", - // by the time stopScript is called, table entry for upstream may already get removed - "ip rule del from all iif $downstream priority 17900") - } - } - fun forward() { transaction.execQuiet("$IPTABLES -N vpnhotspot_fwd") transaction.iptablesInsert("FORWARD -j vpnhotspot_fwd") transaction.iptablesAdd("vpnhotspot_fwd -i $downstream ! -o $downstream -j DROP") // ensure blocking works - // the real forwarding filters will be added in Subroute when clients are connected + // the real forwarding filters will be added in Subrouting when clients are connected } fun masquerade() { - val hostSubnet = "${hostAddress.address.hostAddress}/${hostAddress.networkPrefixLength}" transaction.execQuiet("$IPTABLES -t nat -N vpnhotspot_masquerade") transaction.iptablesInsert("POSTROUTING -j vpnhotspot_masquerade", "nat") - // note: specifying -i wouldn't work for POSTROUTING - if (strict) { - if (upstream != null) { - transaction.iptablesAdd("vpnhotspot_masquerade -s $hostSubnet -o $upstream -j MASQUERADE", "nat") - } // else nothing needs to be done - } else { - transaction.iptablesAdd("vpnhotspot_masquerade -s $hostSubnet -j MASQUERADE", "nat") - } + hasMasquerade = true + // further rules are added when upstreams are found } - fun dnsRedirect(dnses: List) { - val hostAddress = hostAddress.address.hostAddress - val dns = dnses.firstOrNull { it is Inet4Address }?.hostAddress ?: app.pref.getString("service.dns", "8.8.8.8") - debugLog("Routing", "Using $dns from ($dnses)") - transaction.iptablesAdd("PREROUTING -i $downstream -p tcp -d $hostAddress --dport 53 -j DNAT --to-destination $dns", "nat") - transaction.iptablesAdd("PREROUTING -i $downstream -p udp -d $hostAddress --dport 53 -j DNAT --to-destination $dns", "nat") + private inner class DnsRoute(val dns: String) { + val transaction = RootSession.beginTransaction().safeguard { + val hostAddress = hostAddress.address.hostAddress + iptablesAdd("PREROUTING -i $downstream -p tcp -d $hostAddress --dport 53 -j DNAT --to-destination $dns", "nat") + iptablesAdd("PREROUTING -i $downstream -p udp -d $hostAddress --dport 53 -j DNAT --to-destination $dns", "nat") + } + } + private var currentDns: DnsRoute? = null + private fun updateDnsRoute() { + val dns = (upstream.dns + fallbackUpstream.dns).firstOrNull { it is Inet4Address }?.hostAddress + ?: app.pref.getString("service.dns", "8.8.8.8") + if (dns != currentDns?.dns) { + currentDns?.transaction?.revert() + currentDns = try { + DnsRoute(dns) + } catch (e: RuntimeException) { + Timber.w(e) + SmartSnackbar.make(e.localizedMessage).show() + null + } + } } /** @@ -125,80 +178,23 @@ class Routing(private val owner: Context, val upstream: String?, private val dow fun dhcpWorkaround() = transaction.exec("ip rule add iif lo uidrange 0-0 lookup local_network priority 11000", "ip rule del iif lo uidrange 0-0 lookup local_network priority 11000") + fun stop() { + FallbackUpstreamMonitor.unregisterCallback(fallbackUpstream) + fallbackUpstream.subrouting?.close() + UpstreamMonitor.unregisterCallback(upstream) + upstream.subrouting?.close() + } + fun commit() { transaction.commit() - owner.bindService(Intent(owner, ClientMonitorService::class.java), this, Context.BIND_AUTO_CREATE) + FallbackUpstreamMonitor.registerCallback(fallbackUpstream) + UpstreamMonitor.registerCallback(upstream) } fun revert() { stop() TrafficRecorder.update() // record stats before exiting to prevent stats losing - synchronized(subroutes) { subroutes.forEach { (_, subroute) -> subroute.close() } } + fallbackUpstream.subrouting?.revert() + upstream.subrouting?.revert() transaction.revert() } - - /** - * Only unregister client listener. This should only be used when a clean has just performed. - */ - fun stop() = owner.stopAndUnbind(this) - - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - clients = service as ClientMonitorService.Binder - service.clientsChanged[this] = { - synchronized(subroutes) { - val toRemove = HashSet(subroutes.keys) - for (client in it) if (!client.record.blocked) updateForClient(client, toRemove) - if (toRemove.isNotEmpty()) { - TrafficRecorder.update() // record stats before removing rules to prevent stats losing - for (address in toRemove) subroutes.remove(address)!!.close() - } - } - } - } - override fun onServiceDisconnected(name: ComponentName?) { - val clients = clients ?: return - clients.clientsChanged -= this - this.clients = null - } - - private fun updateForClient(client: Client, toRemove: HashSet? = null) { - for ((ip, _) in client.ip) if (ip is Inet4Address) { - toRemove?.remove(ip) - try { - subroutes.computeIfAbsentCompat(ip) { Subroute(ip, client) } - } catch (e: Exception) { - Timber.w(e) - SmartSnackbar.make(e.localizedMessage).show() - } - } - } - - private inner class Subroute(private val ip: Inet4Address, client: Client) : AutoCloseable { - private val transaction = RootSession.beginTransaction().apply { - try { - val address by lazy { ip.hostAddress } - if (!strict) { - // otw allow downstream packets to be redirected to anywhere - // because we don't wanna keep track of default network changes - iptablesInsert("vpnhotspot_fwd -i $downstream -s $address -j ACCEPT") - iptablesInsert("vpnhotspot_fwd -o $downstream -d $address -m state --state ESTABLISHED,RELATED -j ACCEPT") - } else if (upstream != null) { - iptablesInsert("vpnhotspot_fwd -i $downstream -s $address -o $upstream -j ACCEPT") - iptablesInsert("vpnhotspot_fwd -i $upstream -o $downstream -d $address -m state --state ESTABLISHED,RELATED -j ACCEPT") - } // else nothing needs to be done - commit() - } catch (e: Exception) { - revert() - throw e - } - } - - init { - TrafficRecorder.register(ip, if (strict) upstream else null, downstream, client.mac) - } - - override fun close() { - TrafficRecorder.unregister(ip, downstream) - transaction.revert() - } - } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/Subrouting.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/Subrouting.kt new file mode 100644 index 00000000..9c47212a --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/Subrouting.kt @@ -0,0 +1,97 @@ +package be.mygod.vpnhotspot.net + +import be.mygod.vpnhotspot.net.Routing.Companion.iptablesAdd +import be.mygod.vpnhotspot.net.Routing.Companion.iptablesInsert +import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor +import be.mygod.vpnhotspot.net.monitor.TrafficRecorder +import be.mygod.vpnhotspot.room.AppDatabase +import be.mygod.vpnhotspot.room.lookup +import be.mygod.vpnhotspot.room.macToLong +import be.mygod.vpnhotspot.util.RootSession +import be.mygod.vpnhotspot.util.computeIfAbsentCompat +import be.mygod.vpnhotspot.widget.SmartSnackbar +import timber.log.Timber +import java.net.Inet4Address +import java.net.InetAddress + +/** + * The only case when upstream is null is on API 23- and we are using system default rules. + */ +class Subrouting(private val parent: Routing, priority: Int, val upstream: String? = null) : + IpNeighbourMonitor.Callback, AutoCloseable { + private inner class Subroute(private val ip: Inet4Address, mac: String) : AutoCloseable { + private val transaction = RootSession.beginTransaction().safeguard { + val downstream = parent.downstream + val address = ip.hostAddress + if (upstream == null) { + // otw allow downstream packets to be redirected to anywhere + // because we don't wanna keep track of default network changes + iptablesInsert("vpnhotspot_fwd -i $downstream -s $address -j ACCEPT") + iptablesInsert("vpnhotspot_fwd -o $downstream -d $address -m state --state ESTABLISHED,RELATED -j ACCEPT") + } else { + iptablesInsert("vpnhotspot_fwd -i $downstream -s $address -o $upstream -j ACCEPT") + iptablesInsert("vpnhotspot_fwd -i $upstream -o $downstream -d $address -m state --state ESTABLISHED,RELATED -j ACCEPT") + } + } + + init { + TrafficRecorder.register(ip, upstream, parent.downstream, mac) + } + + override fun close() { + TrafficRecorder.unregister(ip, upstream, parent.downstream) + transaction.revert() + } + } + + private val transaction = RootSession.beginTransaction().safeguard { + if (upstream != null) { + val downstream = parent.downstream + exec("ip rule add from all iif $downstream lookup $upstream priority $priority", + // by the time stopScript is called, table entry for upstream may already get removed + "ip rule del from all iif $downstream priority $priority") + } + // note: specifying -i wouldn't work for POSTROUTING + if (parent.hasMasquerade) { + val hostSubnet = parent.hostSubnet + iptablesAdd(if (upstream == null) "vpnhotspot_masquerade -s $hostSubnet -j MASQUERADE" else + "vpnhotspot_masquerade -s $hostSubnet -o $upstream -j MASQUERADE", "nat") + } + } + private val subroutes = HashMap() + + init { + IpNeighbourMonitor.registerCallback(this) + } + + /** + * Unregister client listener. This should be always called even after clean. + */ + override fun close() = IpNeighbourMonitor.unregisterCallback(this) + + override fun onIpNeighbourAvailable(neighbours: List) { + synchronized(parent) { + val toRemove = HashSet(subroutes.keys) + for (neighbour in neighbours) { + if (neighbour.dev != parent.downstream || neighbour.ip !is Inet4Address || + AppDatabase.instance.clientRecordDao.lookup(neighbour.lladdr.macToLong()).blocked) continue + toRemove.remove(neighbour.ip) + try { + subroutes.computeIfAbsentCompat(neighbour.ip) { Subroute(neighbour.ip, neighbour.lladdr) } + } catch (e: Exception) { + Timber.w(e) + SmartSnackbar.make(e.localizedMessage).show() + } + } + if (toRemove.isNotEmpty()) { + TrafficRecorder.update() // record stats before removing rules to prevent stats losing + for (address in toRemove) subroutes.remove(address)!!.close() + } + } + } + + fun revert() { + subroutes.forEach { (_, subroute) -> subroute.close() } + transaction.revert() + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/DefaultNetworkMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/DefaultNetworkMonitor.kt new file mode 100644 index 00000000..356555ce --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/DefaultNetworkMonitor.kt @@ -0,0 +1,86 @@ +package be.mygod.vpnhotspot.net.monitor + +import android.annotation.TargetApi +import android.net.* +import android.os.Build +import be.mygod.vpnhotspot.App.Companion.app +import timber.log.Timber + +object DefaultNetworkMonitor : UpstreamMonitor() { + private var registered = false + private var currentNetwork: Network? = null + override var currentLinkProperties: LinkProperties? = null + private set + /** + * Unfortunately registerDefaultNetworkCallback is going to return VPN interface since Android P DP1: + * https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e + */ + private val networkRequest = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) + .build() + private val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + val properties = app.connectivity.getLinkProperties(network) + val ifname = properties?.interfaceName ?: return + when (currentNetwork) { + null -> currentNetwork = network + network -> { + val oldProperties = currentLinkProperties!! + check(ifname == oldProperties.interfaceName) + if (properties.dnsServers == oldProperties.dnsServers) return + } + else -> check(false) + } + currentLinkProperties = properties + callbacks.forEach { it.onAvailable(ifname, properties.dnsServers) } + } + + override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) { + check(currentNetwork == network) + val oldProperties = currentLinkProperties!! + currentLinkProperties = properties + val ifname = properties.interfaceName!! + check(ifname == oldProperties.interfaceName) + if (properties.dnsServers != oldProperties.dnsServers) + callbacks.forEach { it.onAvailable(ifname, properties.dnsServers) } + } + + override fun onLost(network: Network) { + check(currentNetwork == network) + callbacks.forEach { it.onLost() } + currentNetwork = null + currentLinkProperties = null + } + } + + override fun registerCallbackLocked(callback: Callback) { + if (registered) { + val currentLinkProperties = currentLinkProperties + if (currentLinkProperties != null) { + callback.onAvailable(currentLinkProperties.interfaceName!!, currentLinkProperties.dnsServers) + } + } else { + if (Build.VERSION.SDK_INT in 24..27) @TargetApi(24) { + app.connectivity.registerDefaultNetworkCallback(networkCallback) + } else try { + app.connectivity.requestNetwork(networkRequest, networkCallback) + } catch (e: SecurityException) { + if (Build.VERSION.SDK_INT != 23) throw e + // SecurityException would be thrown in requestNetwork on Android 6.0 thanks to Google's stupid bug + Timber.w(e) + callback.onFallback() + return + } + registered = true + } + } + + override fun destroyLocked() { + if (!registered) return + app.connectivity.unregisterNetworkCallback(networkCallback) + registered = false + currentNetwork = null + currentLinkProperties = null + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/FallbackUpstreamMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/FallbackUpstreamMonitor.kt new file mode 100644 index 00000000..313b4f83 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/FallbackUpstreamMonitor.kt @@ -0,0 +1,42 @@ +package be.mygod.vpnhotspot.net.monitor + +import android.content.SharedPreferences +import be.mygod.vpnhotspot.App.Companion.app + +abstract class FallbackUpstreamMonitor private constructor() : UpstreamMonitor() { + companion object : SharedPreferences.OnSharedPreferenceChangeListener { + const val KEY = "service.upstream.fallback" + + init { + app.pref.registerOnSharedPreferenceChangeListener(this) + } + + private fun generateMonitor(): UpstreamMonitor { + val upstream = app.pref.getString(KEY, null) + return if (upstream.isNullOrEmpty()) DefaultNetworkMonitor else InterfaceMonitor(upstream!!) + } + private var monitor = generateMonitor() + + fun registerCallback(callback: Callback) = synchronized(this) { monitor.registerCallback(callback) } + fun unregisterCallback(callback: Callback) = synchronized(this) { monitor.unregisterCallback(callback) } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + if (key == KEY) synchronized(this) { + val old = monitor + val (active, callbacks) = synchronized(old) { + val active = old.currentIface != null + val callbacks = old.callbacks.toList() + old.callbacks.clear() + old.destroyLocked() + Pair(active, callbacks) + } + val new = generateMonitor() + monitor = new + for (callback in callbacks) { + if (active) callback.onLost() + new.registerCallback(callback) + } + } + } + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/InterfaceMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/InterfaceMonitor.kt index ae78b50a..2eaa687a 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/InterfaceMonitor.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/InterfaceMonitor.kt @@ -23,42 +23,30 @@ class InterfaceMonitor(val iface: String) : UpstreamMonitor() { setPresent(lines.any { parser.find(it)?.groupValues?.get(2) == iface }) } - private fun setPresent(present: Boolean) = if (initializing) { - initializedPresent = present - currentIface = if (present) iface else null - } else synchronized(this) { + private fun setPresent(present: Boolean) = synchronized(this) { val old = currentIface != null if (present == old) return currentIface = if (present) iface else null if (present) { - val dns = dns + val dns = currentDns callbacks.forEach { it.onAvailable(iface, dns) } } else callbacks.forEach { it.onLost() } } private var monitor: IpLinkMonitor? = null - private var initializing = false - private var initializedPresent: Boolean? = null override var currentIface: String? = null private set - private val dns get() = app.connectivity.allNetworks + override val currentLinkProperties get() = app.connectivity.allNetworks .map { app.connectivity.getLinkProperties(it) } .singleOrNull { it?.interfaceName == iface } - ?.dnsServers ?: emptyList() - override fun registerCallbackLocked(callback: Callback): Boolean { + override fun registerCallbackLocked(callback: Callback) { var monitor = monitor - val present = if (monitor == null) { - initializing = true - initializedPresent = null + if (monitor == null) { monitor = IpLinkMonitor() this.monitor = monitor monitor.run() - initializing = false - initializedPresent!! - } else currentIface != null - if (present) callback.onAvailable(iface, dns) - return !present + } else if (currentIface != null) callback.onAvailable(iface, currentDns) } override fun destroyLocked() { diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpNeighbourMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpNeighbourMonitor.kt index 3ef37d3d..55f0577f 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpNeighbourMonitor.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpNeighbourMonitor.kt @@ -2,6 +2,7 @@ package be.mygod.vpnhotspot.net.monitor import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.debugLog +import be.mygod.vpnhotspot.net.IpNeighbour import java.net.InetAddress class IpNeighbourMonitor private constructor() : IpMonitor() { diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TrafficRecorder.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TrafficRecorder.kt index 99a4fec4..f047abef 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TrafficRecorder.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TrafficRecorder.kt @@ -9,6 +9,7 @@ import be.mygod.vpnhotspot.room.macToLong import be.mygod.vpnhotspot.util.Event2 import be.mygod.vpnhotspot.util.RootSession import be.mygod.vpnhotspot.util.parseNumericAddress +import be.mygod.vpnhotspot.widget.SmartSnackbar import timber.log.Timber import java.net.InetAddress import java.util.concurrent.TimeUnit @@ -17,7 +18,8 @@ object TrafficRecorder { private const val ANYWHERE = "0.0.0.0/0" private var scheduled = false - private val records = HashMap, TrafficRecord>() + private var lastUpdate = 0L + private val records = HashMap, TrafficRecord>() val foregroundListeners = Event2, LongSparseArray>() fun register(ip: InetAddress, upstream: String?, downstream: String, mac: String) { @@ -28,13 +30,13 @@ object TrafficRecorder { downstream = downstream) AppDatabase.instance.trafficRecordDao.insert(record) synchronized(this) { - check(records.put(Pair(ip, downstream), record) == null) + check(records.put(Triple(ip, upstream, downstream), record) == null) scheduleUpdateLocked() } } - fun unregister(ip: InetAddress, downstream: String) = synchronized(this) { + fun unregister(ip: InetAddress, upstream: String?, downstream: String) = synchronized(this) { update() // flush stats before removing - check(records.remove(Pair(ip, downstream)) != null) + check(records.remove(Triple(ip, upstream, downstream)) != null) } private fun unscheduleUpdateLocked() { @@ -56,72 +58,85 @@ object TrafficRecorder { scheduleUpdateLocked() } + private fun doUpdate(timestamp: Long) { + val oldRecords = LongSparseArray() + for (line in RootSession.use { it.execOutUnjoined("iptables -nvx -L vpnhotspot_fwd") }.asSequence().drop(2)) { + val columns = line.split("\\s+".toRegex()).filter { it.isNotEmpty() } + try { + check(columns.size >= 9) + when (columns[2]) { + "DROP" -> { } + "ACCEPT" -> { + val isReceive = columns[7] == ANYWHERE + val isSend = columns[8] == ANYWHERE + check(isReceive != isSend) + val ip = parseNumericAddress(columns[if (isReceive) 8 else 7]) + val downstream = columns[if (isReceive) 6 else 5] + var upstream: String? = columns[if (isReceive) 5 else 6] + if (upstream == "*") upstream = null + val key = Triple(ip, upstream, downstream) + val oldRecord = records[key]!! + val record = if (oldRecord.id == null) oldRecord else TrafficRecord( + timestamp = timestamp, + mac = oldRecord.mac, + ip = ip, + upstream = upstream, + downstream = downstream, + sentPackets = -1, + sentBytes = -1, + receivedPackets = -1, + receivedBytes = -1, + previousId = oldRecord.id) + if (isReceive) { + if (record.receivedPackets == -1L && record.receivedBytes == -1L) { + record.receivedPackets = columns[0].toLong() + record.receivedBytes = columns[1].toLong() + } + } else { + if (record.sentPackets == -1L && record.sentBytes == -1L) { + record.sentPackets = columns[0].toLong() + record.sentBytes = columns[1].toLong() + } + } + if (oldRecord.id != null) { + check(records.put(key, record) == oldRecord) + oldRecords.put(oldRecord.id!!, oldRecord) + } + } + else -> check(false) + } + } catch (e: RuntimeException) { + Timber.w(line) + Timber.w(e) + } + } + for ((_, record) in records) if (record.id == null) { + check(record.sentPackets >= 0) + check(record.sentBytes >= 0) + check(record.receivedPackets >= 0) + check(record.receivedBytes >= 0) + AppDatabase.instance.trafficRecordDao.insert(record) + } + foregroundListeners(records.values, oldRecords) + } fun update() { synchronized(this) { + val wasScheduled = scheduled scheduled = false if (records.isEmpty()) return val timestamp = System.currentTimeMillis() - val oldRecords = LongSparseArray() - for (line in RootSession.use { it.execOutUnjoined("iptables -nvx -L vpnhotspot_fwd") } - .asSequence().drop(2)) { - val columns = line.split("\\s+".toRegex()).filter { it.isNotEmpty() } + if (timestamp - lastUpdate > 100) { try { - check(columns.size >= 9) - when (columns[2]) { - "DROP" -> { } - "ACCEPT" -> { - val isReceive = columns[7] == ANYWHERE - val isSend = columns[8] == ANYWHERE - check(isReceive != isSend) - val ip = parseNumericAddress(columns[if (isReceive) 8 else 7]) - val downstream = columns[if (isReceive) 6 else 5] - var upstream: String? = columns[if (isReceive) 5 else 6] - if (upstream == "*") upstream = null - val key = Pair(ip, downstream) - val oldRecord = records[key]!! - check(upstream == oldRecord.upstream) - val record = if (oldRecord.id == null) oldRecord else TrafficRecord( - timestamp = timestamp, - mac = oldRecord.mac, - ip = ip, - upstream = upstream, - downstream = downstream, - sentPackets = -1, - sentBytes = -1, - receivedPackets = -1, - receivedBytes = -1, - previousId = oldRecord.id) - if (isReceive) { - if (record.receivedPackets == -1L && record.receivedBytes == -1L) { - record.receivedPackets = columns[0].toLong() - record.receivedBytes = columns[1].toLong() - } - } else { - if (record.sentPackets == -1L && record.sentBytes == -1L) { - record.sentPackets = columns[0].toLong() - record.sentBytes = columns[1].toLong() - } - } - if (oldRecord.id != null) { - check(records.put(key, record) == oldRecord) - oldRecords.put(oldRecord.id!!, oldRecord) - } - } - else -> check(false) - } + doUpdate(timestamp) } catch (e: RuntimeException) { - Timber.w(line) Timber.w(e) + SmartSnackbar.make(e.localizedMessage) } + lastUpdate = timestamp + } else if (wasScheduled) { + scheduled = true + return } - for ((_, record) in records) if (record.id == null) { - check(record.sentPackets >= 0) - check(record.sentBytes >= 0) - check(record.receivedPackets >= 0) - check(record.receivedBytes >= 0) - AppDatabase.instance.trafficRecordDao.insert(record) - } - foregroundListeners(records.values, oldRecords) scheduleUpdateLocked() } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/UpstreamMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/UpstreamMonitor.kt index 9487b6c7..2f0b8e71 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/UpstreamMonitor.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/UpstreamMonitor.kt @@ -1,6 +1,7 @@ package be.mygod.vpnhotspot.net.monitor import android.content.SharedPreferences +import android.net.LinkProperties import be.mygod.vpnhotspot.App.Companion.app import java.net.InetAddress @@ -18,9 +19,7 @@ abstract class UpstreamMonitor { } private var monitor = generateMonitor() - fun registerCallback(callback: Callback, failfast: (() -> Unit)? = null) = synchronized(this) { - monitor.registerCallback(callback, failfast) - } + fun registerCallback(callback: Callback) = synchronized(this) { monitor.registerCallback(callback) } fun unregisterCallback(callback: Callback) = synchronized(this) { monitor.unregisterCallback(callback) } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { @@ -35,7 +34,10 @@ abstract class UpstreamMonitor { } val new = generateMonitor() monitor = new - callbacks.forEach { new.registerCallback(it) { if (active) it.onLost() } } + for (callback in callbacks) { + if (active) callback.onLost() + new.registerCallback(callback) + } } } } @@ -43,24 +45,39 @@ 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 DNS list. */ fun onAvailable(ifname: String, dns: List) /** * Called if no interface is available. */ fun onLost() + /** + * 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 + * (RULE_PRIORITY_DEFAULT_NETWORK) as our fallback rules, which would work fine until Android 9.0 broke it in + * commit: https://android.googlesource.com/platform/system/netd/+/758627c4d93392190b08e9aaea3bbbfb92a5f364 + */ + fun onFallback() { + throw NotImplementedError() + } } - protected val callbacks = HashSet() - abstract val currentIface: String? - protected abstract fun registerCallbackLocked(callback: Callback): Boolean - protected abstract fun destroyLocked() + val callbacks = HashSet() + protected abstract val currentLinkProperties: LinkProperties? + open val currentIface: String? get() = currentLinkProperties?.interfaceName + /** + * There's no need for overriding currentDns for now. + */ + val currentDns: List get() = currentLinkProperties?.dnsServers ?: emptyList() + protected abstract fun registerCallbackLocked(callback: Callback) + abstract fun destroyLocked() - fun registerCallback(callback: Callback, failfast: (() -> Unit)? = null) { - if (synchronized(this) { + fun registerCallback(callback: Callback) { + synchronized(this) { if (!callbacks.add(callback)) return registerCallbackLocked(callback) - }) failfast?.invoke() + } } fun unregisterCallback(callback: Callback) = synchronized(this) { if (callbacks.remove(callback) && callbacks.isEmpty()) destroyLocked() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/VpnMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/VpnMonitor.kt index 587a0a06..e7f24b41 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/VpnMonitor.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/VpnMonitor.kt @@ -1,9 +1,6 @@ package be.mygod.vpnhotspot.net.monitor -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkRequest +import android.net.* import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.debugLog @@ -16,12 +13,9 @@ object VpnMonitor : UpstreamMonitor() { .build() private var registered = false - /** - * Obtaining ifname in onLost doesn't work so we need to cache it in onAvailable. - */ - private val available = HashMap() + private val available = HashMap() private var currentNetwork: Network? = null - override val currentIface: String? get() { + override val currentLinkProperties: LinkProperties? get() { val currentNetwork = currentNetwork return if (currentNetwork == null) null else available[currentNetwork] } @@ -30,49 +24,56 @@ object VpnMonitor : UpstreamMonitor() { val properties = app.connectivity.getLinkProperties(network) val ifname = properties?.interfaceName ?: return synchronized(this@VpnMonitor) { - if (available.put(network, ifname) != null) return - debugLog(TAG, "onAvailable: $ifname, ${properties.dnsServers.joinToString()}") val old = currentNetwork - if (old != null) debugLog(TAG, "Assuming old VPN interface ${available[old]} is dying") - currentNetwork = network + val oldProperties = available.put(network, properties) + if (old != network) { + if (old != null) { + debugLog(TAG, "Assuming old VPN interface ${available[old]} is dying") + callbacks.forEach { it.onLost() } + } + currentNetwork = network + } else { + check(ifname == oldProperties!!.interfaceName) + if (properties.dnsServers == oldProperties.dnsServers) return + } callbacks.forEach { it.onAvailable(ifname, properties.dnsServers) } } } + override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) { + synchronized(this@VpnMonitor) { + if (currentNetwork != network) return + val oldProperties = available.put(network, properties)!! + val ifname = properties.interfaceName!! + check(ifname == oldProperties.interfaceName) + if (properties.dnsServers != oldProperties.dnsServers) + callbacks.forEach { it.onAvailable(ifname, properties.dnsServers) } + } + } + override fun onLost(network: Network) = synchronized(this@VpnMonitor) { - val ifname = available.remove(network) ?: return - debugLog(TAG, "onLost: $ifname") - if (currentNetwork != network) return - while (available.isNotEmpty()) { + if (available.remove(network) == null || currentNetwork != network) return + if (available.isNotEmpty()) { val next = available.entries.first() currentNetwork = next.key - val properties = app.connectivity.getLinkProperties(next.key) - if (properties != null) { - debugLog(TAG, "Switching to ${next.value} as VPN interface") - callbacks.forEach { it.onAvailable(next.value, properties.dnsServers) } - return - } - available.remove(next.key) + debugLog(TAG, "Switching to ${next.value.interfaceName} as VPN interface") + callbacks.forEach { it.onAvailable(next.value.interfaceName!!, next.value.dnsServers) } + } else { + callbacks.forEach { it.onLost() } + currentNetwork = null } - callbacks.forEach { it.onLost() } - currentNetwork = null } } - override fun registerCallbackLocked(callback: Callback) = if (registered) { - val currentNetwork = currentNetwork - if (currentNetwork == null) true else { - callback.onAvailable(available[currentNetwork]!!, - app.connectivity.getLinkProperties(currentNetwork)?.dnsServers ?: emptyList()) - false - } - } else { - app.connectivity.registerNetworkCallback(request, networkCallback) - registered = true - app.connectivity.allNetworks.all { - val cap = app.connectivity.getNetworkCapabilities(it) - cap == null || !cap.hasTransport(NetworkCapabilities.TRANSPORT_VPN) || - cap.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + override fun registerCallbackLocked(callback: Callback) { + if (registered) { + val currentLinkProperties = currentLinkProperties + if (currentLinkProperties != null) { + callback.onAvailable(currentLinkProperties.interfaceName!!, currentLinkProperties.dnsServers) + } + } else { + app.connectivity.registerNetworkCallback(request, networkCallback) + registered = true } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/RootSession.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/RootSession.kt index 8e1c00c5..68d161b0 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/util/RootSession.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/RootSession.kt @@ -123,14 +123,30 @@ class RootSession : AutoCloseable { fun revert() { if (revertCommands.isEmpty()) return - val shell = if (monitor.isHeldByCurrentThread) this@RootSession else { - monitor.lock() - ensureInstance() + var locked = monitor.isHeldByCurrentThread + try { + val shell = if (locked) this@RootSession else { + monitor.lock() + locked = true + ensureInstance() + } + shell.haltTimeout() + revertCommands.forEach { shell.submit(it) } + } catch (e: RuntimeException) { // if revert fails, it should fail silently + Timber.d(e) + } finally { + revertCommands.clear() + if (locked) unlock() // commit } - shell.haltTimeout() - revertCommands.forEach { shell.submit(it) } - revertCommands.clear() - unlock() // commit + } + + fun safeguard(work: Transaction.() -> Unit) = try { + work() + commit() + this + } catch (e: Exception) { + revert() + throw e } } } diff --git a/mobile/src/main/res/drawable/ic_action_pan_tool.xml b/mobile/src/main/res/drawable/ic_action_pan_tool.xml deleted file mode 100644 index 907facd0..00000000 --- a/mobile/src/main/res/drawable/ic_action_pan_tool.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/mobile/src/main/res/drawable/ic_action_settings_input_component.xml b/mobile/src/main/res/drawable/ic_action_settings_input_component.xml new file mode 100644 index 00000000..2a87e5bd --- /dev/null +++ b/mobile/src/main/res/drawable/ic_action_settings_input_component.xml @@ -0,0 +1,10 @@ + + + diff --git a/mobile/src/main/res/values-zh-rCN/strings.xml b/mobile/src/main/res/values-zh-rCN/strings.xml index d2b528ce..dcfa6ed2 100644 --- a/mobile/src/main/res/values-zh-rCN/strings.xml +++ b/mobile/src/main/res/values-zh-rCN/strings.xml @@ -64,7 +64,7 @@ %s 的昵称 %s 的流量 - 自 %2$s 以来连了 %1$s 次 + 自 %2$s 以来重新路由了 %1$s 次 上传 %1$s 个包,%2$s @@ -79,14 +79,14 @@ 建议使用广告拦截器与 socksfier 等虚拟 VPN 应用时禁用此选项。 Wi\u2011Fi 运行频段 (不稳定) "自动 (1\u201114 = 2.4GHz, 15\u2011165 = 5GHz)" - 严格模式 - 只允许通过 VPN 隧道的包通过 禁用 IPv6 共享 防止 IPv6 VPN 泄漏。 开机自启动中继 备用 DNS 服务器[:端口] 上游网络接口 自动检测系统 VPN + 备用上游接口 + 自动检测系统默认网络 清理/重新应用路由规则 将修改的设置应用到当前启用的服务上。也可用于修复偶尔会发生的竞态条件。 尝试修复 DHCP diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index 943f19dd..1e2fc30f 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -68,8 +68,8 @@ Nickname for %s Stats for %s - Connected 1 time since %2$s - Connected %1$s times since %2$s + Rerouted 1 time since %2$s + Rerouted %1$s times since %2$s Sent 1 packet, %2$s @@ -87,14 +87,14 @@ ad-blockers and socksifiers. Operating Wi\u2011Fi channel (unstable) Auto (1\u201114 = 2.4GHz, 15\u2011165 = 5GHz) - Strict mode - Only allow packets that goes through VPN tunnel. Disable IPv6 tethering Enabling this option will prevent VPN leaks via IPv6. Start repeater on boot Fallback DNS server[:port] Upstream network interface Auto detect system VPN + Fallback upstream interface + Auto detect system default network Enable DHCP workaround Use this if clients cannot obtain IP addresses. Clean/reapply routing rules diff --git a/mobile/src/main/res/xml/pref_settings.xml b/mobile/src/main/res/xml/pref_settings.xml index 76df4014..9f689c49 100644 --- a/mobile/src/main/res/xml/pref_settings.xml +++ b/mobile/src/main/res/xml/pref_settings.xml @@ -14,6 +14,13 @@ android:summary="@string/settings_service_upstream_auto" android:hint="@string/settings_service_upstream_auto" android:singleLine="true"/> + -