mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 18:18:55 +00:00
feat(android): Tunnel lifecycle implementation (#2061)
Fixes #1970 - Implemented TunnelService and added dummy routes. - Updated service lifecycle and implemented foreground notification. - Updated TunnelManager to observe events and propagate them to its listeners.
This commit is contained in:
@@ -114,7 +114,7 @@ dependencies {
|
||||
|
||||
// Moshi
|
||||
implementation "com.squareup.moshi:moshi-kotlin:1.12.0"
|
||||
implementation 'com.squareup.moshi:moshi:1.12.0'
|
||||
implementation "com.squareup.moshi:moshi:1.12.0"
|
||||
|
||||
// Gson
|
||||
implementation "com.google.code.gson:gson:2.9.0"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- XXX: Set usesCleartextTraffic to false for added security when APIMock is removed or served over https -->
|
||||
|
||||
|
||||
@@ -12,9 +12,5 @@ internal interface PreferenceRepository {
|
||||
|
||||
fun saveToken(value: String): Flow<Unit>
|
||||
|
||||
fun saveIsConnectedSync(value: Boolean)
|
||||
|
||||
fun saveIsConnected(value: Boolean): Flow<Unit>
|
||||
|
||||
fun validateCsrfToken(value: String): Flow<Boolean>
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ internal class PreferenceRepositoryImpl @Inject constructor(
|
||||
|
||||
override fun getConfigSync(): Config = Config(
|
||||
accountId = sharedPreferences.getString(ACCOUNT_ID_KEY, null),
|
||||
isConnected = sharedPreferences.getBoolean(IS_CONNECTED_KEY, false),
|
||||
token = sharedPreferences.getString(TOKEN_KEY, null),
|
||||
)
|
||||
|
||||
@@ -41,19 +40,6 @@ internal class PreferenceRepositoryImpl @Inject constructor(
|
||||
)
|
||||
}.flowOn(coroutineDispatcher)
|
||||
|
||||
override fun saveIsConnectedSync(value: Boolean) {
|
||||
sharedPreferences
|
||||
.edit()
|
||||
.putBoolean(IS_CONNECTED_KEY, value)
|
||||
.apply()
|
||||
}
|
||||
|
||||
override fun saveIsConnected(value: Boolean): Flow<Unit> = flow {
|
||||
emit(
|
||||
saveIsConnectedSync(value)
|
||||
)
|
||||
}.flowOn(coroutineDispatcher)
|
||||
|
||||
override fun validateCsrfToken(value: String): Flow<Boolean> = flow {
|
||||
val token = sharedPreferences.getString(CSRF_KEY, "")
|
||||
emit(token == value)
|
||||
@@ -61,7 +47,6 @@ internal class PreferenceRepositoryImpl @Inject constructor(
|
||||
|
||||
companion object {
|
||||
private const val ACCOUNT_ID_KEY = "accountId"
|
||||
private const val IS_CONNECTED_KEY = "isConnected"
|
||||
private const val TOKEN_KEY = "token"
|
||||
private const val CSRF_KEY = "csrf"
|
||||
}
|
||||
|
||||
@@ -6,15 +6,10 @@ import android.content.SharedPreferences
|
||||
import android.content.res.Resources
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dev.firezone.android.core.domain.preference.GetConfigUseCase
|
||||
import dev.firezone.android.core.domain.preference.SaveIsConnectedUseCase
|
||||
import dev.firezone.android.tunnel.TunnelManager
|
||||
|
||||
internal const val ENCRYPTED_SHARED_PREFERENCES = "encryptedSharedPreferences"
|
||||
|
||||
@@ -39,12 +34,4 @@ object AppModule {
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
@Provides
|
||||
internal fun provideTunnelManager(
|
||||
@ApplicationContext appContext: Context,
|
||||
getConfigUseCase: GetConfigUseCase,
|
||||
saveIsConnectedUseCase: SaveIsConnectedUseCase,
|
||||
moshi: Moshi,
|
||||
): TunnelManager = TunnelManager(appContext, getConfigUseCase, saveIsConnectedUseCase, moshi)
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package dev.firezone.android.core.domain.preference
|
||||
|
||||
import dev.firezone.android.core.data.PreferenceRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class SaveIsConnectedUseCase @Inject constructor(
|
||||
private val repository: PreferenceRepository
|
||||
) {
|
||||
operator fun invoke(isConnected: Boolean) {
|
||||
repository.saveIsConnectedSync(isConnected)
|
||||
}
|
||||
|
||||
fun sync(isConnected: Boolean) {
|
||||
repository.saveIsConnectedSync(isConnected)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dev.firezone.android.tunnel.callback.TunnelListener
|
||||
import dev.firezone.android.tunnel.TunnelManager
|
||||
import dev.firezone.android.tunnel.model.Resource
|
||||
import dev.firezone.android.tunnel.model.Tunnel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
@@ -25,41 +26,17 @@ internal class SessionViewModel @Inject constructor(
|
||||
val actionLiveData: LiveData<ViewAction> = actionMutableLiveData
|
||||
|
||||
private val tunnelListener = object: TunnelListener {
|
||||
override fun onSetInterfaceConfig(
|
||||
tunnelAddressIPv4: String,
|
||||
tunnelAddressIPv6: String,
|
||||
dnsAddress: String,
|
||||
dnsFallbackStrategy: String
|
||||
) {
|
||||
//TODO("Not yet implemented")
|
||||
override fun onTunnelStateUpdate(state: Tunnel.State) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun onTunnelReady(): Boolean {
|
||||
//TODO("Not yet implemented")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onAddRoute(cidrAddress: String) {
|
||||
//TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun onRemoveRoute(cidrAddress: String) {
|
||||
//TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun onUpdateResources(resources: List<Resource>) {
|
||||
//TODO("Not yet implemented")
|
||||
override fun onResourcesUpdate(resources: List<Resource>) {
|
||||
Log.d("TunnelManager", "onUpdateResources: $resources")
|
||||
_uiState.value = _uiState.value.copy (
|
||||
resources = resources
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDisconnect(error: String?): Boolean {
|
||||
//TODO("Not yet implemented")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onError(error: String): Boolean {
|
||||
//TODO("Not yet implemented")
|
||||
return true
|
||||
|
||||
@@ -2,113 +2,29 @@ package dev.firezone.android.tunnel
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.provider.Settings
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import com.google.firebase.installations.FirebaseInstallations
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.adapter
|
||||
import dev.firezone.android.BuildConfig
|
||||
import dev.firezone.android.core.domain.preference.GetConfigUseCase
|
||||
import dev.firezone.android.core.domain.preference.SaveIsConnectedUseCase
|
||||
import dev.firezone.android.tunnel.callback.ConnlibCallback
|
||||
import dev.firezone.android.tunnel.model.Resource
|
||||
import dev.firezone.android.tunnel.callback.TunnelListener
|
||||
import dev.firezone.android.tunnel.data.TunnelRepository
|
||||
import dev.firezone.android.tunnel.model.Tunnel
|
||||
import dev.firezone.android.tunnel.model.TunnelConfig
|
||||
import java.lang.ref.WeakReference
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Paths
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
internal class TunnelManager @Inject constructor(
|
||||
private val appContext: Context,
|
||||
private val getConfigUseCase: GetConfigUseCase,
|
||||
private val saveIsConnectedUseCase: SaveIsConnectedUseCase,
|
||||
private val moshi: Moshi,
|
||||
private val tunnelRepository: TunnelRepository,
|
||||
) {
|
||||
|
||||
private var activeTunnel: Tunnel? = null
|
||||
|
||||
private val listeners: MutableSet<WeakReference<TunnelListener>> = mutableSetOf()
|
||||
|
||||
private val callback: ConnlibCallback = object: ConnlibCallback {
|
||||
override fun onUpdateResources(resourceListJSON: String) {
|
||||
// TODO: Call into client app to update resources list and routing table
|
||||
Log.d(TAG, "onUpdateResources: $resourceListJSON")
|
||||
moshi.adapter<List<Resource>>().fromJson(resourceListJSON)?.let { resources ->
|
||||
listeners.onEach {
|
||||
it.get()?.onUpdateResources(resources)
|
||||
}
|
||||
private val tunnelRepositoryListener = SharedPreferences.OnSharedPreferenceChangeListener { _, s ->
|
||||
if (s == TunnelRepository.RESOURCES_KEY) {
|
||||
listeners.forEach {
|
||||
it.get()?.onResourcesUpdate(tunnelRepository.getResources())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSetInterfaceConfig(
|
||||
tunnelAddressIPv4: String,
|
||||
tunnelAddressIPv6: String,
|
||||
dnsAddress: String,
|
||||
dnsFallbackStrategy: String
|
||||
): Int {
|
||||
Log.d(TAG, "onSetInterfaceConfig: [IPv4:$tunnelAddressIPv4] [IPv6:$tunnelAddressIPv6] [dns:$dnsAddress] [dnsFallbackStrategy:$dnsFallbackStrategy]")
|
||||
|
||||
val tunnel = Tunnel(
|
||||
config = TunnelConfig(
|
||||
tunnelAddressIPv4, tunnelAddressIPv6, dnsAddress, dnsFallbackStrategy
|
||||
)
|
||||
)
|
||||
|
||||
listeners.onEach {
|
||||
it.get()?.onSetInterfaceConfig(tunnelAddressIPv4, tunnelAddressIPv6, dnsAddress, dnsFallbackStrategy)
|
||||
}
|
||||
|
||||
return buildVpnService(tunnelAddressIPv4, tunnelAddressIPv6).establish()?.detachFd() ?: -1
|
||||
}
|
||||
|
||||
override fun onTunnelReady(): Boolean {
|
||||
Log.d(TAG, "onTunnelReady")
|
||||
|
||||
listeners.onEach {
|
||||
it.get()?.onTunnelReady()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onError(error: String): Boolean {
|
||||
Log.d(TAG, "onError: $error")
|
||||
|
||||
listeners.onEach {
|
||||
it.get()?.onError(error)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onAddRoute(cidrAddress: String) {
|
||||
Log.d(TAG, "onAddRoute: $cidrAddress")
|
||||
|
||||
listeners.onEach {
|
||||
it.get()?.onAddRoute(cidrAddress)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRemoveRoute(cidrAddress: String) {
|
||||
Log.d(TAG, "onRemoveRoute: $cidrAddress")
|
||||
|
||||
listeners.onEach {
|
||||
it.get()?.onRemoveRoute(cidrAddress)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnect(error: String?): Boolean {
|
||||
Log.d(TAG, "onDisconnect $error")
|
||||
|
||||
listeners.onEach {
|
||||
it.get()?.onDisconnect(error)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fun addListener(listener: TunnelListener) {
|
||||
@@ -119,6 +35,9 @@ internal class TunnelManager @Inject constructor(
|
||||
if (!contains) {
|
||||
listeners.add(WeakReference(listener))
|
||||
}
|
||||
|
||||
tunnelRepository.addListener(tunnelRepositoryListener)
|
||||
tunnelRepository.setState(Tunnel.State.Connecting)
|
||||
}
|
||||
|
||||
fun removeListener(listener: TunnelListener) {
|
||||
@@ -128,99 +47,39 @@ internal class TunnelManager @Inject constructor(
|
||||
it.clear()
|
||||
listeners.remove(it)
|
||||
}
|
||||
|
||||
if (listeners.isEmpty()) {
|
||||
tunnelRepository.removeListener(tunnelRepositoryListener)
|
||||
}
|
||||
}
|
||||
|
||||
fun startVPN() {
|
||||
fun connect() {
|
||||
startVPNService()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
stopVPNService()
|
||||
}
|
||||
|
||||
private fun startVPNService() {
|
||||
val intent = Intent(appContext, TunnelService::class.java)
|
||||
intent.action = TunnelService.ACTION_CONNECT
|
||||
appContext.startService(intent)
|
||||
}
|
||||
|
||||
fun stopVPN() {
|
||||
private fun stopVPNService() {
|
||||
val intent = Intent(appContext, TunnelService::class.java)
|
||||
intent.action = TunnelService.ACTION_DISCONNECT
|
||||
appContext.startService(intent)
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
try {
|
||||
val config = getConfigUseCase.sync()
|
||||
|
||||
Log.d("Connlib", "accountId: ${config.accountId}")
|
||||
Log.d("Connlib", "token: ${config.token}")
|
||||
|
||||
if (config.accountId != null && config.token != null) {
|
||||
Log.d("Connlib", "Attempting to establish TunnelSession...")
|
||||
sessionPtr = TunnelSession.connect(
|
||||
controlPlaneUrl = BuildConfig.CONTROL_PLANE_URL,
|
||||
token = config.token,
|
||||
deviceId = deviceId(),
|
||||
logDir = appContext.filesDir.absolutePath,
|
||||
callback = callback
|
||||
)
|
||||
Log.d("Connlib", "connlib session started! sessionPtr: ${sessionPtr}")
|
||||
setConnectionStatus(true)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
Log.e("Connection error:", exception.message.toString())
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
try {
|
||||
TunnelSession.disconnect(sessionPtr!!)
|
||||
setConnectionStatus(false)
|
||||
} catch (exception: Exception) {
|
||||
Log.e("Disconnection error:", exception.message.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun deviceId(): String {
|
||||
val deviceId = FirebaseInstallations
|
||||
.getInstance()
|
||||
.getId()
|
||||
|
||||
Log.d("Connlib", "Device ID: ${deviceId}")
|
||||
|
||||
return deviceId.toString()
|
||||
}
|
||||
|
||||
private fun getLogDir(): String {
|
||||
// Create log directory if it doesn't exist
|
||||
val logDir = appContext.cacheDir.absolutePath + "/log"
|
||||
Files.createDirectories(Paths.get(logDir))
|
||||
return logDir
|
||||
}
|
||||
|
||||
private fun setConnectionStatus(value: Boolean) {
|
||||
saveIsConnectedUseCase.sync(value)
|
||||
}
|
||||
|
||||
private fun buildVpnService(ipv4Address: String, ipv6Address: String): VpnService.Builder =
|
||||
TunnelService().Builder().apply {
|
||||
addAddress(ipv4Address, 32)
|
||||
addAddress(ipv6Address, 128)
|
||||
|
||||
// TODO: These are the staging Resources. Remove these in favor of the onUpdateResources callback.
|
||||
addRoute("172.31.93.123", 32)
|
||||
addRoute("172.31.83.10", 32)
|
||||
addRoute("172.31.82.179", 32)
|
||||
|
||||
setSession("Firezone VPN")
|
||||
|
||||
// TODO: Can we do better?
|
||||
setMtu(1280)
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
var sessionPtr: Long? = null
|
||||
|
||||
private const val TAG: String = "TunnelManager"
|
||||
|
||||
init {
|
||||
Log.d("Connlib","Attempting to load library from main app...")
|
||||
Log.d(TAG,"Attempting to load library from main app...")
|
||||
System.loadLibrary("connlib")
|
||||
Log.d("Connlib","Library loaded from main app!")
|
||||
Log.d(TAG,"Library loaded from main app!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,264 @@
|
||||
package dev.firezone.android.tunnel
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.system.OsConstants
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.google.firebase.installations.FirebaseInstallations
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.adapter
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dev.firezone.android.BuildConfig
|
||||
import dev.firezone.android.R
|
||||
import dev.firezone.android.core.domain.preference.GetConfigUseCase
|
||||
import dev.firezone.android.core.presentation.MainActivity
|
||||
import dev.firezone.android.tunnel.callback.ConnlibCallback
|
||||
import dev.firezone.android.tunnel.data.TunnelRepository
|
||||
import dev.firezone.android.tunnel.model.Resource
|
||||
import dev.firezone.android.tunnel.model.Tunnel
|
||||
import dev.firezone.android.tunnel.model.TunnelConfig
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Paths
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
class TunnelService: VpnService() {
|
||||
|
||||
@Inject
|
||||
internal lateinit var getConfigUseCase: GetConfigUseCase
|
||||
|
||||
@Inject
|
||||
internal lateinit var tunnelRepository: TunnelRepository
|
||||
|
||||
@Inject
|
||||
internal lateinit var moshi: Moshi
|
||||
|
||||
private var sessionPtr: Long? = null
|
||||
|
||||
private val activeTunnel: Tunnel?
|
||||
get() = tunnelRepository.get()
|
||||
|
||||
private val callback: ConnlibCallback = object: ConnlibCallback {
|
||||
override fun onUpdateResources(resourceListJSON: String) {
|
||||
Log.d(TAG, "onUpdateResources: $resourceListJSON")
|
||||
moshi.adapter<List<Resource>>().fromJson(resourceListJSON)?.let { resources ->
|
||||
tunnelRepository.setResources(resources)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSetInterfaceConfig(
|
||||
tunnelAddressIPv4: String,
|
||||
tunnelAddressIPv6: String,
|
||||
dnsAddress: String,
|
||||
dnsFallbackStrategy: String
|
||||
): Int {
|
||||
Log.d(TAG, "onSetInterfaceConfig: [IPv4:$tunnelAddressIPv4] [IPv6:$tunnelAddressIPv6] [dns:$dnsAddress] [dnsFallbackStrategy:$dnsFallbackStrategy]")
|
||||
|
||||
tunnelRepository.setConfig(
|
||||
TunnelConfig(
|
||||
tunnelAddressIPv4, tunnelAddressIPv6, dnsAddress, dnsFallbackStrategy
|
||||
)
|
||||
)
|
||||
|
||||
// TODO: throw error if failed to establish VpnService
|
||||
val fd = buildVpnService().establish()?.detachFd() ?: -1
|
||||
protect(fd)
|
||||
return fd
|
||||
}
|
||||
|
||||
override fun onTunnelReady(): Boolean {
|
||||
Log.d(TAG, "onTunnelReady")
|
||||
|
||||
tunnelRepository.setState(Tunnel.State.Up)
|
||||
updateStatusNotification("Status: Connected")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onError(error: String): Boolean {
|
||||
Log.d(TAG, "onError: $error")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onAddRoute(cidrAddress: String) {
|
||||
Log.d(TAG, "onAddRoute: $cidrAddress")
|
||||
|
||||
tunnelRepository.addRoute(cidrAddress)
|
||||
}
|
||||
|
||||
override fun onRemoveRoute(cidrAddress: String) {
|
||||
Log.d(TAG, "onRemoveRoute: $cidrAddress")
|
||||
|
||||
tunnelRepository.removeRoute(cidrAddress)
|
||||
}
|
||||
|
||||
override fun onDisconnect(error: String?): Boolean {
|
||||
Log.d(TAG, "onDisconnect $error")
|
||||
|
||||
onTunnelStateUpdate(Tunnel.State.Down)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d("FirezoneVpnService", "onCreate")
|
||||
Log.d(TAG, "onCreate")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Log.d("FirezoneVpnService", "onDestroy")
|
||||
Log.d(TAG, "onDestroy")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: android.content.Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d("FirezoneVpnService", "onStartCommand")
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "onStartCommand")
|
||||
|
||||
if (intent != null && ACTION_DISCONNECT == intent.action) {
|
||||
disconnect()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
connect()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun onTunnelStateUpdate(state: Tunnel.State) {
|
||||
tunnelRepository.setState(state)
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
try {
|
||||
val config = getConfigUseCase.sync()
|
||||
|
||||
Log.d("Connlib", "accountId: ${config.accountId}")
|
||||
Log.d("Connlib", "token: ${config.token}")
|
||||
|
||||
if (config.accountId != null && config.token != null) {
|
||||
Log.d("Connlib", "Attempting to establish TunnelSession...")
|
||||
sessionPtr = TunnelSession.connect(
|
||||
controlPlaneUrl = BuildConfig.CONTROL_PLANE_URL,
|
||||
token = config.token,
|
||||
deviceId = deviceId(),
|
||||
logDir = getLogDir(),
|
||||
callback = callback
|
||||
)
|
||||
Log.d(TAG, "connlib session started! sessionPtr: $sessionPtr")
|
||||
|
||||
onTunnelStateUpdate(Tunnel.State.Connecting)
|
||||
|
||||
updateStatusNotification("Status: Connecting...")
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, exception.message.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun disconnect() {
|
||||
try {
|
||||
sessionPtr?.let {
|
||||
TunnelSession.disconnect(it)
|
||||
}
|
||||
tunnelRepository.setState(Tunnel.State.Down)
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, exception.message.toString())
|
||||
}
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
|
||||
private fun deviceId(): String {
|
||||
val deviceId = FirebaseInstallations.getInstance().id
|
||||
|
||||
Log.d(TAG, "Device ID: $deviceId")
|
||||
|
||||
return deviceId.toString()
|
||||
}
|
||||
|
||||
private fun getLogDir(): String {
|
||||
// Create log directory if it doesn't exist
|
||||
val logDir = cacheDir.absolutePath + "/log"
|
||||
Files.createDirectories(Paths.get(logDir))
|
||||
return logDir
|
||||
}
|
||||
|
||||
private fun configIntent(): PendingIntent? {
|
||||
return PendingIntent.getActivity(
|
||||
this, 0, Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildVpnService(): VpnService.Builder =
|
||||
TunnelService().Builder().apply {
|
||||
activeTunnel?.let { tunnel ->
|
||||
allowFamily(OsConstants.AF_INET)
|
||||
allowFamily(OsConstants.AF_INET6);
|
||||
setMetered(false); // Inherit the metered status from the underlying networks.
|
||||
setUnderlyingNetworks(null); // Use all available networks.
|
||||
|
||||
addAddress(tunnel.config.tunnelAddressIPv4, 32)
|
||||
addAddress(tunnel.config.tunnelAddressIPv6, 128)
|
||||
|
||||
addDnsServer(tunnel.config.dnsAddress)
|
||||
|
||||
/*tunnel.routes.forEach {
|
||||
addRoute(it, 32)
|
||||
}*/
|
||||
|
||||
// TODO: These are the staging Resources. Remove these in favor of the onUpdateResources callback.
|
||||
addRoute("100.100.111.1", 32)
|
||||
addRoute("172.31.82.179", 32)
|
||||
addRoute("172.31.83.10", 32)
|
||||
addRoute("172.31.92.238", 32)
|
||||
addRoute("172.31.93.123", 32)
|
||||
|
||||
setSession(SESSION_NAME)
|
||||
|
||||
// TODO: Can we do better?
|
||||
setMtu(DEFAULT_MTU)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateStatusNotification(message: String?) {
|
||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
val chan = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
NOTIFICATION_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
chan.description = "firezone vpn status"
|
||||
|
||||
manager.createNotificationChannel(chan)
|
||||
|
||||
val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
val notification = notificationBuilder.setOngoing(true)
|
||||
.setSmallIcon(R.drawable.ic_firezone_logo)
|
||||
.setContentTitle(NOTIFICATION_TITLE)
|
||||
.setContentText(message)
|
||||
.setPriority(NotificationManager.IMPORTANCE_MIN)
|
||||
.setCategory(Notification.CATEGORY_SERVICE)
|
||||
.setContentIntent(configIntent())
|
||||
.build()
|
||||
|
||||
startForeground(STATUS_NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
val ACTION_CONNECT = "dev.firezone.android.tunnel.CONNECT"
|
||||
val ACTION_DISCONNECT = "dev.firezone.android.tunnel.DISCONNECT"
|
||||
const val ACTION_CONNECT = "dev.firezone.android.tunnel.CONNECT"
|
||||
const val ACTION_DISCONNECT = "dev.firezone.android.tunnel.DISCONNECT"
|
||||
|
||||
private const val NOTIFICATION_CHANNEL_ID = "firezone-vpn-status"
|
||||
private const val NOTIFICATION_CHANNEL_NAME = "firezone-vpn-status"
|
||||
private const val STATUS_NOTIFICATION_ID = 1337
|
||||
private const val NOTIFICATION_TITLE = "Firezone Vpn Status"
|
||||
|
||||
private const val TAG: String = "TunnelService"
|
||||
private const val SESSION_NAME: String = "Firezone VPN"
|
||||
private const val DEFAULT_MTU: Int = 1280
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
package dev.firezone.android.tunnel.callback
|
||||
|
||||
import dev.firezone.android.tunnel.model.Resource
|
||||
import dev.firezone.android.tunnel.model.Tunnel
|
||||
|
||||
interface TunnelListener {
|
||||
|
||||
fun onSetInterfaceConfig(tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddress: String, dnsFallbackStrategy: String)
|
||||
fun onTunnelStateUpdate(state: Tunnel.State)
|
||||
|
||||
fun onTunnelReady(): Boolean
|
||||
|
||||
fun onAddRoute(cidrAddress: String)
|
||||
|
||||
fun onRemoveRoute(cidrAddress: String)
|
||||
|
||||
fun onUpdateResources(resources: List<Resource>)
|
||||
|
||||
fun onDisconnect(error: String?): Boolean
|
||||
fun onResourcesUpdate(resources: List<Resource>)
|
||||
|
||||
fun onError(error: String): Boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package dev.firezone.android.tunnel.data
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import dev.firezone.android.tunnel.model.Resource
|
||||
import dev.firezone.android.tunnel.model.Tunnel
|
||||
import dev.firezone.android.tunnel.model.TunnelConfig
|
||||
|
||||
interface TunnelRepository {
|
||||
|
||||
fun get(): Tunnel?
|
||||
|
||||
fun setConfig(config: TunnelConfig)
|
||||
|
||||
fun getConfig(): TunnelConfig?
|
||||
|
||||
fun setState(state: Tunnel.State)
|
||||
|
||||
fun getState(): Tunnel.State
|
||||
|
||||
fun setResources(resources: List<Resource>)
|
||||
|
||||
fun getResources(): List<Resource>
|
||||
|
||||
fun addRoute(route: String)
|
||||
|
||||
fun removeRoute(route: String)
|
||||
|
||||
fun getRoutes(): List<String>
|
||||
|
||||
fun clear()
|
||||
|
||||
fun addListener(callback: SharedPreferences.OnSharedPreferenceChangeListener)
|
||||
|
||||
fun removeListener(callback: SharedPreferences.OnSharedPreferenceChangeListener)
|
||||
|
||||
companion object {
|
||||
const val TAG = "TunnelRepository"
|
||||
const val CONFIG_KEY = "tunnelConfigKey"
|
||||
const val STATE_KEY = "tunnelStateKey"
|
||||
const val RESOURCES_KEY = "tunnelResourcesKey"
|
||||
const val ROUTES_KEY = "tunnelRoutesKey"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package dev.firezone.android.tunnel.data
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.adapter
|
||||
import dev.firezone.android.tunnel.data.TunnelRepository.Companion.CONFIG_KEY
|
||||
import dev.firezone.android.tunnel.data.TunnelRepository.Companion.RESOURCES_KEY
|
||||
import dev.firezone.android.tunnel.data.TunnelRepository.Companion.ROUTES_KEY
|
||||
import dev.firezone.android.tunnel.data.TunnelRepository.Companion.STATE_KEY
|
||||
import dev.firezone.android.tunnel.model.Resource
|
||||
import dev.firezone.android.tunnel.model.Tunnel
|
||||
import dev.firezone.android.tunnel.model.TunnelConfig
|
||||
import java.lang.Exception
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
class TunnelRepositoryImpl @Inject constructor(
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
private val moshi: Moshi,
|
||||
) : TunnelRepository {
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
override fun addListener(callback: SharedPreferences.OnSharedPreferenceChangeListener) {
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(callback)
|
||||
}
|
||||
|
||||
override fun removeListener(callback: SharedPreferences.OnSharedPreferenceChangeListener) {
|
||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(callback)
|
||||
}
|
||||
|
||||
override fun get(): Tunnel? = synchronized(lock) {
|
||||
return try {
|
||||
Tunnel(
|
||||
config = requireNotNull(getConfig()),
|
||||
state = getState(),
|
||||
routes = getRoutes(),
|
||||
resources = getResources(),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun setConfig(config: TunnelConfig) {
|
||||
synchronized(lock) {
|
||||
val json = moshi.adapter<TunnelConfig>().toJson(config)
|
||||
sharedPreferences.edit().putString(CONFIG_KEY, json).apply()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getConfig(): TunnelConfig? = synchronized(lock) {
|
||||
val json = sharedPreferences.getString(CONFIG_KEY, "{}") ?: "{}"
|
||||
return moshi.adapter<TunnelConfig>().fromJson(json)
|
||||
}
|
||||
|
||||
override fun setState(state: Tunnel.State) {
|
||||
synchronized(lock) {
|
||||
sharedPreferences.edit().putString(STATE_KEY, state.name).apply()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getState(): Tunnel.State {
|
||||
val json = sharedPreferences.getString(STATE_KEY, null)
|
||||
return json?.let { Tunnel.State.valueOf(it) } ?: Tunnel.State.Down
|
||||
}
|
||||
|
||||
override fun setResources(resources: List<Resource>) {
|
||||
synchronized(lock) {
|
||||
val json = moshi.adapter<List<Resource>>().toJson(resources)
|
||||
sharedPreferences.edit().putString(RESOURCES_KEY, json).apply()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getResources(): List<Resource> {
|
||||
synchronized(lock) {
|
||||
val json = sharedPreferences.getString(RESOURCES_KEY, "[]") ?: "[]"
|
||||
return moshi.adapter<List<Resource>>().fromJson(json) ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun addRoute(route: String) {
|
||||
synchronized(lock) {
|
||||
getRoutes().toMutableList().run {
|
||||
add(route)
|
||||
val json = moshi.adapter<List<String>>().toJson(this)
|
||||
sharedPreferences.edit().putString(ROUTES_KEY, json).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeRoute(route: String) {
|
||||
synchronized(lock) {
|
||||
getRoutes().toMutableList().run {
|
||||
remove(route)
|
||||
val json = moshi.adapter<List<String>>().toJson(this)
|
||||
sharedPreferences.edit().putString(ROUTES_KEY, json).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRoutes(): List<String> = synchronized(lock) {
|
||||
val json = sharedPreferences.getString(ROUTES_KEY, "[]") ?: "[]"
|
||||
return moshi.adapter<List<String>>().fromJson(json) ?: emptyList()
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
synchronized(lock) {
|
||||
sharedPreferences.edit().clear().apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package dev.firezone.android.tunnel.di
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dev.firezone.android.tunnel.TunnelManager
|
||||
import dev.firezone.android.tunnel.data.TunnelRepository
|
||||
import dev.firezone.android.tunnel.data.TunnelRepositoryImpl
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
|
||||
internal const val TUNNEL_ENCRYPTED_SHARED_PREFERENCES = "tunnelEncryptedSharedPreferences"
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object TunnelModule {
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
internal fun provideTunnelRepository(
|
||||
@Named(TunnelRepository.TAG) sharedPreferences: SharedPreferences,
|
||||
moshi: Moshi,
|
||||
): TunnelRepository = TunnelRepositoryImpl(sharedPreferences, moshi)
|
||||
|
||||
@Provides
|
||||
@Named(TunnelRepository.TAG)
|
||||
internal fun provideTunnelEncryptedSharedPreferences(app: Application): SharedPreferences =
|
||||
EncryptedSharedPreferences.create(
|
||||
app.applicationContext,
|
||||
TUNNEL_ENCRYPTED_SHARED_PREFERENCES,
|
||||
MasterKey.Builder(app.applicationContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
@Provides
|
||||
internal fun provideTunnelManager(
|
||||
@ApplicationContext appContext: Context,
|
||||
tunnelRepository: TunnelRepository,
|
||||
): TunnelManager = TunnelManager(appContext, tunnelRepository)
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package dev.firezone.android.tunnel.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.squareup.moshi.JsonClass
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
data class Resource(
|
||||
val type: String,
|
||||
val id: String,
|
||||
val address: String,
|
||||
val name: String,
|
||||
)
|
||||
): Parcelable
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package dev.firezone.android.tunnel.model
|
||||
|
||||
data class Tunnel(
|
||||
val config: TunnelConfig,
|
||||
var state: State = State.Down,
|
||||
val routes: MutableList<String> = mutableListOf(),
|
||||
val resources: MutableList<String> = mutableListOf(),
|
||||
) {
|
||||
import android.os.Parcelable
|
||||
import com.squareup.moshi.JsonClass
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
sealed interface State {
|
||||
object Up: State
|
||||
object CONNECTING: State
|
||||
object Down: State
|
||||
@JsonClass(generateAdapter = true)
|
||||
@Parcelize
|
||||
data class Tunnel(
|
||||
val config: TunnelConfig = TunnelConfig(),
|
||||
var state: State = State.Down,
|
||||
val routes: List<String> = emptyList(),
|
||||
val resources: List<Resource> = emptyList(),
|
||||
): Parcelable {
|
||||
|
||||
enum class State {
|
||||
Up, Connecting, Down;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package dev.firezone.android.tunnel.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class TunnelConfig (
|
||||
val tunnelAddressIPv4: String,
|
||||
val tunnelAddressIPv6: String,
|
||||
val dnsAddress: String,
|
||||
val dnsFallbackStrategy: String,
|
||||
)
|
||||
val tunnelAddressIPv4: String = "",
|
||||
val tunnelAddressIPv6: String = "",
|
||||
val dnsAddress: String = "",
|
||||
val dnsFallbackStrategy: String = "",
|
||||
): Parcelable
|
||||
|
||||
Reference in New Issue
Block a user