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:
Mygod
2018-10-02 21:12:19 +08:00
committed by GitHub
parent 16d1eda0d4
commit 38f95a382e
35 changed files with 946 additions and 98 deletions

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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"

View File

@@ -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()
}
}
}

View 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()
}
}

View File

@@ -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()