VPN Hotspot 2.0: Client+ (#39)
Fix #13, #38. I don't have a lot of confidence that this would work very well for every device. Also here's an SQL command that hopefully somebody could make into the app for me: `SELECT TrafficRecord.mac, SUM(TrafficRecord.sentPackets), SUM(TrafficRecord.sentBytes), SUM(TrafficRecord.receivedPackets), SUM(TrafficRecord.receivedBytes) FROM TrafficRecord LEFT JOIN TrafficRecord AS Next ON TrafficRecord.id = Next.previousId WHERE Next.id IS NULL GROUP BY TrafficRecord.mac;`
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
package be.mygod.vpnhotspot.net
|
||||
|
||||
import java.net.InetAddress
|
||||
|
||||
object InetAddressComparator : Comparator<InetAddress> {
|
||||
override fun compare(o1: InetAddress?, o2: InetAddress?): Int {
|
||||
if (o1 == null && o2 == null) return 0
|
||||
val a1 = o1?.address
|
||||
val a2 = o2?.address
|
||||
val r = (a1?.size ?: 0).compareTo(a2?.size ?: 0)
|
||||
return if (r == 0) a1!!.zip(a2!!).map { (l, r) -> l - r }.find { it != 0 } ?: 0 else r
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package be.mygod.vpnhotspot.net
|
||||
|
||||
import android.util.Log
|
||||
import be.mygod.vpnhotspot.util.parseNumericAddressNoThrow
|
||||
import com.crashlytics.android.Crashlytics
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
|
||||
data class IpNeighbour(val ip: String, val dev: String, val lladdr: String, val state: State) {
|
||||
data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: String, val state: State) {
|
||||
enum class State {
|
||||
INCOMPLETE, VALID, FAILED, DELETING
|
||||
}
|
||||
@@ -29,12 +31,13 @@ data class IpNeighbour(val ip: String, val dev: String, val lladdr: String, val
|
||||
if (line.isNotEmpty()) Crashlytics.log(Log.WARN, TAG, line)
|
||||
return null
|
||||
}
|
||||
val ip = match.groupValues[2]
|
||||
val ip = parseNumericAddressNoThrow(match.groupValues[2]) ?: return null
|
||||
val dev = match.groupValues[4]
|
||||
var lladdr = checkLladdrNotLoopback(match.groupValues[6])
|
||||
// use ARP as fallback
|
||||
if (dev.isNotEmpty() && lladdr.isEmpty()) lladdr = checkLladdrNotLoopback(arp()
|
||||
.filter { it[ARP_IP_ADDRESS] == ip && it[ARP_DEVICE] == dev }
|
||||
.asSequence()
|
||||
.filter { parseNumericAddressNoThrow(it[ARP_IP_ADDRESS]) == ip && it[ARP_DEVICE] == dev }
|
||||
.map { it[ARP_HW_ADDRESS] }
|
||||
.singleOrNull() ?: "")
|
||||
val state = if (match.groupValues[1].isNotEmpty() || lladdr.isEmpty()) State.DELETING else
|
||||
@@ -64,9 +67,11 @@ data class IpNeighbour(val ip: String, val dev: String, val lladdr: String, val
|
||||
private fun arp(): List<List<String>> {
|
||||
if (System.nanoTime() - arpCacheTime >= ARP_CACHE_EXPIRE) try {
|
||||
arpCache = File("/proc/net/arp").bufferedReader().readLines()
|
||||
.asSequence()
|
||||
.map { it.split(spaces) }
|
||||
.drop(1)
|
||||
.filter { it.size >= 6 && mac.matcher(it[ARP_HW_ADDRESS]).matches() }
|
||||
.toList()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
Crashlytics.logException(e)
|
||||
|
||||
@@ -2,6 +2,7 @@ package be.mygod.vpnhotspot.net
|
||||
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.util.debugLog
|
||||
import java.net.InetAddress
|
||||
|
||||
class IpNeighbourMonitor private constructor() : IpMonitor() {
|
||||
companion object {
|
||||
@@ -31,7 +32,7 @@ class IpNeighbourMonitor private constructor() : IpMonitor() {
|
||||
}
|
||||
|
||||
private var updatePosted = false
|
||||
val neighbours = HashMap<String, IpNeighbour>()
|
||||
private val neighbours = HashMap<InetAddress, IpNeighbour>()
|
||||
|
||||
override val monitoredObject: String get() = "neigh"
|
||||
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
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.util.RootSession
|
||||
import be.mygod.vpnhotspot.util.computeIfAbsentCompat
|
||||
import be.mygod.vpnhotspot.util.debugLog
|
||||
import be.mygod.vpnhotspot.util.stopAndUnbind
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import com.crashlytics.android.Crashlytics
|
||||
import java.net.*
|
||||
|
||||
/**
|
||||
@@ -12,7 +23,8 @@ import java.net.*
|
||||
*
|
||||
* Once revert is called, this object no longer serves any purpose.
|
||||
*/
|
||||
class Routing(val upstream: String?, private val downstream: String, ownerAddress: InterfaceAddress? = null) {
|
||||
class Routing(private val owner: Context, val upstream: String?, private val downstream: String,
|
||||
ownerAddress: InterfaceAddress? = null, private val strict: Boolean = true) : ServiceConnection {
|
||||
companion object {
|
||||
/**
|
||||
* -w <seconds> is not supported on 7.1-.
|
||||
@@ -22,16 +34,19 @@ class Routing(val upstream: String?, private val downstream: String, ownerAddres
|
||||
*/
|
||||
private val IPTABLES = if (Build.VERSION.SDK_INT >= 26) "iptables -w 1" else "iptables -w"
|
||||
|
||||
fun clean() = RootSession.use {
|
||||
it.submit("$IPTABLES -t nat -F PREROUTING")
|
||||
it.submit("while $IPTABLES -D FORWARD -j vpnhotspot_fwd; do done")
|
||||
it.submit("$IPTABLES -F vpnhotspot_fwd")
|
||||
it.submit("$IPTABLES -X vpnhotspot_fwd")
|
||||
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 iif lo uidrange 0-0 lookup local_network priority 11000; do done")
|
||||
fun clean() {
|
||||
TrafficRecorder.clean()
|
||||
RootSession.use {
|
||||
it.submit("$IPTABLES -t nat -F PREROUTING")
|
||||
it.submit("while $IPTABLES -D FORWARD -j vpnhotspot_fwd; do done")
|
||||
it.submit("$IPTABLES -F vpnhotspot_fwd")
|
||||
it.submit("$IPTABLES -X vpnhotspot_fwd")
|
||||
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 iif lo uidrange 0-0 lookup local_network priority 11000; do done")
|
||||
}
|
||||
}
|
||||
|
||||
fun RootSession.Transaction.iptablesAdd(content: String, table: String = "filter") =
|
||||
@@ -47,7 +62,8 @@ class Routing(val upstream: String?, private val downstream: String, ownerAddres
|
||||
val hostAddress = ownerAddress ?: NetworkInterface.getByName(downstream)?.interfaceAddresses?.asSequence()
|
||||
?.singleOrNull { it.address is Inet4Address } ?: throw InterfaceNotFoundException()
|
||||
private val transaction = RootSession.beginTransaction()
|
||||
var started = false
|
||||
private val subroutes = HashMap<InetAddress, Subroute>()
|
||||
private var clients: ClientMonitorService.Binder? = null
|
||||
|
||||
fun ipForward() = transaction.exec("echo 1 >/proc/sys/net/ipv4/ip_forward")
|
||||
|
||||
@@ -68,25 +84,14 @@ class Routing(val upstream: String?, private val downstream: String, ownerAddres
|
||||
}
|
||||
}
|
||||
|
||||
fun forward(strict: Boolean = true) {
|
||||
fun forward() {
|
||||
transaction.execQuiet("$IPTABLES -N vpnhotspot_fwd")
|
||||
transaction.iptablesInsert("FORWARD -j vpnhotspot_fwd")
|
||||
if (strict) {
|
||||
if (upstream != null) {
|
||||
transaction.iptablesAdd("vpnhotspot_fwd -i $upstream -o $downstream -m state --state ESTABLISHED,RELATED -j ACCEPT")
|
||||
transaction.iptablesAdd("vpnhotspot_fwd -i $downstream -o $upstream -j ACCEPT")
|
||||
} // else nothing needs to be done
|
||||
} else {
|
||||
// for not strict mode, allow downstream packets to be redirected to anywhere
|
||||
// because we don't wanna keep track of default network changes
|
||||
transaction.iptablesAdd("vpnhotspot_fwd -o $downstream -m state --state ESTABLISHED,RELATED -j ACCEPT")
|
||||
transaction.iptablesAdd("vpnhotspot_fwd -i $downstream -j ACCEPT")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
fun overrideSystemRules() = transaction.iptablesAdd("vpnhotspot_fwd -i $downstream -j DROP")
|
||||
|
||||
fun masquerade(strict: Boolean = true) {
|
||||
fun masquerade() {
|
||||
val hostSubnet = "${hostAddress.address.hostAddress}/${hostAddress.networkPrefixLength}"
|
||||
transaction.execQuiet("$IPTABLES -t nat -N vpnhotspot_masquerade")
|
||||
transaction.iptablesInsert("POSTROUTING -j vpnhotspot_masquerade", "nat")
|
||||
@@ -119,6 +124,77 @@ class Routing(val upstream: String?, private val downstream: String, ownerAddres
|
||||
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 commit() = transaction.commit()
|
||||
fun revert() = transaction.revert()
|
||||
fun commit() {
|
||||
transaction.commit()
|
||||
owner.bindService(Intent(owner, ClientMonitorService::class.java), this, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
fun revert() {
|
||||
stop()
|
||||
synchronized(subroutes) { subroutes.forEach { (_, subroute) -> subroute.close() } }
|
||||
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)
|
||||
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<InetAddress>? = null) {
|
||||
for ((ip, _) in client.ip) if (ip is Inet4Address) {
|
||||
toRemove?.remove(ip)
|
||||
try {
|
||||
subroutes.computeIfAbsentCompat(ip) { Subroute(ip, client) }
|
||||
} catch (e: Exception) {
|
||||
Crashlytics.logException(e)
|
||||
e.printStackTrace()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
123
mobile/src/main/java/be/mygod/vpnhotspot/net/TrafficRecorder.kt
Normal file
123
mobile/src/main/java/be/mygod/vpnhotspot/net/TrafficRecorder.kt
Normal file
@@ -0,0 +1,123 @@
|
||||
package be.mygod.vpnhotspot.net
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.util.LongSparseArray
|
||||
import be.mygod.vpnhotspot.room.AppDatabase
|
||||
import be.mygod.vpnhotspot.room.TrafficRecord
|
||||
import be.mygod.vpnhotspot.room.insert
|
||||
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 com.crashlytics.android.Crashlytics
|
||||
import java.net.InetAddress
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object TrafficRecorder {
|
||||
private const val TAG = "TrafficRecorder"
|
||||
private const val ANYWHERE = "0.0.0.0/0"
|
||||
|
||||
private var scheduled = false
|
||||
private val records = HashMap<Pair<InetAddress, String>, TrafficRecord>()
|
||||
val foregroundListeners = Event2<Collection<TrafficRecord>, LongSparseArray<TrafficRecord>>()
|
||||
|
||||
fun register(ip: InetAddress, upstream: String?, downstream: String, mac: String) {
|
||||
val record = TrafficRecord(
|
||||
mac = mac.macToLong(),
|
||||
ip = ip,
|
||||
upstream = upstream,
|
||||
downstream = downstream)
|
||||
AppDatabase.instance.trafficRecordDao.insert(record)
|
||||
synchronized(this) {
|
||||
check(records.put(Pair(ip, downstream), record) == null)
|
||||
scheduleUpdateLocked()
|
||||
}
|
||||
}
|
||||
fun unregister(ip: InetAddress, downstream: String) = synchronized(this) {
|
||||
update() // flush stats before removing
|
||||
check(records.remove(Pair(ip, downstream)) != null)
|
||||
}
|
||||
|
||||
private fun unscheduleUpdateLocked() {
|
||||
RootSession.handler.removeCallbacksAndMessages(this)
|
||||
scheduled = false
|
||||
}
|
||||
private fun scheduleUpdateLocked() {
|
||||
if (scheduled) return
|
||||
val now = System.currentTimeMillis()
|
||||
val minute = TimeUnit.MINUTES.toMillis(1)
|
||||
var timeout = minute - now % minute
|
||||
if (foregroundListeners.isNotEmpty() && timeout > 1000) timeout = 1000
|
||||
RootSession.handler.postAtTime(this::update, this, SystemClock.uptimeMillis() + timeout)
|
||||
scheduled = true
|
||||
}
|
||||
|
||||
fun rescheduleUpdate() = synchronized(this) {
|
||||
unscheduleUpdateLocked()
|
||||
scheduleUpdateLocked()
|
||||
}
|
||||
|
||||
fun update() {
|
||||
synchronized(this) {
|
||||
scheduled = false
|
||||
if (records.isEmpty()) return
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val oldRecords = LongSparseArray<TrafficRecord>()
|
||||
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 = 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,
|
||||
previousId = oldRecord.id)
|
||||
if (isReceive) {
|
||||
record.receivedPackets = columns[0].toLong()
|
||||
record.receivedBytes = columns[1].toLong()
|
||||
} else {
|
||||
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) {
|
||||
Crashlytics.log(Log.WARN, TAG, line)
|
||||
e.printStackTrace()
|
||||
Crashlytics.logException(e)
|
||||
}
|
||||
}
|
||||
for ((_, record) in records) if (record.id == null) AppDatabase.instance.trafficRecordDao.insert(record)
|
||||
foregroundListeners(records.values, oldRecords)
|
||||
scheduleUpdateLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun clean() = synchronized(this) {
|
||||
update()
|
||||
unscheduleUpdateLocked()
|
||||
records.clear()
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ abstract class UpstreamMonitor {
|
||||
|
||||
private fun generateMonitor(): UpstreamMonitor {
|
||||
val upstream = app.pref.getString(KEY, null)
|
||||
return if (upstream.isNullOrEmpty()) VpnMonitor else InterfaceMonitor(upstream)
|
||||
return if (upstream.isNullOrEmpty()) VpnMonitor else InterfaceMonitor(upstream!!)
|
||||
}
|
||||
private var monitor = generateMonitor()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user