From 49bb0d35a686c21ca38dec00d3c549510deeecfb Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 29 Jun 2026 09:30:20 +0200 Subject: [PATCH 01/11] rename config_poll module to polling --- src-tauri/client-cli/src/main.rs | 4 ++-- src-tauri/client-cli/src/{config_poll.rs => polling.rs} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename src-tauri/client-cli/src/{config_poll.rs => polling.rs} (100%) diff --git a/src-tauri/client-cli/src/main.rs b/src-tauri/client-cli/src/main.rs index 81850fd2..53710ce9 100644 --- a/src-tauri/client-cli/src/main.rs +++ b/src-tauri/client-cli/src/main.rs @@ -6,7 +6,7 @@ use common::check_version_flag; mod brand; mod cli; mod commands; -mod config_poll; +mod polling; mod exit; mod logging; mod mfa; @@ -52,7 +52,7 @@ async fn main() -> ExitCode { } }; - config_poll::poll_config(&state).await; + polling::poll_config(&state).await; // Dispatch command. match cli.command { diff --git a/src-tauri/client-cli/src/config_poll.rs b/src-tauri/client-cli/src/polling.rs similarity index 100% rename from src-tauri/client-cli/src/config_poll.rs rename to src-tauri/client-cli/src/polling.rs From 3ac1142110480c2d9e21b4885076709e65fcc221 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 29 Jun 2026 10:00:12 +0200 Subject: [PATCH 02/11] monitor module, initial shape --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 +- src-tauri/client-cli/Cargo.toml | 1 + src-tauri/client-cli/src/main.rs | 2 + src-tauri/client-cli/src/monitor.rs | 139 ++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 src-tauri/client-cli/src/monitor.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6126e667..7f21cafd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1575,6 +1575,7 @@ name = "defguard-cli" version = "2.1.0" dependencies = [ "base64 0.22.1", + "chrono", "clap", "defguard-client-common", "defguard-client-config-sync", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 371d6696..dbd72fbb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,6 +4,7 @@ default-members = ["cli", "client-cli", "daemon", "."] [workspace.dependencies] base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5", features = ["cargo", "derive", "env"] } defguard_wireguard_rs = "0.10" dirs-next = "2.0" @@ -76,7 +77,7 @@ redundant_closure = "warn" anyhow = "1.0" base64.workspace = true clap.workspace = true -chrono = { version = "0.4", features = ["serde"] } +chrono.workspace = true defguard-client-proto = { path = "client-proto" } defguard-client-core = { path = "core" } defguard-client-posture = { path = "enterprise/posture" } diff --git a/src-tauri/client-cli/Cargo.toml b/src-tauri/client-cli/Cargo.toml index cb40bcba..6b5a46cc 100644 --- a/src-tauri/client-cli/Cargo.toml +++ b/src-tauri/client-cli/Cargo.toml @@ -12,6 +12,7 @@ version.workspace = true name = "defguard-cli" [dependencies] +chrono.workspace = true clap = { workspace = true, features = ["cargo", "derive", "env"] } owo-colors = { version = "4", features = ["supports-colors"] } diff --git a/src-tauri/client-cli/src/main.rs b/src-tauri/client-cli/src/main.rs index 53710ce9..24026da9 100644 --- a/src-tauri/client-cli/src/main.rs +++ b/src-tauri/client-cli/src/main.rs @@ -12,6 +12,7 @@ mod logging; mod mfa; mod mfa_code; mod mfa_qr; +mod monitor; mod output; mod resolve; mod state; @@ -53,6 +54,7 @@ async fn main() -> ExitCode { }; polling::poll_config(&state).await; + monitor::monitor(&state).await; // Dispatch command. match cli.command { diff --git a/src-tauri/client-cli/src/monitor.rs b/src-tauri/client-cli/src/monitor.rs new file mode 100644 index 00000000..553b0418 --- /dev/null +++ b/src-tauri/client-cli/src/monitor.rs @@ -0,0 +1,139 @@ +use chrono::TimeDelta; +use defguard_core::{ConnectionType, connection::active_connections::ACTIVE_CONNECTIONS}; + +use crate::state::State; + +pub async fn monitor(state: &State) { + let connections = ACTIVE_CONNECTIONS.lock().await; + let connection_count = connections.len(); + if connections.is_empty() { + return; + } + // TODO(jck): peer_alive_period from AppState + let peer_alive_period = TimeDelta::seconds(300); + // Check currently active connections. + for con in &*connections { + match con.connection_type { + ConnectionType::Location => { + // match LocationStats::latest_by_download_change(pool, con.location_id).await { + // Ok(Some(latest_stat)) => { + // trace!("Latest statistics for location: {latest_stat:?}"); + // if !check_last_active_connection( + // latest_stat.collected_at, + // peer_alive_period, + // ) { + // // Check if there was any traffic since the connection was established + // // If not and the connection was established longer than the peer_alive_period, + // // consider the location dead and disconnect it later without reconnecting. + // let time_since_connection = Utc::now() - con.start.and_utc(); + // if latest_stat.collected_at < con.start { + // if time_since_connection > peer_alive_period { + // debug!( + // "There wasn't any activity for Location {} since its \ + // connection at {}; considering it being dead and possibly \ + // broken. It will be terminated without a further automatic \ + // reconnect.", + // con.location_id, con.start + // ); + // locations_to_disconnect.push((con.location_id, false)); + // } else { + // debug!( + // "There wasn't any activity for Location {} since its \ + // connection at {}; The amount of time passed since the connection \ + // is {time_since_connection}, the connection will be terminated when it reaches \ + // {peer_alive_period}", + // con.location_id, con.start); + // } + // } else { + // debug!( + // "There wasn't any activity for Location {} for the last \ + // {}s; considering it being dead.", + // con.location_id, + // peer_alive_period.num_seconds() + // ); + // locations_to_disconnect.push((con.location_id, true)); + // } + // } + // } + // Ok(None) => { + // debug!( + // "LocationStats not found in database for active connection {} {}({})", + // con.connection_type, con.interface_name, con.location_id + // ); + // if Utc::now() - con.start.and_utc() > peer_alive_period { + // debug!( + // "There wasn't any activity for Location {} since its \ + // connection at {}; considering it being dead.", + // con.location_id, con.start + // ); + // locations_to_disconnect.push((con.location_id, false)); + // } + // } + // Err(err) => { + // warn!( + // "Verification for location {}({}) skipped due to database error: \ + // {err}", + // con.interface_name, con.location_id + // ); + // } + // } + } + ConnectionType::Tunnel => { + // match TunnelStats::latest_by_download_change(pool, con.location_id).await { + // Ok(Some(latest_stat)) => { + // trace!("Latest statistics for tunnel: {latest_stat:?}"); + // if !check_last_active_connection( + // latest_stat.collected_at, + // peer_alive_period, + // ) { + // // Check if there was any traffic since the connection was established. + // // If not, consider the location dead and disconnect it later without reconnecting. + // if latest_stat.collected_at - con.start < TimeDelta::zero() { + // debug!( + // "There wasn't any activity for Tunnel {} since its \ + // connection at {}; considering it being dead and possibly \ + // broken. It will be disconnected without a further \ + // automatic reconnect.", + // con.location_id, con.start + // ); + // tunnels_to_disconnect.push((con.location_id, false)); + // } else { + // debug!( + // "There wasn't any activity for Tunnel {} for the last + // {}s; considering it being dead.", + // con.location_id, + // peer_alive_period.num_seconds() + // ); + // tunnels_to_disconnect.push((con.location_id, true)); + // } + // } + // } + // Ok(None) => { + // warn!( + // "TunnelStats not found in database for active connection Tunnel {}({})", + // con.interface_name, con.location_id + // ); + // if Utc::now() - con.start.and_utc() > peer_alive_period { + // debug!( + // "There wasn't any activity for Location {} since its \ + // connection at {}; considering it being dead.", + // con.location_id, con.start + // ); + // tunnels_to_disconnect.push((con.location_id, false)); + // } + // } + // Err(err) => { + // warn!( + // "Verification for tunnel {}({}) skipped due to db error. \ + // Error: {err}", + // con.interface_name, con.location_id + // ); + // } + // } + } + } + } + // Before processing locations/tunnels, the lock on active connections must be released. + drop(connections); + +} From fe7bd649c225c00d195406b7be4eae453b4643f2 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 29 Jun 2026 10:36:08 +0200 Subject: [PATCH 03/11] stale connection detection --- src-tauri/client-cli/src/monitor.rs | 169 +++++++--------------------- 1 file changed, 40 insertions(+), 129 deletions(-) diff --git a/src-tauri/client-cli/src/monitor.rs b/src-tauri/client-cli/src/monitor.rs index 553b0418..003d1896 100644 --- a/src-tauri/client-cli/src/monitor.rs +++ b/src-tauri/client-cli/src/monitor.rs @@ -1,139 +1,50 @@ -use chrono::TimeDelta; -use defguard_core::{ConnectionType, connection::active_connections::ACTIVE_CONNECTIONS}; +use chrono::{TimeDelta, Utc}; +use defguard_core::{ + connection::{ + active_connections::ACTIVE_CONNECTIONS, + active_state::{active_state, ActiveConnectionInfo}, + }, + ConnectionType, +}; +use tracing::error; use crate::state::State; -pub async fn monitor(state: &State) { - let connections = ACTIVE_CONNECTIONS.lock().await; - let connection_count = connections.len(); +fn is_stale(connection: &ActiveConnectionInfo, peer_alive_period: u32) -> Option { + let Some(stats) = connection.stats else { + return None; + }; + let Some(last_handshake) = stats.last_handshake else { + return None; + } + + let last_handshake = connection.stats?.last_handshake?; + + Some(connection.stats?.last_handshake? > peer_alive_period as u64) +} + +pub async fn monitor(state: &State) -> Result<(), defguard_core::error::Error> { + let connections = active_state(&state.pool).await?; if connections.is_empty() { - return; + return Ok(()); } - // TODO(jck): peer_alive_period from AppState - let peer_alive_period = TimeDelta::seconds(300); - // Check currently active connections. - for con in &*connections { - match con.connection_type { - ConnectionType::Location => { - // match LocationStats::latest_by_download_change(pool, con.location_id).await { - // Ok(Some(latest_stat)) => { - // trace!("Latest statistics for location: {latest_stat:?}"); - // if !check_last_active_connection( - // latest_stat.collected_at, - // peer_alive_period, - // ) { - // // Check if there was any traffic since the connection was established - // // If not and the connection was established longer than the peer_alive_period, - // // consider the location dead and disconnect it later without reconnecting. - // let time_since_connection = Utc::now() - con.start.and_utc(); - // if latest_stat.collected_at < con.start { - // if time_since_connection > peer_alive_period { - // debug!( - // "There wasn't any activity for Location {} since its \ - // connection at {}; considering it being dead and possibly \ - // broken. It will be terminated without a further automatic \ - // reconnect.", - // con.location_id, con.start - // ); - // locations_to_disconnect.push((con.location_id, false)); - // } else { - // debug!( - // "There wasn't any activity for Location {} since its \ - // connection at {}; The amount of time passed since the connection \ - // is {time_since_connection}, the connection will be terminated when it reaches \ - // {peer_alive_period}", - // con.location_id, con.start); - // } - // } else { - // debug!( - // "There wasn't any activity for Location {} for the last \ - // {}s; considering it being dead.", - // con.location_id, - // peer_alive_period.num_seconds() - // ); - // locations_to_disconnect.push((con.location_id, true)); - // } - // } - // } - // Ok(None) => { - // debug!( - // "LocationStats not found in database for active connection {} {}({})", - // con.connection_type, con.interface_name, con.location_id - // ); - // if Utc::now() - con.start.and_utc() > peer_alive_period { - // debug!( - // "There wasn't any activity for Location {} since its \ - // connection at {}; considering it being dead.", - // con.location_id, con.start - // ); - // locations_to_disconnect.push((con.location_id, false)); - // } - // } - // Err(err) => { - // warn!( - // "Verification for location {}({}) skipped due to database error: \ - // {err}", - // con.interface_name, con.location_id - // ); - // } - // } + for connection in connections { + if is_stale(&connection, state.app_config.peer_alive_period).is_some_and(|v| v) { + let result; + #[cfg(not(target_os = "macos"))] + { + use defguard_core::connection::tear_down; + + result = tear_down(&connection).await; } - ConnectionType::Tunnel => { - // match TunnelStats::latest_by_download_change(pool, con.location_id).await { - // Ok(Some(latest_stat)) => { - // trace!("Latest statistics for tunnel: {latest_stat:?}"); - // if !check_last_active_connection( - // latest_stat.collected_at, - // peer_alive_period, - // ) { - // // Check if there was any traffic since the connection was established. - // // If not, consider the location dead and disconnect it later without reconnecting. - // if latest_stat.collected_at - con.start < TimeDelta::zero() { - // debug!( - // "There wasn't any activity for Tunnel {} since its \ - // connection at {}; considering it being dead and possibly \ - // broken. It will be disconnected without a further \ - // automatic reconnect.", - // con.location_id, con.start - // ); - // tunnels_to_disconnect.push((con.location_id, false)); - // } else { - // debug!( - // "There wasn't any activity for Tunnel {} for the last - // {}s; considering it being dead.", - // con.location_id, - // peer_alive_period.num_seconds() - // ); - // tunnels_to_disconnect.push((con.location_id, true)); - // } - // } - // } - // Ok(None) => { - // warn!( - // "TunnelStats not found in database for active connection Tunnel {}({})", - // con.interface_name, con.location_id - // ); - // if Utc::now() - con.start.and_utc() > peer_alive_period { - // debug!( - // "There wasn't any activity for Location {} since its \ - // connection at {}; considering it being dead.", - // con.location_id, con.start - // ); - // tunnels_to_disconnect.push((con.location_id, false)); - // } - // } - // Err(err) => { - // warn!( - // "Verification for tunnel {}({}) skipped due to db error. \ - // Error: {err}", - // con.interface_name, con.location_id - // ); - // } - // } + #[cfg(target_os = "macos")] + { + result = macos_tear_down(connection.clone()).await; + } + if let Err(err) = result { + error!("Error removing stale connection {}: {err}", connection.name); } } } - // Before processing locations/tunnels, the lock on active connections must be released. - drop(connections); - + Ok(()) } From 0e34c4cbe91cbb27361469a324493ad932099609 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 29 Jun 2026 11:00:28 +0200 Subject: [PATCH 04/11] make it compile --- src-tauri/client-cli/src/main.rs | 4 ++-- src-tauri/client-cli/src/monitor.rs | 24 +++++++----------------- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/src-tauri/client-cli/src/main.rs b/src-tauri/client-cli/src/main.rs index 24026da9..a25e9817 100644 --- a/src-tauri/client-cli/src/main.rs +++ b/src-tauri/client-cli/src/main.rs @@ -6,7 +6,6 @@ use common::check_version_flag; mod brand; mod cli; mod commands; -mod polling; mod exit; mod logging; mod mfa; @@ -14,6 +13,7 @@ mod mfa_code; mod mfa_qr; mod monitor; mod output; +mod polling; mod resolve; mod state; #[cfg(all(test, target_os = "linux"))] @@ -54,7 +54,7 @@ async fn main() -> ExitCode { }; polling::poll_config(&state).await; - monitor::monitor(&state).await; + let _ = monitor::monitor(&state).await; // Dispatch command. match cli.command { diff --git a/src-tauri/client-cli/src/monitor.rs b/src-tauri/client-cli/src/monitor.rs index 003d1896..1c4d0e28 100644 --- a/src-tauri/client-cli/src/monitor.rs +++ b/src-tauri/client-cli/src/monitor.rs @@ -1,26 +1,14 @@ -use chrono::{TimeDelta, Utc}; -use defguard_core::{ - connection::{ - active_connections::ACTIVE_CONNECTIONS, - active_state::{active_state, ActiveConnectionInfo}, - }, - ConnectionType, -}; +use chrono::Utc; +use defguard_core::connection::active_state::{active_state, ActiveConnectionInfo}; use tracing::error; use crate::state::State; fn is_stale(connection: &ActiveConnectionInfo, peer_alive_period: u32) -> Option { - let Some(stats) = connection.stats else { - return None; - }; - let Some(last_handshake) = stats.last_handshake else { - return None; - } - - let last_handshake = connection.stats?.last_handshake?; + let last_handshake = connection.stats.as_ref()?.last_handshake?; + let now: u64 = Utc::now().timestamp().try_into().ok()?; - Some(connection.stats?.last_handshake? > peer_alive_period as u64) + Some(now.saturating_sub(last_handshake) > u64::from(peer_alive_period)) } pub async fn monitor(state: &State) -> Result<(), defguard_core::error::Error> { @@ -28,8 +16,10 @@ pub async fn monitor(state: &State) -> Result<(), defguard_core::error::Error> { if connections.is_empty() { return Ok(()); } + error!(state.app_config.peer_alive_period); for connection in connections { if is_stale(&connection, state.app_config.peer_alive_period).is_some_and(|v| v) { + // if is_stale(&connection, 10).is_some_and(|v| v) { let result; #[cfg(not(target_os = "macos"))] { From b29552a89d4c589977002fee8a3019cf4d43a6b0 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 29 Jun 2026 12:14:23 +0200 Subject: [PATCH 05/11] cleanup, don't return errors --- src-tauri/client-cli/src/main.rs | 3 ++- src-tauri/client-cli/src/monitor.rs | 16 ++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src-tauri/client-cli/src/main.rs b/src-tauri/client-cli/src/main.rs index a25e9817..75ae6938 100644 --- a/src-tauri/client-cli/src/main.rs +++ b/src-tauri/client-cli/src/main.rs @@ -54,7 +54,8 @@ async fn main() -> ExitCode { }; polling::poll_config(&state).await; - let _ = monitor::monitor(&state).await; + #[cfg(not(target_os = "macos"))] + monitor::monitor(&state).await; // Dispatch command. match cli.command { diff --git a/src-tauri/client-cli/src/monitor.rs b/src-tauri/client-cli/src/monitor.rs index 1c4d0e28..795e1f28 100644 --- a/src-tauri/client-cli/src/monitor.rs +++ b/src-tauri/client-cli/src/monitor.rs @@ -11,15 +11,20 @@ fn is_stale(connection: &ActiveConnectionInfo, peer_alive_period: u32) -> Option Some(now.saturating_sub(last_handshake) > u64::from(peer_alive_period)) } -pub async fn monitor(state: &State) -> Result<(), defguard_core::error::Error> { - let connections = active_state(&state.pool).await?; +pub async fn monitor(state: &State) { + let connections = match active_state(&state.pool).await { + Ok(connections) => connections, + Err(err) => { + error!("Failed to retrieve active connections: {err}"); + return; + } + }; if connections.is_empty() { - return Ok(()); + return; } - error!(state.app_config.peer_alive_period); + for connection in connections { if is_stale(&connection, state.app_config.peer_alive_period).is_some_and(|v| v) { - // if is_stale(&connection, 10).is_some_and(|v| v) { let result; #[cfg(not(target_os = "macos"))] { @@ -36,5 +41,4 @@ pub async fn monitor(state: &State) -> Result<(), defguard_core::error::Error> { } } } - Ok(()) } From 99094565321a01aec375aa5c67e24b09eaf2c600 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 29 Jun 2026 12:25:02 +0200 Subject: [PATCH 06/11] don't monitor on macos --- src-tauri/client-cli/src/main.rs | 1 + src-tauri/client-cli/src/monitor.rs | 12 ++---------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src-tauri/client-cli/src/main.rs b/src-tauri/client-cli/src/main.rs index 75ae6938..40a603aa 100644 --- a/src-tauri/client-cli/src/main.rs +++ b/src-tauri/client-cli/src/main.rs @@ -11,6 +11,7 @@ mod logging; mod mfa; mod mfa_code; mod mfa_qr; +#[cfg(not(target_os = "macos"))] mod monitor; mod output; mod polling; diff --git a/src-tauri/client-cli/src/monitor.rs b/src-tauri/client-cli/src/monitor.rs index 795e1f28..eb13a168 100644 --- a/src-tauri/client-cli/src/monitor.rs +++ b/src-tauri/client-cli/src/monitor.rs @@ -25,17 +25,9 @@ pub async fn monitor(state: &State) { for connection in connections { if is_stale(&connection, state.app_config.peer_alive_period).is_some_and(|v| v) { - let result; - #[cfg(not(target_os = "macos"))] - { - use defguard_core::connection::tear_down; + use defguard_core::connection::tear_down; - result = tear_down(&connection).await; - } - #[cfg(target_os = "macos")] - { - result = macos_tear_down(connection.clone()).await; - } + let result = tear_down(&connection).await; if let Err(err) = result { error!("Error removing stale connection {}: {err}", connection.name); } From 423f08ffedc113afff15e8eeb87903820d0cc08a Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 29 Jun 2026 12:41:35 +0200 Subject: [PATCH 07/11] comments --- src-tauri/client-cli/src/monitor.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src-tauri/client-cli/src/monitor.rs b/src-tauri/client-cli/src/monitor.rs index eb13a168..a32ed05f 100644 --- a/src-tauri/client-cli/src/monitor.rs +++ b/src-tauri/client-cli/src/monitor.rs @@ -4,6 +4,11 @@ use tracing::error; use crate::state::State; +/// Determine whether a connection is stale based on its latest WireGuard handshake. +/// +/// Returns `None` when live backend stats are unavailable or the connection has no +/// recorded handshake, because in that case the CLI cannot safely decide whether the +/// connection is stale. fn is_stale(connection: &ActiveConnectionInfo, peer_alive_period: u32) -> Option { let last_handshake = connection.stats.as_ref()?.last_handshake?; let now: u64 = Utc::now().timestamp().try_into().ok()?; @@ -11,7 +16,12 @@ fn is_stale(connection: &ActiveConnectionInfo, peer_alive_period: u32) -> Option Some(now.saturating_sub(last_handshake) > u64::from(peer_alive_period)) } -pub async fn monitor(state: &State) { +/// Disconnect active connections whose latest handshake is older than the configured +/// peer alive period. +/// +/// Connections without usable live stats are left untouched. Failures are logged and do +/// not stop cleanup of the remaining connections. +pub async fn tear_down_stale_connections(state: &State) { let connections = match active_state(&state.pool).await { Ok(connections) => connections, Err(err) => { From 2fa1f70b3ee50a8122f6aa8bef9d14af17df8f5c Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 29 Jun 2026 12:45:42 +0200 Subject: [PATCH 08/11] rename monitor function --- src-tauri/client-cli/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/client-cli/src/main.rs b/src-tauri/client-cli/src/main.rs index 40a603aa..f8c4ac16 100644 --- a/src-tauri/client-cli/src/main.rs +++ b/src-tauri/client-cli/src/main.rs @@ -56,7 +56,7 @@ async fn main() -> ExitCode { polling::poll_config(&state).await; #[cfg(not(target_os = "macos"))] - monitor::monitor(&state).await; + monitor::tear_down_stale_connections(&state).await; // Dispatch command. match cli.command { From ece103f69d5bd5ff7dc47e0d3a06a2e5ccf31a34 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 29 Jun 2026 12:47:59 +0200 Subject: [PATCH 09/11] implement tunnel-stats for macos --- src-tauri/core/src/connection/active_state.rs | 23 +++- src-tauri/core/src/connection/apple.rs | 100 +++++++++++++++- src-tauri/src/apple.rs | 109 +----------------- src-tauri/src/utils.rs | 4 +- 4 files changed, 120 insertions(+), 116 deletions(-) diff --git a/src-tauri/core/src/connection/active_state.rs b/src-tauri/core/src/connection/active_state.rs index 46559079..ae826173 100644 --- a/src-tauri/core/src/connection/active_state.rs +++ b/src-tauri/core/src/connection/active_state.rs @@ -18,6 +18,8 @@ use objc2_network_extension::NEVPNStatus; #[cfg(not(target_os = "macos"))] use tonic::Code; +#[cfg(target_os = "macos")] +use crate::connection::apple::tunnel_stats; #[cfg(target_os = "macos")] use crate::database::models::get_all_tunnels_locations; #[cfg(not(target_os = "macos"))] @@ -62,7 +64,7 @@ pub struct InterfaceStats { /// **unfiltered** snapshot of all managed interfaces (unlike `ReadInterfaceData`, which /// drops peers that haven't completed a handshake or whose stats haven't changed). /// -/// On macOS the Network Extension path is stubbed (pending the NE spike). +/// On macOS this queries Network Extension managers and asks connected providers for stats. #[cfg(target_os = "macos")] pub async fn active_state(_pool: &DbPool) -> Result, Error> { let (tunnels, locations) = get_all_tunnels_locations().await; @@ -73,12 +75,20 @@ pub async fn active_state(_pool: &DbPool) -> Result, E let mut result = Vec::new(); for location in locations { if let Some(NEVPNStatus::Connected) = location.status() { + let stats = tunnel_stats(location.id, &ConnectionType::Location).map(|stats| { + InterfaceStats { + listen_port: 0, + tx_bytes: stats.tx_bytes, + rx_bytes: stats.rx_bytes, + last_handshake: (stats.last_handshake != 0).then_some(stats.last_handshake), + } + }); let info = ActiveConnectionInfo { connection_type: ConnectionType::Location, target_id: location.id, name: location.name, interface_name: String::new(), - stats: None, // TODO + stats, }; result.push(info); } @@ -86,12 +96,19 @@ pub async fn active_state(_pool: &DbPool) -> Result, E for tunnel in tunnels { if let Some(NEVPNStatus::Connected) = tunnel.status() { + let stats = + tunnel_stats(tunnel.id, &ConnectionType::Tunnel).map(|stats| InterfaceStats { + listen_port: 0, + tx_bytes: stats.tx_bytes, + rx_bytes: stats.rx_bytes, + last_handshake: (stats.last_handshake != 0).then_some(stats.last_handshake), + }); let info = ActiveConnectionInfo { connection_type: ConnectionType::Tunnel, target_id: tunnel.id, name: tunnel.name, interface_name: String::new(), - stats: None, // TODO + stats, }; result.push(info); } diff --git a/src-tauri/core/src/connection/apple.rs b/src-tauri/core/src/connection/apple.rs index 70b9d34a..ae9420eb 100644 --- a/src-tauri/core/src/connection/apple.rs +++ b/src-tauri/core/src/connection/apple.rs @@ -17,17 +17,21 @@ const OBSERVER_CLEANUP_INTERVAL: Duration = Duration::from_secs(30); use block2::RcBlock; use objc2::{rc::Retained, runtime::ProtocolObject}; use objc2_foundation::{ - ns_string, NSArray, NSDate, NSError, NSNotification, NSNotificationCenter, NSNumber, + ns_string, NSArray, NSData, NSDate, NSError, NSNotification, NSNotificationCenter, NSNumber, NSObjectProtocol, NSOperationQueue, NSRunLoop, NSString, }; use objc2_network_extension::{ - NETunnelProviderManager, NETunnelProviderProtocol, NEVPNConnection, + NETunnelProviderManager, NETunnelProviderProtocol, NETunnelProviderSession, NEVPNConnection, NEVPNStatusDidChangeNotification, }; +use serde::Deserialize; -use crate::database::{ - models::{location::Location, tunnel::Tunnel, Id}, - DB_POOL, +use crate::{ + database::{ + models::{location::Location, tunnel::Tunnel, Id}, + DB_POOL, + }, + ConnectionType, }; pub const PLUGIN_BUNDLE_ID: &str = "net.defguard.VPNExtension"; @@ -147,6 +151,92 @@ pub fn spawn_runloop_and_wait_for(semaphore: &Arc) { } } +/// Tunnel statistics shared with VPNExtension (written in Swift). +#[derive(Deserialize)] +#[repr(C)] +#[serde(rename_all = "camelCase")] +pub struct Stats { + pub location_id: Option, + pub tunnel_id: Option, + pub tx_bytes: u64, + pub rx_bytes: u64, + pub last_handshake: u64, +} + +/// Retrieve VPN tunnel statistics from VPNExtension. +pub fn tunnel_stats(id: Id, connection_type: &ConnectionType) -> Option { + let new_stats = Arc::new(Mutex::new(None)); + let plugin_bundle_id = ns_string!(PLUGIN_BUNDLE_ID); + + let new_stats_clone = Arc::clone(&new_stats); + + 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 = Some(stats); + } + } else { + warn!("Failed to deserialize tunnel stats"); + } + } else { + debug!("No data received in tunnel stats response, skipping"); + } + finished_clone.store(true, Ordering::Release); + }); + + let manager = manager_for_key_and_value( + match connection_type { + ConnectionType::Location => LOCATION_ID, + ConnectionType::Tunnel => TUNNEL_ID, + }, + 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 the response handler to complete. + while !finished.load(Ordering::Acquire) { + spin_loop(); + } + + new_stats + .lock() + .map_or(None, |mut new_stats_locked| new_stats_locked.take()) +} + /// Handle VPN status change. fn vpn_status_change_handler(notification: &NSNotification) { let name = notification.name(); diff --git a/src-tauri/src/apple.rs b/src-tauri/src/apple.rs index d100dcae..ac1dde28 100644 --- a/src-tauri/src/apple.rs +++ b/src-tauri/src/apple.rs @@ -1,28 +1,13 @@ //! Interchangeability and communication with VPNExtension (written in Swift). -use std::{ - collections::HashMap, - hint::spin_loop, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, Mutex, - }, - time::Duration, -}; +use std::{collections::HashMap, time::Duration}; -use block2::RcBlock; use defguard_client_core::connection::{ active_connections::find_connection, - apple::{ - manager_for_key_and_value, LOCATION_ID, PLUGIN_BUNDLE_ID, TUNNEL_ID, VPN_STATE_UPDATE_COMMS, - }, + apple::{manager_for_key_and_value, LOCATION_ID, TUNNEL_ID, VPN_STATE_UPDATE_COMMS}, }; use objc2::rc::Retained; -use objc2_foundation::{ns_string, NSData}; -use objc2_network_extension::{ - NETunnelProviderManager, NETunnelProviderProtocol, NETunnelProviderSession, NEVPNStatus, -}; -use serde::Deserialize; +use objc2_network_extension::{NETunnelProviderManager, NEVPNStatus}; use tauri::{AppHandle, Emitter, Manager}; use tokio::time::sleep; use tracing::Level; @@ -265,18 +250,6 @@ async fn sync_connections_with_system(app_handle: &AppHandle) { } } -/// Tunnel statistics shared with VPNExtension (written in Swift). -#[derive(Deserialize)] -#[repr(C)] -#[serde(rename_all = "camelCase")] -pub(crate) struct Stats { - pub(crate) location_id: Option, - pub(crate) tunnel_id: Option, - pub(crate) tx_bytes: u64, - pub(crate) rx_bytes: u64, - pub(crate) last_handshake: u64, -} - #[must_use] pub fn get_managers_for_tunnels_and_locations( tunnels: &[Tunnel], @@ -298,79 +271,3 @@ pub fn get_managers_for_tunnels_and_locations( managers } - -/// Retrieve VPN tunnel statistics from VPNExtension. -pub(crate) fn tunnel_stats(id: Id, connection_type: &ConnectionType) -> Option { - let new_stats = Arc::new(Mutex::new(None)); - let plugin_bundle_id = ns_string!(PLUGIN_BUNDLE_ID); - - let new_stats_clone = Arc::clone(&new_stats); - - 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 = Some(stats); - } - } else { - warn!("Failed to deserialize tunnel stats"); - } - } else { - debug!("No data received in tunnel stats response, skipping"); - } - finished_clone.store(true, Ordering::Release); - }); - - let manager = manager_for_key_and_value( - match connection_type { - ConnectionType::Location => LOCATION_ID, - ConnectionType::Tunnel => TUNNEL_ID, - }, - 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 !finished.load(Ordering::Acquire) { - spin_loop(); - } - - let stats = new_stats - .lock() - .map_or(None, |mut new_stats_locked| new_stats_locked.take()); - - stats -} diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index f68a7db8..0d3a118f 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -30,8 +30,6 @@ use windows_service::{ #[cfg(windows)] use windows_sys::Win32::Foundation::ERROR_SERVICE_DOES_NOT_EXIST; -#[cfg(target_os = "macos")] -use crate::apple::tunnel_stats; #[cfg(not(target_os = "macos"))] use crate::database::models::{ location_stats::peer_to_location_stats, tunnel::peer_to_tunnel_stats, @@ -48,6 +46,8 @@ use crate::{ log_watcher::service_log_watcher::spawn_log_watcher_task, ConnectionType, }; +#[cfg(target_os = "macos")] +use defguard_client_core::connection::apple::tunnel_stats; // Work-around MFA propagation delay. FIXME: remove once Core API is corrected. #[cfg(target_os = "macos")] From c31b0c55f9ba9dd5e8cb10a604ff71ab1433b682 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 29 Jun 2026 12:53:31 +0200 Subject: [PATCH 10/11] ungate macos --- src-tauri/client-cli/src/main.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src-tauri/client-cli/src/main.rs b/src-tauri/client-cli/src/main.rs index f8c4ac16..edf34dd8 100644 --- a/src-tauri/client-cli/src/main.rs +++ b/src-tauri/client-cli/src/main.rs @@ -11,7 +11,6 @@ mod logging; mod mfa; mod mfa_code; mod mfa_qr; -#[cfg(not(target_os = "macos"))] mod monitor; mod output; mod polling; @@ -55,7 +54,6 @@ async fn main() -> ExitCode { }; polling::poll_config(&state).await; - #[cfg(not(target_os = "macos"))] monitor::tear_down_stale_connections(&state).await; // Dispatch command. From aa138f09a68029a7a3e8da8c8b72e80ee4eadf30 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 29 Jun 2026 12:54:52 +0200 Subject: [PATCH 11/11] shorter alive period for testing purposes --- src-tauri/client-cli/src/monitor.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src-tauri/client-cli/src/monitor.rs b/src-tauri/client-cli/src/monitor.rs index a32ed05f..432efb4b 100644 --- a/src-tauri/client-cli/src/monitor.rs +++ b/src-tauri/client-cli/src/monitor.rs @@ -9,11 +9,12 @@ use crate::state::State; /// Returns `None` when live backend stats are unavailable or the connection has no /// recorded handshake, because in that case the CLI cannot safely decide whether the /// connection is stale. -fn is_stale(connection: &ActiveConnectionInfo, peer_alive_period: u32) -> Option { +fn is_stale(connection: &ActiveConnectionInfo, _peer_alive_period: u32) -> Option { let last_handshake = connection.stats.as_ref()?.last_handshake?; let now: u64 = Utc::now().timestamp().try_into().ok()?; - Some(now.saturating_sub(last_handshake) > u64::from(peer_alive_period)) + // Some(now.saturating_sub(last_handshake) > u64::from(peer_alive_period)) + Some(now.saturating_sub(last_handshake) > u64::from(20u64)) } /// Disconnect active connections whose latest handshake is older than the configured