From faeb958882ad8e3a493c5a2530e5418bf034c35d Mon Sep 17 00:00:00 2001 From: Thomas Eizinger Date: Tue, 17 Jun 2025 23:48:34 +0200 Subject: [PATCH] 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 Co-authored-by: Jamil Bou Kheir --- .github/actions/setup-rust/action.yml | 4 +- kotlin/android/app/build.gradle.kts | 70 +- .../firezone/android/tunnel/ConnlibSession.kt | 36 - .../firezone/android/tunnel/NetworkMonitor.kt | 46 +- .../firezone/android/tunnel/TunnelService.kt | 301 ++++++--- .../tunnel/callback/ConnlibCallback.kt | 20 - rust/Cargo.lock | 353 ++++++++-- rust/Cargo.toml | 4 +- rust/android-client-ffi/src/lib.rs | 633 ------------------ .../Cargo.toml | 9 +- rust/client-ffi/src/lib.rs | 367 ++++++++++ rust/client-ffi/src/platform.rs | 21 + rust/client-ffi/src/platform/android.rs | 19 + .../src/platform/android}/make_writer.rs | 8 +- .../src/platform/android}/tun.rs | 0 rust/client-ffi/src/platform/fallback.rs | 68 ++ rust/client-shared/src/eventloop.rs | 3 +- rust/client-shared/src/lib.rs | 4 + rust/deny.toml | 1 + rust/tools/uniffi-bindgen/Cargo.toml | 15 + rust/tools/uniffi-bindgen/main.rs | 3 + scripts/nix/flake.nix | 2 +- 22 files changed, 1091 insertions(+), 896 deletions(-) delete mode 100644 kotlin/android/app/src/main/java/dev/firezone/android/tunnel/ConnlibSession.kt delete mode 100644 kotlin/android/app/src/main/java/dev/firezone/android/tunnel/callback/ConnlibCallback.kt delete mode 100644 rust/android-client-ffi/src/lib.rs rename rust/{android-client-ffi => client-ffi}/Cargo.toml (87%) create mode 100644 rust/client-ffi/src/lib.rs create mode 100644 rust/client-ffi/src/platform.rs create mode 100644 rust/client-ffi/src/platform/android.rs rename rust/{android-client-ffi/src => client-ffi/src/platform/android}/make_writer.rs (94%) rename rust/{android-client-ffi/src => client-ffi/src/platform/android}/tun.rs (100%) create mode 100644 rust/client-ffi/src/platform/fallback.rs create mode 100644 rust/tools/uniffi-bindgen/Cargo.toml create mode 100644 rust/tools/uniffi-bindgen/main.rs 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 = ../..;