diff --git a/.sqlx/query-386714aa5e0cbc2d896edf6dcd2f4ec260d08f8feee279c41f73fd8e33aea5aa.json b/.sqlx/query-89698ecaa251e056770bb90827d2d41284e6629883f647462029872c9fad2bfb.json similarity index 88% rename from .sqlx/query-386714aa5e0cbc2d896edf6dcd2f4ec260d08f8feee279c41f73fd8e33aea5aa.json rename to .sqlx/query-89698ecaa251e056770bb90827d2d41284e6629883f647462029872c9fad2bfb.json index 2016018b0a..af565cdd2e 100644 --- a/.sqlx/query-386714aa5e0cbc2d896edf6dcd2f4ec260d08f8feee279c41f73fd8e33aea5aa.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, 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, 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": { @@ -95,7 +95,6 @@ "Text", "Int8", "Text", - "Text", "Bool", "Int4", "Int4", @@ -107,5 +106,5 @@ }, "nullable": [] }, - "hash": "386714aa5e0cbc2d896edf6dcd2f4ec260d08f8feee279c41f73fd8e33aea5aa" + "hash": "89698ecaa251e056770bb90827d2d41284e6629883f647462029872c9fad2bfb" } diff --git a/.sqlx/query-bb022291c5a000545df91eb246c5a24916ddd638337ed9427d94db5b1dc9766a.json b/.sqlx/query-dab137a626956fe0a0f2fbfc17c45075372f6963ff73760a53f843eaf5ebed4a.json similarity index 95% rename from .sqlx/query-bb022291c5a000545df91eb246c5a24916ddd638337ed9427d94db5b1dc9766a.json rename to .sqlx/query-dab137a626956fe0a0f2fbfc17c45075372f6963ff73760a53f843eaf5ebed4a.json index 0e6b201a60..ce20619c2c 100644 --- a/.sqlx/query-bb022291c5a000545df91eb246c5a24916ddd638337ed9427d94db5b1dc9766a.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, 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, 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,41 +327,36 @@ }, { "ordinal": 58, - "name": "webauthn_rp_id", - "type_info": "Text" - }, - { - "ordinal": 59, - "name": "disable_stats_purge", + "name": "enable_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" } @@ -428,7 +423,6 @@ false, true, true, - true, false, false, false, @@ -438,5 +432,5 @@ false ] }, - "hash": "bb022291c5a000545df91eb246c5a24916ddd638337ed9427d94db5b1dc9766a" + "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 e9f39ab14c..2664d98a7a 100644 --- a/crates/defguard_common/src/config.rs +++ b/crates/defguard_common/src/config.rs @@ -74,18 +74,13 @@ 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")] 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")] @@ -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..39ebc718ac 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,42 @@ 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("Invalid defguard_url `{0}`")] + InvalidDefguardUrl(String), } #[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}")] +} + +#[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,8 +202,7 @@ 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, + pub enable_stats_purge: bool, stats_purge_frequency_hours: i32, stats_purge_threshold_days: i32, enrollment_token_timeout_hours: i32, @@ -303,6 +326,61 @@ impl Settings { BASE64_STANDARD.encode(bytes) } + /// 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 + .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) + } + + /// 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() + .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())) + } + + /// Derive the cookie domain from `defguard_url`. + 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())) + } + + /// 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()?; + 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 +409,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, 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 \ @@ -348,6 +426,8 @@ impl Settings { warn!("Detected empty UUID in settings. Generating a new one."); self.uuid = Uuid::new_v4(); } + 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() { @@ -422,14 +502,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 \ + 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", self.openid_enabled, self.wireguard_enabled, @@ -489,8 +568,7 @@ impl Settings { self.public_proxy_url, self.default_admin_id, self.secret_key, - self.webauthn_rp_id, - self.disable_stats_purge, + self.enable_stats_purge, self.stats_purge_frequency_hours, self.stats_purge_threshold_days, self.enrollment_token_timeout_hours, @@ -546,16 +624,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 +732,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(); } @@ -677,7 +742,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; @@ -707,7 +772,7 @@ impl Settings { &mut self, executor: E, config: &DefGuardConfig, - ) -> sqlx::Result<()> + ) -> Result<(), SettingsSaveError> where E: PgExecutor<'e>, { @@ -874,13 +939,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,11 +970,11 @@ 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); - 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); @@ -925,11 +988,10 @@ 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, - disable_stats_purge: true, + enable_stats_purge: false, ..Default::default() }; let config = DefGuardConfig::new_test_config(); @@ -941,25 +1003,110 @@ 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); - assert!(settings.disable_stats_purge); + assert!(!settings.enable_stats_purge); } #[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_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 { + 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] + fn test_validate_accepts_valid_hostname() { + let mut settings = Settings { + defguard_url: "https://defguard.example.com".into(), + ..Default::default() + }; + + assert!(settings.validate().is_ok()); + } + + #[test] + fn test_validate_rejects_invalid_url() { + let mut settings = Settings { + defguard_url: "not a url".into(), + ..Default::default() + }; + + assert!(matches!( + settings.validate(), + Err(SettingsValidationError::InvalidDefguardUrl(_)) + )); } #[test] @@ -1019,15 +1166,15 @@ 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] - 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 +1183,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 +1191,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..644a93914d 100644 --- a/crates/defguard_core/src/appstate.rs +++ b/crates/defguard_core/src/appstate.rs @@ -13,7 +13,6 @@ use tokio::{ }, task::spawn, }; -use webauthn_rs::prelude::*; use crate::{ auth::failed_login::FailedLoginMap, @@ -31,7 +30,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 +102,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}"); + WebError::Http(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + }) + } + /// Create application state pub fn new( pool: PgPool, @@ -118,27 +124,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..341b65c76e 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, + 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) = 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) = 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..70f2562d29 100644 --- a/crates/defguard_core/src/enterprise/license.rs +++ b/crates/defguard_core/src/enterprise/license.rs @@ -6,7 +6,12 @@ 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 +51,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 dc457ca4b1..754bc736c3 100644 --- a/crates/defguard_core/src/error.rs +++ b/crates/defguard_core/src/error.rs @@ -1,7 +1,8 @@ use axum::http::StatusCode; use defguard_common::{ db::models::{ - DeviceError, ModelError, WireguardNetworkError, settings::SettingsValidationError, + DeviceError, ModelError, WireguardNetworkError, + settings::{SettingsSaveError, SettingsValidationError}, user::UserError, }, types::UrlParseError, @@ -178,6 +179,16 @@ 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(), } } } diff --git a/crates/defguard_core/src/handlers/auth.rs b/crates/defguard_core/src/handlers/auth.rs index 6d8d513f95..4768a472d2 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, 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) = 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) = 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, @@ -456,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 @@ -476,16 +471,14 @@ 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 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 +509,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 +532,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 7811bac66c..7367f0a66e 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}, @@ -67,6 +68,18 @@ 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 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| { + warn!("Failed to derive cookie domain: {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..469fc750a0 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, 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) = cookie_domain() { + cookie = cookie.domain(cookie_domain); + } Ok(redirect_to("/login", private_cookies.add(cookie))) } 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 { 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 bdb19cfa47..c8f1f5db96 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/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"); 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..1e38fa5370 --- /dev/null +++ b/migrations/20260312072940_[2.0.0]_rp_id_stats_purge.down.sql @@ -0,0 +1,11 @@ +ALTER TABLE settings + ADD COLUMN webauthn_rp_id text; + +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 new file mode 100644 index 0000000000..1f8bf162f5 --- /dev/null +++ b/migrations/20260312072940_[2.0.0]_rp_id_stats_purge.up.sql @@ -0,0 +1,11 @@ +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; + +UPDATE settings + SET enable_stats_purge = NOT enable_stats_purge; diff --git a/web/messages/en/initial_wizard.json b/web/messages/en/initial_wizard.json index 8f83b1020c..f9165635f1 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/messages/en/settings.json b/web/messages/en/settings.json index 5e66fb9f6e..4d54ebb8d9 100644 --- a/web/messages/en/settings.json +++ b/web/messages/en/settings.json @@ -11,10 +11,14 @@ "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", - "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/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx b/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx index 9994271d1e..31f2032a47 100644 --- a/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx +++ b/web/src/pages/MigrationWizardPage/steps/MigrationWizardGeneralConfigurationStep.tsx @@ -11,6 +11,7 @@ import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedB import { ThemeSpacing } from '../../../shared/defguard-ui/types'; import { useAppForm } from '../../../shared/form'; import { formChangeLogic } from '../../../shared/formLogic'; +import { isValidDefguardUrl } from '../../../shared/utils/defguardUrl'; import { useMigrationWizardStore } from '../store/useMigrationWizardStore'; type FormFields = StoreValues; @@ -47,8 +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()) - .min(1, m.migration_wizard_general_config_error_defguard_url_required()), + .refine( + isValidDefguardUrl, + m.migration_wizard_general_config_error_defguard_url_invalid_host(), + ), 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 a557ef64c7..3f0e01cf33 100644 --- a/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx +++ b/web/src/pages/SetupPage/autoAdoption/steps/AutoAdoptionUrlSettingsStep.tsx @@ -13,6 +13,7 @@ import { Snackbar } from '../../../../shared/defguard-ui/providers/snackbar/snac import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; 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'; @@ -37,8 +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()) - .min(1, m.initial_setup_general_config_error_defguard_url_required()), + .refine( + isValidDefguardUrl, + m.initial_setup_general_config_error_defguard_url_invalid_host(), + ), 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 6640cf76c2..eb6772731e 100644 --- a/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx +++ b/web/src/pages/SetupPage/initial/steps/SetupGeneralConfigStep.tsx @@ -12,6 +12,7 @@ import { Snackbar } from '../../../../shared/defguard-ui/providers/snackbar/snac import { ThemeSpacing } from '../../../../shared/defguard-ui/types'; import { useAppForm } from '../../../../shared/form'; import { formChangeLogic } from '../../../../shared/formLogic'; +import { isValidDefguardUrl } from '../../../../shared/utils/defguardUrl'; import { SetupPageStep } from '../types'; import { useSetupWizardStore } from '../useSetupWizardStore'; @@ -43,8 +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()) - .min(1, m.initial_setup_general_config_error_defguard_url_required()), + .refine( + isValidDefguardUrl, + m.initial_setup_general_config_error_defguard_url_invalid_host(), + ), 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 d77671b2c2..01dcfb2bc2 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'; @@ -22,6 +23,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 +66,13 @@ 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()), instance_name: z .string(m.form_error_required()) .trim() @@ -79,7 +88,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), }); @@ -131,18 +140,20 @@ 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, - 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, }), [ + settings.defguard_url, 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, ], @@ -205,6 +216,15 @@ const Content = ({ settings }: { settings: Settings }) => { title={m.settings_instance_section_core()} description={m.settings_instance_section_core_description()} /> + + {(field) => ( + + )} + + {(field) => ( @@ -236,32 +256,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 e479b3eeb3..a0c9c13a23 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -941,7 +941,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; diff --git a/web/src/shared/utils/defguardUrl.ts b/web/src/shared/utils/defguardUrl.ts new file mode 100644 index 0000000000..f54197f753 --- /dev/null +++ b/web/src/shared/utils/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; + } +};