From b1cc666516502c3e8561a6dcbd6aaf547be31be1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 24 Mar 2026 21:59:06 +0100 Subject: [PATCH 1/2] fix query for fetching latest public IP --- ...66317bb4ca6fdd2d15960e76ff6214de0144.json} | 4 +- .../defguard_common/src/db/models/device.rs | 459 +++++++++++++++++- .../tests/integration/api/user.rs | 52 +- 3 files changed, 494 insertions(+), 21 deletions(-) rename .sqlx/{query-1a828cfcdc03c0aef96b6b18e43c47c7ffbee0e6a825568b4a55f69e225bb7b0.json => query-9ca51f33ffa6d6c8897db9b5a4f066317bb4ca6fdd2d15960e76ff6214de0144.json} (62%) diff --git a/.sqlx/query-1a828cfcdc03c0aef96b6b18e43c47c7ffbee0e6a825568b4a55f69e225bb7b0.json b/.sqlx/query-9ca51f33ffa6d6c8897db9b5a4f066317bb4ca6fdd2d15960e76ff6214de0144.json similarity index 62% rename from .sqlx/query-1a828cfcdc03c0aef96b6b18e43c47c7ffbee0e6a825568b4a55f69e225bb7b0.json rename to .sqlx/query-9ca51f33ffa6d6c8897db9b5a4f066317bb4ca6fdd2d15960e76ff6214de0144.json index eddf80ae9f..190bd19427 100644 --- a/.sqlx/query-1a828cfcdc03c0aef96b6b18e43c47c7ffbee0e6a825568b4a55f69e225bb7b0.json +++ b/.sqlx/query-9ca51f33ffa6d6c8897db9b5a4f066317bb4ca6fdd2d15960e76ff6214de0144.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT n.id network_id, n.name network_name, n.endpoint gateway_endpoint, wnd.wireguard_ips \"device_wireguard_ips: Vec\", latest_session.endpoint \"device_endpoint?\", last_successful_session.connected_at \"last_connected_at?\", latest_session.state \"state?: VpnClientSessionState\" FROM wireguard_network_device wnd JOIN wireguard_network n ON n.id = wnd.wireguard_network_id LEFT JOIN LATERAL ( SELECT id, state, location_id, endpoint, connected_at FROM vpn_client_session WHERE location_id = n.id and device_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1 ) latest_session ON latest_session.location_id = n.id LEFT JOIN LATERAL ( SELECT connected_at FROM vpn_client_session WHERE location_id = n.id AND device_id = $1 AND connected_at IS NOT NULL ORDER BY connected_at DESC, id DESC LIMIT 1 ) last_successful_session ON true WHERE wnd.device_id = $1", + "query": "SELECT n.id network_id, n.name network_name, n.endpoint gateway_endpoint, wnd.wireguard_ips \"device_wireguard_ips: Vec\", latest_successful_stats.endpoint \"device_endpoint?\", latest_successful_session.connected_at \"last_connected_at?\", latest_successful_session.state \"state?: VpnClientSessionState\" FROM wireguard_network_device wnd JOIN wireguard_network n ON n.id = wnd.wireguard_network_id LEFT JOIN LATERAL ( SELECT id, state, connected_at FROM vpn_client_session WHERE location_id = n.id AND device_id = $1 AND connected_at IS NOT NULL ORDER BY connected_at DESC, id DESC LIMIT 1 ) latest_successful_session ON true LEFT JOIN LATERAL ( SELECT endpoint FROM vpn_session_stats WHERE session_id = latest_successful_session.id ORDER BY collected_at DESC, id DESC LIMIT 1 ) latest_successful_stats ON true WHERE wnd.device_id = $1", "describe": { "columns": [ { @@ -65,5 +65,5 @@ false ] }, - "hash": "1a828cfcdc03c0aef96b6b18e43c47c7ffbee0e6a825568b4a55f69e225bb7b0" + "hash": "9ca51f33ffa6d6c8897db9b5a4f066317bb4ca6fdd2d15960e76ff6214de0144" } diff --git a/crates/defguard_common/src/db/models/device.rs b/crates/defguard_common/src/db/models/device.rs index 001a9510d6..2834363678 100644 --- a/crates/defguard_common/src/db/models/device.rs +++ b/crates/defguard_common/src/db/models/device.rs @@ -239,25 +239,25 @@ impl UserDevice { // fetch device config and connection info for all allowed networks let result = query!( "SELECT n.id network_id, n.name network_name, n.endpoint gateway_endpoint, \ - wnd.wireguard_ips \"device_wireguard_ips: Vec\", latest_session.endpoint \"device_endpoint?\", \ - last_successful_session.connected_at \"last_connected_at?\", \ - latest_session.state \"state?: VpnClientSessionState\" \ + wnd.wireguard_ips \"device_wireguard_ips: Vec\", latest_successful_stats.endpoint \"device_endpoint?\", \ + latest_successful_session.connected_at \"last_connected_at?\", \ + latest_successful_session.state \"state?: VpnClientSessionState\" \ FROM wireguard_network_device wnd \ JOIN wireguard_network n ON n.id = wnd.wireguard_network_id \ LEFT JOIN LATERAL ( \ - SELECT id, state, location_id, endpoint, connected_at \ - FROM vpn_client_session \ - WHERE location_id = n.id and device_id = $1 \ - ORDER BY created_at DESC, id DESC \ - LIMIT 1 \ - ) latest_session ON latest_session.location_id = n.id \ - LEFT JOIN LATERAL ( \ - SELECT connected_at \ + SELECT id, state, connected_at \ FROM vpn_client_session \ WHERE location_id = n.id AND device_id = $1 AND connected_at IS NOT NULL \ ORDER BY connected_at DESC, id DESC \ LIMIT 1 \ - ) last_successful_session ON true \ + ) latest_successful_session ON true \ + LEFT JOIN LATERAL ( \ + SELECT endpoint \ + FROM vpn_session_stats \ + WHERE session_id = latest_successful_session.id \ + ORDER BY collected_at DESC, id DESC \ + LIMIT 1 \ + ) latest_successful_stats ON true \ WHERE wnd.device_id = $1", device.id, ) @@ -1150,7 +1150,13 @@ mod test { use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::*; - use crate::db::{models::vpn_client_session::VpnClientMfaMethod, setup_pool}; + use crate::db::{ + models::{ + gateway::Gateway, vpn_client_session::VpnClientMfaMethod, + vpn_session_stats::VpnSessionStats, + }, + setup_pool, + }; impl Device { /// Create new device and assign IP in a given network @@ -1887,14 +1893,27 @@ mod test { .await .unwrap(); + let gateway = Gateway::new(network.id, "gateway", "198.51.100.1", 51820, "tester") + .save(&pool) + .await + .unwrap(); + let last_successful_connection = NaiveDate::from_ymd_opt(2026, 1, 2) .expect("expected valid date") .and_hms_opt(3, 4, 5) .expect("expected valid time"); + let last_successful_stats_at = NaiveDate::from_ymd_opt(2026, 1, 2) + .expect("expected valid date") + .and_hms_opt(3, 5, 6) + .expect("expected valid time"); let newer_session_created_at = NaiveDate::from_ymd_opt(2026, 1, 3) .expect("expected valid date") .and_hms_opt(4, 5, 6) .expect("expected valid time"); + let newer_session_stats_at = NaiveDate::from_ymd_opt(2026, 1, 3) + .expect("expected valid date") + .and_hms_opt(4, 6, 7) + .expect("expected valid time"); let mut connected_session = VpnClientSession::new( network.id, @@ -1904,14 +1923,44 @@ mod test { None, ); connected_session.created_at = last_successful_connection; - connected_session.save(&pool).await.unwrap(); + let connected_session = connected_session.save(&pool).await.unwrap(); + + VpnSessionStats::new( + connected_session.id, + gateway.id, + last_successful_stats_at, + last_successful_stats_at, + "203.0.113.10:51820".into(), + 1, + 1, + 1, + 1, + ) + .save(&pool) + .await + .unwrap(); let mut disconnected_session = VpnClientSession::new(network.id, user.id, device.id, None, None); disconnected_session.created_at = newer_session_created_at; disconnected_session.disconnected_at = Some(newer_session_created_at); disconnected_session.state = VpnClientSessionState::Disconnected; - disconnected_session.save(&pool).await.unwrap(); + let disconnected_session = disconnected_session.save(&pool).await.unwrap(); + + VpnSessionStats::new( + disconnected_session.id, + gateway.id, + newer_session_stats_at, + newer_session_stats_at, + "198.51.100.99:51820".into(), + 2, + 2, + 2, + 2, + ) + .save(&pool) + .await + .unwrap(); let user_device = UserDevice::from_device(&pool, device) .await @@ -1923,7 +1972,8 @@ mod test { .find(|network_info| network_info.network_id == network.id) .expect("expected created network in user device response"); - assert!(!network_info.is_active); + assert!(network_info.is_active); + assert_eq!(network_info.last_connected_ip, Some("203.0.113.10".into())); assert_eq!( network_info.last_connected_at, Some(last_successful_connection) @@ -2024,6 +2074,383 @@ mod test { ); } + #[sqlx::test] + async fn test_user_device_from_device_reads_latest_endpoint_from_session_stats( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let user = User::new( + "testuser", + Some("password"), + "Tester", + "Test", + "test@test.com", + None, + ) + .save(&pool) + .await + .unwrap(); + + let device = Device::new( + "device".into(), + "pubkey".into(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); + + let network = WireguardNetwork::default() + .try_set_address("10.1.1.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + WireguardNetworkDevice::new( + network.id, + device.id, + [IpAddr::from_str("10.1.1.2").unwrap()], + ) + .insert(&pool) + .await + .unwrap(); + + let gateway = Gateway::new(network.id, "gateway", "198.51.100.1", 51820, "tester") + .save(&pool) + .await + .unwrap(); + + let connected_at = NaiveDate::from_ymd_opt(2026, 1, 2) + .expect("expected valid date") + .and_hms_opt(3, 4, 5) + .expect("expected valid time"); + let collected_at = NaiveDate::from_ymd_opt(2026, 1, 2) + .expect("expected valid date") + .and_hms_opt(3, 5, 6) + .expect("expected valid time"); + + let session = + VpnClientSession::new(network.id, user.id, device.id, Some(connected_at), None) + .save(&pool) + .await + .unwrap(); + + VpnSessionStats::new( + session.id, + gateway.id, + collected_at, + collected_at, + "[2001:db8::1]:51820".into(), + 1, + 1, + 1, + 1, + ) + .save(&pool) + .await + .unwrap(); + + let user_device = UserDevice::from_device(&pool, device) + .await + .unwrap() + .unwrap(); + let network_info = user_device + .networks + .into_iter() + .find(|network_info| network_info.network_id == network.id) + .expect("expected created network in user device response"); + + assert_eq!(network_info.last_connected_ip, Some("2001:db8::1".into())); + assert_eq!(network_info.last_connected_at, Some(connected_at)); + assert!(network_info.is_active); + } + + #[sqlx::test] + async fn test_user_device_from_device_returns_empty_connection_fields_without_successful_session( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let user = User::new( + "testuser", + Some("password"), + "Tester", + "Test", + "test@test.com", + None, + ) + .save(&pool) + .await + .unwrap(); + + let device = Device::new( + "device".into(), + "pubkey".into(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); + + let network = WireguardNetwork::default() + .try_set_address("10.1.1.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + WireguardNetworkDevice::new( + network.id, + device.id, + [IpAddr::from_str("10.1.1.2").unwrap()], + ) + .insert(&pool) + .await + .unwrap(); + + let gateway = Gateway::new(network.id, "gateway", "198.51.100.1", 51820, "tester") + .save(&pool) + .await + .unwrap(); + + let attempted_at = NaiveDate::from_ymd_opt(2026, 1, 2) + .expect("expected valid date") + .and_hms_opt(3, 4, 5) + .expect("expected valid time"); + let stats_at = NaiveDate::from_ymd_opt(2026, 1, 2) + .expect("expected valid date") + .and_hms_opt(3, 5, 6) + .expect("expected valid time"); + + let mut attempted_session = + VpnClientSession::new(network.id, user.id, device.id, None, None); + attempted_session.created_at = attempted_at; + let attempted_session = attempted_session.save(&pool).await.unwrap(); + + VpnSessionStats::new( + attempted_session.id, + gateway.id, + stats_at, + stats_at, + "203.0.113.10:51820".into(), + 1, + 1, + 1, + 1, + ) + .save(&pool) + .await + .unwrap(); + + let user_device = UserDevice::from_device(&pool, device) + .await + .unwrap() + .unwrap(); + let network_info = user_device + .networks + .into_iter() + .find(|network_info| network_info.network_id == network.id) + .expect("expected created network in user device response"); + + assert_eq!(network_info.last_connected_at, None); + assert_eq!(network_info.last_connected_ip, None); + assert!(!network_info.is_active); + } + + #[sqlx::test] + async fn test_user_device_from_device_returns_none_for_successful_session_without_stats( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let user = User::new( + "testuser", + Some("password"), + "Tester", + "Test", + "test@test.com", + None, + ) + .save(&pool) + .await + .unwrap(); + + let device = Device::new( + "device".into(), + "pubkey".into(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); + + let network = WireguardNetwork::default() + .try_set_address("10.1.1.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + WireguardNetworkDevice::new( + network.id, + device.id, + [IpAddr::from_str("10.1.1.2").unwrap()], + ) + .insert(&pool) + .await + .unwrap(); + + let connected_at = NaiveDate::from_ymd_opt(2026, 1, 2) + .expect("expected valid date") + .and_hms_opt(3, 4, 5) + .expect("expected valid time"); + + VpnClientSession::new(network.id, user.id, device.id, Some(connected_at), None) + .save(&pool) + .await + .unwrap(); + + let user_device = UserDevice::from_device(&pool, device) + .await + .unwrap() + .unwrap(); + let network_info = user_device + .networks + .into_iter() + .find(|network_info| network_info.network_id == network.id) + .expect("expected created network in user device response"); + + assert_eq!(network_info.last_connected_at, Some(connected_at)); + assert_eq!(network_info.last_connected_ip, None); + assert!(network_info.is_active); + } + + #[sqlx::test] + async fn test_user_device_from_device_uses_stats_id_as_tie_breaker_for_latest_successful_session( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let user = User::new( + "testuser", + Some("password"), + "Tester", + "Test", + "test@test.com", + None, + ) + .save(&pool) + .await + .unwrap(); + + let device = Device::new( + "device".into(), + "pubkey".into(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); + + let network = WireguardNetwork::default() + .try_set_address("10.1.1.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + WireguardNetworkDevice::new( + network.id, + device.id, + [IpAddr::from_str("10.1.1.2").unwrap()], + ) + .insert(&pool) + .await + .unwrap(); + + let gateway = Gateway::new(network.id, "gateway", "198.51.100.1", 51820, "tester") + .save(&pool) + .await + .unwrap(); + + let connected_at = NaiveDate::from_ymd_opt(2026, 1, 2) + .expect("expected valid date") + .and_hms_opt(3, 4, 5) + .expect("expected valid time"); + let collected_at = NaiveDate::from_ymd_opt(2026, 1, 2) + .expect("expected valid date") + .and_hms_opt(3, 5, 6) + .expect("expected valid time"); + + let session = + VpnClientSession::new(network.id, user.id, device.id, Some(connected_at), None) + .save(&pool) + .await + .unwrap(); + + VpnSessionStats::new( + session.id, + gateway.id, + collected_at, + collected_at, + "198.51.100.10:51820".into(), + 1, + 1, + 1, + 1, + ) + .save(&pool) + .await + .unwrap(); + + VpnSessionStats::new( + session.id, + gateway.id, + collected_at, + collected_at, + "198.51.100.11:51820".into(), + 2, + 2, + 2, + 2, + ) + .save(&pool) + .await + .unwrap(); + + let user_device = UserDevice::from_device(&pool, device) + .await + .unwrap() + .unwrap(); + let network_info = user_device + .networks + .into_iter() + .find(|network_info| network_info.network_id == network.id) + .expect("expected created network in user device response"); + + assert_eq!(network_info.last_connected_ip, Some("198.51.100.11".into())); + assert_eq!(network_info.last_connected_at, Some(connected_at)); + assert!(network_info.is_active); + } + #[sqlx::test] fn test_all_for_network_and_user(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/crates/defguard_core/tests/integration/api/user.rs b/crates/defguard_core/tests/integration/api/user.rs index 929ee2720a..6ab4c34e2c 100644 --- a/crates/defguard_core/tests/integration/api/user.rs +++ b/crates/defguard_core/tests/integration/api/user.rs @@ -7,8 +7,10 @@ use defguard_common::{ models::{ Device, DeviceType, MFAMethod, User, WebAuthn, WireguardNetwork, device::{AddDevice, WireguardNetworkDevice}, + gateway::Gateway, oauth2client::OAuth2Client, vpn_client_session::{VpnClientSession, VpnClientSessionState}, + vpn_session_stats::VpnSessionStats, }, }, types::user_info::UserInfo, @@ -408,14 +410,27 @@ async fn test_get_user_keeps_last_successful_connection_for_newer_disconnected_s .await .unwrap(); + let gateway = Gateway::new(network.id, "gateway", "198.51.100.1", 51820, "tester") + .save(&pool) + .await + .unwrap(); + let last_successful_connection = NaiveDate::from_ymd_opt(2026, 1, 2) .expect("expected valid connected_at date") .and_hms_opt(3, 4, 5) .expect("expected valid connected_at time"); + let last_successful_stats_at = NaiveDate::from_ymd_opt(2026, 1, 2) + .expect("expected valid collected_at date") + .and_hms_opt(3, 5, 6) + .expect("expected valid collected_at time"); let disconnected_at = NaiveDate::from_ymd_opt(2026, 1, 3) .expect("expected valid disconnected date") .and_hms_opt(4, 5, 6) .expect("expected valid disconnected time"); + let disconnected_stats_at = NaiveDate::from_ymd_opt(2026, 1, 3) + .expect("expected valid collected_at date") + .and_hms_opt(4, 6, 7) + .expect("expected valid collected_at time"); let mut connected_session = VpnClientSession::new( network.id, @@ -425,14 +440,44 @@ async fn test_get_user_keeps_last_successful_connection_for_newer_disconnected_s None, ); connected_session.created_at = last_successful_connection; - connected_session.save(&pool).await.unwrap(); + let connected_session = connected_session.save(&pool).await.unwrap(); + + VpnSessionStats::new( + connected_session.id, + gateway.id, + last_successful_stats_at, + last_successful_stats_at, + "203.0.113.10:51820".into(), + 1, + 1, + 1, + 1, + ) + .save(&pool) + .await + .unwrap(); let mut disconnected_session = VpnClientSession::new(network.id, user.id, device.id, None, None); disconnected_session.created_at = disconnected_at; disconnected_session.disconnected_at = Some(disconnected_at); disconnected_session.state = VpnClientSessionState::Disconnected; - disconnected_session.save(&pool).await.unwrap(); + let disconnected_session = disconnected_session.save(&pool).await.unwrap(); + + VpnSessionStats::new( + disconnected_session.id, + gateway.id, + disconnected_stats_at, + disconnected_stats_at, + "198.51.100.99:51820".into(), + 2, + 2, + 2, + 2, + ) + .save(&pool) + .await + .unwrap(); let user_details = fetch_user_details(&client, username).await; @@ -447,11 +492,12 @@ async fn test_get_user_keeps_last_successful_connection_for_newer_disconnected_s .find(|network_info| network_info.network_id == network.id) .expect("expected created network in user details response"); - assert!(!network_info.is_active); + assert!(network_info.is_active); assert_eq!( network_info.last_connected_at, Some(last_successful_connection) ); + assert_eq!(network_info.last_connected_ip, Some("203.0.113.10".into())); } #[sqlx::test] From 5589c8f71574c7ffa4c7012f71ed7409bc937f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 24 Mar 2026 22:06:12 +0100 Subject: [PATCH 2/2] update dependencies --- Cargo.lock | 8 ++++---- flake.lock | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a74a10a621..9a13a57b97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3324,9 +3324,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "bitflags 2.11.0", "libc", @@ -6783,9 +6783,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "a559e63b5d8004e12f9bce88af5c6d939c58de839b7532cfe9653846cedd2a9e" [[package]] name = "unicode-width" diff --git a/flake.lock b/flake.lock index 4db212dbcd..db8cfc66ca 100644 --- a/flake.lock +++ b/flake.lock @@ -74,11 +74,11 @@ ] }, "locked": { - "lastModified": 1774235565, - "narHash": "sha256-D8OOwvq3zDDCtIhMcNueb9tGSZaZUanKpWDleRgQ80U=", + "lastModified": 1774321696, + "narHash": "sha256-g18xMjMNla/nsF5XyQCNyWmtb2UlZpkY0XE8KinIXAA=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "dc00324a2438762582b49954373112b8eab29cab", + "rev": "49a67e6894d4cb782842ee6faa466aa90c92812d", "type": "github" }, "original": {