Files
firezone/rust/client-shared/src/lib.rs
Thomas Eizinger faeb958882 refactor: use UniFFI for Android FFI (#9415)
To make our FFI layer between Android and Rust safer, we adopt the
UniFFI tool from Mozilla. UniFFI allows us to create a dedicated crate
(here `client-ffi`) that contains Rust structs annotated with various
attributes. These macros then generate code at compile time that is
built into the shared object. Using a dedicated CLI from the UniFFI
project, we can then generate Kotlin bindings from this shared object.

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

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

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

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

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

Resolves: #9499
Related: #3959

---------

Signed-off-by: Thomas Eizinger <thomas@eizinger.io>
Co-authored-by: Jamil Bou Kheir <jamilbk@users.noreply.github.com>
2025-06-17 21:48:34 +00:00

173 lines
5.7 KiB
Rust

//! Main connlib library for clients.
pub use crate::serde_routelist::{V4RouteList, V6RouteList};
pub use connlib_model::StaticSecret;
pub use eventloop::{DisconnectError, Event};
pub use firezone_tunnel::messages::client::{IngressMessages, ResourceDescription};
use anyhow::{Context as _, Result};
use connlib_model::ResourceId;
use eventloop::{Command, Eventloop};
use firezone_tunnel::ClientTunnel;
use phoenix_channel::{PhoenixChannel, PublicKeyParam};
use socket_factory::{SocketFactory, TcpSocket, UdpSocket};
use std::collections::BTreeSet;
use std::net::IpAddr;
use std::sync::Arc;
use std::task::{Context, Poll};
use tokio::sync::mpsc::{Receiver, Sender, UnboundedReceiver, UnboundedSender};
use tokio::task::JoinHandle;
use tun::Tun;
mod eventloop;
mod serde_routelist;
const PHOENIX_TOPIC: &str = "client";
/// A session is the entry-point for connlib, maintains the runtime and the tunnel.
///
/// A session is created using [`Session::connect`].
/// To stop the session, simply drop this struct.
#[derive(Clone)]
pub struct Session {
channel: UnboundedSender<Command>,
}
pub struct EventStream {
channel: Receiver<Event>,
}
impl Session {
/// Creates a new [`Session`].
///
/// This connects to the portal using the given [`LoginUrl`](phoenix_channel::LoginUrl) and creates a wireguard tunnel using the provided private key.
pub fn connect(
tcp_socket_factory: Arc<dyn SocketFactory<TcpSocket>>,
udp_socket_factory: Arc<dyn SocketFactory<UdpSocket>>,
portal: PhoenixChannel<(), IngressMessages, (), PublicKeyParam>,
handle: tokio::runtime::Handle,
) -> (Self, EventStream) {
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
let (event_tx, event_rx) = tokio::sync::mpsc::channel(1000);
let connect_handle = handle.spawn(connect(
tcp_socket_factory,
udp_socket_factory,
portal,
cmd_rx,
event_tx.clone(),
));
handle.spawn(connect_supervisor(connect_handle, event_tx));
(Self { channel: cmd_tx }, EventStream { channel: event_rx })
}
/// Reset a [`Session`].
///
/// Resetting a session will:
///
/// - Close and re-open a connection to the portal.
/// - Delete all allocations.
/// - Rebind local UDP sockets.
///
/// # Implementation note
///
/// The reason we rebind the UDP sockets are:
///
/// 1. On MacOS, a socket bound to the unspecified IP cannot send to interfaces attached after the socket has been created.
/// 2. Switching between networks changes the 3-tuple of the client.
/// The TURN protocol identifies a client's allocation based on the 3-tuple.
/// Consequently, an allocation is invalid after switching networks and we clear the state.
/// Changing the IP would be enough for that.
/// However, if the user would now change _back_ to the previous network,
/// the TURN server would recognise the old allocation but the client already lost all its state associated with it.
/// To avoid race-conditions like this, we rebind the sockets to a new port.
pub fn reset(&self) {
let _ = self.channel.send(Command::Reset);
}
/// Sets a new set of upstream DNS servers for this [`Session`].
///
/// Changing the DNS servers clears all cached DNS requests which may be disruptive to the UX.
/// Clients should only call this when relevant.
///
/// The implementation is idempotent; calling it with the same set of servers is safe.
pub fn set_dns(&self, new_dns: Vec<IpAddr>) {
let _ = self.channel.send(Command::SetDns(new_dns));
}
pub fn set_disabled_resources(&self, disabled_resources: BTreeSet<ResourceId>) {
let _ = self
.channel
.send(Command::SetDisabledResources(disabled_resources));
}
/// Sets a new [`Tun`] device handle.
pub fn set_tun(&self, new_tun: Box<dyn Tun>) {
let _ = self.channel.send(Command::SetTun(new_tun));
}
pub fn stop(&self) {
let _ = self.channel.send(Command::Stop);
}
}
impl EventStream {
pub fn poll_next(&mut self, cx: &mut Context) -> Poll<Option<Event>> {
self.channel.poll_recv(cx)
}
pub async fn next(&mut self) -> Option<Event> {
self.channel.recv().await
}
}
impl Drop for Session {
fn drop(&mut self) {
tracing::debug!("`Session` dropped")
}
}
/// Connects to the portal and starts a tunnel.
///
/// When this function exits, the tunnel failed unrecoverably and you need to call it again.
async fn connect(
tcp_socket_factory: Arc<dyn SocketFactory<TcpSocket>>,
udp_socket_factory: Arc<dyn SocketFactory<UdpSocket>>,
portal: PhoenixChannel<(), IngressMessages, (), PublicKeyParam>,
cmd_rx: UnboundedReceiver<Command>,
event_tx: Sender<Event>,
) -> Result<()> {
let tunnel = ClientTunnel::new(tcp_socket_factory, udp_socket_factory);
let mut eventloop = Eventloop::new(tunnel, portal, cmd_rx, event_tx);
std::future::poll_fn(|cx| eventloop.poll(cx)).await?;
Ok(())
}
/// A supervisor task that handles, when [`connect`] exits.
async fn connect_supervisor(
connect_handle: JoinHandle<Result<()>>,
event_tx: tokio::sync::mpsc::Sender<Event>,
) {
let task = async {
connect_handle.await.context("connlib crashed")??;
Ok(())
};
let error = match task.await {
Ok(()) => {
tracing::info!("connlib exited gracefully");
return;
}
Err(e) => e,
};
match event_tx.send(Event::Disconnected(error)).await {
Ok(()) => (),
Err(_) => tracing::debug!("Event stream closed before we could send disconnected event"),
}
}