diff --git a/.typesafe-i18n.json b/.typesafe-i18n.json index b3224e1e..e5c75769 100644 --- a/.typesafe-i18n.json +++ b/.typesafe-i18n.json @@ -1,4 +1,4 @@ { "adapter": "react", - "$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json" + "$schema": "https://unpkg.com/typesafe-i18n@5.27.1/schema/typesafe-i18n.json" } \ No newline at end of file diff --git a/flake.lock b/flake.lock index 21b6ba82..37bef692 100644 --- a/flake.lock +++ b/flake.lock @@ -44,11 +44,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1771423170, - "narHash": "sha256-K7Dg9TQ0mOcAtWTO/FX/FaprtWQ8BmEXTpLIaNRhEwU=", + "lastModified": 1778794387, + "narHash": "sha256-BL04pOS9453Awkeb9f90XBJXBSkWxN+vB7HIgnL0iMM=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "bcc4a9d9533c033d806a46b37dc444f9b0da49dd", + "rev": "8a1b0127302ea51e05bf4ea5a291743fac442406", "type": "github" }, "original": { @@ -99,11 +99,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1771816254, - "narHash": "sha256-vkp3iTF6QmHMvL+34DI93IiMPjS2lqcMlA1fl7nXVsQ=", + "lastModified": 1778815121, + "narHash": "sha256-xlhD+1NVJbhrUUM2usRHW6iKWTXP2uw2Fo6sWJmLg8g=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "085bdbf5dde5477538e4c87d1684b6c6df56c0ad", + "rev": "017351829a9356423afd2cca0dde9b63346c8ab3", "type": "github" }, "original": { diff --git a/src-tauri/src/bin/defguard-client.rs b/src-tauri/src/bin/defguard-client.rs index a01e6f99..086a2718 100644 --- a/src-tauri/src/bin/defguard-client.rs +++ b/src-tauri/src/bin/defguard-client.rs @@ -158,6 +158,7 @@ fn main() { save_device_config, all_instances, connect, + connect_with_posture, disconnect, update_instance, location_stats, @@ -180,7 +181,8 @@ fn main() { command_get_app_config, command_set_app_config, get_provisioning_config, - get_platform_header + get_platform_header, + get_posture_data, ]) .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index eaf5cdb0..727fad94 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -29,14 +29,19 @@ use crate::{ }, DB_POOL, }, - enterprise::{periodic::config::poll_instance, provisioning::ProvisioningConfig}, + enterprise::{ + periodic::config::poll_instance, posture::connect_with_posture_check, + provisioning::ProvisioningConfig, + }, error::Error, events::EventKey, log_watcher::{ global_log_watcher::{spawn_global_log_watcher_task, stop_global_log_watcher_task}, service_log_watcher::stop_log_watcher_task, }, - proto::defguard::client_types::DeviceConfigResponse, + proto::defguard::{ + client_types::DeviceConfigResponse, enterprise::posture::v2::DevicePostureData, + }, tray::{configure_tray_icon, reload_tray_menu}, utils::{ construct_platform_header, disconnect_interface, get_location_interface_details, @@ -418,6 +423,7 @@ pub struct LocationInfo { pub pubkey: String, pub network_id: Id, pub location_mfa_mode: LocationMfaMode, + pub posture_check_required: bool, } impl LocationInfo { @@ -470,6 +476,7 @@ pub async fn all_locations(instance_id: Id) -> Result, Error> pubkey: location.pubkey, network_id: location.network_id, location_mfa_mode: location.location_mfa_mode, + posture_check_required: location.posture_check_required, }; location_info.push(info); } @@ -1413,6 +1420,26 @@ pub fn get_platform_header() -> String { construct_platform_header() } +/// Connect to a location that requires a posture check. +/// +/// Collects device posture data, sends it to the proxy, and on success establishes +/// the WireGuard tunnel using the returned preshared key. +#[tauri::command(async)] +pub async fn connect_with_posture( + location_id: Id, + _connection_type: ConnectionType, + handle: AppHandle, +) -> Result<(), Error> { + debug!("Received a command to connect with posture check to location with ID {location_id}"); + connect_with_posture_check(location_id, &handle).await +} + +#[tauri::command(async)] +pub async fn get_posture_data() -> Result { + debug!("Received a command to prepare posture report"); + Ok(DevicePostureData::new()) +} + #[cfg(test)] mod tests { use super::select_reported_app_version; diff --git a/src-tauri/src/enterprise/mod.rs b/src-tauri/src/enterprise/mod.rs index 6074c054..a76ac9a2 100644 --- a/src-tauri/src/enterprise/mod.rs +++ b/src-tauri/src/enterprise/mod.rs @@ -1,5 +1,6 @@ pub mod inspector; pub mod models; pub mod periodic; +pub mod posture; pub mod provisioning; pub mod service_locations; diff --git a/src-tauri/src/enterprise/posture.rs b/src-tauri/src/enterprise/posture.rs new file mode 100644 index 00000000..04055f31 --- /dev/null +++ b/src-tauri/src/enterprise/posture.rs @@ -0,0 +1,103 @@ +use std::time::Duration; + +use reqwest::{Client, StatusCode}; +use serde::Deserialize; +use tauri::AppHandle; + +use crate::{ + database::{ + models::{instance::Instance, location::Location, wireguard_keys::WireguardKeys}, + DB_POOL, + }, + error::Error, + proto::defguard::enterprise::posture::v2::{ + DevicePostureCheckRequest, DevicePostureCheckResponse, DevicePostureData, + }, + tray::{configure_tray_icon, reload_tray_menu}, + utils::{construct_platform_header, handle_connection_for_location}, + CLIENT_PLATFORM_HEADER, CLIENT_VERSION_HEADER, PKG_VERSION, +}; + +const HTTP_TIMEOUT: Duration = Duration::from_secs(10); +const POSTURE_ENDPOINT: &str = "/api/v1/posture/connect"; + +/// Collects device posture data, sends it to the proxy, and on success establishes +/// the WireGuard tunnel using the returned preshared key. +pub async fn connect_with_posture_check( + location_id: crate::database::models::Id, + handle: &AppHandle, +) -> Result<(), Error> { + let location = Location::find_by_id(&*DB_POOL, location_id) + .await? + .ok_or(Error::NotFound)?; + + let instance = Instance::find_by_id(&*DB_POOL, location.instance_id) + .await? + .ok_or(Error::NotFound)?; + + let keys = WireguardKeys::find_by_instance_id(&*DB_POOL, location.instance_id) + .await? + .ok_or_else(|| { + Error::ResourceNotFound(format!( + "WireGuard keys not found for instance {}", + location.instance_id + )) + })?; + + let posture_data = DevicePostureData::new(); + + let request = DevicePostureCheckRequest { + location_id: location_id as i64, + pubkey: keys.pubkey, + device_posture_data: Some(posture_data), + }; + + let proxy_url = tauri::Url::parse(&instance.proxy_url) + .map_err(|e| Error::InternalError(format!("Invalid proxy URL: {e}")))? + .join(POSTURE_ENDPOINT) + .map_err(|e| Error::InternalError(format!("Failed to build posture URL: {e}")))?; + + debug!("Sending posture check request to {proxy_url}"); + let response = Client::new() + .post(proxy_url) + .json(&request) + .header(CLIENT_VERSION_HEADER, PKG_VERSION) + .header(CLIENT_PLATFORM_HEADER, construct_platform_header()) + .timeout(HTTP_TIMEOUT) + .send() + .await + .map_err(|e| Error::HttpError(e.to_string()))?; + + match response.status() { + StatusCode::OK => { + let body: DevicePostureCheckResponse = response + .json() + .await + .map_err(|e| Error::HttpError(e.to_string()))?; + debug!("Posture check approved for location {location_id}, connecting..."); + handle_connection_for_location(&location, Some(body.preshared_key), handle).await?; + reload_tray_menu(handle).await; + configure_tray_icon(handle).await?; + info!("Connected to location {location} after posture check"); + Ok(()) + } + StatusCode::FORBIDDEN => { + #[derive(Deserialize)] + struct PostureRejection { + error: String, + } + let body: PostureRejection = response + .json() + .await + .map_err(|e| Error::HttpError(e.to_string()))?; + error!( + "Posture check rejected for location {location_id}: {}", + body.error + ); + Err(Error::PostureCheckFailed(body.error)) + } + status => Err(Error::HttpError(format!( + "Unexpected proxy response: {status}" + ))), + } +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 176710cb..28a6db94 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -46,6 +46,10 @@ pub enum Error { ConversionError(String), #[error("JSON error: {0}")] JsonError(#[from] serde_json::Error), + #[error("HTTP request error: {0}")] + HttpError(String), + #[error("Posture check failed: {0}")] + PostureCheckFailed(String), } // we must manually implement serde::Serialize diff --git a/src/pages/client/clientAPI/clientApi.ts b/src/pages/client/clientAPI/clientApi.ts index 3fab2b3c..172b3e53 100644 --- a/src/pages/client/clientAPI/clientApi.ts +++ b/src/pages/client/clientAPI/clientApi.ts @@ -68,6 +68,9 @@ const getLocations = async ( const connect = async (data: ConnectionRequest): Promise => invokeWrapper('connect', data); +const connectWithPosture = async (data: ConnectionRequest): Promise => + invokeWrapper('connect_with_posture', data); + const disconnect = async (data: ConnectionRequest): Promise => invokeWrapper('disconnect', data); @@ -130,6 +133,9 @@ const stopGlobalLogWatcher = async (): Promise => const getAppConfig = async (): Promise => invokeWrapper('command_get_app_config'); +const getPostureData = async (): Promise => + invokeWrapper('get_posture_data'); + const getProvisioningConfig = async (): Promise => invokeWrapper('get_provisioning_config'); @@ -149,9 +155,11 @@ export const clientApi = { getAppConfig, setAppConfig, getInstances, + getPostureData, getTunnels, getLocations, connect, + connectWithPosture, disconnect, getLocationStats, getLastConnection, diff --git a/src/pages/client/clientAPI/types.ts b/src/pages/client/clientAPI/types.ts index 0b970e95..e4131d02 100644 --- a/src/pages/client/clientAPI/types.ts +++ b/src/pages/client/clientAPI/types.ts @@ -132,6 +132,7 @@ export type TauriCommandKey = | 'all_instances' | 'all_locations' | 'connect' + | 'connect_with_posture' | 'disconnect' | 'location_stats' | 'last_connection' diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationCardConnectButton/LocationCardConnectButton.tsx b/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationCardConnectButton/LocationCardConnectButton.tsx index 1b416876..14f78790 100644 --- a/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationCardConnectButton/LocationCardConnectButton.tsx +++ b/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationCardConnectButton/LocationCardConnectButton.tsx @@ -17,7 +17,7 @@ import { clientApi } from '../../../../../../clientAPI/clientApi'; import { type CommonWireguardFields, LocationMfaType } from '../../../../../../types'; import { useMFAModal } from '../../modals/MFAModal/useMFAModal'; -const { connect, disconnect } = clientApi; +const { connect, connectWithPosture, disconnect } = clientApi; type Props = { location?: CommonWireguardFields; @@ -50,6 +50,11 @@ export const LocationCardConnectButton = ({ location }: Props) => { } else { if (mfaEnabled) { openMFAModal(location); + } else if (location.posture_check_required) { + await connectWithPosture({ + locationId: location.id, + connectionType: location.connection_type, + }); } else { await connect({ locationId: location?.id, diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/MFAModal.tsx b/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/MFAModal.tsx index cc63098c..0cd1e900 100644 --- a/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/MFAModal.tsx +++ b/src/pages/client/pages/ClientInstancePage/components/LocationsList/modals/MFAModal/MFAModal.tsx @@ -31,7 +31,7 @@ import { MfaMobileApprove } from './components/MfaMobileApprove/MfaMobileApprove import { BrowserErrorIcon, BrowserPendingIcon, GoToBrowserIcon } from './Icons'; import { useMFAModal } from './useMFAModal'; -const { connect } = clientApi; +const { connect, getPostureData } = clientApi; const CODE_LENGTH = 6; const CLIENT_MFA_ENDPOINT = 'api/v1/client-mfa'; @@ -112,11 +112,17 @@ export const MFAModal = () => { setProxyUrl(selectedInstance.proxy_url); const mfaStartUrl = `${selectedInstance.proxy_url + CLIENT_MFA_ENDPOINT}/start`; + console.log("MFAMofal, location.posture_check_required:", location.posture_check_required); + const posture_data = location.posture_check_required + ? await getPostureData() + : undefined; const data = { method, pubkey: selectedInstance.pubkey, location_id: location.network_id, + posture_data, }; + console.log("MFAModal, data:", data); try { const response = await fetch(mfaStartUrl, { @@ -168,7 +174,7 @@ export const MFAModal = () => { if (errorData === 'selected MFA method not available') { toaster.error(localLL.errors.mfaNotConfigured()); } else { - toaster.error(localLL.errors.mfaStartGeneric()); + toaster.error(errorData); } return; diff --git a/src/pages/client/types.ts b/src/pages/client/types.ts index 8c0b1e66..9954bb26 100644 --- a/src/pages/client/types.ts +++ b/src/pages/client/types.ts @@ -75,7 +75,7 @@ export type CommonWireguardFields = { pubkey: string; instance_id: number; network_id: number; - // TODO: device posture data, if available + posture_check_required: boolean; }; export type SelectedInstance = {