diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientViewModel.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientViewModel.kt index 0cad57f7..a107c078 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientViewModel.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientViewModel.kt @@ -59,7 +59,7 @@ class ClientViewModel : ViewModel(), ServiceConnection, IpNeighbourMonitor.Callb init { app.registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED)) - IpNeighbourMonitor.registerCallback(this) + IpNeighbourMonitor.registerCallback(this, true) } override fun onCleared() { diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt index c93b4ad1..d32de802 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt @@ -37,12 +37,35 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddr private val devFallback = "^if(\\d+)\$".toRegex() private fun checkLladdrNotLoopback(lladdr: String) = if (lladdr == "00:00:00:00:00:00") "" else lladdr - fun parse(line: String): List { + private fun populateList(base: IpNeighbour): List { + val devParser = devFallback.matchEntire(base.dev) + if (devParser != null) try { + val index = devParser.groupValues[1].toInt() + val iface = NetworkInterface.getByIndex(index) + if (iface == null) Timber.w("Failed to find network interface #$index") + else return listOf(base.copy(dev = iface.name), base) + } catch (_: SocketException) { } + return listOf(base) + } + + fun parse(line: String, fullMode: Boolean): List { if (line.isBlank()) return emptyList() return try { val match = parser.matchEntire(line)!! val ip = parseNumericAddress(match.groupValues[2]) // by regex, ip is non-empty val dev = match.groupValues[3] // by regex, dev is non-empty as well + val state = if (match.groupValues[1].isNotEmpty()) State.DELETING else + when (match.groupValues[7]) { + "", "INCOMPLETE" -> State.INCOMPLETE + "REACHABLE", "DELAY", "STALE", "PROBE", "PERMANENT" -> State.VALID + "FAILED" -> { + if (!fullMode) return populateList(IpNeighbour(ip, dev, MacAddressCompat.ALL_ZEROS_ADDRESS, + State.DELETING)) // skip parsing lladdr to avoid requesting root + State.FAILED + } + "NOARP" -> return emptyList() // skip + else -> throw IllegalArgumentException("Unknown state encountered: ${match.groupValues[7]}") + } var lladdr = checkLladdrNotLoopback(match.groupValues[5]) // use ARP as fallback for IPv4 if (lladdr.isEmpty()) lladdr = checkLladdrNotLoopback(arp() @@ -50,14 +73,6 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddr .filter { parseNumericAddress(it[ARP_IP_ADDRESS]) == ip && it[ARP_DEVICE] == dev } .map { it[ARP_HW_ADDRESS] } .singleOrNull() ?: "") - val state = if (match.groupValues[1].isNotEmpty()) State.DELETING else - when (match.groupValues[7]) { - "", "INCOMPLETE" -> State.INCOMPLETE - "REACHABLE", "DELAY", "STALE", "PROBE", "PERMANENT" -> State.VALID - "FAILED" -> State.FAILED - "NOARP" -> return emptyList() // skip - else -> throw IllegalArgumentException("Unknown state encountered: ${match.groupValues[7]}") - } val mac = try { MacAddressCompat.fromString(lladdr) } catch (e: IllegalArgumentException) { @@ -66,15 +81,7 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddr if (state != State.DELETING) Timber.w(IOException("Failed to find MAC address for $line", e)) MacAddressCompat.ALL_ZEROS_ADDRESS } - val result = IpNeighbour(ip, dev, mac, state) - val devParser = devFallback.matchEntire(dev) - if (devParser != null) try { - val index = devParser.groupValues[1].toInt() - val iface = NetworkInterface.getByIndex(index) - if (iface == null) Timber.w("Failed to find network interface #$index") - else return listOf(IpNeighbour(ip, iface.name, mac, state), result) - } catch (_: SocketException) { } - listOf(result) + populateList(IpNeighbour(ip, dev, mac, state)) } catch (e: Exception) { Timber.w(IllegalArgumentException("Unable to parse line: $line", e)) emptyList() @@ -114,3 +121,8 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddr } } } + +data class IpDev(val ip: InetAddress, val dev: String) { + override fun toString() = "$ip%$dev" +} +fun IpDev(neighbour: IpNeighbour) = IpDev(neighbour.ip, neighbour.dev) 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 33296052..77ad4438 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt @@ -348,7 +348,7 @@ class Routing(private val caller: Any, private val downstream: String, ipRule("unreachable", RULE_PRIORITY_UPSTREAM_DISABLE_SYSTEM) } UpstreamMonitor.registerCallback(upstream) - IpNeighbourMonitor.registerCallback(this) + IpNeighbourMonitor.registerCallback(this, true) } fun revert() { stop() 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 a2d2928e..c5102727 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 @@ -1,5 +1,6 @@ package be.mygod.vpnhotspot.net.monitor +import be.mygod.vpnhotspot.net.IpDev import be.mygod.vpnhotspot.net.IpNeighbour import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.persistentMapOf @@ -7,15 +8,22 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.actor import kotlinx.coroutines.channels.sendBlocking -import java.net.InetAddress class IpNeighbourMonitor private constructor() : IpMonitor() { companion object { - private val callbacks = mutableSetOf() + private val callbacks = mutableMapOf() var instance: IpNeighbourMonitor? = null + var fullMode = false - fun registerCallback(callback: Callback) = synchronized(callbacks) { - if (!callbacks.add(callback)) return@synchronized null + /** + * @param full Whether the failed entries should also be parsed. + * In this case it is more likely to trigger root request on API 29+. + * However, even in light mode, caller should still filter out failed entries in + * [Callback.onIpNeighbourAvailable] in case the full mode was requested by other callers. + */ + fun registerCallback(callback: Callback, full: Boolean = false) = synchronized(callbacks) { + if (callbacks.put(callback, full) == full) return@synchronized null + fullMode = full || callbacks.any { it.value } var monitor = instance if (monitor == null) { monitor = IpNeighbourMonitor() @@ -25,7 +33,7 @@ class IpNeighbourMonitor private constructor() : IpMonitor() { } else monitor.neighbours.values }?.let { callback.onIpNeighbourAvailable(it) } fun unregisterCallback(callback: Callback) = synchronized(callbacks) { - if (!callbacks.remove(callback) || callbacks.isNotEmpty()) return@synchronized + if (callbacks.remove(callback) == null || callbacks.isNotEmpty()) return@synchronized instance?.destroy() instance = null } @@ -35,30 +43,30 @@ class IpNeighbourMonitor private constructor() : IpMonitor() { fun onIpNeighbourAvailable(neighbours: Collection) } - private val aggregator = GlobalScope.actor>(capacity = Channel.CONFLATED) { + private val aggregator = GlobalScope.actor>(capacity = Channel.CONFLATED) { for (value in channel) { val neighbours = value.values - synchronized(callbacks) { for (callback in callbacks) callback.onIpNeighbourAvailable(neighbours) } + synchronized(callbacks) { for ((callback, _) in callbacks) callback.onIpNeighbourAvailable(neighbours) } } } - private var neighbours = persistentMapOf() + private var neighbours = persistentMapOf() override val monitoredObject: String get() = "neigh" override fun processLine(line: String) { val old = neighbours - for (neighbour in IpNeighbour.parse(line)) neighbours = when (neighbour.state) { - IpNeighbour.State.DELETING -> neighbours.remove(neighbour.ip) - else -> neighbours.put(neighbour.ip, neighbour) + for (neighbour in IpNeighbour.parse(line, fullMode)) neighbours = when (neighbour.state) { + IpNeighbour.State.DELETING -> neighbours.remove(IpDev(neighbour)) + else -> neighbours.put(IpDev(neighbour), neighbour) } if (neighbours != old) aggregator.sendBlocking(neighbours) } override fun processLines(lines: Sequence) { neighbours = lines - .flatMap { IpNeighbour.parse(it).asSequence() } + .flatMap { IpNeighbour.parse(it, fullMode).asSequence() } .filter { it.state != IpNeighbour.State.DELETING } // skip entries without lladdr - .associateByTo(persistentMapOf().builder()) { it.ip } + .associateByTo(persistentMapOf().builder()) { IpDev(it) } .build() aggregator.sendBlocking(neighbours) } 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 5ec9ae18..17a26a80 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 @@ -2,6 +2,7 @@ package be.mygod.vpnhotspot.net.monitor import androidx.collection.LongSparseArray import androidx.collection.set +import be.mygod.vpnhotspot.net.IpDev import be.mygod.vpnhotspot.net.MacAddressCompat import be.mygod.vpnhotspot.net.Routing.Companion.IPTABLES import be.mygod.vpnhotspot.room.AppDatabase @@ -19,22 +20,24 @@ object TrafficRecorder { private const val ANYWHERE = "0.0.0.0/0" private var lastUpdate = 0L - private val records = mutableMapOf, TrafficRecord>() + private val records = mutableMapOf() val foregroundListeners = Event2, LongSparseArray>() fun register(ip: InetAddress, downstream: String, mac: MacAddressCompat) { val record = TrafficRecord(mac = mac.addr, ip = ip, downstream = downstream) AppDatabase.instance.trafficRecordDao.insert(record) synchronized(this) { - Timber.d("Registering $ip%$downstream") - check(records.putIfAbsent(Pair(ip, downstream), record) == null) + val key = IpDev(ip, downstream) + Timber.d("Registering $key") + check(records.putIfAbsent(key, record) == null) scheduleUpdateLocked() } } fun unregister(ip: InetAddress, downstream: String) = synchronized(this) { update() // flush stats before removing - Timber.d("Unregistering $ip%$downstream") - if (records.remove(Pair(ip, downstream)) == null) Timber.w("Failed to find traffic record for $ip%$downstream.") + val key = IpDev(ip, downstream) + Timber.d("Unregistering $key") + if (records.remove(key) == null) Timber.w("Failed to find traffic record for $key.") } private var updateJob: Job? = null @@ -81,7 +84,7 @@ object TrafficRecorder { check(isReceive != isSend) { "Failed to set up blocking rules, please clean routing rules" } val ip = parseNumericAddress(columns[if (isReceive) 8 else 7]) val downstream = columns[if (isReceive) 6 else 5] - val key = Pair(ip, downstream) + val key = IpDev(ip, downstream) val oldRecord = records[key] ?: continue@loop // assuming they're legacy old rules val record = if (oldRecord.id == null) oldRecord else TrafficRecord( timestamp = timestamp,