Support specifying network interface

Fix #15.
This commit is contained in:
Mygod
2018-06-02 07:29:46 +08:00
parent 8e335fec1b
commit da9bf4867e
14 changed files with 326 additions and 173 deletions

View File

@@ -37,8 +37,6 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService() {
val iface = ifaces.singleOrNull()
binder.iface = iface
if (iface == null) {
routingManager?.stop()
routingManager = null
unregisterReceiver()
ServiceNotification.stopForeground(this)
stopSelf()
@@ -103,6 +101,8 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService() {
}
private fun unregisterReceiver() {
routingManager?.stop()
routingManager = null
if (receiverRegistered) {
unregisterReceiver(receiver)
IpNeighbourMonitor.unregisterCallback(this)

View File

@@ -3,28 +3,28 @@ package be.mygod.vpnhotspot
import android.widget.Toast
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.net.Routing
import be.mygod.vpnhotspot.net.VpnMonitor
import be.mygod.vpnhotspot.net.UpstreamMonitor
import java.net.InetAddress
import java.net.SocketException
class LocalOnlyInterfaceManager(val downstream: String, private val owner: InetAddress? = null) : VpnMonitor.Callback {
class LocalOnlyInterfaceManager(val downstream: String, private val owner: InetAddress? = null) :
UpstreamMonitor.Callback {
private var routing: Routing? = null
private var dns = emptyList<InetAddress>()
init {
app.cleanRoutings[this] = this::clean
VpnMonitor.registerCallback(this) { initRouting() }
UpstreamMonitor.registerCallback(this) { initRouting() }
}
override fun onAvailable(ifname: String, dns: List<InetAddress>) {
val routing = routing
initRouting(ifname, if (routing == null) owner else {
routing.stop()
check(routing.upstream == null)
routing.hostAddress
}, dns)
}
override fun onLost(ifname: String) {
override fun onLost() {
val routing = routing ?: return
if (!routing.stop()) app.toast(R.string.noisy_su_failure)
initRouting(null, routing.hostAddress, emptyList())
@@ -54,7 +54,7 @@ class LocalOnlyInterfaceManager(val downstream: String, private val owner: InetA
}
fun stop() {
VpnMonitor.unregisterCallback(this)
UpstreamMonitor.unregisterCallback(this)
app.cleanRoutings -= this
if (routing?.stop() == false) app.toast(R.string.noisy_su_failure)
}

View File

@@ -177,7 +177,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere
p2pManager.requestGroupInfo(channel, {
when {
it == null -> doStart()
it.isGroupOwner -> doStart(it)
it.isGroupOwner -> if (routingManager == null) doStart(it)
else -> {
Log.i(TAG, "Removing old group ($it)")
p2pManager.removeGroup(channel, object : WifiP2pManager.ActionListener {
@@ -220,6 +220,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere
*/
private fun doStart(group: WifiP2pGroup, ownerAddress: InetAddress? = null) {
this.group = group
check(routingManager == null)
routingManager = LocalOnlyInterfaceManager(group.`interface`!!, ownerAddress)
status = Status.ACTIVE
showNotification(group)

View File

@@ -8,12 +8,12 @@ import be.mygod.vpnhotspot.manage.TetheringFragment
import be.mygod.vpnhotspot.net.IpNeighbourMonitor
import be.mygod.vpnhotspot.net.Routing
import be.mygod.vpnhotspot.net.TetheringManager
import be.mygod.vpnhotspot.net.VpnMonitor
import be.mygod.vpnhotspot.net.UpstreamMonitor
import be.mygod.vpnhotspot.util.broadcastReceiver
import java.net.InetAddress
import java.net.SocketException
class TetheringService : IpNeighbourMonitoringService(), VpnMonitor.Callback {
class TetheringService : IpNeighbourMonitoringService(), UpstreamMonitor.Callback {
companion object {
const val EXTRA_ADD_INTERFACE = "interface.add"
const val EXTRA_REMOVE_INTERFACE = "interface.remove"
@@ -45,7 +45,9 @@ class TetheringService : IpNeighbourMonitoringService(), VpnMonitor.Callback {
val upstream = upstream
if (upstream != null) {
var failed = false
for ((downstream, value) in routings) if (value == null) try {
for ((downstream, value) in routings) if (value == null || value.upstream != upstream)
try {
if (value?.stop() == false) failed = true
// system tethering already has working forwarding rules
// so it doesn't make sense to add additional forwarding rules
val routing = Routing(upstream, downstream).rule().forward().masquerade().dnsRedirect(dns)
@@ -67,7 +69,7 @@ class TetheringService : IpNeighbourMonitoringService(), VpnMonitor.Callback {
}
}
IpNeighbourMonitor.registerCallback(this)
VpnMonitor.registerCallback(this)
UpstreamMonitor.registerCallback(this)
receiverRegistered = true
}
updateNotification()
@@ -94,14 +96,13 @@ class TetheringService : IpNeighbourMonitoringService(), VpnMonitor.Callback {
}
override fun onAvailable(ifname: String, dns: List<InetAddress>) {
check(upstream == null || upstream == ifname)
if (upstream == ifname) return
upstream = ifname
this.dns = dns
synchronized(routings) { updateRoutingsLocked() }
}
override fun onLost(ifname: String) {
check(upstream == null || upstream == ifname)
override fun onLost() {
upstream = null
this.dns = emptyList()
var failed = false
@@ -124,7 +125,7 @@ class TetheringService : IpNeighbourMonitoringService(), VpnMonitor.Callback {
unregisterReceiver(receiver)
app.cleanRoutings -= this
IpNeighbourMonitor.unregisterCallback(this)
VpnMonitor.unregisterCallback(this)
UpstreamMonitor.unregisterCallback(this)
upstream = null
receiverRegistered = false
}

View File

@@ -59,10 +59,8 @@ class ClientsFragment : Fragment(), ServiceConnection {
}
override fun onServiceDisconnected(name: ComponentName?) {
val clients = clients
if (clients != null) {
val clients = clients ?: return
clients.clientsChanged -= this
this.clients = null
}
}
}

View File

@@ -115,9 +115,8 @@ class TetheringFragment : Fragment(), ServiceConnection {
}
override fun onServiceDisconnected(name: ComponentName?) {
val context = requireContext()
tetheringBinder?.fragment = null
(tetheringBinder ?: return).fragment = null
tetheringBinder = null
context.unregisterReceiver(receiver)
requireContext().unregisterReceiver(receiver)
}
}

View File

@@ -0,0 +1,70 @@
package be.mygod.vpnhotspot.net
import be.mygod.vpnhotspot.App.Companion.app
class InterfaceMonitor(val iface: String) : UpstreamMonitor() {
companion object {
/**
* Based on: https://android.googlesource.com/platform/external/iproute2/+/70556c1/ip/ipaddress.c#1053
*/
private val parser = ("^(Deleted )?-?\\d+: ([^:@]+)").toRegex()
}
private inner class IpLinkMonitor : IpMonitor() {
override val monitoredObject: String get() = "link"
override fun processLine(line: String) {
val match = parser.find(line) ?: return
if (match.groupValues[2] != iface) return
setPresent(match.groupValues[1].isEmpty())
}
override fun processLines(lines: Sequence<String>) =
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) {
val old = currentIface != null
if (present == old) return
currentIface = if (present) iface else null
if (present) {
val dns = dns
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
.map { app.connectivity.getLinkProperties(it) }
.singleOrNull { it.interfaceName == iface }
?.dnsServers ?: emptyList()
override fun registerCallbackLocked(callback: Callback): Boolean {
var monitor = monitor
val present = if (monitor == null) {
initializing = true
initializedPresent = null
monitor = IpLinkMonitor()
this.monitor = monitor
monitor.run()
initializing = false
initializedPresent!!
} else currentIface != null
if (present) callback.onAvailable(iface, dns)
return !present
}
override fun destroyLocked() {
val monitor = monitor ?: return
this.monitor = null
currentIface = null
monitor.destroy()
}
}

View File

@@ -0,0 +1,67 @@
package be.mygod.vpnhotspot.net
import android.util.Log
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.R
import be.mygod.vpnhotspot.util.thread
import java.io.InterruptedIOException
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
abstract class IpMonitor : Runnable {
protected abstract val monitoredObject: String
protected abstract fun processLine(line: String)
protected abstract fun processLines(lines: Sequence<String>)
private var monitor: Process? = null
private var pool: ScheduledExecutorService? = null
init {
thread("${javaClass.simpleName}-input") {
// monitor may get rejected by SELinux
val monitor = ProcessBuilder("sh", "-c",
"ip monitor $monitoredObject || su -c 'ip monitor $monitoredObject'")
.redirectErrorStream(true)
.start()
this.monitor = monitor
thread("${javaClass.simpleName}-error") {
try {
monitor.errorStream.bufferedReader().forEachLine { Log.e(javaClass.simpleName, it) }
} catch (_: InterruptedIOException) { }
}
try {
monitor.inputStream.bufferedReader().forEachLine(this::processLine)
monitor.waitFor()
if (monitor.exitValue() == 0) return@thread
Log.w(javaClass.simpleName, "Failed to set up monitor, switching to polling")
val pool = Executors.newScheduledThreadPool(1)
pool.scheduleAtFixedRate(this, 1, 1, TimeUnit.SECONDS)
this.pool = pool
} catch (_: InterruptedIOException) { }
}
}
fun flush() = thread("${javaClass.simpleName}-flush") { run() }
override fun run() {
val process = ProcessBuilder("ip", monitoredObject)
.redirectErrorStream(true)
.start()
process.waitFor()
thread("${javaClass.simpleName}-flush-error") {
val err = process.errorStream.bufferedReader().readText()
if (err.isNotBlank()) {
Log.e(javaClass.simpleName, err)
app.toast(R.string.noisy_su_failure)
}
}
process.inputStream.bufferedReader().useLines(this::processLines)
}
fun destroy() {
val monitor = monitor
if (monitor != null) thread("${javaClass.simpleName}-killer") { monitor.destroy() }
pool?.shutdown()
}
}

View File

@@ -1,19 +1,10 @@
package be.mygod.vpnhotspot.net
import android.os.Handler
import android.util.Log
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.R
import be.mygod.vpnhotspot.util.debugLog
import be.mygod.vpnhotspot.util.thread
import java.io.InterruptedIOException
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
class IpNeighbourMonitor private constructor() : Runnable {
class IpNeighbourMonitor private constructor() : IpMonitor() {
companion object {
private const val TAG = "IpNeighbourMonitor"
private val callbacks = HashSet<Callback>()
var instance: IpNeighbourMonitor? = null
@@ -39,63 +30,26 @@ class IpNeighbourMonitor private constructor() : Runnable {
fun onIpNeighbourAvailable(neighbours: List<IpNeighbour>)
}
private val handler = Handler()
private var updatePosted = false
val neighbours = HashMap<String, IpNeighbour>()
private var monitor: Process? = null
private var pool: ScheduledExecutorService? = null
init {
thread("$TAG-input") {
// monitor may get rejected by SELinux
val monitor = ProcessBuilder("sh", "-c", "ip monitor neigh || su -c 'ip monitor neigh'")
.redirectErrorStream(true)
.start()
this.monitor = monitor
thread("$TAG-error") {
try {
monitor.errorStream.bufferedReader().forEachLine { Log.e(TAG, it) }
} catch (_: InterruptedIOException) { }
}
try {
monitor.inputStream.bufferedReader().forEachLine {
override val monitoredObject: String get() = "neigh"
override fun processLine(line: String) {
synchronized(neighbours) {
val neighbour = IpNeighbour.parse(it) ?: return@forEachLine
debugLog(TAG, it)
val neighbour = IpNeighbour.parse(line) ?: return
debugLog(javaClass.simpleName, line)
val changed = if (neighbour.state == IpNeighbour.State.DELETING)
neighbours.remove(neighbour.ip) != null
else neighbours.put(neighbour.ip, neighbour) != neighbour
if (changed) postUpdateLocked()
}
}
monitor.waitFor()
if (monitor.exitValue() == 0) return@thread
Log.w(TAG, "Failed to set up monitor, switching to polling")
val pool = Executors.newScheduledThreadPool(1)
pool.scheduleAtFixedRate(this, 1, 1, TimeUnit.SECONDS)
this.pool = pool
} catch (_: InterruptedIOException) { }
}
}
fun flush() = thread("$TAG-flush") { run() }
override fun run() {
val process = ProcessBuilder("ip", "neigh")
.redirectErrorStream(true)
.start()
process.waitFor()
thread("$TAG-flush-error") {
val err = process.errorStream.bufferedReader().readText()
if (err.isNotBlank()) {
Log.e(TAG, err)
app.toast(R.string.noisy_su_failure)
}
}
process.inputStream.bufferedReader().useLines {
override fun processLines(lines: Sequence<String>) {
synchronized(neighbours) {
neighbours.clear()
neighbours.putAll(it
neighbours.putAll(lines
.map(IpNeighbour.Companion::parse)
.filterNotNull()
.filter { it.state != IpNeighbour.State.DELETING } // skip entries without lladdr
@@ -103,11 +57,10 @@ class IpNeighbourMonitor private constructor() : Runnable {
postUpdateLocked()
}
}
}
private fun postUpdateLocked() {
if (updatePosted || instance != this) return
handler.post {
app.handler.post {
val neighbours = synchronized(neighbours) {
updatePosted = false
neighbours.values.toList()
@@ -116,10 +69,4 @@ class IpNeighbourMonitor private constructor() : Runnable {
}
updatePosted = true
}
fun destroy() {
val monitor = monitor
if (monitor != null) thread("$TAG-killer") { monitor.destroy() }
pool?.shutdown()
}
}

View File

@@ -0,0 +1,68 @@
package be.mygod.vpnhotspot.net
import android.content.SharedPreferences
import be.mygod.vpnhotspot.App.Companion.app
import java.net.InetAddress
abstract class UpstreamMonitor {
companion object : SharedPreferences.OnSharedPreferenceChangeListener {
private const val KEY = "service.upstream"
init {
app.pref.registerOnSharedPreferenceChangeListener(this)
}
private fun generateMonitor(): UpstreamMonitor {
val upstream = app.pref.getString(KEY, null)
return if (upstream.isNullOrEmpty()) VpnMonitor else InterfaceMonitor(upstream)
}
private var monitor = generateMonitor()
fun registerCallback(callback: Callback, failfast: (() -> Unit)? = null) = synchronized(this) {
monitor.registerCallback(callback, failfast)
}
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
callbacks.forEach { new.registerCallback(it) { if (active) it.onLost() } }
}
}
}
interface Callback {
/**
* Called if some interface is available. This might be called on different ifname without having called onLost.
*/
fun onAvailable(ifname: String, dns: List<InetAddress>)
/**
* Called if no interface is available.
*/
fun onLost()
}
protected val callbacks = HashSet<Callback>()
abstract val currentIface: String?
protected abstract fun registerCallbackLocked(callback: Callback): Boolean
protected abstract fun destroyLocked()
fun registerCallback(callback: Callback, failfast: (() -> Unit)? = null) {
if (synchronized(this) {
if (!callbacks.add(callback)) return
registerCallbackLocked(callback)
}) failfast?.invoke()
}
fun unregisterCallback(callback: Callback) = synchronized(this) {
if (callbacks.remove(callback) && callbacks.isEmpty()) destroyLocked()
}
}

View File

@@ -6,21 +6,14 @@ import android.net.NetworkCapabilities
import android.net.NetworkRequest
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.util.debugLog
import java.net.InetAddress
object VpnMonitor : ConnectivityManager.NetworkCallback() {
interface Callback {
fun onAvailable(ifname: String, dns: List<InetAddress>)
fun onLost(ifname: String)
}
object VpnMonitor : UpstreamMonitor() {
private const val TAG = "VpnMonitor"
private val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_VPN)
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.build()
private val callbacks = HashSet<Callback>()
private var registered = false
/**
@@ -28,28 +21,28 @@ object VpnMonitor : ConnectivityManager.NetworkCallback() {
*/
private val available = HashMap<Network, String>()
private var currentNetwork: Network? = null
override val currentIface: String? get() {
val currentNetwork = currentNetwork
return if (currentNetwork == null) null else available[currentNetwork]
}
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
val properties = app.connectivity.getLinkProperties(network)
val ifname = properties?.interfaceName ?: return
synchronized(this) {
synchronized(this@VpnMonitor) {
if (available.put(network, ifname) != null) return
debugLog(TAG, "onAvailable: $ifname, ${properties.dnsServers.joinToString()}")
val old = currentNetwork
if (old != null) {
val name = available[old]!!
debugLog(TAG, "Assuming old VPN interface $name is dying")
callbacks.forEach { it.onLost(name) }
}
if (old != null) debugLog(TAG, "Assuming old VPN interface ${available[old]} is dying")
currentNetwork = network
callbacks.forEach { it.onAvailable(ifname, properties.dnsServers) }
}
}
override fun onLost(network: Network) = synchronized(this) {
override fun onLost(network: Network) = synchronized(this@VpnMonitor) {
val ifname = available.remove(network) ?: return
debugLog(TAG, "onLost: $ifname")
if (currentNetwork != network) return
callbacks.forEach { it.onLost(ifname) }
while (available.isNotEmpty()) {
val next = available.entries.first()
currentNetwork = next.key
@@ -61,32 +54,31 @@ object VpnMonitor : ConnectivityManager.NetworkCallback() {
}
available.remove(next.key)
}
callbacks.forEach { it.onLost() }
currentNetwork = null
}
}
fun registerCallback(callback: Callback, failfast: (() -> Unit)? = null) {
if (synchronized(this) {
if (!callbacks.add(callback)) return
if (!registered) {
app.connectivity.registerNetworkCallback(request, this)
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)
}
} else if (available.isEmpty()) true else {
available.forEach {
callback.onAvailable(it.value,
app.connectivity.getLinkProperties(it.key)?.dnsServers ?: emptyList())
}
false
}
}) failfast?.invoke()
}
fun unregisterCallback(callback: Callback) = synchronized(this) {
if (!callbacks.remove(callback) || callbacks.isNotEmpty() || !registered) return
app.connectivity.unregisterNetworkCallback(this)
override fun destroyLocked() {
if (!registered) return
app.connectivity.unregisterNetworkCallback(networkCallback)
registered = false
available.clear()
currentNetwork = null

View File

@@ -63,6 +63,8 @@
<string name="settings_service_disable_ipv6">禁用 IPv6 共享</string>
<string name="settings_service_disable_ipv6_summary">防止 IPv6 VPN 泄漏。</string>
<string name="settings_service_dns">备用 DNS 服务器[:端口]</string>
<string name="settings_service_upstream">上游网络接口</string>
<string name="settings_service_upstream_auto">自动检测系统 VPN</string>
<string name="settings_service_clean">清理/重新应用路由规则</string>
<string name="settings_misc">杂项</string>
<string name="settings_misc_logcat">导出调试信息</string>

View File

@@ -67,6 +67,8 @@
<string name="settings_service_disable_ipv6">Disable IPv6 tethering</string>
<string name="settings_service_disable_ipv6_summary">Enabling this option will prevent VPN leaks via IPv6.</string>
<string name="settings_service_dns">Fallback DNS server[:port]</string>
<string name="settings_service_upstream">Upstream network interface</string>
<string name="settings_service_upstream_auto">Auto detect system VPN</string>
<string name="settings_service_clean">Clean/reapply routing rules</string>
<string name="settings_misc">Misc</string>
<string name="settings_misc_logcat">Export debug information</string>

View File

@@ -2,6 +2,9 @@
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory
android:title="@string/settings_service">
<Preference
android:key="service.clean"
android:title="@string/settings_service_clean"/>
<SwitchPreference
android:key="service.repeater.strict"
android:title="@string/settings_service_repeater_strict"
@@ -15,9 +18,12 @@
android:title="@string/settings_service_dns"
android:singleLine="true"
android:defaultValue="8.8.8.8"/>
<Preference
android:key="service.clean"
android:title="@string/settings_service_clean"/>
<AutoSummaryEditTextPreference
android:key="service.upstream"
android:title="@string/settings_service_upstream"
android:summary="@string/settings_service_upstream_auto"
android:hint="@string/settings_service_upstream_auto"
android:singleLine="true"/>
</PreferenceCategory>
<PreferenceCategory
android:title="@string/settings_misc">