From b1a7291ae708668522ab8766f106a5352c9e096a Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:10:49 +0200 Subject: [PATCH 1/2] LDAP dry run (#3161) * ldap dry run 1 * format * change table shrinking * make table flex * fix linter errors --- .../src/enterprise/ldap/client.rs | 6 +- .../src/enterprise/ldap/model.rs | 21 +- .../defguard_core/src/enterprise/ldap/sync.rs | 165 +++++++++++++-- .../src/enterprise/ldap/test_client.rs | 10 +- .../src/enterprise/ldap/tests.rs | 189 +++++++++++++++++- crates/defguard_core/src/handlers/settings.rs | 60 +++++- crates/defguard_core/src/lib.rs | 11 +- .../tests/integration/api/settings.rs | 59 +++++- web/messages/en/modal.json | 15 +- web/messages/en/settings.json | 2 +- .../SettingsLdapPage/SettingsLdapPage.tsx | 83 +++++--- .../modals/LdapDryRunModal/DryRunTable.tsx | 123 ++++++++++++ .../LdapDryRunModal/LdapDryRunModal.tsx | 51 +++++ .../modals/LdapDryRunModal/style.scss | 39 ++++ web/src/shared/api/api.ts | 3 + web/src/shared/api/types.ts | 15 ++ web/src/shared/defguard-ui | 2 +- 17 files changed, 801 insertions(+), 53 deletions(-) create mode 100644 web/src/pages/settings/SettingsLdapPage/modals/LdapDryRunModal/DryRunTable.tsx create mode 100644 web/src/pages/settings/SettingsLdapPage/modals/LdapDryRunModal/LdapDryRunModal.tsx create mode 100644 web/src/pages/settings/SettingsLdapPage/modals/LdapDryRunModal/style.scss diff --git a/crates/defguard_core/src/enterprise/ldap/client.rs b/crates/defguard_core/src/enterprise/ldap/client.rs index c475efd6ee..793dac82e7 100644 --- a/crates/defguard_core/src/enterprise/ldap/client.rs +++ b/crates/defguard_core/src/enterprise/ldap/client.rs @@ -29,7 +29,11 @@ fn try_construct_entry(entry: ResultEntry) -> Option { impl LDAPConnection { pub async fn create() -> Result { - let settings = Settings::get_current_settings(); + Self::create_with_settings(Settings::get_current_settings()).await + } + + /// Establishes an LDAP connection using the provided settings + pub async fn create_with_settings(settings: Settings) -> Result { let config = LDAPConfig::try_from(settings.clone())?; let url = settings.ldap_url.ok_or(LdapError::MissingSettings( "LDAP URL is required for LDAP configuration to work".to_owned(), diff --git a/crates/defguard_core/src/enterprise/ldap/model.rs b/crates/defguard_core/src/enterprise/ldap/model.rs index 05732d2e17..e07418dd3d 100644 --- a/crates/defguard_core/src/enterprise/ldap/model.rs +++ b/crates/defguard_core/src/enterprise/ldap/model.rs @@ -297,7 +297,26 @@ where { let settings = Settings::get_current_settings(); let sync_account_status = settings.ldap_uses_ad && settings.ldap_sync_account_status; - let sync_groups = settings.ldap_sync_groups; + ldap_sync_allowed_for_user_scoped( + user, + executor, + sync_account_status, + &settings.ldap_sync_groups, + ) + .await +} + +/// Same as [`ldap_sync_allowed_for_user`] but with the scoping settings passed explicitly. +/// Needed by flows running with settings that differ from the saved ones (LDAP dry run). +pub(crate) async fn ldap_sync_allowed_for_user_scoped<'e, E>( + user: &User, + executor: E, + sync_account_status: bool, + sync_groups: &[String], +) -> sqlx::Result +where + E: PgExecutor<'e>, +{ let my_groups = user.member_of(executor).await?; Ok( (sync_groups.is_empty() || my_groups.iter().any(|g| sync_groups.contains(&g.name))) diff --git a/crates/defguard_core/src/enterprise/ldap/sync.rs b/crates/defguard_core/src/enterprise/ldap/sync.rs index a68ad1a59a..d4b6158489 100644 --- a/crates/defguard_core/src/enterprise/ldap/sync.rs +++ b/crates/defguard_core/src/enterprise/ldap/sync.rs @@ -78,6 +78,7 @@ use defguard_common::db::{ settings::{LdapSyncStatus, update_current_settings}, }, }; +use serde::Serialize; use sqlx::{PgConnection, PgPool}; use tokio::sync::{broadcast::Sender, mpsc::UnboundedSender}; @@ -86,8 +87,8 @@ use crate::{ enrollment_management::try_send_ldap_enrollment_invite, enterprise::{ ldap::model::{ - get_users_without_ldap_path, ldap_sync_allowed_for_user, update_from_ldap_user, - user_from_searchentry, + get_users_without_ldap_path, ldap_sync_allowed_for_user, + ldap_sync_allowed_for_user_scoped, update_from_ldap_user, user_from_searchentry, }, license::get_cached_license, limits::{get_counts, update_counts}, @@ -166,6 +167,74 @@ pub(super) struct UserSyncChanges { pub add_ldap: Vec>, } +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum LdapDryRunAction { + Add, + Remove, +} + +#[derive(Debug, Serialize)] +pub struct LdapDryRunUser { + pub username: String, + pub email: String, + pub first_name: String, + pub last_name: String, + pub action: LdapDryRunAction, +} + +/// Preview of the user changes a full sync would make, split by the system that would be +/// modified. Built from [`UserSyncChanges`]. +#[derive(Debug, Serialize)] +pub struct LdapDryRunResult { + pub defguard: Vec, + pub ldap: Vec, +} + +fn dry_run_user(user: &User, action: LdapDryRunAction) -> LdapDryRunUser { + LdapDryRunUser { + username: user.username.clone(), + email: user.email.clone(), + first_name: user.first_name.clone(), + last_name: user.last_name.clone(), + action, + } +} + +impl From for LdapDryRunResult { + fn from(changes: UserSyncChanges) -> Self { + let mut defguard = Vec::new(); + defguard.extend( + changes + .add_defguard + .iter() + .map(|u| dry_run_user(u, LdapDryRunAction::Add)), + ); + defguard.extend( + changes + .delete_defguard + .iter() + .map(|u| dry_run_user(u, LdapDryRunAction::Remove)), + ); + + let mut ldap = Vec::new(); + ldap.extend( + changes + .add_ldap + .iter() + .map(|u| dry_run_user(u, LdapDryRunAction::Add)), + ); + ldap.extend( + changes + .delete_ldap + .iter() + .map(|u| dry_run_user(u, LdapDryRunAction::Remove)), + ); + + Self { defguard, ldap } + } +} + /// Computes what users should be added/deleted and where pub(super) fn compute_user_sync_changes( all_ldap_users: &mut Vec, @@ -758,17 +827,7 @@ impl super::LDAPConnection { sync_group_members.extend(members); } - let mut all_ldap_users = self.get_all_users().await?; - let mut all_defguard_users = User::all(pool).await?; - - // Filter out users that should be ignored from sync - let mut filtered_users = Vec::new(); - for user in all_defguard_users { - if ldap_sync_allowed_for_user(&user, pool).await? { - filtered_users.push(user); - } - } - all_defguard_users = filtered_users; + let (mut all_ldap_users, mut all_defguard_users) = self.get_sync_users(pool).await?; let ldap_usernames = all_ldap_users .iter() @@ -833,6 +892,86 @@ impl super::LDAPConnection { Ok(()) } + /// Fetches all LDAP users alongside the Defguard users that are allowed to participate in + /// sync, filtering out the ones that should be ignored. + async fn get_sync_users( + &mut self, + pool: &PgPool, + ) -> Result<(Vec, Vec>), LdapError> { + let all_ldap_users = self.get_all_users().await?; + let all_defguard_users = User::all(pool).await?; + + let sync_account_status = self.config.ldap_uses_ad && self.config.ldap_sync_account_status; + let mut filtered_users = Vec::new(); + for user in all_defguard_users { + if ldap_sync_allowed_for_user_scoped( + &user, + pool, + sync_account_status, + &self.config.ldap_sync_groups, + ) + .await? + { + filtered_users.push(user); + } + } + + Ok((all_ldap_users, filtered_users)) + } + + /// Computes the user additions/removals a full sync would perform, without applying any + /// of them. + pub async fn dry_run( + &mut self, + pool: &PgPool, + authority: Authority, + ) -> Result { + debug!("Performing LDAP dry run with authority: {authority:?}"); + + let (mut all_ldap_users, mut all_defguard_users) = self.get_sync_users(pool).await?; + + // Mirror `fix_missing_user_path()` in memory. + let ldap_paths_by_username: HashMap<&str, (&str, Option<&str>)> = all_ldap_users + .iter() + .map(|u| { + ( + u.username.as_str(), + (u.ldap_rdn_value(), u.ldap_user_path.as_deref()), + ) + }) + .collect(); + for defguard_user in &mut all_defguard_users { + if defguard_user.ldap_user_path.is_some() { + continue; + } + if let Some((ldap_rdn, ldap_path)) = + ldap_paths_by_username.get(defguard_user.username.as_str()) + && defguard_user.ldap_rdn_value() == *ldap_rdn + { + defguard_user.ldap_user_path = ldap_path.map(str::to_owned); + } + } + + let mut user_changes = compute_user_sync_changes( + &mut all_ldap_users, + &mut all_defguard_users, + authority, + &self.config, + ); + + let existing_usernames = User::all(pool) + .await? + .into_iter() + .map(|user| user.username) + .collect::>(); + user_changes + .add_defguard + .retain(|user| !existing_usernames.contains(&user.username)); + + debug!("LDAP dry run completed"); + Ok(LdapDryRunResult::from(user_changes)) + } + async fn apply_user_group_sync_changes( &mut self, pool: &PgPool, diff --git a/crates/defguard_core/src/enterprise/ldap/test_client.rs b/crates/defguard_core/src/enterprise/ldap/test_client.rs index 5c05add2c3..b61e607488 100644 --- a/crates/defguard_core/src/enterprise/ldap/test_client.rs +++ b/crates/defguard_core/src/enterprise/ldap/test_client.rs @@ -4,7 +4,7 @@ use std::{ vec::Vec, }; -use defguard_common::db::models::{User, group::Group}; +use defguard_common::db::models::{Settings, User, group::Group}; use ldap3::{Mod, SearchEntry}; use super::{LDAPConfig, LDAPConnection, error::LdapError}; @@ -265,6 +265,14 @@ impl LDAPConnection { }) } + pub async fn create_with_settings(settings: Settings) -> Result { + Ok(Self { + config: LDAPConfig::try_from(settings)?, + url: String::new(), + test_client: TestClient::default(), + }) + } + pub(super) async fn search_users( &mut self, filter: &str, diff --git a/crates/defguard_core/src/enterprise/ldap/tests.rs b/crates/defguard_core/src/enterprise/ldap/tests.rs index 6cd49eee3c..57d99e331d 100644 --- a/crates/defguard_core/src/enterprise/ldap/tests.rs +++ b/crates/defguard_core/src/enterprise/ldap/tests.rs @@ -20,7 +20,7 @@ use tokio::sync::{ use super::{ model::{extract_rdn_value, get_users_without_ldap_path, user_from_searchentry}, sync::{ - Authority, compute_group_sync_changes, compute_user_sync_changes, + Authority, LdapDryRunAction, compute_group_sync_changes, compute_user_sync_changes, extract_intersecting_users, is_ldap_desynced, set_ldap_sync_status, }, test_client::{LdapEvent, group_to_test_attrs, user_to_test_attrs}, @@ -1841,6 +1841,193 @@ async fn test_fix_missing_user_path(_: PgPoolOptions, options: PgConnectOptions) } } +/// A dry run must preview the additions/removals a full sync would make while writing nothing +/// to LDAP or the Defguard database. This guards the hard "no import" requirement of the LDAP +/// setup preview. +#[sqlx::test] +async fn test_ldap_dry_run_previews_changes_without_writing( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let _ = initialize_current_settings(&pool).await; + set_test_license_business(); + + let mut ldap_conn = super::LDAPConnection::create().await.unwrap(); + let config = ldap_conn.config.clone(); + + // Present only in Defguard: a full sync with LDAP authority would remove this user. + make_test_user("defguard_only", Some("defguard_only".to_owned()), None) + .save(&pool) + .await + .unwrap(); + + // Present only in LDAP: a full sync with LDAP authority would import this user. + let ldap_only = make_test_user("ldap_only", Some("ldap_only".to_owned()), None); + ldap_conn + .test_client_mut() + .add_test_user(&ldap_only, &config); + + let before = defguard_sync_snapshot(&pool).await; + ldap_conn.test_client_mut().clear_events(); + + let result = ldap_conn.dry_run(&pool, Authority::LDAP).await.unwrap(); + + let added: Vec<_> = result + .defguard + .iter() + .filter(|u| matches!(u.action, LdapDryRunAction::Add)) + .map(|u| u.username.as_str()) + .collect(); + let removed: Vec<_> = result + .defguard + .iter() + .filter(|u| matches!(u.action, LdapDryRunAction::Remove)) + .map(|u| u.username.as_str()) + .collect(); + assert_eq!(added, vec!["ldap_only"]); + assert_eq!(removed, vec!["defguard_only"]); + + // The dry run must not touch LDAP or the Defguard database. + assert!( + ldap_conn.test_client.get_events().is_empty(), + "dry run emitted LDAP operations: {:?}", + ldap_conn.test_client.get_events() + ); + assert_eq!( + before, + defguard_sync_snapshot(&pool).await, + "dry run mutated Defguard state" + ); +} + +/// A Defguard user that exists in LDAP but has no stored LDAP path must not be previewed as +/// both removed and re-added. A real full sync backfills the missing path first (so the DNs +/// match and the user is treated as unchanged); the dry run replicates that in memory. +#[sqlx::test] +async fn test_ldap_dry_run_does_not_double_list_user_with_missing_path( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let _ = initialize_current_settings(&pool).await; + set_test_license_business(); + + let mut ldap_conn = super::LDAPConnection::create().await.unwrap(); + let config = ldap_conn.config.clone(); + + // Locally-created user with no LDAP path yet, same RDN as the LDAP entry. + make_test_user("shared_user", Some("shared_user".to_owned()), None) + .save(&pool) + .await + .unwrap(); + + // Same user in LDAP, living in an OU (so its DN differs until the path is reconciled). + let mut ldap_user = make_test_user("shared_user", Some("shared_user".to_owned()), None); + ldap_user.ldap_user_path = Some("ou=people,dc=example,dc=com".to_owned()); + ldap_conn + .test_client_mut() + .add_test_user(&ldap_user, &config); + + let result = ldap_conn.dry_run(&pool, Authority::LDAP).await.unwrap(); + + let mentions: Vec<_> = result + .defguard + .iter() + .chain(result.ldap.iter()) + .filter(|u| u.username == "shared_user") + .collect(); + assert!( + mentions.is_empty(), + "user with a missing path was listed as a change: {mentions:?}" + ); +} + +/// A disabled Defguard user that also exists in LDAP is outside the sync scope, so the change +/// computation sees them as missing from Defguard. A real sync skips such additions because +/// the username already exists in the database; the dry run must not preview them as "to be +/// added" either. +#[sqlx::test] +async fn test_ldap_dry_run_skips_disabled_users_existing_in_defguard( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let _ = initialize_current_settings(&pool).await; + set_test_license_business(); + + let mut ldap_conn = super::LDAPConnection::create().await.unwrap(); + let config = ldap_conn.config.clone(); + + let mut disabled_user = make_test_user("disabled_user", Some("disabled_user".to_owned()), None); + disabled_user.is_active = false; + disabled_user.save(&pool).await.unwrap(); + + let ldap_user = make_test_user("disabled_user", Some("disabled_user".to_owned()), None); + ldap_conn + .test_client_mut() + .add_test_user(&ldap_user, &config); + + let result = ldap_conn.dry_run(&pool, Authority::LDAP).await.unwrap(); + + let mentions: Vec<_> = result + .defguard + .iter() + .chain(result.ldap.iter()) + .filter(|u| u.username == "disabled_user") + .collect(); + assert!( + mentions.is_empty(), + "disabled user already present in Defguard was listed as a change: {mentions:?}" + ); +} + +/// The dry run previews not yet saved settings, so user scoping must follow the connection's +/// config (built from the submitted form values) instead of the globally saved settings. +/// Here the saved settings have no sync group restriction, while the submitted config limits +/// the sync to one group: only members of that group may appear in the preview. +#[sqlx::test] +async fn test_ldap_dry_run_scopes_users_by_submitted_settings( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let _ = initialize_current_settings(&pool).await; + set_test_license_business(); + + let mut ldap_conn = super::LDAPConnection::create().await.unwrap(); + // Mirrors a dry run request whose form values restrict the sync to one group. + ldap_conn.config.ldap_sync_groups = vec!["ldap_sync_group".to_owned()]; + + let sync_group = Group::new("ldap_sync_group").save(&pool).await.unwrap(); + + // Both users exist only in Defguard, so with LDAP authority any in-scope user is + // previewed as removed. + let synced_user = make_test_user("synced_user", Some("synced_user".to_owned()), None) + .save(&pool) + .await + .unwrap(); + synced_user.add_to_group(&pool, &sync_group).await.unwrap(); + make_test_user("outside_user", Some("outside_user".to_owned()), None) + .save(&pool) + .await + .unwrap(); + + let result = ldap_conn.dry_run(&pool, Authority::LDAP).await.unwrap(); + + let removed: Vec<_> = result + .defguard + .iter() + .filter(|u| matches!(u.action, LdapDryRunAction::Remove)) + .map(|u| u.username.as_str()) + .collect(); + assert_eq!( + removed, + vec!["synced_user"], + "preview must scope users by the submitted sync groups, not the saved settings" + ); +} + #[sqlx::test] async fn test_sync_users_with_empty_paths_and_nested_ous( _: PgPoolOptions, diff --git a/crates/defguard_core/src/handlers/settings.rs b/crates/defguard_core/src/handlers/settings.rs index 374c13f7c0..2dc23b2b03 100644 --- a/crates/defguard_core/src/handlers/settings.rs +++ b/crates/defguard_core/src/handlers/settings.rs @@ -17,7 +17,11 @@ use super::{ApiResponse, ApiResult}; use crate::{ AppState, auth::{AdminRole, SessionInfo}, - enterprise::{handlers::LicenseInfo, ldap::LDAPConnection, license::update_cached_license}, + enterprise::{ + handlers::LicenseInfo, + ldap::{LDAPConnection, sync::Authority}, + license::update_cached_license, + }, error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, }; @@ -179,3 +183,57 @@ pub(crate) async fn test_ldap_settings(_admin: AdminRole, _license: LicenseInfo) } } } + +/// Tests the LDAP connection using the provided (not yet saved) settings. +pub(crate) async fn test_submitted_ldap_settings( + _admin: AdminRole, + _license: LicenseInfo, + Json(settings): Json, +) -> ApiResult { + debug!("Testing LDAP connection with provided settings"); + match LDAPConnection::create_with_settings(settings).await { + Ok(_) => { + debug!("LDAP connected successfully"); + Ok(ApiResponse::with_status(StatusCode::OK)) + } + Err(err) => { + debug!("LDAP connection rejected: {err}"); + Ok(ApiResponse::with_status(StatusCode::BAD_REQUEST)) + } + } +} + +/// Previews the user changes a full LDAP sync would make using the provided (not yet saved) +/// settings. This is strictly read-only: nothing is imported, removed or persisted. +pub(crate) async fn ldap_dry_run( + _admin: AdminRole, + _license: LicenseInfo, + State(appstate): State, + Json(settings): Json, +) -> ApiResult { + debug!("Performing LDAP dry run with provided settings"); + let authority = if settings.ldap_is_authoritative { + Authority::LDAP + } else { + Authority::Defguard + }; + + let mut connection = match LDAPConnection::create_with_settings(settings).await { + Ok(connection) => connection, + Err(err) => { + debug!("LDAP dry run connection rejected: {err}"); + return Ok(ApiResponse::with_status(StatusCode::BAD_REQUEST)); + } + }; + + match connection.dry_run(&appstate.pool, authority).await { + Ok(result) => { + debug!("LDAP dry run completed successfully"); + Ok(ApiResponse::json(result, StatusCode::OK)) + } + Err(err) => { + debug!("LDAP dry run failed: {err}"); + Ok(ApiResponse::with_status(StatusCode::BAD_REQUEST)) + } + } +} diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 9e31821425..6b78ecdd6a 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -161,8 +161,9 @@ use crate::{ proxy::{delete_proxy, proxy_details, proxy_list, update_proxy}, resource_display::get_locations_display, settings::{ - get_settings, get_settings_essentials, patch_settings, set_default_branding, - test_ldap_settings, update_settings, + get_settings, get_settings_essentials, ldap_dry_run, patch_settings, + set_default_branding, test_ldap_settings, test_submitted_ldap_settings, + update_settings, }, ssh_authorized_keys::get_authorized_keys, static_ips::{ @@ -416,7 +417,11 @@ pub fn build_webapp( .post(change_enabled), ) // ldap - .route("/ldap/test", get(test_ldap_settings)) + .route( + "/ldap/test", + get(test_ldap_settings).post(test_submitted_ldap_settings), + ) + .route("/ldap/dry_run", post(ldap_dry_run)) // activity log .route("/activity_log", get(get_activity_log_events)) // Proxy routes diff --git a/crates/defguard_core/tests/integration/api/settings.rs b/crates/defguard_core/tests/integration/api/settings.rs index 6541768d89..db61f5475e 100644 --- a/crates/defguard_core/tests/integration/api/settings.rs +++ b/crates/defguard_core/tests/integration/api/settings.rs @@ -1,6 +1,11 @@ -use defguard_common::db::models::{ - Settings, - settings::{SettingsPatch, update_current_settings}, +use std::str::FromStr; + +use defguard_common::{ + db::models::{ + Settings, + settings::{SettingsPatch, update_current_settings}, + }, + secret::SecretStringWrapper, }; use defguard_core::handlers::Auth; use reqwest::StatusCode; @@ -193,6 +198,54 @@ async fn test_ldap_settings_validation(_: PgPoolOptions, options: PgConnectOptio ); } +#[sqlx::test] +async fn test_ldap_connection_test_with_submitted_settings( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + let (client, _client_state) = make_test_client(pool).await; + + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + let response = client.get("/api/v1/settings").send().await; + assert_eq!(response.status(), StatusCode::OK); + let saved_settings: Settings = response.json().await; + + let mut submitted = saved_settings.clone(); + submitted.ldap_url = Some("ldap://127.0.0.1:1".to_owned()); + submitted.ldap_bind_username = Some("cn=admin,dc=example,dc=com".to_owned()); + submitted.ldap_bind_password = Some(SecretStringWrapper::from_str("secret").unwrap()); + submitted.ldap_username_attr = Some("uid".to_owned()); + submitted.ldap_user_search_base = Some("ou=users,dc=example,dc=com".to_owned()); + submitted.ldap_user_obj_class = Some("inetOrgPerson".to_owned()); + submitted.ldap_member_attr = Some("memberUid".to_owned()); + submitted.ldap_groupname_attr = Some("cn".to_owned()); + submitted.ldap_group_obj_class = Some("posixGroup".to_owned()); + submitted.ldap_group_member_attr = Some("memberUid".to_owned()); + submitted.ldap_group_search_base = Some("ou=groups,dc=example,dc=com".to_owned()); + let response = client + .post("/api/v1/ldap/test") + .json(&submitted) + .send() + .await; + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "connection test against an unreachable server should return 400" + ); + + let response = client.get("/api/v1/settings").send().await; + assert_eq!(response.status(), StatusCode::OK); + let settings_after: Settings = response.json().await; + assert_eq!( + settings_after, saved_settings, + "the connection test must not save the submitted settings" + ); +} + #[sqlx::test] async fn test_ldap_remote_enrollment_validation(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/web/messages/en/modal.json b/web/messages/en/modal.json index 23448affcf..9c80625e54 100644 --- a/web/messages/en/modal.json +++ b/web/messages/en/modal.json @@ -258,5 +258,18 @@ "modal_app_update_go_to_release": "Go to release page", "modal_app_update_dismiss": "Dismiss", "modal_app_update_snackbar_message": "New version {version} is available", - "modal_app_update_snackbar_action": "What's new" + "modal_app_update_snackbar_action": "What's new", + "modal_ldap_dry_run_title": "Test synchronization results", + "modal_ldap_dry_run_description": "The results below show the outcome of the connection check using the current synchronization settings. These changes will be applied to the system if the configuration is saved and activated.", + "modal_ldap_dry_run_tab_defguard": "Defguard", + "modal_ldap_dry_run_tab_ldap": "LDAP", + "modal_ldap_dry_run_search_placeholder": "Search", + "modal_ldap_dry_run_col_username": "Username", + "modal_ldap_dry_run_col_email": "Email", + "modal_ldap_dry_run_col_status": "Status", + "modal_ldap_dry_run_status_added": "Will be added", + "modal_ldap_dry_run_status_removed": "Will be removed", + "modal_ldap_dry_run_empty_title": "No changes detected", + "modal_ldap_dry_run_empty_subtitle": "Based on the current LDAP configuration, no changes will be applied to users in this group.", + "modal_ldap_dry_run_search_empty_subtitle": "Please try another search request." } diff --git a/web/messages/en/settings.json b/web/messages/en/settings.json index 0c363ee904..966c671e1e 100644 --- a/web/messages/en/settings.json +++ b/web/messages/en/settings.json @@ -299,7 +299,7 @@ "settings_ldap_helper_sync_interval": "", "settings_ldap_toggle_enable_integration": "Enable LDAP integration", "settings_ldap_button_test_connection": "Test connection", - "settings_ldap_test_connection_tooltip": "To test connection please fill the form and save the changes first.", + "settings_ldap_test_connection_tooltip": "To test the synchronization, please fill in all required fields first.", "settings_ldap_section_remote_enrollment_title": "Secure remote enrollment", "settings_ldap_section_remote_enrollment_description": "Control how Defguard synchronizes users with LDAP — disable sync, import users from LDAP, or keep both systems updated automatically.", "settings_ldap_label_remote_enrollment_enabled": "Enable Secure Remote Enrollment", diff --git a/web/src/pages/settings/SettingsLdapPage/SettingsLdapPage.tsx b/web/src/pages/settings/SettingsLdapPage/SettingsLdapPage.tsx index a5a26138bd..4da982eb7c 100644 --- a/web/src/pages/settings/SettingsLdapPage/SettingsLdapPage.tsx +++ b/web/src/pages/settings/SettingsLdapPage/SettingsLdapPage.tsx @@ -11,11 +11,12 @@ import { SettingsLayout } from '../../../shared/components/SettingsLayout/Settin import './style.scss'; import { useStore } from '@tanstack/react-form'; import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'; -import { Suspense, useMemo } from 'react'; +import { Suspense, useMemo, useState } from 'react'; import Skeleton from 'react-loading-skeleton'; import z from 'zod'; import { m } from '../../../paraglide/messages'; import api from '../../../shared/api/api'; +import type { LdapDryRunResult, Settings } from '../../../shared/api/types'; import { businessBadgeProps } from '../../../shared/components/badges/BusinessBadge'; import { Controls } from '../../../shared/components/Controls/Controls'; import { DescriptionBlock } from '../../../shared/components/DescriptionBlock/DescriptionBlock'; @@ -44,6 +45,7 @@ import { getSettingsQueryOptions, } from '../../../shared/query'; import { canUseBusinessFeature } from '../../../shared/utils/license'; +import { LdapDryRunModal } from './modals/LdapDryRunModal/LdapDryRunModal'; const breadcrumbsLinks = [ ; +const csvToArray = (value: string | null): string[] => + value + ? value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + : []; + const PageForm = () => { - const isAppLdapEnabled = useApp((s) => s.appInfo.ldap_info.enabled); const smtpEnabled = useApp((s) => s.appInfo.smtp_enabled); const { data: licenseInfo } = useSuspenseQuery(getLicenseInfoQueryOptions); const { data: settings } = useSuspenseQuery(getSettingsQueryOptions); @@ -185,10 +194,14 @@ const PageForm = () => { }, }); - const { mutate: handleLdapTest, isPending: testInProgress } = useMutation({ - mutationFn: api.settings.getLdapConnectionStatus, - onSuccess: () => { - Snackbar.default(m.settings_ldap_test_success()); + const [dryRunResult, setDryRunResult] = useState(null); + const [dryRunModalOpen, setDryRunModalOpen] = useState(false); + + const { mutate: handleLdapDryRun, isPending: dryRunInProgress } = useMutation({ + mutationFn: (data: Settings) => api.settings.ldapDryRun(data), + onSuccess: (res) => { + setDryRunResult(res.data); + setDryRunModalOpen(true); }, onError: (e) => { Snackbar.error(m.settings_ldap_test_failed()); @@ -196,6 +209,18 @@ const PageForm = () => { }, }); + const { mutate: handleLdapConnectionTest, isPending: connectionTestInProgress } = + useMutation({ + mutationFn: (data: Settings) => api.settings.testLdapSettings(data), + onSuccess: () => { + Snackbar.default(m.settings_ldap_test_success()); + }, + onError: (e) => { + Snackbar.error(m.settings_ldap_test_failed()); + console.error(e); + }, + }); + const form = useAppForm({ defaultValues, validationLogic: formChangeLogic, @@ -212,18 +237,10 @@ const PageForm = () => { await mutateAsync({ ...value, - ldap_user_auxiliary_obj_classes: value.ldap_user_auxiliary_obj_classes - ? value.ldap_user_auxiliary_obj_classes - .split(',') - .map((item) => item.trim()) - .filter(Boolean) - : [], - ldap_sync_groups: value.ldap_sync_groups - ? value.ldap_sync_groups - .split(',') - .map((item) => item.trim()) - .filter(Boolean) - : [], + ldap_user_auxiliary_obj_classes: csvToArray( + value.ldap_user_auxiliary_obj_classes, + ), + ldap_sync_groups: csvToArray(value.ldap_sync_groups), }); formApi.reset(value); }, @@ -625,10 +642,7 @@ const PageForm = () => { {({ isDefaultValue, isSubmitting }) => ( <>
@@ -639,13 +653,25 @@ const PageForm = () => { iconLeft={IconKind.Refresh} disabled={ isSubmitting || - !isDefaultValue || - !isAppLdapEnabled || + !requiredFieldsFilled || !canUseBusinessLicenseCheck } - loading={testInProgress} + loading={dryRunInProgress || connectionTestInProgress} onClick={() => { - handleLdapTest(); + const values = form.state.values; + const submitted = { + ...settings, + ...values, + ldap_user_auxiliary_obj_classes: csvToArray( + values.ldap_user_auxiliary_obj_classes, + ), + ldap_sync_groups: csvToArray(values.ldap_sync_groups), + } as Settings; + if (values.ldap_sync_enabled) { + handleLdapDryRun(submitted); + } else { + handleLdapConnectionTest(submitted); + } }} />
@@ -666,6 +692,11 @@ const PageForm = () => { + setDryRunModalOpen(false)} + /> ); }; diff --git a/web/src/pages/settings/SettingsLdapPage/modals/LdapDryRunModal/DryRunTable.tsx b/web/src/pages/settings/SettingsLdapPage/modals/LdapDryRunModal/DryRunTable.tsx new file mode 100644 index 0000000000..408f2b0f5f --- /dev/null +++ b/web/src/pages/settings/SettingsLdapPage/modals/LdapDryRunModal/DryRunTable.tsx @@ -0,0 +1,123 @@ +import { + createColumnHelper, + getCoreRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { useMemo, useState } from 'react'; +import { m } from '../../../../../paraglide/messages'; +import type { LdapDryRunUser } from '../../../../../shared/api/types'; +import { Badge } from '../../../../../shared/defguard-ui/components/Badge/Badge'; +import { BadgeVariant } from '../../../../../shared/defguard-ui/components/Badge/types'; +import { EmptyState } from '../../../../../shared/defguard-ui/components/EmptyState/EmptyState'; +import { Search } from '../../../../../shared/defguard-ui/components/Search/Search'; +import { SizedBox } from '../../../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { TableBody } from '../../../../../shared/defguard-ui/components/table/TableBody/TableBody'; +import { TableCell } from '../../../../../shared/defguard-ui/components/table/TableCell/TableCell'; +import { ThemeSpacing } from '../../../../../shared/defguard-ui/types'; + +const columnHelper = createColumnHelper(); + +const textCell = (info: { getValue: () => string }) => ( + + {info.getValue()} + +); + +export const DryRunTable = ({ data }: { data: LdapDryRunUser[] }) => { + const [search, setSearch] = useState(''); + + const columns = useMemo( + () => [ + columnHelper.accessor('username', { + header: m.modal_ldap_dry_run_col_username(), + enableSorting: true, + sortingFn: 'text', + size: 160, + minSize: 160, + cell: textCell, + }), + columnHelper.accessor('email', { + header: m.modal_ldap_dry_run_col_email(), + enableSorting: true, + sortingFn: 'text', + size: 260, + minSize: 260, + cell: textCell, + }), + columnHelper.accessor('action', { + header: m.modal_ldap_dry_run_col_status(), + enableSorting: true, + sortingFn: 'text', + size: 160, + minSize: 120, + meta: { flex: true }, + cell: (info) => { + const added = info.getValue() === 'add'; + return ( + + + + ); + }, + }), + ], + [], + ); + + const filtered = useMemo(() => { + const query = search.trim().toLowerCase(); + if (!query) return data; + return data.filter( + (user) => + user.username.toLowerCase().includes(query) || + user.email.toLowerCase().includes(query), + ); + }, [data, search]); + + const table = useReactTable({ + columns, + data: filtered, + enableRowSelection: false, + columnResizeMode: 'onChange', + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + if (data.length === 0) { + return ( + + ); + } + + return ( + <> + + + + {filtered.length === 0 ? ( + + ) : ( + + )} + + ); +}; diff --git a/web/src/pages/settings/SettingsLdapPage/modals/LdapDryRunModal/LdapDryRunModal.tsx b/web/src/pages/settings/SettingsLdapPage/modals/LdapDryRunModal/LdapDryRunModal.tsx new file mode 100644 index 0000000000..9eddd09a95 --- /dev/null +++ b/web/src/pages/settings/SettingsLdapPage/modals/LdapDryRunModal/LdapDryRunModal.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import { m } from '../../../../../paraglide/messages'; +import type { LdapDryRunResult } from '../../../../../shared/api/types'; +import { Modal } from '../../../../../shared/defguard-ui/components/Modal/Modal'; +import { Tabs } from '../../../../../shared/defguard-ui/components/Tabs/Tabs'; +import './style.scss'; +import { SizedBox } from '../../../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { ThemeSpacing } from '../../../../../shared/defguard-ui/types'; +import { DryRunTable } from './DryRunTable'; + +type Props = { + isOpen: boolean; + result: LdapDryRunResult | null; + onClose: () => void; +}; + +type DryRunTab = 'defguard' | 'ldap'; + +export const LdapDryRunModal = ({ isOpen, result, onClose }: Props) => { + const [tab, setTab] = useState('defguard'); + + const activeData = (tab === 'defguard' ? result?.defguard : result?.ldap) ?? []; + + return ( + +

{m.modal_ldap_dry_run_description()}

+ + setTab('defguard'), + }, + { + title: m.modal_ldap_dry_run_tab_ldap(), + active: tab === 'ldap', + onClick: () => setTab('ldap'), + }, + ]} + /> + +
+ ); +}; diff --git a/web/src/pages/settings/SettingsLdapPage/modals/LdapDryRunModal/style.scss b/web/src/pages/settings/SettingsLdapPage/modals/LdapDryRunModal/style.scss new file mode 100644 index 0000000000..369839c4cd --- /dev/null +++ b/web/src/pages/settings/SettingsLdapPage/modals/LdapDryRunModal/style.scss @@ -0,0 +1,39 @@ +.ldap-dry-run-modal { + min-height: 720px; + display: flex; + flex-direction: column; + + .description { + color: var(--fg-muted); + font: var(--t-body-sm-400); + } + + .table { + padding: 0; + border-bottom: 1px solid var(--border-disabled); + border-bottom-left-radius: var(--radius-md); + border-bottom-right-radius: var(--radius-md); + overflow: hidden; + + // the container border above replaces it; both would render doubled + .table-row-container.last { + border-bottom: 0; + } + + // per design the overflowing table has no visible scrollbar + .table-scroll { + scrollbar-width: none; + } + } + + & > .modal-content { + display: flex; + flex-direction: column; + flex: 1 1 auto; + } + + .empty-state { + flex: 1 1 auto; + justify-content: center; + } +} diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index cc4724813f..30ea205834 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -72,6 +72,7 @@ import type { GetInternalSslInfoResponse, GroupInfo, IpValidation, + LdapDryRunResult, LicenseCheckResponse, LicenseInfo, LicenseInfoResponse, @@ -478,6 +479,8 @@ const api = { client.patch('/settings_enterprise', data), getSettingsEssentials: () => client.get('/settings_essentials'), getLdapConnectionStatus: () => client.get(`/ldap/test`), + testLdapSettings: (data: Settings) => client.post('/ldap/test', data), + ldapDryRun: (data: Settings) => client.post('/ldap/dry_run', data), }, openIdProvider: { getOpenIdProvider: () => diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 70c6242811..1c1e6fa178 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -1177,6 +1177,21 @@ export interface SettingsLDAP { ldap_disable_password_management: boolean; } +export type LdapDryRunAction = 'add' | 'remove'; + +export interface LdapDryRunUser { + username: string; + email: string; + first_name: string; + last_name: string; + action: LdapDryRunAction; +} + +export interface LdapDryRunResult { + defguard: LdapDryRunUser[]; + ldap: LdapDryRunUser[]; +} + export interface SettingsOpenID { openid_create_account: boolean; } diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index 1385d3de92..fe79945735 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit 1385d3de92f89dffe5603d0f25e8515c5f3fb243 +Subproject commit fe799457355647ef99b75484993bd8d3882c0319 From 2886aa44feee279f09292327ade80b03a9fd7814 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:12:47 +0200 Subject: [PATCH 2/2] Allow ignoring validation of SMTP certs (#3177) --- ...634374fac89e79524e300156a7b06cdf8747e.json} | 7 ++++--- .../src/db/models/settings/mod.rs | 5 ++++- .../src/db/models/settings/smtp.rs | 4 ++++ .../src/db/models/activity_log/metadata.rs | 2 ++ crates/defguard_core/src/mail/mod.rs | 18 +++++++++++++++--- ...41518_[2.1.0]_smtp_tls_verify_cert.down.sql | 1 + ...3141518_[2.1.0]_smtp_tls_verify_cert.up.sql | 1 + web/messages/en/settings.json | 1 + .../SettingsSmtpPage/SettingsSmtpPage.tsx | 6 ++++++ .../SmtpAuthConfigModal/BasicAuthForm.tsx | 11 +++++++++++ .../SmtpAuthConfigModal/CustomAuthForm.tsx | 11 +++++++++++ .../SmtpAuthConfigModal/GoogleAuthForm.tsx | 11 +++++++++++ .../SmtpAuthConfigModal/MicrosoftAuthForm.tsx | 11 +++++++++++ .../SmtpAuthConfigModal/NoneAuthForm.tsx | 11 +++++++++++ .../SmtpAuthConfigModal/types.ts | 2 ++ web/src/shared/api/types.ts | 1 + 16 files changed, 96 insertions(+), 7 deletions(-) rename .sqlx/{query-e71036352e8d60c48e8848c6026cd50a5375abe17785099694ddab34ae4856b4.json => query-a31ab8c10b294f9eb5d3004a893634374fac89e79524e300156a7b06cdf8747e.json} (96%) create mode 100644 migrations/20260703141518_[2.1.0]_smtp_tls_verify_cert.down.sql create mode 100644 migrations/20260703141518_[2.1.0]_smtp_tls_verify_cert.up.sql diff --git a/.sqlx/query-e71036352e8d60c48e8848c6026cd50a5375abe17785099694ddab34ae4856b4.json b/.sqlx/query-a31ab8c10b294f9eb5d3004a893634374fac89e79524e300156a7b06cdf8747e.json similarity index 96% rename from .sqlx/query-e71036352e8d60c48e8848c6026cd50a5375abe17785099694ddab34ae4856b4.json rename to .sqlx/query-a31ab8c10b294f9eb5d3004a893634374fac89e79524e300156a7b06cdf8747e.json index c24525e230..ce38b0f991 100644 --- a/.sqlx/query-e71036352e8d60c48e8848c6026cd50a5375abe17785099694ddab34ae4856b4.json +++ b/.sqlx/query-a31ab8c10b294f9eb5d3004a893634374fac89e79524e300156a7b06cdf8747e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"settings\" SET openid_enabled = $1, wireguard_enabled = $2, webhooks_enabled = $3, worker_enabled = $4, challenge_template = $5, instance_name = $6, main_logo_url = $7, nav_logo_url = $8, smtp_server = $9, smtp_port = $10, smtp_encryption = $11, smtp_user = $12, smtp_password = $13, smtp_sender = $14, smtp_authentication = $15, smtp_oauth_issuer_url = $16, smtp_oauth_client_id = $17, smtp_oauth_client_secret = $18, smtp_oauth_refresh_token = $19, enrollment_vpn_step_optional = $20, enrollment_welcome_message = $21, enrollment_welcome_email = $22, enrollment_welcome_email_subject = $23, enrollment_use_welcome_message_as_email = $24, enrollment_send_welcome_email = $25, uuid = $26, ldap_url = $27, ldap_bind_username = $28, ldap_bind_password = $29, ldap_group_search_base = $30, ldap_user_search_base = $31, ldap_user_obj_class = $32, ldap_group_obj_class = $33, ldap_username_attr = $34, ldap_groupname_attr = $35, ldap_group_member_attr = $36, ldap_member_attr = $37, ldap_use_starttls = $38, ldap_tls_verify_cert = $39, openid_create_account = $40, license = $41, gateway_disconnect_notifications_enabled = $42, gateway_disconnect_notifications_inactivity_threshold = $43, gateway_disconnect_notifications_reconnect_notification_enabled = $44, ldap_sync_status = $45, ldap_enabled = $46, ldap_sync_enabled = $47, ldap_is_authoritative = $48, ldap_sync_interval = $49, ldap_user_auxiliary_obj_classes = $50, ldap_uses_ad = $51, ldap_user_rdn_attr = $52, ldap_sync_groups = $53, ldap_remote_enrollment_enabled = $54, ldap_remote_enrollment_send_invite = $55, openid_username_handling = $56, defguard_url = $57, default_admin_group_name = $58, authentication_period_days = $59, mfa_code_timeout_seconds = $60, public_proxy_url = $61, default_admin_id = $62, secret_key = $63, openid_signing_key_der = $64, enable_stats_purge = $65, stats_purge_frequency_hours = $66, stats_purge_threshold_days = $67, enrollment_token_timeout_hours = $68, password_reset_token_timeout_hours = $69, enrollment_session_timeout_minutes = $70, password_reset_session_timeout_minutes = $71, ldap_sync_account_status = $72, ldap_disable_password_management = $73, smtp_oauth_tenant_id = $74 WHERE id = 1", + "query": "UPDATE \"settings\" SET openid_enabled = $1, wireguard_enabled = $2, webhooks_enabled = $3, worker_enabled = $4, challenge_template = $5, instance_name = $6, main_logo_url = $7, nav_logo_url = $8, smtp_server = $9, smtp_port = $10, smtp_encryption = $11, smtp_user = $12, smtp_password = $13, smtp_sender = $14, smtp_authentication = $15, smtp_oauth_issuer_url = $16, smtp_oauth_client_id = $17, smtp_oauth_client_secret = $18, smtp_oauth_refresh_token = $19, enrollment_vpn_step_optional = $20, enrollment_welcome_message = $21, enrollment_welcome_email = $22, enrollment_welcome_email_subject = $23, enrollment_use_welcome_message_as_email = $24, enrollment_send_welcome_email = $25, uuid = $26, ldap_url = $27, ldap_bind_username = $28, ldap_bind_password = $29, ldap_group_search_base = $30, ldap_user_search_base = $31, ldap_user_obj_class = $32, ldap_group_obj_class = $33, ldap_username_attr = $34, ldap_groupname_attr = $35, ldap_group_member_attr = $36, ldap_member_attr = $37, ldap_use_starttls = $38, ldap_tls_verify_cert = $39, openid_create_account = $40, license = $41, gateway_disconnect_notifications_enabled = $42, gateway_disconnect_notifications_inactivity_threshold = $43, gateway_disconnect_notifications_reconnect_notification_enabled = $44, ldap_sync_status = $45, ldap_enabled = $46, ldap_sync_enabled = $47, ldap_is_authoritative = $48, ldap_sync_interval = $49, ldap_user_auxiliary_obj_classes = $50, ldap_uses_ad = $51, ldap_user_rdn_attr = $52, ldap_sync_groups = $53, ldap_remote_enrollment_enabled = $54, ldap_remote_enrollment_send_invite = $55, openid_username_handling = $56, defguard_url = $57, default_admin_group_name = $58, authentication_period_days = $59, mfa_code_timeout_seconds = $60, public_proxy_url = $61, default_admin_id = $62, secret_key = $63, openid_signing_key_der = $64, enable_stats_purge = $65, stats_purge_frequency_hours = $66, stats_purge_threshold_days = $67, enrollment_token_timeout_hours = $68, password_reset_token_timeout_hours = $69, enrollment_session_timeout_minutes = $70, password_reset_session_timeout_minutes = $71, ldap_sync_account_status = $72, ldap_disable_password_management = $73, smtp_oauth_tenant_id = $74, smtp_tls_verify_cert = $75 WHERE id = 1", "describe": { "columns": [], "parameters": { @@ -121,10 +121,11 @@ "Int4", "Bool", "Bool", - "Text" + "Text", + "Bool" ] }, "nullable": [] }, - "hash": "e71036352e8d60c48e8848c6026cd50a5375abe17785099694ddab34ae4856b4" + "hash": "a31ab8c10b294f9eb5d3004a893634374fac89e79524e300156a7b06cdf8747e" } diff --git a/crates/defguard_common/src/db/models/settings/mod.rs b/crates/defguard_common/src/db/models/settings/mod.rs index a5be54dbfe..1352c43eaf 100644 --- a/crates/defguard_common/src/db/models/settings/mod.rs +++ b/crates/defguard_common/src/db/models/settings/mod.rs @@ -487,6 +487,7 @@ impl Settings { smtp_port, smtp_encryption, smtp_user, smtp_password, smtp_sender, \ smtp_authentication, smtp_oauth_issuer_url, smtp_oauth_client_id, \ smtp_oauth_client_secret, smtp_oauth_refresh_token, smtp_oauth_tenant_id, \ + smtp_tls_verify_cert, \ enrollment_vpn_step_optional, enrollment_welcome_message, \ enrollment_welcome_email, enrollment_welcome_email_subject, \ enrollment_use_welcome_message_as_email, enrollment_send_welcome_email, \ @@ -634,7 +635,8 @@ impl Settings { password_reset_session_timeout_minutes = $71, \ ldap_sync_account_status = $72, \ ldap_disable_password_management = $73, \ - smtp_oauth_tenant_id = $74 \ + smtp_oauth_tenant_id = $74, \ + smtp_tls_verify_cert = $75 \ WHERE id = 1", self.openid_enabled, self.wireguard_enabled, @@ -710,6 +712,7 @@ impl Settings { self.ldap_sync_account_status, self.ldap_disable_password_management, self.smtp.oauth_tenant_id, + self.smtp.tls_verify_cert, ) .execute(executor) .await?; diff --git a/crates/defguard_common/src/db/models/settings/smtp.rs b/crates/defguard_common/src/db/models/settings/smtp.rs index 6223400017..c4a9f0aff0 100644 --- a/crates/defguard_common/src/db/models/settings/smtp.rs +++ b/crates/defguard_common/src/db/models/settings/smtp.rs @@ -86,6 +86,10 @@ pub struct SmtpSettings { #[sqlx(rename = "smtp_oauth_tenant_id")] #[patch(attribute(serde(rename = "smtp_oauth_tenant_id")))] pub oauth_tenant_id: Option, + #[serde(rename = "smtp_tls_verify_cert")] + #[sqlx(rename = "smtp_tls_verify_cert")] + #[patch(attribute(serde(rename = "smtp_tls_verify_cert")))] + pub tls_verify_cert: bool, } impl SmtpSettings { diff --git a/crates/defguard_core/src/db/models/activity_log/metadata.rs b/crates/defguard_core/src/db/models/activity_log/metadata.rs index 08524f0bdf..e14ef0176f 100644 --- a/crates/defguard_core/src/db/models/activity_log/metadata.rs +++ b/crates/defguard_core/src/db/models/activity_log/metadata.rs @@ -372,6 +372,7 @@ pub struct SettingsNoSecrets { pub smtp_encryption: SmtpEncryption, pub smtp_user: Option, pub smtp_sender: Option, + pub smtp_tls_verify_cert: bool, // Enrollment pub enrollment_vpn_step_optional: bool, pub enrollment_welcome_message: Option, @@ -431,6 +432,7 @@ impl From for SettingsNoSecrets { smtp_encryption: value.smtp.encryption, smtp_user: value.smtp.user, smtp_sender: value.smtp.sender, + smtp_tls_verify_cert: value.smtp.tls_verify_cert, enrollment_vpn_step_optional: value.enrollment_vpn_step_optional, enrollment_welcome_message: value.enrollment_welcome_message, enrollment_welcome_email: value.enrollment_welcome_email, diff --git a/crates/defguard_core/src/mail/mod.rs b/crates/defguard_core/src/mail/mod.rs index b181bd1492..d6d5454928 100644 --- a/crates/defguard_core/src/mail/mod.rs +++ b/crates/defguard_core/src/mail/mod.rs @@ -22,7 +22,10 @@ use defguard_common::db::models::{ use lettre::{ AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, message::{Body, Mailbox, MultiPart, SinglePart, header::ContentType}, - transport::smtp::authentication::{Credentials, Mechanism}, + transport::smtp::{ + authentication::{Credentials, Mechanism}, + client::{Tls, TlsParameters}, + }, }; use serde::Serialize; use sqlx::PgConnection; @@ -268,10 +271,19 @@ impl Mail { return Err(MailError::SmtpNotConfigured); }; + let tls_params = TlsParameters::builder(server.clone()) + .dangerous_accept_invalid_certs(!smtp_settings.tls_verify_cert) + .dangerous_accept_invalid_hostnames(!smtp_settings.tls_verify_cert) + .build()?; + let mut builder = match smtp_settings.encryption { SmtpEncryption::None => Builder::builder_dangerous(server), - SmtpEncryption::StartTls => Builder::starttls_relay(server)?, - SmtpEncryption::ImplicitTls => Builder::relay(server)?, + SmtpEncryption::StartTls => { + Builder::builder_dangerous(server).tls(Tls::Required(tls_params)) + } + SmtpEncryption::ImplicitTls => { + Builder::builder_dangerous(server).tls(Tls::Wrapper(tls_params)) + } } .port(port.try_into().map_err(|_| MailError::InvalidPort(port))?) .timeout(Some(SMTP_TIMEOUT)); diff --git a/migrations/20260703141518_[2.1.0]_smtp_tls_verify_cert.down.sql b/migrations/20260703141518_[2.1.0]_smtp_tls_verify_cert.down.sql new file mode 100644 index 0000000000..d251da29e2 --- /dev/null +++ b/migrations/20260703141518_[2.1.0]_smtp_tls_verify_cert.down.sql @@ -0,0 +1 @@ +ALTER TABLE settings DROP COLUMN smtp_tls_verify_cert; diff --git a/migrations/20260703141518_[2.1.0]_smtp_tls_verify_cert.up.sql b/migrations/20260703141518_[2.1.0]_smtp_tls_verify_cert.up.sql new file mode 100644 index 0000000000..544bdfe28d --- /dev/null +++ b/migrations/20260703141518_[2.1.0]_smtp_tls_verify_cert.up.sql @@ -0,0 +1 @@ +ALTER TABLE settings ADD smtp_tls_verify_cert BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/web/messages/en/settings.json b/web/messages/en/settings.json index 966c671e1e..886373894a 100644 --- a/web/messages/en/settings.json +++ b/web/messages/en/settings.json @@ -233,6 +233,7 @@ "settings_smtp_helper_sender_email_address": "", "settings_smtp_label_encryption": "Encryption", "settings_smtp_helper_encryption": "", + "settings_smtp_checkbox_verify_tls_certificate": "Verify TLS certificate", "settings_smtp_helper_server_username": "", "settings_smtp_helper_server_password": "", "settings_smtp_test_email_helper_email": "", diff --git a/web/src/pages/settings/SettingsSmtpPage/SettingsSmtpPage.tsx b/web/src/pages/settings/SettingsSmtpPage/SettingsSmtpPage.tsx index eeaff4abe0..1b64d98016 100644 --- a/web/src/pages/settings/SettingsSmtpPage/SettingsSmtpPage.tsx +++ b/web/src/pages/settings/SettingsSmtpPage/SettingsSmtpPage.tsx @@ -133,6 +133,7 @@ const formSchema = z.object({ smtp_oauth_client_secret: z.string().trim().nullable(), smtp_oauth_refresh_token: z.string().trim().nullable(), smtp_oauth_tenant_id: z.string().trim().nullable(), + smtp_tls_verify_cert: z.boolean(), }); type FormFields = z.infer; @@ -150,6 +151,7 @@ const emptyValues: FormFields = { smtp_oauth_client_secret: null, smtp_oauth_refresh_token: null, smtp_oauth_tenant_id: null, + smtp_tls_verify_cert: true, }; const Content = ({ @@ -165,6 +167,7 @@ const Content = ({ smtp_port: 587, smtp_sender: '', smtp_encryption: SmtpEncryption.StartTls, + smtp_tls_verify_cert: true, smtp_user: null, smtp_password: null, smtp_oauth_issuer_url: null, @@ -188,6 +191,7 @@ const Content = ({ smtp_oauth_client_secret: settings.smtp_oauth_client_secret ?? null, smtp_oauth_refresh_token: settings.smtp_oauth_refresh_token ?? null, smtp_oauth_tenant_id: settings.smtp_oauth_tenant_id ?? null, + smtp_tls_verify_cert: settings.smtp_tls_verify_cert, }), [settings], ); @@ -224,6 +228,7 @@ const Content = ({ smtp_port: form.state.values.smtp_port, smtp_sender: form.state.values.smtp_sender, smtp_encryption: form.state.values.smtp_encryption as SmtpEncryptionValue, + smtp_tls_verify_cert: form.state.values.smtp_tls_verify_cert, smtp_user: form.state.values.smtp_user, smtp_password: form.state.values.smtp_password, smtp_oauth_issuer_url: form.state.values.smtp_oauth_issuer_url, @@ -321,6 +326,7 @@ const Content = ({ smtp_server: result.smtp_server ?? cur.smtp_server, smtp_port: result.smtp_port ?? cur.smtp_port, smtp_encryption: result.smtp_encryption ?? cur.smtp_encryption, + smtp_tls_verify_cert: result.smtp_tls_verify_cert ?? cur.smtp_tls_verify_cert, smtp_user: result.smtp_user !== undefined ? result.smtp_user : cur.smtp_user, smtp_password: result.smtp_password !== undefined diff --git a/web/src/pages/settings/SettingsSmtpPage/SmtpAuthConfigModal/BasicAuthForm.tsx b/web/src/pages/settings/SettingsSmtpPage/SmtpAuthConfigModal/BasicAuthForm.tsx index 1a16635065..55adac0083 100644 --- a/web/src/pages/settings/SettingsSmtpPage/SmtpAuthConfigModal/BasicAuthForm.tsx +++ b/web/src/pages/settings/SettingsSmtpPage/SmtpAuthConfigModal/BasicAuthForm.tsx @@ -20,6 +20,7 @@ const schema = z.object({ .min(1, m.form_error_required()) .regex(patternValidEmail, m.form_error_email()), smtp_encryption: z.enum(SmtpEncryption), + smtp_tls_verify_cert: z.boolean(), smtp_user: z.string().trim().nullable(), smtp_password: z.string().trim().nullable(), }); @@ -31,6 +32,7 @@ export const BasicAuthForm = ({ initialValues, onApply, onClose }: FormProps) => smtp_port: initialValues.smtp_port, smtp_sender: initialValues.smtp_sender, smtp_encryption: initialValues.smtp_encryption, + smtp_tls_verify_cert: initialValues.smtp_tls_verify_cert, smtp_user: initialValues.smtp_user, smtp_password: initialValues.smtp_password, }, @@ -43,6 +45,7 @@ export const BasicAuthForm = ({ initialValues, onApply, onClose }: FormProps) => smtp_port: value.smtp_port, smtp_sender: value.smtp_sender, smtp_encryption: value.smtp_encryption, + smtp_tls_verify_cert: value.smtp_tls_verify_cert, smtp_user: value.smtp_user, smtp_password: value.smtp_password, }); @@ -123,6 +126,14 @@ export const BasicAuthForm = ({ initialValues, onApply, onClose }: FormProps) => )} + + + {(field) => ( + + )} + ({ isSubmitting: s.isSubmitting })}> {({ isSubmitting }) => ( { @@ -42,6 +43,7 @@ export const CustomAuthForm = ({ initialValues, onApply, onClose }: FormProps) = smtp_oauth_scope: CUSTOM_SCOPE_DEFAULT, smtp_oauth_client_id: initialValues.smtp_oauth_client_id, smtp_oauth_client_secret: initialValues.smtp_oauth_client_secret, + smtp_tls_verify_cert: initialValues.smtp_tls_verify_cert, }, validationLogic: formChangeLogic, validators: { onSubmit: schema, onChange: schema }, @@ -91,6 +93,7 @@ export const CustomAuthForm = ({ initialValues, onApply, onClose }: FormProps) = await onApply({ authentication: SmtpAuthentication.XOAuth2, smtp_sender: value.smtp_sender, + smtp_tls_verify_cert: value.smtp_tls_verify_cert, smtp_oauth_issuer_url: value.smtp_oauth_issuer_url, smtp_oauth_client_id: value.smtp_oauth_client_id, smtp_oauth_client_secret: value.smtp_oauth_client_secret, @@ -165,6 +168,14 @@ export const CustomAuthForm = ({ initialValues, onApply, onClose }: FormProps) = )} + + {(field) => ( + + )} + +

{m.settings_smtp_auth_oauth_info()}

({ isSubmitting: s.isSubmitting })}> diff --git a/web/src/pages/settings/SettingsSmtpPage/SmtpAuthConfigModal/GoogleAuthForm.tsx b/web/src/pages/settings/SettingsSmtpPage/SmtpAuthConfigModal/GoogleAuthForm.tsx index 4822c6a018..e1ec5de88f 100644 --- a/web/src/pages/settings/SettingsSmtpPage/SmtpAuthConfigModal/GoogleAuthForm.tsx +++ b/web/src/pages/settings/SettingsSmtpPage/SmtpAuthConfigModal/GoogleAuthForm.tsx @@ -31,6 +31,7 @@ const schema = z.object({ .regex(patternValidEmail, m.form_error_email()), smtp_oauth_client_id: z.string().trim().nullable(), smtp_oauth_client_secret: z.string().trim().nullable(), + smtp_tls_verify_cert: z.boolean(), }); export const GoogleAuthForm = ({ initialValues, onApply, onClose }: FormProps) => { @@ -42,6 +43,7 @@ export const GoogleAuthForm = ({ initialValues, onApply, onClose }: FormProps) = smtp_sender: initialValues.smtp_sender, smtp_oauth_client_id: initialValues.smtp_oauth_client_id, smtp_oauth_client_secret: initialValues.smtp_oauth_client_secret, + smtp_tls_verify_cert: initialValues.smtp_tls_verify_cert, }, validationLogic: formChangeLogic, validators: { onSubmit: schema, onChange: schema }, @@ -85,6 +87,7 @@ export const GoogleAuthForm = ({ initialValues, onApply, onClose }: FormProps) = smtp_server: GOOGLE_SMTP_SERVER, smtp_port: PROVIDER_SMTP_PORT, smtp_encryption: SmtpEncryption.StartTls, + smtp_tls_verify_cert: value.smtp_tls_verify_cert, }); } catch (err) { handleOAuthError(err); @@ -135,6 +138,14 @@ export const GoogleAuthForm = ({ initialValues, onApply, onClose }: FormProps) = + + {(field) => ( + + )} + +

{m.settings_smtp_auth_oauth_info()}

({ isSubmitting: s.isSubmitting })}> diff --git a/web/src/pages/settings/SettingsSmtpPage/SmtpAuthConfigModal/MicrosoftAuthForm.tsx b/web/src/pages/settings/SettingsSmtpPage/SmtpAuthConfigModal/MicrosoftAuthForm.tsx index a43d883e6b..c9630f8349 100644 --- a/web/src/pages/settings/SettingsSmtpPage/SmtpAuthConfigModal/MicrosoftAuthForm.tsx +++ b/web/src/pages/settings/SettingsSmtpPage/SmtpAuthConfigModal/MicrosoftAuthForm.tsx @@ -25,6 +25,7 @@ const schema = z.object({ smtp_oauth_tenant_id: z.string().trim().nullable(), smtp_oauth_client_id: z.string().trim().nullable(), smtp_oauth_client_secret: z.string().trim().nullable(), + smtp_tls_verify_cert: z.boolean(), }); export const MicrosoftAuthForm = ({ initialValues, onApply, onClose }: FormProps) => { @@ -34,6 +35,7 @@ export const MicrosoftAuthForm = ({ initialValues, onApply, onClose }: FormProps smtp_oauth_tenant_id: initialValues.smtp_oauth_tenant_id, smtp_oauth_client_id: initialValues.smtp_oauth_client_id, smtp_oauth_client_secret: initialValues.smtp_oauth_client_secret, + smtp_tls_verify_cert: initialValues.smtp_tls_verify_cert, }, validationLogic: formChangeLogic, validators: { onSubmit: schema, onChange: schema }, @@ -44,6 +46,7 @@ export const MicrosoftAuthForm = ({ initialValues, onApply, onClose }: FormProps smtp_server: MICROSOFT_SMTP_SERVER, smtp_port: PROVIDER_SMTP_PORT, smtp_encryption: SmtpEncryption.StartTls, + smtp_tls_verify_cert: value.smtp_tls_verify_cert, smtp_oauth_issuer_url: isMicrosoftIssuerUrl(initialValues.smtp_oauth_issuer_url) ? initialValues.smtp_oauth_issuer_url : MICROSOFT_ISSUER_URL, @@ -105,6 +108,14 @@ export const MicrosoftAuthForm = ({ initialValues, onApply, onClose }: FormProps )} + + + {(field) => ( + + )} + ({ isSubmitting: s.isSubmitting })}> {({ isSubmitting }) => ( { @@ -29,6 +30,7 @@ export const NoneAuthForm = ({ initialValues, onApply, onClose }: FormProps) => smtp_port: initialValues.smtp_port, smtp_sender: initialValues.smtp_sender, smtp_encryption: initialValues.smtp_encryption, + smtp_tls_verify_cert: initialValues.smtp_tls_verify_cert, }, validationLogic: formChangeLogic, validators: { onSubmit: schema, onChange: schema }, @@ -39,6 +41,7 @@ export const NoneAuthForm = ({ initialValues, onApply, onClose }: FormProps) => smtp_port: value.smtp_port, smtp_sender: value.smtp_sender, smtp_encryption: value.smtp_encryption, + smtp_tls_verify_cert: value.smtp_tls_verify_cert, }); }, }); @@ -95,6 +98,14 @@ export const NoneAuthForm = ({ initialValues, onApply, onClose }: FormProps) => )} + + + {(field) => ( + + )} + ({ isSubmitting: s.isSubmitting })}> {({ isSubmitting }) => (