diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c913949..fcce08fe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,18 +5,18 @@ on: branches: - main - dev - - "release/**" + - 'release/**' paths-ignore: - - "*.md" - - "LICENSE" + - '*.md' + - 'LICENSE' pull_request: branches: - main - dev - - "release/**" + - 'release/**' paths-ignore: - - "*.md" - - "LICENSE" + - '*.md' + - 'LICENSE' env: CARGO_TERM_COLOR: always @@ -27,7 +27,7 @@ jobs: - codebuild-defguard-client-runner-${{ github.run_id }}-${{ github.run_attempt }} container: - image: rust:1 + image: public.ecr.aws/docker/library/rust:1 options: --user root defaults: diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 14f756e3..f3041a9f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1250,6 +1250,7 @@ dependencies = [ "regex", "reqwest", "rust-ini", + "semver", "serde", "serde_json", "serde_with", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index de08585e..cfce992a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -96,6 +96,7 @@ x25519-dalek = { version = "2", features = [ ] } tauri-plugin-opener = "2.5.0" tauri-plugin-os = "2.3.1" +semver = "1.0.26" [target.'cfg(unix)'.dependencies] tokio-stream = "0.1" diff --git a/src-tauri/src/enterprise/periodic/config.rs b/src-tauri/src/enterprise/periodic/config.rs index e0510275..5cb32286 100644 --- a/src-tauri/src/enterprise/periodic/config.rs +++ b/src-tauri/src/enterprise/periodic/config.rs @@ -1,6 +1,13 @@ -use std::{str::FromStr, time::Duration}; +use std::{ + cmp::Ordering, + collections::HashSet, + str::FromStr, + sync::{LazyLock, Mutex}, + time::Duration, +}; use reqwest::{Client, StatusCode}; +use serde::Serialize; use sqlx::{Sqlite, Transaction}; use tauri::{AppHandle, Emitter, Url}; use tokio::time::sleep; @@ -15,6 +22,7 @@ use crate::{ error::Error, events::EventKey, proto::{DeviceConfigResponse, InstanceInfoRequest, InstanceInfoResponse}, + MIN_CORE_VERSION, MIN_PROXY_VERSION, }; const INTERVAL_SECONDS: Duration = Duration::from_secs(30); @@ -26,6 +34,8 @@ static POLLING_ENDPOINT: &str = "/api/v1/poll"; /// otherwise event is emmited and UI message is displayed. pub async fn poll_config(handle: AppHandle) { debug!("Starting the configuration polling loop."); + // Polling starts sooner than app's frontend may load in dev builds, causing events (toasts) to be lost, + // you may want to wait here before starting if you want to debug it. loop { let Ok(mut transaction) = DB_POOL.begin().await else { error!( @@ -141,6 +151,8 @@ pub async fn poll_instance( instance.name ); + check_min_version(&response, instance, handle)?; + // Return early if the enterprise features are disabled in the core if response.status() == StatusCode::PAYMENT_REQUIRED { debug!( @@ -256,3 +268,132 @@ fn build_request(instance: &Instance) -> Result token: (*token).to_string(), }) } + +/// Tracks instance IDs that for which we already sent notification about version mismatches +/// to prevent duplicate notifications in the app's lifetime. +static NOTIFIED_INSTANCES: LazyLock>> = + LazyLock::new(|| Mutex::new(HashSet::new())); + +const CORE_VERSION_HEADER: &str = "defguard-core-version"; +const PROXY_VERSION_HEADER: &str = "defguard-component-version"; + +#[derive(Clone, Serialize)] +struct VersionMismatchPayload { + instance_name: String, + instance_id: Id, + core_version: String, + proxy_version: String, + core_required_version: String, + proxy_required_version: String, + core_compatible: bool, + proxy_compatible: bool, +} + +fn check_min_version( + response: &reqwest::Response, + instance: &Instance, + handle: &AppHandle, +) -> Result<(), Error> { + let mut notified_instances = NOTIFIED_INSTANCES.lock().unwrap(); + if notified_instances.contains(&instance.id) { + debug!( + "Instance {}({}) already notified about version mismatch, skipping", + instance.name, instance.id + ); + return Ok(()); + } + + let detected_core_version: String; + let detected_proxy_version: String; + + let core_compatible = if let Some(core_version) = response.headers().get(CORE_VERSION_HEADER) { + if let Ok(core_version) = core_version.to_str() { + if let Ok(core_version) = semver::Version::from_str(core_version) { + detected_core_version = core_version.to_string(); + core_version.cmp_precedence(&MIN_CORE_VERSION) != Ordering::Less + } else { + warn!( + "Core version header not a valid semver string in response for instance {}({}): \ + '{core_version}'", + instance.name, instance.id + ); + detected_core_version = core_version.to_string(); + false + } + } else { + warn!( + "Core version header not a valid string in response for instance {}({}): \ + '{core_version:?}'", + instance.name, instance.id + ); + detected_core_version = "unknown".to_string(); + false + } + } else { + warn!( + "Core version header not present in response for instance {}({})", + instance.name, instance.id + ); + detected_core_version = "unknown".to_string(); + false + }; + + let proxy_compatible = if let Some(proxy_version) = response.headers().get(PROXY_VERSION_HEADER) + { + if let Ok(proxy_version) = proxy_version.to_str() { + if let Ok(proxy_version) = semver::Version::from_str(proxy_version) { + detected_proxy_version = proxy_version.to_string(); + proxy_version.cmp_precedence(&MIN_PROXY_VERSION) != Ordering::Less + } else { + warn!( + "Proxy version header not a valid semver string in response for instance {}({}): \ + '{proxy_version}'", + instance.name, instance.id + ); + detected_proxy_version = proxy_version.to_string(); + false + } + } else { + warn!( + "Proxy version header not a valid string in response for instance {}({}): \ + '{proxy_version:?}'", + instance.name, instance.id + ); + detected_proxy_version = "unknown".to_string(); + false + } + } else { + warn!( + "Proxy version header not present in response for instance {}({})", + instance.name, instance.id + ); + detected_proxy_version = "unknown".to_string(); + false + }; + + if !core_compatible || !proxy_compatible { + warn!( + "Instance {} is running incompatible versions: core {detected_core_version}, proxy {detected_proxy_version}. Required \ + versions: core >= {MIN_CORE_VERSION}, proxy >= {MIN_PROXY_VERSION}", + instance.name, + ); + + let payload = VersionMismatchPayload { + instance_name: instance.name.clone(), + instance_id: instance.id, + core_version: detected_core_version, + proxy_version: detected_proxy_version, + core_required_version: MIN_CORE_VERSION.to_string(), + proxy_required_version: MIN_PROXY_VERSION.to_string(), + core_compatible, + proxy_compatible, + }; + if let Err(err) = handle.emit(EventKey::VersionMismatch.into(), payload) { + error!("Failed to emit version mismatch event to the frontend: {err}"); + } else { + notified_instances.insert(instance.id); + } + } + + Ok(()) +} diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index 8ead2f00..e628aade 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -17,6 +17,7 @@ pub enum EventKey { ApplicationConfigChanged, AddInstance, MfaTrigger, + VersionMismatch, } impl From for &'static str { @@ -32,6 +33,7 @@ impl From for &'static str { EventKey::ApplicationConfigChanged => "application-config-changed", EventKey::AddInstance => "add-instance", EventKey::MfaTrigger => "mfa-trigger", + EventKey::VersionMismatch => "version-mismatch", } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 469e2cc5..e57ddd2c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ use std::{fmt, path::PathBuf}; use chrono::NaiveDateTime; +use semver::Version; use serde::{Deserialize, Serialize}; use self::database::models::{Id, NoId}; @@ -66,6 +67,8 @@ pub mod proto { } pub const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), "-", env!("VERGEN_GIT_SHA")); +pub const MIN_CORE_VERSION: Version = Version::new(1, 5, 0); +pub const MIN_PROXY_VERSION: Version = Version::new(1, 5, 0); // This must match tauri.bundle.identifier from tauri.conf.json. const BUNDLE_IDENTIFIER: &str = "net.defguard"; // Returns the path to the user’s data directory. diff --git a/src/i18n/en/index.ts b/src/i18n/en/index.ts index 3f8e0e5e..31a0c1bf 100644 --- a/src/i18n/en/index.ts +++ b/src/i18n/en/index.ts @@ -60,6 +60,10 @@ const en = { error: 'Clipboard is not accessible.', success: 'Content copied to clipboard.', }, + versionMismatch: + 'Your Defguard instance "{instance_name: string}" version is not supported by your Defguard Client version. \ + Defguard Core version: {core_version: string} (required: {core_required_version: string}), Defguard Proxy version: {proxy_version: string} (required: {proxy_required_version: string}). \ + Please contact your administrator.', }, }, components: { diff --git a/src/i18n/i18n-types.ts b/src/i18n/i18n-types.ts index 78a251c1..1ee57158 100644 --- a/src/i18n/i18n-types.ts +++ b/src/i18n/i18n-types.ts @@ -184,6 +184,15 @@ type RootTranslation = { */ success: string } + /** + * Y​o​u​r​ ​D​e​f​g​u​a​r​d​ ​i​n​s​t​a​n​c​e​ ​"​{​i​n​s​t​a​n​c​e​_​n​a​m​e​}​"​ ​v​e​r​s​i​o​n​ ​i​s​ ​n​o​t​ ​s​u​p​p​o​r​t​e​d​ ​b​y​ ​y​o​u​r​ ​D​e​f​g​u​a​r​d​ ​C​l​i​e​n​t​ ​v​e​r​s​i​o​n​.​ ​ ​ ​ ​ ​ ​ ​ ​ ​D​e​f​g​u​a​r​d​ ​C​o​r​e​ ​v​e​r​s​i​o​n​:​ ​{​c​o​r​e​_​v​e​r​s​i​o​n​}​ ​(​r​e​q​u​i​r​e​d​:​ ​{​c​o​r​e​_​r​e​q​u​i​r​e​d​_​v​e​r​s​i​o​n​}​)​,​ ​D​e​f​g​u​a​r​d​ ​P​r​o​x​y​ ​v​e​r​s​i​o​n​:​ ​{​p​r​o​x​y​_​v​e​r​s​i​o​n​}​ ​(​r​e​q​u​i​r​e​d​:​ ​{​p​r​o​x​y​_​r​e​q​u​i​r​e​d​_​v​e​r​s​i​o​n​}​)​.​ ​ ​ ​ ​ ​ ​ ​ ​ ​P​l​e​a​s​e​ ​c​o​n​t​a​c​t​ ​y​o​u​r​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​. + * @param {string} core_required_version + * @param {string} core_version + * @param {string} instance_name + * @param {string} proxy_required_version + * @param {string} proxy_version + */ + versionMismatch: RequiredParams<'core_required_version' | 'core_version' | 'instance_name' | 'proxy_required_version' | 'proxy_version'> } } components: { @@ -1871,6 +1880,10 @@ export type TranslationFunctions = { */ success: () => LocalizedString } + /** + * Your Defguard instance "{instance_name}" version is not supported by your Defguard Client version. Defguard Core version: {core_version} (required: {core_required_version}), Defguard Proxy version: {proxy_version} (required: {proxy_required_version}). Please contact your administrator. + */ + versionMismatch: (arg: { core_required_version: string, core_version: string, instance_name: string, proxy_required_version: string, proxy_version: string }) => LocalizedString } } components: { diff --git a/src/pages/client/ClientPage.tsx b/src/pages/client/ClientPage.tsx index 7dd5f9e3..c6a9c76f 100644 --- a/src/pages/client/ClientPage.tsx +++ b/src/pages/client/ClientPage.tsx @@ -87,6 +87,29 @@ export const ClientPage = () => { }); }); + const verionMismatch = listen(TauriEventKey.VERSION_MISMATCH, (data) => { + const payload = data.payload as { + instance_name: string; + instance_id: number; + core_version: string; + proxy_version: string; + core_required_version: string; + proxy_required_version: string; + core_compatible: boolean; + proxy_compatible: boolean; + }; + toaster.error( + LL.common.messages.versionMismatch({ + instance_name: payload.instance_name, + core_version: payload.core_version, + proxy_version: payload.proxy_version, + core_required_version: payload.core_required_version, + proxy_required_version: payload.proxy_required_version, + }), + { lifetime: -1 }, + ); + }); + const locationUpdate = listen(TauriEventKey.LOCATION_UPDATE, () => { const invalidate = [clientQueryKeys.getLocations, clientQueryKeys.getTunnels]; invalidate.forEach((key) => { @@ -156,6 +179,7 @@ export const ClientPage = () => { locationUpdate.then((cleanup) => cleanup()); appConfigChanged.then((cleanup) => cleanup()); mfaTrigger.then((cleanup) => cleanup()); + verionMismatch.then((cleanup) => cleanup()); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/pages/client/types.ts b/src/pages/client/types.ts index 978fa1b8..0d866443 100644 --- a/src/pages/client/types.ts +++ b/src/pages/client/types.ts @@ -113,4 +113,5 @@ export enum TauriEventKey { DEAD_CONNECTION_RECONNECTED = 'dead-connection-reconnected', APPLICATION_CONFIG_CHANGED = 'application-config-changed', MFA_TRIGGER = 'mfa-trigger', + VERSION_MISMATCH = 'version-mismatch', }