refactor: use UniFFI for Android FFI (#9415)

To make our FFI layer between Android and Rust safer, we adopt the
UniFFI tool from Mozilla. UniFFI allows us to create a dedicated crate
(here `client-ffi`) that contains Rust structs annotated with various
attributes. These macros then generate code at compile time that is
built into the shared object. Using a dedicated CLI from the UniFFI
project, we can then generate Kotlin bindings from this shared object.

The primary motivation for this effort is memory safety across the FFI
boundary. Most importantly, we want to ensure that:

- The session pointer is not used after it has been free'd
- Disconnecting the session frees the pointer
- Freeing the session does not happen as part of a callback as that
triggers a cyclic dependency on the Rust side (callbacks are executed on
a runtime and that runtime is dropped as part of dropping the session)

To achieve all of these goals, we move away from callbacks altogether.
UniFFI has great support for async functions. We leverage this support
to expose a `suspend fn` to Android that returns `Event`s. These events
map to the current callback functions. Internally, these events are read
from a channel with a capacity of 1000 events. It is therefore not very
time-critical that the app reads from this channel. `connlib` will
happily continue even if the channel is full. 1000 events should be more
than sufficient though in case the host app cannot immediately process
them. We don't send events very often after all.

This event-based design has major advantages: It allows us to make use
of `AutoCloseable` on the Kotlin side, meaning the `session` pointer is
only ever accessed as part of a `use` block and automatically closed
(and therefore free'd) at the end of the block.

To communicate with the session, we introduce a `TunnelCommand` which
represents all actions that the host app can send to `connlib`. These
are passed through a channel to the `suspend fn` which continuously
listens for events and commands.

Resolves: #9499
Related: #3959

---------

Signed-off-by: Thomas Eizinger <thomas@eizinger.io>
Co-authored-by: Jamil Bou Kheir <jamilbk@users.noreply.github.com>
This commit is contained in:
Thomas Eizinger
2025-06-17 23:48:34 +02:00
committed by GitHub
parent f3dcd06115
commit faeb958882
22 changed files with 1091 additions and 896 deletions

View File

@@ -18,8 +18,8 @@ outputs:
description: Compilable packages for the current OS
value: ${{
(runner.os == 'Linux' && '--workspace') ||
(runner.os == 'macOS' && '--workspace --exclude ebpf-turn-router --exclude gui-smoke-test --exclude android-client-ffi') ||
(runner.os == 'Windows' && '--workspace --exclude ebpf-turn-router --exclude apple-client-ffi --exclude android-client-ffi') }}
(runner.os == 'macOS' && '--workspace --exclude ebpf-turn-router --exclude gui-smoke-test --exclude client-ffi') ||
(runner.os == 'Windows' && '--workspace --exclude ebpf-turn-router --exclude apple-client-ffi --exclude client-ffi') }}
test-packages:
description: Testable packages for the current OS
value: ${{

View File

@@ -226,6 +226,9 @@ dependencies {
implementation("com.google.firebase:firebase-crashlytics-ktx")
implementation("com.google.firebase:firebase-crashlytics-ndk")
implementation("com.google.firebase:firebase-analytics-ktx")
// UniFFI
implementation("net.java.dev.jna:jna:5.17.0@aar")
}
cargo {
@@ -237,9 +240,8 @@ cargo {
// Needed for Ubuntu 22.04
pythonCommand = "python3"
prebuiltToolchains = true
module = "../../../rust/android-client-ffi"
module = "../../../rust/client-ffi"
libname = "connlib"
verbose = true
targets =
listOf(
"arm64",
@@ -250,6 +252,58 @@ cargo {
targetDirectory = "../../../rust/target"
}
// Custom task to run uniffi-bindgen
val generateUniffiBindings =
tasks.register("generateUniffiBindings") {
description = "Generate Kotlin bindings using uniffi-bindgen"
group = "build"
// This task should run after cargo build completes
dependsOn("cargoBuild")
// Determine the correct path to libconnlib.so based on build flavor
val profile =
if (gradle.startParameter.taskNames.any { it.lowercase().contains("release") }) {
"release"
} else {
"debug"
}
val rustDir = layout.projectDirectory.dir("../../../rust")
// Hardcode the x86_64 target here, it doesn't matter which one we use, they are
// all the same from the bindings PoV.
val input = rustDir.dir("target/x86_64-linux-android/$profile/libconnlib.so")
val outDir = layout.buildDirectory.dir("generated/source/uniffi/$profile").get()
doLast {
// Execute uniffi-bindgen command from the rust directory
project.exec {
// Set working directory to the rust directory which is outside the gradle project
workingDir(rustDir)
// Build the command
commandLine(
"cargo",
"run",
"--bin",
"uniffi-bindgen",
"generate",
"--library",
"--language",
"kotlin",
input.asFile,
"--out-dir",
outDir.asFile,
"--no-format",
)
}
}
inputs.file(input)
outputs.dir(outDir)
}
tasks.matching { it.name.matches(Regex("merge.*JniLibFolders")) }.configureEach {
inputs.dir(layout.buildDirectory.file("rustJniLibs/android"))
dependsOn("cargoBuild")
@@ -258,3 +312,15 @@ tasks.matching { it.name.matches(Regex("merge.*JniLibFolders")) }.configureEach
tasks.matching { it.name == "appDistributionUploadRelease" }.configureEach {
dependsOn("processReleaseGoogleServices")
}
kapt {
correctErrorTypes = true
}
kotlin {
sourceSets {
main {
kotlin.srcDir(generateUniffiBindings)
}
}
}

View File

@@ -1,36 +0,0 @@
/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */
package dev.firezone.android.tunnel
object ConnlibSession {
external fun connect(
apiUrl: String,
token: String,
deviceId: String,
deviceName: String,
osVersion: String,
logDir: String,
logFilter: String,
callback: Any,
deviceInfo: String,
): Long
external fun disconnect(connlibSession: Long): Boolean
// `disabledResourceList` is a JSON array of Resource ID strings.
external fun setDisabledResources(
connlibSession: Long,
disabledResourceList: String,
): Boolean
external fun setDns(
connlibSession: Long,
dnsList: String,
): Boolean
external fun setTun(
connlibSession: Long,
fd: Int,
): Boolean
external fun reset(connlibSession: Long): Boolean
}

View File

@@ -2,10 +2,6 @@
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import com.google.firebase.crashlytics.ktx.crashlytics
import com.google.firebase.ktx.Firebase
import com.google.gson.Gson
import dev.firezone.android.tunnel.ConnlibSession
import dev.firezone.android.tunnel.TunnelService
import dev.firezone.android.tunnel.TunnelStatusNotification
import java.net.InetAddress
@@ -20,35 +16,25 @@ class NetworkMonitor(
network: Network,
linkProperties: LinkProperties,
) {
// Acquire mutex lock
if (tunnelService.lock.tryLock()) {
if (tunnelService.tunnelState != TunnelService.Companion.State.UP) {
tunnelService.tunnelState = TunnelService.Companion.State.UP
tunnelService.updateStatusNotification(TunnelStatusNotification.Connected)
}
if (tunnelService.tunnelState != TunnelService.Companion.State.UP) {
tunnelService.tunnelState = TunnelService.Companion.State.UP
tunnelService.updateStatusNotification(TunnelStatusNotification.Connected)
}
if (lastDns != linkProperties.dnsServers) {
lastDns = linkProperties.dnsServers
if (lastDns != linkProperties.dnsServers) {
lastDns = linkProperties.dnsServers
// Strip the scope id from IPv6 addresses. See https://github.com/firezone/firezone/issues/5781
val dnsList =
linkProperties.dnsServers.mapNotNull {
it.hostAddress?.split("%")?.getOrNull(0)
}
tunnelService.connlibSessionPtr?.let {
ConnlibSession.setDns(it, Gson().toJson(dnsList))
} ?: Firebase.crashlytics.recordException(NullPointerException("connlibSessionPtr is null"))
}
// Strip the scope id from IPv6 addresses. See https://github.com/firezone/firezone/issues/5781
val dnsList =
linkProperties.dnsServers.mapNotNull {
it.hostAddress?.split("%")?.getOrNull(0)
}
tunnelService.setDns(dnsList)
}
if (lastNetwork != network) {
lastNetwork = network
tunnelService.connlibSessionPtr?.let {
ConnlibSession.reset(it)
} ?: Firebase.crashlytics.recordException(NullPointerException("connlibSessionPtr is null"))
}
// Release mutex lock
tunnelService.lock.unlock()
if (lastNetwork != network) {
lastNetwork = network
tunnelService.reset()
}
super.onLinkPropertiesChanged(network, linkProperties)

View File

@@ -29,16 +29,24 @@ import dagger.hilt.android.AndroidEntryPoint
import dev.firezone.android.core.data.Repository
import dev.firezone.android.core.data.ResourceState
import dev.firezone.android.core.data.isEnabled
import dev.firezone.android.tunnel.callback.ConnlibCallback
import dev.firezone.android.tunnel.model.Cidr
import dev.firezone.android.tunnel.model.Resource
import dev.firezone.android.tunnel.model.isInternetResource
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.channels.produce
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import uniffi.connlib.Event
import uniffi.connlib.ProtectSocket
import uniffi.connlib.Session
import uniffi.connlib.SessionInterface
import java.nio.file.Files
import java.nio.file.Paths
import java.util.concurrent.Executors
import java.util.concurrent.locks.ReentrantLock
import javax.inject.Inject
data class DeviceInfo(
@@ -73,12 +81,9 @@ class TunnelService : VpnService() {
// the VPN from the system settings or MDM disconnects us.
private var disconnectCallback: DisconnectMonitor? = null
// General purpose mutex lock for preventing network monitoring from calling connlib
// during shutdown.
val lock = ReentrantLock()
var startedByUser: Boolean = false
var connlibSessionPtr: Long? = null
private var commandChannel: Channel<TunnelCommand>? = null
private val serviceScope = CoroutineScope(SupervisorJob())
var tunnelResources: List<Resource>
get() = _tunnelResources
@@ -106,62 +111,10 @@ class TunnelService : VpnService() {
override fun onBind(intent: Intent): IBinder = binder
private val callback: ConnlibCallback =
object : ConnlibCallback {
override fun onUpdateResources(resourceListJSON: String) {
moshi.adapter<List<Resource>>().fromJson(resourceListJSON)?.let {
tunnelResources = it
resourcesUpdated()
}
}
override fun onSetInterfaceConfig(
addressIPv4: String,
addressIPv6: String,
dnsAddresses: String,
searchDomain: String?,
routes4JSON: String,
routes6JSON: String,
) {
// init tunnel config
tunnelDnsAddresses = moshi.adapter<MutableList<String>>().fromJson(dnsAddresses)!!
val routes4 = moshi.adapter<MutableList<Cidr>>().fromJson(routes4JSON)!!
val routes6 = moshi.adapter<MutableList<Cidr>>().fromJson(routes6JSON)!!
tunnelSearchDomain = searchDomain
tunnelIpv4Address = addressIPv4
tunnelIpv6Address = addressIPv6
tunnelRoutes.clear()
tunnelRoutes.addAll(routes4)
tunnelRoutes.addAll(routes6)
buildVpnService()
}
// Unexpected disconnect, most likely a 401. Clear the token and initiate a stop of the
// service.
override fun onDisconnect(error: String): Boolean {
stopNetworkMonitoring()
stopDisconnectMonitoring()
// Clear any user tokens and actorNames
repo.clearToken()
repo.clearActorName()
// Free the connlib session
connlibSessionPtr?.let {
ConnlibSession.disconnect(it)
}
shutdown()
if (startedByUser) {
updateStatusNotification(TunnelStatusNotification.SignedOut)
}
return true
}
override fun protectFileDescriptor(fileDescriptor: Int) {
protect(fileDescriptor)
private val protectSocket: ProtectSocket =
object : ProtectSocket {
override fun protectSocket(fd: Int) {
protect(fd)
}
}
@@ -220,11 +173,7 @@ class TunnelService : VpnService() {
addAddress(tunnelIpv6Address!!, 128)
}.establish()
?.detachFd()
?.also { fd ->
connlibSessionPtr?.let {
ConnlibSession.setTun(it, fd)
}
}
?.also { fd -> sendTunnelCommand(TunnelCommand.SetTun(fd)) }
}
private val restrictionsFilter = IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED)
@@ -238,7 +187,7 @@ class TunnelService : VpnService() {
// Only change VPN if appRestrictions have changed
val restrictionsManager = context.getSystemService(Context.RESTRICTIONS_SERVICE) as android.content.RestrictionsManager
val newAppRestrictions = restrictionsManager.applicationRestrictions
GlobalScope.launch { repo.saveManagedConfiguration(newAppRestrictions).collect {} }
serviceScope.launch { repo.saveManagedConfiguration(newAppRestrictions).collect {} }
val changed = MANAGED_CONFIGURATIONS.any { newAppRestrictions.getString(it) != appRestrictions.getString(it) }
if (!changed) {
return
@@ -271,6 +220,7 @@ class TunnelService : VpnService() {
override fun onDestroy() {
unregisterReceiver(restrictionsReceiver)
serviceScope.cancel()
super.onDestroy()
}
@@ -292,9 +242,7 @@ class TunnelService : VpnService() {
emptySet()
}
connlibSessionPtr?.let {
ConnlibSession.setDisabledResources(it, Gson().toJson(currentlyDisabled))
}
sendTunnelCommand(TunnelCommand.SetDisabledResources(Gson().toJson(currentlyDisabled)))
}
fun internetResourceToggled(state: ResourceState) {
@@ -307,25 +255,15 @@ class TunnelService : VpnService() {
// Call this to stop the tunnel and shutdown the service, leaving the token intact.
fun disconnect() {
// Acquire mutex lock
lock.lock()
stopNetworkMonitoring()
connlibSessionPtr?.let {
ConnlibSession.disconnect(it)
}
shutdown()
// Release mutex lock
lock.unlock()
sendTunnelCommand(TunnelCommand.Disconnect)
}
private fun shutdown() {
connlibSessionPtr = null
stopSelf()
tunnelState = State.DOWN
fun setDns(dnsList: List<String>) {
sendTunnelCommand(TunnelCommand.SetDns(Gson().toJson(dnsList)))
}
fun reset() {
sendTunnelCommand(TunnelCommand.Reset)
}
private fun connect() {
@@ -337,43 +275,71 @@ class TunnelService : VpnService() {
tunnelState = State.CONNECTING
updateStatusNotification(TunnelStatusNotification.Connecting)
val executor = Executors.newSingleThreadExecutor()
executor.execute {
val deviceInfo = DeviceInfo()
runCatching {
Tasks.await(FirebaseInstallations.getInstance().id)
}.onSuccess { firebaseInstallationId ->
val deviceInfo = DeviceInfo()
runCatching { Tasks.await(FirebaseInstallations.getInstance().id) }
.onSuccess { firebaseInstallationId ->
deviceInfo.firebaseInstallationId = firebaseInstallationId
}.onFailure { exception ->
Log.d(TAG, "Failed to obtain firebase installation id: $exception")
}
val gson: Gson =
GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.create()
val gson: Gson =
GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.create()
connlibSessionPtr =
ConnlibSession.connect(
commandChannel = Channel<TunnelCommand>(Channel.UNLIMITED)
serviceScope.launch {
Session
.newAndroid(
apiUrl = config.apiUrl,
token = token,
accountSlug = config.accountSlug,
deviceId = deviceId(),
deviceName = getDeviceName(),
osVersion = Build.VERSION.RELEASE,
logDir = getLogDir(),
logFilter = config.logFilter,
callback = callback,
protectSocket = protectSocket,
deviceInfo = gson.toJson(deviceInfo),
)
).use { session ->
startNetworkMonitoring()
startDisconnectMonitoring()
startNetworkMonitoring()
startDisconnectMonitoring()
eventLoop(session, commandChannel!!)
Log.i(TAG, "Event-loop finished")
commandChannel = null
tunnelState = State.DOWN
if (startedByUser) {
updateStatusNotification(TunnelStatusNotification.SignedOut)
}
stopNetworkMonitoring()
stopDisconnectMonitoring()
}
}
}
}
private fun sendTunnelCommand(command: TunnelCommand) {
val commandName = command.javaClass.name
if (commandChannel == null) {
Log.d(TAG, "Cannot send $commandName: No active connlib session")
return
}
try {
commandChannel?.trySend(command)?.getOrThrow()
} catch (e: Exception) {
Log.w(TAG, "Cannot send $commandName: ${e.message}")
}
}
private fun startDisconnectMonitoring() {
disconnectCallback = DisconnectMonitor(this)
val networkRequest = NetworkRequest.Builder()
@@ -476,6 +442,123 @@ class TunnelService : VpnService() {
}
}
sealed class TunnelCommand {
data object Disconnect : TunnelCommand()
data class SetDisabledResources(
val disabledResources: String,
) : TunnelCommand()
data class SetDns(
val dnsServers: String,
) : TunnelCommand()
data class SetLogDirectives(
val directives: String,
) : TunnelCommand()
data class SetTun(
val fd: Int,
) : TunnelCommand()
data object Reset : TunnelCommand()
}
private suspend fun eventLoop(
session: SessionInterface,
commandChannel: Channel<TunnelCommand>,
) {
val eventChannel =
serviceScope.produce {
while (isActive) {
send(session.nextEvent())
}
}
var running = true
while (running) {
try {
select<Unit> {
commandChannel.onReceive { command ->
when (command) {
is TunnelCommand.Disconnect -> {
session.disconnect()
// Sending disconnect will close the event-stream which will exit this loop
}
is TunnelCommand.SetDisabledResources -> {
session.setDisabledResources(command.disabledResources)
}
is TunnelCommand.SetDns -> {
session.setDns(command.dnsServers)
}
is TunnelCommand.SetLogDirectives -> {
session.setLogDirectives(command.directives)
}
is TunnelCommand.SetTun -> {
session.setTun(command.fd)
}
is TunnelCommand.Reset -> {
session.reset()
}
}
}
eventChannel.onReceive { event ->
when (event) {
is Event.ResourcesUpdated -> {
tunnelResources =
moshi.adapter<List<Resource>>().fromJson(event.resources)!!
resourcesUpdated()
}
is Event.TunInterfaceUpdated -> {
tunnelDnsAddresses =
moshi.adapter<MutableList<String>>().fromJson(event.dns)!!
tunnelSearchDomain = event.searchDomain
tunnelIpv4Address = event.ipv4
tunnelIpv6Address = event.ipv6
tunnelRoutes.clear()
tunnelRoutes.addAll(
moshi
.adapter<MutableList<Cidr>>()
.fromJson(event.ipv4Routes)!!,
)
tunnelRoutes.addAll(
moshi
.adapter<MutableList<Cidr>>()
.fromJson(event.ipv6Routes)!!,
)
buildVpnService()
}
is Event.Disconnected -> {
// Clear any user tokens and actorNames
repo.clearToken()
repo.clearActorName()
running = false
}
null -> {
Log.i(TAG, "Event channel closed")
running = false
}
}
}
}
} catch (e: ClosedReceiveChannelException) {
running = false
} catch (e: Exception) {
Log.e(TAG, "Error in event loop", e)
}
}
}
companion object {
enum class State {
CONNECTING,

View File

@@ -1,20 +0,0 @@
/* Licensed under Apache 2.0 (C) 2024 Firezone, Inc. */
package dev.firezone.android.tunnel.callback
interface ConnlibCallback {
fun onSetInterfaceConfig(
addressIPv4: String,
addressIPv6: String,
dnsAddresses: String,
searchDomain: String?,
routes4JSON: String,
routes6JSON: String,
)
fun onUpdateResources(resourceListJSON: String)
// The JNI doesn't support nullable types, so we need two method signatures
fun onDisconnect(error: String): Boolean
fun protectFileDescriptor(fileDescriptor: Int)
}

353
rust/Cargo.lock generated
View File

@@ -240,39 +240,6 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android-client-ffi"
version = "1.5.2"
dependencies = [
"android_log-sys",
"anyhow",
"backoff",
"client-shared",
"connlib-model",
"dns-types",
"firezone-logging",
"firezone-telemetry",
"flume",
"futures",
"ip-packet",
"ip_network",
"jni",
"libc",
"log",
"phoenix-channel",
"rustls",
"secrecy",
"serde_json",
"socket-factory",
"thiserror 2.0.12",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"tun",
"url",
]
[[package]]
name = "android-tzdata"
version = "0.1.1"
@@ -437,6 +404,48 @@ dependencies = [
"zbus 5.7.1",
]
[[package]]
name = "askama"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7"
dependencies = [
"askama_derive",
"itoa 1.0.15",
"percent-encoding",
"serde",
"serde_json",
]
[[package]]
name = "askama_derive"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac"
dependencies = [
"askama_parser",
"basic-toml",
"memchr",
"proc-macro2",
"quote",
"rustc-hash",
"serde",
"serde_derive",
"syn 2.0.103",
]
[[package]]
name = "askama_parser"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f"
dependencies = [
"memchr",
"serde",
"serde_derive",
"winnow 0.7.10",
]
[[package]]
name = "assert_matches"
version = "1.5.0"
@@ -839,6 +848,15 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "basic-toml"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
dependencies = [
"serde",
]
[[package]]
name = "bimap"
version = "0.6.3"
@@ -1133,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02260d489095346e5cafd04dea8e8cb54d1d74fcd759022a9b72986ebe9a1257"
dependencies = [
"serde",
"toml",
"toml 0.8.22",
]
[[package]]
@@ -1282,6 +1300,39 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]]
name = "client-ffi"
version = "0.1.0"
dependencies = [
"android_log-sys",
"anyhow",
"backoff",
"client-shared",
"connlib-model",
"dns-types",
"firezone-logging",
"firezone-telemetry",
"flume",
"futures",
"ip-packet",
"ip_network",
"libc",
"log",
"phoenix-channel",
"rustls",
"secrecy",
"serde_json",
"socket-factory",
"thiserror 2.0.12",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"tun",
"uniffi",
"url",
]
[[package]]
name = "client-shared"
version = "0.1.0"
@@ -2085,7 +2136,7 @@ dependencies = [
"cc",
"memchr",
"rustc_version",
"toml",
"toml 0.8.22",
"vswhom",
"winreg 0.52.0",
]
@@ -2658,6 +2709,15 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs-err"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41"
dependencies = [
"autocfg",
]
[[package]]
name = "futf"
version = "0.1.5"
@@ -3069,6 +3129,17 @@ dependencies = [
"system-deps",
]
[[package]]
name = "goblin"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47"
dependencies = [
"log",
"plain",
"scroll",
]
[[package]]
name = "gtk"
version = "0.18.2"
@@ -3792,15 +3863,6 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "java-locator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09c46c1fe465c59b1474e665e85e1256c3893dd00927b8d55f63b09044c1e64f"
dependencies = [
"glob",
]
[[package]]
name = "javascriptcore-rs"
version = "1.1.2"
@@ -3853,9 +3915,7 @@ dependencies = [
"cesu8",
"cfg-if",
"combine",
"java-locator",
"jni-sys",
"libloading 0.7.4",
"log",
"thiserror 1.0.69",
"walkdir",
@@ -5304,6 +5364,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
version = "1.7.1"
@@ -6162,6 +6228,26 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scroll"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6"
dependencies = [
"scroll_derive",
]
[[package]]
name = "scroll_derive"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.103",
]
[[package]]
name = "sctp-proto"
version = "0.3.0"
@@ -6662,6 +6748,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smawk"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]]
name = "smbios-lib"
version = "0.9.2"
@@ -7055,7 +7147,7 @@ dependencies = [
"cfg-expr",
"heck 0.5.0",
"pkg-config",
"toml",
"toml 0.8.22",
"version-compare",
]
@@ -7191,7 +7283,7 @@ dependencies = [
"serde_json",
"tauri-utils",
"tauri-winres",
"toml",
"toml 0.8.22",
"walkdir",
]
@@ -7249,7 +7341,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"toml",
"toml 0.8.22",
"walkdir",
]
@@ -7289,7 +7381,7 @@ dependencies = [
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.12",
"toml",
"toml 0.8.22",
"url",
]
@@ -7435,7 +7527,7 @@ dependencies = [
"serde_with",
"swift-rs",
"thiserror 2.0.12",
"toml",
"toml 0.8.22",
"url",
"urlpattern",
"uuid",
@@ -7450,7 +7542,7 @@ checksum = "e8d321dbc6f998d825ab3f0d62673e810c861aac2d0de2cc2c395328f1d113b4"
dependencies = [
"embed-resource",
"indexmap 2.9.0",
"toml",
"toml 0.8.22",
]
[[package]]
@@ -7544,6 +7636,15 @@ dependencies = [
"syn 2.0.103",
]
[[package]]
name = "textwrap"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
"smawk",
]
[[package]]
name = "thin-slice"
version = "0.1.1"
@@ -7737,6 +7838,15 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
dependencies = [
"serde",
]
[[package]]
name = "toml"
version = "0.8.22"
@@ -8084,7 +8194,7 @@ dependencies = [
"serde",
"syn 2.0.103",
"thiserror 1.0.69",
"toml",
"toml 0.8.22",
"uuid",
]
@@ -8224,6 +8334,134 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "uniffi"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dcd1d240101ba3b9d7532ae86d9cb64d9a7ff63e13a2b7b9e94a32a601d8233"
dependencies = [
"anyhow",
"camino",
"cargo_metadata",
"clap",
"uniffi_bindgen",
"uniffi_core",
"uniffi_macros",
"uniffi_pipeline",
]
[[package]]
name = "uniffi-bindgen"
version = "0.1.0"
dependencies = [
"uniffi",
]
[[package]]
name = "uniffi_bindgen"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d0525f06d749ea80d8049dc0bb038bb87941e3d909eefa76b6f0a5589b59ac5"
dependencies = [
"anyhow",
"askama",
"camino",
"cargo_metadata",
"fs-err",
"glob",
"goblin",
"heck 0.5.0",
"indexmap 2.9.0",
"once_cell",
"serde",
"tempfile",
"textwrap",
"toml 0.5.11",
"uniffi_internal_macros",
"uniffi_meta",
"uniffi_pipeline",
"uniffi_udl",
]
[[package]]
name = "uniffi_core"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3fa8eb4d825b4ed095cb13483cba6927c3002b9eb603cef9b7688758cc3772e"
dependencies = [
"anyhow",
"bytes",
"once_cell",
"static_assertions",
]
[[package]]
name = "uniffi_internal_macros"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83b547d69d699e52f2129fde4b57ae0d00b5216e59ed5b56097c95c86ba06095"
dependencies = [
"anyhow",
"indexmap 2.9.0",
"proc-macro2",
"quote",
"syn 2.0.103",
]
[[package]]
name = "uniffi_macros"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00f1de72edc8cb9201c7d650e3678840d143e4499004571aac49e6cb1b17da43"
dependencies = [
"camino",
"fs-err",
"once_cell",
"proc-macro2",
"quote",
"serde",
"syn 2.0.103",
"toml 0.5.11",
"uniffi_meta",
]
[[package]]
name = "uniffi_meta"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acc9204632f6a555b2cba7c8852c5523bc1aa5f3eff605c64af5054ea28b72e"
dependencies = [
"anyhow",
"siphasher 0.3.11",
"uniffi_internal_macros",
"uniffi_pipeline",
]
[[package]]
name = "uniffi_pipeline"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b5336a9a925b358183837d31541d12590b7fcec373256d3770de02dff24c69"
dependencies = [
"anyhow",
"heck 0.5.0",
"indexmap 2.9.0",
"tempfile",
"uniffi_internal_macros",
]
[[package]]
name = "uniffi_udl"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f95e73373d85f04736bc51997d3e6855721144ec4384cae9ca8513c80615e129"
dependencies = [
"anyhow",
"textwrap",
"uniffi_meta",
"weedle2",
]
[[package]]
name = "universal-hash"
version = "0.5.1"
@@ -8608,6 +8846,15 @@ dependencies = [
"windows-core",
]
[[package]]
name = "weedle2"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e"
dependencies = [
"nom",
]
[[package]]
name = "wfd"
version = "0.1.7"

View File

@@ -1,8 +1,8 @@
[workspace]
members = [
"android-client-ffi",
"apple-client-ffi",
"bin-shared",
"client-ffi",
"client-shared",
"connlib/bufferpool",
"connlib/dns-over-tcp",
@@ -28,6 +28,7 @@ members = [
"telemetry",
"tests/gui-smoke-test",
"tests/http-test-server",
"tools/uniffi-bindgen",
]
resolver = "2"
@@ -189,6 +190,7 @@ tracing-stackdriver = "0.11.0"
tracing-subscriber = { version = "0.3.19", features = ["parking_lot"] }
trackable = "1.3.0"
tun = { path = "connlib/tun" }
uniffi = "0.29.2"
url = "2.5.2"
uuid = "1.17.0"
which = "4.4.2"

View File

@@ -1,633 +0,0 @@
// The "system" ABI is only needed for Java FFI on Win32, not Android:
// https://github.com/jni-rs/jni-rs/pull/22
// However, this consideration has made it idiomatic for Java FFI in the Rust
// ecosystem, so it's used here for consistency.
#![cfg(unix)]
use crate::tun::Tun;
use anyhow::{Context as _, Result};
use backoff::ExponentialBackoffBuilder;
use client_shared::{DisconnectError, Session, V4RouteList, V6RouteList};
use connlib_model::ResourceView;
use dns_types::DomainName;
use firezone_logging::{err_with_src, sentry_layer};
use firezone_telemetry::{ANDROID_DSN, Telemetry, analytics};
use ip_network::{Ipv4Network, Ipv6Network};
use jni::{
JNIEnv, JavaVM,
objects::{GlobalRef, JClass, JObject, JString, JValue},
strings::JNIString,
sys::jlong,
};
use phoenix_channel::LoginUrl;
use phoenix_channel::PhoenixChannel;
use phoenix_channel::get_user_agent;
use secrecy::{Secret, SecretString};
use socket_factory::{SocketFactory, TcpSocket, UdpSocket};
use std::{io, net::IpAddr, os::fd::AsRawFd, path::Path, sync::Arc};
use std::{
net::{Ipv4Addr, Ipv6Addr},
os::fd::RawFd,
path::PathBuf,
};
use std::{sync::OnceLock, time::Duration};
use thiserror::Error;
use tokio::runtime::Runtime;
use tracing_subscriber::prelude::*;
mod make_writer;
mod tun;
/// The Android client doesn't use platform APIs to detect network connectivity changes,
/// so we rely on connlib to do so. We have valid use cases for headless Android clients
/// (IoT devices, point-of-sale devices, etc), so try to reconnect for 30 days.
const MAX_PARTITION_TIME: Duration = Duration::from_secs(60 * 60 * 24 * 30);
/// The Sentry release.
///
/// This module is only responsible for the connlib part of the Android app.
/// Bugs within the Android app itself may use the same DSN but a different component as part of the version string.
const RELEASE: &str = concat!("connlib-android@", env!("CARGO_PKG_VERSION"));
pub struct CallbackHandler {
vm: JavaVM,
callback_handler: GlobalRef,
}
impl Clone for CallbackHandler {
fn clone(&self) -> Self {
// This is essentially a `memcpy` to bypass redundant checks from
// doing `as_raw` -> `from_raw`/etc; both of these fields are just
// dumb pointers but the wrappers don't implement `Clone`.
//
// SAFETY: `self` is guaranteed to be valid and `Self` is POD.
Self {
vm: unsafe { std::ptr::read(&self.vm) },
callback_handler: self.callback_handler.clone(),
}
}
}
#[derive(Debug, Error)]
pub enum CallbackError {
#[error("Failed to attach current thread: {0}")]
AttachCurrentThreadFailed(#[source] jni::errors::Error),
#[error("Failed to serialize JSON: {0}")]
SerializeFailed(#[from] serde_json::Error),
#[error("Failed to create string `{name}`: {source}")]
NewStringFailed {
name: &'static str,
source: jni::errors::Error,
},
#[error("Failed to call method `{name}`: {source}")]
CallMethodFailed {
name: &'static str,
source: jni::errors::Error,
},
#[error(transparent)]
Io(#[from] io::Error),
}
impl CallbackHandler {
fn env<T>(
&self,
f: impl FnOnce(JNIEnv) -> Result<T, CallbackError>,
) -> Result<T, CallbackError> {
self.vm
.attach_current_thread_as_daemon()
.map_err(CallbackError::AttachCurrentThreadFailed)
.and_then(f)
}
fn protect(&self, socket: RawFd) -> io::Result<()> {
self.env(|mut env| {
call_method(
&mut env,
&self.callback_handler,
"protectFileDescriptor",
"(I)V",
&[JValue::Int(socket)],
)
})
.map_err(io::Error::other)
}
}
fn call_method(
env: &mut JNIEnv,
this: &JObject,
name: &'static str,
sig: &str,
args: &[JValue],
) -> Result<(), CallbackError> {
env.call_method(this, name, sig, args)
.map(|val| log::trace!("`{name}` returned `{val:?}`"))
.map_err(|source| CallbackError::CallMethodFailed { name, source })
}
fn init_logging(log_dir: &Path, log_filter: String) -> Result<()> {
static LOGGER_STATE: OnceLock<(
firezone_logging::file::Handle,
firezone_logging::FilterReloadHandle,
)> = OnceLock::new();
if let Some((_, reload_handle)) = LOGGER_STATE.get() {
reload_handle
.reload(&log_filter)
.context("Failed to apply new log-filter")?;
return Ok(());
}
let (log_filter, reload_handle) = firezone_logging::try_filter(&log_filter)?;
let (file_layer, handle) = firezone_logging::file::layer(log_dir, "connlib");
let subscriber = tracing_subscriber::registry()
.with(log_filter)
.with(file_layer)
.with(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.event_format(
firezone_logging::Format::new()
.without_timestamp()
.without_level(),
)
.with_writer(make_writer::MakeWriter::new("connlib")),
)
.with(sentry_layer());
firezone_logging::init(subscriber)?;
LOGGER_STATE
.set((handle, reload_handle))
.expect("Logging guard should never be initialized twice");
Ok(())
}
impl CallbackHandler {
fn on_set_interface_config(
&self,
tunnel_address_v4: Ipv4Addr,
tunnel_address_v6: Ipv6Addr,
dns_addresses: Vec<IpAddr>,
search_domain: Option<DomainName>,
route_list_4: Vec<Ipv4Network>,
route_list_6: Vec<Ipv6Network>,
) {
self.env(|mut env| {
let tunnel_address_v4 =
env.new_string(tunnel_address_v4.to_string())
.map_err(|source| CallbackError::NewStringFailed {
name: "tunnel_address_v4",
source,
})?;
let tunnel_address_v6 =
env.new_string(tunnel_address_v6.to_string())
.map_err(|source| CallbackError::NewStringFailed {
name: "tunnel_address_v6",
source,
})?;
let dns_addresses = env
.new_string(serde_json::to_string(&dns_addresses)?)
.map_err(|source| CallbackError::NewStringFailed {
name: "dns_addresses",
source,
})?;
let search_domain = search_domain
.map(|domain| {
env.new_string(domain.to_string())
.map_err(|source| CallbackError::NewStringFailed {
name: "search_domain",
source,
})
})
.transpose()?
.unwrap_or_default();
let route_list_4 = env
.new_string(serde_json::to_string(&V4RouteList::new(route_list_4))?)
.map_err(|source| CallbackError::NewStringFailed {
name: "route_list_4",
source,
})?;
let route_list_6 = env
.new_string(serde_json::to_string(&V6RouteList::new(route_list_6))?)
.map_err(|source| CallbackError::NewStringFailed {
name: "route_list_6",
source,
})?;
let name = "onSetInterfaceConfig";
env.call_method(
&self.callback_handler,
name,
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V",
&[
JValue::from(&tunnel_address_v4),
JValue::from(&tunnel_address_v6),
JValue::from(&dns_addresses),
JValue::from(&search_domain),
JValue::from(&route_list_4),
JValue::from(&route_list_6),
],
)
.map_err(|source| CallbackError::CallMethodFailed { name, source })?;
Ok(())
})
.expect("onSetInterfaceConfig callback failed");
}
fn on_update_resources(&self, resource_list: Vec<ResourceView>) {
self.env(|mut env| {
let resource_list = env
.new_string(serde_json::to_string(&resource_list)?)
.map_err(|source| CallbackError::NewStringFailed {
name: "resource_list",
source,
})?;
call_method(
&mut env,
&self.callback_handler,
"onUpdateResources",
"(Ljava/lang/String;)V",
&[JValue::from(&resource_list)],
)
})
.expect("onUpdateResources callback failed")
}
fn on_disconnect(&self, error: DisconnectError) {
if !error.is_authentication_error() {
tracing::error!("{error}")
}
self.env(|mut env| {
let error = env
.new_string(serde_json::to_string(&error.to_string())?)
.map_err(|source| CallbackError::NewStringFailed {
name: "error",
source,
})?;
call_method(
&mut env,
&self.callback_handler,
"onDisconnect",
"(Ljava/lang/String;)Z",
&[JValue::from(&error)],
)
})
.expect("onDisconnect callback failed")
}
}
fn throw(env: &mut JNIEnv, class: &str, msg: impl Into<JNIString>) {
if let Err(err) = env.throw_new(class, msg) {
// We can't panic, since unwinding across the FFI boundary is UB...
tracing::error!("failed to throw Java exception: {}", err_with_src(&err));
}
}
fn catch_and_throw<F: FnOnce(&mut JNIEnv) -> R, R>(env: &mut JNIEnv, f: F) -> Option<R> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(env)))
.map_err(|info| {
tracing::error!("catching Rust panic");
throw(
env,
"java/lang/Exception",
match info.downcast_ref::<&str>() {
Some(msg) => format!("Rust panicked: {msg}"),
None => "Rust panicked with no message".to_owned(),
},
);
})
.ok()
}
macro_rules! string_from_jstring {
($env:expr, $j:ident) => {
String::from(
($env)
.get_string(&($j))
.with_context(|| format!("Failed to get string {} from JNIEnv", stringify!($j)))?,
)
};
}
// TODO: Refactor this when we refactor PhoenixChannel.
// See https://github.com/firezone/firezone/issues/2158
#[expect(clippy::too_many_arguments)]
fn connect(
env: &mut JNIEnv,
api_url: JString,
token: JString,
device_id: JString,
device_name: JString,
os_version: JString,
log_dir: JString,
log_filter: JString,
callback_handler: GlobalRef,
device_info: JString,
) -> Result<SessionWrapper> {
let api_url = string_from_jstring!(env, api_url);
let secret = SecretString::from(string_from_jstring!(env, token));
let device_id = string_from_jstring!(env, device_id);
let device_name = string_from_jstring!(env, device_name);
let os_version = string_from_jstring!(env, os_version);
let log_dir = string_from_jstring!(env, log_dir);
let log_filter = string_from_jstring!(env, log_filter);
let device_info = string_from_jstring!(env, device_info);
let device_info =
serde_json::from_str(&device_info).context("Failed to deserialize `DeviceInfo`")?;
let mut telemetry = Telemetry::default();
telemetry.start(&api_url, RELEASE, ANDROID_DSN);
Telemetry::set_firezone_id(device_id.clone());
analytics::identify(device_id.clone(), api_url.to_string(), RELEASE.to_owned());
init_logging(&PathBuf::from(log_dir), log_filter)?;
install_rustls_crypto_provider();
let callbacks = CallbackHandler {
vm: env.get_java_vm()?,
callback_handler,
};
let url = LoginUrl::client(
api_url.as_str(),
&secret,
device_id.clone(),
Some(device_name),
device_info,
)?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.thread_name("connlib")
.enable_all()
.build()?;
let _guard = runtime.enter(); // Constructing `PhoenixChannel` requires a runtime context.
let tcp_socket_factory = Arc::new(protected_tcp_socket_factory(callbacks.clone()));
let portal = PhoenixChannel::disconnected(
Secret::new(url),
get_user_agent(Some(os_version), env!("CARGO_PKG_VERSION")),
"client",
(),
|| {
ExponentialBackoffBuilder::default()
.with_max_elapsed_time(Some(MAX_PARTITION_TIME))
.build()
},
tcp_socket_factory,
)?;
let (session, mut event_stream) = Session::connect(
Arc::new(protected_tcp_socket_factory(callbacks.clone())),
Arc::new(protected_udp_socket_factory(callbacks.clone())),
portal,
runtime.handle().clone(),
);
analytics::new_session(device_id, api_url.to_string());
runtime.spawn(async move {
while let Some(event) = event_stream.next().await {
match event {
client_shared::Event::TunInterfaceUpdated {
ipv4,
ipv6,
dns,
search_domain,
ipv4_routes,
ipv6_routes,
} => callbacks.on_set_interface_config(
ipv4,
ipv6,
dns,
search_domain,
ipv4_routes,
ipv6_routes,
),
client_shared::Event::ResourcesUpdated(resource_views) => {
callbacks.on_update_resources(resource_views)
}
client_shared::Event::Disconnected(error) => callbacks.on_disconnect(error),
}
}
});
Ok(SessionWrapper {
inner: session,
runtime,
telemetry,
})
}
/// # Safety
/// Pointers must be valid
/// fd must be a valid file descriptor
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_dev_firezone_android_tunnel_ConnlibSession_connect(
mut env: JNIEnv,
_class: JClass,
api_url: JString,
token: JString,
device_id: JString,
device_name: JString,
os_version: JString,
log_dir: JString,
log_filter: JString,
callback_handler: JObject,
device_info: JString,
) -> *const SessionWrapper {
let Ok(callback_handler) = env.new_global_ref(callback_handler) else {
return std::ptr::null();
};
let connect = catch_and_throw(&mut env, |env| {
connect(
env,
api_url,
token,
device_id,
device_name,
os_version,
log_dir,
log_filter,
callback_handler,
device_info,
)
});
let session = match connect {
Some(Ok(session)) => session,
Some(Err(err)) => {
throw(&mut env, "java/lang/Exception", err.to_string());
return std::ptr::null();
}
None => return std::ptr::null(),
};
// Note: this pointer will probably be casted into a jlong after it is received by android.
// jlong is 64bits so the worst case scenario it will be padded, in that case, when casting it back to a pointer we expect `as` to select only the relevant bytes
Box::into_raw(Box::new(session))
}
pub struct SessionWrapper {
inner: Session,
runtime: Runtime,
telemetry: Telemetry,
}
/// # Safety
/// session_ptr should have been obtained from `connect` function
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_dev_firezone_android_tunnel_ConnlibSession_disconnect(
mut env: JNIEnv,
_: JClass,
session_ptr: jlong,
) {
// Creating an owned `Box` from this will properly drop this at the end of the scope.
let mut session = unsafe { Box::from_raw(session_ptr as *mut SessionWrapper) };
catch_and_throw(&mut env, |_| {
session.runtime.block_on(session.telemetry.stop());
});
// Drop session in new thread to ensure we are not dropping a runtime in a runtime.
// The Android app may call this from a callback which is executed within the runtime.
std::thread::spawn(move || {
drop(session);
});
}
/// # Safety
/// session_ptr should have been obtained from `connect` function, and shouldn't be dropped with disconnect
/// at any point before or during operation of this function.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_dev_firezone_android_tunnel_ConnlibSession_setDisabledResources(
mut env: JNIEnv,
_: JClass,
session_ptr: jlong,
disabled_resources: JString,
) {
let session = unsafe { &*(session_ptr as *const SessionWrapper) };
let disabled_resources = String::from(
env.get_string(&disabled_resources)
.expect("Invalid string returned from android client"),
);
let disabled_resources = serde_json::from_str(&disabled_resources)
.expect("Failed to deserialize disabled resource IDs");
tracing::debug!("disabled resource: {disabled_resources:?}");
session.inner.set_disabled_resources(disabled_resources);
}
/// Set system DNS resolvers
///
/// `dns_list` must not have any IPv6 scopes
/// <https://github.com/firezone/firezone/issues/4350>
/// <https://github.com/firezone/firezone/issues/5781>
///
/// # Safety
/// session_ptr should have been obtained from `connect` function, and shouldn't be dropped with disconnect
/// at any point before or during operation of this function.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_dev_firezone_android_tunnel_ConnlibSession_setDns(
mut env: JNIEnv,
_: JClass,
session_ptr: jlong,
dns_list: JString,
) {
let session = unsafe { &*(session_ptr as *const SessionWrapper) };
let dns = String::from(
env.get_string(&dns_list)
.expect("Invalid string returned from android client"),
);
let dns = serde_json::from_str::<Vec<IpAddr>>(&dns).expect("Failed to deserialize DNS IPs");
session.inner.set_dns(dns);
}
/// # Safety
/// session_ptr should have been obtained from `connect` function, and shouldn't be dropped with disconnect
/// at any point before or during operation of this function.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_dev_firezone_android_tunnel_ConnlibSession_reset(
_: JNIEnv,
_: JClass,
session_ptr: jlong,
) {
let session = unsafe { &*(session_ptr as *const SessionWrapper) };
session.inner.reset();
}
/// # Safety
/// session_ptr should have been obtained from `connect` function, and shouldn't be dropped with disconnect
/// at any point before or during operation of this function.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_dev_firezone_android_tunnel_ConnlibSession_setTun(
mut env: JNIEnv,
_: JClass,
session_ptr: jlong,
fd: RawFd,
) {
let session = unsafe { &*(session_ptr as *const SessionWrapper) };
// Enter tokio RT context to construct `Tun`.
let _enter = session.runtime.enter();
let tun_result = unsafe { Tun::from_fd(fd) };
let tun = match tun_result {
Ok(t) => t,
Err(e) => {
throw(&mut env, "java/lang/Exception", e.to_string());
return;
}
};
session.inner.set_tun(Box::new(tun));
}
fn protected_tcp_socket_factory(callbacks: CallbackHandler) -> impl SocketFactory<TcpSocket> {
move |addr| {
let socket = socket_factory::tcp(addr)?;
callbacks.protect(socket.as_raw_fd())?;
Ok(socket)
}
}
fn protected_udp_socket_factory(callbacks: CallbackHandler) -> impl SocketFactory<UdpSocket> {
move |addr| {
let socket = socket_factory::udp(addr)?;
callbacks.protect(socket.as_raw_fd())?;
Ok(socket)
}
}
/// Installs the `ring` crypto provider for rustls.
fn install_rustls_crypto_provider() {
let existing = rustls::crypto::ring::default_provider().install_default();
if existing.is_err() {
// On Android, connlib gets loaded as shared library by the JVM and may remain loaded even if we disconnect the tunnel.
tracing::debug!("Skipping install of crypto provider because we already have one.");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_jstring_is_null() {
assert!(JString::default().is_null())
}
}

View File

@@ -1,13 +1,12 @@
[package]
name = "android-client-ffi"
# mark:next-android-version
version = "1.5.2"
name = "client-ffi"
version = "0.1.0"
edition = { workspace = true }
license = { workspace = true }
[lib]
name = "connlib"
crate-type = ["lib", "cdylib"]
crate-type = ["lib", "cdylib", "staticlib"]
doc = false
[dependencies]
@@ -22,7 +21,6 @@ flume = { workspace = true }
futures = { workspace = true }
ip-packet = { workspace = true }
ip_network = { workspace = true }
jni = { workspace = true, features = ["invocation"] }
libc = { workspace = true }
log = { workspace = true }
phoenix-channel = { workspace = true }
@@ -36,6 +34,7 @@ tracing = { workspace = true, features = ["std", "attributes"] }
tracing-appender = { workspace = true }
tracing-subscriber = { workspace = true }
tun = { workspace = true }
uniffi = { workspace = true }
url = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]

367
rust/client-ffi/src/lib.rs Normal file
View File

@@ -0,0 +1,367 @@
mod platform;
use std::{
fmt, io,
os::fd::{AsRawFd as _, RawFd},
path::{Path, PathBuf},
sync::{Arc, OnceLock},
};
use anyhow::{Context as _, Result};
use backoff::ExponentialBackoffBuilder;
use client_shared::{V4RouteList, V6RouteList};
use firezone_logging::sentry_layer;
use firezone_telemetry::{Telemetry, analytics};
use phoenix_channel::{LoginUrl, PhoenixChannel, get_user_agent};
use platform::RELEASE;
use secrecy::{Secret, SecretString};
use socket_factory::{SocketFactory, TcpSocket, UdpSocket};
use tokio::sync::Mutex;
use tracing_subscriber::layer::SubscriberExt as _;
uniffi::setup_scaffolding!();
#[derive(uniffi::Object)]
pub struct Session {
inner: client_shared::Session,
events: Mutex<client_shared::EventStream>,
telemetry: Mutex<Telemetry>,
runtime: tokio::runtime::Runtime,
}
#[derive(uniffi::Object, thiserror::Error, Debug)]
#[error("{0:#}")]
pub struct Error(anyhow::Error);
#[derive(uniffi::Error, thiserror::Error, Debug)]
pub enum CallbackError {
#[error("{0}")]
Failed(String),
}
#[derive(uniffi::Object, Debug)]
pub struct DisconnectError(client_shared::DisconnectError);
#[derive(uniffi::Enum)]
pub enum Event {
TunInterfaceUpdated {
ipv4: String,
ipv6: String,
dns: String,
search_domain: Option<String>,
ipv4_routes: String,
ipv6_routes: String,
},
ResourcesUpdated {
resources: String,
},
Disconnected {
error: Arc<DisconnectError>,
},
}
#[uniffi::export]
impl DisconnectError {
pub fn message(&self) -> String {
self.0.to_string()
}
pub fn is_authentication_error(&self) -> bool {
self.0.is_authentication_error()
}
}
#[uniffi::export(with_foreign)]
pub trait ProtectSocket: Send + Sync + fmt::Debug {
fn protect_socket(&self, fd: RawFd) -> Result<(), CallbackError>;
}
#[uniffi::export]
impl Session {
#[uniffi::constructor]
#[expect(
clippy::too_many_arguments,
reason = "This is the API we want to expose over FFI."
)]
pub fn new_android(
api_url: String,
token: String,
device_id: String,
account_slug: String,
device_name: String,
os_version: String,
log_dir: String,
log_filter: String,
device_info: String,
protect_socket: Arc<dyn ProtectSocket>,
) -> Result<Self, Error> {
let udp_socket_factory = Arc::new(protected_udp_socket_factory(protect_socket.clone()));
let tcp_socket_factory = Arc::new(protected_tcp_socket_factory(protect_socket));
connect(
api_url,
token,
device_id,
account_slug,
Some(device_name),
Some(os_version),
log_dir,
log_filter,
device_info,
tcp_socket_factory,
udp_socket_factory,
)
}
pub fn disconnect(&self) {
self.runtime.block_on(async {
self.telemetry.lock().await.stop().await;
});
self.inner.stop();
}
pub fn set_disabled_resources(&self, disabled_resources: String) -> Result<(), Error> {
let disabled_resources = serde_json::from_str(&disabled_resources)
.context("Failed to deserialize disabled resource IDs")?;
self.inner.set_disabled_resources(disabled_resources);
Ok(())
}
pub fn set_dns(&self, dns_servers: String) -> Result<(), Error> {
let dns_servers =
serde_json::from_str(&dns_servers).context("Failed to deserialize DNS servers")?;
self.inner.set_dns(dns_servers);
Ok(())
}
pub fn reset(&self) {
self.inner.reset()
}
pub fn set_log_directives(&self, directives: String) -> Result<(), Error> {
let (_, reload_handle) = LOGGER_STATE.get().context("Logger not yet initialised")?;
reload_handle
.reload(&directives)
.context("Failed to apply new directives")?;
Ok(())
}
pub fn set_tun(&self, fd: RawFd) -> Result<(), Error> {
let _guard = self.runtime.enter();
// SAFETY: FD must be open.
let tun = unsafe { platform::Tun::from_fd(fd).context("Failed to create new Tun")? };
self.inner.set_tun(Box::new(tun));
Ok(())
}
pub async fn next_event(&self) -> Result<Option<Event>, Error> {
match self.events.lock().await.next().await {
Some(client_shared::Event::TunInterfaceUpdated {
ipv4,
ipv6,
dns,
search_domain,
ipv4_routes,
ipv6_routes,
}) => {
let dns = serde_json::to_string(&dns).context("Failed to serialize DNS servers")?;
let ipv4_routes = serde_json::to_string(&V4RouteList::new(ipv4_routes))
.context("Failed to serialize IPv4 routes")?;
let ipv6_routes = serde_json::to_string(&V6RouteList::new(ipv6_routes))
.context("Failed to serialize IPv6 routes")?;
Ok(Some(Event::TunInterfaceUpdated {
ipv4: ipv4.to_string(),
ipv6: ipv6.to_string(),
dns,
search_domain: search_domain.map(|d| d.to_string()),
ipv4_routes,
ipv6_routes,
}))
}
Some(client_shared::Event::ResourcesUpdated(resources)) => {
let resources = serde_json::to_string(&resources)
.context("Failed to serialize resource list")?;
Ok(Some(Event::ResourcesUpdated { resources }))
}
Some(client_shared::Event::Disconnected(error)) => Ok(Some(Event::Disconnected {
error: Arc::new(DisconnectError(error)),
})),
None => Ok(None),
}
}
}
impl Drop for Session {
fn drop(&mut self) {
self.runtime
.block_on(async { self.telemetry.lock().await.stop_on_crash().await })
}
}
#[expect(clippy::too_many_arguments, reason = "We don't care.")]
fn connect(
api_url: String,
token: String,
device_id: String,
account_slug: String,
device_name: Option<String>,
os_version: Option<String>,
log_dir: String,
log_filter: String,
device_info: String,
tcp_socket_factory: Arc<dyn SocketFactory<TcpSocket>>,
udp_socket_factory: Arc<dyn SocketFactory<UdpSocket>>,
) -> Result<Session, Error> {
let device_info =
serde_json::from_str(&device_info).context("Failed to deserialize `DeviceInfo`")?;
let secret = SecretString::from(token);
let mut telemetry = Telemetry::default();
telemetry.start(&api_url, RELEASE, platform::DSN);
Telemetry::set_firezone_id(device_id.clone());
Telemetry::set_account_slug(account_slug);
analytics::identify(device_id.clone(), api_url.to_string(), RELEASE.to_owned());
init_logging(&PathBuf::from(log_dir), log_filter)?;
install_rustls_crypto_provider();
let url = LoginUrl::client(
api_url.as_str(),
&secret,
device_id.clone(),
device_name,
device_info,
)
.context("Failed to create login URL")?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.thread_name("connlib")
.enable_all()
.build()
.context("Failed to create tokio runtime")?;
let _guard = runtime.enter(); // Constructing `PhoenixChannel` requires a runtime context.
let portal = PhoenixChannel::disconnected(
Secret::new(url),
get_user_agent(os_version, platform::VERSION),
"client",
(),
|| {
ExponentialBackoffBuilder::default()
.with_max_elapsed_time(Some(platform::MAX_PARTITION_TIME))
.build()
},
tcp_socket_factory.clone(),
)
.context("Failed to create `PhoenixChannel`")?;
let (session, events) = client_shared::Session::connect(
tcp_socket_factory,
udp_socket_factory,
portal,
runtime.handle().clone(),
);
analytics::new_session(device_id, api_url.to_string());
Ok(Session {
inner: session,
events: Mutex::new(events),
telemetry: Mutex::new(telemetry),
runtime,
})
}
static LOGGER_STATE: OnceLock<(
firezone_logging::file::Handle,
firezone_logging::FilterReloadHandle,
)> = OnceLock::new();
fn init_logging(log_dir: &Path, log_filter: String) -> Result<()> {
if let Some((_, reload_handle)) = LOGGER_STATE.get() {
reload_handle
.reload(&log_filter)
.context("Failed to apply new log-filter")?;
return Ok(());
}
let (log_filter, reload_handle) = firezone_logging::try_filter(&log_filter)?;
let (file_layer, handle) = firezone_logging::file::layer(log_dir, "connlib");
let subscriber = tracing_subscriber::registry()
.with(log_filter)
.with(file_layer)
.with(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.event_format(
firezone_logging::Format::new()
.without_timestamp()
.without_level(),
)
.with_writer(platform::MakeWriter::default()),
)
.with(sentry_layer());
firezone_logging::init(subscriber)?;
LOGGER_STATE
.set((handle, reload_handle))
.expect("Logging guard should never be initialized twice");
Ok(())
}
fn protected_tcp_socket_factory(callback: Arc<dyn ProtectSocket>) -> impl SocketFactory<TcpSocket> {
move |addr| {
let socket = socket_factory::tcp(addr)?;
callback
.protect_socket(socket.as_raw_fd())
.map_err(io::Error::other)?;
Ok(socket)
}
}
fn protected_udp_socket_factory(callback: Arc<dyn ProtectSocket>) -> impl SocketFactory<UdpSocket> {
move |addr| {
let socket = socket_factory::udp(addr)?;
callback
.protect_socket(socket.as_raw_fd())
.map_err(io::Error::other)?;
Ok(socket)
}
}
/// Installs the `ring` crypto provider for rustls.
fn install_rustls_crypto_provider() {
let existing = rustls::crypto::ring::default_provider().install_default();
if existing.is_err() {
tracing::debug!("Skipping install of crypto provider because we already have one.");
}
}
impl From<anyhow::Error> for Error {
fn from(value: anyhow::Error) -> Self {
Self(value)
}
}
impl From<uniffi::UnexpectedUniFFICallbackError> for CallbackError {
fn from(value: uniffi::UnexpectedUniFFICallbackError) -> Self {
Self::Failed(format!("Callback failed: {}", value.reason))
}
}

View File

@@ -0,0 +1,21 @@
#[cfg(any(
target_os = "linux",
target_os = "windows",
target_os = "macos",
target_os = "ios"
))]
mod fallback;
#[cfg(target_os = "android")]
mod android;
#[cfg(target_os = "android")]
pub use android::*;
#[cfg(any(
target_os = "linux",
target_os = "windows",
target_os = "macos",
target_os = "ios"
))]
pub use fallback::*;

View File

@@ -0,0 +1,19 @@
use firezone_telemetry::Dsn;
use std::time::Duration;
mod make_writer;
mod tun;
// mark:next-android-version
pub const RELEASE: &str = "connlib-android@1.5.2";
// mark:next-android-version
pub const VERSION: &str = "1.5.2";
/// We have valid use cases for headless Android clients
/// (IoT devices, point-of-sale devices, etc), so try to reconnect for 30 days.
pub const MAX_PARTITION_TIME: Duration = Duration::from_secs(60 * 60 * 24 * 30);
pub const DSN: Dsn = firezone_telemetry::ANDROID_DSN;
pub(crate) use make_writer::MakeWriter;
pub(crate) use tun::Tun;

View File

@@ -17,13 +17,15 @@ pub(crate) struct Writer {
tag: CString,
}
impl MakeWriter {
pub(crate) fn new(tag: &'static str) -> Self {
impl Default for MakeWriter {
fn default() -> Self {
Self {
tag: CString::new(tag).expect("tag must not contain nul-byte"),
tag: CString::new("connlib").expect("tag must not contain nul-byte"),
}
}
}
impl MakeWriter {
fn make_writer_for_level(&self, level: Level) -> BufWriter<Writer> {
let inner = Writer {
level,

View File

@@ -0,0 +1,68 @@
use std::{io, os::fd::RawFd, time::Duration};
use firezone_telemetry::Dsn;
pub const RELEASE: &str = "";
pub const VERSION: &str = "";
pub const DSN: Dsn = firezone_telemetry::TESTING;
pub const MAX_PARTITION_TIME: Duration = Duration::ZERO;
#[derive(Default)]
pub struct MakeWriter {}
pub struct DevNull;
impl tracing_subscriber::fmt::MakeWriter<'_> for MakeWriter {
type Writer = DevNull;
fn make_writer(&self) -> Self::Writer {
DevNull
}
fn make_writer_for(&self, _: &tracing::Metadata<'_>) -> Self::Writer {
DevNull
}
}
impl io::Write for DevNull {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
pub struct Tun;
impl Tun {
pub unsafe fn from_fd(_: RawFd) -> io::Result<Self> {
Err(io::Error::other("Stub!"))
}
}
impl tun::Tun for Tun {
fn poll_send_ready(&mut self, _: &mut std::task::Context) -> std::task::Poll<io::Result<()>> {
todo!()
}
fn send(&mut self, _: ip_packet::IpPacket) -> io::Result<()> {
todo!()
}
fn poll_recv_many(
&mut self,
_: &mut std::task::Context,
_: &mut Vec<ip_packet::IpPacket>,
_: usize,
) -> std::task::Poll<usize> {
todo!()
}
fn name(&self) -> &str {
todo!()
}
}

View File

@@ -32,6 +32,7 @@ pub struct Eventloop {
/// Commands that can be sent to the [`Eventloop`].
pub enum Command {
Reset,
Stop,
SetDns(Vec<IpAddr>),
SetTun(Box<dyn Tun>),
SetDisabledResources(BTreeSet<ResourceId>),
@@ -93,7 +94,7 @@ impl Eventloop {
pub fn poll(&mut self, cx: &mut Context<'_>) -> Poll<Result<()>> {
loop {
match self.cmd_rx.poll_recv(cx) {
Poll::Ready(None) => return Poll::Ready(Ok(())),
Poll::Ready(None | Some(Command::Stop)) => return Poll::Ready(Ok(())),
Poll::Ready(Some(Command::SetDns(dns))) => {
self.tunnel.state_mut().update_system_resolvers(dns);

View File

@@ -105,6 +105,10 @@ impl Session {
pub fn set_tun(&self, new_tun: Box<dyn Tun>) {
let _ = self.channel.send(Command::SetTun(new_tun));
}
pub fn stop(&self) {
let _ = self.channel.send(Command::Stop);
}
}
impl EventStream {

View File

@@ -264,6 +264,7 @@ skip = [
"syn",
"thiserror",
"thiserror-impl",
"toml",
"toml_edit",
"tower",
"wasi",

View File

@@ -0,0 +1,15 @@
[package]
name = "uniffi-bindgen"
version = "0.1.0"
edition = { workspace = true }
license = { workspace = true }
[[bin]]
name = "uniffi-bindgen"
path = "main.rs"
[dependencies]
uniffi = { workspace = true, features = ["cli"] }
[lints]
workspace = true

View File

@@ -0,0 +1,3 @@
fn main() {
uniffi::uniffi_bindgen_main()
}

View File

@@ -59,7 +59,7 @@
{
devShells = {
x86_64-linux.default = pkgs.mkShell {
packages = [ pkgs.cargo-tauri pkgs.iptables pkgs.pnpm pkgs.unstable.cargo-sort pkgs.cargo-deny pkgs.cargo-autoinherit pkgs.dump_syms pkgs.xvfb-run ];
packages = [ pkgs.cargo-tauri pkgs.iptables pkgs.pnpm pkgs.unstable.cargo-sort pkgs.cargo-deny pkgs.cargo-autoinherit pkgs.dump_syms pkgs.xvfb-run pkgs.ktlint ];
buildInputs = packages;
src = ../..;