diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8cfbcf69..61439de3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,18 +5,18 @@ on: branches: - main - dev - - 'release/**' + - "release/**" paths-ignore: - - '*.md' - - 'LICENSE' + - "*.md" + - "LICENSE" pull_request: branches: - main - dev - - 'release/**' + - "release/**" paths-ignore: - - '*.md' - - 'LICENSE' + - "*.md" + - "LICENSE" env: CARGO_TERM_COLOR: always @@ -42,8 +42,8 @@ jobs: - name: Scan code with Trivy uses: aquasecurity/trivy-action@0.33.1 with: - scan-type: 'fs' - scan-ref: '.' + scan-type: "fs" + scan-ref: "." exit-code: "1" ignore-unfixed: true severity: "CRITICAL,HIGH,MEDIUM" @@ -66,7 +66,7 @@ jobs: - name: Run cargo deny working-directory: ./src-tauri run: | - cargo install cargo-deny + cargo install cargo-deny --version 0.18.6 cargo deny check - name: Run tests run: cargo test --locked --no-fail-fast diff --git a/package.json b/package.json index 11a719dd..ff2a17b3 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,10 @@ "onlyBuiltDependencies": [ "@swc/core", "esbuild" - ] + ], + "overrides": { + "mdast-util-to-hast": "13.2.1" + } }, "dependencies": { "@floating-ui/react": "^0.27.16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79a0806f..f5572b38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + mdast-util-to-hast: 13.2.1 + importers: .: @@ -2166,8 +2169,8 @@ packages: mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} - mdast-util-to-hast@13.2.0: - resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} @@ -4940,7 +4943,7 @@ snapshots: '@types/mdast': 4.0.4 unist-util-is: 6.0.1 - mdast-util-to-hast@13.2.0: + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 @@ -5332,7 +5335,7 @@ snapshots: devlop: 1.1.0 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 - mdast-util-to-hast: 13.2.0 + mdast-util-to-hast: 13.2.1 react: 19.2.0 remark-parse: 11.0.0 remark-rehype: 11.1.2 @@ -5458,7 +5461,7 @@ snapshots: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - mdast-util-to-hast: 13.2.0 + mdast-util-to-hast: 13.2.1 unified: 11.0.5 vfile: 6.0.3 diff --git a/src-tauri/Client.entitlements b/src-tauri/Client.entitlements index 552baf4b..863984e6 100644 --- a/src-tauri/Client.entitlements +++ b/src-tauri/Client.entitlements @@ -14,5 +14,9 @@ 82GZ7KN29J.net.defguard com.apple.developer.team-identifier 82GZ7KN29J + com.apple.security.application-groups + + group.net.defguard + diff --git a/src-tauri/src/apple.rs b/src-tauri/src/apple.rs index 4d7fafb6..a3af555c 100644 --- a/src-tauri/src/apple.rs +++ b/src-tauri/src/apple.rs @@ -1,39 +1,324 @@ //! Structures used for interchangeability with the Swift code. use std::{ + collections::HashMap, hint::spin_loop, net::IpAddr, + ptr::NonNull, str::FromStr, sync::{ - atomic::{AtomicBool, AtomicUsize, Ordering}, - mpsc::channel, - Arc, Mutex, + atomic::{AtomicBool, Ordering}, + mpsc::{self, channel, Receiver, RecvTimeoutError, Sender}, + Arc, LazyLock, Mutex, }, + time::Duration, }; use block2::RcBlock; use defguard_wireguard_rs::{host::Peer, key::Key, net::IpAddrMask}; -use objc2::{rc::Retained, runtime::AnyObject}; +use objc2::{ + rc::Retained, + runtime::{AnyObject, ProtocolObject}, +}; use objc2_foundation::{ ns_string, NSArray, NSData, NSDate, NSDictionary, NSError, NSMutableArray, NSMutableDictionary, - NSNumber, NSRunLoop, NSString, + NSNotification, NSNotificationCenter, NSNotificationName, NSNumber, NSObjectProtocol, + NSOperationQueue, NSRunLoop, NSString, }; use objc2_network_extension::{ - NETunnelProviderManager, NETunnelProviderProtocol, NETunnelProviderSession, + NETunnelProviderManager, NETunnelProviderProtocol, NETunnelProviderSession, NEVPNStatus, }; use serde::{Deserialize, Serialize}; use sqlx::SqliteExecutor; +use tauri::{AppHandle, Emitter, Manager}; use crate::{ + active_connections::find_connection, + appstate::AppState, database::{ models::{location::Location, tunnel::Tunnel, wireguard_keys::WireguardKeys, Id}, DB_POOL, }, error::Error, + events::EventKey, utils::{DEFAULT_ROUTE_IPV4, DEFAULT_ROUTE_IPV6}, + ConnectionType, }; const PLUGIN_BUNDLE_ID: &str = "net.defguard.VPNExtension"; +static OBSERVER_COMMS: LazyLock<( + Mutex>, + Mutex>>, +)> = LazyLock::new(|| { + let (tx, rx) = mpsc::channel(); + (Mutex::new(tx), Mutex::new(Some(rx))) +}); + +static VPN_STATE_UPDATE_COMMS: LazyLock<( + Mutex>, + Mutex>>, +)> = LazyLock::new(|| { + let (tx, rx) = tokio::sync::mpsc::channel(100); + (Mutex::new(tx), Mutex::new(Some(rx))) +}); + +/// Thread responsible for handling VPN status update requests. +/// This is an async function. +/// It has access to the `AppHandle` to be able to emit events. +pub async fn connection_state_update_thread(app_handle: &AppHandle) { + let mut receiver = { + let mut rx_opt = VPN_STATE_UPDATE_COMMS + .1 + .lock() + .expect("Failed to lock state update receiver"); + rx_opt.take().expect("Receiver already taken") + }; + + while receiver.recv().await.is_some() { + debug!("Waiting for status update message from channel..."); + + debug!("Status update message received, synchronizing state..."); + sync_connections_with_system(app_handle).await; + + debug!("Processed status update message."); + } +} + +/// Synchronize the app's connection state with the system's VPN state. +/// This checks all locations and tunnels and updates the app state to match +/// what's actually running in the system. +pub async fn sync_connections_with_system(app_handle: &AppHandle) { + let pool = DB_POOL.clone(); + let app_state = app_handle.state::(); + let locations = Location::all(&pool, false).await.unwrap_or_default(); + let tunnels = Tunnel::all(&pool).await.unwrap_or_default(); + + for location in locations { + debug!( + "Synchronizing VPN status for location with system status: {}. Querying status...", + location.name + ); + let status = get_location_status(&location); + debug!( + "Location {} (ID {}) status: {status:?}", + location.name, location.id + ); + + match status { + Some(NEVPNStatus::Connected) => { + debug!("Location {} is connected", location.name); + if find_connection(location.id, crate::ConnectionType::Location) + .await + .is_some() + { + debug!( + "Location {} has already a connected state, skipping synchronization", + location.name + ); + } else { + debug!("Adding connection for location {}", location.name); + + app_state + .add_connection( + location.id, + &location.name, + crate::ConnectionType::Location, + ) + .await; + app_handle + .emit(EventKey::ConnectionChanged.into(), ()) + .unwrap(); + } + } + Some(NEVPNStatus::Disconnected) => { + debug!("Location {} is disconnected", location.name); + if find_connection(location.id, crate::ConnectionType::Location) + .await + .is_some() + { + debug!("Removing connection for location {}", location.name); + app_state + .remove_connection(location.id, crate::ConnectionType::Location) + .await; + app_handle + .emit(EventKey::ConnectionChanged.into(), ()) + .unwrap(); + } else { + debug!( + "Location {} has no active connection, skipping removal", + location.name + ); + } + } + Some(unknown_status) => { + debug!( + "Location {} has unknown status {unknown_status:?}, skipping synchronization", + location.name + ); + } + None => { + debug!( + "Couldn't find configuration for tunnel {}, skipping synchronization", + location.name + ); + } + } + } + + for tunnel in tunnels { + debug!( + "Synchronizing VPN status for tunnel with system status: {}. Querying status...", + tunnel.name + ); + let status = get_tunnel_status(&tunnel); + debug!( + "Location {} (ID {}) status: {status:?}", + tunnel.name, tunnel.id + ); + + match status { + Some(NEVPNStatus::Connected) => { + debug!("Location {} is connected", tunnel.name); + if find_connection(tunnel.id, crate::ConnectionType::Tunnel) + .await + .is_some() + { + debug!( + "Location {} has already a connected state, skipping synchronization", + tunnel.name + ); + } else { + debug!("Adding connection for location {}", tunnel.name); + + app_state + .add_connection(tunnel.id, &tunnel.name, crate::ConnectionType::Tunnel) + .await; + + app_handle + .emit(EventKey::ConnectionChanged.into(), ()) + .unwrap(); + } + } + Some(NEVPNStatus::Disconnected) => { + debug!("Location {} is disconnected", tunnel.name); + if find_connection(tunnel.id, crate::ConnectionType::Tunnel) + .await + .is_some() + { + debug!("Removing connection for location {}", tunnel.name); + app_state + .remove_connection(tunnel.id, crate::ConnectionType::Tunnel) + .await; + app_handle + .emit(EventKey::ConnectionChanged.into(), ()) + .unwrap(); + } else { + debug!( + "Location {} has no active connection, skipping removal", + tunnel.name + ); + } + } + Some(unknown_status) => { + debug!( + "Location {} has unknown status {:?}, skipping synchronization", + tunnel.name, unknown_status + ); + } + None => { + debug!( + "Couldn't find configuration for tunnel {}, skipping synchronization", + tunnel.name + ); + } + } + } +} + +const OBSERVER_CLEANUP_INTERVAL: Duration = Duration::from_secs(30); + +/// Thread responsible for observing VPN status changes. +/// This is intentionally a blocking function, as it uses the objective-c objects which are not thread safe. +pub fn observer_thread(initial_managers: HashMap<(String, Id), Retained>) { + debug!("Starting VPN connection observer thread"); + let receiver = { + let mut rx_opt = OBSERVER_COMMS + .1 + .lock() + .expect("Failed to lock observer receiver"); + rx_opt.take().expect("Receiver already taken") + }; + + let mut observers = HashMap::new(); + + // spawn initial observers for existing managers + for ((key, value), manager) in initial_managers { + debug!("Spawning initial observer for manager with key: {key}, value: {value}"); + let connection = unsafe { manager.connection() }; + + let observer = create_observer( + &NSNotificationCenter::defaultCenter(), + unsafe { objc2_network_extension::NEVPNStatusDidChangeNotification }, + vpn_status_change_handler, + Some(connection.as_ref()), + ); + debug!("Registered initial observer for manager with key: {key}, value: {value}"); + observers.insert((key, value), observer); + } + + loop { + match receiver.recv_timeout(OBSERVER_CLEANUP_INTERVAL) { + Ok(message) => { + debug!("Received message to observe the following connection: {message:?}"); + + let (key, value) = message; + + if observers.contains_key(&(key.clone(), value)) { + debug!("Observer for manager with key: {key}, value: {value} already exists, skipping",); + continue; + } + + let manager = manager_for_key_and_value(&key, value).unwrap(); + let connection = unsafe { manager.connection() }; + let observer = create_observer( + &NSNotificationCenter::defaultCenter(), + unsafe { objc2_network_extension::NEVPNStatusDidChangeNotification }, + vpn_status_change_handler, + Some(connection.as_ref()), + ); + + observers.insert((key.clone(), value), observer); + debug!("Registered observer for manager with key: {key}, value: {value}"); + } + Err(RecvTimeoutError::Timeout) => { + debug!("Performing periodic cleanup of dead observers"); + let mut dead_keys = Vec::new(); + + for (key, value) in observers.keys() { + if manager_for_key_and_value(key, *value).is_none() { + debug!("Manager for key: {key}, value: {value} no longer exists, marking for removal"); + dead_keys.push((key.clone(), *value)); + } + } + + for dead_key in dead_keys { + if let Some(_observer) = observers.remove(&dead_key) { + debug!( + "Removed dead VPN connection observer for key: {}, value: {}", + dead_key.0, dead_key.1 + ); + } + } + } + Err(RecvTimeoutError::Disconnected) => { + error!("Observer receiver channel disconnected, exiting observer thread"); + break; + } + } + } + + debug!("Exiting VPN connection observer thread"); +} /// Tunnel statistics shared with VPNExtension (written in Swift). #[derive(Deserialize)] @@ -61,6 +346,55 @@ pub fn spawn_runloop_and_wait_for(semaphore: Arc) { } } +fn vpn_status_change_handler(notification: &NSNotification) { + let name = notification.name(); + debug!("Received VPN status change notification: {name:?}"); + VPN_STATE_UPDATE_COMMS + .0 + .lock() + .expect("Failed to lock state update sender") + .blocking_send(()) + .expect("Failed to send to state update channel"); + debug!("Sent status update request to channel"); +} + +pub fn create_observer( + center: &NSNotificationCenter, + name: &NSNotificationName, + handler: impl Fn(&NSNotification) + 'static, + object: Option<&AnyObject>, +) -> Retained> { + let block = RcBlock::new(move |notification: NonNull| { + handler(unsafe { notification.as_ref() }); + }); + let queue = NSOperationQueue::mainQueue(); + unsafe { + center.addObserverForName_object_queue_usingBlock(Some(name), object, Some(&queue), &block) + } +} + +#[must_use] +pub fn get_managers_for_tunnels_and_locations( + tunnels: &[Tunnel], + locations: &[Location], +) -> HashMap<(String, Id), Retained> { + let mut managers = HashMap::new(); + + for location in locations { + if let Some(manager) = manager_for_key_and_value("locationId", location.id) { + managers.insert(("locationId".to_string(), location.id), manager); + } + } + + for tunnel in tunnels { + if let Some(manager) = manager_for_key_and_value("tunnelId", tunnel.id) { + managers.insert(("tunnelId".to_string(), tunnel.id), manager); + } + } + + managers +} + pub(crate) fn manager_for_key_and_value( key: &str, value: Id, @@ -279,11 +613,11 @@ impl TunnelConfiguration { let spinlock_clone = Arc::clone(&spinlock); let plugin_bundle_id = NSString::from_str(PLUGIN_BUNDLE_ID); - unsafe { - let provider_manager = self - .tunnel_provider_manager() - .unwrap_or_else(|| NETunnelProviderManager::new()); + let provider_manager = self + .tunnel_provider_manager() + .unwrap_or_else(|| unsafe { NETunnelProviderManager::new() }); + unsafe { let tunnel_protocol = NETunnelProviderProtocol::new(); tunnel_protocol.setProviderBundleIdentifier(Some(&plugin_bundle_id)); let server_address = self.peers.first().map_or(String::new(), |peer| { @@ -305,9 +639,9 @@ impl TunnelConfiguration { // Save to system settings. let handler = RcBlock::new(move |error_ptr: *mut NSError| { if error_ptr.is_null() { - info!("Saved tunnel configuration for {name}"); + debug!("Saved tunnel configuration for {name} to system settings"); } else { - error!("Failed to save tunnel configuration for: {name}"); + error!("Failed to save tunnel configuration for: {name} to system settings"); } spinlock_clone.store(true, Ordering::Release); }); @@ -327,6 +661,18 @@ impl TunnelConfiguration { { error!("Failed to start VPN: {err}"); } else { + OBSERVER_COMMS + .0 + .lock() + .expect("Failed to lock observer sender") + .send(( + self.location_id.map_or_else( + || "tunnelId".to_string(), + |_location_id| "locationId".to_string(), + ), + self.location_id.or(self.tunnel_id).unwrap(), + )) + .expect("Failed to send to observer channel"); info!("VPN started"); } } else { @@ -354,7 +700,7 @@ pub(crate) fn remove_config_for_location(location: &Location) { /// Remove configuration from system settings for [`Tunnel`]. pub(crate) fn remove_config_for_tunnel(tunnel: &Tunnel) { - if let Some(provider_manager) = manager_for_key_and_value("locationId", tunnel.id) { + if let Some(provider_manager) = manager_for_key_and_value("tunnelId", tunnel.id) { unsafe { provider_manager.removeFromPreferencesWithCompletionHandler(None); } @@ -368,128 +714,150 @@ pub(crate) fn remove_config_for_tunnel(tunnel: &Tunnel) { /// Stop tunnel for [`Location`]. pub(crate) fn stop_tunnel_for_location(location: &Location) -> bool { - if let Some(provider_manager) = manager_for_key_and_value("locationId", location.id) { - unsafe { - provider_manager.connection().stopVPNTunnel(); - } - info!("VPN stopped"); - true - } else { - error!( - "Couldn't find configuration in system settings for location {}", - location.name - ); - false - } + manager_for_key_and_value("locationId", location.id).map_or_else( + || { + error!( + "Couldn't find configuration in system settings for location {}", + location.name + ); + false + }, + |provider_manager| { + unsafe { + provider_manager.connection().stopVPNTunnel(); + } + info!("VPN stopped"); + true + }, + ) } /// Stop tunnel for [`Tunnel`]. pub(crate) fn stop_tunnel_for_tunnel(tunnel: &Tunnel) -> bool { - if let Some(provider_manager) = manager_for_key_and_value("tunnelId", tunnel.id) { - unsafe { - provider_manager.connection().stopVPNTunnel(); - } - info!("VPN stopped"); - true - } else { - error!( - "Couldn't find configuration in system settings for location {}", - tunnel.name - ); - false - } + manager_for_key_and_value("tunnelId", tunnel.id).map_or_else( + || { + error!( + "Couldn't find configuration in system settings for location {}", + tunnel.name + ); + false + }, + |provider_manager| { + unsafe { + provider_manager.connection().stopVPNTunnel(); + } + info!("VPN stopped"); + true + }, + ) } -pub(crate) fn tunnel_stats() -> Vec { - let new_stats = Arc::new(Mutex::new(Vec::new())); +/// Check whether tunnel is running for [`Location`]. +pub(crate) fn get_location_status(location: &Location) -> Option { + manager_for_key_and_value("locationId", location.id).map_or_else( + || { + error!( + "Couldn't find configuration in system settings for location {}", + location.name + ); + None + }, + |provider_manager| { + let connection = unsafe { provider_manager.connection() }; + Some(unsafe { connection.status() }) + }, + ) +} + +/// Check whether tunnel is running for [`Tunnel`]. +pub(crate) fn get_tunnel_status(tunnel: &Tunnel) -> Option { + manager_for_key_and_value("tunnelId", tunnel.id).map_or_else( + || { + error!( + "Couldn't find configuration in system settings for tunnel {}", + tunnel.name + ); + None + }, + |provider_manager| { + let connection = unsafe { provider_manager.connection() }; + Some(unsafe { connection.status() }) + }, + ) +} + +pub(crate) fn tunnel_stats(id: Id, connection_type: &ConnectionType) -> Option { + let new_stats = Arc::new(Mutex::new(None)); let plugin_bundle_id = NSString::from_str(PLUGIN_BUNDLE_ID); - let spinlock = Arc::new(AtomicUsize::new(1)); let new_stats_clone = Arc::clone(&new_stats); - let spinlock_for_response = Arc::clone(&spinlock); + + let finished = Arc::new(AtomicBool::new(false)); + let finished_clone = Arc::clone(&finished); + let response_handler = RcBlock::new(move |data_ptr: *mut NSData| { if let Some(data) = unsafe { data_ptr.as_ref() } { if let Ok(stats) = serde_json::from_slice(data.to_vec().as_slice()) { if let Ok(mut new_stats_locked) = new_stats_clone.lock() { - new_stats_locked.push(stats); + *new_stats_locked = Some(stats); } } else { warn!("Failed to deserialize tunnel stats"); } } else { - warn!("No data"); + debug!("No data received in tunnel stats response, skipping"); } - spinlock_for_response.fetch_sub(1, Ordering::Release); + finished_clone.store(true, Ordering::Release); }); - let spinlock_for_handler = Arc::clone(&spinlock); - let handler = RcBlock::new( - move |managers_ptr: *mut NSArray, error_ptr: *mut NSError| { - if !error_ptr.is_null() { - error!("Failed to load tunnel provider managers."); - return; - } - - let Some(managers) = (unsafe { managers_ptr.as_ref() }) else { - error!("No managers"); - return; - }; - - for manager in managers { - let Some(vpn_protocol) = (unsafe { manager.protocolConfiguration() }) else { - continue; - }; - let Ok(tunnel_protocol) = vpn_protocol.downcast::() - else { - error!("Failed to downcast to NETunnelProviderProtocol"); - continue; - }; - // Sometimes all managers from all apps come through, so filter by bundle ID. - if let Some(bundle_id) = unsafe { tunnel_protocol.providerBundleIdentifier() } { - if bundle_id != plugin_bundle_id { - continue; - } - } - - let Ok(session) = - unsafe { manager.connection() }.downcast::() - else { - error!("Failed to downcast to NETunnelProviderSession"); - continue; - }; - - let message_data = NSData::new(); - if unsafe { - session.sendProviderMessage_returnError_responseHandler( - &message_data, - None, - Some(&response_handler), - ) - } { - spinlock_for_handler.fetch_add(1, Ordering::Release); - info!("Message sent to NETunnelProviderSession"); - } else { - error!("Failed to send to NETunnelProviderSession"); - } - } - // Final release - spinlock_for_handler.fetch_sub(1, Ordering::Release); + let manager = manager_for_key_and_value( + match connection_type { + ConnectionType::Location => "locationId", + ConnectionType::Tunnel => "tunnelId", }, - ); - unsafe { - NETunnelProviderManager::loadAllFromPreferencesWithCompletionHandler(&handler); + id, + )?; + + let vpn_protocol = (unsafe { manager.protocolConfiguration() })?; + let Ok(tunnel_protocol) = vpn_protocol.downcast::() else { + error!("Failed to downcast to NETunnelProviderProtocol"); + return None; + }; + + // Sometimes all managers from all apps come through, so filter by bundle ID. + if let Some(bundle_id) = unsafe { tunnel_protocol.providerBundleIdentifier() } { + if bundle_id != plugin_bundle_id { + return None; + } + } + + let Ok(session) = unsafe { manager.connection() }.downcast::() else { + error!("Failed to downcast to NETunnelProviderSession"); + return None; + }; + + let message_data = NSData::new(); + if unsafe { + session.sendProviderMessage_returnError_responseHandler( + &message_data, + None, + Some(&response_handler), + ) + } { + debug!("Message sent to NETunnelProviderSession"); + } else { + error!("Failed to send to NETunnelProviderSession while requesting stats"); } // Wait for all handlers to complete. - while spinlock.load(Ordering::Acquire) != 0 { + while !finished.load(Ordering::Acquire) { spin_loop(); } let stats = new_stats .lock() - .expect("Failed to acquire lock") - .drain(..) - .collect(); + .map_or(None, |mut new_stats_locked| new_stats_locked.take()); + stats } @@ -694,12 +1062,10 @@ impl Tunnel { debug!("Using all traffic routing for tunnel {self}"); vec![DEFAULT_ROUTE_IPV4.into(), DEFAULT_ROUTE_IPV6.into()] } else { - let msg = match &self.allowed_ips { - Some(ips) => { - format!("Using predefined location traffic for tunnel {self}: {ips}") - } - None => "No allowed IP addresses found in tunnel {self} configuration".to_string(), - }; + let msg = self.allowed_ips.as_ref().map_or_else( + || "No allowed IP addresses found in tunnel {self} configuration".to_string(), + |ips| format!("Using predefined location traffic for tunnel {self}: {ips}"), + ); debug!("{msg}"); self.allowed_ips .as_ref() diff --git a/src-tauri/src/appstate.rs b/src-tauri/src/appstate.rs index 578ef4f0..179b9a6b 100644 --- a/src-tauri/src/appstate.rs +++ b/src-tauri/src/appstate.rs @@ -6,10 +6,7 @@ use tokio_util::sync::CancellationToken; use crate::{ active_connections::ACTIVE_CONNECTIONS, app_config::AppConfig, - database::{ - models::{connection::ActiveConnection, Id}, - DB_POOL, - }, + database::models::{connection::ActiveConnection, Id}, enterprise::provisioning::ProvisioningConfig, utils::stats_handler, ConnectionType, @@ -25,7 +22,7 @@ pub struct AppState { impl AppState { #[must_use] pub fn new(config: AppConfig, provisioning_config: Option) -> Self { - AppState { + Self { log_watchers: Mutex::new(HashMap::new()), app_config: Mutex::new(config), stat_threads: Mutex::new(HashMap::new()), @@ -48,13 +45,17 @@ impl AppState { drop(connections); debug!("Spawning thread for network statistics for location ID {location_id}"); - let handle = spawn(stats_handler(DB_POOL.clone(), ifname, connection_type)); + #[cfg(target_os = "macos")] + let handle = spawn(stats_handler(location_id, connection_type)); + #[cfg(not(target_os = "macos"))] + let handle = spawn(stats_handler(ifname, connection_type)); let Some(old_handle) = self .stat_threads .lock() .unwrap() .insert(location_id, handle) else { + debug!("Added new network statistics thread for location ID {location_id}"); return; }; warn!("Something went wrong: old network statistics thread still exists"); diff --git a/src-tauri/src/bin/defguard-client.rs b/src-tauri/src/bin/defguard-client.rs index 20910a78..94e6c67d 100644 --- a/src-tauri/src/bin/defguard-client.rs +++ b/src-tauri/src/bin/defguard-client.rs @@ -83,6 +83,10 @@ async fn startup(app_handle: &AppHandle) { } #[cfg(target_os = "macos")] { + use defguard_client::{ + apple::get_managers_for_tunnels_and_locations, utils::get_all_tunnels_locations, + }; + let semaphore = Arc::new(AtomicBool::new(false)); let semaphore_clone = Arc::clone(&semaphore); @@ -93,6 +97,25 @@ async fn startup(app_handle: &AppHandle) { }); defguard_client::apple::spawn_runloop_and_wait_for(semaphore); let _ = handle.await; + + let (tunnels, locations) = get_all_tunnels_locations().await; + let handle = app_handle.clone(); + // Observer thread is blocking, so its better not to mess with the tauri runtime, + // hence std::thread::spawn. + std::thread::spawn(move || { + defguard_client::apple::observer_thread(get_managers_for_tunnels_and_locations( + &tunnels, &locations, + )); + error!("VPN observer thread has exited unexpectedly, quitting the app."); + handle.exit(0); + }); + + let handle = app_handle.clone(); + tauri::async_runtime::spawn(async move { + defguard_client::apple::connection_state_update_thread(&handle).await; + error!("Connection state update thread has exited unexpectedly, quitting the app."); + handle.exit(0); + }); } // Run periodic tasks. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 54c77bb6..018a77d5 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -312,8 +312,8 @@ pub async fn save_device_config( #[cfg(target_os = "macos")] async fn push_service_locations( - instance: &Instance, - keys: WireguardKeys, + _instance: &Instance, + _keys: WireguardKeys, ) -> Result>, Error> { // Nothing here... yet diff --git a/src-tauri/src/log_watcher/global_log_watcher.rs b/src-tauri/src/log_watcher/global_log_watcher.rs index 38ca7b41..8a7a1abc 100644 --- a/src-tauri/src/log_watcher/global_log_watcher.rs +++ b/src-tauri/src/log_watcher/global_log_watcher.rs @@ -2,36 +2,50 @@ //! // FIXME: Some of the code here overlaps with the `log_watcher` module and could be refactored to avoid duplication. +#[cfg(not(target_os = "macos"))] +use std::fs::read_dir; use std::{ - fs::{read_dir, File}, + fs::File, io::{BufRead, BufReader}, path::PathBuf, str::FromStr, - thread::sleep, time::Duration, }; -use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc}; +#[cfg(not(target_os = "macos"))] +use chrono::NaiveDate; +use chrono::{DateTime, NaiveDateTime, TimeZone, Utc}; use regex::Regex; use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager}; use tokio_util::sync::CancellationToken; use tracing::Level; +#[cfg(target_os = "macos")] +use crate::log_watcher::get_vpn_extension_log_path; use crate::{ appstate::AppState, error::Error, - log_watcher::{extract_timestamp, LogLine, LogLineFields, LogSource, LogWatcherError}, - utils::get_service_log_dir, + log_watcher::{LogLine, LogLineFields, LogSource, LogWatcherError}, }; +#[cfg(not(target_os = "macos"))] +use crate::{log_watcher::extract_timestamp, utils::get_service_log_dir}; + +#[cfg(target_os = "macos")] +const VPN_EXTENSION_LOG_FILENAME: &str = "vpn-extension.log"; /// Helper struct to handle log directory logic #[derive(Debug)] pub struct LogDirs { // Service + #[cfg(not(target_os = "macos"))] service_log_dir: PathBuf, + #[cfg(not(target_os = "macos"))] current_service_log_file: Option, // Client client_log_dir: PathBuf, + // VPN Extension (macOS only) + #[cfg(target_os = "macos")] + vpn_extension_log_dir: PathBuf, } const DELAY: Duration = Duration::from_secs(2); @@ -39,19 +53,37 @@ const DELAY: Duration = Duration::from_secs(2); impl LogDirs { pub fn new(handle: &AppHandle) -> Result { debug!("Getting log directories for service and client to watch."); + #[cfg(not(target_os = "macos"))] let service_log_dir = get_service_log_dir().to_path_buf(); let client_log_dir = handle.path().app_log_dir().map_err(|_| { LogWatcherError::LogPathError("Path to client logs directory is empty.".to_string()) })?; + + #[cfg(target_os = "macos")] + let vpn_extension_log_dir = get_vpn_extension_log_path()?; + + #[cfg(not(target_os = "macos"))] debug!( - "Log directories of service and client have been identified by the global log watcher: \ - {} and {}", service_log_dir.display(), client_log_dir.display() + "Log directories identified by global log watcher: service={}, client={}", + service_log_dir.display(), + client_log_dir.display() + ); + + #[cfg(target_os = "macos")] + debug!( + "Log directories identified by global log watcher: client={}, vpn_extension={}", + client_log_dir.display(), + vpn_extension_log_dir.display() ); Ok(Self { + #[cfg(not(target_os = "macos"))] service_log_dir, + #[cfg(not(target_os = "macos"))] current_service_log_file: None, client_log_dir, + #[cfg(target_os = "macos")] + vpn_extension_log_dir, }) } @@ -59,12 +91,14 @@ impl LogDirs { /// /// Log files are rotated daily and have a known naming format, /// with the last 10 characters specifying a date (e.g. `2023-12-15`). + #[cfg(not(target_os = "macos"))] fn get_latest_log_file(&self) -> Result, LogWatcherError> { - trace!( + debug!( "Getting latest log file from directory: {}", self.service_log_dir.display() ); let entries = read_dir(&self.service_log_dir)?; + debug!("Read entries from service log directory"); let mut latest_log = None; let mut latest_time = NaiveDate::MIN; @@ -81,9 +115,15 @@ impl LogDirs { } } + debug!( + "Latest log file determined: {:?}", + latest_log.as_ref().map(|p| p.display()) + ); + Ok(latest_log) } + #[cfg(not(target_os = "macos"))] fn get_current_service_file(&self) -> Result { trace!( "Opening service log file: {:?}", @@ -123,6 +163,17 @@ impl LogDirs { trace!("Client log file at {path:?} opened successfully"); Ok(file) } + + /// Get the VPN extension log file (macOS only) + /// The VPN extension writes logs to the App Group shared container + #[cfg(target_os = "macos")] + fn get_vpn_extension_file(&self) -> Result { + let path = self.vpn_extension_log_dir.join(VPN_EXTENSION_LOG_FILENAME); + trace!("Opening VPN extension log file: {}", path.display()); + let file = File::open(&path)?; + trace!("VPN extension log file opened successfully"); + Ok(file) + } } #[derive(Debug)] @@ -154,8 +205,9 @@ impl GlobalLogWatcher { } /// Start log watching, calls the [`parse_log_dirs`] function. - pub fn run(&mut self) -> Result<(), LogWatcherError> { - self.parse_log_dirs() + pub async fn run(&mut self) -> Result<(), LogWatcherError> { + debug!("Starting global log watcher run loop."); + self.parse_log_dirs().await } /// Parse the log files @@ -163,10 +215,11 @@ impl GlobalLogWatcher { /// This function will open the log files and read them line by line, parsing each line /// into a [`LogLine`] struct and emitting it to the frontend. It can be stopped by cancelling /// the token by calling [`stop_global_log_watcher_task()`] - fn parse_log_dirs(&mut self) -> Result<(), LogWatcherError> { - trace!("Processing log directories for service and client."); + #[cfg(not(target_os = "macos"))] + async fn parse_log_dirs(&mut self) -> Result<(), LogWatcherError> { + debug!("Processing log directories for service and client."); self.log_dirs.current_service_log_file = self.log_dirs.get_latest_log_file()?; - trace!( + debug!( "Latest service log file found: {:?}", self.log_dirs.current_service_log_file ); @@ -182,7 +235,7 @@ impl GlobalLogWatcher { None }; - trace!("Checking if log files are available"); + debug!("Checking if log files are available"); if service_reader.is_none() && client_reader.is_none() { warn!( "Couldn't read files at {:?} and {}, there will be no logs reported in the client.", @@ -190,10 +243,10 @@ impl GlobalLogWatcher { self.log_dirs.client_log_dir.display() ); // Wait for logs to appear. - sleep(DELAY); + tokio::time::sleep(DELAY).await; return Ok(()); } - trace!("Log files are available, starting to read lines."); + debug!("Log files are available, starting to read lines."); let mut service_line = String::new(); let mut client_line = String::new(); @@ -282,7 +335,122 @@ impl GlobalLogWatcher { parsed_lines.clear(); } trace!("Sleeping for {DELAY:?} seconds before reading again"); - sleep(DELAY); + tokio::time::sleep(DELAY).await; + } + + Ok(()) + } + + #[cfg(target_os = "macos")] + async fn parse_log_dirs(&self) -> Result<(), LogWatcherError> { + debug!("Processing log directories for client and VPN extension."); + let mut client_reader = self + .log_dirs + .get_client_file() + .map_or_else(|_| None, |file| Some(BufReader::new(file))); + + let mut vpn_extension_reader = self.log_dirs.get_vpn_extension_file().map_or_else( + |_| { + debug!("VPN extension log file not available yet"); + None + }, + |file| { + debug!("VPN extension log file opened successfully"); + Some(BufReader::new(file)) + }, + ); + + debug!("Checking if log files are available"); + if client_reader.is_none() && vpn_extension_reader.is_none() { + warn!( + "Couldn't read client logs at {} or VPN extension logs at {}, there will be no logs reported.", + self.log_dirs.client_log_dir.display(), + self.log_dirs.vpn_extension_log_dir.display() + ); + // Wait for logs to appear. + tokio::time::sleep(DELAY).await; + return Ok(()); + } + debug!("Log files are available, starting to read lines."); + + let mut client_line = String::new(); + let mut vpn_extension_line = String::new(); + let mut parsed_lines = Vec::new(); + + // Track the amount of bytes read from the log lines + let mut client_line_read; + let mut vpn_extension_line_read; + + debug!("Global log watcher is starting the loop for reading client and VPN extension log files"); + loop { + if self.cancellation_token.is_cancelled() { + debug!("Received cancellation request. Stopping global log watcher"); + break; + } + + // Client logs + // If the reader is present, read the log file to the end. + // Parse every line. + // Warning: don't use anything other than a trace log level in this loop for logs that would appear on every iteration (or very often) + // This could result in the reader constantly producing and consuming logs without any progress. + if let Some(reader) = &mut client_reader { + loop { + client_line_read = reader.read_line(&mut client_line)?; + if client_line_read > 0 { + if let Ok(Some(parsed_line)) = self.parse_client_log_line(&client_line) { + parsed_lines.push(parsed_line); + } else { + // Don't log it, as it will cause an endless loop + } + client_line.clear(); + } else { + break; + } + } + } else { + // Try to open the client log file if it wasn't available before + if let Ok(file) = self.log_dirs.get_client_file() { + debug!("Client log file is now available, opening reader"); + client_reader = Some(BufReader::new(file)); + } + } + + // VPN Extension logs + // Read the VPN extension log file written by the Swift network extension + if let Some(reader) = &mut vpn_extension_reader { + loop { + vpn_extension_line_read = reader.read_line(&mut vpn_extension_line)?; + if vpn_extension_line_read > 0 { + if let Ok(Some(parsed_line)) = + self.parse_vpn_extension_log_line(&vpn_extension_line) + { + parsed_lines.push(parsed_line); + } else { + // Don't log it, as it will cause an endless loop + } + vpn_extension_line.clear(); + } else { + break; + } + } + } else { + // Try to open the VPN extension log file if it wasn't available before + if let Ok(file) = self.log_dirs.get_vpn_extension_file() { + debug!("VPN extension log file is now available, opening reader"); + vpn_extension_reader = Some(BufReader::new(file)); + } + } + + trace!("Reached EOF in all log files."); + if !parsed_lines.is_empty() { + parsed_lines.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); + trace!("Emitting parsed lines for the frontend"); + self.handle.emit(&self.event_topic, &parsed_lines)?; + trace!("Emitted {} lines to the frontend", parsed_lines.len()); + parsed_lines.clear(); + } + trace!("Sleeping for {DELAY:?} seconds before reading again"); + tokio::time::sleep(DELAY).await; } Ok(()) @@ -292,6 +460,7 @@ impl GlobalLogWatcher { /// /// Deserializes the log line into a known struct. /// Also performs filtering by log level and optional timestamp. + #[cfg(not(target_os = "macos"))] fn parse_service_log_line(&self, line: &str) -> Option { let Ok(mut log_line) = serde_json::from_str::(line) else { warn!("Failed to parse service log line: {line}"); @@ -388,6 +557,91 @@ impl GlobalLogWatcher { Ok(Some(log_line)) } + + /// Parse a VPN extension log line into a known struct using regex. + #[cfg(target_os = "macos")] + fn parse_vpn_extension_log_line(&self, line: &str) -> Result, LogWatcherError> { + use crate::log_watcher::LOG_LINE_REGEX; + + let trimmed = line.trim(); + + if trimmed.is_empty() || trimmed.starts_with('#') { + return Ok(None); + } + + // Example log: + // 2024-01-15 14:32:45.123 [INFO] [Adapter] Tunnel started successfully + // Format: YYYY-MM-DD HH:mm:ss.SSS [LEVEL] [Category] Message + + let captures = LOG_LINE_REGEX + .captures(trimmed) + .ok_or(LogWatcherError::LogParseError(line.to_string()))?; + + let timestamp_str = captures + .get(1) + .ok_or(LogWatcherError::LogParseError(line.to_string()))? + .as_str(); + + // Parse timestamp as UTC (Swift FileLogger is configured to use UTC timezone) + let timestamp = Utc.from_utc_datetime( + &NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d %H:%M:%S%.3f").map_err( + |err| { + LogWatcherError::LogParseError(format!( + "Failed to parse VPN extension timestamp {timestamp_str} with error: {err}" + )) + }, + )?, + ); + + let level_str = captures + .get(2) + .ok_or(LogWatcherError::LogParseError(line.to_string()))? + .as_str(); + + // Map VPN extension log levels to tracing levels + // VPN extension uses: DEBUG, INFO, WARN, ERROR + let level = match level_str.to_uppercase().as_str() { + "DEBUG" => Level::DEBUG, + "WARN" => Level::WARN, + "ERROR" => Level::ERROR, + _ => Level::INFO, // Default to INFO for unknown (and INFO) levels + }; + + let category = captures + .get(3) + .ok_or(LogWatcherError::LogParseError(line.to_string()))? + .as_str(); + + let message = captures + .get(4) + .ok_or(LogWatcherError::LogParseError(line.to_string()))? + .as_str(); + + let fields = LogLineFields { + message: message.to_string(), + }; + + let log_line = LogLine { + timestamp, + level, + target: format!("VPNExtension::{category}"), + fields, + span: None, + source: Some(LogSource::VpnExtension), + }; + + if log_line.level > self.log_level { + return Ok(None); + } + + if let Some(from) = self.from { + if log_line.timestamp < from { + return Ok(None); + } + } + + Ok(Some(log_line)) + } } /// Starts a global log watcher in a separate thread @@ -415,7 +669,8 @@ pub async fn spawn_global_log_watcher_task( let _join_handle: JoinHandle> = tauri::async_runtime::spawn(async move { GlobalLogWatcher::new(handle_clone, token_clone, topic_clone, log_level, from)? - .run()?; + .run() + .await?; Ok(()) }); @@ -444,14 +699,17 @@ pub fn stop_global_log_watcher_task(handle: &AppHandle) -> Result<(), Error> { .lock() .expect("Failed to lock log watchers mutex"); - if let Some(token) = log_watchers.remove("GLOBAL") { - debug!("Using cancellation token for global log watcher"); - token.cancel(); - debug!("Global log watcher cancelled"); - Ok(()) - } else { - // Silently ignore if global log watcher is not found, as there is nothing to cancel - debug!("Global log watcher not found, nothing to cancel"); - Ok(()) - } + log_watchers.remove("GLOBAL").map_or_else( + || { + // Silently ignore if global log watcher is not found, as there is nothing to cancel + debug!("Global log watcher not found, nothing to cancel"); + Ok(()) + }, + |token| { + debug!("Using cancellation token for global log watcher"); + token.cancel(); + debug!("Global log watcher cancelled"); + Ok(()) + }, + ) } diff --git a/src-tauri/src/log_watcher/mod.rs b/src-tauri/src/log_watcher/mod.rs index c7e34c67..0b994a33 100644 --- a/src-tauri/src/log_watcher/mod.rs +++ b/src-tauri/src/log_watcher/mod.rs @@ -1,4 +1,9 @@ +#[cfg(target_os = "macos")] +use std::{path::PathBuf, sync::LazyLock}; + use chrono::{DateTime, NaiveDate, Utc}; +#[cfg(target_os = "macos")] +use regex::Regex; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, DisplayFromStr}; use thiserror::Error; @@ -25,8 +30,15 @@ pub enum LogWatcherError { #[derive(Debug, Deserialize, Serialize, Clone)] pub enum LogSource { + /// Service logs (Linux) or VPN Extension logs (macOS) + /// Serializes to "Vpn" for frontend compatibility + #[cfg(not(target_os = "macos"))] + #[serde(rename = "VPN")] Service, Client, + #[cfg(target_os = "macos")] + #[serde(rename = "VPN")] + VpnExtension, } /// Represents a single line in log file @@ -62,3 +74,28 @@ fn extract_timestamp(filename: &str) -> Option { // parse and convert to `NaiveDate` NaiveDate::parse_from_str(timestamp, "%Y-%m-%d").ok() } + +#[cfg(target_os = "macos")] +static LOG_LINE_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}) \[(\w+)\] \[(\w+)\] (.*)$").unwrap() +}); + +/// Get the VPN extension log file path on macOS +#[cfg(target_os = "macos")] +fn get_vpn_extension_log_path() -> Result { + use objc2_foundation::{ns_string, NSFileManager}; + + let manager = NSFileManager::defaultManager(); + manager + .containerURLForSecurityApplicationGroupIdentifier(ns_string!("group.net.defguard")) + .and_then(|url| url.to_file_path()) + .map_or_else( + || { + error!("Failed to get container URL for VPN extension logs"); + Err(LogWatcherError::LogPathError( + "Failed to get container URL for VPN extension logs".to_string(), + )) + }, + |url| Ok(url.join("Logs/")), + ) +} diff --git a/src-tauri/src/log_watcher/service_log_watcher.rs b/src-tauri/src/log_watcher/service_log_watcher.rs index 94bd9a57..1f1936ff 100644 --- a/src-tauri/src/log_watcher/service_log_watcher.rs +++ b/src-tauri/src/log_watcher/service_log_watcher.rs @@ -3,6 +3,9 @@ //! This is meant to handle passing relevant logs from `defguard-service` daemon to the client GUI. //! The watcher monitors a given directory for any changes. Whenever a change is detected //! it parses the log files and sends logs relevant to a specified interface to the fronted. +//! +//! On macOS, this module also provides a VPN extension log watcher that reads from the +//! App Group shared container where the Swift network extension writes its logs. use std::{ fs::{read_dir, File}, @@ -14,18 +17,20 @@ use std::{ }; use chrono::{DateTime, NaiveDate, Utc}; +#[cfg(target_os = "macos")] +use chrono::{NaiveDateTime, TimeZone}; use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager}; use tokio_util::sync::CancellationToken; use tracing::Level; +#[cfg(target_os = "macos")] +use super::LogLineFields; use super::{LogLine, LogWatcherError}; +#[cfg(not(target_os = "macos"))] +use crate::utils::get_service_log_dir; use crate::{ - appstate::AppState, - database::models::Id, - error::Error, - log_watcher::extract_timestamp, - utils::{get_service_log_dir, get_tunnel_or_location_name}, - ConnectionType, + appstate::AppState, database::models::Id, error::Error, log_watcher::extract_timestamp, + utils::get_tunnel_or_location_name, ConnectionType, }; const DELAY: Duration = Duration::from_secs(2); @@ -206,12 +211,195 @@ impl<'a> ServiceLogWatcher<'a> { } } +/// macOS-specific log watcher for VPN extension logs +/// +/// On macOS, the VPN functionality is handled by a Network Extension which writes +/// its logs to an App Group shared container. This watcher reads those logs. +#[cfg(target_os = "macos")] +#[derive(Debug)] +pub struct VpnExtensionLogWatcher { + log_level: Level, + from: Option>, + log_file: PathBuf, + handle: AppHandle, + cancellation_token: CancellationToken, + event_topic: String, +} + +#[cfg(target_os = "macos")] +impl VpnExtensionLogWatcher { + #[must_use] + pub fn new( + handle: AppHandle, + cancellation_token: CancellationToken, + event_topic: String, + log_level: Level, + from: Option>, + log_file: PathBuf, + ) -> Self { + Self { + log_level, + from, + log_file, + handle, + cancellation_token, + event_topic, + } + } + + /// Run the VPN extension log watcher + pub fn run(&mut self) -> Result<(), LogWatcherError> { + debug!( + "Starting VPN extension log watcher, reading from: {}", + self.log_file.display() + ); + + // Wait for the log file to exist + while !self.log_file.exists() { + if self.cancellation_token.is_cancelled() { + return Ok(()); + } + debug!( + "VPN extension log file not found at {}, waiting...", + self.log_file.display() + ); + sleep(DELAY); + } + + let file = File::open(&self.log_file)?; + let mut reader = BufReader::new(file); + let mut line = String::new(); + let mut parsed_lines = Vec::new(); + + loop { + if self.cancellation_token.is_cancelled() { + info!("VPN extension log watcher is being stopped."); + break; + } + + let size = reader.read_line(&mut line)?; + if size == 0 { + // EOF reached, emit collected logs and wait + if !parsed_lines.is_empty() { + trace!( + "Emitting {} VPN extension log lines for the frontend", + parsed_lines.len() + ); + self.handle.emit(&self.event_topic, &parsed_lines)?; + parsed_lines.clear(); + } + sleep(DELAY); + } else { + match self.parse_log_line(&line) { + Ok(Some(parsed_line)) => { + parsed_lines.push(parsed_line); + } + Ok(None) => { + // Line was filtered out + } + Err(e) => { + trace!("Failed to parse VPN extension log line: {e}"); + } + } + line.clear(); + } + } + + Ok(()) + } + + /// Parse a VPN extension log line + /// + /// Log format: `2024-01-15 14:32:45.123 [INFO] [Adapter] Message here` + fn parse_log_line(&self, line: &str) -> Result, LogWatcherError> { + use crate::log_watcher::LOG_LINE_REGEX; + + let trimmed = line.trim(); + + // Skip empty lines and separator/header lines + if trimmed.is_empty() || trimmed.starts_with('#') { + return Ok(None); + } + + let captures = LOG_LINE_REGEX + .captures(trimmed) + .ok_or(LogWatcherError::LogParseError(line.to_string()))?; + + let timestamp_str = captures + .get(1) + .ok_or(LogWatcherError::LogParseError(line.to_string()))? + .as_str(); + + // Parse timestamp as UTC (Swift FileLogger is configured to use UTC timezone) + let timestamp = Utc.from_utc_datetime( + &NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d %H:%M:%S%.3f").map_err( + |err| { + LogWatcherError::LogParseError(format!( + "Failed to parse VPN extension timestamp {timestamp_str} with error: {err}" + )) + }, + )?, + ); + + let level_str = captures + .get(2) + .ok_or(LogWatcherError::LogParseError(line.to_string()))? + .as_str(); + + let level = match level_str.to_uppercase().as_str() { + "DEBUG" => Level::DEBUG, + "WARN" => Level::WARN, + "ERROR" => Level::ERROR, + _ => Level::INFO, + }; + + let category = captures + .get(3) + .ok_or(LogWatcherError::LogParseError(line.to_string()))? + .as_str(); + + let message = captures + .get(4) + .ok_or(LogWatcherError::LogParseError(line.to_string()))? + .as_str(); + + // Filter by log level + if level > self.log_level { + return Ok(None); + } + + // Filter by timestamp + if let Some(from) = self.from { + if timestamp < from { + return Ok(None); + } + } + + let fields = LogLineFields { + message: message.to_string(), + }; + + Ok(Some(LogLine { + timestamp, + level, + target: format!("VPNExtension::{category}"), + fields, + span: None, + source: None, + })) + } +} + /// Starts a log watcher in a separate thread /// /// The watcher parses `defguard-service` log files and extracts logs relevant /// to the WireGuard interface for a given location. /// Logs are then transmitted to the frontend by using `tauri` `Events`. /// Returned value is the name of an event topic to monitor. +/// +/// On macOS, this uses the VPN extension log watcher instead, reading from +/// the App Group shared container where the Swift network extension writes logs. +#[cfg(not(target_os = "macos"))] pub async fn spawn_log_watcher_task( handle: AppHandle, location_id: Id, @@ -287,6 +475,86 @@ pub async fn spawn_log_watcher_task( Ok(event_topic) } +/// macOS version: Starts a VPN extension log watcher in a separate thread +/// +/// On macOS, the VPN functionality is handled by a Network Extension which writes +/// its logs to an App Group shared container. This function spawns a watcher for those logs. +/// +/// TODO: Currently the "service log watcher" should watch only given interface, this is not yet implemented for VPN extension logs. +#[cfg(target_os = "macos")] +pub async fn spawn_log_watcher_task( + handle: AppHandle, + location_id: Id, + interface_name: String, + connection_type: ConnectionType, + log_level: Level, + from: Option, +) -> Result { + use crate::log_watcher::get_vpn_extension_log_path; + + debug!( + "Spawning VPN extension log watcher task for location ID {location_id}, interface {interface_name}" + ); + let app_state = handle.state::(); + + let from = from.and_then(|from| DateTime::::from_str(&from).ok()); + + let connection_type_str = if connection_type.eq(&ConnectionType::Tunnel) { + "Tunnel" + } else { + "Location" + }; + let event_topic = format!("log-update-{connection_type_str}-{location_id}"); + debug!("Using the following event topic for the VPN extension log watcher: {event_topic}"); + + let log_file = get_vpn_extension_log_path().map_err(|e| Error::InternalError(e.to_string()))?; + debug!("VPN extension log file path: {}", log_file.display()); + + let topic_clone = event_topic.clone(); + let handle_clone = handle.clone(); + + let token = CancellationToken::new(); + let token_clone = token.clone(); + + let mut log_watcher = VpnExtensionLogWatcher::new( + handle_clone, + token_clone, + topic_clone, + log_level, + from, + log_file, + ); + + // spawn task + let _join_handle: JoinHandle> = + tauri::async_runtime::spawn(async move { + log_watcher.run()?; + Ok(()) + }); + + // store `CancellationToken` to manually stop watcher thread + // keep this in a block as we .await later, which should not be done while holding a lock like this + { + let mut log_watchers = app_state + .log_watchers + .lock() + .expect("Failed to lock log watchers mutex"); + if let Some(old_token) = log_watchers.insert(interface_name.clone(), token) { + // cancel previous log watcher for this interface + debug!("Existing log watcher for interface {interface_name} found. Cancelling..."); + old_token.cancel(); + } + } + + let name = get_tunnel_or_location_name(location_id, connection_type).await; + info!( + "A background task has been spawned to watch the VPN extension log file for \ + {connection_type} {name} (interface {interface_name}), location's specific collected logs \ + will be displayed in the {connection_type}'s detailed view." + ); + Ok(event_topic) +} + /// Stops the log watcher thread pub fn stop_log_watcher_task(handle: &AppHandle, interface_name: &str) -> Result<(), Error> { debug!("Stopping service log watcher task for interface {interface_name}"); diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index c2320e11..c9fcd58b 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -133,8 +133,6 @@ pub(crate) async fn setup_interface( mtu: Option, pool: &DbPool, ) -> Result { - debug!("Setting up interface for location: {location}"); - let tunnel_config = location .tunnel_configurarion(pool, preshared_key, mtu) .await?; @@ -148,23 +146,22 @@ pub(crate) async fn setup_interface( } #[cfg(target_os = "macos")] -pub(crate) async fn stats_handler( - pool: DbPool, - _interface_name: String, - _connection_type: ConnectionType, -) { +pub(crate) async fn stats_handler(id: Id, connection_type: ConnectionType) { + debug!("Starting stats handler for ID {id} and connection type {connection_type:?}"); use crate::database::models::{location_stats::LocationStats, tunnel::TunnelStats}; const CHECK_INTERVAL: Duration = Duration::from_secs(10); let mut interval = tokio::time::interval(CHECK_INTERVAL); + let pool = DB_POOL.clone(); loop { + debug!("Waiting for the next stats collection interval for ID {id} and connection type {connection_type:?}"); interval.tick().await; - let mut all_stats = tunnel_stats(); - if all_stats.is_empty() { + let stats = tunnel_stats(id, &connection_type); + let Some(stats) = stats else { continue; - } + }; let mut transaction = match pool.begin().await { Ok(transactions) => transactions, @@ -176,48 +173,39 @@ pub(crate) async fn stats_handler( } }; - for stats in all_stats.drain(..) { - if let Some(location_id) = stats.location_id { - let location_stats = LocationStats::new( - location_id, - stats.tx_bytes as i64, - stats.rx_bytes as i64, - stats.last_handshake as i64, - 0, - None, - ); - match location_stats.save(&mut *transaction).await { - Ok(_) => { - debug!("Saved network usage stats for location ID {location_id}"); - } - Err(err) => { - error!( - "Failed to save network usage stats for location ID {location_id}: \ - {err}" - ); - } + if connection_type == ConnectionType::Location { + let location_stats = LocationStats::new( + id, + stats.tx_bytes as i64, + stats.rx_bytes as i64, + stats.last_handshake as i64, + 0, + None, + ); + match location_stats.save(&mut *transaction).await { + Ok(_) => { + debug!("Saved network usage stats for location ID {id}"); + } + Err(err) => { + error!("Failed to save network usage stats for location ID {id}: {err}"); } } - if let Some(tunnel_id) = stats.tunnel_id { - let tunnel_stats = TunnelStats::new( - tunnel_id, - stats.tx_bytes as i64, - stats.rx_bytes as i64, - stats.last_handshake as i64, - chrono::Utc::now().naive_utc(), - 0, - 0, - ); - match tunnel_stats.save(&mut *transaction).await { - Ok(_) => { - debug!("Saved network usage stats for tunnel ID {tunnel_id}"); - } - Err(err) => { - error!( - "Failed to save network usage stats for tunnel ID {tunnel_id}: \ - {err}" - ); - } + } else { + let tunnel_stats = TunnelStats::new( + id, + stats.tx_bytes as i64, + stats.rx_bytes as i64, + stats.last_handshake as i64, + chrono::Utc::now().naive_utc(), + 0, + 0, + ); + match tunnel_stats.save(&mut *transaction).await { + Ok(_) => { + debug!("Saved network usage stats for tunnel ID {id}"); + } + Err(err) => { + error!("Failed to save network usage stats for tunnel ID {id}: {err}"); } } } @@ -229,11 +217,8 @@ pub(crate) async fn stats_handler( } #[cfg(not(target_os = "macos"))] -pub(crate) async fn stats_handler( - pool: DbPool, - interface_name: String, - connection_type: ConnectionType, -) { +pub(crate) async fn stats_handler(interface_name: String, connection_type: ConnectionType) { + let pool = DB_POOL.clone(); let request = ReadInterfaceDataRequest { interface_name: interface_name.clone(), }; @@ -1113,3 +1098,12 @@ pub(crate) fn construct_platform_header() -> String { BASE64_STANDARD.encode(buffer) } + +#[must_use] +/// Utility function to get all tunnels and locations from the database. +#[cfg(target_os = "macos")] +pub async fn get_all_tunnels_locations() -> (Vec>, Vec>) { + let tunnels = Tunnel::all(&*DB_POOL).await.unwrap_or_default(); + let locations = Location::all(&*DB_POOL, false).await.unwrap_or_default(); + (tunnels, locations) +} diff --git a/src/i18n/en/index.ts b/src/i18n/en/index.ts index 0fc79abe..20b3c0e7 100644 --- a/src/i18n/en/index.ts +++ b/src/i18n/en/index.ts @@ -189,11 +189,11 @@ If you are an admin/devops - all your customers (instances) and all their tunnel globalLogs: { logSources: { client: 'Client', - service: 'Service', + vpn: 'VPN', all: 'All', }, logSourceHelper: - 'The source of the logs. Logs can come from the Defguard client or the background Defguard service that manages VPN conncetions at the network level.', + 'The source of the logs. Logs can come from the Defguard client or the VPN service/extension that manages VPN connections at the network level.', }, theme: { title: 'Theme', diff --git a/src/i18n/fr/index.ts b/src/i18n/fr/index.ts index 1c4e24d3..7b7aa7af 100644 --- a/src/i18n/fr/index.ts +++ b/src/i18n/fr/index.ts @@ -175,11 +175,11 @@ Si vous êtes un administrateur/devops - tous vos clients (instances) et tous le globalLogs: { logSources: { client: 'Client', - service: 'Service', + vpn: 'VPN', all: 'Tous', }, logSourceHelper: - 'La source des journaux. Les journaux peuvent provenir du client Defguard ou du service Defguard en arrière-plan qui gère les connexions VPN au niveau du réseau.', + 'La source des journaux. Les journaux peuvent provenir du client Defguard ou du service/extension VPN qui gère les connexions VPN au niveau du réseau.', }, theme: { title: 'Thème', diff --git a/src/i18n/i18n-types.ts b/src/i18n/i18n-types.ts index 64140060..a88a420e 100644 --- a/src/i18n/i18n-types.ts +++ b/src/i18n/i18n-types.ts @@ -449,16 +449,16 @@ type RootTranslation = { */ client: string /** - * S​e​r​v​i​c​e + * V​P​N */ - service: string + vpn: string /** * A​l​l */ all: string } /** - * T​h​e​ ​s​o​u​r​c​e​ ​o​f​ ​t​h​e​ ​l​o​g​s​.​ ​L​o​g​s​ ​c​a​n​ ​c​o​m​e​ ​f​r​o​m​ ​t​h​e​ ​D​e​f​g​u​a​r​d​ ​c​l​i​e​n​t​ ​o​r​ ​t​h​e​ ​b​a​c​k​g​r​o​u​n​d​ ​D​e​f​g​u​a​r​d​ ​s​e​r​v​i​c​e​ ​t​h​a​t​ ​m​a​n​a​g​e​s​ ​V​P​N​ ​c​o​n​n​c​e​t​i​o​n​s​ ​a​t​ ​t​h​e​ ​n​e​t​w​o​r​k​ ​l​e​v​e​l​. + * T​h​e​ ​s​o​u​r​c​e​ ​o​f​ ​t​h​e​ ​l​o​g​s​.​ ​L​o​g​s​ ​c​a​n​ ​c​o​m​e​ ​f​r​o​m​ ​t​h​e​ ​D​e​f​g​u​a​r​d​ ​c​l​i​e​n​t​ ​o​r​ ​t​h​e​ ​V​P​N​ ​s​e​r​v​i​c​e​/​e​x​t​e​n​s​i​o​n​ ​t​h​a​t​ ​m​a​n​a​g​e​s​ ​V​P​N​ ​c​o​n​n​e​c​t​i​o​n​s​ ​a​t​ ​t​h​e​ ​n​e​t​w​o​r​k​ ​l​e​v​e​l​. */ logSourceHelper: string } @@ -2153,16 +2153,16 @@ export type TranslationFunctions = { */ client: () => LocalizedString /** - * Service + * VPN */ - service: () => LocalizedString + vpn: () => LocalizedString /** * All */ all: () => LocalizedString } /** - * The source of the logs. Logs can come from the Defguard client or the background Defguard service that manages VPN conncetions at the network level. + * The source of the logs. Logs can come from the Defguard client or the VPN service/extension that manages VPN connections at the network level. */ logSourceHelper: () => LocalizedString } diff --git a/src/pages/client/clientAPI/types.ts b/src/pages/client/clientAPI/types.ts index b4eeb624..0b970e95 100644 --- a/src/pages/client/clientAPI/types.ts +++ b/src/pages/client/clientAPI/types.ts @@ -52,7 +52,7 @@ export type LogLevel = 'ERROR' | 'INFO' | 'DEBUG' | 'TRACE' | 'WARN'; export const availableLogLevels: LogLevel[] = ['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE']; export type GlobalLogLevel = 'ERROR' | 'INFO' | 'DEBUG'; -export type LogSource = 'Client' | 'Service' | 'All'; +export type LogSource = 'Client' | 'VPN' | 'All'; export type ClientView = 'grid' | 'detail'; diff --git a/src/pages/client/pages/ClientSettingsPage/components/GlobalLogs/GlobalLogsSourceSelect.tsx b/src/pages/client/pages/ClientSettingsPage/components/GlobalLogs/GlobalLogsSourceSelect.tsx index 04234b07..b962b115 100644 --- a/src/pages/client/pages/ClientSettingsPage/components/GlobalLogs/GlobalLogsSourceSelect.tsx +++ b/src/pages/client/pages/ClientSettingsPage/components/GlobalLogs/GlobalLogsSourceSelect.tsx @@ -33,8 +33,8 @@ export const GlobalLogsSourceSelect = ({ initSelected, onChange }: Props) => { }, { key: 2, - label: localLL.service(), - value: 'Service', + label: localLL.vpn(), + value: 'VPN', }, ]; }, [localLL]); diff --git a/swift/extension/VPNExtension/Adapter.swift b/swift/extension/VPNExtension/Adapter.swift index 0899bed9..51d15257 100644 --- a/swift/extension/VPNExtension/Adapter.swift +++ b/swift/extension/VPNExtension/Adapter.swift @@ -1,7 +1,6 @@ import Foundation import Network import NetworkExtension -import os /// State of Adapter. enum State { @@ -26,8 +25,8 @@ enum State { private var networkMonitor: NWPathMonitor? /// Keep alive timer private var keepAliveTimer: Timer? - /// Logging - private lazy var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Adapter") + /// Unified logger (writes to both system log and file) + private let log = Log(category: "Adapter") /// Adapter state. private var state: State = .stopped @@ -49,13 +48,13 @@ enum State { func start(tunnelConfiguration: TunnelConfiguration) throws { guard case .stopped = self.state else { - logger.error("Invalid state") + log.error("Invalid state - cannot start tunnel") // TODO: throw invalid state return } if tunnel != nil { - logger.info("Cleaning exiting Tunnel") + log.info("Cleaning existing Tunnel") tunnel = nil connection = nil } @@ -67,7 +66,7 @@ enum State { networkMonitor.start(queue: .main) self.networkMonitor = networkMonitor - logger.info("Initializing Tunnel") + log.info("Initializing Tunnel") tunnel = try Tunnel.init( privateKey: tunnelConfiguration.privateKey, serverPublicKey: tunnelConfiguration.peers[0].publicKey, @@ -78,22 +77,25 @@ enum State { locationId = tunnelConfiguration.locationId tunnelId = tunnelConfiguration.tunnelId - logger.info("Connecting to endpoint") + log.info( + "Connecting to endpoint (locationId: \(tunnelConfiguration.locationId ?? 0), tunnelId: \(tunnelConfiguration.tunnelId ?? 0))" + ) guard let endpoint = tunnelConfiguration.peers[0].endpoint else { - logger.error("Endpoint is nil") + log.error("Endpoint is nil, cannot connect") return } self.endpoint = endpoint.asNWEndpoint() initEndpoint() - logger.info("Sniffing packets") + log.info("Starting to sniff packets") readPackets() state = .running + log.info("Tunnel started successfully") } func stop() { - logger.info("Stopping Adapter") + log.info("Stopping Adapter") connection?.cancel() connection = nil tunnel = nil @@ -104,7 +106,8 @@ enum State { networkMonitor = nil state = .stopped - logger.info("Tunnel stopped") + log.info("Tunnel stopped") + log.flush() } // Obtain tunnel statistics. @@ -127,10 +130,10 @@ enum State { // Nothing to do. break case .err(let error): - logger.error("Tunnel error \(error, privacy: .public)") + log.error("Tunnel error: \(error)") switch error { case .InvalidAeadTag: - logger.error("Invalid pre-shared key; stopping tunnel") + log.error("Invalid pre-shared key; stopping tunnel") // The correct way is to call the packet tunnel provider, if there is one. if let provider = packetTunnelProvider { provider.cancelTunnelWithError(error) @@ -138,7 +141,7 @@ enum State { stop() } case .ConnectionExpired: - logger.error("Connecion has expired; re-connecting") + log.warning("Connection has expired; re-connecting") packetTunnelProvider?.reasserting = true initEndpoint() packetTunnelProvider?.reasserting = false @@ -162,7 +165,7 @@ enum State { private func initEndpoint() { guard let endpoint = endpoint else { return } - logger.info("Init Endpoint") + log.info("Initializing endpoint connection to: \(endpoint)") // Cancel previous connection connection?.cancel() connection = nil @@ -180,21 +183,20 @@ enum State { /// Setup UDP connection to endpoint. This method should be called when UDP connection is ready to send and receive. private func setupEndpoint() { - logger.info("Setup endpoint") + log.info("Setting up endpoint") // Send initial handshake packet if let tunnel = self.tunnel { + log.info("Sending initial handshake") handleTunnelResult(tunnel.forceHandshake()) } - logger.info("Receiving UDP from endpoint") - logger.debug( - "NWConnection path \(String(describing: self.connection?.currentPath), privacy: .public)" - ) + log.info("Starting UDP receive loop") + log.debug("NWConnection path: \(String(describing: self.connection?.currentPath))") receive() // Use Timer to send keep-alive packets. keepAliveTimer?.invalidate() - logger.info("Creating keep-alive timer") + log.info("Creating keep-alive timer") let timer = Timer(timeInterval: 0.25, repeats: true) { [weak self] timer in guard let self = self, let tunnel = self.tunnel else { return } self.handleTunnelResult(tunnel.tick()) @@ -209,13 +211,13 @@ enum State { if connection.state == .ready { connection.send( content: data, - completion: .contentProcessed { error in + completion: .contentProcessed { [weak self] error in if let error = error { - self.logger.error("UDP connection send error: \(error, privacy: .public)") + self?.log.error("UDP connection send error: \(error)") } }) } else { - logger.warning("UDP connection not ready to send") + log.warning("UDP connection not ready to send") } } @@ -230,7 +232,7 @@ enum State { // continue receiving self.receive() } else { - logger.error("receive() error: \(error)") + self.log.error("receive() error: \(String(describing: error))") } } } @@ -251,7 +253,7 @@ enum State { /// Handle UDP connection state changes. private func endpointStateChange(state: NWConnection.State) { - logger.debug("UDP connection state: \(String(describing: state), privacy: .public)") + log.debug("UDP connection state changed: \(state)") switch state { case .ready: setupEndpoint() @@ -263,7 +265,7 @@ enum State { // self.stop() // } case .failed(let error): - logger.error("Failed to establish endpoint connection: \(error)") + log.error("Failed to establish endpoint connection: \(error)") // The correct way is to call the packet tunnel provider, if there is one. if let provider = packetTunnelProvider { provider.cancelTunnelWithError(error) @@ -277,20 +279,18 @@ enum State { /// Handle network path updates. private func networkPathUpdate(path: Network.NWPath) { - logger - .debug( - "Network path status \(String(describing: path.status), privacy: .public); interfaces \(path.availableInterfaces, privacy: .public)" - ) + log.debug( + "Network path update - status: \(path.status), interfaces: \(path.availableInterfaces)") if path.status == .unsatisfied { if state == .running { - logger.warning("Unsatisfied network path: going dormant") + log.warning("Unsatisfied network path: going dormant") connection?.cancel() connection = nil state = .dormant } } else { if state == .dormant { - logger.warning("Satisfied network path: going running") + log.warning("Satisfied network path: going running") initEndpoint() state = .running } diff --git a/swift/extension/VPNExtension/FileLogger.swift b/swift/extension/VPNExtension/FileLogger.swift new file mode 100644 index 00000000..556fba70 --- /dev/null +++ b/swift/extension/VPNExtension/FileLogger.swift @@ -0,0 +1,237 @@ +import Foundation +import os + +/// Log levels +enum LogLevel: String { + case debug = "DEBUG" + case info = "INFO" + case warning = "WARN" + case error = "ERROR" + + var osLogType: OSLogType { + switch self { + case .debug: return .debug + case .info: return .info + case .warning: return .default + case .error: return .error + } + } +} + +/// Logger that writes to both system log (os.Logger) and file. +/// Use this instead of os.Logger directly to get dual logging with a single call. +final class Log { + /// The category for this logger instance (usually class name), e.g. "PacketTunnelProvider" + let category: String + private let systemLogger: Logger + private let fileLogger = FileLogger.shared + + init(category: String) { + self.category = category + self.systemLogger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "net.defguard.VPNExtension", + category: category + ) + } + + func debug(_ message: String) { + systemLogger.debug("\(message, privacy: .public)") + fileLogger.log(level: .debug, message: message, category: category) + } + + func info(_ message: String) { + systemLogger.info("\(message, privacy: .public)") + fileLogger.log(level: .info, message: message, category: category) + } + + func warning(_ message: String) { + systemLogger.warning("\(message, privacy: .public)") + fileLogger.log(level: .warning, message: message, category: category) + } + + func error(_ message: String) { + systemLogger.error("\(message, privacy: .public)") + fileLogger.log(level: .error, message: message, category: category) + } + + func flush() { + fileLogger.flush() + } +} + +/// A file-based logger that writes to an App Group shared container. +/// This allows the main rust app to read logs from the network extension. +/// Use the `Log` class instead of this directly for unified logging. +final class FileLogger { + static let shared = FileLogger() + static let appGroupIdentifier = "group.net.defguard" + private let logFileName = "vpn-extension.log" + private let maxLogFileSize: UInt64 = 5 * 1024 * 1024 // 5 MB + private let maxBackupFiles = 3 + private let flushInterval = 5 // Flush every N log entries + private var fileHandle: FileHandle? + private var logFileURL: URL? + private var unflushedCount = 0 + + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter + }() + + private let queue = DispatchQueue(label: "net.defguard.VPNExtension.filelogger") + + private let internalLogger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "net.defguard.VPNExtension", + category: "FileLogger") + + private init() { + setupLogFile() + } + + deinit { + closeLogFile() + } + + private func setupLogFile() { + guard + let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: Self.appGroupIdentifier) + else { + internalLogger.error( + "Failed to get App Group container URL for \(Self.appGroupIdentifier)") + return + } + + let logsDirectory = containerURL.appendingPathComponent("Logs", isDirectory: true) + + do { + try FileManager.default.createDirectory( + at: logsDirectory, withIntermediateDirectories: true, attributes: nil) + } catch { + internalLogger.error("Failed to create Logs directory: \(error.localizedDescription)") + return + } + + logFileURL = logsDirectory.appendingPathComponent(logFileName) + + guard let logFileURL = logFileURL else { return } + + if !FileManager.default.fileExists(atPath: logFileURL.path) { + FileManager.default.createFile(atPath: logFileURL.path, contents: nil, attributes: nil) + } + + do { + fileHandle = try FileHandle(forWritingTo: logFileURL) + fileHandle?.seekToEndOfFile() + + let startupMessage = + "# VPN Extension Log Started at \(dateFormatter.string(from: Date()))\n" + if let data = startupMessage.data(using: .utf8) { + fileHandle?.write(data) + } + } catch { + internalLogger.error( + "Failed to open log file for writing: \(error.localizedDescription)") + } + + internalLogger.info("FileLogger initialized at: \(logFileURL.path)") + } + + private func closeLogFile() { + queue.sync { + try? fileHandle?.synchronize() + try? fileHandle?.close() + fileHandle = nil + } + } + + /// Rotate log files if the current one exceeds the maximum size + private func rotateLogFilesIfNeeded() { + guard let logFileURL = logFileURL else { return } + + do { + let attributes = try FileManager.default.attributesOfItem(atPath: logFileURL.path) + if let fileSize = attributes[.size] as? UInt64, fileSize >= maxLogFileSize { + rotateLogFiles() + } + } catch { + } + } + + private func rotateLogFiles() { + guard let logFileURL = logFileURL else { return } + + try? fileHandle?.synchronize() + try? fileHandle?.close() + fileHandle = nil + + let fileManager = FileManager.default + let directory = logFileURL.deletingLastPathComponent() + let baseName = logFileURL.deletingPathExtension().lastPathComponent + let ext = logFileURL.pathExtension + + // Remove oldest backup if it exists + let oldestBackup = directory.appendingPathComponent("\(baseName).\(maxBackupFiles).\(ext)") + try? fileManager.removeItem(at: oldestBackup) + + for i in stride(from: maxBackupFiles - 1, through: 1, by: -1) { + let current = directory.appendingPathComponent("\(baseName).\(i).\(ext)") + let next = directory.appendingPathComponent("\(baseName).\(i + 1).\(ext)") + try? fileManager.moveItem(at: current, to: next) + } + + let firstBackup = directory.appendingPathComponent("\(baseName).1.\(ext)") + try? fileManager.moveItem(at: logFileURL, to: firstBackup) + + fileManager.createFile(atPath: logFileURL.path, contents: nil, attributes: nil) + + do { + fileHandle = try FileHandle(forWritingTo: logFileURL) + fileHandle?.seekToEndOfFile() + } catch { + internalLogger.error( + "Failed to reopen log file after rotation: \(error.localizedDescription)") + } + } + + /// Write a log message to the file + /// - level: Log level (debug, info, warning, error) + /// - message: The message to log + /// - category: Optional category/subsystem + func log(level: LogLevel, message: String, category: String? = nil) { + queue.async { [weak self] in + guard let self = self, let fileHandle = self.fileHandle else { return } + + self.rotateLogFilesIfNeeded() + + let timestamp = self.dateFormatter.string(from: Date()) + let categoryStr = category.map { "[\($0)] " } ?? "" + let logLine = "\(timestamp) [\(level.rawValue)] \(categoryStr)\(message)\n" + + if let data = logLine.data(using: .utf8) { + fileHandle.write(data) + self.unflushedCount += 1 + + // Flush for important messages or periodically + if level == .error || level == .warning || self.unflushedCount >= self.flushInterval + { + try? fileHandle.synchronize() + self.unflushedCount = 0 + } + } + } + } + + func flush() { + queue.sync { + try? fileHandle?.synchronize() + } + } + + var logFilePath: String? { + return logFileURL?.path + } +} diff --git a/swift/extension/VPNExtension/PacketTunnelProvider.swift b/swift/extension/VPNExtension/PacketTunnelProvider.swift index 55c1d18f..a82ad116 100644 --- a/swift/extension/VPNExtension/PacketTunnelProvider.swift +++ b/swift/extension/VPNExtension/PacketTunnelProvider.swift @@ -1,36 +1,40 @@ import NetworkExtension -import os enum WireGuardTunnelError: Error { case invalidTunnelConfiguration } class PacketTunnelProvider: NEPacketTunnelProvider { - /// Logging - private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PacketTunnelProvider") + /// Unified logger (writes to both system log and file) + private let log = Log(category: "PacketTunnelProvider") private lazy var adapter: Adapter = { return Adapter(with: self) }() - override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { - logger.debug("\(#function)") + override func startTunnel( + options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void + ) { + log.info("\(#function) called") if let options = options { - logger.log("Options: \(options)") + log.debug("Options: \(options)") } guard let protocolConfig = self.protocolConfiguration as? NETunnelProviderProtocol, - let providerConfig = protocolConfig.providerConfiguration, - let tunnelConfig = try? TunnelConfiguration.from(dictionary: providerConfig) else { - self.logger.error("Failed to parse tunnel configuration") + let providerConfig = protocolConfig.providerConfiguration, + let tunnelConfig = try? TunnelConfiguration.from(dictionary: providerConfig) + else { + log.error("Failed to parse tunnel configuration") completionHandler(WireGuardTunnelError.invalidTunnelConfiguration) return } + log.info("Tunnel configuration parsed successfully") + let networkSettings = tunnelConfig.asNetworkSettings() self.setTunnelNetworkSettings(networkSettings) { error in if error != nil { - self.logger.error("Set tunnel network settings returned \(error)") + self.log.error("Set tunnel network settings error: \(String(describing: error))") } completionHandler(error) return @@ -39,23 +43,25 @@ class PacketTunnelProvider: NEPacketTunnelProvider { do { try adapter.start(tunnelConfiguration: tunnelConfig) } catch { - logger.error("Failed to start tunnel") + log.error("Failed to start tunnel: \(error)") completionHandler(error) } - logger.info("Tunnel started") + log.info("Tunnel started successfully") completionHandler(nil) } - override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { - logger.debug("\(#function)") + override func stopTunnel( + with reason: NEProviderStopReason, completionHandler: @escaping () -> Void + ) { + log.info("\(#function) called with reason: \(reason)") adapter.stop() - logger.info("Tunnel stopped") + log.info("Tunnel stopped") completionHandler() } override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { - logger.debug("\(#function)") + log.debug("\(#function) called") // TODO: messageData should contain a valid message. if let handler = completionHandler { if let stats = adapter.stats() { @@ -68,13 +74,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } override func sleep(completionHandler: @escaping () -> Void) { - logger.debug("\(#function)") + log.info("System going to sleep") // Add code here to get ready to sleep. completionHandler() } override func wake() { - logger.debug("\(#function)") + log.info("System waking up") // Add code here to wake up. } } diff --git a/swift/extension/VPNExtension/VPNExtension.entitlements b/swift/extension/VPNExtension/VPNExtension.entitlements index dbbd0259..5dfafa51 100644 --- a/swift/extension/VPNExtension/VPNExtension.entitlements +++ b/swift/extension/VPNExtension/VPNExtension.entitlements @@ -12,5 +12,9 @@ com.apple.security.network.server + com.apple.security.application-groups + + group.net.defguard +