Implement VPN over native AP
This commit is contained in:
@@ -2,7 +2,8 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="be.mygod.vpnhotspot">
|
package="be.mygod.vpnhotspot">
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.wifi.direct" android:required="true"/>
|
<uses-feature android:name="android.hardware.wifi"/>
|
||||||
|
<uses-feature android:name="android.hardware.wifi.direct" android:required="false"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
|
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import android.app.Service
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.NetworkInfo
|
import android.net.NetworkInfo
|
||||||
|
import android.net.wifi.WifiConfiguration
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
import android.net.wifi.p2p.WifiP2pGroup
|
import android.net.wifi.p2p.WifiP2pGroup
|
||||||
import android.net.wifi.p2p.WifiP2pInfo
|
import android.net.wifi.p2p.WifiP2pInfo
|
||||||
import android.net.wifi.p2p.WifiP2pManager
|
import android.net.wifi.p2p.WifiP2pManager
|
||||||
@@ -16,7 +18,6 @@ import android.support.v4.content.LocalBroadcastManager
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import be.mygod.vpnhotspot.App.Companion.app
|
import be.mygod.vpnhotspot.App.Companion.app
|
||||||
import java.net.NetworkInterface
|
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
class HotspotService : Service(), WifiP2pManager.ChannelListener {
|
class HotspotService : Service(), WifiP2pManager.ChannelListener {
|
||||||
@@ -24,7 +25,15 @@ class HotspotService : Service(), WifiP2pManager.ChannelListener {
|
|||||||
const val CHANNEL = "hotspot"
|
const val CHANNEL = "hotspot"
|
||||||
const val STATUS_CHANGED = "be.mygod.vpnhotspot.HotspotService.STATUS_CHANGED"
|
const val STATUS_CHANGED = "be.mygod.vpnhotspot.HotspotService.STATUS_CHANGED"
|
||||||
const val KEY_UPSTREAM = "service.upstream"
|
const val KEY_UPSTREAM = "service.upstream"
|
||||||
|
const val KEY_WIFI = "service.wifi"
|
||||||
private const val TAG = "HotspotService"
|
private const val TAG = "HotspotService"
|
||||||
|
// constants from WifiManager
|
||||||
|
private const val WIFI_AP_STATE_CHANGED_ACTION = "android.net.wifi.WIFI_AP_STATE_CHANGED"
|
||||||
|
private const val WIFI_AP_STATE_ENABLED = 13
|
||||||
|
|
||||||
|
private val upstream get() = app.pref.getString(KEY_UPSTREAM, "tun0")
|
||||||
|
private val wifi get() = app.pref.getString(KEY_WIFI, "wlan0")
|
||||||
|
private val dns get() = app.pref.getString("service.dns", "8.8.8.8:53")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Matches the output of dumpsys wifip2p. This part is available since Android 4.2.
|
* Matches the output of dumpsys wifip2p. This part is available since Android 4.2.
|
||||||
@@ -37,10 +46,17 @@ class HotspotService : Service(), WifiP2pManager.ChannelListener {
|
|||||||
* https://android.googlesource.com/platform/frameworks/base.git/+/220871a/core/java/android/net/NetworkInfo.java#415
|
* https://android.googlesource.com/platform/frameworks/base.git/+/220871a/core/java/android/net/NetworkInfo.java#415
|
||||||
*/
|
*/
|
||||||
private val patternNetworkInfo = "^mNetworkInfo .* (isA|a)vailable: (true|false)".toPattern(Pattern.MULTILINE)
|
private val patternNetworkInfo = "^mNetworkInfo .* (isA|a)vailable: (true|false)".toPattern(Pattern.MULTILINE)
|
||||||
|
|
||||||
|
private val isWifiApEnabledMethod = WifiManager::class.java.getDeclaredMethod("isWifiApEnabled")
|
||||||
|
val WifiManager.isWifiApEnabled get() = isWifiApEnabledMethod.invoke(this) as Boolean
|
||||||
|
|
||||||
|
init {
|
||||||
|
isWifiApEnabledMethod.isAccessible = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class Status {
|
enum class Status {
|
||||||
IDLE, STARTING, ACTIVE
|
IDLE, STARTING, ACTIVE_P2P, ACTIVE_AP
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class HotspotBinder : Binder() {
|
inner class HotspotBinder : Binder() {
|
||||||
@@ -48,25 +64,33 @@ class HotspotService : Service(), WifiP2pManager.ChannelListener {
|
|||||||
var data: MainActivity.Data? = null
|
var data: MainActivity.Data? = null
|
||||||
|
|
||||||
fun shutdown() {
|
fun shutdown() {
|
||||||
p2pManager.removeGroup(channel, object : WifiP2pManager.ActionListener {
|
when (status) {
|
||||||
|
Status.ACTIVE_P2P -> p2pManager.removeGroup(channel, object : WifiP2pManager.ActionListener {
|
||||||
override fun onSuccess() = clean()
|
override fun onSuccess() = clean()
|
||||||
override fun onFailure(reason: Int) {
|
override fun onFailure(reason: Int) {
|
||||||
if (reason == WifiP2pManager.BUSY) clean() else { // assuming it's already gone
|
if (reason == WifiP2pManager.BUSY) clean() else { // assuming it's already gone
|
||||||
Toast.makeText(this@HotspotService, "Failed to remove P2P group (${formatReason(reason)})",
|
Toast.makeText(this@HotspotService, "Failed to remove P2P group (${formatReason(reason)})",
|
||||||
Toast.LENGTH_SHORT).show()
|
Toast.LENGTH_SHORT).show()
|
||||||
LocalBroadcastManager.getInstance(this@HotspotService).sendBroadcast(Intent(STATUS_CHANGED))
|
LocalBroadcastManager.getInstance(this@HotspotService)
|
||||||
|
.sendBroadcast(Intent(STATUS_CHANGED))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
else -> clean()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var p2pManager: WifiP2pManager
|
private val wifiManager by lazy { getSystemService(Context.WIFI_SERVICE) as WifiManager }
|
||||||
private lateinit var channel: WifiP2pManager.Channel
|
private val p2pManager by lazy { getSystemService(Context.WIFI_P2P_SERVICE) as WifiP2pManager }
|
||||||
|
private var _channel: WifiP2pManager.Channel? = null
|
||||||
|
private val channel: WifiP2pManager.Channel get() {
|
||||||
|
if (_channel == null) onChannelDisconnected()
|
||||||
|
return _channel!!
|
||||||
|
}
|
||||||
lateinit var group: WifiP2pGroup
|
lateinit var group: WifiP2pGroup
|
||||||
private set
|
private set
|
||||||
var hostAddress: String? = null
|
private var apConfiguration: WifiConfiguration? = null
|
||||||
private set
|
|
||||||
private val binder = HotspotBinder()
|
private val binder = HotspotBinder()
|
||||||
private var receiverRegistered = false
|
private var receiverRegistered = false
|
||||||
private val receiver = broadcastReceiver { _, intent ->
|
private val receiver = broadcastReceiver { _, intent ->
|
||||||
@@ -78,25 +102,30 @@ class HotspotService : Service(), WifiP2pManager.ChannelListener {
|
|||||||
val info = intent.getParcelableExtra<WifiP2pInfo>(WifiP2pManager.EXTRA_WIFI_P2P_INFO)
|
val info = intent.getParcelableExtra<WifiP2pInfo>(WifiP2pManager.EXTRA_WIFI_P2P_INFO)
|
||||||
val net = intent.getParcelableExtra<NetworkInfo>(WifiP2pManager.EXTRA_NETWORK_INFO)
|
val net = intent.getParcelableExtra<NetworkInfo>(WifiP2pManager.EXTRA_NETWORK_INFO)
|
||||||
val group = intent.getParcelableExtra<WifiP2pGroup>(WifiP2pManager.EXTRA_WIFI_P2P_GROUP)
|
val group = intent.getParcelableExtra<WifiP2pGroup>(WifiP2pManager.EXTRA_WIFI_P2P_GROUP)
|
||||||
if (downstream == null) onGroupCreated(info, group)
|
if (routing == null) onGroupCreated(info, group)
|
||||||
this.group = group
|
this.group = group
|
||||||
binder.data?.onGroupChanged()
|
binder.data?.onGroupChanged()
|
||||||
showNotification(group)
|
showNotification(group)
|
||||||
Log.d(TAG, "${intent.action}: $info, $net, $group")
|
Log.d(TAG, "${intent.action}: $info, $net, $group")
|
||||||
}
|
}
|
||||||
|
WIFI_AP_STATE_CHANGED_ACTION ->
|
||||||
|
if (intent.getIntExtra(WifiManager.EXTRA_WIFI_STATE, 0) != WIFI_AP_STATE_ENABLED) clean()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var downstream: String? = null
|
val ssid get() = when (status) {
|
||||||
|
HotspotService.Status.ACTIVE_P2P -> group.networkName
|
||||||
|
HotspotService.Status.ACTIVE_AP -> apConfiguration?.SSID ?: "Unknown"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val password get() = when (status) {
|
||||||
|
HotspotService.Status.ACTIVE_P2P -> group.passphrase
|
||||||
|
HotspotService.Status.ACTIVE_AP -> apConfiguration?.preSharedKey
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
var routing: Routing? = null
|
||||||
private set
|
private set
|
||||||
private val upstream get() = app.pref.getString(KEY_UPSTREAM, "tun0")
|
|
||||||
/**
|
|
||||||
* subnetPrefixLength has been the same forever but this option is here anyways. Source:
|
|
||||||
* https://android.googlesource.com/platform/frameworks/base/+/android-4.0.1_r1/wifi/java/android/net/wifi/p2p/WifiP2pService.java#1028
|
|
||||||
* https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/a8d5e40/service/java/com/android/server/wifi/p2p/WifiP2pServiceImpl.java#2547
|
|
||||||
*/
|
|
||||||
private var subnetPrefixLength: Short = 24
|
|
||||||
private val dns get() = app.pref.getString("service.dns", "8.8.8.8:53")
|
|
||||||
|
|
||||||
var status = Status.IDLE
|
var status = Status.IDLE
|
||||||
private set(value) {
|
private set(value) {
|
||||||
@@ -115,33 +144,21 @@ class HotspotService : Service(), WifiP2pManager.ChannelListener {
|
|||||||
|
|
||||||
override fun onBind(intent: Intent) = binder
|
override fun onBind(intent: Intent) = binder
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
p2pManager = getSystemService(Context.WIFI_P2P_SERVICE) as WifiP2pManager
|
|
||||||
onChannelDisconnected()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onChannelDisconnected() {
|
override fun onChannelDisconnected() {
|
||||||
channel = p2pManager.initialize(this, Looper.getMainLooper(), this)
|
_channel = p2pManager.initialize(this, Looper.getMainLooper(), this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
if (status != Status.IDLE) return START_NOT_STICKY
|
if (status != Status.IDLE) return START_NOT_STICKY
|
||||||
status = Status.STARTING
|
status = Status.STARTING
|
||||||
val matcher = patternNetworkInfo.matcher(loggerSu("dumpsys ${Context.WIFI_P2P_SERVICE}"))
|
val matcher = patternNetworkInfo.matcher(loggerSu("dumpsys ${Context.WIFI_P2P_SERVICE}"))
|
||||||
if (!matcher.find()) {
|
when {
|
||||||
startFailure("Root unavailable")
|
!matcher.find() -> startFailure("Root unavailable")
|
||||||
return START_NOT_STICKY
|
matcher.group(2) == "true" -> {
|
||||||
}
|
unregisterReceiver()
|
||||||
if (matcher.group(2) != "true") {
|
|
||||||
startFailure("Wi-Fi direct unavailable")
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
if (!receiverRegistered) {
|
|
||||||
registerReceiver(receiver, intentFilter(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION,
|
registerReceiver(receiver, intentFilter(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION,
|
||||||
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION))
|
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION))
|
||||||
receiverRegistered = true
|
receiverRegistered = true
|
||||||
}
|
|
||||||
p2pManager.requestGroupInfo(channel, {
|
p2pManager.requestGroupInfo(channel, {
|
||||||
when {
|
when {
|
||||||
it == null -> doStart()
|
it == null -> doStart()
|
||||||
@@ -152,13 +169,33 @@ class HotspotService : Service(), WifiP2pManager.ChannelListener {
|
|||||||
override fun onSuccess() = doStart()
|
override fun onSuccess() = doStart()
|
||||||
override fun onFailure(reason: Int) {
|
override fun onFailure(reason: Int) {
|
||||||
Toast.makeText(this@HotspotService,
|
Toast.makeText(this@HotspotService,
|
||||||
"Failed to remove old P2P group (${formatReason(reason)})", Toast.LENGTH_SHORT)
|
"Failed to remove old P2P group (${formatReason(reason)})",
|
||||||
.show()
|
Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
wifiManager.isWifiApEnabled -> {
|
||||||
|
unregisterReceiver()
|
||||||
|
registerReceiver(receiver, intentFilter(WIFI_AP_STATE_CHANGED_ACTION))
|
||||||
|
receiverRegistered = true
|
||||||
|
val routing = try {
|
||||||
|
Routing(upstream, wifi)
|
||||||
|
} catch (_: Routing.InterfaceNotFoundException) {
|
||||||
|
startFailure(getString(R.string.exception_interface_not_found))
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}.apRule().forward().dnsRedirect(dns)
|
||||||
|
if (routing.start()) {
|
||||||
|
this.routing = routing
|
||||||
|
apConfiguration = NetUtils.loadApConfiguration()
|
||||||
|
status = Status.ACTIVE_AP
|
||||||
|
showNotification()
|
||||||
|
} else startFailure("Something went wrong, please check logcat.")
|
||||||
|
}
|
||||||
|
else -> startFailure("Wi-Fi direct unavailable and hotspot disabled, please enable either")
|
||||||
|
}
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,72 +210,50 @@ class HotspotService : Service(), WifiP2pManager.ChannelListener {
|
|||||||
})
|
})
|
||||||
private fun doStart(group: WifiP2pGroup) {
|
private fun doStart(group: WifiP2pGroup) {
|
||||||
this.group = group
|
this.group = group
|
||||||
status = Status.ACTIVE
|
status = Status.ACTIVE_P2P
|
||||||
showNotification(group)
|
showNotification(group)
|
||||||
}
|
}
|
||||||
private fun showNotification(group: WifiP2pGroup? = null) {
|
private fun showNotification(group: WifiP2pGroup? = null) {
|
||||||
val deviceCount = group?.clientList?.size ?: 0
|
val builder = NotificationCompat.Builder(this, CHANNEL)
|
||||||
startForeground(1,
|
|
||||||
NotificationCompat.Builder(this@HotspotService, CHANNEL)
|
|
||||||
.setWhen(0)
|
.setWhen(0)
|
||||||
.setColor(ContextCompat.getColor(this@HotspotService, R.color.colorPrimary))
|
.setColor(ContextCompat.getColor(this, R.color.colorPrimary))
|
||||||
.setContentTitle(group?.networkName)
|
.setContentTitle(group?.networkName ?: ssid ?: "Connecting...")
|
||||||
.setContentText(group?.passphrase)
|
|
||||||
.setSubText(resources.getQuantityString(R.plurals.notification_connected_devices,
|
|
||||||
deviceCount, deviceCount))
|
|
||||||
.setSmallIcon(R.drawable.ic_device_wifi_tethering)
|
.setSmallIcon(R.drawable.ic_device_wifi_tethering)
|
||||||
.setContentIntent(PendingIntent.getActivity(this, 0,
|
.setContentIntent(PendingIntent.getActivity(this, 0,
|
||||||
Intent(this, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT))
|
Intent(this, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT))
|
||||||
.build())
|
if (group != null) builder.setContentText(resources.getQuantityString(R.plurals.notification_connected_devices,
|
||||||
|
group.clientList.size, group.clientList.size))
|
||||||
|
startForeground(1, builder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onGroupCreated(info: WifiP2pInfo, group: WifiP2pGroup) {
|
private fun onGroupCreated(info: WifiP2pInfo, group: WifiP2pGroup) {
|
||||||
val owner = info.groupOwnerAddress
|
val owner = info.groupOwnerAddress
|
||||||
val hostAddress = owner?.hostAddress
|
|
||||||
val downstream = group.`interface`
|
val downstream = group.`interface`
|
||||||
if (!info.groupFormed || !info.isGroupOwner || downstream == null || hostAddress == null) return
|
if (!info.groupFormed || !info.isGroupOwner || downstream == null || owner == null) return
|
||||||
this.downstream = downstream
|
receiverRegistered = true
|
||||||
this.hostAddress = hostAddress
|
val routing = try {
|
||||||
var subnetPrefixLength = NetworkInterface.getByName(downstream)?.interfaceAddresses
|
Routing(upstream, downstream, owner)
|
||||||
?.singleOrNull { it.address == owner }?.networkPrefixLength
|
} catch (_: Routing.InterfaceNotFoundException) {
|
||||||
if (subnetPrefixLength == null) {
|
startFailure(getString(R.string.exception_interface_not_found))
|
||||||
Log.w(TAG, "Unable to find prefix length of interface $downstream, 24 is assumed")
|
return
|
||||||
subnetPrefixLength = 24
|
}.p2pRule().forward().dnsRedirect(dns)
|
||||||
}
|
if (routing.start()) {
|
||||||
this.subnetPrefixLength = subnetPrefixLength
|
this.routing = routing
|
||||||
if (noisySu("echo 1 >/proc/sys/net/ipv4/ip_forward",
|
|
||||||
"ip route add default dev $upstream scope link table 62",
|
|
||||||
"ip route add $hostAddress/$subnetPrefixLength dev $downstream scope link table 62",
|
|
||||||
"ip route add broadcast 255.255.255.255 dev $downstream scope link table 62",
|
|
||||||
"ip rule add iif $downstream lookup 62",
|
|
||||||
"iptables -N vpnhotspot_fwd",
|
|
||||||
"iptables -A vpnhotspot_fwd -i $upstream -o $downstream -m state --state ESTABLISHED,RELATED -j ACCEPT",
|
|
||||||
"iptables -A vpnhotspot_fwd -i $downstream -o $upstream -j ACCEPT",
|
|
||||||
"iptables -I FORWARD -j vpnhotspot_fwd",
|
|
||||||
"iptables -t nat -A PREROUTING -i $downstream -p tcp -d $hostAddress --dport 53 -j DNAT --to-destination $dns",
|
|
||||||
"iptables -t nat -A PREROUTING -i $downstream -p udp -d $hostAddress --dport 53 -j DNAT --to-destination $dns")) {
|
|
||||||
doStart(group)
|
doStart(group)
|
||||||
} else startFailure("Something went wrong, please check logcat.")
|
} else startFailure("Something went wrong, please check logcat.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clean() {
|
private fun unregisterReceiver() {
|
||||||
if (receiverRegistered) {
|
if (receiverRegistered) {
|
||||||
unregisterReceiver(receiver)
|
unregisterReceiver(receiver)
|
||||||
receiverRegistered = false
|
receiverRegistered = false
|
||||||
}
|
}
|
||||||
if (downstream != null)
|
}
|
||||||
if (noisySu("iptables -t nat -D PREROUTING -i $downstream -p tcp -d $hostAddress --dport 53 -j DNAT --to-destination $dns",
|
private fun clean() {
|
||||||
"iptables -t nat -D PREROUTING -i $downstream -p udp -d $hostAddress --dport 53 -j DNAT --to-destination $dns",
|
unregisterReceiver()
|
||||||
"iptables -D FORWARD -j vpnhotspot_fwd",
|
if (routing?.stop() == false)
|
||||||
"iptables -F vpnhotspot_fwd",
|
Toast.makeText(this, "Something went wrong, please check logcat.", Toast.LENGTH_SHORT).show()
|
||||||
"iptables -X vpnhotspot_fwd",
|
routing = null
|
||||||
"ip rule del iif $downstream lookup 62",
|
|
||||||
"ip route del broadcast 255.255.255.255 dev $downstream scope link table 62",
|
|
||||||
"ip route del $hostAddress/$subnetPrefixLength dev $downstream scope link table 62",
|
|
||||||
"ip route del default dev $upstream scope link table 62")) {
|
|
||||||
hostAddress = null
|
|
||||||
downstream = null
|
|
||||||
} else Toast.makeText(this, "Something went wrong, please check logcat.", Toast.LENGTH_SHORT).show()
|
|
||||||
status = Status.IDLE
|
status = Status.IDLE
|
||||||
stopForeground(true)
|
stopForeground(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,13 @@ class MainActivity : AppCompatActivity(), ServiceConnection, Toolbar.OnMenuItemC
|
|||||||
inner class Data : BaseObservable() {
|
inner class Data : BaseObservable() {
|
||||||
val switchEnabled: Boolean
|
val switchEnabled: Boolean
|
||||||
@Bindable get() = when (binder?.service?.status) {
|
@Bindable get() = when (binder?.service?.status) {
|
||||||
HotspotService.Status.IDLE -> true
|
HotspotService.Status.IDLE, HotspotService.Status.ACTIVE_P2P, HotspotService.Status.ACTIVE_AP -> true
|
||||||
HotspotService.Status.ACTIVE -> true
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
var serviceStarted: Boolean
|
var serviceStarted: Boolean
|
||||||
@Bindable get() = when (binder?.service?.status) {
|
@Bindable get() = when (binder?.service?.status) {
|
||||||
HotspotService.Status.STARTING -> true
|
HotspotService.Status.STARTING, HotspotService.Status.ACTIVE_P2P, HotspotService.Status.ACTIVE_AP ->
|
||||||
HotspotService.Status.ACTIVE -> true
|
true
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
set(value) {
|
set(value) {
|
||||||
@@ -40,13 +39,12 @@ class MainActivity : AppCompatActivity(), ServiceConnection, Toolbar.OnMenuItemC
|
|||||||
HotspotService.Status.IDLE ->
|
HotspotService.Status.IDLE ->
|
||||||
if (value) ContextCompat.startForegroundService(this@MainActivity,
|
if (value) ContextCompat.startForegroundService(this@MainActivity,
|
||||||
Intent(this@MainActivity, HotspotService::class.java))
|
Intent(this@MainActivity, HotspotService::class.java))
|
||||||
HotspotService.Status.ACTIVE -> if (!value) binder.shutdown()
|
HotspotService.Status.ACTIVE_P2P, HotspotService.Status.ACTIVE_AP -> if (!value) binder.shutdown()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val running get() = binder?.service?.status == HotspotService.Status.ACTIVE
|
val ssid @Bindable get() = binder?.service?.ssid ?: "Service inactive"
|
||||||
val ssid: String @Bindable get() = if (running) binder!!.service.group.networkName else ""
|
val password @Bindable get() = binder?.service?.password ?: ""
|
||||||
val password: String @Bindable get() = if (running) binder!!.service.group.passphrase else ""
|
|
||||||
|
|
||||||
fun onStatusChanged() {
|
fun onStatusChanged() {
|
||||||
notifyPropertyChanged(BR.switchEnabled)
|
notifyPropertyChanged(BR.switchEnabled)
|
||||||
@@ -69,11 +67,11 @@ class MainActivity : AppCompatActivity(), ServiceConnection, Toolbar.OnMenuItemC
|
|||||||
private lateinit var arpCache: Map<String, String>
|
private lateinit var arpCache: Map<String, String>
|
||||||
|
|
||||||
fun fetchClients() {
|
fun fetchClients() {
|
||||||
if (data.running) {
|
val binder = binder
|
||||||
val binder = binder!!
|
if (binder?.service?.status == HotspotService.Status.ACTIVE_P2P) {
|
||||||
owner = binder.service.group.owner
|
owner = binder.service.group.owner
|
||||||
clients = binder.service.group.clientList
|
clients = binder.service.group.clientList
|
||||||
arpCache = NetUtils.arp(binder.service.downstream)
|
arpCache = NetUtils.arp(binder.service.routing?.downstream)
|
||||||
} else owner = null
|
} else owner = null
|
||||||
notifyDataSetChanged() // recreate everything
|
notifyDataSetChanged() // recreate everything
|
||||||
binding.swipeRefresher.isRefreshing = false
|
binding.swipeRefresher.isRefreshing = false
|
||||||
@@ -89,7 +87,7 @@ class MainActivity : AppCompatActivity(), ServiceConnection, Toolbar.OnMenuItemC
|
|||||||
}
|
}
|
||||||
holder.binding.device = device
|
holder.binding.device = device
|
||||||
holder.binding.ipAddress = when (position) {
|
holder.binding.ipAddress = when (position) {
|
||||||
0 -> binder?.service?.hostAddress
|
0 -> binder?.service?.routing?.hostAddress
|
||||||
else -> arpCache[device?.deviceAddress]
|
else -> arpCache[device?.deviceAddress]
|
||||||
}
|
}
|
||||||
holder.binding.executePendingBindings()
|
holder.binding.executePendingBindings()
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
package be.mygod.vpnhotspot
|
package be.mygod.vpnhotspot
|
||||||
|
|
||||||
|
import android.net.wifi.WifiConfiguration
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.DataInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
object NetUtils {
|
object NetUtils {
|
||||||
|
private const val TAG = "NetUtils"
|
||||||
private val spaces = " +".toPattern()
|
private val spaces = " +".toPattern()
|
||||||
private val mac = "^([0-9a-f]{2}:){5}[0-9a-f]{2}$".toPattern()
|
private val mac = "^([0-9a-f]{2}:){5}[0-9a-f]{2}$".toPattern()
|
||||||
|
|
||||||
@@ -14,4 +19,34 @@ object NetUtils {
|
|||||||
mac.matcher(it[3]).matches() }
|
mac.matcher(it[3]).matches() }
|
||||||
.associateBy({ it[3] }, { it[0] })
|
.associateBy({ it[3] }, { it[0] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load AP configuration from persistent storage.
|
||||||
|
*
|
||||||
|
* Based on: https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/0cafbe0/service/java/com/android/server/wifi/WifiApConfigStore.java#138
|
||||||
|
*/
|
||||||
|
fun loadApConfiguration(): WifiConfiguration? = try {
|
||||||
|
loggerSuStream("cat /data/misc/wifi/softap.conf").buffered().use {
|
||||||
|
val data = DataInputStream(it)
|
||||||
|
val version = data.readInt()
|
||||||
|
when (version) {
|
||||||
|
1, 2 -> {
|
||||||
|
val config = WifiConfiguration()
|
||||||
|
config.SSID = data.readUTF()
|
||||||
|
if (version >= 2) data.readLong() // apBand and apChannel
|
||||||
|
val authType = data.readInt()
|
||||||
|
config.allowedKeyManagement.set(authType)
|
||||||
|
if (authType != WifiConfiguration.KeyMgmt.NONE) config.preSharedKey = data.readUTF()
|
||||||
|
config
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.e(TAG, "Bad version on hotspot configuration file $version")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Error reading hotspot configuration $e")
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
77
mobile/src/main/java/be/mygod/vpnhotspot/Routing.kt
Normal file
77
mobile/src/main/java/be/mygod/vpnhotspot/Routing.kt
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package be.mygod.vpnhotspot
|
||||||
|
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class Routing(private val upstream: String, val downstream: String, ownerAddress: InetAddress? = null) {
|
||||||
|
companion object {
|
||||||
|
fun clean() = noisySu(
|
||||||
|
"iptables -t nat -F PREROUTING",
|
||||||
|
"while iptables -D FORWARD -j vpnhotspot_fwd; do done",
|
||||||
|
"iptables -F vpnhotspot_fwd",
|
||||||
|
"iptables -X vpnhotspot_fwd",
|
||||||
|
"while ip rule del lookup 62; do done",
|
||||||
|
"ip route flush table 62",
|
||||||
|
"while ip rule del priority 17999; do done")
|
||||||
|
}
|
||||||
|
|
||||||
|
class InterfaceNotFoundException : IOException()
|
||||||
|
|
||||||
|
val hostAddress: String
|
||||||
|
private val subnetPrefixLength: Short
|
||||||
|
private val startScript = LinkedList<String>()
|
||||||
|
private val stopScript = LinkedList<String>()
|
||||||
|
init {
|
||||||
|
val address = NetworkInterface.getByName(downstream)?.interfaceAddresses
|
||||||
|
?.singleOrNull { if (ownerAddress == null) it.address is Inet4Address else it.address == ownerAddress }
|
||||||
|
?: throw InterfaceNotFoundException()
|
||||||
|
hostAddress = address.address.hostAddress
|
||||||
|
subnetPrefixLength = address.networkPrefixLength
|
||||||
|
}
|
||||||
|
|
||||||
|
fun p2pRule(): Routing {
|
||||||
|
startScript.add("echo 1 >/proc/sys/net/ipv4/ip_forward") // Wi-Fi direct doesn't enable ip_forward
|
||||||
|
startScript.add("ip route add default dev $upstream scope link table 62")
|
||||||
|
startScript.add("ip route add $hostAddress/$subnetPrefixLength dev $downstream scope link table 62")
|
||||||
|
startScript.add("ip route add broadcast 255.255.255.255 dev $downstream scope link table 62")
|
||||||
|
startScript.add("ip rule add iif $downstream lookup 62")
|
||||||
|
stopScript.addFirst("ip route del default dev $upstream scope link table 62")
|
||||||
|
stopScript.addFirst("ip route del $hostAddress/$subnetPrefixLength dev $downstream scope link table 62")
|
||||||
|
stopScript.addFirst("ip route del broadcast 255.255.255.255 dev $downstream scope link table 62")
|
||||||
|
stopScript.addFirst("ip rule del iif $downstream lookup 62")
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Since Android 5.0, RULE_PRIORITY_TETHERING = 18000.
|
||||||
|
* https://android.googlesource.com/platform/system/netd/+/b9baf26/server/RouteController.cpp#65 */
|
||||||
|
fun apRule(): Routing {
|
||||||
|
startScript.add("ip rule add from all iif $downstream lookup $upstream priority 17999")
|
||||||
|
stopScript.addFirst("ip rule del from all iif $downstream lookup $upstream priority 17999")
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forward(): Routing {
|
||||||
|
startScript.add("iptables -N vpnhotspot_fwd")
|
||||||
|
startScript.add("iptables -A vpnhotspot_fwd -i $upstream -o $downstream -m state --state ESTABLISHED,RELATED -j ACCEPT")
|
||||||
|
startScript.add("iptables -A vpnhotspot_fwd -i $downstream -o $upstream -j ACCEPT")
|
||||||
|
startScript.add("iptables -I FORWARD -j vpnhotspot_fwd")
|
||||||
|
stopScript.addFirst("iptables -X vpnhotspot_fwd")
|
||||||
|
stopScript.addFirst("iptables -F vpnhotspot_fwd")
|
||||||
|
stopScript.addFirst("iptables -D FORWARD -j vpnhotspot_fwd")
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dnsRedirect(dns: String): Routing {
|
||||||
|
startScript.add("iptables -t nat -A PREROUTING -i $downstream -p tcp -d $hostAddress --dport 53 -j DNAT --to-destination $dns")
|
||||||
|
startScript.add("iptables -t nat -A PREROUTING -i $downstream -p udp -d $hostAddress --dport 53 -j DNAT --to-destination $dns")
|
||||||
|
stopScript.addFirst("iptables -t nat -D PREROUTING -i $downstream -p tcp -d $hostAddress --dport 53 -j DNAT --to-destination $dns")
|
||||||
|
stopScript.addFirst("iptables -t nat -D PREROUTING -i $downstream -p udp -d $hostAddress --dport 53 -j DNAT --to-destination $dns")
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start() = noisySu(startScript)
|
||||||
|
fun stop() = noisySu(stopScript)
|
||||||
|
}
|
||||||
@@ -27,12 +27,7 @@ class SettingsFragment : PreferenceFragmentCompatDividers(), ServiceConnection {
|
|||||||
addPreferencesFromResource(R.xml.pref_settings)
|
addPreferencesFromResource(R.xml.pref_settings)
|
||||||
service = findPreference("service")
|
service = findPreference("service")
|
||||||
findPreference("service.clean").setOnPreferenceClickListener {
|
findPreference("service.clean").setOnPreferenceClickListener {
|
||||||
noisySu("iptables -t nat -F PREROUTING",
|
Routing.clean()
|
||||||
"while iptables -D FORWARD -j vpnhotspot_fwd; do done",
|
|
||||||
"iptables -F vpnhotspot_fwd",
|
|
||||||
"iptables -X vpnhotspot_fwd",
|
|
||||||
"while ip rule del lookup 62; do done",
|
|
||||||
"ip route flush table 62")
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
findPreference("misc.logcat").setOnPreferenceClickListener {
|
findPreference("misc.logcat").setOnPreferenceClickListener {
|
||||||
@@ -59,7 +54,13 @@ class SettingsFragment : PreferenceFragmentCompatDividers(), ServiceConnection {
|
|||||||
Bundle().put(AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat.KEY_SUGGESTIONS,
|
Bundle().put(AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat.KEY_SUGGESTIONS,
|
||||||
NetworkInterface.getNetworkInterfaces().asSequence()
|
NetworkInterface.getNetworkInterfaces().asSequence()
|
||||||
.filter { it.isUp && !it.isLoopback && it.interfaceAddresses.isNotEmpty() }
|
.filter { it.isUp && !it.isLoopback && it.interfaceAddresses.isNotEmpty() }
|
||||||
.map { it.name }.toList().toTypedArray()))
|
.map { it.name }.sorted().toList().toTypedArray()))
|
||||||
|
HotspotService.KEY_WIFI -> displayPreferenceDialog(
|
||||||
|
AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat(), HotspotService.KEY_WIFI,
|
||||||
|
Bundle().put(AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat.KEY_SUGGESTIONS,
|
||||||
|
NetworkInterface.getNetworkInterfaces().asSequence()
|
||||||
|
.filter { !it.isLoopback } // wlan0 is down in airplane mode
|
||||||
|
.map { it.name }.sorted().toList().toTypedArray()))
|
||||||
else -> super.onDisplayPreferenceDialog(preference)
|
else -> super.onDisplayPreferenceDialog(preference)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.content.Intent
|
|||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
fun broadcastReceiver(receiver: (Context, Intent) -> Unit) = object : BroadcastReceiver() {
|
fun broadcastReceiver(receiver: (Context, Intent) -> Unit) = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) = receiver(context, intent)
|
override fun onReceive(context: Context, intent: Intent) = receiver(context, intent)
|
||||||
@@ -24,21 +25,23 @@ fun Bundle.put(key: String, map: Array<String>): Bundle {
|
|||||||
|
|
||||||
const val NOISYSU_TAG = "NoisySU"
|
const val NOISYSU_TAG = "NoisySU"
|
||||||
const val NOISYSU_SUFFIX = "SUCCESS\n"
|
const val NOISYSU_SUFFIX = "SUCCESS\n"
|
||||||
fun loggerSu(vararg commands: String): String {
|
fun loggerSuStream(command: String): InputStream {
|
||||||
val process = ProcessBuilder("su", "-c", commands.joinToString("\n"))
|
val process = ProcessBuilder("su", "-c", command)
|
||||||
.redirectErrorStream(true)
|
.redirectErrorStream(true)
|
||||||
.start()
|
.start()
|
||||||
process.waitFor()
|
process.waitFor()
|
||||||
val err = process.errorStream.bufferedReader().use { it.readText() }
|
val err = process.errorStream.bufferedReader().use { it.readText() }
|
||||||
if (!err.isBlank()) Log.e(NOISYSU_TAG, err)
|
if (!err.isBlank()) Log.e(NOISYSU_TAG, err)
|
||||||
return process.inputStream.bufferedReader().use { it.readText() }
|
return process.inputStream
|
||||||
}
|
}
|
||||||
fun noisySu(vararg commands: String): Boolean {
|
fun loggerSu(command: String): String = loggerSuStream(command).bufferedReader().use { it.readText() }
|
||||||
|
fun noisySu(commands: Iterable<String>): Boolean {
|
||||||
var out = loggerSu("""function noisy() { "$@" || echo "$@" exited with $?; }
|
var out = loggerSu("""function noisy() { "$@" || echo "$@" exited with $?; }
|
||||||
${commands.joinToString("\n") { if (it.startsWith("while ")) it else "noisy $it" }}
|
${commands.joinToString("\n") { if (it.startsWith("while ")) it else "noisy $it" }}
|
||||||
echo $NOISYSU_SUFFIX""")
|
echo $NOISYSU_SUFFIX""")
|
||||||
val result = out != NOISYSU_SUFFIX
|
val result = out == NOISYSU_SUFFIX
|
||||||
out = out.removeSuffix(NOISYSU_SUFFIX)
|
out = out.removeSuffix(NOISYSU_SUFFIX)
|
||||||
if (!out.isBlank()) Log.i(NOISYSU_TAG, out)
|
if (!out.isBlank()) Log.i(NOISYSU_TAG, out)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
fun noisySu(vararg commands: String) = noisySu(commands.asIterable())
|
||||||
|
|||||||
@@ -5,4 +5,5 @@
|
|||||||
<item quantity="one">1 connected device</item>
|
<item quantity="one">1 connected device</item>
|
||||||
<item quantity="other">%d connected devices</item>
|
<item quantity="other">%d connected devices</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="exception_interface_not_found">Fatal: Downstream interface not found</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
android:title="Upstream interface"
|
android:title="Upstream interface"
|
||||||
android:summary="%s"
|
android:summary="%s"
|
||||||
android:defaultValue="tun0"/>
|
android:defaultValue="tun0"/>
|
||||||
|
<be.mygod.vpnhotspot.preference.AlwaysAutoCompleteEditTextPreference
|
||||||
|
android:key="service.wifi"
|
||||||
|
android:title="Wi-Fi interface"
|
||||||
|
android:summary="%s"
|
||||||
|
android:defaultValue="wlan0"/>
|
||||||
<AutoSummaryEditTextPreference
|
<AutoSummaryEditTextPreference
|
||||||
android:key="service.dns"
|
android:key="service.dns"
|
||||||
android:title="Downstream DNS server:port"
|
android:title="Downstream DNS server:port"
|
||||||
|
|||||||
Reference in New Issue
Block a user