From 9e0c2c77e4f1afc37d15d87ba0804e730bac7fa4 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 13 Mar 2026 07:43:49 +0100 Subject: [PATCH 01/16] remove rp id from settings and derive it from defguard_url --- ...f4ec260d08f8feee279c41f73fd8e33aea5aa.json | 3 +- ...5a24916ddd638337ed9427d94db5b1dc9766a.json | 19 +- crates/defguard_common/src/config.rs | 38 +--- .../defguard_common/src/db/models/settings.rs | 196 +++++++++++++----- crates/defguard_core/src/appstate.rs | 28 +-- .../src/enterprise/handlers/openid_login.rs | 33 ++- .../src/enterprise/ldap/error.rs | 3 + .../defguard_core/src/enterprise/license.rs | 7 +- crates/defguard_core/src/error.rs | 19 +- crates/defguard_core/src/handlers/auth.rs | 47 ++--- crates/defguard_core/src/handlers/mod.rs | 11 + .../defguard_core/src/handlers/openid_flow.rs | 14 +- .../defguard_core/tests/integration/common.rs | 3 - crates/defguard_setup/src/auto_adoption.rs | 1 + ...260312072940_[2.0.0]_remove_rp_id.down.sql | 1 + ...20260312072940_[2.0.0]_remove_rp_id.up.sql | 1 + web/messages/en/initial_wizard.json | 1 + web/messages/en/migration_wizard.json | 1 + ...igrationWizardGeneralConfigurationStep.tsx | 5 + .../steps/AutoAdoptionUrlSettingsStep.tsx | 5 + .../initial/steps/SetupGeneralConfigStep.tsx | 5 + web/src/shared/defguardUrl.ts | 20 ++ 22 files changed, 290 insertions(+), 171 deletions(-) create mode 100644 migrations/20260312072940_[2.0.0]_remove_rp_id.down.sql create mode 100644 migrations/20260312072940_[2.0.0]_remove_rp_id.up.sql create mode 100644 web/src/shared/defguardUrl.ts diff --git a/.sqlx/query-386714aa5e0cbc2d896edf6dcd2f4ec260d08f8feee279c41f73fd8e33aea5aa.json b/.sqlx/query-386714aa5e0cbc2d896edf6dcd2f4ec260d08f8feee279c41f73fd8e33aea5aa.json index 2016018b0a..c24d01f3b3 100644 --- a/.sqlx/query-386714aa5e0cbc2d896edf6dcd2f4ec260d08f8feee279c41f73fd8e33aea5aa.json +++ b/.sqlx/query-386714aa5e0cbc2d896edf6dcd2f4ec260d08f8feee279c41f73fd8e33aea5aa.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, enrollment_vpn_step_optional = $15, enrollment_welcome_message = $16, enrollment_welcome_email = $17, enrollment_welcome_email_subject = $18, enrollment_use_welcome_message_as_email = $19, uuid = $20, ldap_url = $21, ldap_bind_username = $22, ldap_bind_password = $23, ldap_group_search_base = $24, ldap_user_search_base = $25, ldap_user_obj_class = $26, ldap_group_obj_class = $27, ldap_username_attr = $28, ldap_groupname_attr = $29, ldap_group_member_attr = $30, ldap_member_attr = $31, ldap_use_starttls = $32, ldap_tls_verify_cert = $33, openid_create_account = $34, license = $35, gateway_disconnect_notifications_enabled = $36, gateway_disconnect_notifications_inactivity_threshold = $37, gateway_disconnect_notifications_reconnect_notification_enabled = $38, ldap_sync_status = $39, ldap_enabled = $40, ldap_sync_enabled = $41, ldap_is_authoritative = $42, ldap_sync_interval = $43, ldap_user_auxiliary_obj_classes = $44, ldap_uses_ad = $45, ldap_user_rdn_attr = $46, ldap_sync_groups = $47, openid_username_handling = $48, ca_key_der = $49, ca_cert_der = $50, ca_expiry = $51, defguard_url = $52, default_admin_group_name = $53, authentication_period_days = $54, mfa_code_timeout_seconds = $55, public_proxy_url = $56, default_admin_id = $57, secret_key = $58, webauthn_rp_id = $59, disable_stats_purge = $60, stats_purge_frequency_hours = $61, stats_purge_threshold_days = $62, enrollment_token_timeout_hours = $63, password_reset_token_timeout_hours = $64, enrollment_session_timeout_minutes = $65, password_reset_session_timeout_minutes = $66 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, enrollment_vpn_step_optional = $15, enrollment_welcome_message = $16, enrollment_welcome_email = $17, enrollment_welcome_email_subject = $18, enrollment_use_welcome_message_as_email = $19, uuid = $20, ldap_url = $21, ldap_bind_username = $22, ldap_bind_password = $23, ldap_group_search_base = $24, ldap_user_search_base = $25, ldap_user_obj_class = $26, ldap_group_obj_class = $27, ldap_username_attr = $28, ldap_groupname_attr = $29, ldap_group_member_attr = $30, ldap_member_attr = $31, ldap_use_starttls = $32, ldap_tls_verify_cert = $33, openid_create_account = $34, license = $35, gateway_disconnect_notifications_enabled = $36, gateway_disconnect_notifications_inactivity_threshold = $37, gateway_disconnect_notifications_reconnect_notification_enabled = $38, ldap_sync_status = $39, ldap_enabled = $40, ldap_sync_enabled = $41, ldap_is_authoritative = $42, ldap_sync_interval = $43, ldap_user_auxiliary_obj_classes = $44, ldap_uses_ad = $45, ldap_user_rdn_attr = $46, ldap_sync_groups = $47, openid_username_handling = $48, ca_key_der = $49, ca_cert_der = $50, ca_expiry = $51, defguard_url = $52, default_admin_group_name = $53, authentication_period_days = $54, mfa_code_timeout_seconds = $55, public_proxy_url = $56, default_admin_id = $57, secret_key = $58, disable_stats_purge = $59, stats_purge_frequency_hours = $60, stats_purge_threshold_days = $61, enrollment_token_timeout_hours = $62, password_reset_token_timeout_hours = $63, enrollment_session_timeout_minutes = $64, password_reset_session_timeout_minutes = $65 WHERE id = 1", "describe": { "columns": [], "parameters": { @@ -95,7 +95,6 @@ "Text", "Int8", "Text", - "Text", "Bool", "Int4", "Int4", diff --git a/.sqlx/query-bb022291c5a000545df91eb246c5a24916ddd638337ed9427d94db5b1dc9766a.json b/.sqlx/query-bb022291c5a000545df91eb246c5a24916ddd638337ed9427d94db5b1dc9766a.json index 0e6b201a60..80ae934483 100644 --- a/.sqlx/query-bb022291c5a000545df91eb246c5a24916ddd638337ed9427d94db5b1dc9766a.json +++ b/.sqlx/query-bb022291c5a000545df91eb246c5a24916ddd638337ed9427d94db5b1dc9766a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT openid_enabled, wireguard_enabled, webhooks_enabled, worker_enabled, challenge_template, instance_name, main_logo_url, nav_logo_url, smtp_server, smtp_port, smtp_encryption \"smtp_encryption: _\", smtp_user, smtp_password \"smtp_password?: SecretStringWrapper\", smtp_sender, enrollment_vpn_step_optional, enrollment_welcome_message, enrollment_welcome_email, enrollment_welcome_email_subject, enrollment_use_welcome_message_as_email, uuid, ldap_url, ldap_bind_username, ldap_bind_password \"ldap_bind_password?: SecretStringWrapper\", ldap_group_search_base, ldap_user_search_base, ldap_user_obj_class, ldap_group_obj_class, ldap_username_attr, ldap_groupname_attr, ldap_group_member_attr, ldap_member_attr, openid_create_account, license, gateway_disconnect_notifications_enabled, ldap_use_starttls, ldap_tls_verify_cert, gateway_disconnect_notifications_inactivity_threshold, gateway_disconnect_notifications_reconnect_notification_enabled, ldap_sync_status \"ldap_sync_status: LdapSyncStatus\", ldap_enabled, ldap_sync_enabled, ldap_is_authoritative, ldap_sync_interval, ldap_user_auxiliary_obj_classes, ldap_uses_ad, ldap_user_rdn_attr, ldap_sync_groups, openid_username_handling \"openid_username_handling: OpenIdUsernameHandling\", ca_key_der, ca_cert_der, ca_expiry, defguard_url, default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds, public_proxy_url, default_admin_id, secret_key, webauthn_rp_id, disable_stats_purge, stats_purge_frequency_hours, stats_purge_threshold_days, enrollment_token_timeout_hours, password_reset_token_timeout_hours, enrollment_session_timeout_minutes, password_reset_session_timeout_minutes FROM \"settings\" WHERE id = 1", + "query": "SELECT openid_enabled, wireguard_enabled, webhooks_enabled, worker_enabled, challenge_template, instance_name, main_logo_url, nav_logo_url, smtp_server, smtp_port, smtp_encryption \"smtp_encryption: _\", smtp_user, smtp_password \"smtp_password?: SecretStringWrapper\", smtp_sender, enrollment_vpn_step_optional, enrollment_welcome_message, enrollment_welcome_email, enrollment_welcome_email_subject, enrollment_use_welcome_message_as_email, uuid, ldap_url, ldap_bind_username, ldap_bind_password \"ldap_bind_password?: SecretStringWrapper\", ldap_group_search_base, ldap_user_search_base, ldap_user_obj_class, ldap_group_obj_class, ldap_username_attr, ldap_groupname_attr, ldap_group_member_attr, ldap_member_attr, openid_create_account, license, gateway_disconnect_notifications_enabled, ldap_use_starttls, ldap_tls_verify_cert, gateway_disconnect_notifications_inactivity_threshold, gateway_disconnect_notifications_reconnect_notification_enabled, ldap_sync_status \"ldap_sync_status: LdapSyncStatus\", ldap_enabled, ldap_sync_enabled, ldap_is_authoritative, ldap_sync_interval, ldap_user_auxiliary_obj_classes, ldap_uses_ad, ldap_user_rdn_attr, ldap_sync_groups, openid_username_handling \"openid_username_handling: OpenIdUsernameHandling\", ca_key_der, ca_cert_der, ca_expiry, defguard_url, default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds, public_proxy_url, default_admin_id, secret_key, disable_stats_purge, stats_purge_frequency_hours, stats_purge_threshold_days, enrollment_token_timeout_hours, password_reset_token_timeout_hours, enrollment_session_timeout_minutes, password_reset_session_timeout_minutes FROM \"settings\" WHERE id = 1", "describe": { "columns": [ { @@ -327,41 +327,36 @@ }, { "ordinal": 58, - "name": "webauthn_rp_id", - "type_info": "Text" - }, - { - "ordinal": 59, "name": "disable_stats_purge", "type_info": "Bool" }, { - "ordinal": 60, + "ordinal": 59, "name": "stats_purge_frequency_hours", "type_info": "Int4" }, { - "ordinal": 61, + "ordinal": 60, "name": "stats_purge_threshold_days", "type_info": "Int4" }, { - "ordinal": 62, + "ordinal": 61, "name": "enrollment_token_timeout_hours", "type_info": "Int4" }, { - "ordinal": 63, + "ordinal": 62, "name": "password_reset_token_timeout_hours", "type_info": "Int4" }, { - "ordinal": 64, + "ordinal": 63, "name": "enrollment_session_timeout_minutes", "type_info": "Int4" }, { - "ordinal": 65, + "ordinal": 64, "name": "password_reset_session_timeout_minutes", "type_info": "Int4" } diff --git a/crates/defguard_common/src/config.rs b/crates/defguard_common/src/config.rs index e9f39ab14c..6504335d92 100644 --- a/crates/defguard_common/src/config.rs +++ b/crates/defguard_common/src/config.rs @@ -3,18 +3,18 @@ use std::{net::IpAddr, sync::OnceLock}; use clap::{Args, Parser, Subcommand}; use humantime::Duration; use ipnetwork::IpNetwork; -use openidconnect::{JsonWebKeyId, core::CoreRsaPrivateSigningKey}; +use openidconnect::{core::CoreRsaPrivateSigningKey, JsonWebKeyId}; use reqwest::Url; use rsa::{ - RsaPrivateKey, pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey}, pkcs8::{DecodePrivateKey, LineEnding}, traits::PublicKeyParts, + RsaPrivateKey, }; use secrecy::{ExposeSecret, SecretString}; use serde::Serialize; -use crate::{VERSION, db::models::Settings}; +use crate::{db::models::Settings, VERSION}; pub static SERVER_CONFIG: OnceLock = OnceLock::new(); @@ -74,11 +74,6 @@ pub struct DefGuardConfig { #[serde(skip_serializing)] pub openid_signing_key: Option, - // relying party id and relying party origin for WebAuthn - #[arg(long, env = "DEFGUARD_WEBAUTHN_RP_ID")] - #[deprecated(since = "2.0.0", note = "Use Settings.webauthn_rp_id instead")] - pub webauthn_rp_id: Option, - #[arg(long, env = "DEFGUARD_URL", value_parser = Url::parse, default_value = "http://localhost:8000")] #[serde(skip_serializing)] #[deprecated(since = "2.0.0", note = "Use Settings.defguard_url instead")] @@ -240,17 +235,9 @@ impl DefGuardConfig { /// Initialize values that depend on Settings. pub fn initialize_post_settings(&mut self) { - let url = Settings::url().expect("Unable to parse Defguard URL."); - self.initialize_cookie_domain(&url); - } - - fn initialize_cookie_domain(&mut self, url: &Url) { if self.cookie_domain.is_none() { - self.cookie_domain = Some( - url.domain() - .expect("Unable to get domain for server URL.") - .to_string(), - ); + let settings = Settings::get_current_settings(); + self.cookie_domain = settings.cookie_domain().ok(); } } @@ -294,20 +281,7 @@ mod tests { } #[test] - fn test_generate_cookie_domain() { - unsafe { - env::remove_var("DEFGUARD_COOKIE_DOMAIN"); - } - - let url = Url::parse("https://defguard.example.com").unwrap(); - let mut config = DefGuardConfig::new(); - config.initialize_cookie_domain(&url); - - assert_eq!( - config.cookie_domain, - Some("defguard.example.com".to_string()) - ); - + fn test_cookie_domain_env_override() { unsafe { env::set_var("DEFGUARD_COOKIE_DOMAIN", "example.com"); } diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index d55de1f48d..14d50b56e4 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, fmt, time::Duration}; +use std::{collections::HashMap, fmt, net::IpAddr, time::Duration}; use base64::{Engine, prelude::BASE64_STANDARD}; use chrono::NaiveDateTime; @@ -12,6 +12,7 @@ use tracing::{debug, info, warn}; use url::Url; use utoipa::ToSchema; use uuid::Uuid; +use webauthn_rs::prelude::WebauthnBuilder; use crate::{ config::DefGuardConfig, db::Id, global_value, secret::SecretStringWrapper, types::AuthFlowType, @@ -37,9 +38,10 @@ pub async fn initialize_current_settings(pool: &PgPool) -> sqlx::Result<()> { /// `SETTINGS` struct. pub async fn update_current_settings<'e, E: sqlx::PgExecutor<'e>>( executor: E, - new_settings: Settings, -) -> sqlx::Result<()> { + mut new_settings: Settings, +) -> Result<(), SettingsSaveError> { debug!("Updating current settings to: {new_settings:?}"); + new_settings.validate()?; new_settings.save(executor).await?; set_settings(Some(new_settings)); Ok(()) @@ -49,20 +51,44 @@ pub async fn update_current_settings<'e, E: sqlx::PgExecutor<'e>>( pub enum SettingsValidationError { #[error("Cannot enable gateway disconnect notifications. SMTP is not configured")] CannotEnableGatewayNotifications, + #[error(transparent)] + InvalidDefguardUrl(#[from] SettingsUrlError), } #[derive(Error, Debug)] pub enum SettingsInitializationError { #[error(transparent)] Db(#[from] sqlx::Error), + #[error(transparent)] + Save(#[from] SettingsSaveError), #[error("Missing required setting: {0}")] Missing(&'static str), #[error("Invalid required setting `{0}`: {1}")] Invalid(&'static str, &'static str), - #[error("Unable to derive webauthn_rp_id from defguard_url {0}")] + #[error(transparent)] + InvalidDefguardUrl(#[from] SettingsUrlError), +} + +#[derive(Error, Debug, Clone)] +pub enum SettingsUrlError { + #[error("Unable to parse defguard_url `{0}`")] InvalidDefguardUrl(String), + #[error("Unable to derive webauthn_rp_id: defguard_url has no host: {0}")] + MissingDefguardHost(String), #[error("Unable to derive webauthn_rp_id: defguard_url has no domain: {0}")] MissingDefguardDomain(String), + #[error("defguard_url cannot use an IP address host: {0}")] + DefguardUrlUsesIpAddress(String), + #[error("Invalid WebAuthn configuration for defguard_url `{0}`: {1}")] + InvalidWebauthnConfiguration(String, String), +} + +#[derive(Error, Debug)] +pub enum SettingsSaveError { + #[error(transparent)] + Db(#[from] sqlx::Error), + #[error(transparent)] + Validation(#[from] SettingsValidationError), } #[derive(Clone, Deserialize, Serialize, PartialEq, Eq, Type, Debug, Default)] @@ -178,7 +204,6 @@ pub struct Settings { pub default_admin_id: Option, // 1.6 config options pub secret_key: Option, - pub webauthn_rp_id: Option, pub disable_stats_purge: bool, stats_purge_frequency_hours: i32, stats_purge_threshold_days: i32, @@ -303,6 +328,72 @@ impl Settings { BASE64_STANDARD.encode(bytes) } + pub fn parse_defguard_url(&self) -> Result { + let url = Url::parse(&self.defguard_url) + .map_err(|_| SettingsUrlError::InvalidDefguardUrl(self.defguard_url.clone()))?; + let host = url + .host_str() + .ok_or_else(|| SettingsUrlError::MissingDefguardHost(self.defguard_url.clone()))?; + if host.parse::().is_ok() { + return Err(SettingsUrlError::DefguardUrlUsesIpAddress( + self.defguard_url.clone(), + )); + } + Ok(url) + } + + pub fn webauthn_rp_id(&self) -> Result { + let url = self.parse_defguard_url()?; + let domain = url.domain().map(str::to_string).or_else(|| match url.host_str() { + Some("localhost") => Some("localhost".to_string()), + _ => None, + }); + + domain.ok_or_else(|| SettingsUrlError::MissingDefguardDomain(self.defguard_url.clone())) + } + + pub fn cookie_domain(&self) -> Result { + let url = self.parse_defguard_url()?; + url.host_str() + .map(ToString::to_string) + .ok_or_else(|| SettingsUrlError::MissingDefguardHost(self.defguard_url.clone())) + } + + pub fn validate_defguard_url(&self) -> Result<(), SettingsUrlError> { + let url = self.parse_defguard_url()?; + let rp_id = self.webauthn_rp_id()?; + let builder = WebauthnBuilder::new(&rp_id, &url).map_err(|err| { + SettingsUrlError::InvalidWebauthnConfiguration( + self.defguard_url.clone(), + err.to_string(), + ) + })?; + builder.build().map_err(|err| { + SettingsUrlError::InvalidWebauthnConfiguration( + self.defguard_url.clone(), + err.to_string(), + ) + })?; + Ok(()) + } + + pub fn build_webauthn(&self) -> Result { + let url = self.parse_defguard_url()?; + let rp_id = self.webauthn_rp_id()?; + let builder = WebauthnBuilder::new(&rp_id, &url).map_err(|err| { + SettingsUrlError::InvalidWebauthnConfiguration( + self.defguard_url.clone(), + err.to_string(), + ) + })?; + builder.build().map_err(|err| { + SettingsUrlError::InvalidWebauthnConfiguration( + self.defguard_url.clone(), + err.to_string(), + ) + }) + } + pub async fn get<'e, E>(executor: E) -> sqlx::Result> where E: PgExecutor<'e>, @@ -331,7 +422,7 @@ impl Settings { ca_key_der, ca_cert_der, ca_expiry, defguard_url, \ default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds, \ public_proxy_url, \ - default_admin_id, secret_key, webauthn_rp_id, disable_stats_purge, \ + default_admin_id, secret_key, disable_stats_purge, \ stats_purge_frequency_hours, stats_purge_threshold_days, \ enrollment_token_timeout_hours, password_reset_token_timeout_hours, \ enrollment_session_timeout_minutes, password_reset_session_timeout_minutes \ @@ -348,6 +439,7 @@ impl Settings { warn!("Detected empty UUID in settings. Generating a new one."); self.uuid = Uuid::new_v4(); } + self.validate_defguard_url()?; // Check if gateway disconnect notifications can be enabled, since it requires SMTP to be // configured. if self.gateway_disconnect_notifications_enabled && !self.smtp_configured() { @@ -422,14 +514,13 @@ impl Settings { public_proxy_url = $56, \ default_admin_id = $57, \ secret_key = $58, \ - webauthn_rp_id = $59, \ - disable_stats_purge = $60, \ - stats_purge_frequency_hours = $61, \ - stats_purge_threshold_days = $62, \ - enrollment_token_timeout_hours = $63, \ - password_reset_token_timeout_hours = $64, \ - enrollment_session_timeout_minutes = $65, \ - password_reset_session_timeout_minutes = $66 \ + disable_stats_purge = $59, \ + stats_purge_frequency_hours = $60, \ + stats_purge_threshold_days = $61, \ + enrollment_token_timeout_hours = $62, \ + password_reset_token_timeout_hours = $63, \ + enrollment_session_timeout_minutes = $64, \ + password_reset_session_timeout_minutes = $65 \ WHERE id = 1", self.openid_enabled, self.wireguard_enabled, @@ -489,7 +580,6 @@ impl Settings { self.public_proxy_url, self.default_admin_id, self.secret_key, - self.webauthn_rp_id, self.disable_stats_purge, self.stats_purge_frequency_hours, self.stats_purge_threshold_days, @@ -546,16 +636,6 @@ impl Settings { } } - if settings.webauthn_rp_id.is_none() { - let url = Url::parse(&settings.defguard_url).map_err(|_| { - SettingsInitializationError::InvalidDefguardUrl(settings.defguard_url.clone()) - })?; - let domain = url.domain().ok_or_else(|| { - SettingsInitializationError::MissingDefguardDomain(settings.defguard_url.clone()) - })?; - settings.webauthn_rp_id = Some(domain.to_string()); - } - update_current_settings(pool, settings).await?; Ok(()) @@ -664,9 +744,6 @@ impl Settings { self.secret_key = Some(secret_key.to_string()); } } - if let Some(webauthn_rp_id) = &config.webauthn_rp_id { - self.webauthn_rp_id = Some(webauthn_rp_id.clone()); - } if let Some(enrollment_url) = &config.enrollment_url { self.public_proxy_url = enrollment_url.to_string(); } @@ -707,7 +784,7 @@ impl Settings { &mut self, executor: E, config: &DefGuardConfig, - ) -> sqlx::Result<()> + ) -> Result<(), SettingsSaveError> where E: PgExecutor<'e>, { @@ -874,13 +951,11 @@ mod test { fn test_apply_from_config_maps_migrated_fields() { let mut settings = Settings { defguard_url: "https://defguard.example.com".into(), - webauthn_rp_id: Some("existing-rp".into()), ..Default::default() }; let mut config = DefGuardConfig::new_test_config(); config.secret_key = Some(SecretString::from("a".repeat(64))); - config.webauthn_rp_id = Some("rp-from-config".into()); config.enrollment_url = Some(Url::parse("https://proxy.example.com").unwrap()); config.mfa_code_timeout = Some(Duration::from(std::time::Duration::from_secs(75))); config.session_timeout = Some(Duration::from(std::time::Duration::from_secs( @@ -907,7 +982,7 @@ mod test { settings.secret_key.as_deref(), Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") ); - assert_eq!(settings.webauthn_rp_id.as_deref(), Some("rp-from-config")); + assert_eq!(settings.webauthn_rp_id().unwrap(), "defguard.example.com"); assert_eq!(settings.public_proxy_url, "https://proxy.example.com/"); assert_eq!(settings.mfa_code_timeout_seconds, 75); assert_eq!(settings.authentication_period_days, 10); @@ -925,7 +1000,6 @@ mod test { let mut settings = Settings { defguard_url: "https://defguard.example.com".into(), secret_key: Some("z".repeat(64)), - webauthn_rp_id: Some("already-set".into()), public_proxy_url: "https://proxy.initial".into(), mfa_code_timeout_seconds: 123, authentication_period_days: 9, @@ -941,7 +1015,7 @@ mod test { settings.secret_key.as_deref(), Some(existing_secret.as_str()) ); - assert_eq!(settings.webauthn_rp_id.as_deref(), Some("already-set")); + assert_eq!(settings.webauthn_rp_id().unwrap(), "defguard.example.com"); assert_eq!(settings.public_proxy_url, "https://proxy.initial"); assert_eq!(settings.mfa_code_timeout_seconds, 123); assert_eq!(settings.authentication_period_days, 9); @@ -949,17 +1023,52 @@ mod test { } #[test] - fn test_apply_from_config_invalid_defguard_url_does_not_set_webauthn_rp_id() { + fn test_webauthn_rp_id_rejects_invalid_defguard_url() { let mut settings = Settings { defguard_url: "this is not an url".into(), - webauthn_rp_id: None, ..Default::default() }; let config = DefGuardConfig::new_test_config(); settings.apply_from_config(&config); - assert!(settings.webauthn_rp_id.is_none()); + assert!(matches!( + settings.webauthn_rp_id(), + Err(SettingsUrlError::InvalidDefguardUrl(_)) + )); + } + + #[test] + fn test_cookie_domain_derives_from_defguard_url() { + let settings = Settings { + defguard_url: "https://defguard.example.com:8443/path".into(), + ..Default::default() + }; + + assert_eq!(settings.cookie_domain().unwrap(), "defguard.example.com"); + } + + #[test] + fn test_cookie_domain_allows_localhost() { + let settings = Settings { + defguard_url: "http://localhost:8000".into(), + ..Default::default() + }; + + assert_eq!(settings.cookie_domain().unwrap(), "localhost"); + } + + #[test] + fn test_cookie_domain_rejects_ip_hosts() { + let settings = Settings { + defguard_url: "http://127.0.0.1:8000".into(), + ..Default::default() + }; + + assert!(matches!( + settings.cookie_domain(), + Err(SettingsUrlError::DefguardUrlUsesIpAddress(_)) + )); } #[test] @@ -1027,7 +1136,7 @@ mod test { } #[sqlx::test] - async fn test_initialize_runtime_defaults_derives_webauthn_rp_id_from_defguard_url( + async fn test_initialize_runtime_defaults_keeps_valid_defguard_url( _: PgPoolOptions, options: PgConnectOptions, ) { @@ -1036,7 +1145,6 @@ mod test { let mut settings = Settings::get_current_settings(); settings.defguard_url = "https://defguard.example.com:8443/path".into(); - settings.webauthn_rp_id = None; settings.secret_key = Some("a".repeat(64)); update_current_settings(&pool, settings).await.unwrap(); @@ -1045,14 +1153,8 @@ mod test { let current = Settings::get_current_settings(); let from_db = Settings::get(&pool).await.unwrap().unwrap(); - assert_eq!( - current.webauthn_rp_id.as_deref(), - Some("defguard.example.com") - ); - assert_eq!( - from_db.webauthn_rp_id.as_deref(), - Some("defguard.example.com") - ); + assert_eq!(current.webauthn_rp_id().unwrap(), "defguard.example.com"); + assert_eq!(from_db.webauthn_rp_id().unwrap(), "defguard.example.com"); } #[test] diff --git a/crates/defguard_core/src/appstate.rs b/crates/defguard_core/src/appstate.rs index 6e06111b70..4aaa41be56 100644 --- a/crates/defguard_core/src/appstate.rs +++ b/crates/defguard_core/src/appstate.rs @@ -13,8 +13,6 @@ use tokio::{ }, task::spawn, }; -use webauthn_rs::prelude::*; - use crate::{ auth::failed_login::FailedLoginMap, db::{AppEvent, WebHook}, @@ -31,7 +29,6 @@ pub struct AppState { pub pool: PgPool, tx: UnboundedSender, pub wireguard_tx: Sender, - pub webauthn: Arc, pub failed_logins: Arc>, key: Key, pub event_tx: UnboundedSender, @@ -104,6 +101,14 @@ impl AppState { Ok(self.event_tx.send(event)?) } + pub fn webauthn(&self) -> Result, WebError> { + let settings = Settings::get_current_settings(); + settings.build_webauthn().map(Arc::new).map_err(|err| { + error!("Failed to build WebAuthn configuration from current settings: {err}"); + err.into() + }) + } + /// Create application state pub fn new( pool: PgPool, @@ -118,27 +123,10 @@ impl AppState { ) -> Self { spawn(Self::handle_triggers(pool.clone(), rx)); - let url = Settings::url().expect("Invalid Defguard URL configuration"); - let settings = Settings::get_current_settings(); - let webauthn_builder = WebauthnBuilder::new( - settings - .webauthn_rp_id - .as_ref() - .expect("Webauth RP ID configuration is required"), - &url, - ) - .expect("Invalid WebAuthn configuration"); - let webauthn = Arc::new( - webauthn_builder - .build() - .expect("Invalid WebAuthn configuration"), - ); - Self { pool, tx, wireguard_tx, - webauthn, failed_logins, key, event_tx, diff --git a/crates/defguard_core/src/enterprise/handlers/openid_login.rs b/crates/defguard_core/src/enterprise/handlers/openid_login.rs index 7d0061ad06..bd602815b2 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_login.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_login.rs @@ -47,6 +47,7 @@ use crate::{ handlers::{ ApiResponse, AuthResponse, SESSION_COOKIE_NAME, SIGN_IN_COOKIE_NAME, auth::create_session, + current_cookie_domain, mail::send_user_import_blocked_email, user::{MAX_USERNAME_CHARS, check_username}, }, @@ -523,26 +524,24 @@ pub async fn get_auth_info( let (authorize_url, csrf_state, nonce) = authorize_url_builder.url(); - let cookie_domain = config - .cookie_domain - .as_ref() - .expect("Cookie domain not found"); - let nonce_cookie = Cookie::build((NONCE_COOKIE_NAME, nonce.secret().clone())) - .domain(cookie_domain) + let mut nonce_cookie = Cookie::build((NONCE_COOKIE_NAME, nonce.secret().clone())) .path("/api/v1/openid/callback") .http_only(true) .same_site(SameSite::Strict) .secure(!config.cookie_insecure) - .max_age(COOKIE_MAX_AGE) - .build(); - let csrf_cookie = Cookie::build((CSRF_COOKIE_NAME, csrf_state.secret().clone())) - .domain(cookie_domain) + .max_age(COOKIE_MAX_AGE); + let mut csrf_cookie = Cookie::build((CSRF_COOKIE_NAME, csrf_state.secret().clone())) .path("/api/v1/openid/callback") .http_only(true) .same_site(SameSite::Strict) .secure(!config.cookie_insecure) - .max_age(COOKIE_MAX_AGE) - .build(); + .max_age(COOKIE_MAX_AGE); + if let Some(cookie_domain) = current_cookie_domain() { + nonce_cookie = nonce_cookie.domain(cookie_domain.clone()); + csrf_cookie = csrf_cookie.domain(cookie_domain); + } + let nonce_cookie = nonce_cookie.build(); + let csrf_cookie = csrf_cookie.build(); let private_cookies = private_cookies.add(nonce_cookie).add(csrf_cookie); Ok(( @@ -610,17 +609,15 @@ pub async fn auth_callback( error!("Failed to convert authentication timeout for cookie max-age: {err}"); WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) })?; - let cookie_domain = config - .cookie_domain - .as_ref() - .expect("Cookie domain not found"); - let auth_cookie = Cookie::build((SESSION_COOKIE_NAME, session.id)) - .domain(cookie_domain) + let mut auth_cookie = Cookie::build((SESSION_COOKIE_NAME, session.id)) .path("/") .http_only(true) .secure(!config.cookie_insecure) .same_site(SameSite::Lax) .max_age(max_age); + if let Some(cookie_domain) = current_cookie_domain() { + auth_cookie = auth_cookie.domain(cookie_domain); + } let cookies = cookies.add(auth_cookie); // The user may not yet be authorized (pre-MFA), but syncing their groups should be fine here, diff --git a/crates/defguard_core/src/enterprise/ldap/error.rs b/crates/defguard_core/src/enterprise/ldap/error.rs index dfcf9a52c3..b2ebb2981a 100644 --- a/crates/defguard_core/src/enterprise/ldap/error.rs +++ b/crates/defguard_core/src/enterprise/ldap/error.rs @@ -1,3 +1,4 @@ +use defguard_common::db::models::settings::SettingsSaveError; use sqlx::error::Error as SqlxError; use thiserror::Error; @@ -13,6 +14,8 @@ pub enum LdapError { TooManyObjects, #[error("Database error: {0}")] Database(#[from] SqlxError), + #[error(transparent)] + SettingsSave(#[from] SettingsSaveError), #[error("Expected different DN: {0}")] InvalidDN(String), #[error("Missing attribute: {0}")] diff --git a/crates/defguard_core/src/enterprise/license.rs b/crates/defguard_core/src/enterprise/license.rs index c0b3227b3f..1f1c199113 100644 --- a/crates/defguard_core/src/enterprise/license.rs +++ b/crates/defguard_core/src/enterprise/license.rs @@ -6,7 +6,10 @@ use chrono::{DateTime, TimeDelta, Utc}; use defguard_common::{ VERSION, config::server_config, - db::models::{Settings, gateway::Gateway, proxy::Proxy, settings::update_current_settings}, + db::models::{ + Settings, gateway::Gateway, proxy::Proxy, + settings::{SettingsSaveError, update_current_settings}, + }, global_value, types::proxy::ProxyControlMessage, }; @@ -46,6 +49,8 @@ pub enum LicenseError { InvalidSignature, #[error("Database error")] DbError(#[from] SqlxError), + #[error(transparent)] + SettingsSave(#[from] SettingsSaveError), #[error("License decoding error: {0}")] DecodeError(&'static str), #[error( diff --git a/crates/defguard_core/src/error.rs b/crates/defguard_core/src/error.rs index 5e5edbdd0c..e0422b0ca3 100644 --- a/crates/defguard_core/src/error.rs +++ b/crates/defguard_core/src/error.rs @@ -1,8 +1,9 @@ use axum::http::StatusCode; use defguard_common::{ db::models::{ - DeviceError, ModelError, WireguardNetworkError, settings::SettingsValidationError, + settings::{SettingsSaveError, SettingsUrlError, SettingsValidationError}, user::UserError, + DeviceError, ModelError, WireguardNetworkError, }, types::UrlParseError, }; @@ -174,10 +175,26 @@ impl From for WebError { SettingsValidationError::CannotEnableGatewayNotifications => { Self::BadRequest(err.to_string()) } + SettingsValidationError::InvalidDefguardUrl(_) => Self::BadRequest(err.to_string()), } } } +impl From for WebError { + fn from(err: SettingsSaveError) -> Self { + match err { + SettingsSaveError::Db(err) => Self::DbError(err.to_string()), + SettingsSaveError::Validation(err) => err.into(), + } + } +} + +impl From for WebError { + fn from(_err: SettingsUrlError) -> Self { + Self::Http(StatusCode::INTERNAL_SERVER_ERROR) + } +} + impl From for WebError { fn from(err: UserError) -> Self { error!("{err}"); diff --git a/crates/defguard_core/src/handlers/auth.rs b/crates/defguard_core/src/handlers/auth.rs index 6d8d513f95..1ceb550cbf 100644 --- a/crates/defguard_core/src/handlers/auth.rs +++ b/crates/defguard_core/src/handlers/auth.rs @@ -41,7 +41,7 @@ use crate::{ error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, handlers::{ - SIGN_IN_COOKIE_NAME, + SIGN_IN_COOKIE_NAME, current_cookie_domain, mail::{send_email_mfa_activation_email, send_mfa_configured_email}, user_for_admin_or_self, }, @@ -243,17 +243,15 @@ pub async fn authenticate( WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) })?; let config = server_config(); - let cookie_domain = config - .cookie_domain - .as_ref() - .expect("Cookie domain not found"); - let auth_cookie = Cookie::build((SESSION_COOKIE_NAME, session.id.clone())) - .domain(cookie_domain) + let mut auth_cookie = Cookie::build((SESSION_COOKIE_NAME, session.id.clone())) .path("/") .http_only(true) .secure(!config.cookie_insecure) .same_site(SameSite::Lax) .max_age(max_age); + if let Some(cookie_domain) = current_cookie_domain() { + auth_cookie = auth_cookie.domain(cookie_domain); + } let cookies = cookies.add(auth_cookie); if let Some(mfa_info) = mfa_info { @@ -317,17 +315,12 @@ pub async fn logout( InsecureClientIp(insecure_ip): InsecureClientIp, State(appstate): State, ) -> Result<(CookieJar, PrivateCookieJar, ApiResponse), WebError> { - let cookie_domain = server_config() - .cookie_domain - .as_ref() - .ok_or(WebError::Http(StatusCode::INTERNAL_SERVER_ERROR))? - .clone(); - let session_cookie = Cookie::build((SESSION_COOKIE_NAME, "")) - .domain(cookie_domain.clone()) - .path("/"); - let sign_in_cookie = Cookie::build((SIGN_IN_COOKIE_NAME, "")) - .domain(cookie_domain) - .path("/"); + let mut session_cookie = Cookie::build((SESSION_COOKIE_NAME, "")).path("/"); + let mut sign_in_cookie = Cookie::build((SIGN_IN_COOKIE_NAME, "")).path("/"); + if let Some(cookie_domain) = current_cookie_domain() { + session_cookie = session_cookie.domain(cookie_domain.clone()); + sign_in_cookie = sign_in_cookie.domain(cookie_domain); + } let cookies = cookies.remove(session_cookie); let private_cookies = private_cookies.remove(sign_in_cookie); let user = User::find_by_id(&appstate.pool, session.user_id) @@ -424,7 +417,8 @@ pub async fn webauthn_init( ); // passkeys to exclude let passkeys = WebAuthn::passkeys_for_user(&appstate.pool, user.id).await?; - match appstate.webauthn.start_passkey_registration( + let webauthn = appstate.webauthn()?; + match webauthn.start_passkey_registration( Uuid::new_v4(), &user.username, &user.username, @@ -477,15 +471,15 @@ pub async fn webauthn_finish( info!( "Allowed origins: {:?}", appstate - .webauthn + .webauthn()? .get_allowed_origins() .iter() .map(ToString::to_string) .collect::>() ); - let passkey = appstate - .webauthn + let webauthn = appstate.webauthn()?; + let passkey = webauthn .finish_passkey_registration(&webauth_reg.rpkc, &passkey_reg) .map_err(|err| WebError::WebauthnRegistration(err.to_string()))?; let mut user = User::find_by_id(&appstate.pool, session.session.user_id) @@ -516,8 +510,9 @@ pub async fn webauthn_start( State(appstate): State, ) -> ApiResult { let passkeys = WebAuthn::passkeys_for_user(&appstate.pool, session.user_id).await?; + let webauthn = appstate.webauthn()?; - match appstate.webauthn.start_passkey_authentication(&passkeys) { + match webauthn.start_passkey_authentication(&passkeys) { Ok((rcr, passkey_reg)) => { session .set_passkey_authentication(&appstate.pool, &passkey_reg) @@ -538,10 +533,8 @@ pub async fn webauthn_end( Json(pubkey): Json, ) -> Result<(PrivateCookieJar, ApiResponse), WebError> { if let Some(passkey_auth) = session.get_passkey_authentication() { - match appstate - .webauthn - .finish_passkey_authentication(&pubkey, &passkey_auth) - { + let webauthn = appstate.webauthn()?; + match webauthn.finish_passkey_authentication(&pubkey, &passkey_auth) { Ok(auth_result) => { if auth_result.needs_update() { // Find `Passkey` and try to update its credentials diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index 857e486b0b..74762e3356 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -7,6 +7,7 @@ use axum::{ use axum_client_ip::InsecureClientIp; use axum_extra::{TypedHeader, headers::UserAgent}; use defguard_common::{ + config::server_config, db::{ Id, NoId, models::{Device, User}, @@ -61,6 +62,16 @@ pub(crate) static SIGN_IN_COOKIE_NAME: &str = "defguard_sign_in"; pub(crate) const SIGN_IN_COOKIE_MAX_AGE: time::Duration = time::Duration::minutes(10); pub(crate) const DEFAULT_API_PAGE_SIZE: u32 = 50; +pub(crate) fn current_cookie_domain() -> Option { + server_config().cookie_domain.clone().or_else(|| { + let settings = defguard_common::db::models::Settings::get_current_settings(); + settings.cookie_domain().map_err(|err| { + error!("Failed to derive cookie domain from defguard_url: {err}"); + }) + .ok() + }) +} + #[derive(Default, ToSchema)] pub struct ApiResponse { json: Value, diff --git a/crates/defguard_core/src/handlers/openid_flow.rs b/crates/defguard_core/src/handlers/openid_flow.rs index 9d7034edbb..28111a8adb 100644 --- a/crates/defguard_core/src/handlers/openid_flow.rs +++ b/crates/defguard_core/src/handlers/openid_flow.rs @@ -49,7 +49,8 @@ use crate::{ auth::{SessionInfo, UserClaims}, error::WebError, handlers::{ - SIGN_IN_COOKIE_MAX_AGE, SIGN_IN_COOKIE_NAME, mail::send_new_device_ocid_login_email, + SIGN_IN_COOKIE_MAX_AGE, SIGN_IN_COOKIE_NAME, current_cookie_domain, + mail::send_new_device_ocid_login_email, }, server_config, }; @@ -354,24 +355,21 @@ fn login_redirect( error!("Failed to prepare redirect URL: {err}"); WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) })?; - let cookie = Cookie::build(( + let mut cookie = Cookie::build(( SIGN_IN_COOKIE_NAME, format!( "{base_url}?{}", serde_urlencoded::to_string(data).unwrap_or_default() ), )) - .domain( - config - .cookie_domain - .clone() - .expect("Cookie domain not found"), - ) .path("/") .secure(!config.cookie_insecure) .same_site(SameSite::Lax) .http_only(true) .max_age(SIGN_IN_COOKIE_MAX_AGE); + if let Some(cookie_domain) = current_cookie_domain() { + cookie = cookie.domain(cookie_domain); + } Ok(redirect_to("/login", private_cookies.add(cookie))) } diff --git a/crates/defguard_core/tests/integration/common.rs b/crates/defguard_core/tests/integration/common.rs index d6b7cf3c69..e85c699537 100644 --- a/crates/defguard_core/tests/integration/common.rs +++ b/crates/defguard_core/tests/integration/common.rs @@ -6,7 +6,6 @@ use defguard_common::{ }, }; use defguard_core::enterprise::license::{License, LicenseTier, set_cached_license}; -use reqwest::Url; use sqlx::PgPool; fn set_test_license_business() { @@ -29,7 +28,6 @@ pub(crate) async fn init_config( ) -> DefGuardConfig { let url = custom_defguard_url.unwrap_or("http://localhost:8000"); let test_secret_key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - let webauthn_rp_id = Url::parse(url).unwrap().domain().unwrap().to_string(); let mut config = DefGuardConfig::new_test_config(); initialize_current_settings(pool) .await @@ -37,7 +35,6 @@ pub(crate) async fn init_config( let mut settings = Settings::get_current_settings(); settings.defguard_url = url.to_string(); settings.secret_key = Some(test_secret_key.to_string()); - settings.webauthn_rp_id = Some(webauthn_rp_id); update_current_settings(pool, settings) .await .expect("Could not update current settings in the database"); diff --git a/crates/defguard_setup/src/auto_adoption.rs b/crates/defguard_setup/src/auto_adoption.rs index cdd73df194..5e0026015b 100644 --- a/crates/defguard_setup/src/auto_adoption.rs +++ b/crates/defguard_setup/src/auto_adoption.rs @@ -86,6 +86,7 @@ async fn ensure_ca_for_auto_adoption(pool: &PgPool) -> Result<(), anyhow::Error> update_current_settings(pool, settings) .await + .map_err(anyhow::Error::from) .context("Failed to persist automatically generated CA for auto-adoption")?; info!( diff --git a/migrations/20260312072940_[2.0.0]_remove_rp_id.down.sql b/migrations/20260312072940_[2.0.0]_remove_rp_id.down.sql new file mode 100644 index 0000000000..1561c46bbd --- /dev/null +++ b/migrations/20260312072940_[2.0.0]_remove_rp_id.down.sql @@ -0,0 +1 @@ +ALTER TABLE settings ADD COLUMN webauthn_rp_id text; diff --git a/migrations/20260312072940_[2.0.0]_remove_rp_id.up.sql b/migrations/20260312072940_[2.0.0]_remove_rp_id.up.sql new file mode 100644 index 0000000000..cb593aee1c --- /dev/null +++ b/migrations/20260312072940_[2.0.0]_remove_rp_id.up.sql @@ -0,0 +1 @@ +ALTER TABLE settings DROP webauthn_rp_id; diff --git a/web/messages/en/initial_wizard.json b/web/messages/en/initial_wizard.json index 8541eff216..2e1e2467b6 100644 --- a/web/messages/en/initial_wizard.json +++ b/web/messages/en/initial_wizard.json @@ -51,6 +51,7 @@ "initial_setup_general_config_error_invalid_url": "Invalid URL", "initial_setup_general_config_error_defguard_url_required": "Defguard URL is required", + "initial_setup_general_config_error_defguard_url_invalid_host": "Defguard URL must use a hostname, not an IP address", "initial_setup_general_config_error_admin_group_required": "Default admin group name is required", "initial_setup_general_config_error_auth_period_min": "Authentication period must be at least 1 day", "initial_setup_general_config_error_mfa_timeout_min": "MFA code timeout must be at least 60 seconds", diff --git a/web/messages/en/migration_wizard.json b/web/messages/en/migration_wizard.json index 0bf0ddc07a..9712b45e5a 100644 --- a/web/messages/en/migration_wizard.json +++ b/web/messages/en/migration_wizard.json @@ -28,6 +28,7 @@ "migration_wizard_ca_validity_years": "{years} years", "migration_wizard_general_config_error_invalid_url": "Invalid URL", "migration_wizard_general_config_error_defguard_url_required": "Defguard URL is required", + "migration_wizard_general_config_error_defguard_url_invalid_host": "Defguard URL must use a hostname, not an IP address", "migration_wizard_general_config_error_admin_group_required": "Default admin group name is required", "migration_wizard_general_config_error_auth_period_min": "Authentication period must be at least 1 day", "migration_wizard_general_config_error_mfa_timeout_min": "MFA code timeout must be at least 60 seconds", diff --git a/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx b/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx index 9994271d1e..f49c9f0c70 100644 --- a/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx +++ b/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx @@ -9,6 +9,7 @@ import { WizardCard } from '../../../shared/components/wizard/WizardCard/WizardC import { Button } from '../../../shared/defguard-ui/components/Button/Button'; import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { ThemeSpacing } from '../../../shared/defguard-ui/types'; +import { isValidDefguardUrl } from '../../../shared/defguardUrl'; import { useAppForm } from '../../../shared/form'; import { formChangeLogic } from '../../../shared/formLogic'; import { useMigrationWizardStore } from '../store/useMigrationWizardStore'; @@ -48,6 +49,10 @@ export const MigrationWizardGeneralConfigurationStep = () => { z.object({ defguard_url: z .url(m.migration_wizard_general_config_error_invalid_url()) + .refine( + isValidDefguardUrl, + m.migration_wizard_general_config_error_defguard_url_invalid_host(), + ) .min(1, m.migration_wizard_general_config_error_defguard_url_required()), default_admin_group_name: z .string() diff --git a/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx index a557ef64c7..756a13c696 100644 --- a/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx +++ b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx @@ -11,6 +11,7 @@ import { Divider } from '../../../../shared/defguard-ui/components/Divider/Divid import { SizedBox } from '../../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { Snackbar } from '../../../../shared/defguard-ui/providers/snackbar/snackbar'; import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; +import { isValidDefguardUrl } from '../../../../shared/defguardUrl'; import { useAppForm } from '../../../../shared/form'; import { formChangeLogic } from '../../../../shared/formLogic'; import { AutoAdoptionSetupStep } from '../types'; @@ -38,6 +39,10 @@ export const AutoAdoptionUrlSettingsStep = () => { z.object({ defguard_url: z .url(m.initial_setup_general_config_error_invalid_url()) + .refine( + isValidDefguardUrl, + m.initial_setup_general_config_error_defguard_url_invalid_host(), + ) .min(1, m.initial_setup_general_config_error_defguard_url_required()), public_proxy_url: z .url(m.initial_setup_general_config_error_public_proxy_url_invalid()) diff --git a/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx b/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx index 6640cf76c2..0426806683 100644 --- a/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx +++ b/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx @@ -10,6 +10,7 @@ import { Button } from '../../../../shared/defguard-ui/components/Button/Button' import { SizedBox } from '../../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { Snackbar } from '../../../../shared/defguard-ui/providers/snackbar/snackbar'; import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; +import { isValidDefguardUrl } from '../../../../shared/defguardUrl'; import { useAppForm } from '../../../../shared/form'; import { formChangeLogic } from '../../../../shared/formLogic'; import { SetupPageStep } from '../types'; @@ -44,6 +45,10 @@ export const SetupGeneralConfigStep = () => { z.object({ defguard_url: z .url(m.initial_setup_general_config_error_invalid_url()) + .refine( + isValidDefguardUrl, + m.initial_setup_general_config_error_defguard_url_invalid_host(), + ) .min(1, m.initial_setup_general_config_error_defguard_url_required()), default_admin_group_name: z .string() diff --git a/web/src/shared/defguardUrl.ts b/web/src/shared/defguardUrl.ts new file mode 100644 index 0000000000..f54197f753 --- /dev/null +++ b/web/src/shared/defguardUrl.ts @@ -0,0 +1,20 @@ +import ipaddr from 'ipaddr.js'; + +export const isValidDefguardUrl = (value: string): boolean => { + try { + const url = new URL(value); + const hostname = url.hostname; + + if (hostname.length === 0) { + return false; + } + + if (ipaddr.isValid(hostname)) { + return false; + } + + return true; + } catch { + return false; + } +}; From 07125651809ae80c585bc1baa789a45dee6351ad Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 13 Mar 2026 07:57:55 +0100 Subject: [PATCH 02/16] more defguard_url parsing tests --- .../defguard_common/src/db/models/settings.rs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index 14d50b56e4..422de96ecd 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -1038,6 +1038,33 @@ mod test { )); } + #[test] + fn test_parse_defguard_url_parses_valid_hostname_url() { + let settings = Settings { + defguard_url: "https://defguard.example.com:8443/path".into(), + ..Default::default() + }; + + let url = settings.parse_defguard_url().unwrap(); + + assert_eq!(url.host_str(), Some("defguard.example.com")); + assert_eq!(url.port(), Some(8443)); + assert_eq!(url.path(), "/path"); + } + + #[test] + fn test_parse_defguard_url_rejects_ip_host() { + let settings = Settings { + defguard_url: "http://127.0.0.1:8000".into(), + ..Default::default() + }; + + assert!(matches!( + settings.parse_defguard_url(), + Err(SettingsUrlError::DefguardUrlUsesIpAddress(_)) + )); + } + #[test] fn test_cookie_domain_derives_from_defguard_url() { let settings = Settings { @@ -1071,6 +1098,29 @@ mod test { )); } + #[test] + fn test_validate_defguard_url_accepts_valid_hostname() { + let settings = Settings { + defguard_url: "https://defguard.example.com".into(), + ..Default::default() + }; + + assert!(settings.validate_defguard_url().is_ok()); + } + + #[test] + fn test_validate_defguard_url_rejects_invalid_url() { + let settings = Settings { + defguard_url: "not a url".into(), + ..Default::default() + }; + + assert!(matches!( + settings.validate_defguard_url(), + Err(SettingsUrlError::InvalidDefguardUrl(_)) + )); + } + #[test] #[allow(deprecated)] fn test_apply_from_config_invalid_secret_key_generates_new() { From ed5ff6654eaffb9ca1f8831b9b24902bebdd97c0 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 13 Mar 2026 07:59:57 +0100 Subject: [PATCH 03/16] cargo fmt --- crates/defguard_common/src/config.rs | 6 +++--- crates/defguard_common/src/db/models/settings.rs | 11 +++++++---- crates/defguard_core/src/appstate.rs | 16 ++++++++-------- crates/defguard_core/src/enterprise/license.rs | 4 +++- crates/defguard_core/src/error.rs | 2 +- crates/defguard_core/src/handlers/mod.rs | 10 ++++++---- 6 files changed, 28 insertions(+), 21 deletions(-) diff --git a/crates/defguard_common/src/config.rs b/crates/defguard_common/src/config.rs index 6504335d92..602505f1a2 100644 --- a/crates/defguard_common/src/config.rs +++ b/crates/defguard_common/src/config.rs @@ -3,18 +3,18 @@ use std::{net::IpAddr, sync::OnceLock}; use clap::{Args, Parser, Subcommand}; use humantime::Duration; use ipnetwork::IpNetwork; -use openidconnect::{core::CoreRsaPrivateSigningKey, JsonWebKeyId}; +use openidconnect::{JsonWebKeyId, core::CoreRsaPrivateSigningKey}; use reqwest::Url; use rsa::{ + RsaPrivateKey, pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey}, pkcs8::{DecodePrivateKey, LineEnding}, traits::PublicKeyParts, - RsaPrivateKey, }; use secrecy::{ExposeSecret, SecretString}; use serde::Serialize; -use crate::{db::models::Settings, VERSION}; +use crate::{VERSION, db::models::Settings}; pub static SERVER_CONFIG: OnceLock = OnceLock::new(); diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index 422de96ecd..9ce549b5da 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -344,10 +344,13 @@ impl Settings { pub fn webauthn_rp_id(&self) -> Result { let url = self.parse_defguard_url()?; - let domain = url.domain().map(str::to_string).or_else(|| match url.host_str() { - Some("localhost") => Some("localhost".to_string()), - _ => None, - }); + let domain = url + .domain() + .map(str::to_string) + .or_else(|| match url.host_str() { + Some("localhost") => Some("localhost".to_string()), + _ => None, + }); domain.ok_or_else(|| SettingsUrlError::MissingDefguardDomain(self.defguard_url.clone())) } diff --git a/crates/defguard_core/src/appstate.rs b/crates/defguard_core/src/appstate.rs index 4aaa41be56..c7e8fa5100 100644 --- a/crates/defguard_core/src/appstate.rs +++ b/crates/defguard_core/src/appstate.rs @@ -1,5 +1,13 @@ use std::sync::{Arc, Mutex, RwLock}; +use crate::{ + auth::failed_login::FailedLoginMap, + db::{AppEvent, WebHook}, + error::WebError, + events::ApiEvent, + grpc::{GatewayEvent, send_multiple_wireguard_events, send_wireguard_event}, + version::IncompatibleComponents, +}; use axum::extract::FromRef; use axum_extra::extract::cookie::Key; use defguard_common::{db::models::Settings, types::proxy::ProxyControlMessage}; @@ -13,14 +21,6 @@ use tokio::{ }, task::spawn, }; -use crate::{ - auth::failed_login::FailedLoginMap, - db::{AppEvent, WebHook}, - error::WebError, - events::ApiEvent, - grpc::{GatewayEvent, send_multiple_wireguard_events, send_wireguard_event}, - version::IncompatibleComponents, -}; const X_DEFGUARD_EVENT: &str = "x-defguard-event"; diff --git a/crates/defguard_core/src/enterprise/license.rs b/crates/defguard_core/src/enterprise/license.rs index 1f1c199113..70f2562d29 100644 --- a/crates/defguard_core/src/enterprise/license.rs +++ b/crates/defguard_core/src/enterprise/license.rs @@ -7,7 +7,9 @@ use defguard_common::{ VERSION, config::server_config, db::models::{ - Settings, gateway::Gateway, proxy::Proxy, + Settings, + gateway::Gateway, + proxy::Proxy, settings::{SettingsSaveError, update_current_settings}, }, global_value, diff --git a/crates/defguard_core/src/error.rs b/crates/defguard_core/src/error.rs index e0422b0ca3..c63eb7f18a 100644 --- a/crates/defguard_core/src/error.rs +++ b/crates/defguard_core/src/error.rs @@ -1,9 +1,9 @@ use axum::http::StatusCode; use defguard_common::{ db::models::{ + DeviceError, ModelError, WireguardNetworkError, settings::{SettingsSaveError, SettingsUrlError, SettingsValidationError}, user::UserError, - DeviceError, ModelError, WireguardNetworkError, }, types::UrlParseError, }; diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index 74762e3356..7dec5fffa2 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -65,10 +65,12 @@ pub(crate) const DEFAULT_API_PAGE_SIZE: u32 = 50; pub(crate) fn current_cookie_domain() -> Option { server_config().cookie_domain.clone().or_else(|| { let settings = defguard_common::db::models::Settings::get_current_settings(); - settings.cookie_domain().map_err(|err| { - error!("Failed to derive cookie domain from defguard_url: {err}"); - }) - .ok() + settings + .cookie_domain() + .map_err(|err| { + error!("Failed to derive cookie domain from defguard_url: {err}"); + }) + .ok() }) } From 5980a1f9ea6ce45a9f0ba375678ae459288fe1d7 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 13 Mar 2026 08:18:50 +0100 Subject: [PATCH 04/16] update sqlx query data --- ...dc7bd4c4b88cd209a2a399041dec3dce7d374fd2c0fa3bb9d0d81.json} | 2 +- ...989f48cbe5283f9c81481d06a4a0ba5543678e809ffb12dc0168f.json} | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) rename .sqlx/{query-386714aa5e0cbc2d896edf6dcd2f4ec260d08f8feee279c41f73fd8e33aea5aa.json => query-5972488273bdc7bd4c4b88cd209a2a399041dec3dce7d374fd2c0fa3bb9d0d81.json} (98%) rename .sqlx/{query-bb022291c5a000545df91eb246c5a24916ddd638337ed9427d94db5b1dc9766a.json => query-915f29b91da989f48cbe5283f9c81481d06a4a0ba5543678e809ffb12dc0168f.json} (99%) diff --git a/.sqlx/query-386714aa5e0cbc2d896edf6dcd2f4ec260d08f8feee279c41f73fd8e33aea5aa.json b/.sqlx/query-5972488273bdc7bd4c4b88cd209a2a399041dec3dce7d374fd2c0fa3bb9d0d81.json similarity index 98% rename from .sqlx/query-386714aa5e0cbc2d896edf6dcd2f4ec260d08f8feee279c41f73fd8e33aea5aa.json rename to .sqlx/query-5972488273bdc7bd4c4b88cd209a2a399041dec3dce7d374fd2c0fa3bb9d0d81.json index c24d01f3b3..96d6aa4d9d 100644 --- a/.sqlx/query-386714aa5e0cbc2d896edf6dcd2f4ec260d08f8feee279c41f73fd8e33aea5aa.json +++ b/.sqlx/query-5972488273bdc7bd4c4b88cd209a2a399041dec3dce7d374fd2c0fa3bb9d0d81.json @@ -106,5 +106,5 @@ }, "nullable": [] }, - "hash": "386714aa5e0cbc2d896edf6dcd2f4ec260d08f8feee279c41f73fd8e33aea5aa" + "hash": "5972488273bdc7bd4c4b88cd209a2a399041dec3dce7d374fd2c0fa3bb9d0d81" } diff --git a/.sqlx/query-bb022291c5a000545df91eb246c5a24916ddd638337ed9427d94db5b1dc9766a.json b/.sqlx/query-915f29b91da989f48cbe5283f9c81481d06a4a0ba5543678e809ffb12dc0168f.json similarity index 99% rename from .sqlx/query-bb022291c5a000545df91eb246c5a24916ddd638337ed9427d94db5b1dc9766a.json rename to .sqlx/query-915f29b91da989f48cbe5283f9c81481d06a4a0ba5543678e809ffb12dc0168f.json index 80ae934483..85105629b9 100644 --- a/.sqlx/query-bb022291c5a000545df91eb246c5a24916ddd638337ed9427d94db5b1dc9766a.json +++ b/.sqlx/query-915f29b91da989f48cbe5283f9c81481d06a4a0ba5543678e809ffb12dc0168f.json @@ -423,7 +423,6 @@ false, true, true, - true, false, false, false, @@ -433,5 +432,5 @@ false ] }, - "hash": "bb022291c5a000545df91eb246c5a24916ddd638337ed9427d94db5b1dc9766a" + "hash": "915f29b91da989f48cbe5283f9c81481d06a4a0ba5543678e809ffb12dc0168f" } From 1d3dcb53dbed32711441b9e51e1ea2a1addb5d60 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 13 Mar 2026 08:45:33 +0100 Subject: [PATCH 05/16] formatting, style tweaks --- crates/defguard_core/src/appstate.rs | 17 +++++++++-------- .../src/enterprise/handlers/openid_login.rs | 6 +++--- crates/defguard_core/src/handlers/auth.rs | 6 +++--- crates/defguard_core/src/handlers/mod.rs | 4 ++-- .../defguard_core/src/handlers/openid_flow.rs | 4 ++-- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/crates/defguard_core/src/appstate.rs b/crates/defguard_core/src/appstate.rs index c7e8fa5100..90fc03924b 100644 --- a/crates/defguard_core/src/appstate.rs +++ b/crates/defguard_core/src/appstate.rs @@ -1,13 +1,5 @@ use std::sync::{Arc, Mutex, RwLock}; -use crate::{ - auth::failed_login::FailedLoginMap, - db::{AppEvent, WebHook}, - error::WebError, - events::ApiEvent, - grpc::{GatewayEvent, send_multiple_wireguard_events, send_wireguard_event}, - version::IncompatibleComponents, -}; use axum::extract::FromRef; use axum_extra::extract::cookie::Key; use defguard_common::{db::models::Settings, types::proxy::ProxyControlMessage}; @@ -22,6 +14,15 @@ use tokio::{ task::spawn, }; +use crate::{ + auth::failed_login::FailedLoginMap, + db::{AppEvent, WebHook}, + error::WebError, + events::ApiEvent, + grpc::{GatewayEvent, send_multiple_wireguard_events, send_wireguard_event}, + version::IncompatibleComponents, +}; + const X_DEFGUARD_EVENT: &str = "x-defguard-event"; #[derive(Clone)] diff --git a/crates/defguard_core/src/enterprise/handlers/openid_login.rs b/crates/defguard_core/src/enterprise/handlers/openid_login.rs index bd602815b2..341b65c76e 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_login.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_login.rs @@ -47,7 +47,7 @@ use crate::{ handlers::{ ApiResponse, AuthResponse, SESSION_COOKIE_NAME, SIGN_IN_COOKIE_NAME, auth::create_session, - current_cookie_domain, + cookie_domain, mail::send_user_import_blocked_email, user::{MAX_USERNAME_CHARS, check_username}, }, @@ -536,7 +536,7 @@ pub async fn get_auth_info( .same_site(SameSite::Strict) .secure(!config.cookie_insecure) .max_age(COOKIE_MAX_AGE); - if let Some(cookie_domain) = current_cookie_domain() { + if let Some(cookie_domain) = cookie_domain() { nonce_cookie = nonce_cookie.domain(cookie_domain.clone()); csrf_cookie = csrf_cookie.domain(cookie_domain); } @@ -615,7 +615,7 @@ pub async fn auth_callback( .secure(!config.cookie_insecure) .same_site(SameSite::Lax) .max_age(max_age); - if let Some(cookie_domain) = current_cookie_domain() { + if let Some(cookie_domain) = cookie_domain() { auth_cookie = auth_cookie.domain(cookie_domain); } let cookies = cookies.add(auth_cookie); diff --git a/crates/defguard_core/src/handlers/auth.rs b/crates/defguard_core/src/handlers/auth.rs index 1ceb550cbf..a7d9b3391b 100644 --- a/crates/defguard_core/src/handlers/auth.rs +++ b/crates/defguard_core/src/handlers/auth.rs @@ -41,7 +41,7 @@ use crate::{ error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, handlers::{ - SIGN_IN_COOKIE_NAME, current_cookie_domain, + SIGN_IN_COOKIE_NAME, cookie_domain, mail::{send_email_mfa_activation_email, send_mfa_configured_email}, user_for_admin_or_self, }, @@ -249,7 +249,7 @@ pub async fn authenticate( .secure(!config.cookie_insecure) .same_site(SameSite::Lax) .max_age(max_age); - if let Some(cookie_domain) = current_cookie_domain() { + if let Some(cookie_domain) = cookie_domain() { auth_cookie = auth_cookie.domain(cookie_domain); } let cookies = cookies.add(auth_cookie); @@ -317,7 +317,7 @@ pub async fn logout( ) -> Result<(CookieJar, PrivateCookieJar, ApiResponse), WebError> { let mut session_cookie = Cookie::build((SESSION_COOKIE_NAME, "")).path("/"); let mut sign_in_cookie = Cookie::build((SIGN_IN_COOKIE_NAME, "")).path("/"); - if let Some(cookie_domain) = current_cookie_domain() { + if let Some(cookie_domain) = cookie_domain() { session_cookie = session_cookie.domain(cookie_domain.clone()); sign_in_cookie = sign_in_cookie.domain(cookie_domain); } diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index 7dec5fffa2..b431db731e 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -62,13 +62,13 @@ pub(crate) static SIGN_IN_COOKIE_NAME: &str = "defguard_sign_in"; pub(crate) const SIGN_IN_COOKIE_MAX_AGE: time::Duration = time::Duration::minutes(10); pub(crate) const DEFAULT_API_PAGE_SIZE: u32 = 50; -pub(crate) fn current_cookie_domain() -> Option { +pub(crate) fn cookie_domain() -> Option { server_config().cookie_domain.clone().or_else(|| { let settings = defguard_common::db::models::Settings::get_current_settings(); settings .cookie_domain() .map_err(|err| { - error!("Failed to derive cookie domain from defguard_url: {err}"); + warn!("Failed to derive cookie domain: {err}"); }) .ok() }) diff --git a/crates/defguard_core/src/handlers/openid_flow.rs b/crates/defguard_core/src/handlers/openid_flow.rs index 28111a8adb..469fc750a0 100644 --- a/crates/defguard_core/src/handlers/openid_flow.rs +++ b/crates/defguard_core/src/handlers/openid_flow.rs @@ -49,7 +49,7 @@ use crate::{ auth::{SessionInfo, UserClaims}, error::WebError, handlers::{ - SIGN_IN_COOKIE_MAX_AGE, SIGN_IN_COOKIE_NAME, current_cookie_domain, + SIGN_IN_COOKIE_MAX_AGE, SIGN_IN_COOKIE_NAME, cookie_domain, mail::send_new_device_ocid_login_email, }, server_config, @@ -367,7 +367,7 @@ fn login_redirect( .same_site(SameSite::Lax) .http_only(true) .max_age(SIGN_IN_COOKIE_MAX_AGE); - if let Some(cookie_domain) = current_cookie_domain() { + if let Some(cookie_domain) = cookie_domain() { cookie = cookie.domain(cookie_domain); } Ok(redirect_to("/login", private_cookies.add(cookie))) From 7b1b081e1c27b1130fc2d64af31e7dc47f1a4f44 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 13 Mar 2026 11:21:34 +0100 Subject: [PATCH 06/16] mv defguardUrl to utils dir --- .../steps/MigrationWizardGeneralConfigurationStep.tsx | 2 +- .../autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx | 2 +- .../pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx | 2 +- web/src/shared/{ => utils}/defguardUrl.ts | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename web/src/shared/{ => utils}/defguardUrl.ts (100%) diff --git a/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx b/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx index f49c9f0c70..43fc84cc14 100644 --- a/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx +++ b/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx @@ -9,7 +9,7 @@ import { WizardCard } from '../../../shared/components/wizard/WizardCard/WizardC import { Button } from '../../../shared/defguard-ui/components/Button/Button'; import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { ThemeSpacing } from '../../../shared/defguard-ui/types'; -import { isValidDefguardUrl } from '../../../shared/defguardUrl'; +import { isValidDefguardUrl } from '../../../shared/utils/defguardUrl'; import { useAppForm } from '../../../shared/form'; import { formChangeLogic } from '../../../shared/formLogic'; import { useMigrationWizardStore } from '../store/useMigrationWizardStore'; diff --git a/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx index 756a13c696..418f403dfa 100644 --- a/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx +++ b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx @@ -11,7 +11,7 @@ import { Divider } from '../../../../shared/defguard-ui/components/Divider/Divid import { SizedBox } from '../../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { Snackbar } from '../../../../shared/defguard-ui/providers/snackbar/snackbar'; import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; -import { isValidDefguardUrl } from '../../../../shared/defguardUrl'; +import { isValidDefguardUrl } from '../../../../shared/utils/defguardUrl'; import { useAppForm } from '../../../../shared/form'; import { formChangeLogic } from '../../../../shared/formLogic'; import { AutoAdoptionSetupStep } from '../types'; diff --git a/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx b/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx index 0426806683..f0d439dfa0 100644 --- a/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx +++ b/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx @@ -10,7 +10,7 @@ import { Button } from '../../../../shared/defguard-ui/components/Button/Button' import { SizedBox } from '../../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { Snackbar } from '../../../../shared/defguard-ui/providers/snackbar/snackbar'; import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; -import { isValidDefguardUrl } from '../../../../shared/defguardUrl'; +import { isValidDefguardUrl } from '../../../../shared/utils/defguardUrl'; import { useAppForm } from '../../../../shared/form'; import { formChangeLogic } from '../../../../shared/formLogic'; import { SetupPageStep } from '../types'; diff --git a/web/src/shared/defguardUrl.ts b/web/src/shared/utils/defguardUrl.ts similarity index 100% rename from web/src/shared/defguardUrl.ts rename to web/src/shared/utils/defguardUrl.ts From f43e9d39b4d61498c3c04725b46c8f2ac6b92db5 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 13 Mar 2026 11:48:28 +0100 Subject: [PATCH 07/16] defguard_url setting UI --- web/messages/en/settings.json | 4 ++++ .../MigrationWizardGeneralConfigurationStep.tsx | 2 +- .../steps/AutoAdoptionUrlSettingsStep.tsx | 2 +- .../initial/steps/SetupGeneralConfigStep.tsx | 2 +- .../SettingsInstancePage.tsx | 16 ++++++++++++++++ 5 files changed, 23 insertions(+), 3 deletions(-) diff --git a/web/messages/en/settings.json b/web/messages/en/settings.json index 5e66fb9f6e..acd07b6e88 100644 --- a/web/messages/en/settings.json +++ b/web/messages/en/settings.json @@ -11,6 +11,10 @@ "settings_instance_section_core_description": "Configure the primary instance identity, URLs and session lifetime used across the platform.", "settings_instance_section_data_retention": "VPN stats retention", "settings_instance_section_data_retention_description": "Control if VPN statistics are purged automatically and how long they are kept.", + "settings_instance_error_invalid_url": "Invalid URL", + "settings_instance_error_invalid_host": "Defguard URL must use a hostname, not an IP address", + "settings_instance_error_defguard_url_required": "Defguard URL is required", + "settings_instance_label_defguard_url": "Defguard URL", "settings_instance_label_name": "Instance name", "settings_instance_label_public_proxy_url": "Public Edge Component URL", "settings_instance_label_session_duration": "Session duration", diff --git a/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx b/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx index 43fc84cc14..fa2e90c759 100644 --- a/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx +++ b/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx @@ -9,9 +9,9 @@ import { WizardCard } from '../../../shared/components/wizard/WizardCard/WizardC import { Button } from '../../../shared/defguard-ui/components/Button/Button'; import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { ThemeSpacing } from '../../../shared/defguard-ui/types'; -import { isValidDefguardUrl } from '../../../shared/utils/defguardUrl'; import { useAppForm } from '../../../shared/form'; import { formChangeLogic } from '../../../shared/formLogic'; +import { isValidDefguardUrl } from '../../../shared/utils/defguardUrl'; import { useMigrationWizardStore } from '../store/useMigrationWizardStore'; type FormFields = StoreValues; diff --git a/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx index 418f403dfa..923c3bc0dd 100644 --- a/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx +++ b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx @@ -11,9 +11,9 @@ import { Divider } from '../../../../shared/defguard-ui/components/Divider/Divid import { SizedBox } from '../../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { Snackbar } from '../../../../shared/defguard-ui/providers/snackbar/snackbar'; import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; -import { isValidDefguardUrl } from '../../../../shared/utils/defguardUrl'; import { useAppForm } from '../../../../shared/form'; import { formChangeLogic } from '../../../../shared/formLogic'; +import { isValidDefguardUrl } from '../../../../shared/utils/defguardUrl'; import { AutoAdoptionSetupStep } from '../types'; import { useAutoAdoptionSetupWizardStore } from '../useAutoAdoptionSetupWizardStore'; import './style.scss'; diff --git a/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx b/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx index f0d439dfa0..ccf61b51ba 100644 --- a/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx +++ b/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx @@ -10,9 +10,9 @@ import { Button } from '../../../../shared/defguard-ui/components/Button/Button' import { SizedBox } from '../../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { Snackbar } from '../../../../shared/defguard-ui/providers/snackbar/snackbar'; import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; -import { isValidDefguardUrl } from '../../../../shared/utils/defguardUrl'; import { useAppForm } from '../../../../shared/form'; import { formChangeLogic } from '../../../../shared/formLogic'; +import { isValidDefguardUrl } from '../../../../shared/utils/defguardUrl'; import { SetupPageStep } from '../types'; import { useSetupWizardStore } from '../useSetupWizardStore'; diff --git a/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx b/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx index 314e3e50b6..81a6061465 100644 --- a/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx +++ b/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx @@ -22,6 +22,7 @@ import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; import { useAppForm } from '../../../shared/form'; import { formChangeLogic } from '../../../shared/formLogic'; import { getSettingsQueryOptions } from '../../../shared/query'; +import { isValidDefguardUrl } from '../../../shared/utils/defguardUrl'; import { createNumericSelectOptions, withNumericFallbackOption, @@ -64,6 +65,10 @@ export const SettingsInstancePage = () => { }; const formSchema = z.object({ + defguard_url: z + .url(m.settings_instance_error_invalid_url()) + .refine(isValidDefguardUrl, m.settings_instance_error_invalid_host()) + .min(1, m.settings_instance_error_defguard_url_required()), instance_name: z .string(m.form_error_required()) .trim() @@ -131,6 +136,7 @@ const Content = ({ settings }: { settings: Settings }) => { const defaultValues = useMemo( (): FormFields => ({ + defguard_url: settings.defguard_url ?? '', instance_name: settings.instance_name ?? '', public_proxy_url: settings.public_proxy_url ?? '', authentication_period_days: settings.authentication_period_days ?? 7, @@ -139,6 +145,7 @@ const Content = ({ settings }: { settings: Settings }) => { stats_purge_threshold_days: settings.stats_purge_threshold_days ?? 30, }), [ + settings.defguard_url, settings.instance_name, settings.public_proxy_url, settings.authentication_period_days, @@ -205,6 +212,15 @@ const Content = ({ settings }: { settings: Settings }) => { title={m.settings_instance_section_core()} description={m.settings_instance_section_core_description()} /> + + {(field) => ( + + )} + + {(field) => ( From dafba0077b51e8486370d258a9c7f9a99610f7d0 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 13 Mar 2026 12:11:22 +0100 Subject: [PATCH 08/16] enable_stats_purge instead of disable_stats_purge --- ...d41284e6629883f647462029872c9fad2bfb.json} | 4 +- ...5075372f6963ff73760a53f843eaf5ebed4a.json} | 6 +- crates/defguard/src/main.rs | 2 +- crates/defguard_common/src/config.rs | 8 +-- .../defguard_common/src/db/models/settings.rs | 20 +++---- ...2.0.0]_rename_disable_stats_purge.down.sql | 5 ++ ..._[2.0.0]_rename_disable_stats_purge.up.sql | 5 ++ web/messages/en/settings.json | 2 +- .../SettingsInstancePage.tsx | 60 +++++++++++-------- web/src/shared/api/types.ts | 2 +- 10 files changed, 66 insertions(+), 48 deletions(-) rename .sqlx/{query-5972488273bdc7bd4c4b88cd209a2a399041dec3dce7d374fd2c0fa3bb9d0d81.json => query-89698ecaa251e056770bb90827d2d41284e6629883f647462029872c9fad2bfb.json} (89%) rename .sqlx/{query-915f29b91da989f48cbe5283f9c81481d06a4a0ba5543678e809ffb12dc0168f.json => query-dab137a626956fe0a0f2fbfc17c45075372f6963ff73760a53f843eaf5ebed4a.json} (96%) create mode 100644 migrations/20260313154500_[2.0.0]_rename_disable_stats_purge.down.sql create mode 100644 migrations/20260313154500_[2.0.0]_rename_disable_stats_purge.up.sql diff --git a/.sqlx/query-5972488273bdc7bd4c4b88cd209a2a399041dec3dce7d374fd2c0fa3bb9d0d81.json b/.sqlx/query-89698ecaa251e056770bb90827d2d41284e6629883f647462029872c9fad2bfb.json similarity index 89% rename from .sqlx/query-5972488273bdc7bd4c4b88cd209a2a399041dec3dce7d374fd2c0fa3bb9d0d81.json rename to .sqlx/query-89698ecaa251e056770bb90827d2d41284e6629883f647462029872c9fad2bfb.json index 96d6aa4d9d..af565cdd2e 100644 --- a/.sqlx/query-5972488273bdc7bd4c4b88cd209a2a399041dec3dce7d374fd2c0fa3bb9d0d81.json +++ b/.sqlx/query-89698ecaa251e056770bb90827d2d41284e6629883f647462029872c9fad2bfb.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, enrollment_vpn_step_optional = $15, enrollment_welcome_message = $16, enrollment_welcome_email = $17, enrollment_welcome_email_subject = $18, enrollment_use_welcome_message_as_email = $19, uuid = $20, ldap_url = $21, ldap_bind_username = $22, ldap_bind_password = $23, ldap_group_search_base = $24, ldap_user_search_base = $25, ldap_user_obj_class = $26, ldap_group_obj_class = $27, ldap_username_attr = $28, ldap_groupname_attr = $29, ldap_group_member_attr = $30, ldap_member_attr = $31, ldap_use_starttls = $32, ldap_tls_verify_cert = $33, openid_create_account = $34, license = $35, gateway_disconnect_notifications_enabled = $36, gateway_disconnect_notifications_inactivity_threshold = $37, gateway_disconnect_notifications_reconnect_notification_enabled = $38, ldap_sync_status = $39, ldap_enabled = $40, ldap_sync_enabled = $41, ldap_is_authoritative = $42, ldap_sync_interval = $43, ldap_user_auxiliary_obj_classes = $44, ldap_uses_ad = $45, ldap_user_rdn_attr = $46, ldap_sync_groups = $47, openid_username_handling = $48, ca_key_der = $49, ca_cert_der = $50, ca_expiry = $51, defguard_url = $52, default_admin_group_name = $53, authentication_period_days = $54, mfa_code_timeout_seconds = $55, public_proxy_url = $56, default_admin_id = $57, secret_key = $58, disable_stats_purge = $59, stats_purge_frequency_hours = $60, stats_purge_threshold_days = $61, enrollment_token_timeout_hours = $62, password_reset_token_timeout_hours = $63, enrollment_session_timeout_minutes = $64, password_reset_session_timeout_minutes = $65 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, enrollment_vpn_step_optional = $15, enrollment_welcome_message = $16, enrollment_welcome_email = $17, enrollment_welcome_email_subject = $18, enrollment_use_welcome_message_as_email = $19, uuid = $20, ldap_url = $21, ldap_bind_username = $22, ldap_bind_password = $23, ldap_group_search_base = $24, ldap_user_search_base = $25, ldap_user_obj_class = $26, ldap_group_obj_class = $27, ldap_username_attr = $28, ldap_groupname_attr = $29, ldap_group_member_attr = $30, ldap_member_attr = $31, ldap_use_starttls = $32, ldap_tls_verify_cert = $33, openid_create_account = $34, license = $35, gateway_disconnect_notifications_enabled = $36, gateway_disconnect_notifications_inactivity_threshold = $37, gateway_disconnect_notifications_reconnect_notification_enabled = $38, ldap_sync_status = $39, ldap_enabled = $40, ldap_sync_enabled = $41, ldap_is_authoritative = $42, ldap_sync_interval = $43, ldap_user_auxiliary_obj_classes = $44, ldap_uses_ad = $45, ldap_user_rdn_attr = $46, ldap_sync_groups = $47, openid_username_handling = $48, ca_key_der = $49, ca_cert_der = $50, ca_expiry = $51, defguard_url = $52, default_admin_group_name = $53, authentication_period_days = $54, mfa_code_timeout_seconds = $55, public_proxy_url = $56, default_admin_id = $57, secret_key = $58, enable_stats_purge = $59, stats_purge_frequency_hours = $60, stats_purge_threshold_days = $61, enrollment_token_timeout_hours = $62, password_reset_token_timeout_hours = $63, enrollment_session_timeout_minutes = $64, password_reset_session_timeout_minutes = $65 WHERE id = 1", "describe": { "columns": [], "parameters": { @@ -106,5 +106,5 @@ }, "nullable": [] }, - "hash": "5972488273bdc7bd4c4b88cd209a2a399041dec3dce7d374fd2c0fa3bb9d0d81" + "hash": "89698ecaa251e056770bb90827d2d41284e6629883f647462029872c9fad2bfb" } diff --git a/.sqlx/query-915f29b91da989f48cbe5283f9c81481d06a4a0ba5543678e809ffb12dc0168f.json b/.sqlx/query-dab137a626956fe0a0f2fbfc17c45075372f6963ff73760a53f843eaf5ebed4a.json similarity index 96% rename from .sqlx/query-915f29b91da989f48cbe5283f9c81481d06a4a0ba5543678e809ffb12dc0168f.json rename to .sqlx/query-dab137a626956fe0a0f2fbfc17c45075372f6963ff73760a53f843eaf5ebed4a.json index 85105629b9..ce20619c2c 100644 --- a/.sqlx/query-915f29b91da989f48cbe5283f9c81481d06a4a0ba5543678e809ffb12dc0168f.json +++ b/.sqlx/query-dab137a626956fe0a0f2fbfc17c45075372f6963ff73760a53f843eaf5ebed4a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT openid_enabled, wireguard_enabled, webhooks_enabled, worker_enabled, challenge_template, instance_name, main_logo_url, nav_logo_url, smtp_server, smtp_port, smtp_encryption \"smtp_encryption: _\", smtp_user, smtp_password \"smtp_password?: SecretStringWrapper\", smtp_sender, enrollment_vpn_step_optional, enrollment_welcome_message, enrollment_welcome_email, enrollment_welcome_email_subject, enrollment_use_welcome_message_as_email, uuid, ldap_url, ldap_bind_username, ldap_bind_password \"ldap_bind_password?: SecretStringWrapper\", ldap_group_search_base, ldap_user_search_base, ldap_user_obj_class, ldap_group_obj_class, ldap_username_attr, ldap_groupname_attr, ldap_group_member_attr, ldap_member_attr, openid_create_account, license, gateway_disconnect_notifications_enabled, ldap_use_starttls, ldap_tls_verify_cert, gateway_disconnect_notifications_inactivity_threshold, gateway_disconnect_notifications_reconnect_notification_enabled, ldap_sync_status \"ldap_sync_status: LdapSyncStatus\", ldap_enabled, ldap_sync_enabled, ldap_is_authoritative, ldap_sync_interval, ldap_user_auxiliary_obj_classes, ldap_uses_ad, ldap_user_rdn_attr, ldap_sync_groups, openid_username_handling \"openid_username_handling: OpenIdUsernameHandling\", ca_key_der, ca_cert_der, ca_expiry, defguard_url, default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds, public_proxy_url, default_admin_id, secret_key, disable_stats_purge, stats_purge_frequency_hours, stats_purge_threshold_days, enrollment_token_timeout_hours, password_reset_token_timeout_hours, enrollment_session_timeout_minutes, password_reset_session_timeout_minutes FROM \"settings\" WHERE id = 1", + "query": "SELECT openid_enabled, wireguard_enabled, webhooks_enabled, worker_enabled, challenge_template, instance_name, main_logo_url, nav_logo_url, smtp_server, smtp_port, smtp_encryption \"smtp_encryption: _\", smtp_user, smtp_password \"smtp_password?: SecretStringWrapper\", smtp_sender, enrollment_vpn_step_optional, enrollment_welcome_message, enrollment_welcome_email, enrollment_welcome_email_subject, enrollment_use_welcome_message_as_email, uuid, ldap_url, ldap_bind_username, ldap_bind_password \"ldap_bind_password?: SecretStringWrapper\", ldap_group_search_base, ldap_user_search_base, ldap_user_obj_class, ldap_group_obj_class, ldap_username_attr, ldap_groupname_attr, ldap_group_member_attr, ldap_member_attr, openid_create_account, license, gateway_disconnect_notifications_enabled, ldap_use_starttls, ldap_tls_verify_cert, gateway_disconnect_notifications_inactivity_threshold, gateway_disconnect_notifications_reconnect_notification_enabled, ldap_sync_status \"ldap_sync_status: LdapSyncStatus\", ldap_enabled, ldap_sync_enabled, ldap_is_authoritative, ldap_sync_interval, ldap_user_auxiliary_obj_classes, ldap_uses_ad, ldap_user_rdn_attr, ldap_sync_groups, openid_username_handling \"openid_username_handling: OpenIdUsernameHandling\", ca_key_der, ca_cert_der, ca_expiry, defguard_url, default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds, public_proxy_url, default_admin_id, secret_key, enable_stats_purge, stats_purge_frequency_hours, stats_purge_threshold_days, enrollment_token_timeout_hours, password_reset_token_timeout_hours, enrollment_session_timeout_minutes, password_reset_session_timeout_minutes FROM \"settings\" WHERE id = 1", "describe": { "columns": [ { @@ -327,7 +327,7 @@ }, { "ordinal": 58, - "name": "disable_stats_purge", + "name": "enable_stats_purge", "type_info": "Bool" }, { @@ -432,5 +432,5 @@ false ] }, - "hash": "915f29b91da989f48cbe5283f9c81481d06a4a0ba5543678e809ffb12dc0168f" + "hash": "dab137a626956fe0a0f2fbfc17c45075372f6963ff73760a53f843eaf5ebed4a" } diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index fbc0e5f7c0..b94de46f96 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -270,7 +270,7 @@ async fn main() -> Result<(), anyhow::Error> { pool.clone(), settings.stats_purge_frequency(), settings.stats_purge_threshold() - ), if !settings.disable_stats_purge => + ), if settings.enable_stats_purge => error!("Periodic stats purge task returned early: {res:?}"), res = run_periodic_license_check(&pool, proxy_control_tx) => error!("Periodic license check task returned early: {res:?}"), diff --git a/crates/defguard_common/src/config.rs b/crates/defguard_common/src/config.rs index 602505f1a2..121239a8fd 100644 --- a/crates/defguard_common/src/config.rs +++ b/crates/defguard_common/src/config.rs @@ -3,18 +3,18 @@ use std::{net::IpAddr, sync::OnceLock}; use clap::{Args, Parser, Subcommand}; use humantime::Duration; use ipnetwork::IpNetwork; -use openidconnect::{JsonWebKeyId, core::CoreRsaPrivateSigningKey}; +use openidconnect::{core::CoreRsaPrivateSigningKey, JsonWebKeyId}; use reqwest::Url; use rsa::{ - RsaPrivateKey, pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey}, pkcs8::{DecodePrivateKey, LineEnding}, traits::PublicKeyParts, + RsaPrivateKey, }; use secrecy::{ExposeSecret, SecretString}; use serde::Serialize; -use crate::{VERSION, db::models::Settings}; +use crate::{db::models::Settings, VERSION}; pub static SERVER_CONFIG: OnceLock = OnceLock::new(); @@ -80,7 +80,7 @@ pub struct DefGuardConfig { pub url: Url, #[arg(long, env = "DEFGUARD_DISABLE_STATS_PURGE")] - #[deprecated(since = "2.0.0", note = "Use Settings.disable_stats_purge instead")] + #[deprecated(since = "2.0.0", note = "Use Settings.enable_stats_purge instead")] pub disable_stats_purge: Option, #[arg(long, env = "DEFGUARD_STATS_PURGE_FREQUENCY")] diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index 9ce549b5da..4ff5b2f1fe 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -204,7 +204,7 @@ pub struct Settings { pub default_admin_id: Option, // 1.6 config options pub secret_key: Option, - pub disable_stats_purge: bool, + pub enable_stats_purge: bool, stats_purge_frequency_hours: i32, stats_purge_threshold_days: i32, enrollment_token_timeout_hours: i32, @@ -425,7 +425,7 @@ impl Settings { ca_key_der, ca_cert_der, ca_expiry, defguard_url, \ default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds, \ public_proxy_url, \ - default_admin_id, secret_key, disable_stats_purge, \ + default_admin_id, secret_key, enable_stats_purge, \ stats_purge_frequency_hours, stats_purge_threshold_days, \ enrollment_token_timeout_hours, password_reset_token_timeout_hours, \ enrollment_session_timeout_minutes, password_reset_session_timeout_minutes \ @@ -517,7 +517,7 @@ impl Settings { public_proxy_url = $56, \ default_admin_id = $57, \ secret_key = $58, \ - disable_stats_purge = $59, \ + enable_stats_purge = $59, \ stats_purge_frequency_hours = $60, \ stats_purge_threshold_days = $61, \ enrollment_token_timeout_hours = $62, \ @@ -583,7 +583,7 @@ impl Settings { self.public_proxy_url, self.default_admin_id, self.secret_key, - self.disable_stats_purge, + self.enable_stats_purge, self.stats_purge_frequency_hours, self.stats_purge_threshold_days, self.enrollment_token_timeout_hours, @@ -757,7 +757,7 @@ impl Settings { self.authentication_period_days = (session_timeout.as_secs() / day) as i32; } if let Some(disable_stats_purge) = config.disable_stats_purge { - self.disable_stats_purge = disable_stats_purge; + self.enable_stats_purge = !disable_stats_purge; } if let Some(stats_purge_frequency) = config.stats_purge_frequency { self.stats_purge_frequency_hours = (stats_purge_frequency.as_secs() / hour) as i32; @@ -989,7 +989,7 @@ mod test { assert_eq!(settings.public_proxy_url, "https://proxy.example.com/"); assert_eq!(settings.mfa_code_timeout_seconds, 75); assert_eq!(settings.authentication_period_days, 10); - assert!(settings.disable_stats_purge); + assert!(!settings.enable_stats_purge); assert_eq!(settings.stats_purge_frequency_hours, 5); assert_eq!(settings.stats_purge_threshold_days, 12); assert_eq!(settings.enrollment_token_timeout_hours, 7); @@ -1006,7 +1006,7 @@ mod test { public_proxy_url: "https://proxy.initial".into(), mfa_code_timeout_seconds: 123, authentication_period_days: 9, - disable_stats_purge: true, + enable_stats_purge: false, ..Default::default() }; let config = DefGuardConfig::new_test_config(); @@ -1022,7 +1022,7 @@ mod test { assert_eq!(settings.public_proxy_url, "https://proxy.initial"); assert_eq!(settings.mfa_code_timeout_seconds, 123); assert_eq!(settings.authentication_period_days, 9); - assert!(settings.disable_stats_purge); + assert!(!settings.enable_stats_purge); } #[test] @@ -1181,11 +1181,11 @@ mod test { assert_eq!(current.mfa_code_timeout_seconds, 90); assert_eq!(current.authentication_period_days, 2); - assert!(current.disable_stats_purge); + assert!(!current.enable_stats_purge); assert_eq!(from_db.mfa_code_timeout_seconds, 90); assert_eq!(from_db.authentication_period_days, 2); - assert!(from_db.disable_stats_purge); + assert!(!from_db.enable_stats_purge); } #[sqlx::test] diff --git a/migrations/20260313154500_[2.0.0]_rename_disable_stats_purge.down.sql b/migrations/20260313154500_[2.0.0]_rename_disable_stats_purge.down.sql new file mode 100644 index 0000000000..eebb7bcd7c --- /dev/null +++ b/migrations/20260313154500_[2.0.0]_rename_disable_stats_purge.down.sql @@ -0,0 +1,5 @@ +UPDATE settings +SET enable_stats_purge = NOT enable_stats_purge; + +ALTER TABLE settings + RENAME COLUMN enable_stats_purge TO disable_stats_purge; diff --git a/migrations/20260313154500_[2.0.0]_rename_disable_stats_purge.up.sql b/migrations/20260313154500_[2.0.0]_rename_disable_stats_purge.up.sql new file mode 100644 index 0000000000..fe36141275 --- /dev/null +++ b/migrations/20260313154500_[2.0.0]_rename_disable_stats_purge.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE settings + RENAME COLUMN disable_stats_purge TO enable_stats_purge; + +UPDATE settings +SET enable_stats_purge = NOT enable_stats_purge; diff --git a/web/messages/en/settings.json b/web/messages/en/settings.json index acd07b6e88..4d54ebb8d9 100644 --- a/web/messages/en/settings.json +++ b/web/messages/en/settings.json @@ -18,7 +18,7 @@ "settings_instance_label_name": "Instance name", "settings_instance_label_public_proxy_url": "Public Edge Component URL", "settings_instance_label_session_duration": "Session duration", - "settings_vpn_stats_toggle_disable_title": "Disable stats purge", + "settings_vpn_stats_toggle_title": "Stats purge", "settings_vpn_stats_label_purge_frequency": "Stats purge frequency", "settings_vpn_stats_label_purge_threshold": "Stats purge threshold", "settings_enrollment_title": "Enrollment", diff --git a/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx b/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx index 81a6061465..1c9f15cd89 100644 --- a/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx +++ b/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx @@ -13,6 +13,7 @@ import { SettingsHeader } from '../../../shared/components/SettingsHeader/Settin import { SettingsLayout } from '../../../shared/components/SettingsLayout/SettingsLayout'; import { Button } from '../../../shared/defguard-ui/components/Button/Button'; import { Divider } from '../../../shared/defguard-ui/components/Divider/Divider'; +import { Fold } from '../../../shared/defguard-ui/components/Fold/Fold'; import { MarkedSection } from '../../../shared/defguard-ui/components/MarkedSection/MarkedSection'; import { MarkedSectionHeader } from '../../../shared/defguard-ui/components/MarkedSectionHeader/MarkedSectionHeader'; import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; @@ -84,7 +85,7 @@ const formSchema = z.object({ .url(m.initial_setup_general_config_error_public_proxy_url_invalid()) .min(1, m.initial_setup_general_config_error_public_proxy_url_required()), authentication_period_days: z.number().min(1, m.form_error_invalid()), - disable_stats_purge: z.boolean(), + enable_stats_purge: z.boolean(), stats_purge_frequency_hours: z.number(m.form_error_required()).int().min(1), stats_purge_threshold_days: z.number(m.form_error_required()).int().min(1), }); @@ -140,7 +141,7 @@ const Content = ({ settings }: { settings: Settings }) => { instance_name: settings.instance_name ?? '', public_proxy_url: settings.public_proxy_url ?? '', authentication_period_days: settings.authentication_period_days ?? 7, - disable_stats_purge: settings.disable_stats_purge ?? false, + enable_stats_purge: settings.enable_stats_purge ?? true, stats_purge_frequency_hours: settings.stats_purge_frequency_hours ?? 24, stats_purge_threshold_days: settings.stats_purge_threshold_days ?? 30, }), @@ -149,7 +150,7 @@ const Content = ({ settings }: { settings: Settings }) => { settings.instance_name, settings.public_proxy_url, settings.authentication_period_days, - settings.disable_stats_purge, + settings.enable_stats_purge, settings.stats_purge_frequency_hours, settings.stats_purge_threshold_days, ], @@ -252,32 +253,39 @@ const Content = ({ settings }: { settings: Settings }) => { title={m.settings_instance_section_data_retention()} description={m.settings_instance_section_data_retention_description()} /> - + {(field) => ( - )} - - - - {(field) => ( - - )} - - - - {(field) => ( - + title={m.settings_vpn_stats_toggle_title()} + > + s.values.enable_stats_purge}> + {(enabled) => ( + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + )} + + )} diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index ef0fac4845..3864856c01 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -931,7 +931,7 @@ export interface SettingsGatewayNotifications { } export interface SettingsTimeoutsAndMaintenance { - disable_stats_purge: boolean; + enable_stats_purge: boolean; stats_purge_frequency_hours: number; stats_purge_threshold_days: number; enrollment_token_timeout_hours: number; From a60e0f704024dcb493f3fa8b8341f90305f0c9f1 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 13 Mar 2026 12:36:23 +0100 Subject: [PATCH 09/16] merge migrations --- migrations/20260312072940_[2.0.0]_remove_rp_id.down.sql | 1 - migrations/20260312072940_[2.0.0]_remove_rp_id.up.sql | 1 - .../20260312072940_[2.0.0]_rp_id_stats_purge.down.sql | 8 ++++++++ .../20260312072940_[2.0.0]_rp_id_stats_purge.up.sql | 8 ++++++++ ...0313154500_[2.0.0]_rename_disable_stats_purge.down.sql | 5 ----- ...260313154500_[2.0.0]_rename_disable_stats_purge.up.sql | 5 ----- 6 files changed, 16 insertions(+), 12 deletions(-) delete mode 100644 migrations/20260312072940_[2.0.0]_remove_rp_id.down.sql delete mode 100644 migrations/20260312072940_[2.0.0]_remove_rp_id.up.sql create mode 100644 migrations/20260312072940_[2.0.0]_rp_id_stats_purge.down.sql create mode 100644 migrations/20260312072940_[2.0.0]_rp_id_stats_purge.up.sql delete mode 100644 migrations/20260313154500_[2.0.0]_rename_disable_stats_purge.down.sql delete mode 100644 migrations/20260313154500_[2.0.0]_rename_disable_stats_purge.up.sql diff --git a/migrations/20260312072940_[2.0.0]_remove_rp_id.down.sql b/migrations/20260312072940_[2.0.0]_remove_rp_id.down.sql deleted file mode 100644 index 1561c46bbd..0000000000 --- a/migrations/20260312072940_[2.0.0]_remove_rp_id.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE settings ADD COLUMN webauthn_rp_id text; diff --git a/migrations/20260312072940_[2.0.0]_remove_rp_id.up.sql b/migrations/20260312072940_[2.0.0]_remove_rp_id.up.sql deleted file mode 100644 index cb593aee1c..0000000000 --- a/migrations/20260312072940_[2.0.0]_remove_rp_id.up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE settings DROP webauthn_rp_id; diff --git a/migrations/20260312072940_[2.0.0]_rp_id_stats_purge.down.sql b/migrations/20260312072940_[2.0.0]_rp_id_stats_purge.down.sql new file mode 100644 index 0000000000..000ea5a916 --- /dev/null +++ b/migrations/20260312072940_[2.0.0]_rp_id_stats_purge.down.sql @@ -0,0 +1,8 @@ +ALTER TABLE settings + ADD COLUMN webauthn_rp_id text; + +UPDATE settings + SET enable_stats_purge = NOT enable_stats_purge; + +ALTER TABLE settings + RENAME COLUMN enable_stats_purge TO disable_stats_purge; diff --git a/migrations/20260312072940_[2.0.0]_rp_id_stats_purge.up.sql b/migrations/20260312072940_[2.0.0]_rp_id_stats_purge.up.sql new file mode 100644 index 0000000000..37ece16bfd --- /dev/null +++ b/migrations/20260312072940_[2.0.0]_rp_id_stats_purge.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE settings + RENAME COLUMN disable_stats_purge TO enable_stats_purge; + +ALTER TABLE settings + DROP COLUMN webauthn_rp_id; + +UPDATE settings + SET enable_stats_purge = NOT enable_stats_purge; diff --git a/migrations/20260313154500_[2.0.0]_rename_disable_stats_purge.down.sql b/migrations/20260313154500_[2.0.0]_rename_disable_stats_purge.down.sql deleted file mode 100644 index eebb7bcd7c..0000000000 --- a/migrations/20260313154500_[2.0.0]_rename_disable_stats_purge.down.sql +++ /dev/null @@ -1,5 +0,0 @@ -UPDATE settings -SET enable_stats_purge = NOT enable_stats_purge; - -ALTER TABLE settings - RENAME COLUMN enable_stats_purge TO disable_stats_purge; diff --git a/migrations/20260313154500_[2.0.0]_rename_disable_stats_purge.up.sql b/migrations/20260313154500_[2.0.0]_rename_disable_stats_purge.up.sql deleted file mode 100644 index fe36141275..0000000000 --- a/migrations/20260313154500_[2.0.0]_rename_disable_stats_purge.up.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE settings - RENAME COLUMN disable_stats_purge TO enable_stats_purge; - -UPDATE settings -SET enable_stats_purge = NOT enable_stats_purge; From 50d9a2783811b43a3ef88a8abbf4e7b1f35719fd Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 13 Mar 2026 12:45:46 +0100 Subject: [PATCH 10/16] cargo fmt --- crates/defguard_common/src/config.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/defguard_common/src/config.rs b/crates/defguard_common/src/config.rs index 121239a8fd..2664d98a7a 100644 --- a/crates/defguard_common/src/config.rs +++ b/crates/defguard_common/src/config.rs @@ -3,18 +3,18 @@ use std::{net::IpAddr, sync::OnceLock}; use clap::{Args, Parser, Subcommand}; use humantime::Duration; use ipnetwork::IpNetwork; -use openidconnect::{core::CoreRsaPrivateSigningKey, JsonWebKeyId}; +use openidconnect::{JsonWebKeyId, core::CoreRsaPrivateSigningKey}; use reqwest::Url; use rsa::{ + RsaPrivateKey, pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey}, pkcs8::{DecodePrivateKey, LineEnding}, traits::PublicKeyParts, - RsaPrivateKey, }; use secrecy::{ExposeSecret, SecretString}; use serde::Serialize; -use crate::{db::models::Settings, VERSION}; +use crate::{VERSION, db::models::Settings}; pub static SERVER_CONFIG: OnceLock = OnceLock::new(); From 667d9d9583e5cbf7c8081b193e4accd15a20b67f Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 13 Mar 2026 13:17:17 +0100 Subject: [PATCH 11/16] fix defguard_url zod validator --- .../steps/MigrationWizardGeneralConfigurationStep.tsx | 7 +++++-- .../autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx | 7 +++++-- .../SetupPage/initial/steps/SetupGeneralConfigStep.tsx | 7 +++++-- .../settings/SettingsInstancePage/SettingsInstancePage.tsx | 7 +++++-- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx b/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx index fa2e90c759..31f2032a47 100644 --- a/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx +++ b/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx @@ -48,12 +48,15 @@ export const MigrationWizardGeneralConfigurationStep = () => { () => z.object({ defguard_url: z + .string({ + error: m.migration_wizard_general_config_error_defguard_url_required(), + }) + .min(1, m.migration_wizard_general_config_error_defguard_url_required()) .url(m.migration_wizard_general_config_error_invalid_url()) .refine( isValidDefguardUrl, m.migration_wizard_general_config_error_defguard_url_invalid_host(), - ) - .min(1, m.migration_wizard_general_config_error_defguard_url_required()), + ), default_admin_group_name: z .string() .min(1, m.migration_wizard_general_config_error_admin_group_required()), diff --git a/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx index 923c3bc0dd..3f0e01cf33 100644 --- a/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx +++ b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx @@ -38,12 +38,15 @@ export const AutoAdoptionUrlSettingsStep = () => { () => z.object({ defguard_url: z + .string({ + error: m.initial_setup_general_config_error_defguard_url_required(), + }) + .min(1, m.initial_setup_general_config_error_defguard_url_required()) .url(m.initial_setup_general_config_error_invalid_url()) .refine( isValidDefguardUrl, m.initial_setup_general_config_error_defguard_url_invalid_host(), - ) - .min(1, m.initial_setup_general_config_error_defguard_url_required()), + ), public_proxy_url: z .url(m.initial_setup_general_config_error_public_proxy_url_invalid()) .min(1, m.initial_setup_general_config_error_public_proxy_url_required()), diff --git a/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx b/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx index ccf61b51ba..eb6772731e 100644 --- a/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx +++ b/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx @@ -44,12 +44,15 @@ export const SetupGeneralConfigStep = () => { () => z.object({ defguard_url: z + .string({ + error: m.initial_setup_general_config_error_defguard_url_required(), + }) + .min(1, m.initial_setup_general_config_error_defguard_url_required()) .url(m.initial_setup_general_config_error_invalid_url()) .refine( isValidDefguardUrl, m.initial_setup_general_config_error_defguard_url_invalid_host(), - ) - .min(1, m.initial_setup_general_config_error_defguard_url_required()), + ), default_admin_group_name: z .string() .min(1, m.initial_setup_general_config_error_admin_group_required()), diff --git a/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx b/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx index 1c9f15cd89..40834de0a3 100644 --- a/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx +++ b/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx @@ -67,9 +67,12 @@ export const SettingsInstancePage = () => { const formSchema = z.object({ defguard_url: z + .string({ + error: m.settings_instance_error_defguard_url_required(), + }) + .min(1, m.settings_instance_error_defguard_url_required()) .url(m.settings_instance_error_invalid_url()) - .refine(isValidDefguardUrl, m.settings_instance_error_invalid_host()) - .min(1, m.settings_instance_error_defguard_url_required()), + .refine(isValidDefguardUrl, m.settings_instance_error_invalid_host()), instance_name: z .string(m.form_error_required()) .trim() From 9a61e1319974780b3bae9d2acece3ea1985993d5 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 13 Mar 2026 13:20:33 +0100 Subject: [PATCH 12/16] build webauthn once in auth handler --- crates/defguard_core/src/handlers/auth.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/defguard_core/src/handlers/auth.rs b/crates/defguard_core/src/handlers/auth.rs index a7d9b3391b..4768a472d2 100644 --- a/crates/defguard_core/src/handlers/auth.rs +++ b/crates/defguard_core/src/handlers/auth.rs @@ -450,6 +450,7 @@ pub async fn webauthn_finish( "Finishing WebAuthn registration for user {}", session.user.username ); + let webauthn = appstate.webauthn()?; let passkey_reg = session .session @@ -470,15 +471,13 @@ pub async fn webauthn_finish( ); info!( "Allowed origins: {:?}", - appstate - .webauthn()? + webauthn .get_allowed_origins() .iter() .map(ToString::to_string) .collect::>() ); - let webauthn = appstate.webauthn()?; let passkey = webauthn .finish_passkey_registration(&webauth_reg.rpkc, &passkey_reg) .map_err(|err| WebError::WebauthnRegistration(err.to_string()))?; From a342c33177f38e68caa610e608015d6fb828e0bc Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 13 Mar 2026 13:23:32 +0100 Subject: [PATCH 13/16] set purge setting default value --- migrations/20260312072940_[2.0.0]_rp_id_stats_purge.down.sql | 3 +++ migrations/20260312072940_[2.0.0]_rp_id_stats_purge.up.sql | 3 +++ 2 files changed, 6 insertions(+) diff --git a/migrations/20260312072940_[2.0.0]_rp_id_stats_purge.down.sql b/migrations/20260312072940_[2.0.0]_rp_id_stats_purge.down.sql index 000ea5a916..1e38fa5370 100644 --- a/migrations/20260312072940_[2.0.0]_rp_id_stats_purge.down.sql +++ b/migrations/20260312072940_[2.0.0]_rp_id_stats_purge.down.sql @@ -4,5 +4,8 @@ ALTER TABLE settings UPDATE settings SET enable_stats_purge = NOT enable_stats_purge; +ALTER TABLE settings + ALTER COLUMN enable_stats_purge SET DEFAULT false; + ALTER TABLE settings RENAME COLUMN enable_stats_purge TO disable_stats_purge; diff --git a/migrations/20260312072940_[2.0.0]_rp_id_stats_purge.up.sql b/migrations/20260312072940_[2.0.0]_rp_id_stats_purge.up.sql index 37ece16bfd..1f8bf162f5 100644 --- a/migrations/20260312072940_[2.0.0]_rp_id_stats_purge.up.sql +++ b/migrations/20260312072940_[2.0.0]_rp_id_stats_purge.up.sql @@ -1,6 +1,9 @@ ALTER TABLE settings RENAME COLUMN disable_stats_purge TO enable_stats_purge; +ALTER TABLE settings + ALTER COLUMN enable_stats_purge SET DEFAULT true; + ALTER TABLE settings DROP COLUMN webauthn_rp_id; From eae620042413e2837c6bdaaff33c998d24f4e0a5 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 16 Mar 2026 09:06:27 +0100 Subject: [PATCH 14/16] less complex errors for settings save --- .../defguard_common/src/db/models/settings.rs | 49 +++++++------------ crates/defguard_core/src/appstate.rs | 2 +- crates/defguard_core/src/error.rs | 10 +--- crates/defguard_core/src/handlers/settings.rs | 14 +++--- 4 files changed, 27 insertions(+), 48 deletions(-) diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index 4ff5b2f1fe..39ebc718ac 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -51,8 +51,8 @@ pub async fn update_current_settings<'e, E: sqlx::PgExecutor<'e>>( pub enum SettingsValidationError { #[error("Cannot enable gateway disconnect notifications. SMTP is not configured")] CannotEnableGatewayNotifications, - #[error(transparent)] - InvalidDefguardUrl(#[from] SettingsUrlError), + #[error("Invalid defguard_url `{0}`")] + InvalidDefguardUrl(String), } #[derive(Error, Debug)] @@ -65,8 +65,6 @@ pub enum SettingsInitializationError { Missing(&'static str), #[error("Invalid required setting `{0}`: {1}")] Invalid(&'static str, &'static str), - #[error(transparent)] - InvalidDefguardUrl(#[from] SettingsUrlError), } #[derive(Error, Debug, Clone)] @@ -328,7 +326,8 @@ impl Settings { BASE64_STANDARD.encode(bytes) } - pub fn parse_defguard_url(&self) -> Result { + /// Parse `defguard_url` and reject unsupported host forms. + fn parse_defguard_url(&self) -> Result { let url = Url::parse(&self.defguard_url) .map_err(|_| SettingsUrlError::InvalidDefguardUrl(self.defguard_url.clone()))?; let host = url @@ -342,7 +341,8 @@ impl Settings { Ok(url) } - pub fn webauthn_rp_id(&self) -> Result { + /// Derive the WebAuthn relying party ID from `defguard_url`. + fn webauthn_rp_id(&self) -> Result { let url = self.parse_defguard_url()?; let domain = url .domain() @@ -355,6 +355,7 @@ impl Settings { domain.ok_or_else(|| SettingsUrlError::MissingDefguardDomain(self.defguard_url.clone())) } + /// Derive the cookie domain from `defguard_url`. pub fn cookie_domain(&self) -> Result { let url = self.parse_defguard_url()?; url.host_str() @@ -362,24 +363,7 @@ impl Settings { .ok_or_else(|| SettingsUrlError::MissingDefguardHost(self.defguard_url.clone())) } - pub fn validate_defguard_url(&self) -> Result<(), SettingsUrlError> { - let url = self.parse_defguard_url()?; - let rp_id = self.webauthn_rp_id()?; - let builder = WebauthnBuilder::new(&rp_id, &url).map_err(|err| { - SettingsUrlError::InvalidWebauthnConfiguration( - self.defguard_url.clone(), - err.to_string(), - ) - })?; - builder.build().map_err(|err| { - SettingsUrlError::InvalidWebauthnConfiguration( - self.defguard_url.clone(), - err.to_string(), - ) - })?; - Ok(()) - } - + /// Build a WebAuthn configuration from the current Defguard URL. pub fn build_webauthn(&self) -> Result { let url = self.parse_defguard_url()?; let rp_id = self.webauthn_rp_id()?; @@ -442,7 +426,8 @@ impl Settings { warn!("Detected empty UUID in settings. Generating a new one."); self.uuid = Uuid::new_v4(); } - self.validate_defguard_url()?; + self.build_webauthn() + .map_err(|_| SettingsValidationError::InvalidDefguardUrl(self.defguard_url.clone()))?; // Check if gateway disconnect notifications can be enabled, since it requires SMTP to be // configured. if self.gateway_disconnect_notifications_enabled && !self.smtp_configured() { @@ -1102,25 +1087,25 @@ mod test { } #[test] - fn test_validate_defguard_url_accepts_valid_hostname() { - let settings = Settings { + fn test_validate_accepts_valid_hostname() { + let mut settings = Settings { defguard_url: "https://defguard.example.com".into(), ..Default::default() }; - assert!(settings.validate_defguard_url().is_ok()); + assert!(settings.validate().is_ok()); } #[test] - fn test_validate_defguard_url_rejects_invalid_url() { - let settings = Settings { + fn test_validate_rejects_invalid_url() { + let mut settings = Settings { defguard_url: "not a url".into(), ..Default::default() }; assert!(matches!( - settings.validate_defguard_url(), - Err(SettingsUrlError::InvalidDefguardUrl(_)) + settings.validate(), + Err(SettingsValidationError::InvalidDefguardUrl(_)) )); } diff --git a/crates/defguard_core/src/appstate.rs b/crates/defguard_core/src/appstate.rs index 90fc03924b..644a93914d 100644 --- a/crates/defguard_core/src/appstate.rs +++ b/crates/defguard_core/src/appstate.rs @@ -106,7 +106,7 @@ impl AppState { let settings = Settings::get_current_settings(); settings.build_webauthn().map(Arc::new).map_err(|err| { error!("Failed to build WebAuthn configuration from current settings: {err}"); - err.into() + WebError::Http(axum::http::StatusCode::INTERNAL_SERVER_ERROR) }) } diff --git a/crates/defguard_core/src/error.rs b/crates/defguard_core/src/error.rs index c63eb7f18a..0d1ab57bc5 100644 --- a/crates/defguard_core/src/error.rs +++ b/crates/defguard_core/src/error.rs @@ -1,9 +1,9 @@ use axum::http::StatusCode; use defguard_common::{ db::models::{ - DeviceError, ModelError, WireguardNetworkError, - settings::{SettingsSaveError, SettingsUrlError, SettingsValidationError}, + settings::{SettingsSaveError, SettingsValidationError}, user::UserError, + DeviceError, ModelError, WireguardNetworkError, }, types::UrlParseError, }; @@ -189,12 +189,6 @@ impl From for WebError { } } -impl From for WebError { - fn from(_err: SettingsUrlError) -> Self { - Self::Http(StatusCode::INTERNAL_SERVER_ERROR) - } -} - impl From for WebError { fn from(err: UserError) -> Self { error!("{err}"); diff --git a/crates/defguard_core/src/handlers/settings.rs b/crates/defguard_core/src/handlers/settings.rs index 1bae0ea2ba..bb9b5686a0 100644 --- a/crates/defguard_core/src/handlers/settings.rs +++ b/crates/defguard_core/src/handlers/settings.rs @@ -51,14 +51,15 @@ pub(crate) async fn update_settings( // fetch current settings for event let before = Settings::get_current_settings(); + let license = data.license.clone(); - update_cached_license(data.license.as_deref())?; data.uuid = before.uuid; data.validate()?; // clone for event let after = data.clone(); update_current_settings(&appstate.pool, data).await?; + update_cached_license(license.as_deref())?; info!("User {} updated settings", session.user.username); appstate.emit_event(ApiEvent { @@ -127,12 +128,7 @@ pub async fn patch_settings( let mut settings = Settings::get_current_settings(); // prepare clone for emitting an event let before = settings.clone(); - - // Handle updating the cached license - if let Some(license_key) = &data.license { - update_cached_license(license_key.as_deref())?; - debug!("Saving the new license key to the database as part of the settings patch"); - } + let license = data.license.clone(); if let Some(ldap_enabled) = data.ldap_enabled { if !ldap_enabled { @@ -157,6 +153,10 @@ pub async fn patch_settings( // clone for event let after = settings.clone(); update_current_settings(&appstate.pool, settings).await?; + if let Some(license_key) = &license { + update_cached_license(license_key.as_deref())?; + debug!("Updated cached license after saving settings patch"); + } info!("Admin {} patched settings", session.user.username); appstate.emit_event(ApiEvent { From de2c6f2b31424ad49219996d5f9db4059b478a98 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 16 Mar 2026 09:07:54 +0100 Subject: [PATCH 15/16] cargo fmt --- crates/defguard_core/src/error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/defguard_core/src/error.rs b/crates/defguard_core/src/error.rs index 0d1ab57bc5..20f1e5b43d 100644 --- a/crates/defguard_core/src/error.rs +++ b/crates/defguard_core/src/error.rs @@ -1,9 +1,9 @@ use axum::http::StatusCode; use defguard_common::{ db::models::{ + DeviceError, ModelError, WireguardNetworkError, settings::{SettingsSaveError, SettingsValidationError}, user::UserError, - DeviceError, ModelError, WireguardNetworkError, }, types::UrlParseError, }; From 3c880d4ea6d4709dd363e816215405a644d9ef63 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 16 Mar 2026 09:37:01 +0100 Subject: [PATCH 16/16] fix tests --- crates/defguard_setup/tests/common/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/defguard_setup/tests/common/mod.rs b/crates/defguard_setup/tests/common/mod.rs index e8abf68469..2abf9c548e 100644 --- a/crates/defguard_setup/tests/common/mod.rs +++ b/crates/defguard_setup/tests/common/mod.rs @@ -135,7 +135,6 @@ pub async fn init_settings_with_secret_key(pool: &PgPool) { let mut settings = Settings::get_current_settings(); settings.secret_key = Some(TEST_SECRET_KEY.to_string()); settings.defguard_url = "http://localhost:8000".to_string(); - settings.webauthn_rp_id = Some("localhost".to_string()); update_current_settings(pool, settings) .await .expect("Failed to update settings");