From 2aadc0cd693c7e1efca1d1c5582b4c8aa382f43f Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Mon, 1 Sep 2025 15:36:54 +0200 Subject: [PATCH 1/4] version detection --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/enterprise/periodic/config.rs | 146 +++++++++++++++++++- src-tauri/src/events.rs | 2 + src-tauri/src/lib.rs | 3 + src/i18n/en/index.ts | 4 + src/i18n/i18n-types.ts | 13 ++ src/pages/client/ClientPage.tsx | 24 ++++ src/pages/client/types.ts | 1 + 9 files changed, 194 insertions(+), 1 deletion(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 56a7d00b..033a0add 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 9c507f93..5bce5db4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -95,6 +95,7 @@ x25519-dalek = { version = "2", features = [ "static_secrets", ] } tauri-plugin-opener = "2.5.0" +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..f5b279b7 100644 --- a/src-tauri/src/enterprise/periodic/config.rs +++ b/src-tauri/src/enterprise/periodic/config.rs @@ -1,6 +1,14 @@ -use std::{str::FromStr, time::Duration}; +use std::{ + cmp::Ordering, + collections::HashSet, + f32::MIN, + 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,9 +23,11 @@ use crate::{ error::Error, events::EventKey, proto::{DeviceConfigResponse, InstanceInfoRequest, InstanceInfoResponse}, + MIN_CORE_VERSION, MIN_PROXY_VERSION, }; const INTERVAL_SECONDS: Duration = Duration::from_secs(30); +const INITIAL_POLL_DELAY: Duration = Duration::from_secs(3); const HTTP_REQ_TIMEOUT: Duration = Duration::from_secs(5); static POLLING_ENDPOINT: &str = "/api/v1/poll"; @@ -26,6 +36,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, causing events (toasts) to be lost, wait a bit before starting. + sleep(INITIAL_POLL_DELAY).await; loop { let Ok(mut transaction) = DB_POOL.begin().await else { error!( @@ -141,6 +153,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 +270,133 @@ 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 { + // Mark this instance as notified + notified_instances.insert(instance.id); + } + } + + Ok(()) +} diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index 77f0856c..7b6c70d3 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -18,6 +18,7 @@ pub enum EventKey { ApplicationConfigChanged, AddInstance, MfaTrigger, + VersionMismatch, } impl From for &'static str { @@ -34,6 +35,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 38301ff6..c51ac2c9 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) => { @@ -162,6 +185,7 @@ export const ClientPage = () => { appConfigChanged.then((cleanup) => cleanup()); addInstance.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 c73966af..b5b3e90e 100644 --- a/src/pages/client/types.ts +++ b/src/pages/client/types.ts @@ -120,4 +120,5 @@ export enum TauriEventKey { APPLICATION_CONFIG_CHANGED = 'application-config-changed', ADD_INSTANCE = 'add-instance', MFA_TRIGGER = 'mfa-trigger', + VERSION_MISMATCH = 'version-mismatch', } From a1d320a162865c6a17cc06f6954e022f2ed944c2 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Mon, 1 Sep 2025 15:48:44 +0200 Subject: [PATCH 2/4] fix --- src-tauri/src/enterprise/periodic/config.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src-tauri/src/enterprise/periodic/config.rs b/src-tauri/src/enterprise/periodic/config.rs index f5b279b7..e0e66ab1 100644 --- a/src-tauri/src/enterprise/periodic/config.rs +++ b/src-tauri/src/enterprise/periodic/config.rs @@ -1,7 +1,6 @@ use std::{ cmp::Ordering, collections::HashSet, - f32::MIN, str::FromStr, sync::{LazyLock, Mutex}, time::Duration, @@ -393,7 +392,6 @@ fn check_min_version( if let Err(err) = handle.emit(EventKey::VersionMismatch.into(), payload) { error!("Failed to emit version mismatch event to the frontend: {err}"); } else { - // Mark this instance as notified notified_instances.insert(instance.id); } } From e98fa51a244a20a5b1e5969af3bd2f48f6eb2389 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Mon, 1 Sep 2025 16:37:35 +0200 Subject: [PATCH 3/4] Update config.rs --- src-tauri/src/enterprise/periodic/config.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/enterprise/periodic/config.rs b/src-tauri/src/enterprise/periodic/config.rs index e0e66ab1..5cb32286 100644 --- a/src-tauri/src/enterprise/periodic/config.rs +++ b/src-tauri/src/enterprise/periodic/config.rs @@ -26,7 +26,6 @@ use crate::{ }; const INTERVAL_SECONDS: Duration = Duration::from_secs(30); -const INITIAL_POLL_DELAY: Duration = Duration::from_secs(3); const HTTP_REQ_TIMEOUT: Duration = Duration::from_secs(5); static POLLING_ENDPOINT: &str = "/api/v1/poll"; @@ -35,8 +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, causing events (toasts) to be lost, wait a bit before starting. - sleep(INITIAL_POLL_DELAY).await; + // 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!( From 9d793bdcf5937842f6d4f792819b25a89cd22587 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Mon, 1 Sep 2025 16:39:29 +0200 Subject: [PATCH 4/4] fix? --- .github/workflows/test.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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: