diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml index e310720ba..228a976d5 100644 --- a/.github/actions/setup-rust/action.yml +++ b/.github/actions/setup-rust/action.yml @@ -21,12 +21,12 @@ outputs: value: ${{ (runner.os == 'Linux' && '--workspace') || (runner.os == 'macOS' && '--workspace') || - (runner.os == 'Windows' && '--workspace --exclude ebpf-turn-router --exclude apple-client-ffi --exclude client-ffi') }} + (runner.os == 'Windows' && '--workspace --exclude ebpf-turn-router --exclude client-ffi') }} test-packages: description: Testable packages for the current OS value: ${{ (runner.os == 'Linux' && '--workspace') || - (runner.os == 'macOS' && '-p apple-client-ffi -p client-shared -p firezone-tunnel -p snownet') || + (runner.os == 'macOS' && '-p client-ffi -p client-shared -p firezone-tunnel -p snownet') || (runner.os == 'Windows' && '-p client-shared -p connlib-model -p firezone-bin-shared -p firezone-gui-client -p firezone-headless-client -p firezone-logging -p firezone-telemetry -p firezone-tunnel -p gui-smoke-test -p http-test-server -p ip-packet -p phoenix-channel -p snownet -p socket-factory -p tun') }} nightly_version: description: The nightly version of Rust diff --git a/.gitignore b/.gitignore index b33935efb..444f4d6a3 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,8 @@ gha-creds-*.json # Ignore local `direnv` cache .direnv +# Ignore personal spelling dictionary +/cspell.json + # Ignore build data for sourcekit-lsp (Swift code) buildServer.json diff --git a/rust/Cargo.lock b/rust/Cargo.lock index cd5e05896..ec17bf805 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -311,38 +311,6 @@ version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" -[[package]] -name = "apple-client-ffi" -version = "1.5.9" -dependencies = [ - "anyhow", - "backoff", - "client-shared", - "connlib-model", - "dns-types", - "firezone-logging", - "firezone-telemetry", - "futures", - "ip-packet", - "ip_network", - "libc", - "oslog", - "phoenix-channel", - "rustls", - "secrecy", - "serde_json", - "socket-factory", - "swift-bridge", - "swift-bridge-build", - "tokio", - "tokio-util", - "tracing", - "tracing-appender", - "tracing-subscriber", - "tun", - "url", -] - [[package]] name = "arbitrary" version = "1.4.2" @@ -1340,6 +1308,7 @@ dependencies = [ "ip_network", "libc", "log", + "oslog", "phoenix-channel", "rustls", "secrecy", @@ -7336,51 +7305,6 @@ dependencies = [ "is_ci", ] -[[package]] -name = "swift-bridge" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca240710850bfee64549b2f86cd2c6fbbb3de64ce5f3da764cb1ecec5c6cc37" -dependencies = [ - "swift-bridge-build", - "swift-bridge-macro", -] - -[[package]] -name = "swift-bridge-build" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d0ea3c38460a65d975df382b71edbc0e22942d937710d63144267d6609ce738" -dependencies = [ - "proc-macro2", - "swift-bridge-ir", - "syn 1.0.109", - "tempfile", -] - -[[package]] -name = "swift-bridge-ir" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ea2dcd83a40a918fb26a1bb90187691aa0ae8f2c96e8ea5e6963207bc65676" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "swift-bridge-macro" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9b93285ca24a3fd596ecd316b217c4a0fd78c629e09efb1b4990fab1478183" -dependencies = [ - "proc-macro2", - "quote", - "swift-bridge-ir", - "syn 1.0.109", -] - [[package]] name = "swift-rs" version = "1.0.7" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index a18191fc0..11c115782 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,5 @@ [workspace] members = [ - "apple-client-ffi", "bin-shared", "client-ffi", "client-shared", @@ -42,7 +41,6 @@ edition = "2024" [workspace.dependencies] admx-macro = { path = "gui-client/src-admx-macro" } anyhow = "1.0.99" -apple-client-ffi = { path = "apple-client-ffi" } arbitrary = "1.4.2" arboard = { version = "3.6.1", default-features = false } async-trait = { version = "0.1", default-features = false } diff --git a/rust/apple-client-ffi/.gitignore b/rust/apple-client-ffi/.gitignore deleted file mode 100644 index d35e9808b..000000000 --- a/rust/apple-client-ffi/.gitignore +++ /dev/null @@ -1,31 +0,0 @@ -.DS_Store - -# Rust -/target -Cargo.lock - -### Xcode ### -xcuserdata/ -/.build -/Packages -DerivedData/ -.swiftpm/config/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc -*.xcarchive/ -*.xcframework/ - -*.checksum.txt - -### Xcode Patch ### -*.xcodeproj/* -!*.xcodeproj/project.pbxproj -!*.xcodeproj/xcshareddata/ -!*.xcodeproj/project.xcworkspace/ -!*.xcworkspace/contents.xcworkspacedata -/*.gcno -**/xcshareddata/WorkspaceSettings.xcsettings - -## Xcode 8 and earlier -*.xcscmblueprint -*.xccheckout diff --git a/rust/apple-client-ffi/Cargo.toml b/rust/apple-client-ffi/Cargo.toml deleted file mode 100644 index 25f781f94..000000000 --- a/rust/apple-client-ffi/Cargo.toml +++ /dev/null @@ -1,46 +0,0 @@ -[package] -name = "apple-client-ffi" -# mark:next-apple-version -version = "1.5.9" -edition = { workspace = true } -license = { workspace = true } - -[lib] -name = "connlib" -crate-type = ["staticlib"] -doc = false - -[dependencies] -anyhow = { workspace = true } -backoff = { workspace = true } -client-shared = { workspace = true } -connlib-model = { workspace = true } -dns-types = { workspace = true } -firezone-logging = { workspace = true } -firezone-telemetry = { workspace = true } -futures = { workspace = true } -ip-packet = { workspace = true } -ip_network = { workspace = true } -libc = { workspace = true } -phoenix-channel = { workspace = true } -rustls = { workspace = true } -secrecy = { workspace = true } -serde_json = { workspace = true } -socket-factory = { workspace = true } -swift-bridge = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread", "sync"] } -tokio-util = { workspace = true } -tracing = { workspace = true } -tracing-appender = { workspace = true } -tracing-subscriber = { workspace = true } -tun = { workspace = true } -url = { workspace = true } - -[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] -oslog = { version = "0.2.0", default-features = false } - -[build-dependencies] -swift-bridge-build = { workspace = true } - -[lints] -workspace = true diff --git a/rust/apple-client-ffi/README.md b/rust/apple-client-ffi/README.md deleted file mode 100644 index 58be4bda5..000000000 --- a/rust/apple-client-ffi/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# Connlib Apple Wrapper - -Apple Package wrapper for Connlib for inclusion in the Firezone Apple -client. - -This is built as part of the Apple client build. diff --git a/rust/apple-client-ffi/build-rust.sh b/rust/apple-client-ffi/build-rust.sh deleted file mode 100755 index ad6dfae8d..000000000 --- a/rust/apple-client-ffi/build-rust.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/bin/bash - -################################################## -# We call this from an Xcode run script. -################################################## - -set -euo pipefail - -cmd=${1:-""} - -# Sanitize the environment to prevent Xcode's shenanigans from leaking -# into our highly evolved Rust-based build system. -for var in $(env | awk -F= '{print $1}'); do - if [[ "$var" != "HOME" ]] && - [[ "$var" != "MACOSX_DEPLOYMENT_TARGET" ]] && - [[ "$var" != "IPHONEOS_DEPLOYMENT_TARGET" ]] && - [[ "$var" != "USER" ]] && - [[ "$var" != "LOGNAME" ]] && - [[ "$var" != "TERM" ]] && - [[ "$var" != "PWD" ]] && - [[ "$var" != "SHELL" ]] && - [[ "$var" != "TMPDIR" ]] && - [[ "$var" != "XPC_FLAGS" ]] && - [[ "$var" != "XPC_SERVICE_NAME" ]] && - [[ "$var" != "PLATFORM_NAME" ]] && - [[ "$var" != "CONFIGURATION" ]] && - [[ "$var" != "NATIVE_ARCH" ]] && - [[ "$var" != "ONLY_ACTIVE_ARCH" ]] && - [[ "$var" != "ARCHS" ]] && - [[ "$var" != "SDKROOT" ]] && - [[ "$var" != "OBJROOT" ]] && - [[ "$var" != "SYMROOT" ]] && - [[ "$var" != "SRCROOT" ]] && - [[ "$var" != "TARGETED_DEVICE_FAMILY" ]] && - [[ "$var" != "RUSTC_WRAPPER" ]] && - [[ "$var" != "RUST_TOOLCHAIN" ]] && - [[ "$var" != "SCCACHE_GCS_BUCKET" ]] && - [[ "$var" != "SCCACHE_GCS_RW_MODE" ]] && - [[ "$var" != "GOOGLE_CLOUD_PROJECT" ]] && - [[ "$var" != "GCP_PROJECT" ]] && - [[ "$var" != "GCLOUD_PROJECT" ]] && - [[ "$var" != "CLOUDSDK_PROJECT" ]] && - [[ "$var" != "CLOUDSDK_CORE_PROJECT" ]] && - [[ "$var" != "GOOGLE_GHA_CREDS_PATH" ]] && - [[ "$var" != "GOOGLE_APPLICATION_CREDENTIALS" ]] && - [[ "$var" != "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE" ]] && - [[ "$var" != "ACTIONS_CACHE_URL" ]] && - [[ "$var" != "ACTIONS_RUNTIME_TOKEN" ]] && - [[ "$var" != "CARGO_INCREMENTAL" ]] && - [[ "$var" != "CARGO_TERM_COLOR" ]] && - [[ "$var" != "FIREZONE_PACKAGE_VERSION" ]] && - [[ "$var" != "CONNLIB_TARGET_DIR" ]]; then - unset "$var" - fi -done - -# Use pristine path; the PATH from Xcode is polluted with stuff we don't want which can -# confuse rustc. -export PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:$HOME/.cargo/bin:/run/current-system/sw/bin/" - -if [[ $cmd == "clean" ]]; then - echo "Skipping build during 'clean'" - exit 0 -fi - -if [[ -z "$PLATFORM_NAME" ]]; then - echo "PLATFORM_NAME is not set" - exit 1 -fi - -TARGETS="" -if [[ "$PLATFORM_NAME" = "macosx" ]]; then - if [[ $CONFIGURATION == "Release" ]] || [[ -z "$NATIVE_ARCH" ]]; then - TARGETS=("aarch64-apple-darwin" "x86_64-apple-darwin") - else - if [[ $NATIVE_ARCH == "arm64" ]]; then - TARGETS=("aarch64-apple-darwin") - else - if [[ $NATIVE_ARCH == "x86_64" ]]; then - TARGETS=("x86_64-apple-darwin") - else - echo "Unsupported native arch for $PLATFORM_NAME: $NATIVE_ARCH" - fi - fi - fi -else - if [[ "$PLATFORM_NAME" = "iphoneos" ]]; then - TARGETS=("aarch64-apple-ios") - else - echo "Unsupported platform: $PLATFORM_NAME" - exit 1 - fi -fi - -MESSAGE="Building Connlib" -CONFIGURATION_ARGS="" - -if [[ $CONFIGURATION == "Release" ]]; then - echo "${MESSAGE} for Release" - CONFIGURATION_ARGS="--release" -else - echo "${MESSAGE} for Debug" -fi - -if [[ -n "$CONNLIB_TARGET_DIR" ]]; then - export CARGO_TARGET_DIR=$CONNLIB_TARGET_DIR -fi - -target_list="" -for target in "${TARGETS[@]}"; do - target_list+="--target $target " -done - -target_list="${target_list% }" - -# Build the library -cargo build --verbose $target_list $CONFIGURATION_ARGS diff --git a/rust/apple-client-ffi/build.rs b/rust/apple-client-ffi/build.rs deleted file mode 100644 index d7744c202..000000000 --- a/rust/apple-client-ffi/build.rs +++ /dev/null @@ -1,14 +0,0 @@ -const XCODE_CONFIGURATION_ENV: &str = "CONFIGURATION"; - -fn main() { - let out_dir = "../../swift/apple/FirezoneNetworkExtension/Connlib/Generated"; - - let bridges = vec!["src/lib.rs"]; - for path in &bridges { - println!("cargo:rerun-if-changed={path}"); - } - println!("cargo:rerun-if-env-changed={XCODE_CONFIGURATION_ENV}"); - - swift_bridge_build::parse_bridges(bridges) - .write_all_concatenated(out_dir, env!("CARGO_PKG_NAME")); -} diff --git a/rust/apple-client-ffi/src/lib.rs b/rust/apple-client-ffi/src/lib.rs deleted file mode 100644 index d50637c22..000000000 --- a/rust/apple-client-ffi/src/lib.rs +++ /dev/null @@ -1,411 +0,0 @@ -// Swift bridge generated code triggers this below -#![allow(clippy::unnecessary_cast, improper_ctypes, non_camel_case_types)] -#![cfg(unix)] - -mod make_writer; -mod tun; - -use anyhow::Context; -use anyhow::Result; -use backoff::ExponentialBackoffBuilder; -use client_shared::{DisconnectError, Event, Session, V4RouteList, V6RouteList}; -use connlib_model::ResourceView; -use dns_types::DomainName; -use firezone_logging::err_with_src; -use firezone_logging::sentry_layer; -use firezone_telemetry::APPLE_DSN; -use firezone_telemetry::Telemetry; -use firezone_telemetry::analytics; -use ip_network::{Ipv4Network, Ipv6Network}; -use phoenix_channel::LoginUrl; -use phoenix_channel::PhoenixChannel; -use phoenix_channel::get_user_agent; -use secrecy::{Secret, SecretString}; -use std::sync::OnceLock; -use std::{ - net::{IpAddr, Ipv4Addr, Ipv6Addr}, - path::PathBuf, - sync::Arc, - time::Duration, -}; -use tokio::runtime::Runtime; -use tokio::task::JoinHandle; -use tracing_subscriber::prelude::*; -use tun::Tun; - -/// The Apple client implements reconnect logic in the upper layer using OS provided -/// APIs to detect network connectivity changes. The reconnect timeout here only -/// applies only in the following conditions: -/// -/// * That reconnect logic fails to detect network changes (not expected to happen) -/// * The portal is DOWN -/// -/// Hopefully we aren't down for more than 24 hours. -const MAX_PARTITION_TIME: Duration = Duration::from_secs(60 * 60 * 24); - -/// The Sentry release. -/// -/// This module is only responsible for the connlib part of the MacOS/iOS app. -/// Bugs within the MacOS/iOS app itself may use the same DSN but a different component as part of the version string. -const RELEASE: &str = concat!("connlib-apple@", env!("CARGO_PKG_VERSION")); - -#[swift_bridge::bridge] -mod ffi { - extern "Rust" { - type WrappedSession; - type DisconnectError; - - #[swift_bridge(associated_to = WrappedSession, return_with = err_to_string)] - fn connect( - api_url: String, - token: String, - device_id: String, - account_slug: String, - device_name_override: Option, - os_version_override: Option, - log_dir: String, - log_filter: String, - is_internet_resource_active: bool, - callback_handler: CallbackHandler, - device_info: String, - ) -> Result; - - fn reset(self: &mut WrappedSession, reason: String); - - // Set system DNS resolvers - // - // `dns_servers` must not have any IPv6 scopes - // - #[swift_bridge(swift_name = "setDns", return_with = err_to_string)] - fn set_dns(self: &mut WrappedSession, dns_servers: String) -> Result<(), String>; - - #[swift_bridge(swift_name = "setInternetResourceState")] - fn set_internet_resource_state(self: &mut WrappedSession, active: bool); - - #[swift_bridge(swift_name = "setLogDirectives", return_with = err_to_string)] - fn set_log_directives(self: &mut WrappedSession, directives: String) -> Result<(), String>; - - #[swift_bridge(swift_name = "isAuthenticationError")] - fn is_authentication_error(self: &DisconnectError) -> bool; - - #[swift_bridge(swift_name = "toString")] - fn to_string(self: &DisconnectError) -> String; - } - - extern "Swift" { - type CallbackHandler; - - #[swift_bridge(swift_name = "onSetInterfaceConfig")] - fn on_set_interface_config( - &self, - tunnelAddressIPv4: String, - tunnelAddressIPv6: String, - searchDomain: Option, - dnsAddresses: String, - routeListv4: String, - routeListv6: String, - ); - - #[swift_bridge(swift_name = "onUpdateResources")] - fn on_update_resources(&self, resourceList: String); - - #[swift_bridge(swift_name = "onDisconnect")] - fn on_disconnect(&self, error: DisconnectError); - } -} - -/// This is used by the apple client to interact with our code. -pub struct WrappedSession { - inner: Session, - runtime: Option, - event_stream_handler: Option>, - - telemetry: Telemetry, -} - -// SAFETY: `CallbackHandler.swift` promises to be thread-safe. -// TODO: Uphold that promise! -unsafe impl Send for ffi::CallbackHandler {} -unsafe impl Sync for ffi::CallbackHandler {} - -pub struct CallbackHandler { - // Generated Swift opaque type wrappers have a `Drop` impl that decrements the - // refcount, but there's no way to generate a `Clone` impl that increments the - // recount. Instead, we just wrap it in an `Arc`. - inner: ffi::CallbackHandler, -} - -impl CallbackHandler { - fn on_set_interface_config( - &self, - tunnel_address_v4: Ipv4Addr, - tunnel_address_v6: Ipv6Addr, - dns_addresses: Vec, - search_domain: Option, - route_list_v4: impl IntoIterator, - route_list_v6: impl IntoIterator, - ) { - match ( - serde_json::to_string(&dns_addresses), - serde_json::to_string(&V4RouteList::new(route_list_v4)), - serde_json::to_string(&V6RouteList::new(route_list_v6)), - ) { - (Ok(dns_addresses), Ok(route_list_4), Ok(route_list_6)) => { - self.inner.on_set_interface_config( - tunnel_address_v4.to_string(), - tunnel_address_v6.to_string(), - search_domain.map(|s| s.to_string()), - dns_addresses, - route_list_4, - route_list_6, - ); - } - (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => { - tracing::error!("Failed to serialize to JSON: {}", err_with_src(&e)); - } - } - } - - fn on_update_resources(&self, resource_list: Vec) { - let resource_list = match serde_json::to_string(&resource_list) { - Ok(resource_list) => resource_list, - Err(e) => { - tracing::error!("Failed to serialize resource list: {}", err_with_src(&e)); - return; - } - }; - - self.inner.on_update_resources(resource_list); - } - - fn on_disconnect(&self, error: DisconnectError) { - if !error.is_authentication_error() { - tracing::error!("{error}") - } - - self.inner.on_disconnect(error); - } -} - -static LOGGER_STATE: OnceLock<( - firezone_logging::file::Handle, - firezone_logging::FilterReloadHandle, -)> = OnceLock::new(); - -/// Initialises a global logger with the specified log filter. -/// -/// A global logger can only be set once, hence this function uses `static` state to check whether a logger has already been set. -/// If so, the new `log_filter` will be applied to the existing logger but a different `log_dir` won't have any effect. -/// -/// From within the FFI module, we have no control over our memory lifecycle and we may get initialised multiple times within the same process. -fn init_logging(log_dir: PathBuf, 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 (file_log_filter, file_reload_handle) = firezone_logging::try_filter(&log_filter)?; - let (oslog_log_filter, oslog_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(file_layer.with_filter(file_log_filter)) - .with( - tracing_subscriber::fmt::layer() - .with_ansi(false) - .event_format( - firezone_logging::Format::new() - .without_timestamp() - .without_level(), - ) - .with_writer(make_writer::MakeWriter::new( - "dev.firezone.firezone", - "connlib", - )) - .with_filter(oslog_log_filter), - ) - .with(sentry_layer()); - - let reload_handle = file_reload_handle.merge(oslog_reload_handle); - - firezone_logging::init(subscriber)?; - - LOGGER_STATE - .set((handle, reload_handle)) - .expect("logger state should only ever be initialised once"); - - Ok(()) -} - -impl WrappedSession { - fn connect( - api_url: String, - token: String, - device_id: String, - account_slug: String, - device_name_override: Option, - os_version_override: Option, - log_dir: String, - log_filter: String, - is_internet_resource_active: bool, - callback_handler: ffi::CallbackHandler, - device_info: String, - ) -> Result { - let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(1) - .thread_name("connlib") - .enable_all() - .build()?; - - let mut telemetry = Telemetry::new(); - runtime.block_on(telemetry.start(&api_url, RELEASE, APPLE_DSN, device_id.clone())); - Telemetry::set_account_slug(account_slug.clone()); - - analytics::identify(RELEASE.to_owned(), Some(account_slug)); - - init_logging(log_dir.into(), log_filter)?; - install_rustls_crypto_provider(); - - let secret = SecretString::from(token); - let device_info = - serde_json::from_str(&device_info).context("Failed to deserialize `DeviceInfo`")?; - - let url = LoginUrl::client( - api_url.as_str(), - &secret, - device_id.clone(), - device_name_override, - device_info, - )?; - - let _guard = runtime.enter(); // Constructing `PhoenixChannel` requires a runtime context. - - let portal = PhoenixChannel::disconnected( - Secret::new(url), - get_user_agent( - os_version_override, - "apple-client", - env!("CARGO_PKG_VERSION"), - ), - "client", - (), - || { - ExponentialBackoffBuilder::default() - .with_max_elapsed_time(Some(MAX_PARTITION_TIME)) - .build() - }, - Arc::new(socket_factory::tcp), - )?; - let (session, mut event_stream) = Session::connect( - Arc::new(socket_factory::tcp), - Arc::new(socket_factory::udp), - portal, - is_internet_resource_active, - runtime.handle().clone(), - ); - session.set_tun(Box::new(Tun::new()?)); - - analytics::new_session(device_id, api_url.to_string()); - - let event_stream_handler = runtime.spawn(async move { - let callback_handler = CallbackHandler { - inner: callback_handler, - }; - - while let Some(event) = event_stream.next().await { - match event { - Event::TunInterfaceUpdated(config) => { - callback_handler.on_set_interface_config( - config.ip.v4, - config.ip.v6, - config.dns_sentinel_ips(), - config.search_domain, - config.ipv4_routes, - config.ipv6_routes, - ); - } - Event::ResourcesUpdated(resource_views) => { - callback_handler.on_update_resources(resource_views); - } - Event::Disconnected(error) => { - callback_handler.on_disconnect(error); - } - } - } - }); - - Ok(Self { - inner: session, - runtime: Some(runtime), - event_stream_handler: Some(event_stream_handler), - telemetry, - }) - } - - fn reset(&mut self, reason: String) { - self.inner.reset(reason) - } - - fn set_dns(&mut self, dns_servers: String) -> Result<()> { - tracing::debug!(%dns_servers); - - let dns_servers = serde_json::from_str(&dns_servers) - .context("Failed to deserialize DNS servers from JSON")?; - - self.inner.set_dns(dns_servers); - - Ok(()) - } - - fn set_internet_resource_state(&mut self, active: bool) { - self.inner.set_internet_resource_state(active); - } - - fn set_log_directives(&mut self, directives: String) -> Result<()> { - let (_, handle) = LOGGER_STATE.get().context("Logger is not initialised")?; - - handle.reload(&directives)?; - - Ok(()) - } -} - -impl Drop for WrappedSession { - fn drop(&mut self) { - let Some(runtime) = self.runtime.take() else { - return; - }; - - self.inner.stop(); // Instruct the event-loop to shut down. - runtime.block_on(async { - self.telemetry.stop().await; - - // The `event_stream_handler` task will exit once the stream is drained. - // That only happens once the event-loop has fully shut down. - // Hence, waiting for this task here allows us to wait for the graceful shutdown to complete. - let Some(event_stream_handler) = self.event_stream_handler.take() else { - return; - }; - - let _ = tokio::time::timeout(Duration::from_secs(1), event_stream_handler).await; - }); - runtime.shutdown_timeout(Duration::from_secs(1)); // Ensure we don't block forever on a task in the blocking pool. - } -} - -fn err_to_string(result: Result) -> Result { - result.map_err(|e| format!("{e:#}")) -} - -/// 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."); - } -} diff --git a/rust/client-ffi/.cargo/config.toml b/rust/client-ffi/.cargo/config.toml new file mode 100644 index 000000000..807e50006 --- /dev/null +++ b/rust/client-ffi/.cargo/config.toml @@ -0,0 +1,12 @@ +# Cargo configuration for client-ffi +# This helps prevent rebuilds due to environment variable changes + +[env] +# Set a consistent macOS deployment target to prevent rebuilds +# when switching between Xcode and command-line builds +# This must match the Xcode project setting (12.4) +MACOSX_DEPLOYMENT_TARGET = "12.4" + +[build] +# Use incremental compilation for faster rebuilds +incremental = true diff --git a/rust/client-ffi/Cargo.toml b/rust/client-ffi/Cargo.toml index c45fa840c..ae79d819d 100644 --- a/rust/client-ffi/Cargo.toml +++ b/rust/client-ffi/Cargo.toml @@ -38,6 +38,9 @@ tun = { workspace = true } uniffi = { workspace = true } url = { workspace = true } +[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies] +oslog = { version = "0.2.0", default-features = false } + [target.'cfg(target_os = "android")'.dependencies] android_log-sys = "0.3.2" diff --git a/rust/client-ffi/src/lib.rs b/rust/client-ffi/src/lib.rs index 7b99712b2..223432423 100644 --- a/rust/client-ffi/src/lib.rs +++ b/rust/client-ffi/src/lib.rs @@ -1,8 +1,8 @@ mod platform; use std::{ - fmt, io, - os::fd::{AsRawFd as _, RawFd}, + fmt, + os::fd::RawFd, path::{Path, PathBuf}, sync::{Arc, OnceLock}, time::Duration, @@ -32,7 +32,7 @@ pub struct Session { #[derive(uniffi::Object, thiserror::Error, Debug)] #[error("{0:#}")] -pub struct Error(anyhow::Error); +pub struct ConnlibError(anyhow::Error); #[derive(uniffi::Error, thiserror::Error, Debug)] pub enum CallbackError { @@ -78,6 +78,7 @@ pub trait ProtectSocket: Send + Sync + fmt::Debug { } #[uniffi::export] +#[cfg(target_os = "android")] impl Session { #[uniffi::constructor] #[expect( @@ -96,7 +97,7 @@ impl Session { device_info: String, is_internet_resource_active: bool, protect_socket: Arc, - ) -> Result { + ) -> 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)); @@ -115,8 +116,94 @@ impl Session { udp_socket_factory, ) } +} - pub fn disconnect(&self) -> Result<(), Error> { +#[uniffi::export] +#[cfg(any(target_os = "ios", target_os = "macos"))] +impl Session { + #[uniffi::constructor] + #[expect( + clippy::too_many_arguments, + reason = "This is the API we want to expose over FFI." + )] + pub fn new_apple( + 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, + is_internet_resource_active: bool, + ) -> Result { + // iOS doesn't need socket protection like Android + let tcp_socket_factory = Arc::new(socket_factory::tcp); + let udp_socket_factory = Arc::new(socket_factory::udp); + + let session = connect( + api_url, + token, + device_id, + account_slug, + device_name, + os_version, + log_dir, + log_filter, + device_info, + is_internet_resource_active, + tcp_socket_factory, + udp_socket_factory, + )?; + + set_tun_from_search(&session)?; + + Ok(session) + } +} + +/// Set up TUN device with retry logic. +/// +/// Retries a few times with a small delay, as the NetworkExtension +/// might still be setting up the TUN interface. +#[cfg(any(target_os = "ios", target_os = "macos"))] +fn set_tun_from_search(session: &Session) -> Result<(), ConnlibError> { + const MAX_TUN_SETUP_ATTEMPTS: u32 = 5; + const TUN_SETUP_RETRY_DELAY_MS: u64 = 100; + + let runtime = session.runtime.as_ref().context("No runtime")?; + + let mut last_error = None; + for attempt in 1..=MAX_TUN_SETUP_ATTEMPTS { + tracing::debug!("Attempting to find TUN device (attempt {})", attempt); + match platform::Tun::new(runtime.handle()) { + Ok(tun) => { + tracing::debug!("Successfully found and set TUN device"); + session.inner.set_tun(Box::new(tun)); + return Ok(()); + } + Err(e) => { + tracing::warn!("Attempt {} failed: {}", attempt, e); + last_error = Some(e); + if attempt < MAX_TUN_SETUP_ATTEMPTS { + std::thread::sleep(std::time::Duration::from_millis(TUN_SETUP_RETRY_DELAY_MS)); + } + } + } + } + + Err(anyhow::anyhow!( + "Failed to find TUN device after {} attempts: {}", + MAX_TUN_SETUP_ATTEMPTS, + last_error.map_or_else(|| "unknown error".to_string(), |e| e.to_string()) + ) + .into()) +} + +#[uniffi::export] +impl Session { + pub fn disconnect(&self) -> Result<(), ConnlibError> { let runtime = self.runtime.as_ref().context("No runtime")?; runtime.block_on(async { @@ -131,7 +218,7 @@ impl Session { self.inner.set_internet_resource_state(active); } - pub fn set_dns(&self, dns_servers: String) -> Result<(), Error> { + pub fn set_dns(&self, dns_servers: String) -> Result<(), ConnlibError> { let dns_servers = serde_json::from_str(&dns_servers).context("Failed to deserialize DNS servers")?; @@ -144,7 +231,7 @@ impl Session { self.inner.reset(reason) } - pub fn set_log_directives(&self, directives: String) -> Result<(), Error> { + pub fn set_log_directives(&self, directives: String) -> Result<(), ConnlibError> { let (_, reload_handle) = LOGGER_STATE.get().context("Logger not yet initialised")?; reload_handle @@ -154,17 +241,19 @@ impl Session { Ok(()) } - pub fn set_tun(&self, fd: RawFd) -> Result<(), Error> { - let _guard = self.runtime.as_ref().context("No runtime")?.enter(); + pub fn set_tun(&self, fd: RawFd) -> Result<(), ConnlibError> { + let runtime = self.runtime.as_ref().context("No runtime")?; // SAFETY: FD must be open. - let tun = unsafe { platform::Tun::from_fd(fd).context("Failed to create new Tun")? }; + let tun = unsafe { + platform::Tun::from_fd(fd, runtime.handle()).context("Failed to create new Tun")? + }; self.inner.set_tun(Box::new(tun)); Ok(()) } - pub async fn next_event(&self) -> Result, Error> { + pub async fn next_event(&self) -> Result, ConnlibError> { match self.events.lock().await.next().await { Some(client_shared::Event::TunInterfaceUpdated(config)) => { let dns = serde_json::to_string( @@ -232,7 +321,7 @@ fn connect( is_internet_resource_active: bool, tcp_socket_factory: Arc>, udp_socket_factory: Arc>, -) -> Result { +) -> Result { let device_info = serde_json::from_str(&device_info).context("Failed to deserialize `DeviceInfo`")?; let secret = SecretString::from(token); @@ -338,23 +427,27 @@ fn init_logging(log_dir: &Path, log_filter: String) -> Result<()> { Ok(()) } +#[cfg(target_os = "android")] fn protected_tcp_socket_factory(callback: Arc) -> impl SocketFactory { move |addr| { let socket = socket_factory::tcp(addr)?; + use std::os::fd::AsRawFd; callback .protect_socket(socket.as_raw_fd()) - .map_err(io::Error::other)?; + .map_err(std::io::Error::other)?; Ok(socket) } } +#[cfg(target_os = "android")] fn protected_udp_socket_factory(callback: Arc) -> impl SocketFactory { move |addr| { let socket = socket_factory::udp(addr)?; + use std::os::fd::AsRawFd; callback .protect_socket(socket.as_raw_fd()) - .map_err(io::Error::other)?; + .map_err(std::io::Error::other)?; Ok(socket) } @@ -369,7 +462,7 @@ fn install_rustls_crypto_provider() { } } -impl From for Error { +impl From for ConnlibError { fn from(value: anyhow::Error) -> Self { Self(value) } diff --git a/rust/client-ffi/src/platform.rs b/rust/client-ffi/src/platform.rs index 129a7dc71..71c644078 100644 --- a/rust/client-ffi/src/platform.rs +++ b/rust/client-ffi/src/platform.rs @@ -1,21 +1,17 @@ -#[cfg(any( - target_os = "linux", - target_os = "windows", - target_os = "macos", - target_os = "ios" -))] +#[cfg(any(target_os = "linux", target_os = "windows"))] mod fallback; #[cfg(target_os = "android")] mod android; +#[cfg(any(target_os = "ios", target_os = "macos"))] +mod apple; + #[cfg(target_os = "android")] pub use android::*; -#[cfg(any( - target_os = "linux", - target_os = "windows", - target_os = "macos", - target_os = "ios" -))] +#[cfg(any(target_os = "ios", target_os = "macos"))] +pub use apple::*; + +#[cfg(any(target_os = "linux", target_os = "windows"))] pub use fallback::*; diff --git a/rust/client-ffi/src/platform/android/tun.rs b/rust/client-ffi/src/platform/android/tun.rs index f9670dd53..d6cf03ad7 100644 --- a/rust/client-ffi/src/platform/android/tun.rs +++ b/rust/client-ffi/src/platform/android/tun.rs @@ -54,20 +54,20 @@ impl Tun { /// /// - The file descriptor must be open. /// - The file descriptor must not get closed by anyone else. - pub unsafe fn from_fd(fd: RawFd) -> io::Result { + pub unsafe fn from_fd(fd: RawFd, runtime: &tokio::runtime::Handle) -> io::Result { let name = unsafe { interface_name(fd)? }; let (inbound_tx, inbound_rx) = mpsc::channel(QUEUE_SIZE); let (outbound_tx, outbound_rx) = mpsc::channel(QUEUE_SIZE); - tokio::spawn(otel::metrics::periodic_system_queue_length( + runtime.spawn(otel::metrics::periodic_system_queue_length( outbound_tx.downgrade(), [ otel::attr::queue_item_ip_packet(), otel::attr::network_io_direction_transmit(), ], )); - tokio::spawn(otel::metrics::periodic_system_queue_length( + runtime.spawn(otel::metrics::periodic_system_queue_length( inbound_tx.downgrade(), [ otel::attr::queue_item_ip_packet(), diff --git a/rust/client-ffi/src/platform/apple.rs b/rust/client-ffi/src/platform/apple.rs new file mode 100644 index 000000000..7c5671caf --- /dev/null +++ b/rust/client-ffi/src/platform/apple.rs @@ -0,0 +1,28 @@ +use firezone_telemetry::Dsn; +use std::time::Duration; + +mod make_writer; +mod tun; + +// mark:next-apple-version +pub const RELEASE: &str = "connlib-apple@1.5.8"; + +// mark:next-apple-version +pub const VERSION: &str = "1.5.8"; + +pub const COMPONENT: &str = "apple-client"; + +/// The Apple client implements reconnect logic in the upper layer using OS provided +/// APIs to detect network connectivity changes. The reconnect timeout here only +/// applies only in the following conditions: +/// +/// * That reconnect logic fails to detect network changes (not expected to happen) +/// * The portal is DOWN +/// +/// Hopefully we aren't down for more than 24 hours. +pub const MAX_PARTITION_TIME: Duration = Duration::from_secs(60 * 60 * 24); + +pub const DSN: Dsn = firezone_telemetry::APPLE_DSN; + +pub(crate) use make_writer::MakeWriter; +pub(crate) use tun::Tun; diff --git a/rust/apple-client-ffi/src/make_writer.rs b/rust/client-ffi/src/platform/apple/make_writer.rs similarity index 95% rename from rust/apple-client-ffi/src/make_writer.rs rename to rust/client-ffi/src/platform/apple/make_writer.rs index b9dc74f80..93d8edcb5 100644 --- a/rust/apple-client-ffi/src/make_writer.rs +++ b/rust/client-ffi/src/platform/apple/make_writer.rs @@ -24,6 +24,12 @@ impl MakeWriter { } } +impl Default for MakeWriter { + fn default() -> Self { + Self::new("dev.firezone.firezone", "connlib") + } +} + impl<'l> tracing_subscriber::fmt::MakeWriter<'l> for MakeWriter { type Writer = Writer<'l>; diff --git a/rust/apple-client-ffi/src/tun.rs b/rust/client-ffi/src/platform/apple/tun.rs similarity index 91% rename from rust/apple-client-ffi/src/tun.rs rename to rust/client-ffi/src/platform/apple/tun.rs index 50432c584..68d6ad460 100644 --- a/rust/apple-client-ffi/src/tun.rs +++ b/rust/client-ffi/src/platform/apple/tun.rs @@ -20,22 +20,38 @@ pub struct Tun { } impl Tun { - pub fn new() -> io::Result { + pub fn new(runtime: &tokio::runtime::Handle) -> io::Result { let fd = search_for_tun_fd()?; set_non_blocking(fd)?; + Self::from_fd_inner(fd, runtime) + } + + /// Create a new [`Tun`] from a raw file descriptor. + /// + /// # Safety + /// + /// - The file descriptor must be open. + /// - The file descriptor must not get closed by anyone else. + /// - On iOS/macOS, the NetworkExtension owns the fd, so we don't take ownership. + pub unsafe fn from_fd(fd: RawFd, runtime: &tokio::runtime::Handle) -> io::Result { + set_non_blocking(fd)?; + Self::from_fd_inner(fd, runtime) + } + + fn from_fd_inner(fd: RawFd, runtime: &tokio::runtime::Handle) -> io::Result { let name = name(fd)?; let (inbound_tx, inbound_rx) = mpsc::channel(QUEUE_SIZE); let (outbound_tx, outbound_rx) = mpsc::channel(QUEUE_SIZE); - tokio::spawn(otel::metrics::periodic_system_queue_length( + runtime.spawn(otel::metrics::periodic_system_queue_length( outbound_tx.downgrade(), [ otel::attr::queue_item_ip_packet(), otel::attr::network_io_direction_transmit(), ], )); - tokio::spawn(otel::metrics::periodic_system_queue_length( + runtime.spawn(otel::metrics::periodic_system_queue_length( inbound_tx.downgrade(), [ otel::attr::queue_item_ip_packet(), diff --git a/rust/client-ffi/src/platform/fallback.rs b/rust/client-ffi/src/platform/fallback.rs index a4721c6bf..575671df7 100644 --- a/rust/client-ffi/src/platform/fallback.rs +++ b/rust/client-ffi/src/platform/fallback.rs @@ -40,7 +40,7 @@ impl io::Write for DevNull { pub struct Tun; impl Tun { - pub unsafe fn from_fd(_: RawFd) -> io::Result { + pub unsafe fn from_fd(_: RawFd, _: &tokio::runtime::Handle) -> io::Result { Err(io::Error::other("Stub!")) } } diff --git a/rust/client-ffi/uniffi.toml b/rust/client-ffi/uniffi.toml new file mode 100644 index 000000000..5feda5a2f --- /dev/null +++ b/rust/client-ffi/uniffi.toml @@ -0,0 +1,16 @@ +# UniFFI Configuration for client-ffi + +[bindings.swift] +# Enable experimental features for better async support +experimental_sendable_value_types = true + +# Configure async runtime for Swift +# This enables proper async/await bridging from Rust to Swift +async_runtime = "tokio" + +# Generate proper Swift concurrency annotations +generate_immutable_records = true + +[bindings.kotlin] +# Kotlin configuration (already working) +async_runtime = "tokio" diff --git a/swift/apple/Firezone.xcodeproj/project.pbxproj b/swift/apple/Firezone.xcodeproj/project.pbxproj index 977653f2c..ca003d6ce 100644 --- a/swift/apple/Firezone.xcodeproj/project.pbxproj +++ b/swift/apple/Firezone.xcodeproj/project.pbxproj @@ -10,28 +10,25 @@ 05CF1CF1290B1CEE00CF4755 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 05D3BB1628FDBD8A00BC3727 /* NetworkExtension.framework */; }; 05CF1D17290B1FE700CF4755 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */; }; 05D3BB2128FDE9C000BC3727 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 05D3BB1628FDBD8A00BC3727 /* NetworkExtension.framework */; }; - 6FE454F62A5BFB93006549B1 /* Adapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE454EA2A5BFABA006549B1 /* Adapter.swift */; }; - 6FE455092A5D110D006549B1 /* CallbackHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE455082A5D110D006549B1 /* CallbackHandler.swift */; }; - 6FE4550C2A5D111E006549B1 /* SwiftBridgeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */; }; - 6FE4550F2A5D112C006549B1 /* apple-client-ffi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE4550E2A5D112C006549B1 /* apple-client-ffi.swift */; }; + + 6F0EDF1F2E79A13800D6D632 /* Adapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177FB893F19457A113042247 /* Adapter.swift */; }; + 6F0EDF212E79A15700D6D632 /* connlib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2847ACC9D73EA6F356F2DEE1 /* connlib.swift */; }; 6FE93AFB2A738D7E002D278A /* NetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */; }; 794C38152970A2660029F38F /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 794C38142970A2660029F38F /* FirezoneKit */; }; 79756C6629704A7A0018E2D5 /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 79756C6529704A7A0018E2D5 /* FirezoneKit */; }; - 8D4087D52D24653B005B2BAF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 8D4087D42D24653B005B2BAF /* Sentry */; }; - 8D4087D92D246541005B2BAF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 8D4087D82D246541005B2BAF /* Sentry */; }; - 8D4087DD2D246651005B2BAF /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 8D4087DC2D246651005B2BAF /* Sentry */; }; + 858AA13A09A55E3B1DD5DEDA /* Adapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177FB893F19457A113042247 /* Adapter.swift */; }; 8D41B9A52D15DD6800D16065 /* TunnelLogArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D41B9A42D15DD6800D16065 /* TunnelLogArchive.swift */; }; 8D41B9A62D15DD6800D16065 /* TunnelLogArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D41B9A42D15DD6800D16065 /* TunnelLogArchive.swift */; }; 8D5047F82CE6AA22009802E9 /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 8D5047F72CE6AA22009802E9 /* FirezoneKit */; }; 8D5047FB2CE6AA37009802E9 /* FirezoneNetworkExtension-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = 6FE455112A5D13A2006549B1 /* FirezoneNetworkExtension-Bridging-Header.h */; }; - 8D5047FC2CE6AA47009802E9 /* CallbackHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE455082A5D110D006549B1 /* CallbackHandler.swift */; }; - 8D5047FD2CE6AA47009802E9 /* apple-client-ffi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE4550E2A5D112C006549B1 /* apple-client-ffi.swift */; }; 8D5047FE2CE6AA54009802E9 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */; }; - 8D5047FF2CE6AA54009802E9 /* Adapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE454EA2A5BFABA006549B1 /* Adapter.swift */; }; 8D5048002CE6AA60009802E9 /* SystemConfigurationResolvers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D6939312BA2521A00AF4396 /* SystemConfigurationResolvers.swift */; }; 8D5048012CE6AA60009802E9 /* NetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */; }; - 8D5048042CE6B0AE009802E9 /* SwiftBridgeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */; }; 8D69392C2BA24FE600AF4396 /* BindResolvers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D69392B2BA24FE600AF4396 /* BindResolvers.swift */; }; + 146C8E809FF744D18C053A79 /* Channel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C09DC14A4A04715A37BC04C /* Channel.swift */; }; + C1892134991C462C8E137DE3 /* Channel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C09DC14A4A04715A37BC04C /* Channel.swift */; }; + C4B08E1145E04823BC25E00D /* SessionEventLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14DC5E933BD4F63A844A8A6 /* SessionEventLoop.swift */; }; + 6571861AB9324D6A9395BDB3 /* SessionEventLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14DC5E933BD4F63A844A8A6 /* SessionEventLoop.swift */; }; 8DC08BD72B297DB400675F46 /* FirezoneNetworkExtension-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = 6FE455112A5D13A2006549B1 /* FirezoneNetworkExtension-Bridging-Header.h */; }; 8DC1699D2CFF77D1006801B5 /* dev.firezone.firezone.network-extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 05CF1CF0290B1CEE00CF4755 /* dev.firezone.firezone.network-extension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 8DC169A02CFF77D1006801B5 /* dev.firezone.firezone.network-extension.systemextension in Embed System Extensions */ = {isa = PBXBuildFile; fileRef = 8D5047E32CE6A8F4009802E9 /* dev.firezone.firezone.network-extension.systemextension */; platformFilters = (macos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -44,6 +41,8 @@ 8DCC022A28D512AE007E12D2 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8DCC022928D512AE007E12D2 /* Preview Assets.xcassets */; }; 8DE452A82CE6C194004CEDF9 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5047E82CE6A8F4009802E9 /* main.swift */; }; 8DFDEAC72D2CEBA500615095 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 8DA12C322BB7DA04007D91EB /* PrivacyInfo.xcprivacy */; }; + + CCC04BEF428B758D9BB5F842 /* connlib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2847ACC9D73EA6F356F2DEE1 /* connlib.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -89,14 +88,14 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; 05CF1CF0290B1CEE00CF4755 /* dev.firezone.firezone.network-extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "dev.firezone.firezone.network-extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 05CF1CF6290B1CEE00CF4755 /* FirezoneNetworkExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FirezoneNetworkExtension.entitlements; sourceTree = ""; }; 05D3BB1628FDBD8A00BC3727 /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; }; - 6FE454EA2A5BFABA006549B1 /* Adapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Adapter.swift; sourceTree = ""; }; - 6FE455082A5D110D006549B1 /* CallbackHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallbackHandler.swift; sourceTree = ""; }; - 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SwiftBridgeCore.swift; path = Connlib/Generated/SwiftBridgeCore.swift; sourceTree = ""; }; - 6FE4550E2A5D112C006549B1 /* apple-client-ffi.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "apple-client-ffi.swift"; path = "Connlib/Generated/apple-client-ffi/apple-client-ffi.swift"; sourceTree = ""; }; + 177FB893F19457A113042247 /* Adapter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Adapter.swift; sourceTree = ""; }; + 2847ACC9D73EA6F356F2DEE1 /* connlib.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = connlib.swift; path = FirezoneNetworkExtension/Connlib/Generated/connlib.swift; sourceTree = ""; }; + 6FB20C422E7049A300E41294 /* ConnlibFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = ConnlibFFI.xcframework; path = Frameworks/ConnlibFFI.xcframework; sourceTree = ""; }; 6FE455112A5D13A2006549B1 /* FirezoneNetworkExtension-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FirezoneNetworkExtension-Bridging-Header.h"; sourceTree = ""; }; 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSettings.swift; sourceTree = ""; }; 6FFECD5B2AD6998400E00273 /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; @@ -106,6 +105,8 @@ 8D5047E32CE6A8F4009802E9 /* dev.firezone.firezone.network-extension.systemextension */ = {isa = PBXFileReference; explicitFileType = "wrapper.system-extension"; includeInIndex = 0; path = "dev.firezone.firezone.network-extension.systemextension"; sourceTree = BUILT_PRODUCTS_DIR; }; 8D5047E82CE6A8F4009802E9 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; 8D69392B2BA24FE600AF4396 /* BindResolvers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BindResolvers.swift; sourceTree = ""; }; + 6C09DC14A4A04715A37BC04C /* Channel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Channel.swift; sourceTree = ""; }; + E14DC5E933BD4F63A844A8A6 /* SessionEventLoop.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionEventLoop.swift; sourceTree = ""; }; 8D6939312BA2521A00AF4396 /* SystemConfigurationResolvers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemConfigurationResolvers.swift; sourceTree = ""; }; 8DA12C322BB7DA04007D91EB /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 8DC08BCC2B296C5900675F46 /* libresolv.9.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.9.tbd; path = usr/lib/libresolv.9.tbd; sourceTree = SDKROOT; }; @@ -129,7 +130,6 @@ files = ( 8DC333F92D2FA85200E627D5 /* libresolv.tbd in Frameworks */, 8DC333FA2D2FA85200E627D5 /* libresolv.9.tbd in Frameworks */, - 8D4087D52D24653B005B2BAF /* Sentry in Frameworks */, 794C38152970A2660029F38F /* FirezoneKit in Frameworks */, 05CF1CF1290B1CEE00CF4755 /* NetworkExtension.framework in Frameworks */, ); @@ -142,7 +142,6 @@ 8DC333FB2D2FA89100E627D5 /* SystemConfiguration.framework in Frameworks */, 8D5047F82CE6AA22009802E9 /* FirezoneKit in Frameworks */, 8DC9FB852CF5A738001BCE6A /* NetworkExtension.framework in Frameworks */, - 8D4087D92D246541005B2BAF /* Sentry in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -152,7 +151,6 @@ files = ( 05D3BB2128FDE9C000BC3727 /* NetworkExtension.framework in Frameworks */, 79756C6629704A7A0018E2D5 /* FirezoneKit in Frameworks */, - 8D4087DD2D246651005B2BAF /* Sentry in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -166,15 +164,13 @@ 8DE1077B2D2313EB00DB5A45 /* Info.macOS.plist */, 8D6939312BA2521A00AF4396 /* SystemConfigurationResolvers.swift */, 8D69392B2BA24FE600AF4396 /* BindResolvers.swift */, + 6C09DC14A4A04715A37BC04C /* Channel.swift */, + E14DC5E933BD4F63A844A8A6 /* SessionEventLoop.swift */, 05CF1CF6290B1CEE00CF4755 /* FirezoneNetworkExtension.entitlements */, 8D5047E82CE6A8F4009802E9 /* main.swift */, 05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */, - 6FE454EA2A5BFABA006549B1 /* Adapter.swift */, 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */, - 6FE455082A5D110D006549B1 /* CallbackHandler.swift */, - 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */, 8D41B9A42D15DD6800D16065 /* TunnelLogArchive.swift */, - 6FE4550E2A5D112C006549B1 /* apple-client-ffi.swift */, 6FE455112A5D13A2006549B1 /* FirezoneNetworkExtension-Bridging-Header.h */, ); path = FirezoneNetworkExtension; @@ -188,9 +184,28 @@ path = Application; sourceTree = ""; }; + 64A772F0917639D7745EA995 /* Connlib */ = { + isa = PBXGroup; + children = ( + 99553B27AEABC2EA3782DDEA /* Generated */, + ); + name = Connlib; + path = FirezoneNetworkExtension/Connlib; + sourceTree = ""; + }; + 8BCDECDF49DAFFD25CF140AF /* FirezoneNetworkExtension */ = { + isa = PBXGroup; + children = ( + 64A772F0917639D7745EA995 /* Connlib */, + 177FB893F19457A113042247 /* Adapter.swift */, + ); + path = FirezoneNetworkExtension; + sourceTree = ""; + }; 8D3F90C328D64FAD00980124 /* Frameworks */ = { isa = PBXGroup; children = ( + 6FB20C422E7049A300E41294 /* ConnlibFFI.xcframework */, 8DC08BD12B297B7B00675F46 /* libresolv.tbd */, 8DC08BCC2B296C5900675F46 /* libresolv.9.tbd */, 8DC333F72D2FA85200E627D5 /* libresolv.tbd */, @@ -210,6 +225,8 @@ 05833DF928F73B070008FAB0 /* FirezoneNetworkExtension */, 8DCC021A28D512AC007E12D2 /* Products */, 8D3F90C328D64FAD00980124 /* Frameworks */, + 8BCDECDF49DAFFD25CF140AF /* FirezoneNetworkExtension */, + 2847ACC9D73EA6F356F2DEE1 /* connlib.swift */, ); sourceTree = ""; }; @@ -252,6 +269,14 @@ name = Packages; sourceTree = ""; }; + 99553B27AEABC2EA3782DDEA /* Generated */ = { + isa = PBXGroup; + children = ( + ); + name = Generated; + path = FirezoneNetworkExtension/Connlib/Generated; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -355,8 +380,6 @@ Base, ); mainGroup = 8DCC021028D512AC007E12D2; - packageReferences = ( - ); productRefGroup = 8DCC021A28D512AC007E12D2 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -452,7 +475,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "cd ../../rust/apple-client-ffi\n./build-rust.sh\n"; + shellScript = "export PLATFORM_NAME=\"${PLATFORM_NAME}\"\nexport CONFIGURATION=\"${CONFIGURATION}\"\nexport NATIVE_ARCH=\"${ARCHS}\"\nexport CONNLIB_TARGET_DIR=\"${CONNLIB_TARGET_DIR}\"\n./build-rust.sh\n"; }; 8D8555832D0A7CBB00A1EA09 /* Build Connlib */ = { isa = PBXShellScriptBuildPhase; @@ -472,7 +495,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "cd ../../rust/apple-client-ffi\n./build-rust.sh\n"; + shellScript = "export PLATFORM_NAME=\"${PLATFORM_NAME}\"\nexport CONFIGURATION=\"${CONFIGURATION}\"\nexport NATIVE_ARCH=\"${ARCHS}\"\nexport CONNLIB_TARGET_DIR=\"${CONNLIB_TARGET_DIR}\"\n./build-rust.sh\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -481,14 +504,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 6F0EDF212E79A15700D6D632 /* connlib.swift in Sources */, + 6F0EDF1F2E79A13800D6D632 /* Adapter.swift in Sources */, 8DC08BD72B297DB400675F46 /* FirezoneNetworkExtension-Bridging-Header.h in Sources */, - 6FE455092A5D110D006549B1 /* CallbackHandler.swift in Sources */, - 6FE4550F2A5D112C006549B1 /* apple-client-ffi.swift in Sources */, 8D41B9A52D15DD6800D16065 /* TunnelLogArchive.swift in Sources */, 05CF1D17290B1FE700CF4755 /* PacketTunnelProvider.swift in Sources */, - 6FE454F62A5BFB93006549B1 /* Adapter.swift in Sources */, - 6FE4550C2A5D111E006549B1 /* SwiftBridgeCore.swift in Sources */, 8D69392C2BA24FE600AF4396 /* BindResolvers.swift in Sources */, + 146C8E809FF744D18C053A79 /* Channel.swift in Sources */, + C4B08E1145E04823BC25E00D /* SessionEventLoop.swift in Sources */, 6FE93AFB2A738D7E002D278A /* NetworkSettings.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -500,13 +523,13 @@ 8D41B9A62D15DD6800D16065 /* TunnelLogArchive.swift in Sources */, 8DE452A82CE6C194004CEDF9 /* main.swift in Sources */, 8D5047FB2CE6AA37009802E9 /* FirezoneNetworkExtension-Bridging-Header.h in Sources */, - 8D5048042CE6B0AE009802E9 /* SwiftBridgeCore.swift in Sources */, - 8D5047FD2CE6AA47009802E9 /* apple-client-ffi.swift in Sources */, - 8D5047FC2CE6AA47009802E9 /* CallbackHandler.swift in Sources */, 8D5047FE2CE6AA54009802E9 /* PacketTunnelProvider.swift in Sources */, 8D5048002CE6AA60009802E9 /* SystemConfigurationResolvers.swift in Sources */, 8D5048012CE6AA60009802E9 /* NetworkSettings.swift in Sources */, - 8D5047FF2CE6AA54009802E9 /* Adapter.swift in Sources */, + C1892134991C462C8E137DE3 /* Channel.swift in Sources */, + 6571861AB9324D6A9395BDB3 /* SessionEventLoop.swift in Sources */, + 858AA13A09A55E3B1DD5DEDA /* Adapter.swift in Sources */, + CCC04BEF428B758D9BB5F842 /* connlib.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -558,11 +581,12 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(CONNLIB_TARGET_DIR)/aarch64-apple-ios/debug"; + "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(CONNLIB_TARGET_DIR)/aarch64-apple-ios/debug"; MACOSX_DEPLOYMENT_TARGET = 12.4; MARKETING_VERSION = 1.5.9; ONLY_ACTIVE_ARCH = YES; OTHER_LDFLAGS = "-lconnlib"; + OTHER_SWIFT_FLAGS = "-D UNIFFI"; PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension"; PRODUCT_NAME = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PROVISIONING_PROFILE_SPECIFIER = "$(NE_PROFILE_ID)"; @@ -600,10 +624,11 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(CONNLIB_TARGET_DIR)/aarch64-apple-ios/release"; + "LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(CONNLIB_TARGET_DIR)/aarch64-apple-ios/release"; MACOSX_DEPLOYMENT_TARGET = 12.4; MARKETING_VERSION = 1.5.9; OTHER_LDFLAGS = "-lconnlib"; + OTHER_SWIFT_FLAGS = "-D UNIFFI"; PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension"; PRODUCT_NAME = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PROVISIONING_PROFILE_SPECIFIER = "$(NE_PROFILE_ID)"; @@ -645,13 +670,16 @@ "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(CONNLIB_TARGET_DIR)/x86_64-apple-darwin/debug"; MARKETING_VERSION = 1.5.9; OTHER_LDFLAGS = "-lconnlib"; + OTHER_SWIFT_FLAGS = "-D UNIFFI"; PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension"; PRODUCT_NAME = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PROVISIONING_PROFILE_SPECIFIER = "$(NE_PROFILE_ID)"; SDKROOT = macosx; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = UNIFFI; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = "$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/Generated"; SWIFT_OBJC_BRIDGING_HEADER = "FirezoneNetworkExtension/FirezoneNetworkExtension-Bridging-Header.h"; SWIFT_VERSION = 5.0; }; @@ -683,13 +711,16 @@ "LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(CONNLIB_TARGET_DIR)/x86_64-apple-darwin/release"; MARKETING_VERSION = 1.5.9; OTHER_LDFLAGS = "-lconnlib"; + OTHER_SWIFT_FLAGS = "-D UNIFFI"; PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension"; PRODUCT_NAME = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PROVISIONING_PROFILE_SPECIFIER = "$(NE_PROFILE_ID)"; SDKROOT = macosx; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = UNIFFI; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = "$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/Generated"; SWIFT_OBJC_BRIDGING_HEADER = "FirezoneNetworkExtension/FirezoneNetworkExtension-Bridging-Header.h"; SWIFT_VERSION = 5.0; }; @@ -729,7 +760,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CONNLIB_SOURCE_DIR = "$(PROJECT_DIR)/../../rust/apple-client-ffi"; + CONNLIB_SOURCE_DIR = "$(PROJECT_DIR)/../../rust/client-ffi"; CONNLIB_TARGET_DIR = "$(PROJECT_DIR)/../../rust/target"; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; @@ -798,7 +829,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CONNLIB_SOURCE_DIR = "$(PROJECT_DIR)/../../rust/apple-client-ffi"; + CONNLIB_SOURCE_DIR = "$(PROJECT_DIR)/../../rust/client-ffi"; CONNLIB_TARGET_DIR = "$(PROJECT_DIR)/../../rust/target"; COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; @@ -964,7 +995,6 @@ }; /* End XCConfigurationList section */ - /* Begin XCSwiftPackageProductDependency section */ 794C38142970A2660029F38F /* FirezoneKit */ = { isa = XCSwiftPackageProductDependency; diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/SystemExtensionManager.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/SystemExtensionManager.swift index 31315f25c..8716f30f0 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/SystemExtensionManager.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Managers/SystemExtensionManager.swift @@ -95,6 +95,19 @@ fatalError("Version should exist in bundle") } + Log.info( + "Checking system extension - Client version: \(ourBundleShortVersion) (\(ourBundleVersion))" + ) + + // Log all found extensions for debugging + for sysex in properties { + if sysex.isEnabled { + Log.info( + "Found enabled extension - Version: \(sysex.bundleShortVersion) (\(sysex.bundleVersion))" + ) + } + } + // Up to date if version and build number match let isCurrentVersionInstalled = properties.contains { sysex in sysex.isEnabled @@ -109,13 +122,17 @@ // Needs replacement if we found our extension, but its version doesn't match // Note this can happen for upgrades _or_ downgrades - let isAnyVersionInstalled = properties.contains { $0.isEnabled } - if isAnyVersionInstalled { + let enabledExtension = properties.first { $0.isEnabled } + if let enabledExtension = enabledExtension { + Log.warning( + "Extension version mismatch - Installed: \(enabledExtension.bundleShortVersion) (\(enabledExtension.bundleVersion)), Expected: \(ourBundleShortVersion) (\(ourBundleVersion))" + ) resume(returning: .needsReplacement) return } + Log.info("No system extension found - needs install") resume(returning: .needsInstall) } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift index 9b4a871cf..bc1139005 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Stores/Store.swift @@ -151,7 +151,9 @@ public final class Store: ObservableObject { // If already installed but the wrong version, go ahead and install. This shouldn't prompt the user. if systemExtensionStatus == .needsReplacement { + Log.info("Replacing system extension with current version") try await systemExtensionRequest(.install) + Log.info("System extension replacement completed successfully") } #endif } diff --git a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/UpdateNotification.swift b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/UpdateNotification.swift index 7e86baf15..98bc7fab0 100644 --- a/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/UpdateNotification.swift +++ b/swift/apple/FirezoneKit/Sources/FirezoneKit/Views/UpdateNotification.swift @@ -230,9 +230,10 @@ func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping ( - UNNotificationPresentationOptions - ) -> Void + withCompletionHandler completionHandler: + @escaping ( + UNNotificationPresentationOptions + ) -> Void ) { // Show the notification even when the app is in the foreground completionHandler([.badge, .banner, .sound]) diff --git a/swift/apple/FirezoneNetworkExtension/Adapter.swift b/swift/apple/FirezoneNetworkExtension/Adapter.swift index 1b74a8c0e..81a8907e2 100644 --- a/swift/apple/FirezoneNetworkExtension/Adapter.swift +++ b/swift/apple/FirezoneNetworkExtension/Adapter.swift @@ -1,6 +1,6 @@ // // Adapter.swift -// (c) 2024 Firezone, Inc. +// (c) 2024-2025 Firezone, Inc. // LICENSE: Apache-2.0 // @@ -14,7 +14,7 @@ import OSLog enum AdapterError: Error { /// Failure to perform an operation in such state. - case invalidSession(WrappedSession?) + case invalidSession(Session?) /// connlib failed to start case connlibConnectError(String) @@ -35,17 +35,19 @@ enum AdapterError: Error { } // Loosely inspired from WireGuardAdapter from WireGuardKit -class Adapter { - typealias StartTunnelCompletionHandler = ((AdapterError?) -> Void) +class Adapter: @unchecked Sendable { - private var callbackHandler: CallbackHandler + /// Command sender for sending commands to the session + private var commandSender: Sender? - private var session: WrappedSession? + /// Task handles for explicit cancellation during cleanup + private var eventLoopTask: Task? + private var eventConsumerTask: Task? // Our local copy of the accountSlug - private var accountSlug: String + private let accountSlug: String - /// Network settings + /// Network settings for tunnel configuration. private var networkSettings: NetworkSettings? /// Packet tunnel provider. @@ -54,11 +56,6 @@ class Adapter { /// Network routes monitor. private var networkMonitor: NWPathMonitor? - /// Used to avoid path update callback cycles on iOS - #if os(iOS) - private var gateways: [Network.NWEndpoint] = [] - #endif - #if os(macOS) /// Used for finding system DNS resolvers on macOS when network conditions have changed. private let systemConfigurationResolvers = SystemConfigurationResolvers() @@ -130,7 +127,7 @@ class Adapter { // out of a different interface even when 0.0.0.0 is used as the source. // If our primary interface changes, we can be certain the old socket shouldn't be // used anymore. - session?.reset("primary network path changed") + self.sendCommand(.reset("primary network path changed")) } setSystemDefaultResolvers(path) @@ -139,26 +136,22 @@ class Adapter { } } - /// Currently disabled resources + /// Internet resource enabled state private var internetResourceEnabled: Bool - /// Cache of internet resource - private var internetResource: Resource? - - /// Keep track of resources + /// Keep track of resources for UI private var resourceListJSON: String? /// Starting parameters private let apiURL: String private let token: Token - private let id: String + private let deviceId: String private let logFilter: String - private let connlibLogFolderPath: String init( apiURL: String, token: Token, - id: String, + deviceId: String, logFilter: String, accountSlug: String, internetResourceEnabled: Bool, @@ -166,58 +159,98 @@ class Adapter { ) { self.apiURL = apiURL self.token = token - self.id = id - self.packetTunnelProvider = packetTunnelProvider - self.callbackHandler = CallbackHandler() + self.deviceId = deviceId self.logFilter = logFilter self.accountSlug = accountSlug - self.connlibLogFolderPath = SharedAccess.connlibLogFolderURL?.path ?? "" - self.networkSettings = nil self.internetResourceEnabled = internetResourceEnabled + self.packetTunnelProvider = packetTunnelProvider } // Could happen abruptly if the process is killed. deinit { Log.log("Adapter.deinit") + // Cancel all Tasks - this triggers cooperative cancellation + // Event loop checks Task.isCancelled in its polling loop + // Event consumer will exit when eventSender.deinit closes the stream + eventLoopTask?.cancel() + eventConsumerTask?.cancel() + // Cancel network monitor networkMonitor?.cancel() } - /// Start the tunnel. + func start() throws { - Log.log("Adapter.start") + Log.log("Adapter.start: Starting session for account: \(accountSlug)") - guard session == nil else { - throw AdapterError.invalidSession(session) - } + // Get device metadata + let deviceName = DeviceMetadata.getDeviceName() + let osVersion = DeviceMetadata.getOSVersion() + let deviceInfo = try JSONEncoder().encode(DeviceMetadata.deviceInfo()) + let deviceInfoStr = String(data: deviceInfo, encoding: .utf8) ?? "{}" + let logDir = SharedAccess.connlibLogFolderURL?.path ?? "/tmp/firezone" - callbackHandler.delegate = self - - Log.log("Adapter.start: Starting connlib") + // Create the session + let session: Session do { - let jsonEncoder = JSONEncoder() - jsonEncoder.keyEncodingStrategy = .convertToSnakeCase - - // Grab a session pointer - session = try WrappedSession.connect( - apiURL, - "\(token)", - "\(id)", - accountSlug, - DeviceMetadata.getDeviceName(), - DeviceMetadata.getOSVersion(), - connlibLogFolderPath, - logFilter, - internetResourceEnabled, - callbackHandler, - String(data: jsonEncoder.encode(DeviceMetadata.deviceInfo()), encoding: .utf8)! + session = try Session.newApple( + apiUrl: apiURL, + token: token.description, + deviceId: deviceId, + accountSlug: accountSlug, + deviceName: deviceName, + osVersion: osVersion, + logDir: logDir, + logFilter: logFilter, + deviceInfo: deviceInfoStr, + isInternetResourceActive: internetResourceEnabled ) - } catch let error { - // `toString` needed to deep copy the string and avoid a possible dangling pointer - let msg = (error as? RustString)?.toString() ?? "Unknown error" - throw AdapterError.connlibConnectError(msg) + } catch { + throw AdapterError.connlibConnectError(String(describing: error)) } + + // Create channels - following Rust pattern with separate sender/receiver + let (commandSender, commandReceiver): (Sender, Receiver) = + Channel.create() + self.commandSender = commandSender + + let (eventSender, eventReceiver): (Sender, Receiver) = Channel.create() + + // Start event loop - owns session, receives commands, sends events + eventLoopTask = Task { [weak self] in + defer { + // ALWAYS cleanup, even if event loop crashes + self?.commandSender = nil + Log.log("Adapter: Event loop finished, session dropped") + } + + await runSessionEventLoop( + session: session, + commandReceiver: commandReceiver, + eventSender: eventSender + ) + } + + // Start event consumer - consumes events from receiver (Rust pattern: receiver outside) + eventConsumerTask = Task { [weak self] in + for await event in eventReceiver.stream { + // Check self on each iteration - if Adapter is deallocated, stop processing events + guard let self = self else { + Log.log("Adapter: Event consumer stopping - Adapter deallocated") + break + } + + await self.handleEvent(event) + } + + Log.log("Adapter: Event consumer finished") + } + + // Configure DNS and path monitoring + startNetworkPathMonitoring() + + Log.log("Adapter.start: Session started successfully") } /// Final callback called by packetTunnelProvider when tunnel is to be stopped. @@ -232,11 +265,13 @@ class Adapter { func stop() { Log.log("Adapter.stop") - // Assigning `nil` will invoke `Drop` on the Rust side - session = nil + sendCommand(.disconnect) networkMonitor?.cancel() networkMonitor = nil + + // Tasks will finish naturally after disconnect command is processed + // No need to cancel them here - they'll clean up via their defer blocks } /// Get the current set of resources in the completionHandler, only returning @@ -258,119 +293,91 @@ class Adapter { } func reset(reason: String, path: Network.NWPath? = nil) { - session?.reset(reason) + sendCommand(.reset(reason)) if let path = (path ?? lastPath) { setSystemDefaultResolvers(path) } } - func resources() -> [Resource] { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - guard let resourceList = resourceListJSON else { return [] } - return (try? decoder.decode([Resource].self, from: resourceList.data(using: .utf8)!)) ?? [] - } - func setInternetResourceEnabled(_ enabled: Bool) { workQueue.async { [weak self] in guard let self = self else { return } self.internetResourceEnabled = enabled - session?.setInternetResourceState(enabled) + self.sendCommand(.setInternetResourceState(enabled)) } } -} -// MARK: Responding to path updates + // MARK: - Event handling -extension Adapter { - private func beginPathMonitoring() { - let networkMonitor = NWPathMonitor() - networkMonitor.pathUpdateHandler = self.pathUpdateHandler - networkMonitor.start(queue: self.workQueue) - } -} + private func handleEvent(_ event: Event) async { + switch event { + case .tunInterfaceUpdated( + let ipv4, let ipv6, let dns, let searchDomain, let ipv4Routes, let ipv6Routes): + Log.log("Received TunInterfaceUpdated event") -// MARK: Implementing CallbackHandlerDelegate - -extension Adapter: CallbackHandlerDelegate { - func onSetInterfaceConfig( - tunnelAddressIPv4: String, - tunnelAddressIPv6: String, - searchDomain: String?, - dnsAddresses: [String], - routeListv4: String, - routeListv6: String - ) { - // This is a queued callback to ensure ordering - workQueue.async { [weak self] in - guard let self = self else { return } - - let networkSettings = - networkSettings ?? NetworkSettings(packetTunnelProvider: packetTunnelProvider) - - guard let data4 = routeListv4.data(using: .utf8), - let data6 = routeListv6.data(using: .utf8), + // Decode all data into local variables first to ensure all parsing succeeds before applying + guard let dnsData = dns.data(using: .utf8), + let dnsAddresses = try? JSONDecoder().decode([String].self, from: dnsData), + let data4 = ipv4Routes.data(using: .utf8), + let data6 = ipv6Routes.data(using: .utf8), let decoded4 = try? JSONDecoder().decode([NetworkSettings.Cidr].self, from: data4), let decoded6 = try? JSONDecoder().decode([NetworkSettings.Cidr].self, from: data6) else { - fatalError("Could not decode route list from connlib") + fatalError("Could not decode network configuration from connlib") } let routes4 = decoded4.compactMap({ $0.asNEIPv4Route }) let routes6 = decoded6.compactMap({ $0.asNEIPv6Route }) - networkSettings.tunnelAddressIPv4 = tunnelAddressIPv4 - networkSettings.tunnelAddressIPv6 = tunnelAddressIPv6 + // All decoding succeeded - now apply settings atomically + guard let provider = packetTunnelProvider else { + Log.error(AdapterError.invalidSession(nil)) + return + } + + Log.log("Setting interface config") + + let networkSettings = NetworkSettings(packetTunnelProvider: provider) + networkSettings.tunnelAddressIPv4 = ipv4 + networkSettings.tunnelAddressIPv6 = ipv6 networkSettings.dnsAddresses = dnsAddresses networkSettings.routes4 = routes4 networkSettings.routes6 = routes6 networkSettings.setSearchDomain(domain: searchDomain) self.networkSettings = networkSettings - // Now that we have our interface configured, start listening for events. The first one will be us applying - // our network settings in the call below. We need the physical interface name macOS chooses for us in order - // to get the correct DNS resolvers on macOS. For that, we need the path parameter from the path update callback. - beginPathMonitoring() - networkSettings.apply() - } - } - func onUpdateResources(resourceList: String) { - // This is a queued callback to ensure ordering - workQueue.async { [weak self] in - guard let self = self else { return } + case .resourcesUpdated(let resources): + Log.log("Received ResourcesUpdated event with \(resources.count) bytes") - if resourceListJSON != resourceList, let networkSettings = self.networkSettings { - // Update resource List. We don't care what's inside. - resourceListJSON = resourceList + // Store resource list + workQueue.async { [weak self] in + guard let self = self else { return } + self.resourceListJSON = resources + } - // Apply network settings to flush DNS cache when resources change - // This ensures new DNS resources are immediately resolvable + // Apply network settings to flush DNS cache when resources change + // This ensures new DNS resources are immediately resolvable + if let networkSettings = networkSettings { Log.log("Reapplying network settings to flush DNS cache after resource update") networkSettings.apply() } - session?.setInternetResourceState(self.internetResourceEnabled) - } - } + case .disconnected(let error): + let errorMessage = error.message() + Log.info("Received Disconnected event: \(errorMessage)") - func onDisconnect(error: DisconnectError) { - // Since connlib has already shutdown by this point, we queue this callback - // to ensure that we can clean up even if connlib exits before we are done. - workQueue.async { [weak self] in - guard let self = self else { return } - - // Immediately invalidate our session pointer to prevent workQueue items from trying to use it. - // Assigning to `nil` will invoke `Drop` on the Rust side. - // This must happen asynchronously and not as part of the callback to allow Rust to break - // cyclic dependencies between the runtime and the task that is executing the callback. - self.session = nil + guard let provider = packetTunnelProvider else { + Log.error(AdapterError.invalidSession(nil)) + return + } // If auth expired/is invalid, delete stored token and save the reason why so the GUI can act upon it. if error.isAuthenticationError() { + // Delete stored token and save the reason for the GUI do { try Token.delete() let reason: NEProviderStopReason = .authenticationCanceled @@ -379,17 +386,26 @@ extension Adapter: CallbackHandlerDelegate { } catch { Log.error(error) } + #if os(iOS) // iOS notifications should be shown from the tunnel process SessionNotification.showSignedOutNotificationiOS() #endif + } else { + Log.warning("Disconnected with error: \(errorMessage)") } - // Tell the system to shut us down - self.packetTunnelProvider?.cancelTunnelWithError(nil) + // Handle disconnection + provider.cancelTunnelWithError(nil) } } + private func startNetworkPathMonitoring() { + networkMonitor = NWPathMonitor() + networkMonitor?.pathUpdateHandler = self.pathUpdateHandler + networkMonitor?.start(queue: workQueue) + } + private func setSystemDefaultResolvers(_ path: Network.NWPath) { // Step 1: Get system default resolvers #if os(macOS) @@ -448,15 +464,14 @@ extension Adapter: CallbackHandlerDelegate { } // Step 4: Send to connlib - do { - Log.log("Sending resolvers to connlib: \(jsonResolvers)") - try session?.setDns(jsonResolvers.intoRustString()) - } catch let error { - // `toString` needed to deep copy the string and avoid a possible dangling pointer - let msg = (error as? RustString)?.toString() ?? "Unknown error" - Log.error(AdapterError.setDnsError(msg)) - } + Log.log("Sending resolvers to connlib: \(jsonResolvers)") + sendCommand(.setDns(jsonResolvers)) } + + private func sendCommand(_ command: SessionCommand) { + commandSender?.send(command) + } + } // MARK: Getting System Resolvers on iOS diff --git a/swift/apple/FirezoneNetworkExtension/CallbackHandler.swift b/swift/apple/FirezoneNetworkExtension/CallbackHandler.swift deleted file mode 100644 index 149711292..000000000 --- a/swift/apple/FirezoneNetworkExtension/CallbackHandler.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// CallbackHandler.swift -// - -import FirezoneKit -import NetworkExtension -import OSLog - -// When the FFI changes from the Rust side, change the CallbackHandler -// functions along with that, but not the delegate protocol. -// When the app gets updated to use the FFI, the delegate protocol -// shall get updated. -// This is so that the app stays buildable even when the FFI changes. - -// See https://github.com/chinedufn/swift-bridge/issues/150 -extension RustString: @unchecked Sendable {} -extension RustString: Error {} - -public protocol CallbackHandlerDelegate: AnyObject { - func onSetInterfaceConfig( - tunnelAddressIPv4: String, - tunnelAddressIPv6: String, - searchDomain: String?, - dnsAddresses: [String], - routeListv4: String, - routeListv6: String - ) - func onUpdateResources(resourceList: String) - func onDisconnect(error: DisconnectError) -} - -public class CallbackHandler { - public weak var delegate: CallbackHandlerDelegate? - func onSetInterfaceConfig( - tunnelAddressIPv4: RustString, - tunnelAddressIPv6: RustString, - searchDomain: RustString?, - dnsAddresses: RustString, - routeListv4: RustString, - routeListv6: RustString - ) { - Log.log( - """ - CallbackHandler.onSetInterfaceConfig: - IPv4: \(tunnelAddressIPv4.toString()) - IPv6: \(tunnelAddressIPv6.toString()) - SearchDomain: \(String(describing: (searchDomain?.toString()))) - DNS: \(dnsAddresses.toString()) - IPv4 routes: \(routeListv4.toString()) - IPv6 routes: \(routeListv6.toString()) - """) - - guard let dnsData = dnsAddresses.toString().data(using: .utf8), - let dnsArray = try? JSONDecoder().decode([String].self, from: dnsData) - else { - fatalError("Should be able to decode DNS Addresses from connlib") - } - - delegate?.onSetInterfaceConfig( - tunnelAddressIPv4: tunnelAddressIPv4.toString(), - tunnelAddressIPv6: tunnelAddressIPv6.toString(), - searchDomain: searchDomain?.toString(), - dnsAddresses: dnsArray, - routeListv4: routeListv4.toString(), - routeListv6: routeListv6.toString() - ) - } - - func onUpdateResources(resourceList: RustString) { - Log.log("CallbackHandler.onUpdateResources: \(resourceList.toString())") - delegate?.onUpdateResources(resourceList: resourceList.toString()) - } - - func onDisconnect(error: DisconnectError) { - let errorStr = error.toString() - Log.log("CallbackHandler.onDisconnect: \(errorStr)") - delegate?.onDisconnect(error: error) - } -} diff --git a/swift/apple/FirezoneNetworkExtension/Channel.swift b/swift/apple/FirezoneNetworkExtension/Channel.swift new file mode 100644 index 000000000..3c5bbc17c --- /dev/null +++ b/swift/apple/FirezoneNetworkExtension/Channel.swift @@ -0,0 +1,82 @@ +import Foundation + +/// Sender side of a channel - can only send values. +/// +/// This wraps AsyncStream.Continuation to provide automatic cleanup via RAII. +/// When the sender is deallocated, the continuation is automatically finished, +/// matching Rust's behaviour where dropping a sender closes the channel. +final class Sender: Sendable { + private let continuation: AsyncStream.Continuation + + fileprivate init(continuation: AsyncStream.Continuation) { + self.continuation = continuation + } + + /// Sends a value into the channel. + @discardableResult + func send(_ value: T) -> AsyncStream.Continuation.YieldResult { + continuation.yield(value) + } + + /// Explicitly finishes the channel (optional, as deinit will do this automatically). + func finish() { + continuation.finish() + } + + deinit { + continuation.finish() + } +} + +/// Receiver side of a channel - can only receive values. +/// +/// This wraps AsyncStream to provide the receiving end of the channel. +/// Values can be consumed by iterating over the stream: +/// +/// for await value in receiver.stream { +/// // handle value +/// } +/// +final class Receiver: Sendable { + let stream: AsyncStream + + fileprivate init(stream: AsyncStream) { + self.stream = stream + } +} + +/// Channel factory - creates sender/receiver pairs matching Rust's channel pattern. +/// +/// This provides a type-safe way to create unidirectional communication channels, +/// where the sender can only send and the receiver can only receive. +/// +/// Example usage: +/// +/// let (sender, receiver) = Channel.create() +/// +/// // Producer task +/// Task { +/// sender.send(someEvent) +/// } +/// +/// // Consumer task +/// Task { +/// for await event in receiver.stream { +/// handle(event) +/// } +/// } +/// +struct Channel { + /// Creates a sender/receiver pair for type-safe unidirectional communication. + static func create() -> (Sender, Receiver) { + var continuation: AsyncStream.Continuation! + let stream = AsyncStream { cont in + continuation = cont + } + + let sender = Sender(continuation: continuation) + let receiver = Receiver(stream: stream) + + return (sender, receiver) + } +} diff --git a/swift/apple/FirezoneNetworkExtension/Connlib.xcfilelist b/swift/apple/FirezoneNetworkExtension/Connlib.xcfilelist index 29fa0d553..5fd8a9e5b 100644 --- a/swift/apple/FirezoneNetworkExtension/Connlib.xcfilelist +++ b/swift/apple/FirezoneNetworkExtension/Connlib.xcfilelist @@ -1,5 +1,2 @@ -$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/Generated/apple-client-ffi/apple-client-ffi.swift -$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/Generated/apple-client-ffi/apple-client-ffi.h -$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/Generated/SwiftBridgeCore.h -$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/Generated/SwiftBridgeCore.swift -$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/connlib.h +$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/Generated/connlib.swift +$(PROJECT_DIR)/FirezoneNetworkExtension/Connlib/Generated/connlibFFI.h diff --git a/swift/apple/FirezoneNetworkExtension/Connlib/connlib-Bridging-Header.h b/swift/apple/FirezoneNetworkExtension/Connlib/connlib-Bridging-Header.h new file mode 100644 index 000000000..7ee85a064 --- /dev/null +++ b/swift/apple/FirezoneNetworkExtension/Connlib/connlib-Bridging-Header.h @@ -0,0 +1,7 @@ +#ifndef connlib_Bridging_Header_h +#define connlib_Bridging_Header_h + +// Import the generated UniFFI C header +#import "connlibFFI.h" + +#endif /* connlib_Bridging_Header_h */ diff --git a/swift/apple/FirezoneNetworkExtension/Connlib/connlib.h b/swift/apple/FirezoneNetworkExtension/Connlib/connlib.h deleted file mode 100644 index b94029a15..000000000 --- a/swift/apple/FirezoneNetworkExtension/Connlib/connlib.h +++ /dev/null @@ -1,9 +0,0 @@ -// Umbrella header for connlib - -#ifndef connlib_h -#define connlib_h - -#include "Generated/SwiftBridgeCore.h" -#include "Generated/apple-client-ffi/apple-client-ffi.h" - -#endif diff --git a/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension-Bridging-Header.h b/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension-Bridging-Header.h index 0cf5995a8..12689f9ee 100644 --- a/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension-Bridging-Header.h +++ b/swift/apple/FirezoneNetworkExtension/FirezoneNetworkExtension-Bridging-Header.h @@ -2,5 +2,7 @@ // Use this file to import your target's public headers that you would like to expose to Swift. // -#include "Connlib/connlib.h" +// UniFFI generated header +#include "Connlib/Generated/connlibFFI.h" + #include diff --git a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift index 5d003cfe1..ae0b75f5e 100644 --- a/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift +++ b/swift/apple/FirezoneNetworkExtension/PacketTunnelProvider.swift @@ -35,6 +35,14 @@ class PacketTunnelProvider: NEPacketTunnelProvider { super.init() + // Log version information immediately on startup + let version = + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown" + let bundleId = Bundle.main.bundleIdentifier ?? "unknown" + Log.info( + "NetworkExtension starting - Version: \(version), Build: \(build), Bundle ID: \(bundleId)") + migrateFirezoneId() self.tunnelConfiguration = TunnelConfiguration.tryLoad() } @@ -51,10 +59,17 @@ class PacketTunnelProvider: NEPacketTunnelProvider { // Dummy start to get the extension running on macOS after upgrade if options?["dryRun"] as? Bool == true { + Log.info("Dry run startup requested - extension awakened but not starting tunnel") completionHandler(nil) return } + // Log version on actual tunnel start + let version = + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown" + Log.info("Starting tunnel - Version: \(version), Build: \(build)") + // If the tunnel starts up before the GUI after an upgrade crossing the 1.4.15 version boundary, // the old system settings-based config will still be present and the new configuration will be empty. // So handle that edge case gracefully. @@ -93,16 +108,18 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let internetResourceEnabled = enabled != nil ? enabled == "true" : (tunnelConfiguration?.internetResourceEnabled ?? false) + // Create the adapter with all configuration let adapter = Adapter( apiURL: apiURL, token: token, - id: id, + deviceId: id, logFilter: logFilter, accountSlug: accountSlug, internetResourceEnabled: internetResourceEnabled, packetTunnelProvider: self ) + // Start the adapter try adapter.start() self.adapter = adapter @@ -149,11 +166,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider { case .setConfiguration(let tunnelConfiguration): tunnelConfiguration.save() self.tunnelConfiguration = tunnelConfiguration + self.adapter?.setInternetResourceEnabled(tunnelConfiguration.internetResourceEnabled) completionHandler?(nil) case .signOut: do { try Token.delete() } catch { Log.error(error) } + completionHandler?(nil) case .getResourceList(let hash): guard let adapter = adapter else { @@ -163,6 +182,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } + // Use hash comparison to only return resources if they've changed adapter.getResourcesIfVersionDifferentFrom(hash: hash) { resourceListJSON in completionHandler?(resourceListJSON?.data(using: .utf8)) } diff --git a/swift/apple/FirezoneNetworkExtension/SessionEventLoop.swift b/swift/apple/FirezoneNetworkExtension/SessionEventLoop.swift new file mode 100644 index 000000000..d8931e39e --- /dev/null +++ b/swift/apple/FirezoneNetworkExtension/SessionEventLoop.swift @@ -0,0 +1,91 @@ +import FirezoneKit +import Foundation + +/// Commands that can be sent to the Session. +enum SessionCommand { + case disconnect + case setInternetResourceState(Bool) + case setDns(String) + case reset(String) +} + +/// Runs the session event loop, owning the Session lifecycle. +/// +/// When either task completes, both are cancelled and the function returns. +/// This ensures the Session's Drop is called on the Rust side. +func runSessionEventLoop( + session: Session, + commandReceiver: Receiver, + eventSender: Sender +) async { + + // Multiplex between commands and events + await withTaskGroup(of: Void.self) { group in + // Event polling task - polls Rust for events and sends to eventSender + group.addTask { + while !Task.isCancelled { + do { + // Poll for next event from Rust + guard let event = try await session.nextEvent() else { + // No event returned - session has ended + Log.log("SessionEventLoop: Event stream ended, exiting event loop") + break + } + + eventSender.send(event) + } catch { + Log.error(error) + Log.log("SessionEventLoop: Error polling event, continuing") + continue + } + } + + Log.log("SessionEventLoop: Event polling finished") + } + + // Command handling task - receives commands from commandReceiver + group.addTask { + for await command in commandReceiver.stream { + await handleCommand(command, session: session) + + // Exit loop if disconnect command + if case .disconnect = command { + Log.log("SessionEventLoop: Disconnect command received, exiting command loop") + break + } + } + + Log.log("SessionEventLoop: Command handling finished") + } + + // Wait for first task to complete, then cancel all + _ = await group.next() + Log.log("SessionEventLoop: One task completed, cancelling event loop") + group.cancelAll() + } +} + +/// Handles a command by calling the appropriate session method. +private func handleCommand(_ command: SessionCommand, session: Session) async { + switch command { + case .disconnect: + do { + try session.disconnect() + } catch { + Log.error(error) + } + + case .setInternetResourceState(let active): + session.setInternetResourceState(active: active) + + case .setDns(let servers): + do { + try session.setDns(dnsServers: servers) + } catch { + Log.error(error) + } + + case .reset(let reason): + session.reset(reason: reason) + } +} diff --git a/swift/apple/Makefile b/swift/apple/Makefile index c885c05d0..fbf78cac5 100644 --- a/swift/apple/Makefile +++ b/swift/apple/Makefile @@ -1,15 +1,29 @@ # Creates a macOS debug build -PLATFORM=macOS +PLATFORM?=macOS ARCH=$(shell uname -m) +CONFIGURATION?=Debug + +# Path to rust target directory (relative to this Makefile) +RUST_TARGET_DIR=$(abspath ../../rust/target) # Set consistent environment to prevent Rust rebuilds # This must match the Xcode project setting export MACOSX_DEPLOYMENT_TARGET=12.4 -build-macos: - echo "Building debug build for ${PLATFORM}, ${ARCH}" - @xcodebuild build -scheme Firezone -sdk macosx -destination 'platform=${PLATFORM},arch=${ARCH}' +# Map architecture names for Rust targets +ifeq ($(ARCH),arm64) + RUST_TARGET=aarch64-apple-darwin +else + RUST_TARGET=x86_64-apple-darwin +endif + +# Get the current Git SHA for build identification +GIT_SHA=$(shell git rev-parse HEAD 2>/dev/null || echo "unknown") + +# Default target: build and install +.PHONY: all +all: build install # Info for sourcekit-lsp (LSP server for other IDEs) lsp: @@ -17,6 +31,20 @@ lsp: -project Firezone.xcodeproj \ -scheme Firezone +.PHONY: build +build: + @echo "Building Xcode project for ${PLATFORM}, ${ARCH}" + @echo "Git SHA: ${GIT_SHA}" + @xcodebuild build \ + -project Firezone.xcodeproj \ + -scheme Firezone \ + -configuration $(CONFIGURATION) \ + -sdk macosx \ + -destination 'platform=${PLATFORM},arch=${ARCH}' \ + CONNLIB_TARGET_DIR="${RUST_TARGET_DIR}" \ + GIT_SHA="${GIT_SHA}" \ + ONLY_ACTIVE_ARCH=YES + .PHONY: install install: @echo "Stopping any running Firezone instances..." @@ -32,9 +60,18 @@ install: @echo "Launching Firezone..." @open /Applications/Firezone.app +.PHONY: clean clean: - @xcodebuild clean -scheme Firezone -sdk macosx -destination 'platform=${PLATFORM},arch=${ARCH}' - cd ../../rust/apple-client-ffi && rm -rf ./Connlib.xcframework + @echo "Cleaning Xcode build" + @xcodebuild clean \ + -project Firezone.xcodeproj \ + -scheme Firezone \ + -configuration $(CONFIGURATION) \ + -sdk macosx + @echo "Cleaning Rust build artifacts" + @cd ../../rust/client-ffi && cargo clean + @echo "Removing Generated bindings" + @rm -rf FirezoneNetworkExtension/Connlib/Generated .PHONY: format format: @@ -42,6 +79,14 @@ format: @find . -name "*.swift" -not -path "./FirezoneNetworkExtension/Connlib/Generated/*" -not -path "./FirezoneKit/.build/*" | xargs swift format format --in-place --parallel @echo "Linting Swift code..." @find . -name "*.swift" -not -path "./FirezoneNetworkExtension/Connlib/Generated/*" -not -path "./FirezoneKit/.build/*" | xargs swift format lint --parallel --strict + +.PHONY: setup +setup: + @echo "Installing required Rust targets..." + @rustup target add aarch64-apple-darwin x86_64-apple-darwin + @rustup target add aarch64-apple-ios x86_64-apple-ios + @echo "Setup complete!" + .PHONY: show-client-log show-client-log: @echo "Opening latest Firezone client log..." diff --git a/swift/apple/build-rust.sh b/swift/apple/build-rust.sh new file mode 100755 index 000000000..77bd85778 --- /dev/null +++ b/swift/apple/build-rust.sh @@ -0,0 +1,206 @@ +#!/bin/bash +set -euo pipefail + +# Error handler +trap 'echo "ERROR: Build script failed at line $LINENO" >&2' ERR + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RUST_DIR="$SCRIPT_DIR/../../rust" +GENERATED_DIR="$SCRIPT_DIR/FirezoneNetworkExtension/Connlib/Generated" + +# Sanitize the environment to prevent Xcode's shenanigans from leaking +# into our highly evolved Rust-based build system. +for var in $(env | awk -F= '{print $1}'); do + if [[ "$var" != "HOME" ]] && + [[ "$var" != "MACOSX_DEPLOYMENT_TARGET" ]] && + [[ "$var" != "IPHONEOS_DEPLOYMENT_TARGET" ]] && + [[ "$var" != "USER" ]] && + [[ "$var" != "LOGNAME" ]] && + [[ "$var" != "TERM" ]] && + [[ "$var" != "PWD" ]] && + [[ "$var" != "SHELL" ]] && + [[ "$var" != "TMPDIR" ]] && + [[ "$var" != "XPC_FLAGS" ]] && + [[ "$var" != "XPC_SERVICE_NAME" ]] && + [[ "$var" != "PLATFORM_NAME" ]] && + [[ "$var" != "CONFIGURATION" ]] && + [[ "$var" != "NATIVE_ARCH" ]] && + [[ "$var" != "ONLY_ACTIVE_ARCH" ]] && + [[ "$var" != "ARCHS" ]] && + [[ "$var" != "SDKROOT" ]] && + [[ "$var" != "OBJROOT" ]] && + [[ "$var" != "SYMROOT" ]] && + [[ "$var" != "SRCROOT" ]] && + [[ "$var" != "TARGETED_DEVICE_FAMILY" ]] && + [[ "$var" != "RUSTC_WRAPPER" ]] && + [[ "$var" != "RUST_TOOLCHAIN" ]] && + [[ "$var" != "SCCACHE_GCS_BUCKET" ]] && + [[ "$var" != "SCCACHE_GCS_RW_MODE" ]] && + [[ "$var" != "GOOGLE_CLOUD_PROJECT" ]] && + [[ "$var" != "GCP_PROJECT" ]] && + [[ "$var" != "GCLOUD_PROJECT" ]] && + [[ "$var" != "CLOUDSDK_PROJECT" ]] && + [[ "$var" != "CLOUDSDK_CORE_PROJECT" ]] && + [[ "$var" != "GOOGLE_GHA_CREDS_PATH" ]] && + [[ "$var" != "GOOGLE_APPLICATION_CREDENTIALS" ]] && + [[ "$var" != "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE" ]] && + [[ "$var" != "ACTIONS_CACHE_URL" ]] && + [[ "$var" != "ACTIONS_RUNTIME_TOKEN" ]] && + [[ "$var" != "CARGO_INCREMENTAL" ]] && + [[ "$var" != "CARGO_TERM_COLOR" ]] && + [[ "$var" != "FIREZONE_PACKAGE_VERSION" ]] && + [[ "$var" != "CONNLIB_TARGET_DIR" ]]; then + unset "$var" + fi +done + +# Use pristine path; the PATH from Xcode is polluted with stuff we don't want which can +# confuse rustc. +export PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:$HOME/.cargo/bin:/run/current-system/sw/bin/" + +# Parse Xcode environment +PLATFORM_NAME="${PLATFORM_NAME:-macosx}" +CONFIGURATION="${CONFIGURATION:-Debug}" +NATIVE_ARCH="${ARCHS:-${NATIVE_ARCH:-$(uname -m)}}" + +# Set target directory - use CONNLIB_TARGET_DIR if set, otherwise default +export CARGO_TARGET_DIR="${CONNLIB_TARGET_DIR:-$RUST_DIR/target}" + +echo "=========================================" +echo "Building Connlib for Xcode" +echo "Platform: $PLATFORM_NAME" +echo "Configuration: $CONFIGURATION" +echo "Architecture: $NATIVE_ARCH" +echo "Target Directory: $CARGO_TARGET_DIR" +echo "=========================================" + +# Determine Rust targets based on platform and architecture +TARGETS=() +case "$PLATFORM_NAME" in +macosx) + if [[ "$CONFIGURATION" == "Release" ]] || [[ "$CONFIGURATION" == "Profile" ]] || [[ -z "$NATIVE_ARCH" ]]; then + # Build universal binary for Release and Profile + TARGETS=("aarch64-apple-darwin" "x86_64-apple-darwin") + else + # Build only for native arch in Debug + if [[ "$NATIVE_ARCH" == "arm64" ]]; then + TARGETS=("aarch64-apple-darwin") + elif [[ "$NATIVE_ARCH" == "x86_64" ]]; then + TARGETS=("x86_64-apple-darwin") + else + echo "ERROR: Unsupported native arch for $PLATFORM_NAME: $NATIVE_ARCH" >&2 + exit 1 + fi + fi + ;; +iphoneos) + TARGETS=("aarch64-apple-ios") + ;; +iphonesimulator) + if [[ "$NATIVE_ARCH" == "arm64" ]]; then + TARGETS=("aarch64-apple-ios-sim") + elif [[ "$NATIVE_ARCH" == "x86_64" ]]; then + TARGETS=("x86_64-apple-ios") + else + echo "ERROR: Unsupported native arch for $PLATFORM_NAME: $NATIVE_ARCH" >&2 + exit 1 + fi + ;; +*) + echo "ERROR: Unknown platform: $PLATFORM_NAME" >&2 + exit 1 + ;; +esac + +# Prepare cargo build flags +if [ "$CONFIGURATION" = "Release" ] || [ "$CONFIGURATION" = "Profile" ]; then + CARGO_BUILD_FLAGS="--release" + BUILD_DIR="release" +else + CARGO_BUILD_FLAGS="" + BUILD_DIR="debug" +fi + +# Ensure RUSTUP_HOME is set +if [ -z "${RUSTUP_HOME:-}" ] && [ -d "$HOME/.rustup" ]; then + export RUSTUP_HOME="$HOME/.rustup" +fi + +# Ensure Rust targets are installed (from rust directory to use correct toolchain) +cd "$RUST_DIR" +for target in "${TARGETS[@]}"; do + if ! rustup target list --installed | grep -q "^$target$"; then + echo "Installing Rust target: $target" + rustup target add "$target" + fi +done + +# Step 1: Build Rust library +echo "" +echo "Step 1/3: Building Rust library..." + +# Build target list for cargo command +target_list="" +for target in "${TARGETS[@]}"; do + target_list+="--target $target " +done +target_list="${target_list% }" + +cd "$RUST_DIR" +cargo build --package client-ffi $target_list $CARGO_BUILD_FLAGS + +# Remove any dylib files to ensure static linking +for target in "${TARGETS[@]}"; do + LIBRARY_PATH="$CARGO_TARGET_DIR/$target/$BUILD_DIR" + if [ -f "$LIBRARY_PATH/libconnlib.dylib" ]; then + rm -f "$LIBRARY_PATH/libconnlib.dylib" + fi +done + +# Step 2: Generate UniFFI bindings +echo "" +echo "Step 2/3: Generating UniFFI bindings..." +mkdir -p "$GENERATED_DIR" + +# Use the first target's library for generating bindings (they're the same for all architectures) +FIRST_TARGET="${TARGETS[0]}" +LIBRARY_PATH="$CARGO_TARGET_DIR/$FIRST_TARGET/$BUILD_DIR" + +cargo run -p uniffi-bindgen -- generate \ + --library "$LIBRARY_PATH/libconnlib.a" \ + --language swift \ + --out-dir "$GENERATED_DIR" + +# Remove module maps (we use bridging header instead) +rm -f "$GENERATED_DIR"/*.modulemap + +# Fix imports in generated Swift file to use bridging header +if [ -f "$GENERATED_DIR/connlib.swift" ]; then + # Comment out the #if canImport(connlibFFI) block + sed -i.bak '/#if canImport(connlibFFI)/,/#endif/s/^/\/\/ /' "$GENERATED_DIR/connlib.swift" + rm -f "$GENERATED_DIR/connlib.swift.bak" +fi + +# Step 3: Verify generated files +echo "" +echo "Step 3/3: Verifying generated files..." +if [ ! -f "$GENERATED_DIR/connlib.swift" ]; then + echo "ERROR: Generated Swift file not found" >&2 + exit 1 +fi + +if [ ! -f "$GENERATED_DIR/connlibFFI.h" ]; then + echo "ERROR: Generated header file not found" >&2 + exit 1 +fi + +echo "" +echo "✅ Build completed successfully!" +echo " Swift bindings: $GENERATED_DIR/connlib.swift" +echo " C header: $GENERATED_DIR/connlibFFI.h" +echo " Built libraries:" +for target in "${TARGETS[@]}"; do + echo " - $CARGO_TARGET_DIR/$target/$BUILD_DIR/libconnlib.a" +done +echo "=========================================" diff --git a/swift/apple/cleanup.sh b/swift/apple/cleanup.sh deleted file mode 100755 index 08f3892d1..000000000 --- a/swift/apple/cleanup.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash - -set -e - -ARG=$1 - -if [[ -z "$ARG" ]]; then - ARG="swift" -fi - -if [[ $ARG == "all" ]] || [[ $ARG == "swift" ]] || [[ $ARG == "rust" ]]; then - echo "Cleaning up $ARG build artifacts"; -else - echo "Usage: $0 [ all | swift | rust ]" - echo " (Default: swift)" -fi - -if [[ $ARG == "swift" ]] || [[ $ARG == "all" ]]; then - set -x - xcodebuild clean - rm -rf ./FirezoneNetworkExtension/Connlib - set +x -fi - -if [[ $ARG == "rust" ]] || [[ $ARG == "all" ]]; then - set -x - cd ../../rust/apple-client-ffi && cargo clean - cd Sources/Connlib/Generated && git clean -df - set +x -fi