From ceb888c41ac416b35c9b9f6dc4828e6942e05b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 30 Sep 2025 11:11:34 +0200 Subject: [PATCH 01/13] share rust toolchain between shell and package --- nix/package.nix | 27 ++++++++++++++------------- nix/shell.nix | 13 ++++++++++++- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index ae9b6c50..93c55805 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -3,6 +3,8 @@ lib, stdenv, rustPlatform, + rustc, + cargo, makeDesktopItem, }: let pname = "defguard-client"; @@ -17,7 +19,7 @@ categories = ["Network" "Security"]; }; - rustToolchain = pkgs.rust-bin.stable.latest.default; + pnpm = pkgs.pnpm_10; buildInputs = with pkgs; [ at-spi2-atk @@ -38,22 +40,21 @@ desktop-file-utils ]; - nativeBuildInputs = with pkgs; [ - rustToolchain - pkg-config - gobject-introspection - cargo-tauri - nodejs_24 - protobuf + nativeBuildInputs = [ + rustc + cargo + pkgs.pkg-config + pkgs.gobject-introspection + pkgs.cargo-tauri + pkgs.nodejs_24 + pkgs.protobuf pnpm # configures pnpm to use pre-fetched dependencies pnpm.configHook # configures cargo to use pre-fetched dependencies rustPlatform.cargoSetupHook - # perl - wrapGAppsHook # helper to add dynamic library paths - makeWrapper + pkgs.makeWrapper ]; in stdenv.mkDerivation (finalAttrs: rec { @@ -79,7 +80,7 @@ in ; fetcherVersion = 2; - hash = "sha256-GlgQuPpOibPrItt6X9EqV4QmCOyajZh5yy7gHh+O+ME="; + hash = "sha256-Ao3m8qNJsA267ah3qyfpIFeTveiDh8SKKoZ/Q59YjbQ="; }; buildPhase = '' @@ -99,7 +100,7 @@ in # add required library to client binary RPATH wrapProgram $out/bin/${pname} \ - --prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath [pkgs.libayatana-appindicator]} + --prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath [pkgs.libayatana-appindicator pkgs.desktop-file-utils]} mkdir -p $out/share/applications cp ${desktopItem}/share/applications/* $out/share/applications/ diff --git a/nix/shell.nix b/nix/shell.nix index 3de733fd..34c2be3a 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -5,7 +5,17 @@ targets = ["x86_64-apple-darwin" "aarch64-apple-darwin" "x86_64-pc-windows-gnu"]; }; - defguard-client = pkgs.callPackage ./package.nix {}; + # share custom toolchain with package + rustPlatform = pkgs.makeRustPlatform { + cargo = rustToolchain; + rustc = rustToolchain; + }; + + defguard-client = pkgs.callPackage ./package.nix { + inherit rustPlatform; + cargo = rustToolchain; + rustc = rustToolchain; + }; # runtime libraries needed to run the dev server libraries = with pkgs; [ @@ -18,6 +28,7 @@ in # add additional dev tools packages = with pkgs; [ + rustToolchain trunk sqlx-cli vtsls From 25095dda863af897105ca8ae43f93ed44469e167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 30 Sep 2025 11:11:39 +0200 Subject: [PATCH 02/13] update inputs --- flake.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flake.lock b/flake.lock index b10558f0..5000e7ba 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1758213207, - "narHash": "sha256-rqoqF0LEi+6ZT59tr+hTQlxVwrzQsET01U4uUdmqRtM=", + "lastModified": 1758446476, + "narHash": "sha256-5rdAi7CTvM/kSs6fHe1bREIva5W3TbImsto+dxG4mBo=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "f4b140d5b253f5e2a1ff4e5506edbf8267724bde", + "rev": "a1f79a1770d05af18111fbbe2a3ab2c42c0f6cd0", "type": "github" }, "original": { @@ -60,11 +60,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1758249250, - "narHash": "sha256-bg228atm49IZ8koNOlT3bsrFKE9sFjq6vn6Tx8eVgpc=", + "lastModified": 1758681214, + "narHash": "sha256-8cW731vev6kfr58cILO2ZsjHwaPhm88dQ8Q6nTSjP9I=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "e26a009e7edab102bd569dc041459deb6c0009f4", + "rev": "b12ed88d8d33d4f3cbc842bf29fad93bb1437299", "type": "github" }, "original": { From 8caae4d34835e4fde0cb84bef115a5394b451c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 1 Oct 2025 12:00:38 +0200 Subject: [PATCH 03/13] fix path --- src-tauri/src/events.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index 68be593b..d37f2be8 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -4,7 +4,7 @@ use tauri_plugin_notification::NotificationExt; use crate::{tray::show_main_window, ConnectionType}; -// Match src/page/client/types.ts. +// Match src/pages/client/types.ts. #[non_exhaustive] pub enum EventKey { ConnectionChanged, From 366ee532f6f3cedb0f32097201d0bc939d368516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 1 Oct 2025 12:00:43 +0200 Subject: [PATCH 04/13] update inputs --- flake.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flake.lock b/flake.lock index 5000e7ba..35a5b516 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1758446476, - "narHash": "sha256-5rdAi7CTvM/kSs6fHe1bREIva5W3TbImsto+dxG4mBo=", + "lastModified": 1759070547, + "narHash": "sha256-JVZl8NaVRYb0+381nl7LvPE+A774/dRpif01FKLrYFQ=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "a1f79a1770d05af18111fbbe2a3ab2c42c0f6cd0", + "rev": "647e5c14cbd5067f44ac86b74f014962df460840", "type": "github" }, "original": { @@ -60,11 +60,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1758681214, - "narHash": "sha256-8cW731vev6kfr58cILO2ZsjHwaPhm88dQ8Q6nTSjP9I=", + "lastModified": 1759286284, + "narHash": "sha256-JLdGGc4XDutzSD1L65Ni6Ye+oTm8kWfm0KTPMcyl7Y4=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "b12ed88d8d33d4f3cbc842bf29fad93bb1437299", + "rev": "f6f2da475176bb7cff51faae8b3fe879cd393545", "type": "github" }, "original": { From 394510cb406885863033b618dd5d21394905bf1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 1 Oct 2025 12:01:10 +0200 Subject: [PATCH 05/13] combine compilation flags --- src-tauri/src/bin/defguard-client.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/bin/defguard-client.rs b/src-tauri/src/bin/defguard-client.rs index c9a337d3..d03eda8e 100644 --- a/src-tauri/src/bin/defguard-client.rs +++ b/src-tauri/src/bin/defguard-client.rs @@ -290,9 +290,11 @@ fn main() { // Ensure directories have appropriate permissions (dg25-28). #[cfg(unix)] - set_perms(&data_dir); - #[cfg(unix)] - set_perms(&log_dir); + { + set_perms(&data_dir); + set_perms(&log_dir); + } + info!( "Application data (database file) will be stored in: {data_dir:?} and application \ logs in: {log_dir:?}. Logs of the background Defguard service responsible for \ From df51158180eae08db163e187dd72357baee8c9a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 3 Oct 2025 12:37:41 +0200 Subject: [PATCH 06/13] update flake --- flake.lock | 12 ++++++------ nix/package.nix | 5 ++++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/flake.lock b/flake.lock index 5000e7ba..aa8073ef 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1758446476, - "narHash": "sha256-5rdAi7CTvM/kSs6fHe1bREIva5W3TbImsto+dxG4mBo=", + "lastModified": 1759417375, + "narHash": "sha256-O7eHcgkQXJNygY6AypkF9tFhsoDQjpNEojw3eFs73Ow=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "a1f79a1770d05af18111fbbe2a3ab2c42c0f6cd0", + "rev": "dc704e6102e76aad573f63b74c742cd96f8f1e6c", "type": "github" }, "original": { @@ -60,11 +60,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1758681214, - "narHash": "sha256-8cW731vev6kfr58cILO2ZsjHwaPhm88dQ8Q6nTSjP9I=", + "lastModified": 1759458749, + "narHash": "sha256-WKnbJnm1B2+TO2ZUudgS39EzecQeLl4/bnRtd3y46LI=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "b12ed88d8d33d4f3cbc842bf29fad93bb1437299", + "rev": "bbc3a8ae797d1700e57a4f4bcc4e79af727d4138", "type": "github" }, "original": { diff --git a/nix/package.nix b/nix/package.nix index 72269da8..814cb749 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -68,6 +68,9 @@ in cargoDeps = rustPlatform.importCargoLock { lockFile = ../src-tauri/Cargo.lock; + outputHashes = { + "boringtun-0.6.0" = "sha256-UlgcnHAdrWm3S1v5T4W0froF4jJNqRAsfcVuI2EMSgk="; + }; }; # prefetch pnpm dependencies @@ -80,7 +83,7 @@ in ; fetcherVersion = 2; - hash = "sha256-QgHFIJv7BwzRQAzJ30+GhD5lCwVgASM0MwSwlRBWL4I="; + hash = "sha256-Qt4xC7BO7JZn236jXVe2VPAAFNnxdSJAvq+PYflW264="; }; buildPhase = '' From 08e8b6cfd4074d3170e05287205d1a655841f440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 6 Oct 2025 12:57:47 +0200 Subject: [PATCH 07/13] implement client provisioning PoC --- biome.json | 12 +- src-tauri/src/bin/defguard-client.rs | 10 ++ src-tauri/src/database/models/instance.rs | 2 +- src-tauri/src/enterprise/mod.rs | 1 + src-tauri/src/enterprise/provisioning/mod.rs | 84 ++++++++++ src-tauri/src/events.rs | 6 +- src/pages/client/ClientPage.tsx | 27 +++- src/pages/client/clientAPI/types.ts | 2 +- .../ClientSideBar/ClientSideBar.tsx | 5 +- src/pages/client/hooks/useClientStore.tsx | 2 +- .../AddInstanceDeviceForm.tsx | 2 +- .../AddInstanceInitForm.tsx | 2 +- .../ClientInstancePage/ClientInstancePage.tsx | 5 +- .../LocationsList/LocationsList.tsx | 2 +- .../LocationsDetailView.tsx | 2 +- .../LocationConnectionHistory.tsx | 2 +- .../LocationDetails/LocationDetails.tsx | 2 +- .../components/LocationLogs/LocationLogs.tsx | 4 +- .../LocationsGridView/LocationsGridView.tsx | 2 +- src/pages/client/types.ts | 6 + .../components/providers/DeepLinkProvider.tsx | 149 +++--------------- src/shared/hooks/useAddInstance.ts | 136 ++++++++++++++++ 22 files changed, 305 insertions(+), 160 deletions(-) create mode 100644 src-tauri/src/enterprise/provisioning/mod.rs create mode 100644 src/shared/hooks/useAddInstance.ts diff --git a/biome.json b/biome.json index c2ca6e40..a0a83836 100644 --- a/biome.json +++ b/biome.json @@ -27,9 +27,7 @@ "bracketSpacing": true, "expand": "auto", "useEditorconfig": true, - "includes": [ - "./src/**" - ] + "includes": ["./src/**"] }, "linter": { "enabled": true, @@ -70,9 +68,7 @@ "noArrayIndexKey": "off" } }, - "includes": [ - "src/**" - ] + "includes": ["src/**"] }, "javascript": { "formatter": { @@ -94,9 +90,7 @@ }, "overrides": [ { - "includes": [ - "**/*.js" - ] + "includes": ["**/*.js"] } ], "assist": { diff --git a/src-tauri/src/bin/defguard-client.rs b/src-tauri/src/bin/defguard-client.rs index 00a5a222..f8c99a3a 100644 --- a/src-tauri/src/bin/defguard-client.rs +++ b/src-tauri/src/bin/defguard-client.rs @@ -18,6 +18,7 @@ use defguard_client::{ models::{location_stats::LocationStats, tunnel::TunnelStats}, DB_POOL, }, + enterprise::provisioning::handle_client_initialization, periodic::run_periodic_tasks, service, tray::{configure_tray_icon, setup_tray, show_main_window}, @@ -295,6 +296,15 @@ fn main() { app_handle_clone.exit(0); }); debug!("Ctrl-C handler has been set up successfully"); + + let app_handle_clone = app_handle.clone(); + tauri::async_runtime::spawn(async move { + // Wait for frontend to be ready + tokio::time::sleep(std::time::Duration::from_secs(15)).await; + + // Handle client initialization if necessary + handle_client_initialization(&app_handle_clone).await; + }); } RunEvent::ExitRequested { code, api, .. } => { debug!("Received exit request"); diff --git a/src-tauri/src/database/models/instance.rs b/src-tauri/src/database/models/instance.rs index a9aac5b6..bcd74499 100644 --- a/src-tauri/src/database/models/instance.rs +++ b/src-tauri/src/database/models/instance.rs @@ -67,7 +67,7 @@ impl Instance { Ok(()) } - pub(crate) async fn all<'e, E>(executor: E) -> Result, sqlx::Error> + pub async fn all<'e, E>(executor: E) -> Result, sqlx::Error> where E: SqliteExecutor<'e>, { diff --git a/src-tauri/src/enterprise/mod.rs b/src-tauri/src/enterprise/mod.rs index f9a20825..98f9e5ef 100644 --- a/src-tauri/src/enterprise/mod.rs +++ b/src-tauri/src/enterprise/mod.rs @@ -1,2 +1,3 @@ pub mod models; pub mod periodic; +pub mod provisioning; diff --git a/src-tauri/src/enterprise/provisioning/mod.rs b/src-tauri/src/enterprise/provisioning/mod.rs new file mode 100644 index 00000000..0e0aec8e --- /dev/null +++ b/src-tauri/src/enterprise/provisioning/mod.rs @@ -0,0 +1,84 @@ +use std::{fs::OpenOptions, path::Path}; + +use serde::Deserialize; +use tauri::{AppHandle, Emitter, Manager}; + +use crate::{ + database::{models::instance::Instance, DB_POOL}, + events::{AddInstancePayload, EventKey}, +}; + +const CONFIG_FILE_NAME: &str = "provisioning_config"; + +#[derive(Debug, Deserialize)] +pub struct ProvisioningConfig { + pub enrollment_url: String, + pub enrollment_token: String, +} + +impl ProvisioningConfig { + /// Load configuration from a file at `path`. + fn load(path: &Path) -> Option { + let file = match OpenOptions::new().read(true).open(path) { + Ok(file) => file, + Err(err) => { + warn!("Failed to open provisioning configuration file at {path:?}. Error details: {err}"); + return None; + } + }; + match serde_json::from_reader::<_, Self>(file) { + Ok(config) => Some(config), + Err(err) => { + warn!("Failed to parse provisioning configuration file at {path:?}. Error details: {err}"); + return None; + } + } + } +} + +pub fn try_get_provisioning_config(app_data_dir: &Path) -> Option { + debug!("Trying to find provisioning config in {app_data_dir:?}"); + + let config_file_path = app_data_dir.join(CONFIG_FILE_NAME); + ProvisioningConfig::load(&config_file_path) +} + +/// Checks if the client has already been initialized +/// and triggers the process of adding an instance if necessary +pub async fn handle_client_initialization(app_handle: &AppHandle) { + // check if client has already been initialized + match Instance::all(&*DB_POOL).await { + Ok(instances) => { + if instances.is_empty() { + debug!( + "Client has not been initialized yet. Checking if provisioning config exists" + ); + let data_dir = app_handle + .path() + .app_data_dir() + .unwrap_or_else(|_| "UNDEFINED DATA DIRECTORY".into()); + match try_get_provisioning_config(&data_dir) { + Some(config) => { + info!( + "Provisioning config found in {data_dir:?}. Triggering enrollment start." + ); + debug!("Provisioning config: {config:?}"); + let _ = app_handle.emit( + EventKey::AddInstance.into(), + AddInstancePayload { + token: &config.enrollment_token, + url: &config.enrollment_url, + }, + ); + } + None => { + debug!("Provisioning config not found in {data_dir:?}. Proceeding with normal startup.") + } + } + } + } + Err(err) => { + error!("Failed to verify if the client has already been initialized: {err}") + } + } +} diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index d37f2be8..e7267d08 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -95,9 +95,9 @@ impl DeadConnReconnected { } #[derive(Clone, Serialize)] -struct AddInstancePayload<'a> { - token: &'a str, - url: &'a str, +pub struct AddInstancePayload<'a> { + pub token: &'a str, + pub url: &'a str, } /// Handle deep-link URLs. diff --git a/src/pages/client/ClientPage.tsx b/src/pages/client/ClientPage.tsx index 74b04bd4..3a28ceec 100644 --- a/src/pages/client/ClientPage.tsx +++ b/src/pages/client/ClientPage.tsx @@ -2,13 +2,14 @@ import './style.scss'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { listen } from '@tauri-apps/api/event'; +import { error } from '@tauri-apps/plugin-log'; import { useEffect } from 'react'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { shallow } from 'zustand/shallow'; - import { useI18nContext } from '../../i18n/i18n-react'; import { DeepLinkProvider } from '../../shared/components/providers/DeepLinkProvider'; import { useToaster } from '../../shared/defguard-ui/hooks/toasts/useToaster'; +import useAddInstance from '../../shared/hooks/useAddInstance'; import { routes } from '../../shared/routes'; import { clientApi } from './clientAPI/clientApi'; import { ClientSideBar } from './components/ClientSideBar/ClientSideBar'; @@ -20,10 +21,11 @@ import { useClientStore } from './hooks/useClientStore'; import { useMFAModal } from './pages/ClientInstancePage/components/LocationsList/modals/MFAModal/useMFAModal'; import { clientQueryKeys } from './query'; import { + type AddInstancePayload, + ClientConnectionType, type CommonWireguardFields, type DeadConDroppedPayload, TauriEventKey, - ClientConnectionType, } from './types'; const { getInstances, getTunnels, getAppConfig } = clientApi; @@ -45,6 +47,7 @@ export const ClientPage = () => { const openDeadConDroppedModal = useDeadConDroppedModal((s) => s.open); const openMFAModal = useMFAModal((state) => state.open); const { LL } = useI18nContext(); + const { handleAddInstance } = useAddInstance(); const { data: instances } = useQuery({ queryFn: getInstances, @@ -67,6 +70,17 @@ export const ClientPage = () => { refetchOnWindowFocus: false, }); + const handleProvisioning = async (data: AddInstancePayload) => { + try { + console.log('adding with: ', data); + await handleAddInstance(data); + } catch (e) { + error( + `Failed to handle automatic add instance ${JSON.stringify(data)}!\n${JSON.stringify(e)}`, + ); + } + }; + // biome-ignore lint/correctness/useExhaustiveDependencies: migration, checkMeLater useEffect(() => { const appConfigChanged = listen(TauriEventKey.APPLICATION_CONFIG_CHANGED, () => { @@ -186,6 +200,14 @@ export const ClientPage = () => { }, ); + const addInstance = listen( + TauriEventKey.ADD_INSTANCE, + async (data) => { + console.log('adding instance'); + await handleProvisioning(data.payload); + }, + ); + return () => { deadConnectionDropped.then((cleanup) => cleanup()); deadConnectionReconnected.then((cleanup) => cleanup()); @@ -197,6 +219,7 @@ export const ClientPage = () => { mfaTrigger.then((cleanup) => cleanup()); verionMismatch.then((cleanup) => cleanup()); uuidMismatch.then((cleanup) => cleanup()); + addInstance.then((cleanup) => cleanup()); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/pages/client/clientAPI/types.ts b/src/pages/client/clientAPI/types.ts index 39d3ad1b..b1b4869b 100644 --- a/src/pages/client/clientAPI/types.ts +++ b/src/pages/client/clientAPI/types.ts @@ -1,6 +1,6 @@ import type { ThemeKey } from '../../../shared/defguard-ui/hooks/theme/types'; import type { CreateDeviceResponse } from '../../../shared/hooks/api/types'; -import type { DefguardInstance, DefguardLocation, ClientConnectionType } from '../types'; +import type { ClientConnectionType, DefguardInstance, DefguardLocation } from '../types'; export type GetLocationsRequest = { instanceId: number; diff --git a/src/pages/client/components/ClientSideBar/ClientSideBar.tsx b/src/pages/client/components/ClientSideBar/ClientSideBar.tsx index 36a5efb5..7e8a9c94 100644 --- a/src/pages/client/components/ClientSideBar/ClientSideBar.tsx +++ b/src/pages/client/components/ClientSideBar/ClientSideBar.tsx @@ -60,8 +60,9 @@ export const ClientSideBar = () => { {instances.map((instance) => ( { }); useEffect(() => { - const isDefguardInstance = - selectedInstanceType === ClientConnectionType.LOCATION; + const isDefguardInstance = selectedInstanceType === ClientConnectionType.LOCATION; const isTunnelInstance = selectedInstanceType === ClientConnectionType.TUNNEL; if (isDefguardInstance && !selectedInstance) { diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationsList/LocationsList.tsx b/src/pages/client/pages/ClientInstancePage/components/LocationsList/LocationsList.tsx index c708b45e..3c1546ab 100644 --- a/src/pages/client/pages/ClientInstancePage/components/LocationsList/LocationsList.tsx +++ b/src/pages/client/pages/ClientInstancePage/components/LocationsList/LocationsList.tsx @@ -9,9 +9,9 @@ import { useToaster } from '../../../../../../shared/defguard-ui/hooks/toasts/us import { routes } from '../../../../../../shared/routes'; import { useClientStore } from '../../../../hooks/useClientStore'; import { + ClientConnectionType, type CommonWireguardFields, type DefguardInstance, - ClientConnectionType, } from '../../../../types'; import { LocationsDetailView } from './components/LocationsDetailView/LocationsDetailView'; import { LocationsGridView } from './components/LocationsGridView/LocationsGridView'; diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsDetailView/LocationsDetailView.tsx b/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsDetailView/LocationsDetailView.tsx index 54dfc3fe..72fb46f6 100644 --- a/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsDetailView/LocationsDetailView.tsx +++ b/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsDetailView/LocationsDetailView.tsx @@ -13,9 +13,9 @@ import { clientApi } from '../../../../../../clientAPI/clientApi'; import { useClientStore } from '../../../../../../hooks/useClientStore'; import { clientQueryKeys } from '../../../../../../query'; import { + ClientConnectionType, type CommonWireguardFields, type DefguardInstance, - ClientConnectionType, } from '../../../../../../types'; import { LocationConnectionHistory } from './components/LocationConnectionHistory/LocationConnectionHistory'; import { LocationDetailCard } from './components/LocationDetailCard/LocationDetailCard'; diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsDetailView/components/LocationConnectionHistory/LocationConnectionHistory.tsx b/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsDetailView/components/LocationConnectionHistory/LocationConnectionHistory.tsx index 3e547527..ca76cba4 100644 --- a/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsDetailView/components/LocationConnectionHistory/LocationConnectionHistory.tsx +++ b/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsDetailView/components/LocationConnectionHistory/LocationConnectionHistory.tsx @@ -7,8 +7,8 @@ import { Card } from '../../../../../../../../../../shared/defguard-ui/component import { clientApi } from '../../../../../../../../clientAPI/clientApi'; import { clientQueryKeys } from '../../../../../../../../query'; import type { - DefguardLocation, ClientConnectionType, + DefguardLocation, } from '../../../../../../../../types'; import { LocationCardNeverConnected } from '../../../LocationCardNeverConnected/LocationCardNeverConnected'; import { LocationHistoryTable } from './LocationHistoryTable/LocationHistoryTable'; diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsDetailView/components/LocationDetails/LocationDetails.tsx b/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsDetailView/components/LocationDetails/LocationDetails.tsx index 94c33000..4fa78d60 100644 --- a/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsDetailView/components/LocationDetails/LocationDetails.tsx +++ b/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsDetailView/components/LocationDetails/LocationDetails.tsx @@ -11,8 +11,8 @@ import { Label } from '../../../../../../../../../../shared/defguard-ui/componen import { clientApi } from '../../../../../../../../clientAPI/clientApi'; import { clientQueryKeys } from '../../../../../../../../query'; import type { - DefguardLocation, ClientConnectionType, + DefguardLocation, } from '../../../../../../../../types'; import { LocationLogs } from '../LocationLogs/LocationLogs'; diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsDetailView/components/LocationLogs/LocationLogs.tsx b/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsDetailView/components/LocationLogs/LocationLogs.tsx index 94fd2f88..56cdfce1 100644 --- a/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsDetailView/components/LocationLogs/LocationLogs.tsx +++ b/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsDetailView/components/LocationLogs/LocationLogs.tsx @@ -13,8 +13,8 @@ import { Card } from '../../../../../../../../../../shared/defguard-ui/component import type { LogItem, LogLevel } from '../../../../../../../../clientAPI/types'; import { useClientStore } from '../../../../../../../../hooks/useClientStore'; import type { - DefguardLocation, ClientConnectionType, + DefguardLocation, } from '../../../../../../../../types'; import { LocationLogsSelect } from './LocationLogsSelect'; @@ -66,7 +66,7 @@ export const LocationLogs = ({ locationId, connectionType }: Props) => { const element = createLogLineElement(messageString); const scrollAfterAppend = logsContainerElement.current.scrollHeight - - logsContainerElement.current.scrollTop === + logsContainerElement.current.scrollTop === logsContainerElement.current.clientHeight; logsContainerElement.current.appendChild(element); // auto scroll to bottom if user didn't scroll up diff --git a/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsGridView/LocationsGridView.tsx b/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsGridView/LocationsGridView.tsx index 926e94dc..2503380b 100644 --- a/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsGridView/LocationsGridView.tsx +++ b/src/pages/client/pages/ClientInstancePage/components/LocationsList/components/LocationsGridView/LocationsGridView.tsx @@ -13,9 +13,9 @@ import { clientApi } from '../../../../../../clientAPI/clientApi'; import { useClientStore } from '../../../../../../hooks/useClientStore'; import { clientQueryKeys } from '../../../../../../query'; import type { + ClientConnectionType, CommonWireguardFields, DefguardInstance, - ClientConnectionType, } from '../../../../../../types'; import { LocationUsageChart } from '../../../LocationUsageChart/LocationUsageChart'; import { LocationUsageChartType } from '../../../LocationUsageChart/types'; diff --git a/src/pages/client/types.ts b/src/pages/client/types.ts index 97136f0c..89389198 100644 --- a/src/pages/client/types.ts +++ b/src/pages/client/types.ts @@ -92,6 +92,11 @@ export type DeadConDroppedPayload = { peer_alive_period: number; }; +export type AddInstancePayload = { + token: string; + url: string; +}; + export enum TauriEventKey { CONNECTION_CHANGED = 'connection-changed', INSTANCE_UPDATE = 'instance-update', @@ -104,4 +109,5 @@ export enum TauriEventKey { MFA_TRIGGER = 'mfa-trigger', VERSION_MISMATCH = 'version-mismatch', UUID_MISMATCH = 'uuid-mismatch', + ADD_INSTANCE = 'add-instance', } diff --git a/src/shared/components/providers/DeepLinkProvider.tsx b/src/shared/components/providers/DeepLinkProvider.tsx index 3f8792b5..26658c13 100644 --- a/src/shared/components/providers/DeepLinkProvider.tsx +++ b/src/shared/components/providers/DeepLinkProvider.tsx @@ -1,27 +1,16 @@ -import { invoke } from '@tauri-apps/api/core'; import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; -import { debug, error } from '@tauri-apps/plugin-log'; -import dayjs from 'dayjs'; +import { error } from '@tauri-apps/plugin-log'; import { type PropsWithChildren, useCallback, useEffect, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; import z, { string } from 'zod'; -import { clientApi } from '../../../pages/client/clientAPI/clientApi'; -import { useClientStore } from '../../../pages/client/hooks/useClientStore'; -import { AddInstanceFormStep } from '../../../pages/client/pages/ClientAddInstancePage/hooks/types'; -import { useAddInstanceStore } from '../../../pages/client/pages/ClientAddInstancePage/hooks/useAddInstanceStore'; -import { ClientConnectionType } from '../../../pages/client/types'; -import { useEnrollmentStore } from '../../../pages/enrollment/hooks/store/useEnrollmentStore'; -import { useEnrollmentApi } from '../../../pages/enrollment/hooks/useEnrollmentApi'; -import type { EnrollmentStartResponse } from '../../hooks/api/types'; -import { routes } from '../../routes'; +import useAddInstance from '../../hooks/useAddInstance'; enum DeepLink { AddInstance = 'addinstance', } -const linkStorageKey = 'lastSuccessfullyHandledDeepLink'; +export const linkStorageKey = 'lastSuccessfullyHandledDeepLink'; -const storeLink = (value: string) => { +export const storeLink = (value: string) => { sessionStorage.setItem(linkStorageKey, value); }; @@ -63,123 +52,25 @@ const linkIntoPayload = (link: URL | null): LinkPayload | null => { return null; }; -const prepareProxyUrl = (value: string) => { - let proxyUrl = value; - if (proxyUrl[proxyUrl.length - 1] === '/') { - proxyUrl = proxyUrl.slice(0, -1); - } - proxyUrl = `${proxyUrl}/api/v1`; - return proxyUrl; -}; - export const DeepLinkProvider = ({ children }: PropsWithChildren) => { const mounted = useRef(false); - const { - enrollment: { start, networkInfo }, - } = useEnrollmentApi(); - - const setEnrollmentState = useEnrollmentStore((s) => s.init); - const setAddInstanceState = useAddInstanceStore((s) => s.setState); - const setClientState = useClientStore((s) => s.setState); - - const navigate = useNavigate(); - - // biome-ignore lint/correctness/useExhaustiveDependencies: should init once - const handleValidLink = useCallback(async (payload: LinkPayload, rawLink?: string) => { - const { data, link } = payload; - switch (link) { - case DeepLink.AddInstance: - await start({ - token: data.token, - proxyUrl: prepareProxyUrl(data.url), - }).then(async (response) => { - if (response.ok) { - const authCookie = response.headers - .getSetCookie() - .find((cookie) => cookie.startsWith('defguard_proxy=')); - if (authCookie === undefined) { - error('Failed to open deep link, auth cookie missing from proxy response.'); - return; - } - const respData = (await response.json()) as EnrollmentStartResponse; - const instances = await clientApi.getInstances(); - const proxy_api_url = prepareProxyUrl( - respData.instance.proxy_url ?? respData.instance.url, - ); - const existingInstance = instances.find( - (instance) => instance.uuid === respData.instance.id, - ); - if (existingInstance) { - // update existing instance instead - const networkInfoResp = await networkInfo( - { - pubkey: existingInstance.pubkey, - }, - proxy_api_url, - authCookie, - ); - await invoke('update_instance', { - instanceId: existingInstance.id, - response: networkInfoResp, - }); - setClientState({ - selectedInstance: { - type: ClientConnectionType.LOCATION, - id: existingInstance.id, - }, - }); - if (rawLink) { - storeLink(rawLink); - } - debug(`Updated ${existingInstance.name} via deep link`); - navigate(routes.client.base, { replace: true }); - return; - } - if (!respData.user.enrolled) { - // user needs full enrollment - const sessionEnd = dayjs - .unix(respData.deadline_timestamp) - .utc() - .local() - .format(); - const sessionStart = dayjs().local().format(); - // set enrollment - setEnrollmentState({ - enrollmentSettings: respData.settings, - proxy_url: proxy_api_url, - userInfo: respData.user, - adminInfo: respData.admin, - endContent: respData.final_page_content, - cookie: authCookie, - sessionEnd, - sessionStart, - }); - navigate('/enrollment', { replace: true }); - } else { - // only needs to register this device - setAddInstanceState({ - step: AddInstanceFormStep.DEVICE, - response: { - cookie: authCookie, - device_names: respData.user.device_names, - url: proxy_api_url, - }, - }); - navigate('/client/add-instance', { replace: true }); - } - } else { - error( - `Add instance from deep link failed! Proxy enrollment start request failed! status: ${response.status}`, - ); - } - }); - break; - } - if (rawLink) { - storeLink(rawLink); - } - }, []); + const { handleAddInstance } = useAddInstance(); + + const handleValidLink = useCallback( + async (payload: LinkPayload, rawLink?: string) => { + const { data, link } = payload; + switch (link) { + case DeepLink.AddInstance: + await handleAddInstance(data, rawLink); + break; + } + if (rawLink) { + storeLink(rawLink); + } + }, + [handleAddInstance], + ); // biome-ignore lint/correctness/useExhaustiveDependencies: only on mount useEffect(() => { diff --git a/src/shared/hooks/useAddInstance.ts b/src/shared/hooks/useAddInstance.ts new file mode 100644 index 00000000..b48e0c89 --- /dev/null +++ b/src/shared/hooks/useAddInstance.ts @@ -0,0 +1,136 @@ +/** + * Hook which handles adding an instance in the background and triggering enrollment process (if necessary) + * in automated scenarios e.g. deep-link, client provisioning etc. + */ + +import { invoke } from '@tauri-apps/api/core'; +import { debug, error } from '@tauri-apps/plugin-log'; +import dayjs from 'dayjs'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { clientApi } from '../../pages/client/clientAPI/clientApi'; +import { useClientStore } from '../../pages/client/hooks/useClientStore'; +import { AddInstanceFormStep } from '../../pages/client/pages/ClientAddInstancePage/hooks/types'; +import { useAddInstanceStore } from '../../pages/client/pages/ClientAddInstancePage/hooks/useAddInstanceStore'; +import { type AddInstancePayload, ClientConnectionType } from '../../pages/client/types'; +import { useEnrollmentStore } from '../../pages/enrollment/hooks/store/useEnrollmentStore'; +import { useEnrollmentApi } from '../../pages/enrollment/hooks/useEnrollmentApi'; +import { storeLink } from '../components/providers/DeepLinkProvider'; +import type { EnrollmentStartResponse } from '../hooks/api/types'; +import { routes } from '../routes'; + +const prepareProxyUrl = (value: string) => { + let proxyUrl = value; + if (proxyUrl[proxyUrl.length - 1] === '/') { + proxyUrl = proxyUrl.slice(0, -1); + } + proxyUrl = `${proxyUrl}/api/v1`; + return proxyUrl; +}; + +export default function useAddInstance() { + const [loading, setLoading] = useState(false); + + const setEnrollmentState = useEnrollmentStore((s) => s.init); + const setAddInstanceState = useAddInstanceStore((s) => s.setState); + const setClientState = useClientStore((s) => s.setState); + + const navigate = useNavigate(); + + const { + enrollment: { start, networkInfo }, + } = useEnrollmentApi(); + + const handleAddInstance = async (payload: AddInstancePayload, rawLink?: string) => { + setLoading(true); + + console.log('starting enrollment with: ', payload); + await start({ + token: payload.token, + proxyUrl: prepareProxyUrl(payload.url), + }).then(async (response) => { + console.log('got start response from proxy: ', response); + if (response.ok) { + const authCookie = response.headers + .getSetCookie() + .find((cookie) => cookie.startsWith('defguard_proxy=')); + if (authCookie === undefined) { + error('Failed to open deep link, auth cookie missing from proxy response.'); + return; + } + const respData = (await response.json()) as EnrollmentStartResponse; + const instances = await clientApi.getInstances(); + const proxy_api_url = prepareProxyUrl( + respData.instance.proxy_url ?? respData.instance.url, + ); + const existingInstance = instances.find( + (instance) => instance.uuid === respData.instance.id, + ); + if (existingInstance) { + // update existing instance instead + const networkInfoResp = await networkInfo( + { + pubkey: existingInstance.pubkey, + }, + proxy_api_url, + authCookie, + ); + await invoke('update_instance', { + instanceId: existingInstance.id, + response: networkInfoResp, + }); + setClientState({ + selectedInstance: { + type: ClientConnectionType.LOCATION, + id: existingInstance.id, + }, + }); + if (rawLink) { + storeLink(rawLink); + } + debug(`Automatically updated ${existingInstance.name}`); + navigate(routes.client.base, { replace: true }); + return; + } + if (!respData.user.enrolled) { + // user needs full enrollment + const sessionEnd = dayjs + .unix(respData.deadline_timestamp) + .utc() + .local() + .format(); + const sessionStart = dayjs().local().format(); + // set enrollment + setEnrollmentState({ + enrollmentSettings: respData.settings, + proxy_url: proxy_api_url, + userInfo: respData.user, + adminInfo: respData.admin, + endContent: respData.final_page_content, + cookie: authCookie, + sessionEnd, + sessionStart, + }); + navigate('/enrollment', { replace: true }); + } else { + // only needs to register this device + setAddInstanceState({ + step: AddInstanceFormStep.DEVICE, + response: { + cookie: authCookie, + device_names: respData.user.device_names, + url: proxy_api_url, + }, + }); + navigate('/client/add-instance', { replace: true }); + } + } else { + error( + `Add instance automatically failed! Proxy enrollment start request failed! status: ${response.status}`, + ); + } + }); + }; + + return { handleAddInstance, loading, error }; +} From b6b7d95bd021252fdacd201c8aa9829ae667eb4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 20 Oct 2025 13:56:14 +0200 Subject: [PATCH 08/13] load provisioning config on startup --- src-tauri/src/appstate.rs | 5 +++- src-tauri/src/bin/defguard-client.rs | 7 +++++- src-tauri/src/enterprise/provisioning/mod.rs | 26 ++++++++------------ 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/appstate.rs b/src-tauri/src/appstate.rs index f8fa0d96..578ef4f0 100644 --- a/src-tauri/src/appstate.rs +++ b/src-tauri/src/appstate.rs @@ -10,6 +10,7 @@ use crate::{ models::{connection::ActiveConnection, Id}, DB_POOL, }, + enterprise::provisioning::ProvisioningConfig, utils::stats_handler, ConnectionType, }; @@ -18,15 +19,17 @@ pub struct AppState { pub log_watchers: Mutex>, pub app_config: Mutex, stat_threads: Mutex>>, // location ID is the key + pub provisioning_config: Mutex>, } impl AppState { #[must_use] - pub fn new(config: AppConfig) -> Self { + pub fn new(config: AppConfig, provisioning_config: Option) -> Self { AppState { log_watchers: Mutex::new(HashMap::new()), app_config: Mutex::new(config), stat_threads: Mutex::new(HashMap::new()), + provisioning_config: Mutex::new(provisioning_config), } } diff --git a/src-tauri/src/bin/defguard-client.rs b/src-tauri/src/bin/defguard-client.rs index f8c99a3a..f971e018 100644 --- a/src-tauri/src/bin/defguard-client.rs +++ b/src-tauri/src/bin/defguard-client.rs @@ -179,6 +179,11 @@ fn main() { // Prepare `AppConfig`. let config = AppConfig::new(app_handle); + // Check if client needs to be initialized + // and try to load provisioning config if necessary + let provisioning_config = + tauri::async_runtime::block_on(handle_client_initialization(app_handle)); + // Setup logging. // If deriving from env value fails, use config default (env overrides config file). @@ -245,7 +250,7 @@ fn main() { .build(), )?; - let state = AppState::new(config); + let state = AppState::new(config, provisioning_config); app.manage(state); info!("App setup completed, log level: {log_level}"); diff --git a/src-tauri/src/enterprise/provisioning/mod.rs b/src-tauri/src/enterprise/provisioning/mod.rs index 0e0aec8e..fdc5d440 100644 --- a/src-tauri/src/enterprise/provisioning/mod.rs +++ b/src-tauri/src/enterprise/provisioning/mod.rs @@ -1,14 +1,11 @@ use std::{fs::OpenOptions, path::Path}; use serde::Deserialize; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Manager}; -use crate::{ - database::{models::instance::Instance, DB_POOL}, - events::{AddInstancePayload, EventKey}, -}; +use crate::database::{models::instance::Instance, DB_POOL}; -const CONFIG_FILE_NAME: &str = "provisioning_config"; +const CONFIG_FILE_NAME: &str = "enrollment.json"; #[derive(Debug, Deserialize)] pub struct ProvisioningConfig { @@ -30,7 +27,7 @@ impl ProvisioningConfig { Ok(config) => Some(config), Err(err) => { warn!("Failed to parse provisioning configuration file at {path:?}. Error details: {err}"); - return None; + None } } } @@ -44,9 +41,10 @@ pub fn try_get_provisioning_config(app_data_dir: &Path) -> Option Option { // check if client has already been initialized + // we assume that if any instances exist the client has been initialized match Instance::all(&*DB_POOL).await { Ok(instances) => { if instances.is_empty() { @@ -63,13 +61,7 @@ pub async fn handle_client_initialization(app_handle: &AppHandle) { "Provisioning config found in {data_dir:?}. Triggering enrollment start." ); debug!("Provisioning config: {config:?}"); - let _ = app_handle.emit( - EventKey::AddInstance.into(), - AddInstancePayload { - token: &config.enrollment_token, - url: &config.enrollment_url, - }, - ); + return Some(config); } None => { debug!("Provisioning config not found in {data_dir:?}. Proceeding with normal startup.") @@ -81,4 +73,6 @@ pub async fn handle_client_initialization(app_handle: &AppHandle) { error!("Failed to verify if the client has already been initialized: {err}") } } + + None } From c245e4339030c7050b17c989b92a4ea9eae0477a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 20 Oct 2025 14:18:17 +0200 Subject: [PATCH 09/13] add command to fetch current provisioning config --- src-tauri/src/commands.rs | 19 ++++++++++++++++++- src-tauri/src/enterprise/provisioning/mod.rs | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c638e105..2c440913 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -29,7 +29,7 @@ use crate::{ }, DB_POOL, }, - enterprise::periodic::config::poll_instance, + enterprise::{periodic::config::poll_instance, provisioning::ProvisioningConfig}, error::Error, events::EventKey, log_watcher::{ @@ -1120,3 +1120,20 @@ pub async fn command_set_app_config( } Ok(res) } + +#[tauri::command] +pub fn get_provisioning_config( + app_state: State<'_, AppState>, +) -> Result, Error> { + debug!("Running command get_provisioning_config."); + let res = app_state + .provisioning_config + .lock() + .map_err(|_err| { + error!("Failed to acquire lock on client provisioning config"); + Error::StateLockFail + })? + .clone(); + trace!("Returning config: {res:?}"); + Ok(res) +} diff --git a/src-tauri/src/enterprise/provisioning/mod.rs b/src-tauri/src/enterprise/provisioning/mod.rs index fdc5d440..275c50a2 100644 --- a/src-tauri/src/enterprise/provisioning/mod.rs +++ b/src-tauri/src/enterprise/provisioning/mod.rs @@ -7,7 +7,7 @@ use crate::database::{models::instance::Instance, DB_POOL}; const CONFIG_FILE_NAME: &str = "enrollment.json"; -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] pub struct ProvisioningConfig { pub enrollment_url: String, pub enrollment_token: String, From 7417a90fd5a5739d7325f6e2cb6cc9295ebe28dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 21 Oct 2025 09:30:01 +0200 Subject: [PATCH 10/13] handle provisioning explicitly in the frontend --- src-tauri/src/bin/defguard-client.rs | 13 ++++--- src-tauri/src/enterprise/provisioning/mod.rs | 8 ++-- src/pages/client/ClientPage.tsx | 40 ++++++++++++-------- src/pages/client/clientAPI/clientApi.ts | 5 +++ src/pages/client/clientAPI/types.ts | 8 +++- src/pages/client/query.ts | 1 + src/pages/client/types.ts | 1 - src/shared/hooks/useAddInstance.ts | 8 ++-- 8 files changed, 52 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/bin/defguard-client.rs b/src-tauri/src/bin/defguard-client.rs index f971e018..609cc4dc 100644 --- a/src-tauri/src/bin/defguard-client.rs +++ b/src-tauri/src/bin/defguard-client.rs @@ -138,7 +138,8 @@ fn main() { start_global_logwatcher, stop_global_logwatcher, command_get_app_config, - command_set_app_config + command_set_app_config, + get_provisioning_config ]) .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { @@ -179,11 +180,6 @@ fn main() { // Prepare `AppConfig`. let config = AppConfig::new(app_handle); - // Check if client needs to be initialized - // and try to load provisioning config if necessary - let provisioning_config = - tauri::async_runtime::block_on(handle_client_initialization(app_handle)); - // Setup logging. // If deriving from env value fails, use config default (env overrides config file). @@ -250,6 +246,11 @@ fn main() { .build(), )?; + // Check if client needs to be initialized + // and try to load provisioning config if necessary + let provisioning_config = + tauri::async_runtime::block_on(handle_client_initialization(app_handle)); + let state = AppState::new(config, provisioning_config); app.manage(state); diff --git a/src-tauri/src/enterprise/provisioning/mod.rs b/src-tauri/src/enterprise/provisioning/mod.rs index 275c50a2..41cc7caf 100644 --- a/src-tauri/src/enterprise/provisioning/mod.rs +++ b/src-tauri/src/enterprise/provisioning/mod.rs @@ -1,13 +1,13 @@ use std::{fs::OpenOptions, path::Path}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Manager}; use crate::database::{models::instance::Instance, DB_POOL}; const CONFIG_FILE_NAME: &str = "enrollment.json"; -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct ProvisioningConfig { pub enrollment_url: String, pub enrollment_token: String, @@ -57,9 +57,7 @@ pub async fn handle_client_initialization(app_handle: &AppHandle) -> Option { - info!( - "Provisioning config found in {data_dir:?}. Triggering enrollment start." - ); + info!("Provisioning config found in {data_dir:?}."); debug!("Provisioning config: {config:?}"); return Some(config); } diff --git a/src/pages/client/ClientPage.tsx b/src/pages/client/ClientPage.tsx index 3a28ceec..f1fa5587 100644 --- a/src/pages/client/ClientPage.tsx +++ b/src/pages/client/ClientPage.tsx @@ -12,6 +12,7 @@ import { useToaster } from '../../shared/defguard-ui/hooks/toasts/useToaster'; import useAddInstance from '../../shared/hooks/useAddInstance'; import { routes } from '../../shared/routes'; import { clientApi } from './clientAPI/clientApi'; +import type { ProvisioningConfig } from './clientAPI/types'; import { ClientSideBar } from './components/ClientSideBar/ClientSideBar'; import { MfaModalProvider } from './components/MfaModalProvider'; import { DeadConDroppedModal } from './components/modals/DeadConDroppedModal/DeadConDroppedModal'; @@ -21,14 +22,13 @@ import { useClientStore } from './hooks/useClientStore'; import { useMFAModal } from './pages/ClientInstancePage/components/LocationsList/modals/MFAModal/useMFAModal'; import { clientQueryKeys } from './query'; import { - type AddInstancePayload, ClientConnectionType, type CommonWireguardFields, type DeadConDroppedPayload, TauriEventKey, } from './types'; -const { getInstances, getTunnels, getAppConfig } = clientApi; +const { getInstances, getTunnels, getAppConfig, getProvisioningConfig } = clientApi; export const ClientPage = () => { const queryClient = useQueryClient(); @@ -70,13 +70,25 @@ export const ClientPage = () => { refetchOnWindowFocus: false, }); - const handleProvisioning = async (data: AddInstancePayload) => { + const { data: provisioningConfig } = useQuery({ + queryFn: getProvisioningConfig, + queryKey: [clientQueryKeys.getProvisioningConfig], + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + const handleProvisioning = async (config: ProvisioningConfig) => { try { - console.log('adding with: ', data); - await handleAddInstance(data); + await handleAddInstance({ + url: config.enrollment_url, + token: config.enrollment_token, + }); } catch (e) { error( - `Failed to handle automatic add instance ${JSON.stringify(data)}!\n${JSON.stringify(e)}`, + `Failed to handle automatic client provisioning with ${JSON.stringify(config)}.\n Error: ${JSON.stringify(e)}`, + ); + toaster.error( + 'Automatic client provisioning failed, please contact your administrator.', ); } }; @@ -200,14 +212,6 @@ export const ClientPage = () => { }, ); - const addInstance = listen( - TauriEventKey.ADD_INSTANCE, - async (data) => { - console.log('adding instance'); - await handleProvisioning(data.payload); - }, - ); - return () => { deadConnectionDropped.then((cleanup) => cleanup()); deadConnectionReconnected.then((cleanup) => cleanup()); @@ -219,7 +223,6 @@ export const ClientPage = () => { mfaTrigger.then((cleanup) => cleanup()); verionMismatch.then((cleanup) => cleanup()); uuidMismatch.then((cleanup) => cleanup()); - addInstance.then((cleanup) => cleanup()); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -257,6 +260,13 @@ export const ClientPage = () => { } }, [navigate, listChecked, instances, tunnels]); + // biome-ignore lint/correctness/useExhaustiveDependencies: migration, checkMeLater + useEffect(() => { + if (provisioningConfig) { + handleProvisioning(provisioningConfig); + } + }, [provisioningConfig]); + return ( diff --git a/src/pages/client/clientAPI/clientApi.ts b/src/pages/client/clientAPI/clientApi.ts index b689e1aa..4ca1506a 100644 --- a/src/pages/client/clientAPI/clientApi.ts +++ b/src/pages/client/clientAPI/clientApi.ts @@ -17,6 +17,7 @@ import type { GetLocationsRequest, LocationDetails, LocationDetailsRequest, + ProvisioningConfig, RoutingRequest, SaveConfigRequest, SaveDeviceConfigResponse, @@ -129,6 +130,9 @@ const stopGlobalLogWatcher = async (): Promise => const getAppConfig = async (): Promise => invokeWrapper('command_get_app_config'); +const getProvisioningConfig = async (): Promise => + invokeWrapper('get_provisioning_config'); + const setAppConfig = async ( appConfig: Partial, emitEvent: boolean, @@ -164,4 +168,5 @@ export const clientApi = { getLatestAppVersion, startGlobalLogWatcher, stopGlobalLogWatcher, + getProvisioningConfig, }; diff --git a/src/pages/client/clientAPI/types.ts b/src/pages/client/clientAPI/types.ts index b1b4869b..95ed9f93 100644 --- a/src/pages/client/clientAPI/types.ts +++ b/src/pages/client/clientAPI/types.ts @@ -82,6 +82,11 @@ export type AppConfig = { peer_alive_period: number; }; +export type ProvisioningConfig = { + enrollment_token: string; + enrollment_url: string; +}; + export type LocationDetails = { location_id: number; name: string; @@ -142,4 +147,5 @@ export type TauriCommandKey = | 'start_global_logwatcher' | 'stop_global_logwatcher' | 'command_get_app_config' - | 'command_set_app_config'; + | 'command_set_app_config' + | 'get_provisioning_config'; diff --git a/src/pages/client/query.ts b/src/pages/client/query.ts index 93d1c945..a19ec385 100644 --- a/src/pages/client/query.ts +++ b/src/pages/client/query.ts @@ -8,4 +8,5 @@ export const clientQueryKeys = { getLocationDetails: 'GET_LOCATION_DETAILS', getTunnels: 'GET_TUNNELS', getApplicationConfig: 'GET_APPLICATION_CONFIG', + getProvisioningConfig: 'GET_PROVISIONING_CONFIG', }; diff --git a/src/pages/client/types.ts b/src/pages/client/types.ts index 89389198..26f32900 100644 --- a/src/pages/client/types.ts +++ b/src/pages/client/types.ts @@ -109,5 +109,4 @@ export enum TauriEventKey { MFA_TRIGGER = 'mfa-trigger', VERSION_MISMATCH = 'version-mismatch', UUID_MISMATCH = 'uuid-mismatch', - ADD_INSTANCE = 'add-instance', } diff --git a/src/shared/hooks/useAddInstance.ts b/src/shared/hooks/useAddInstance.ts index b48e0c89..f5c578f1 100644 --- a/src/shared/hooks/useAddInstance.ts +++ b/src/shared/hooks/useAddInstance.ts @@ -44,18 +44,18 @@ export default function useAddInstance() { const handleAddInstance = async (payload: AddInstancePayload, rawLink?: string) => { setLoading(true); - console.log('starting enrollment with: ', payload); await start({ token: payload.token, proxyUrl: prepareProxyUrl(payload.url), }).then(async (response) => { - console.log('got start response from proxy: ', response); if (response.ok) { const authCookie = response.headers .getSetCookie() .find((cookie) => cookie.startsWith('defguard_proxy=')); if (authCookie === undefined) { - error('Failed to open deep link, auth cookie missing from proxy response.'); + error( + 'Failed to automatically add new instance, auth cookie missing from proxy response.', + ); return; } const respData = (await response.json()) as EnrollmentStartResponse; @@ -126,7 +126,7 @@ export default function useAddInstance() { } } else { error( - `Add instance automatically failed! Proxy enrollment start request failed! status: ${response.status}`, + `Adding instance automatically failed. Proxy enrollment start request failed with status: ${response.status}`, ); } }); From ad635570533edf62a5041dd485c6eb648f0186dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 21 Oct 2025 10:52:48 +0200 Subject: [PATCH 11/13] wrap hook function in useCallback --- src/shared/hooks/useAddInstance.ts | 180 +++++++++++++++-------------- 1 file changed, 95 insertions(+), 85 deletions(-) diff --git a/src/shared/hooks/useAddInstance.ts b/src/shared/hooks/useAddInstance.ts index f5c578f1..ea3fde7a 100644 --- a/src/shared/hooks/useAddInstance.ts +++ b/src/shared/hooks/useAddInstance.ts @@ -6,7 +6,7 @@ import { invoke } from '@tauri-apps/api/core'; import { debug, error } from '@tauri-apps/plugin-log'; import dayjs from 'dayjs'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { clientApi } from '../../pages/client/clientAPI/clientApi'; import { useClientStore } from '../../pages/client/hooks/useClientStore'; @@ -41,96 +41,106 @@ export default function useAddInstance() { enrollment: { start, networkInfo }, } = useEnrollmentApi(); - const handleAddInstance = async (payload: AddInstancePayload, rawLink?: string) => { - setLoading(true); + const handleAddInstance = useCallback( + async (payload: AddInstancePayload, rawLink?: string) => { + setLoading(true); - await start({ - token: payload.token, - proxyUrl: prepareProxyUrl(payload.url), - }).then(async (response) => { - if (response.ok) { - const authCookie = response.headers - .getSetCookie() - .find((cookie) => cookie.startsWith('defguard_proxy=')); - if (authCookie === undefined) { - error( - 'Failed to automatically add new instance, auth cookie missing from proxy response.', + await start({ + token: payload.token, + proxyUrl: prepareProxyUrl(payload.url), + }).then(async (response) => { + if (response.ok) { + const authCookie = response.headers + .getSetCookie() + .find((cookie) => cookie.startsWith('defguard_proxy=')); + if (authCookie === undefined) { + error( + 'Failed to automatically add new instance, auth cookie missing from proxy response.', + ); + return; + } + const respData = (await response.json()) as EnrollmentStartResponse; + const instances = await clientApi.getInstances(); + const proxy_api_url = prepareProxyUrl( + respData.instance.proxy_url ?? respData.instance.url, ); - return; - } - const respData = (await response.json()) as EnrollmentStartResponse; - const instances = await clientApi.getInstances(); - const proxy_api_url = prepareProxyUrl( - respData.instance.proxy_url ?? respData.instance.url, - ); - const existingInstance = instances.find( - (instance) => instance.uuid === respData.instance.id, - ); - if (existingInstance) { - // update existing instance instead - const networkInfoResp = await networkInfo( - { - pubkey: existingInstance.pubkey, - }, - proxy_api_url, - authCookie, + const existingInstance = instances.find( + (instance) => instance.uuid === respData.instance.id, ); - await invoke('update_instance', { - instanceId: existingInstance.id, - response: networkInfoResp, - }); - setClientState({ - selectedInstance: { - type: ClientConnectionType.LOCATION, - id: existingInstance.id, - }, - }); - if (rawLink) { - storeLink(rawLink); + if (existingInstance) { + // update existing instance instead + const networkInfoResp = await networkInfo( + { + pubkey: existingInstance.pubkey, + }, + proxy_api_url, + authCookie, + ); + await invoke('update_instance', { + instanceId: existingInstance.id, + response: networkInfoResp, + }); + setClientState({ + selectedInstance: { + type: ClientConnectionType.LOCATION, + id: existingInstance.id, + }, + }); + if (rawLink) { + storeLink(rawLink); + } + debug(`Automatically updated ${existingInstance.name}`); + navigate(routes.client.base, { replace: true }); + return; } - debug(`Automatically updated ${existingInstance.name}`); - navigate(routes.client.base, { replace: true }); - return; - } - if (!respData.user.enrolled) { - // user needs full enrollment - const sessionEnd = dayjs - .unix(respData.deadline_timestamp) - .utc() - .local() - .format(); - const sessionStart = dayjs().local().format(); - // set enrollment - setEnrollmentState({ - enrollmentSettings: respData.settings, - proxy_url: proxy_api_url, - userInfo: respData.user, - adminInfo: respData.admin, - endContent: respData.final_page_content, - cookie: authCookie, - sessionEnd, - sessionStart, - }); - navigate('/enrollment', { replace: true }); - } else { - // only needs to register this device - setAddInstanceState({ - step: AddInstanceFormStep.DEVICE, - response: { + if (!respData.user.enrolled) { + // user needs full enrollment + const sessionEnd = dayjs + .unix(respData.deadline_timestamp) + .utc() + .local() + .format(); + const sessionStart = dayjs().local().format(); + // set enrollment + setEnrollmentState({ + enrollmentSettings: respData.settings, + proxy_url: proxy_api_url, + userInfo: respData.user, + adminInfo: respData.admin, + endContent: respData.final_page_content, cookie: authCookie, - device_names: respData.user.device_names, - url: proxy_api_url, - }, - }); - navigate('/client/add-instance', { replace: true }); + sessionEnd, + sessionStart, + }); + navigate('/enrollment', { replace: true }); + } else { + // only needs to register this device + setAddInstanceState({ + step: AddInstanceFormStep.DEVICE, + response: { + cookie: authCookie, + device_names: respData.user.device_names, + url: proxy_api_url, + }, + }); + navigate('/client/add-instance', { replace: true }); + } + } else { + error( + `Adding instance automatically failed. Proxy enrollment start request failed with status: ${response.status}`, + ); } - } else { - error( - `Adding instance automatically failed. Proxy enrollment start request failed with status: ${response.status}`, - ); - } - }); - }; + }); + }, + [ + setClientState, + networkInfo, + start, + setEnrollmentState, + setAddInstanceState, + navigate, + ], + ); return { handleAddInstance, loading, error }; } From 306e3b36929ec31f3d8cb6f1e1d3d05b916c0503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 21 Oct 2025 11:07:00 +0200 Subject: [PATCH 12/13] put provisioning in a dedicated component --- src/components/AutoProvisioningManager.tsx | 46 ++++++++++++++++++++++ src/pages/client/ClientPage.tsx | 38 ++---------------- 2 files changed, 49 insertions(+), 35 deletions(-) create mode 100644 src/components/AutoProvisioningManager.tsx diff --git a/src/components/AutoProvisioningManager.tsx b/src/components/AutoProvisioningManager.tsx new file mode 100644 index 00000000..6c40f627 --- /dev/null +++ b/src/components/AutoProvisioningManager.tsx @@ -0,0 +1,46 @@ +import { useQuery } from '@tanstack/react-query'; +import { error } from '@tauri-apps/plugin-log'; +import { useEffect } from 'react'; +import { clientApi } from '../pages/client/clientAPI/clientApi'; +import type { ProvisioningConfig } from '../pages/client/clientAPI/types'; +import { clientQueryKeys } from '../pages/client/query'; +import { useToaster } from '../shared/defguard-ui/hooks/toasts/useToaster'; +import useAddInstance from '../shared/hooks/useAddInstance'; + +const { getProvisioningConfig } = clientApi; + +export default function AutoProvisioningManager() { + const toaster = useToaster(); + const { handleAddInstance } = useAddInstance(); + const { data: provisioningConfig } = useQuery({ + queryFn: getProvisioningConfig, + queryKey: [clientQueryKeys.getProvisioningConfig], + refetchOnMount: false, + refetchOnWindowFocus: false, + }); + + const handleProvisioning = async (config: ProvisioningConfig) => { + try { + await handleAddInstance({ + url: config.enrollment_url, + token: config.enrollment_token, + }); + } catch (e) { + error( + `Failed to handle automatic client provisioning with ${JSON.stringify(config)}.\n Error: ${JSON.stringify(e)}`, + ); + toaster.error( + 'Automatic client provisioning failed, please contact your administrator.', + ); + } + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: migration, checkMeLater + useEffect(() => { + if (provisioningConfig) { + handleProvisioning(provisioningConfig); + } + }, [provisioningConfig]); + + return null; +} diff --git a/src/pages/client/ClientPage.tsx b/src/pages/client/ClientPage.tsx index f1fa5587..97c760d9 100644 --- a/src/pages/client/ClientPage.tsx +++ b/src/pages/client/ClientPage.tsx @@ -2,17 +2,15 @@ import './style.scss'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { listen } from '@tauri-apps/api/event'; -import { error } from '@tauri-apps/plugin-log'; import { useEffect } from 'react'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { shallow } from 'zustand/shallow'; +import AutoProvisioningManager from '../../components/AutoProvisioningManager'; import { useI18nContext } from '../../i18n/i18n-react'; import { DeepLinkProvider } from '../../shared/components/providers/DeepLinkProvider'; import { useToaster } from '../../shared/defguard-ui/hooks/toasts/useToaster'; -import useAddInstance from '../../shared/hooks/useAddInstance'; import { routes } from '../../shared/routes'; import { clientApi } from './clientAPI/clientApi'; -import type { ProvisioningConfig } from './clientAPI/types'; import { ClientSideBar } from './components/ClientSideBar/ClientSideBar'; import { MfaModalProvider } from './components/MfaModalProvider'; import { DeadConDroppedModal } from './components/modals/DeadConDroppedModal/DeadConDroppedModal'; @@ -28,7 +26,7 @@ import { TauriEventKey, } from './types'; -const { getInstances, getTunnels, getAppConfig, getProvisioningConfig } = clientApi; +const { getInstances, getTunnels, getAppConfig } = clientApi; export const ClientPage = () => { const queryClient = useQueryClient(); @@ -47,7 +45,6 @@ export const ClientPage = () => { const openDeadConDroppedModal = useDeadConDroppedModal((s) => s.open); const openMFAModal = useMFAModal((state) => state.open); const { LL } = useI18nContext(); - const { handleAddInstance } = useAddInstance(); const { data: instances } = useQuery({ queryFn: getInstances, @@ -70,29 +67,6 @@ export const ClientPage = () => { refetchOnWindowFocus: false, }); - const { data: provisioningConfig } = useQuery({ - queryFn: getProvisioningConfig, - queryKey: [clientQueryKeys.getProvisioningConfig], - refetchOnMount: false, - refetchOnWindowFocus: false, - }); - - const handleProvisioning = async (config: ProvisioningConfig) => { - try { - await handleAddInstance({ - url: config.enrollment_url, - token: config.enrollment_token, - }); - } catch (e) { - error( - `Failed to handle automatic client provisioning with ${JSON.stringify(config)}.\n Error: ${JSON.stringify(e)}`, - ); - toaster.error( - 'Automatic client provisioning failed, please contact your administrator.', - ); - } - }; - // biome-ignore lint/correctness/useExhaustiveDependencies: migration, checkMeLater useEffect(() => { const appConfigChanged = listen(TauriEventKey.APPLICATION_CONFIG_CHANGED, () => { @@ -260,13 +234,6 @@ export const ClientPage = () => { } }, [navigate, listChecked, instances, tunnels]); - // biome-ignore lint/correctness/useExhaustiveDependencies: migration, checkMeLater - useEffect(() => { - if (provisioningConfig) { - handleProvisioning(provisioningConfig); - } - }, [provisioningConfig]); - return ( @@ -274,6 +241,7 @@ export const ClientPage = () => { + ); }; From 82471d768eb9b9b24dd213b4c5cfc75161704c1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Tue, 21 Oct 2025 11:30:51 +0200 Subject: [PATCH 13/13] change into a wrapping component --- src/components/AutoProvisioningManager.tsx | 6 +++--- src/pages/client/ClientPage.tsx | 18 ++++++++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/components/AutoProvisioningManager.tsx b/src/components/AutoProvisioningManager.tsx index 6c40f627..536418d3 100644 --- a/src/components/AutoProvisioningManager.tsx +++ b/src/components/AutoProvisioningManager.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { error } from '@tauri-apps/plugin-log'; -import { useEffect } from 'react'; +import { type PropsWithChildren, useEffect } from 'react'; import { clientApi } from '../pages/client/clientAPI/clientApi'; import type { ProvisioningConfig } from '../pages/client/clientAPI/types'; import { clientQueryKeys } from '../pages/client/query'; @@ -9,7 +9,7 @@ import useAddInstance from '../shared/hooks/useAddInstance'; const { getProvisioningConfig } = clientApi; -export default function AutoProvisioningManager() { +export default function AutoProvisioningManager({ children }: PropsWithChildren) { const toaster = useToaster(); const { handleAddInstance } = useAddInstance(); const { data: provisioningConfig } = useQuery({ @@ -42,5 +42,5 @@ export default function AutoProvisioningManager() { } }, [provisioningConfig]); - return null; + return <>{children}; } diff --git a/src/pages/client/ClientPage.tsx b/src/pages/client/ClientPage.tsx index 97c760d9..9358bf44 100644 --- a/src/pages/client/ClientPage.tsx +++ b/src/pages/client/ClientPage.tsx @@ -235,13 +235,15 @@ export const ClientPage = () => { }, [navigate, listChecked, instances, tunnels]); return ( - - - - - - - - + + + + + + + + + + ); };