diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml index 2e1868e5c..dd5af3388 100644 --- a/.github/actions/setup-rust/action.yml +++ b/.github/actions/setup-rust/action.yml @@ -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: ${{ diff --git a/kotlin/android/app/build.gradle.kts b/kotlin/android/app/build.gradle.kts index f7234d17c..230b0eeb0 100644 --- a/kotlin/android/app/build.gradle.kts +++ b/kotlin/android/app/build.gradle.kts @@ -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) + } + } +} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/ConnlibSession.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/ConnlibSession.kt deleted file mode 100644 index 9a85e1786..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/ConnlibSession.kt +++ /dev/null @@ -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 -} diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/NetworkMonitor.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/NetworkMonitor.kt index 4992c8159..57b6ca2e3 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/NetworkMonitor.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/NetworkMonitor.kt @@ -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) diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt index b4a67bf83..e7c6f7b45 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/TunnelService.kt @@ -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? = null + private val serviceScope = CoroutineScope(SupervisorJob()) var tunnelResources: List 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>().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>().fromJson(dnsAddresses)!! - val routes4 = moshi.adapter>().fromJson(routes4JSON)!! - val routes6 = moshi.adapter>().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) { + 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(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, + ) { + val eventChannel = + serviceScope.produce { + while (isActive) { + send(session.nextEvent()) + } + } + + var running = true + + while (running) { + try { + select { + 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>().fromJson(event.resources)!! + resourcesUpdated() + } + + is Event.TunInterfaceUpdated -> { + tunnelDnsAddresses = + moshi.adapter>().fromJson(event.dns)!! + tunnelSearchDomain = event.searchDomain + tunnelIpv4Address = event.ipv4 + tunnelIpv6Address = event.ipv6 + tunnelRoutes.clear() + tunnelRoutes.addAll( + moshi + .adapter>() + .fromJson(event.ipv4Routes)!!, + ) + tunnelRoutes.addAll( + moshi + .adapter>() + .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, diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/callback/ConnlibCallback.kt b/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/callback/ConnlibCallback.kt deleted file mode 100644 index 6faaa0a61..000000000 --- a/kotlin/android/app/src/main/java/dev/firezone/android/tunnel/callback/ConnlibCallback.kt +++ /dev/null @@ -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) -} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 18a650ef4..cf00b88e3 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -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" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 9598cbbaf..6eb214879 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -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" diff --git a/rust/android-client-ffi/src/lib.rs b/rust/android-client-ffi/src/lib.rs deleted file mode 100644 index 6bd362d06..000000000 --- a/rust/android-client-ffi/src/lib.rs +++ /dev/null @@ -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( - &self, - f: impl FnOnce(JNIEnv) -> Result, - ) -> Result { - 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, - search_domain: Option, - route_list_4: Vec, - route_list_6: Vec, - ) { - 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) { - 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) { - 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 R, R>(env: &mut JNIEnv, f: F) -> Option { - 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 { - 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 -/// -/// -/// -/// # 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::>(&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 { - 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 { - 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()) - } -} diff --git a/rust/android-client-ffi/Cargo.toml b/rust/client-ffi/Cargo.toml similarity index 87% rename from rust/android-client-ffi/Cargo.toml rename to rust/client-ffi/Cargo.toml index c7e3d65b8..77a134ddc 100644 --- a/rust/android-client-ffi/Cargo.toml +++ b/rust/client-ffi/Cargo.toml @@ -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] diff --git a/rust/client-ffi/src/lib.rs b/rust/client-ffi/src/lib.rs new file mode 100644 index 000000000..3cd6a64dc --- /dev/null +++ b/rust/client-ffi/src/lib.rs @@ -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, + telemetry: Mutex, + 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, + ipv4_routes: String, + ipv6_routes: String, + }, + ResourcesUpdated { + resources: String, + }, + Disconnected { + error: Arc, + }, +} + +#[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, + ) -> Result { + 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, 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, + os_version: Option, + log_dir: String, + log_filter: String, + device_info: String, + tcp_socket_factory: Arc>, + udp_socket_factory: Arc>, +) -> Result { + 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) -> impl SocketFactory { + 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) -> impl SocketFactory { + 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 for Error { + fn from(value: anyhow::Error) -> Self { + Self(value) + } +} + +impl From for CallbackError { + fn from(value: uniffi::UnexpectedUniFFICallbackError) -> Self { + Self::Failed(format!("Callback failed: {}", value.reason)) + } +} diff --git a/rust/client-ffi/src/platform.rs b/rust/client-ffi/src/platform.rs new file mode 100644 index 000000000..129a7dc71 --- /dev/null +++ b/rust/client-ffi/src/platform.rs @@ -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::*; diff --git a/rust/client-ffi/src/platform/android.rs b/rust/client-ffi/src/platform/android.rs new file mode 100644 index 000000000..53fc376b5 --- /dev/null +++ b/rust/client-ffi/src/platform/android.rs @@ -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; diff --git a/rust/android-client-ffi/src/make_writer.rs b/rust/client-ffi/src/platform/android/make_writer.rs similarity index 94% rename from rust/android-client-ffi/src/make_writer.rs rename to rust/client-ffi/src/platform/android/make_writer.rs index b12483f30..f604dc926 100644 --- a/rust/android-client-ffi/src/make_writer.rs +++ b/rust/client-ffi/src/platform/android/make_writer.rs @@ -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 { let inner = Writer { level, diff --git a/rust/android-client-ffi/src/tun.rs b/rust/client-ffi/src/platform/android/tun.rs similarity index 100% rename from rust/android-client-ffi/src/tun.rs rename to rust/client-ffi/src/platform/android/tun.rs diff --git a/rust/client-ffi/src/platform/fallback.rs b/rust/client-ffi/src/platform/fallback.rs new file mode 100644 index 000000000..cfd89287b --- /dev/null +++ b/rust/client-ffi/src/platform/fallback.rs @@ -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 { + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +pub struct Tun; + +impl Tun { + pub unsafe fn from_fd(_: RawFd) -> io::Result { + Err(io::Error::other("Stub!")) + } +} + +impl tun::Tun for Tun { + fn poll_send_ready(&mut self, _: &mut std::task::Context) -> std::task::Poll> { + todo!() + } + + fn send(&mut self, _: ip_packet::IpPacket) -> io::Result<()> { + todo!() + } + + fn poll_recv_many( + &mut self, + _: &mut std::task::Context, + _: &mut Vec, + _: usize, + ) -> std::task::Poll { + todo!() + } + + fn name(&self) -> &str { + todo!() + } +} diff --git a/rust/client-shared/src/eventloop.rs b/rust/client-shared/src/eventloop.rs index cca9fd98b..b2ca4faaf 100644 --- a/rust/client-shared/src/eventloop.rs +++ b/rust/client-shared/src/eventloop.rs @@ -32,6 +32,7 @@ pub struct Eventloop { /// Commands that can be sent to the [`Eventloop`]. pub enum Command { Reset, + Stop, SetDns(Vec), SetTun(Box), SetDisabledResources(BTreeSet), @@ -93,7 +94,7 @@ impl Eventloop { pub fn poll(&mut self, cx: &mut Context<'_>) -> Poll> { 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); diff --git a/rust/client-shared/src/lib.rs b/rust/client-shared/src/lib.rs index 98498a8cd..bbfb32049 100644 --- a/rust/client-shared/src/lib.rs +++ b/rust/client-shared/src/lib.rs @@ -105,6 +105,10 @@ impl Session { pub fn set_tun(&self, new_tun: Box) { let _ = self.channel.send(Command::SetTun(new_tun)); } + + pub fn stop(&self) { + let _ = self.channel.send(Command::Stop); + } } impl EventStream { diff --git a/rust/deny.toml b/rust/deny.toml index 8a0f62d1e..59e41826c 100644 --- a/rust/deny.toml +++ b/rust/deny.toml @@ -264,6 +264,7 @@ skip = [ "syn", "thiserror", "thiserror-impl", + "toml", "toml_edit", "tower", "wasi", diff --git a/rust/tools/uniffi-bindgen/Cargo.toml b/rust/tools/uniffi-bindgen/Cargo.toml new file mode 100644 index 000000000..355421306 --- /dev/null +++ b/rust/tools/uniffi-bindgen/Cargo.toml @@ -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 diff --git a/rust/tools/uniffi-bindgen/main.rs b/rust/tools/uniffi-bindgen/main.rs new file mode 100644 index 000000000..f6cff6cf1 --- /dev/null +++ b/rust/tools/uniffi-bindgen/main.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/scripts/nix/flake.nix b/scripts/nix/flake.nix index 2ea395583..ec4070b36 100644 --- a/scripts/nix/flake.nix +++ b/scripts/nix/flake.nix @@ -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 = ../..;