diff --git a/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs b/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs index b03d904df..6e5e8d032 100644 --- a/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs +++ b/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs @@ -11,7 +11,7 @@ use crate::{ events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, DesktopClientMfaEvent}, grpc::{ client_mfa::{ClientLoginSession, ClientMfaServer}, - utils::parse_client_info, + utils::parse_client_ip_agent, }, }; @@ -66,7 +66,7 @@ impl ClientMfaServer { return Err(Status::invalid_argument("invalid MFA method")); } - let (ip, _user_agent) = parse_client_info(&info).map_err(Status::internal)?; + let (ip, _user_agent) = parse_client_ip_agent(&info).map_err(Status::internal)?; let context = BidiRequestContext::new( user.id, user.username.clone(), diff --git a/crates/defguard_core/src/enterprise/grpc/polling.rs b/crates/defguard_core/src/enterprise/grpc/polling.rs index ecce3ec2f..8e04e9a41 100644 --- a/crates/defguard_core/src/enterprise/grpc/polling.rs +++ b/crates/defguard_core/src/enterprise/grpc/polling.rs @@ -1,5 +1,5 @@ use defguard_common::db::Id; -use defguard_proto::proxy::{InstanceInfoRequest, InstanceInfoResponse}; +use defguard_proto::proxy::{DeviceInfo, InstanceInfoRequest, InstanceInfoResponse}; use sqlx::PgPool; use tonic::Status; @@ -47,7 +47,11 @@ impl PollingServer { /// Prepares instance info for polling requests. Enterprise only. #[instrument(skip_all)] - pub async fn info(&self, request: InstanceInfoRequest) -> Result { + pub async fn info( + &self, + request: InstanceInfoRequest, + device_info: Option, + ) -> Result { trace!("Polling info start"); let token = self.validate_session(&request.token).await?; let Some(device) = Device::find_by_id(&self.pool, token.device_id) @@ -82,7 +86,8 @@ impl PollingServer { } // Build and return polling info. - let device_config = build_device_config_response(&self.pool, device, None).await?; + let device_config = + build_device_config_response(&self.pool, device, None, device_info).await?; Ok(InstanceInfoResponse { device_config: Some(device_config), diff --git a/crates/defguard_core/src/grpc/client_mfa.rs b/crates/defguard_core/src/grpc/client_mfa.rs index ea1e6482d..f688a41a4 100644 --- a/crates/defguard_core/src/grpc/client_mfa.rs +++ b/crates/defguard_core/src/grpc/client_mfa.rs @@ -32,7 +32,7 @@ use crate::{ }, enterprise::{db::models::openid_provider::OpenIdProvider, is_enterprise_enabled}, events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, DesktopClientMfaEvent}, - grpc::utils::parse_client_info, + grpc::utils::parse_client_ip_agent, handlers::mail::send_email_mfa_code_email, }; @@ -395,7 +395,7 @@ impl ClientMfaServer { } = session; // Prepare event context - let (ip, _user_agent) = parse_client_info(&info).map_err(Status::internal)?; + let (ip, _user_agent) = parse_client_ip_agent(&info).map_err(Status::internal)?; let context = BidiRequestContext::new( user.id, user.username.clone(), diff --git a/crates/defguard_core/src/grpc/client_version.rs b/crates/defguard_core/src/grpc/client_version.rs new file mode 100644 index 000000000..d8bc7be95 --- /dev/null +++ b/crates/defguard_core/src/grpc/client_version.rs @@ -0,0 +1,386 @@ +use base64::{Engine, prelude::BASE64_STANDARD}; +use defguard_proto::proxy::{ClientPlatformInfo, DeviceInfo}; +use prost::Message; +use semver::Version; + +pub(crate) fn parse_client_version_platform( + info: Option<&DeviceInfo>, +) -> (Option, Option) { + let Some(info) = info else { + debug!("Device information is missing from the request"); + return (None, None); + }; + + let version = info.version.as_ref().map_or_else( + || None, + |v| { + Version::parse(v).map_or_else( + |_| { + error!("Invalid version string: {v}"); + None + }, + Some, + ) + }, + ); + + let platform = info.platform.as_ref().and_then(|p| { + let binary = BASE64_STANDARD + .decode(p) + .map_err(|e| { + error!("Failed to decode base64 platform string: {e}"); + e + }) + .ok()?; + let platform_info = ClientPlatformInfo::decode(&*binary) + .map_err(|e| { + error!("Failed to decode ClientPlatformInfo from bytes: {e}"); + e + }) + .ok()?; + Some(platform_info) + }); + + (version, platform) +} + +/// Represents a client feature that may have minimum version and OS family requirements. +#[derive(Debug)] +pub(crate) enum ClientFeature { + ServiceLocations, +} + +impl ClientFeature { + const fn min_version(&self) -> Option { + match self { + Self::ServiceLocations => Some(Version::new(1, 6, 0)), + } + } + + fn required_os_family(&self) -> Option> { + match self { + Self::ServiceLocations => Some(vec!["windows"]), + } + } + + pub(crate) fn is_supported_by_device(&self, info: Option<&DeviceInfo>) -> bool { + let (version, platform) = parse_client_version_platform(info); + + // No minimum version = matches all + let version_matches = self.min_version().is_none_or(|min_version| { + // No version info = does not match + version + .as_ref() + .is_some_and(|version| version >= &min_version) + }); + + if !version_matches { + debug!( + "Client version {version:?} does not meet minimum version {:?} for feature {self:?}", + self.min_version() + ); + } + + // No required OS family = matches all + let platform_matches = self.required_os_family().is_none_or(|platforms| { + platforms.iter().any(|p| { + platform + .as_ref() + .is_some_and(|platform| platform.os_family.eq_ignore_ascii_case(p)) + }) + }); + + if !platform_matches { + debug!( + "Client OS {:?} does not meet required OS {:?} for feature {self:?}", + platform.as_ref().map(|p| &p.os_family), + self.required_os_family() + ); + } + + version_matches && platform_matches + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Helper function to create DeviceInfo + fn create_device_info( + version: Option, + platform: Option, + ) -> DeviceInfo { + let platform = platform.map(|p| { + let mut buf = Vec::new(); + p.encode(&mut buf).unwrap(); + BASE64_STANDARD.encode(&buf) + }); + + DeviceInfo { + version, + platform, + ..Default::default() + } + } + + #[test] + fn test_parse_client_version_platform() { + // Test with valid version and platform + let info = create_device_info( + Some("1.5.0".to_string()), + Some(ClientPlatformInfo { + os_family: "windows".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), + ); + let (version, platform) = parse_client_version_platform(Some(&info)); + assert!(version.is_some()); + assert_eq!(version.unwrap(), Version::new(1, 5, 0)); + assert!(platform.is_some()); + assert_eq!(platform.unwrap().os_family, "windows"); + + // Test with no DeviceInfo + let (version, platform) = parse_client_version_platform(None); + assert!(version.is_none()); + assert!(platform.is_none()); + + // Test with invalid version string + let info = create_device_info( + Some("invalid.version".to_string()), + Some(ClientPlatformInfo { + os_family: "linux".to_string(), + os_type: "Ubuntu".to_string(), + version: "22.04".to_string(), + ..Default::default() + }), + ); + let (version, platform) = parse_client_version_platform(Some(&info)); + assert!(version.is_none()); + assert!(platform.is_some()); + + // Test with missing version field + let info = create_device_info( + None, + Some(ClientPlatformInfo { + os_family: "linux".to_string(), + os_type: "Ubuntu".to_string(), + version: "22.04".to_string(), + ..Default::default() + }), + ); + let (version, platform) = parse_client_version_platform(Some(&info)); + assert!(version.is_none()); + assert!(platform.is_some()); + + // Test with missing platform field + let info = create_device_info(Some("1.5.0".to_string()), None); + let (version, platform) = parse_client_version_platform(Some(&info)); + assert!(version.is_some()); + assert!(platform.is_none()); + + // Test with both fields missing + let info = create_device_info(None, None); + let (version, platform) = parse_client_version_platform(Some(&info)); + assert!(version.is_none()); + assert!(platform.is_none()); + + // Test with pre-release version + let info = create_device_info( + Some("1.5.0-alpha1".to_string()), + Some(ClientPlatformInfo { + os_family: "macos".to_string(), + os_type: "macOS".to_string(), + version: "14.0".to_string(), + ..Default::default() + }), + ); + let (version, platform) = parse_client_version_platform(Some(&info)); + assert!(version.is_some()); + assert_eq!(version.unwrap(), Version::parse("1.5.0-alpha1").unwrap()); + assert!(platform.is_some()); + } + + #[test] + fn test_client_feature_is_supported_by_device() { + // Test ServiceLocations feature with supported version and OS + let info = create_device_info( + Some("1.6.0".to_string()), + Some(ClientPlatformInfo { + os_family: "windows".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), + ); + assert!( + ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should be supported on Windows with version 1.6.0" + ); + + // Test with exact minimum version + let info = create_device_info( + Some("1.6.0".to_string()), + Some(ClientPlatformInfo { + os_family: "Windows".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), + ); + assert!( + ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should be supported at minimum version" + ); + + // Test with higher version + let info = create_device_info( + Some("2.0.0".to_string()), + Some(ClientPlatformInfo { + os_family: "WINDOWS".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), + ); + assert!( + ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should be supported with higher version" + ); + + // Test with version below minimum + let info = create_device_info( + Some("1.5.9".to_string()), + Some(ClientPlatformInfo { + os_family: "windows".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), + ); + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should not be supported below minimum version" + ); + + // Test with wrong OS family (linux) + let info = create_device_info( + Some("1.6.0".to_string()), + Some(ClientPlatformInfo { + os_family: "linux".to_string(), + os_type: "Ubuntu".to_string(), + version: "22.04".to_string(), + ..Default::default() + }), + ); + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should not be supported on Linux" + ); + + // Test with wrong OS family (macos) + let info = create_device_info( + Some("1.6.0".to_string()), + Some(ClientPlatformInfo { + os_family: "macos".to_string(), + os_type: "macOS".to_string(), + version: "14.0".to_string(), + ..Default::default() + }), + ); + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should not be supported on macOS" + ); + + // Test with no DeviceInfo + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(None), + "ServiceLocations should not be supported without device info" + ); + + // Test with missing version + let info = create_device_info( + None, + Some(ClientPlatformInfo { + os_family: "windows".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), + ); + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should not be supported without version info" + ); + + // Test with missing platform + let info = create_device_info(Some("1.6.0".to_string()), None); + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should not be supported without platform info" + ); + + // Test with invalid version string + let info = create_device_info( + Some("invalid".to_string()), + Some(ClientPlatformInfo { + os_family: "windows".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), + ); + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should not be supported with invalid version" + ); + + // Test case insensitivity of OS family matching + let info = create_device_info( + Some("1.6.0".to_string()), + Some(ClientPlatformInfo { + os_family: "WiNdOwS".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), + ); + assert!( + ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should be supported with mixed-case OS family" + ); + + // Test with pre-release version above minimum + let info = create_device_info( + Some("1.7.0-alpha1".to_string()), + Some(ClientPlatformInfo { + os_family: "windows".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), + ); + assert!( + ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should be supported with pre-release version above minimum" + ); + + // Test with pre-release version below minimum + let info = create_device_info( + Some("1.5.0-alpha1".to_string()), + Some(ClientPlatformInfo { + os_family: "windows".to_string(), + os_type: "Windows".to_string(), + version: "11".to_string(), + ..Default::default() + }), + ); + assert!( + !ClientFeature::ServiceLocations.is_supported_by_device(Some(&info)), + "ServiceLocations should not be supported with pre-release version below minimum" + ); + } +} diff --git a/crates/defguard_core/src/grpc/enrollment.rs b/crates/defguard_core/src/grpc/enrollment.rs index 639ee3cf7..c7a43069b 100644 --- a/crates/defguard_core/src/grpc/enrollment.rs +++ b/crates/defguard_core/src/grpc/enrollment.rs @@ -43,7 +43,10 @@ use crate::{ limits::update_counts, }, events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, EnrollmentEvent}, - grpc::utils::{build_device_config_response, new_polling_token, parse_client_info}, + grpc::{ + client_version::ClientFeature, + utils::{build_device_config_response, new_polling_token, parse_client_ip_agent}, + }, handlers::{ mail::{ send_email_mfa_activation_email, send_mfa_configured_email, send_new_device_added_email, @@ -289,7 +292,7 @@ impl EnrollmentServer { })?; // Prepare event context and push the event - let (ip, user_agent) = parse_client_info(&info).map_err(Status::internal)?; + let (ip, user_agent) = parse_client_ip_agent(&info).map_err(Status::internal)?; let context = BidiRequestContext::new(user_id, username, ip, user_agent); self.emit_event(context, EnrollmentEvent::EnrollmentStarted) .map_err(|err| { @@ -479,7 +482,7 @@ impl EnrollmentServer { info!("User {} activated", user.username); // Prepare event context and push the event - let (ip, user_agent) = parse_client_info(&req_device_info).map_err(Status::internal)?; + let (ip, user_agent) = parse_client_ip_agent(&req_device_info).map_err(Status::internal)?; let context = BidiRequestContext::new(user.id, user.username.clone(), ip, user_agent); self.emit_event(context, EnrollmentEvent::EnrollmentCompleted) .map_err(|err| { @@ -806,6 +809,16 @@ impl EnrollmentServer { Status::internal("unexpected error") })?; + // Don't send them service locations if they don't support it + let configs = configs + .into_iter() + .filter(|config| { + config.service_location_mode == ServiceLocationMode::Disabled + || ClientFeature::ServiceLocations + .is_supported_by_device(req_device_info.as_ref()) + }) + .collect::>(); + let template_locations: Vec = configs .iter() .map(|c| TemplateLocation { @@ -854,7 +867,7 @@ impl EnrollmentServer { }; // Prepare event context and push the event - let (ip, user_agent) = parse_client_info(&req_device_info).map_err(Status::internal)?; + let (ip, user_agent) = parse_client_ip_agent(&req_device_info).map_err(Status::internal)?; let context = BidiRequestContext::new(user.id, user.username.clone(), ip, user_agent); self.emit_event(context, EnrollmentEvent::EnrollmentDeviceAdded { device }) .map_err(|err| { @@ -870,6 +883,7 @@ impl EnrollmentServer { pub async fn get_network_info( &self, request: ExistingDevice, + device_info: Option, ) -> Result { debug!("Getting network info for device: {:?}", request.pubkey); let token = self.validate_session(request.token.as_ref()).await?; @@ -896,7 +910,7 @@ impl EnrollmentServer { } let token = new_polling_token(&self.pool, &device).await?; - build_device_config_response(&self.pool, device, Some(token)).await + build_device_config_response(&self.pool, device, Some(token), device_info).await } // TODO: Add events diff --git a/crates/defguard_core/src/grpc/mod.rs b/crates/defguard_core/src/grpc/mod.rs index 85cf800d9..96dbd24e6 100644 --- a/crates/defguard_core/src/grpc/mod.rs +++ b/crates/defguard_core/src/grpc/mod.rs @@ -69,6 +69,7 @@ static VERSION_ZERO: Version = Version::new(0, 0, 0); mod auth; pub(crate) mod client_mfa; +pub mod client_version; pub mod enrollment; pub mod gateway; mod interceptor; @@ -234,7 +235,11 @@ async fn handle_proxy_message_loop( } // rpc GetNetworkInfo (ExistingDevice) returns (DeviceConfigResponse) Some(core_request::Payload::ExistingDevice(request)) => { - match context.enrollment_server.get_network_info(request).await { + match context + .enrollment_server + .get_network_info(request, received.device_info) + .await + { Ok(response_payload) => { Some(core_response::Payload::DeviceConfig(response_payload)) } @@ -345,7 +350,11 @@ async fn handle_proxy_message_loop( } // rpc LocationInfo (LocationInfoRequest) returns (LocationInfoResponse) Some(core_request::Payload::InstanceInfo(request)) => { - match context.polling_server.info(request).await { + match context + .polling_server + .info(request, received.device_info) + .await + { Ok(response_payload) => { Some(core_response::Payload::InstanceInfo(response_payload)) } diff --git a/crates/defguard_core/src/grpc/password_reset.rs b/crates/defguard_core/src/grpc/password_reset.rs index 0c1957e71..4e8b35e6d 100644 --- a/crates/defguard_core/src/grpc/password_reset.rs +++ b/crates/defguard_core/src/grpc/password_reset.rs @@ -14,7 +14,7 @@ use crate::{ }, enterprise::ldap::utils::ldap_change_password, events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, PasswordResetEvent}, - grpc::utils::parse_client_info, + grpc::utils::parse_client_ip_agent, handlers::{ mail::{send_password_reset_email, send_password_reset_success_email}, user::check_password_strength, @@ -169,7 +169,7 @@ impl PasswordResetServer { ); // Prepare event context and push the event - let (ip, user_agent) = parse_client_info(&req_device_info).map_err(Status::internal)?; + let (ip, user_agent) = parse_client_ip_agent(&req_device_info).map_err(Status::internal)?; let context = BidiRequestContext::new(user.id, user.username, ip, user_agent); self.emit_event(context, PasswordResetEvent::PasswordResetRequested) .map_err(|err| { @@ -235,7 +235,7 @@ impl PasswordResetServer { user.username ); // Prepare event context and push the event - let (ip, user_agent) = parse_client_info(&info).map_err(Status::internal)?; + let (ip, user_agent) = parse_client_ip_agent(&info).map_err(Status::internal)?; let context = BidiRequestContext::new(user.id, user.username, ip, user_agent); self.emit_event(context, PasswordResetEvent::PasswordResetStarted) .map_err(|err| { @@ -308,7 +308,7 @@ impl PasswordResetServer { )?; // Prepare event context and push the event - let (ip, user_agent) = parse_client_info(&req_device_info).map_err(Status::internal)?; + let (ip, user_agent) = parse_client_ip_agent(&req_device_info).map_err(Status::internal)?; let context = BidiRequestContext::new(user.id, user.username, ip, user_agent); self.emit_event(context, PasswordResetEvent::PasswordResetCompleted) .map_err(|err| { diff --git a/crates/defguard_core/src/grpc/utils.rs b/crates/defguard_core/src/grpc/utils.rs index 1f7ffec69..b82e91fa1 100644 --- a/crates/defguard_core/src/grpc/utils.rs +++ b/crates/defguard_core/src/grpc/utils.rs @@ -24,6 +24,7 @@ use crate::{ enterprise::db::models::{ enterprise_settings::EnterpriseSettings, openid_provider::OpenIdProvider, }, + grpc::client_version::ClientFeature, }; // Create a new token for configuration polling. @@ -72,6 +73,7 @@ pub(crate) async fn build_device_config_response( pool: &PgPool, device: Device, token: Option, + device_info: Option, ) -> Result { let settings = Settings::get_current_settings(); @@ -122,15 +124,17 @@ pub(crate) async fn build_device_config_response( ); Status::internal(format!("unexpected error: {err}")) })?; - if network.should_prevent_service_location_usage() { + + if network.service_location_mode != ServiceLocationMode::Disabled { error!( - "Tried to use service location {} with disabled enterprise features.", - network.name + "Network device {} tried to fetch config for service location {}, which is unsupported.", + device.name, network.name ); return Err(Status::permission_denied( - "service location mode is not available", + "service location mode is not available for network devices", )); } + // DEPRECATED(1.5): superseeded by location_mfa_mode let mfa_enabled = network.location_mfa_mode == LocationMfaMode::Internal; let config = @@ -182,6 +186,15 @@ pub(crate) async fn build_device_config_response( ); continue; } + if network.service_location_mode != ServiceLocationMode::Disabled + && !ClientFeature::ServiceLocations.is_supported_by_device(device_info.as_ref()) + { + info!( + "Device {} does not support service locations feature, skipping sending network {} configuration to device {}.", + device.name, network.name, device.name + ); + continue; + } // DEPRECATED(1.5): superseeded by location_mfa_mode let mfa_enabled = network.location_mfa_mode == LocationMfaMode::Internal; if let Some(wireguard_network_device) = wireguard_network_device { @@ -218,7 +231,7 @@ pub(crate) async fn build_device_config_response( info!( "User {}({}) device {}({}) automatically fetched the newest configuration.", - user.username, user.id, device.name, device.id, + user.username, user.id, device.name, device.id ); Ok(DeviceConfigResponse { @@ -238,7 +251,7 @@ pub(crate) async fn build_device_config_response( } /// Parses `DeviceInfo` returning client IP address and user agent. -pub(crate) fn parse_client_info(info: &Option) -> Result<(IpAddr, String), String> { +pub(crate) fn parse_client_ip_agent(info: &Option) -> Result<(IpAddr, String), String> { let Some(info) = info else { error!("Missing DeviceInfo in proxy request"); return Err("missing device info".to_string()); diff --git a/crates/defguard_core/src/version.rs b/crates/defguard_core/src/version.rs index e61142618..849c23233 100644 --- a/crates/defguard_core/src/version.rs +++ b/crates/defguard_core/src/version.rs @@ -9,7 +9,7 @@ use defguard_version::{ComponentInfo, Version, is_version_lower}; use serde::Serialize; use tonic::{Status, service::Interceptor}; -const MIN_PROXY_VERSION: Version = Version::new(1, 5, 0); +const MIN_PROXY_VERSION: Version = Version::new(1, 6, 0); pub const MIN_GATEWAY_VERSION: Version = Version::new(1, 5, 0); static OUTDATED_COMPONENT_LIFETIME: TimeDelta = TimeDelta::hours(1); diff --git a/proto b/proto index fee706013..96249ebde 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit fee706013b3bb5452c3c4dbf35bd973d0637ff25 +Subproject commit 96249ebde0556f4ae8c47eebc6015efb04ed0104