diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e76ad954c9..f0dcaa38df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,6 @@ jobs: env: CARGO_TERM_COLOR: always - DEFGUARD_SECRET_KEY: aa5a506b11d719dd7170f57f5d9947faf8eb0bc2be1325e42aa0237c3dcfd26456e73dff9eef3b12c7bcf8711b45e3e703d8e21ee1c08520f5e12e3f5772da94 DEFGUARD_DB_HOST: postgres DEFGUARD_DB_PORT: 5432 DEFGUARD_DB_NAME: defguard diff --git a/.sqlx/query-e51e9e60cbd2bd128b9b869cccde2d8eee47f3e62fc65979df70de5204ba2b0b.json b/.sqlx/query-96917ed09e836086c38396c5778b83ca94ffff8f5f636ef650250befbcd78ab4.json similarity index 89% rename from .sqlx/query-e51e9e60cbd2bd128b9b869cccde2d8eee47f3e62fc65979df70de5204ba2b0b.json rename to .sqlx/query-96917ed09e836086c38396c5778b83ca94ffff8f5f636ef650250befbcd78ab4.json index ef4617f7d1..444299927c 100644 --- a/.sqlx/query-e51e9e60cbd2bd128b9b869cccde2d8eee47f3e62fc65979df70de5204ba2b0b.json +++ b/.sqlx/query-96917ed09e836086c38396c5778b83ca94ffff8f5f636ef650250befbcd78ab4.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, auth_cookie_timeout_days = $58, secret_key = $59, webauthn_rp_id = $60, grpc_url = $61, disable_stats_purge = $62, stats_purge_frequency_hours = $63, stats_purge_threshold_days = $64, enrollment_token_timeout_hours = $65, password_reset_token_timeout_hours = $66, enrollment_session_timeout_minutes = $67, password_reset_session_timeout_minutes = $68 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, auth_cookie_timeout_days = $58, secret_key = $59, webauthn_rp_id = $60, disable_stats_purge = $61, stats_purge_frequency_hours = $62, stats_purge_threshold_days = $63, enrollment_token_timeout_hours = $64, password_reset_token_timeout_hours = $65, enrollment_session_timeout_minutes = $66, password_reset_session_timeout_minutes = $67 WHERE id = 1", "describe": { "columns": [], "parameters": { @@ -97,7 +97,6 @@ "Int4", "Text", "Text", - "Text", "Bool", "Int4", "Int4", @@ -109,5 +108,5 @@ }, "nullable": [] }, - "hash": "e51e9e60cbd2bd128b9b869cccde2d8eee47f3e62fc65979df70de5204ba2b0b" + "hash": "96917ed09e836086c38396c5778b83ca94ffff8f5f636ef650250befbcd78ab4" } diff --git a/.sqlx/query-7db8485d8dd3b8e344a27c2d20f43f0234e842b0590a9123e242b97a8fceaf80.json b/.sqlx/query-a043fb7d435267b6c1bd9febbbeb0bc1d29e14daf738f64246f8deea246f2630.json similarity index 95% rename from .sqlx/query-7db8485d8dd3b8e344a27c2d20f43f0234e842b0590a9123e242b97a8fceaf80.json rename to .sqlx/query-a043fb7d435267b6c1bd9febbbeb0bc1d29e14daf738f64246f8deea246f2630.json index 72843c4990..caa5f6b3a7 100644 --- a/.sqlx/query-7db8485d8dd3b8e344a27c2d20f43f0234e842b0590a9123e242b97a8fceaf80.json +++ b/.sqlx/query-a043fb7d435267b6c1bd9febbbeb0bc1d29e14daf738f64246f8deea246f2630.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, auth_cookie_timeout_days, secret_key, webauthn_rp_id, grpc_url, 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, auth_cookie_timeout_days, 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", "describe": { "columns": [ { @@ -337,41 +337,36 @@ }, { "ordinal": 60, - "name": "grpc_url", - "type_info": "Text" - }, - { - "ordinal": 61, "name": "disable_stats_purge", "type_info": "Bool" }, { - "ordinal": 62, + "ordinal": 61, "name": "stats_purge_frequency_hours", "type_info": "Int4" }, { - "ordinal": 63, + "ordinal": 62, "name": "stats_purge_threshold_days", "type_info": "Int4" }, { - "ordinal": 64, + "ordinal": 63, "name": "enrollment_token_timeout_hours", "type_info": "Int4" }, { - "ordinal": 65, + "ordinal": 64, "name": "password_reset_token_timeout_hours", "type_info": "Int4" }, { - "ordinal": 66, + "ordinal": 65, "name": "enrollment_session_timeout_minutes", "type_info": "Int4" }, { - "ordinal": 67, + "ordinal": 66, "name": "password_reset_session_timeout_minutes", "type_info": "Int4" } @@ -446,9 +441,8 @@ false, false, false, - false, false ] }, - "hash": "7db8485d8dd3b8e344a27c2d20f43f0234e842b0590a9123e242b97a8fceaf80" + "hash": "a043fb7d435267b6c1bd9febbbeb0bc1d29e14daf738f64246f8deea246f2630" } diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index 9de5269366..0971412a5e 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -97,16 +97,12 @@ async fn main() -> Result<(), anyhow::Error> { info!("Using HMAC OpenID signing key"); } - // initialize default settings - Settings::init_defaults(&pool).await?; - Settings::ensure_secret_key(&pool, &config).await?; - let mut ini_server_config = true; // initialize global settings struct initialize_current_settings(&pool).await?; let has_auto_adopt_flags = config.adopt_edge.is_some() || config.adopt_gateway.is_some(); let wizard = Wizard::init(&pool, has_auto_adopt_flags).await?; - // FIXME: Merge logic conflict, migration wizard depended on WizardFlags, move this logic to Wizard + let mut ini_server_config = true; if !wizard.completed { match wizard.active_wizard { @@ -128,6 +124,8 @@ async fn main() -> Result<(), anyhow::Error> { let mut settings = Settings::get_current_settings(); settings.update_from_config(&pool, &config).await?; + Settings::initialize_runtime_defaults(&pool).await?; + config.initialize_post_settings(); SERVER_CONFIG .set(config.clone()) @@ -148,6 +146,8 @@ async fn main() -> Result<(), anyhow::Error> { } } + Settings::initialize_runtime_defaults(&pool).await?; + if ini_server_config { config.initialize_post_settings(); diff --git a/crates/defguard_common/src/config.rs b/crates/defguard_common/src/config.rs index 1f0ea16b52..091e82e3aa 100644 --- a/crates/defguard_common/src/config.rs +++ b/crates/defguard_common/src/config.rs @@ -37,10 +37,10 @@ pub struct DefGuardConfig { #[arg(long, env = "DEFGUARD_LOG_FILE")] pub log_file: Option, - #[arg(long, env = "DEFGUARD_AUTH_COOKIE_TIMEOUT", default_value = "7d")] + #[arg(long, env = "DEFGUARD_AUTH_COOKIE_TIMEOUT")] #[serde(skip_serializing)] - #[deprecated(since = "2.0.0", note = "Use Settings.default_authentication instead")] - pub auth_cookie_timeout: Duration, + #[deprecated(since = "2.0.0", note = "Use Settings.auth_cookie_timeout instead")] + pub auth_cookie_timeout: Option, #[arg(long, env = "DEFGUARD_SECRET_KEY")] #[serde(skip_serializing)] @@ -76,16 +76,6 @@ pub struct DefGuardConfig { #[arg(long, env = "DEFGUARD_GRPC_KEY")] pub grpc_key: Option, - #[arg( - long, - env = "DEFGUARD_DEFAULT_ADMIN_PASSWORD", - default_value = "pass123" - )] - #[serde(skip_serializing)] - // TODO: Deprecate this, since we have initial setup now. - // We use it in some dev/test scenarios still so the approach will need to be changed there. - pub default_admin_password: SecretString, - #[arg(long, env = "DEFGUARD_OPENID_KEY", value_parser = Self::parse_openid_key)] #[serde(skip_serializing)] pub openid_signing_key: Option, @@ -100,10 +90,6 @@ pub struct DefGuardConfig { #[deprecated(since = "2.0.0", note = "Use Settings.defguard_url instead")] pub url: Url, - #[arg(long, env = "DEFGUARD_GRPC_URL", value_parser = Url::parse)] - #[deprecated(since = "2.0.0", note = "Use Settings.grpc_url instead")] - pub grpc_url: Option, - #[arg(long, env = "DEFGUARD_DISABLE_STATS_PURGE")] #[deprecated(since = "2.0.0", note = "Use Settings.disable_stats_purge instead")] pub disable_stats_purge: Option, @@ -118,7 +104,7 @@ pub struct DefGuardConfig { #[deprecated(since = "2.0.0", note = "Use Settings.stats_purge_threshold instead")] pub stats_purge_threshold: Option, - #[arg(long, env = "DEFGUARD_ENROLLMENT_URL", value_parser = Url::parse, default_value = "http://localhost:8080")] + #[arg(long, env = "DEFGUARD_ENROLLMENT_URL", value_parser = Url::parse)] #[serde(skip_serializing)] #[deprecated(since = "2.0.0", note = "Use Settings.public_proxy_url instead")] pub enrollment_url: Option, @@ -261,21 +247,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."); - // TODO(jck) - // self.initialize_rp_id(&url); self.initialize_cookie_domain(&url); } - // fn initialize_rp_id(&mut self, url: &Url) { - // if self.webauthn_rp_id.is_none() { - // self.webauthn_rp_id = Some( - // url.domain() - // .expect("Unable to get domain for server URL.") - // .to_string(), - // ); - // } - // } - fn initialize_cookie_domain(&mut self, url: &Url) { if self.cookie_domain.is_none() { self.cookie_domain = Some( @@ -325,30 +299,6 @@ mod tests { DefGuardConfig::command().debug_assert(); } - // #[test] - // fn test_generate_rp_id() { - // unsafe { - // env::remove_var("DEFGUARD_WEBAUTHN_RP_ID"); - // } - - // let url = Url::parse("https://defguard.example.com").unwrap(); - // let mut config = DefGuardConfig::new(); - // config.initialize_rp_id(&url); - - // assert_eq!( - // config.webauthn_rp_id, - // Some("defguard.example.com".to_string()) - // ); - - // unsafe { - // env::set_var("DEFGUARD_WEBAUTHN_RP_ID", "example.com"); - // } - - // let config = DefGuardConfig::new(); - - // assert_eq!(config.webauthn_rp_id, Some("example.com".to_string())); - // } - #[test] fn test_generate_cookie_domain() { unsafe { diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index 928b5add3d..5c757d41b1 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -1,5 +1,6 @@ use std::{collections::HashMap, fmt, time::Duration}; +use base64::{Engine, prelude::BASE64_STANDARD}; use chrono::NaiveDateTime; use rand::{RngCore, rngs::OsRng}; use secrecy::ExposeSecret; @@ -51,11 +52,17 @@ pub enum SettingsValidationError { } #[derive(Error, Debug)] -pub enum SettingsRequiredValueError { +pub enum SettingsInitializationError { + #[error(transparent)] + Db(#[from] sqlx::Error), #[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}")] + InvalidDefguardUrl(String), + #[error("Unable to derive webauthn_rp_id: defguard_url has no domain: {0}")] + MissingDefguardDomain(String), } #[derive(Clone, Deserialize, Serialize, PartialEq, Eq, Type, Debug, Default)] @@ -172,7 +179,6 @@ pub struct Settings { // 1.6 config options pub secret_key: Option, pub webauthn_rp_id: Option, - pub grpc_url: String, pub disable_stats_purge: bool, auth_cookie_timeout_days: i32, stats_purge_frequency_hours: i32, @@ -273,16 +279,16 @@ impl fmt::Debug for Settings { } impl Settings { - pub(crate) fn validate_secret_key(secret_key: &str) -> Result<(), SettingsRequiredValueError> { + pub(crate) fn validate_secret_key(secret_key: &str) -> Result<(), SettingsInitializationError> { if secret_key.trim().len() != secret_key.len() { - return Err(SettingsRequiredValueError::Invalid( + return Err(SettingsInitializationError::Invalid( "secret_key", "cannot have leading or trailing whitespace", )); } if secret_key.len() < 64 { - return Err(SettingsRequiredValueError::Invalid( + return Err(SettingsInitializationError::Invalid( "secret_key", "must be at least 64 characters long", )); @@ -291,43 +297,11 @@ impl Settings { Ok(()) } + /// Generates length 64 random base64 string. fn generate_secret_key() -> String { - let mut bytes = [0_u8; 32]; + let mut bytes = [0_u8; 48]; OsRng.fill_bytes(&mut bytes); - let mut secret_key = String::with_capacity(64); - for byte in bytes { - use std::fmt::Write as _; - let _ = write!(secret_key, "{byte:02x}"); - } - secret_key - } - - pub async fn ensure_secret_key( - pool: &PgPool, - config: &DefGuardConfig, - ) -> Result<(), anyhow::Error> { - let mut settings = Settings::get(pool).await?.unwrap(); - - #[allow(deprecated)] - if let Some(secret_key) = &config.secret_key { - let secret_key = secret_key.expose_secret(); - Settings::validate_secret_key(secret_key)?; - if settings.secret_key.as_deref() != Some(secret_key) { - settings.secret_key = Some(secret_key.to_string()); - update_current_settings(pool, settings).await?; - } - return Ok(()); - } - - if let Some(secret_key) = settings.secret_key.as_deref() { - Settings::validate_secret_key(secret_key)?; - return Ok(()); - } - - settings.secret_key = Some(Settings::generate_secret_key()); - update_current_settings(pool, settings).await?; - - Ok(()) + BASE64_STANDARD.encode(bytes) } pub async fn get<'e, E>(executor: E) -> Result, sqlx::Error> @@ -358,7 +332,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, auth_cookie_timeout_days, secret_key, webauthn_rp_id, grpc_url, disable_stats_purge, \ + default_admin_id, auth_cookie_timeout_days, 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 \ @@ -451,14 +425,13 @@ impl Settings { auth_cookie_timeout_days = $58, \ secret_key = $59, \ webauthn_rp_id = $60, \ - grpc_url = $61, \ - disable_stats_purge = $62, \ - stats_purge_frequency_hours = $63, \ - stats_purge_threshold_days = $64, \ - enrollment_token_timeout_hours = $65, \ - password_reset_token_timeout_hours = $66, \ - enrollment_session_timeout_minutes = $67, \ - password_reset_session_timeout_minutes = $68 \ + disable_stats_purge = $61, \ + stats_purge_frequency_hours = $62, \ + stats_purge_threshold_days = $63, \ + enrollment_token_timeout_hours = $64, \ + password_reset_token_timeout_hours = $65, \ + enrollment_session_timeout_minutes = $66, \ + password_reset_session_timeout_minutes = $67 \ WHERE id = 1", self.openid_enabled, self.wireguard_enabled, @@ -520,7 +493,6 @@ impl Settings { self.auth_cookie_timeout_days, self.secret_key, self.webauthn_rp_id, - self.grpc_url, self.disable_stats_purge, self.stats_purge_frequency_hours, self.stats_purge_threshold_days, @@ -547,8 +519,10 @@ impl Settings { // Set default values for settings if not set yet. // This is only relevant to a subset of settings which are nullable // and we want to initialize their values. - pub async fn init_defaults(pool: &PgPool) -> Result<(), sqlx::Error> { - info!("Initializing default settings"); + pub async fn initialize_runtime_defaults( + pool: &PgPool, + ) -> Result<(), SettingsInitializationError> { + info!("Initializing runtime default settings"); let default_settings = HashMap::from([ ("enrollment_welcome_message", defaults::WELCOME_MESSAGE), @@ -564,6 +538,29 @@ impl Settings { query(&query_string).bind(value).execute(pool).await?; } + let mut settings = Settings::get(pool).await?.unwrap_or_default(); + + match settings.secret_key.as_deref() { + Some(secret_key) => { + Settings::validate_secret_key(secret_key)?; + } + None => { + settings.secret_key = Some(Settings::generate_secret_key()); + } + } + + 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(()) } @@ -643,11 +640,11 @@ impl Settings { Duration::from_secs(self.password_reset_session_timeout_minutes as u64 * 60) } - pub fn secret_key_required(&self) -> Result<&str, SettingsRequiredValueError> { + pub fn secret_key_required(&self) -> Result<&str, SettingsInitializationError> { let secret_key = self .secret_key .as_deref() - .ok_or(SettingsRequiredValueError::Missing("secret_key"))?; + .ok_or(SettingsInitializationError::Missing("secret_key"))?; Settings::validate_secret_key(secret_key)?; @@ -659,28 +656,28 @@ impl Settings { } #[allow(deprecated)] - pub async fn update_from_config<'e, E>( - &mut self, - executor: E, - config: &DefGuardConfig, - ) -> Result<(), sqlx::Error> - where - E: PgExecutor<'e>, - { - info!("Updating Settings from DefguardConfig: {config:?}"); + fn apply_from_config(&mut self, config: &DefGuardConfig) { let minute = 60; let hour = minute * 60; let day = hour * 24; - self.auth_cookie_timeout_days = (config.auth_cookie_timeout.as_secs() / day) as i32; + + if let Some(auth_cookie_timeout) = config.auth_cookie_timeout { + self.auth_cookie_timeout_days = (auth_cookie_timeout.as_secs() / day) as i32; + } if let Some(secret_key) = &config.secret_key { - self.secret_key = Some(secret_key.expose_secret().to_string()); + let secret_key = secret_key.expose_secret(); + if let Err(err) = Settings::validate_secret_key(secret_key) { + warn!( + "Invalid secret_key provided in deprecated config, generating new one: {err}" + ); + self.secret_key = Some(Settings::generate_secret_key()); + } else { + 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(grpc_url) = &config.grpc_url { - self.grpc_url = grpc_url.to_string(); - } if let Some(enrollment_url) = &config.enrollment_url { self.public_proxy_url = enrollment_url.to_string(); } @@ -715,6 +712,19 @@ impl Settings { self.password_reset_session_timeout_minutes = (password_reset_session_timeout.as_secs() / minute) as i32; } + } + + pub async fn update_from_config<'e, E>( + &mut self, + executor: E, + config: &DefGuardConfig, + ) -> Result<(), sqlx::Error> + where + E: PgExecutor<'e>, + { + info!("Updating Settings from DefguardConfig: {config:?}"); + self.apply_from_config(config); + update_current_settings(executor, self.clone()).await?; info!("Updated Settings from DefguardConfig: {config:?}"); @@ -807,7 +817,13 @@ Star us on GitHub! https://github.com/defguard/defguard\ mod test { use std::str::FromStr; + use humantime::Duration; + use reqwest::Url; + use secrecy::SecretString; + use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + use super::*; + use crate::db::setup_pool; #[test] fn test_smtp_config() { @@ -864,6 +880,196 @@ mod test { ); } + #[test] + #[allow(deprecated)] + 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.auth_cookie_timeout = Some(Duration::from(std::time::Duration::from_secs( + 3 * 24 * 3600, + ))); + 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( + 10 * 24 * 3600, + ))); + config.disable_stats_purge = Some(true); + config.stats_purge_frequency = + Some(Duration::from(std::time::Duration::from_secs(5 * 3600))); + config.stats_purge_threshold = Some(Duration::from(std::time::Duration::from_secs( + 12 * 24 * 3600, + ))); + config.enrollment_token_timeout = + Some(Duration::from(std::time::Duration::from_secs(7 * 3600))); + config.password_reset_token_timeout = + Some(Duration::from(std::time::Duration::from_secs(9 * 3600))); + config.enrollment_session_timeout = + Some(Duration::from(std::time::Duration::from_secs(15 * 60))); + config.password_reset_session_timeout = + Some(Duration::from(std::time::Duration::from_secs(20 * 60))); + + settings.apply_from_config(&config); + + assert_eq!(settings.auth_cookie_timeout_days, 3); + assert_eq!( + settings.secret_key.as_deref(), + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); + assert_eq!(settings.webauthn_rp_id.as_deref(), Some("rp-from-config")); + 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_eq!(settings.stats_purge_frequency_hours, 5); + assert_eq!(settings.stats_purge_threshold_days, 12); + assert_eq!(settings.enrollment_token_timeout_hours, 7); + assert_eq!(settings.password_reset_token_timeout_hours, 9); + assert_eq!(settings.enrollment_session_timeout_minutes, 15); + assert_eq!(settings.password_reset_session_timeout_minutes, 20); + } + + #[test] + fn test_apply_from_config_keeps_values_when_config_is_none() { + 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, + ..Default::default() + }; + let config = DefGuardConfig::new_test_config(); + let existing_secret = "z".repeat(64); + + settings.apply_from_config(&config); + + assert_eq!( + settings.secret_key.as_deref(), + Some(existing_secret.as_str()) + ); + assert_eq!(settings.webauthn_rp_id.as_deref(), Some("already-set")); + 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); + } + + #[test] + fn test_apply_from_config_invalid_defguard_url_does_not_set_webauthn_rp_id() { + 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()); + } + + #[test] + #[allow(deprecated)] + fn test_apply_from_config_invalid_secret_key_generates_new() { + let mut settings = Settings::default(); + let mut config = DefGuardConfig::new_test_config(); + config.secret_key = Some(SecretString::from(" short ".to_string())); + + settings.apply_from_config(&config); + + let generated = settings.secret_key.expect("secret key should be generated"); + assert_eq!(generated.len(), 64); + assert_ne!(generated, " short "); + assert!(Settings::validate_secret_key(&generated).is_ok()); + } + + #[test] + #[allow(deprecated)] + fn test_apply_from_config_valid_secret_key_is_used() { + let mut settings = Settings::default(); + let mut config = DefGuardConfig::new_test_config(); + let valid_secret = "b".repeat(64); + config.secret_key = Some(SecretString::from(valid_secret.clone())); + + settings.apply_from_config(&config); + + assert_eq!(settings.secret_key.as_deref(), Some(valid_secret.as_str())); + } + + #[sqlx::test] + #[allow(deprecated)] + async fn test_update_from_config_persists_and_updates_current_settings( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool).await.unwrap(); + + let mut settings = Settings::get_current_settings(); + settings.defguard_url = "https://defguard.example.com".into(); + update_current_settings(&pool, settings.clone()) + .await + .unwrap(); + + let mut config = DefGuardConfig::new_test_config(); + config.mfa_code_timeout = Some(Duration::from(std::time::Duration::from_secs(90))); + config.session_timeout = Some(Duration::from(std::time::Duration::from_secs( + 2 * 24 * 3600, + ))); + config.disable_stats_purge = Some(true); + + settings.update_from_config(&pool, &config).await.unwrap(); + + let current = Settings::get_current_settings(); + let from_db = Settings::get(&pool).await.unwrap().unwrap(); + + assert_eq!(current.mfa_code_timeout_seconds, 90); + assert_eq!(current.authentication_period_days, 2); + assert!(current.disable_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); + } + + #[sqlx::test] + async fn test_initialize_runtime_defaults_derives_webauthn_rp_id_from_defguard_url( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool).await.unwrap(); + + 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(); + + Settings::initialize_runtime_defaults(&pool).await.unwrap(); + + 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") + ); + } + #[test] fn test_edge_callback_url() { let mut s = Settings { diff --git a/crates/defguard_core/src/enterprise/directory_sync/tests.rs b/crates/defguard_core/src/enterprise/directory_sync/tests.rs index e06ca653b6..94cd863772 100644 --- a/crates/defguard_core/src/enterprise/directory_sync/tests.rs +++ b/crates/defguard_core/src/enterprise/directory_sync/tests.rs @@ -14,7 +14,6 @@ mod test { }, }; use ipnetwork::IpNetwork; - use secrecy::ExposeSecret; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use tokio::sync::broadcast; @@ -44,7 +43,7 @@ mod test { target: DirectorySyncTarget, prefetch_users: bool, ) -> OpenIdProvider { - Settings::init_defaults(pool).await.unwrap(); + Settings::initialize_runtime_defaults(pool).await.unwrap(); initialize_current_settings(pool).await.unwrap(); let current = OpenIdProvider::get_current(pool).await.unwrap(); @@ -231,9 +230,7 @@ mod test { let config = DefGuardConfig::new_test_config(); let _ = SERVER_CONFIG.set(config.clone()); let (wg_tx, mut wg_rx) = broadcast::channel::(16); - User::init_admin_user(&pool, config.default_admin_password.expose_secret()) - .await - .unwrap(); + User::init_admin_user(&pool, "pass123").await.unwrap(); let _ = make_test_provider( &pool, @@ -296,9 +293,7 @@ mod test { false, ) .await; - User::init_admin_user(&pool, config.default_admin_password.expose_secret()) - .await - .unwrap(); + User::init_admin_user(&pool, "pass123").await.unwrap(); let mut client = DirectorySyncClient::build(&pool).await.unwrap(); client.prepare().await.unwrap(); diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 44e0b9c212..362366d7d7 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -672,6 +672,7 @@ pub async fn run_web_server( } /// Automates test objects creation to easily setup development environment. +/// Admin password: pass123 /// Test network keys: /// Public: zGMeVGm9HV9I4wSKF9AXmYnnAIhDySyqLMuKpcfIaQo= /// Private: MAk3d5KuB167G88HM7nGYR6ksnPMAOguAg2s5EcPp1M= @@ -691,7 +692,7 @@ pub async fn init_dev_env(config: &DefGuardConfig) { .await; // initialize admin user - User::init_admin_user(&pool, config.default_admin_password.expose_secret()) + User::init_admin_user(&pool, "pass123") .await .expect("Failed to create admin user"); diff --git a/crates/defguard_core/tests/integration/api/acl.rs b/crates/defguard_core/tests/integration/api/acl.rs index eef7df3200..95a5681ac1 100644 --- a/crates/defguard_core/tests/integration/api/acl.rs +++ b/crates/defguard_core/tests/integration/api/acl.rs @@ -39,7 +39,7 @@ async fn make_client_v2(pool: PgPool, config: DefGuardConfig) -> TestClient { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("Could not bind ephemeral socket"); - initialize_users(&pool, &config).await; + initialize_users(&pool).await; initialize_current_settings(&pool) .await .expect("Could not initialize settings"); diff --git a/crates/defguard_core/tests/integration/api/common/mod.rs b/crates/defguard_core/tests/integration/api/common/mod.rs index 372ff5d72c..894f04786b 100644 --- a/crates/defguard_core/tests/integration/api/common/mod.rs +++ b/crates/defguard_core/tests/integration/api/common/mod.rs @@ -158,7 +158,7 @@ pub(crate) async fn make_test_client(pool: PgPool) -> (TestClient, ClientState) .expect("Could not bind ephemeral socket"); let port = listener.local_addr().unwrap().port(); let config = init_config(Some(&format!("http://localhost:{port}")), &pool).await; - initialize_users(&pool, &config).await; + initialize_users(&pool).await; initialize_current_settings(&pool) .await .expect("Could not initialize settings"); diff --git a/crates/defguard_core/tests/integration/common.rs b/crates/defguard_core/tests/integration/common.rs index 2310f11350..d6b7cf3c69 100644 --- a/crates/defguard_core/tests/integration/common.rs +++ b/crates/defguard_core/tests/integration/common.rs @@ -6,7 +6,7 @@ use defguard_common::{ }, }; use defguard_core::enterprise::license::{License, LicenseTier, set_cached_license}; -use secrecy::{ExposeSecret, SecretString}; +use reqwest::Url; use sqlx::PgPool; fn set_test_license_business() { @@ -28,13 +28,16 @@ pub(crate) async fn init_config( pool: &PgPool, ) -> 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(); - config.default_admin_password = SecretString::new("pass123".into()); initialize_current_settings(pool) .await .expect("Could not initialize current settings in the database"); 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"); @@ -45,10 +48,8 @@ pub(crate) async fn init_config( config } -pub(crate) async fn initialize_users(pool: &PgPool, config: &DefGuardConfig) { - User::init_admin_user(pool, config.default_admin_password.expose_secret()) - .await - .unwrap(); +pub(crate) async fn initialize_users(pool: &PgPool) { + User::init_admin_user(pool, "pass123").await.unwrap(); User::new( "hpotter", diff --git a/crates/defguard_core/tests/integration/grpc/common/mod.rs b/crates/defguard_core/tests/integration/grpc/common/mod.rs index b771cfbc41..d5d3794685 100644 --- a/crates/defguard_core/tests/integration/grpc/common/mod.rs +++ b/crates/defguard_core/tests/integration/grpc/common/mod.rs @@ -131,8 +131,7 @@ pub(crate) async fn make_grpc_test_server(pool: &PgPool) -> TestGrpcServer { let failed_logins = FailedLoginMap::new(); let failed_logins = Arc::new(Mutex::new(failed_logins)); - let config = init_config(None, pool).await; - initialize_users(pool, &config).await; + initialize_users(pool).await; initialize_current_settings(pool) .await .expect("Could not initialize settings"); diff --git a/crates/defguard_proxy_manager/src/servers/enrollment.rs b/crates/defguard_proxy_manager/src/servers/enrollment.rs index 1c84423dc8..6adb0af5df 100644 --- a/crates/defguard_proxy_manager/src/servers/enrollment.rs +++ b/crates/defguard_proxy_manager/src/servers/enrollment.rs @@ -1149,7 +1149,7 @@ mod test { ); // initialize settings - Settings::init_defaults(&pool).await.unwrap(); + Settings::initialize_runtime_defaults(&pool).await.unwrap(); initialize_current_settings(&pool).await.unwrap(); let mut settings = Settings::get(&pool).await.unwrap().unwrap(); diff --git a/migrations/20260227091211_[2.0.0]_settings_in_db.down.sql b/migrations/20260227091211_[2.0.0]_settings_in_db.down.sql index 5e68c99968..c74dfd7d0d 100644 --- a/migrations/20260227091211_[2.0.0]_settings_in_db.down.sql +++ b/migrations/20260227091211_[2.0.0]_settings_in_db.down.sql @@ -3,7 +3,6 @@ ALTER TABLE settings DROP COLUMN secret_key, DROP COLUMN openid_signing_key, DROP COLUMN webauthn_rp_id, - DROP COLUMN grpc_url, DROP COLUMN disable_stats_purge, DROP COLUMN stats_purge_frequency_hours, DROP COLUMN stats_purge_threshold_days, diff --git a/migrations/20260227091211_[2.0.0]_settings_in_db.up.sql b/migrations/20260227091211_[2.0.0]_settings_in_db.up.sql index b414c04c1c..64858bd93f 100644 --- a/migrations/20260227091211_[2.0.0]_settings_in_db.up.sql +++ b/migrations/20260227091211_[2.0.0]_settings_in_db.up.sql @@ -3,7 +3,6 @@ ALTER TABLE settings ADD COLUMN secret_key text, ADD COLUMN openid_signing_key text, ADD COLUMN webauthn_rp_id text, - ADD COLUMN grpc_url text NOT NULL DEFAULT 'http://localhost:50055', ADD COLUMN disable_stats_purge boolean NOT NULL DEFAULT false, ADD COLUMN stats_purge_frequency_hours int4 NOT NULL DEFAULT 24, ADD COLUMN stats_purge_threshold_days int4 NOT NULL DEFAULT 30, diff --git a/web/messages/en/settings.json b/web/messages/en/settings.json index d7f3e95e79..e66da76ddd 100644 --- a/web/messages/en/settings.json +++ b/web/messages/en/settings.json @@ -8,6 +8,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_instance_label_auth_cookie_timeout_days": "Auth cookie timeout (days)", "settings_instance_session_duration_1": "1 day", "settings_instance_session_duration_2": "2 days", "settings_instance_session_duration_3": "3 days", diff --git a/web/src/pages/settings/SettingsEnrollmentPage/SettingsEnrollmentPage.tsx b/web/src/pages/settings/SettingsEnrollmentPage/SettingsEnrollmentPage.tsx new file mode 100644 index 0000000000..81c2bbb020 --- /dev/null +++ b/web/src/pages/settings/SettingsEnrollmentPage/SettingsEnrollmentPage.tsx @@ -0,0 +1,175 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { useMemo } from 'react'; +import z from 'zod'; +import { m } from '../../../paraglide/messages'; +import api from '../../../shared/api/api'; +import type { Settings } from '../../../shared/api/types'; +import { Breadcrumbs } from '../../../shared/components/Breadcrumbs/Breadcrumbs'; +import { Controls } from '../../../shared/components/Controls/Controls'; +import { Page } from '../../../shared/components/Page/Page'; +import { SettingsCard } from '../../../shared/components/SettingsCard/SettingsCard'; +import { SettingsHeader } from '../../../shared/components/SettingsHeader/SettingsHeader'; +import { SettingsLayout } from '../../../shared/components/SettingsLayout/SettingsLayout'; +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 { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; +import { useAppForm } from '../../../shared/form'; +import { formChangeLogic } from '../../../shared/formLogic'; +import { getSettingsQueryOptions } from '../../../shared/query'; + +const breadcrumbs = [ + + General + , + + Enrollment + , +]; + +export const SettingsEnrollmentPage = () => { + const { data: settings } = useQuery(getSettingsQueryOptions); + return ( + + + + + {isPresent(settings) && ( + + + + )} + + + ); +}; + +const formSchema = z.object({ + enrollment_token_timeout_hours: z.number(m.form_error_required()).int().min(1), + password_reset_token_timeout_hours: z.number(m.form_error_required()).int().min(1), + enrollment_session_timeout_minutes: z.number(m.form_error_required()).int().min(1), + password_reset_session_timeout_minutes: z.number(m.form_error_required()).int().min(1), +}); + +type FormFields = z.infer; + +const Content = ({ settings }: { settings: Settings }) => { + const { mutateAsync } = useMutation({ + mutationFn: api.settings.patchSettings, + meta: { + invalidate: ['settings'], + }, + }); + + const defaultValues = useMemo( + (): FormFields => ({ + enrollment_token_timeout_hours: settings.enrollment_token_timeout_hours ?? 24, + password_reset_token_timeout_hours: + settings.password_reset_token_timeout_hours ?? 24, + enrollment_session_timeout_minutes: + settings.enrollment_session_timeout_minutes ?? 10, + password_reset_session_timeout_minutes: + settings.password_reset_session_timeout_minutes ?? 10, + }), + [settings], + ); + + const form = useAppForm({ + defaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: async ({ value }) => { + await mutateAsync(value); + form.reset(value); + }, + }); + + return ( +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + ({ + isDefault: s.isDefaultValue || s.isPristine, + isSubmitting: s.isSubmitting, + })} + > + {({ isDefault, isSubmitting }) => ( + +
+
+
+ )} +
+
+ ); +}; diff --git a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsGeneralTab.tsx b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsGeneralTab.tsx index 74306dd329..df69eff1f2 100644 --- a/web/src/pages/settings/SettingsIndexPage/tabs/SettingsGeneralTab.tsx +++ b/web/src/pages/settings/SettingsIndexPage/tabs/SettingsGeneralTab.tsx @@ -48,6 +48,22 @@ export const SettingsGeneralTab = () => { navigate({ to: '/settings/client' }); }} /> + + + + + + + + ); }; diff --git a/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx b/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx index be047c14bb..c13edd7001 100644 --- a/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx +++ b/web/src/pages/settings/SettingsInstancePage/SettingsInstancePage.tsx @@ -68,6 +68,7 @@ const formSchema = z.object({ }), ) .max(64, m.form_error_max_len({ length: 64 })), + auth_cookie_timeout_days: z.number(m.form_error_required()).int().min(1), 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()), @@ -115,6 +116,7 @@ const Content = ({ settings }: { settings: Settings }) => { const defaultValues = useMemo( (): FormFields => ({ instance_name: settings.instance_name ?? '', + auth_cookie_timeout_days: settings.auth_cookie_timeout_days ?? 7, public_proxy_url: settings.public_proxy_url ?? '', authentication_period_days: settings.authentication_period_days ?? 7, }), @@ -122,6 +124,7 @@ const Content = ({ settings }: { settings: Settings }) => { settings.instance_name, settings.public_proxy_url, settings.authentication_period_days, + settings.auth_cookie_timeout_days, ], ); @@ -171,6 +174,16 @@ const Content = ({ settings }: { settings: Settings }) => { /> )} + + + {(field) => ( + + )} + ({ diff --git a/web/src/pages/settings/SettingsVpnStatsPage/SettingsVpnStatsPage.tsx b/web/src/pages/settings/SettingsVpnStatsPage/SettingsVpnStatsPage.tsx new file mode 100644 index 0000000000..d45f079dd3 --- /dev/null +++ b/web/src/pages/settings/SettingsVpnStatsPage/SettingsVpnStatsPage.tsx @@ -0,0 +1,160 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { useMemo } from 'react'; +import z from 'zod'; +import { m } from '../../../paraglide/messages'; +import api from '../../../shared/api/api'; +import type { Settings } from '../../../shared/api/types'; +import { Breadcrumbs } from '../../../shared/components/Breadcrumbs/Breadcrumbs'; +import { Controls } from '../../../shared/components/Controls/Controls'; +import { Page } from '../../../shared/components/Page/Page'; +import { SettingsCard } from '../../../shared/components/SettingsCard/SettingsCard'; +import { SettingsHeader } from '../../../shared/components/SettingsHeader/SettingsHeader'; +import { SettingsLayout } from '../../../shared/components/SettingsLayout/SettingsLayout'; +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 { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; +import { useAppForm } from '../../../shared/form'; +import { formChangeLogic } from '../../../shared/formLogic'; +import { getSettingsQueryOptions } from '../../../shared/query'; + +const breadcrumbs = [ + + General + , + + VPN stats + , +]; + +export const SettingsVpnStatsPage = () => { + const { data: settings } = useQuery(getSettingsQueryOptions); + return ( + + + + + {isPresent(settings) && ( + + + + )} + + + ); +}; + +const formSchema = z.object({ + disable_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), +}); + +type FormFields = z.infer; + +const Content = ({ settings }: { settings: Settings }) => { + const { mutateAsync } = useMutation({ + mutationFn: api.settings.patchSettings, + meta: { + invalidate: ['settings'], + }, + }); + + const defaultValues = useMemo( + (): FormFields => ({ + disable_stats_purge: settings.disable_stats_purge ?? false, + stats_purge_frequency_hours: settings.stats_purge_frequency_hours ?? 24, + stats_purge_threshold_days: settings.stats_purge_threshold_days ?? 30, + }), + [settings], + ); + + const form = useAppForm({ + defaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: async ({ value }) => { + await mutateAsync(value); + form.reset(value); + }, + }); + + return ( +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + ({ + isDefault: s.isDefaultValue || s.isPristine, + isSubmitting: s.isSubmitting, + })} + > + {({ isDefault, isSubmitting }) => ( + +
+
+
+ )} +
+
+ ); +}; diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index d08bab3162..d9d51fbae5 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -44,11 +44,13 @@ import { Route as AuthorizedDefaultSettingsIndexRouteImport } from './routes/_au import { Route as AuthorizedDefaultLocationsIndexRouteImport } from './routes/_authorized/_default/locations/index' import { Route as AuthorizedDefaultVpnOverviewLocationIdRouteImport } from './routes/_authorized/_default/vpn-overview/$locationId' import { Route as AuthorizedDefaultUserUsernameRouteImport } from './routes/_authorized/_default/user/$username' +import { Route as AuthorizedDefaultSettingsVpnStatsRouteImport } from './routes/_authorized/_default/settings/vpn-stats' import { Route as AuthorizedDefaultSettingsSmtpRouteImport } from './routes/_authorized/_default/settings/smtp' import { Route as AuthorizedDefaultSettingsOpenidRouteImport } from './routes/_authorized/_default/settings/openid' import { Route as AuthorizedDefaultSettingsLdapRouteImport } from './routes/_authorized/_default/settings/ldap' import { Route as AuthorizedDefaultSettingsInstanceRouteImport } from './routes/_authorized/_default/settings/instance' import { Route as AuthorizedDefaultSettingsGatewayNotificationsRouteImport } from './routes/_authorized/_default/settings/gateway-notifications' +import { Route as AuthorizedDefaultSettingsEnrollmentRouteImport } from './routes/_authorized/_default/settings/enrollment' import { Route as AuthorizedDefaultSettingsEditOpenidRouteImport } from './routes/_authorized/_default/settings/edit-openid' import { Route as AuthorizedDefaultSettingsClientRouteImport } from './routes/_authorized/_default/settings/client' import { Route as AuthorizedDefaultAclRulesRouteImport } from './routes/_authorized/_default/acl/rules' @@ -249,6 +251,12 @@ const AuthorizedDefaultUserUsernameRoute = path: '/user/$username', getParentRoute: () => AuthorizedDefaultRoute, } as any) +const AuthorizedDefaultSettingsVpnStatsRoute = + AuthorizedDefaultSettingsVpnStatsRouteImport.update({ + id: '/settings/vpn-stats', + path: '/settings/vpn-stats', + getParentRoute: () => AuthorizedDefaultRoute, + } as any) const AuthorizedDefaultSettingsSmtpRoute = AuthorizedDefaultSettingsSmtpRouteImport.update({ id: '/settings/smtp', @@ -279,6 +287,12 @@ const AuthorizedDefaultSettingsGatewayNotificationsRoute = path: '/settings/gateway-notifications', getParentRoute: () => AuthorizedDefaultRoute, } as any) +const AuthorizedDefaultSettingsEnrollmentRoute = + AuthorizedDefaultSettingsEnrollmentRouteImport.update({ + id: '/settings/enrollment', + path: '/settings/enrollment', + getParentRoute: () => AuthorizedDefaultRoute, + } as any) const AuthorizedDefaultSettingsEditOpenidRoute = AuthorizedDefaultSettingsEditOpenidRouteImport.update({ id: '/settings/edit-openid', @@ -405,11 +419,13 @@ export interface FileRoutesByFullPath { '/acl/rules': typeof AuthorizedDefaultAclRulesRoute '/settings/client': typeof AuthorizedDefaultSettingsClientRoute '/settings/edit-openid': typeof AuthorizedDefaultSettingsEditOpenidRoute + '/settings/enrollment': typeof AuthorizedDefaultSettingsEnrollmentRoute '/settings/gateway-notifications': typeof AuthorizedDefaultSettingsGatewayNotificationsRoute '/settings/instance': typeof AuthorizedDefaultSettingsInstanceRoute '/settings/ldap': typeof AuthorizedDefaultSettingsLdapRoute '/settings/openid': typeof AuthorizedDefaultSettingsOpenidRoute '/settings/smtp': typeof AuthorizedDefaultSettingsSmtpRoute + '/settings/vpn-stats': typeof AuthorizedDefaultSettingsVpnStatsRoute '/user/$username': typeof AuthorizedDefaultUserUsernameRoute '/vpn-overview/$locationId': typeof AuthorizedDefaultVpnOverviewLocationIdRoute '/locations/': typeof AuthorizedDefaultLocationsIndexRoute @@ -459,11 +475,13 @@ export interface FileRoutesByTo { '/acl/rules': typeof AuthorizedDefaultAclRulesRoute '/settings/client': typeof AuthorizedDefaultSettingsClientRoute '/settings/edit-openid': typeof AuthorizedDefaultSettingsEditOpenidRoute + '/settings/enrollment': typeof AuthorizedDefaultSettingsEnrollmentRoute '/settings/gateway-notifications': typeof AuthorizedDefaultSettingsGatewayNotificationsRoute '/settings/instance': typeof AuthorizedDefaultSettingsInstanceRoute '/settings/ldap': typeof AuthorizedDefaultSettingsLdapRoute '/settings/openid': typeof AuthorizedDefaultSettingsOpenidRoute '/settings/smtp': typeof AuthorizedDefaultSettingsSmtpRoute + '/settings/vpn-stats': typeof AuthorizedDefaultSettingsVpnStatsRoute '/user/$username': typeof AuthorizedDefaultUserUsernameRoute '/vpn-overview/$locationId': typeof AuthorizedDefaultVpnOverviewLocationIdRoute '/locations': typeof AuthorizedDefaultLocationsIndexRoute @@ -516,11 +534,13 @@ export interface FileRoutesById { '/_authorized/_default/acl/rules': typeof AuthorizedDefaultAclRulesRoute '/_authorized/_default/settings/client': typeof AuthorizedDefaultSettingsClientRoute '/_authorized/_default/settings/edit-openid': typeof AuthorizedDefaultSettingsEditOpenidRoute + '/_authorized/_default/settings/enrollment': typeof AuthorizedDefaultSettingsEnrollmentRoute '/_authorized/_default/settings/gateway-notifications': typeof AuthorizedDefaultSettingsGatewayNotificationsRoute '/_authorized/_default/settings/instance': typeof AuthorizedDefaultSettingsInstanceRoute '/_authorized/_default/settings/ldap': typeof AuthorizedDefaultSettingsLdapRoute '/_authorized/_default/settings/openid': typeof AuthorizedDefaultSettingsOpenidRoute '/_authorized/_default/settings/smtp': typeof AuthorizedDefaultSettingsSmtpRoute + '/_authorized/_default/settings/vpn-stats': typeof AuthorizedDefaultSettingsVpnStatsRoute '/_authorized/_default/user/$username': typeof AuthorizedDefaultUserUsernameRoute '/_authorized/_default/vpn-overview/$locationId': typeof AuthorizedDefaultVpnOverviewLocationIdRoute '/_authorized/_default/locations/': typeof AuthorizedDefaultLocationsIndexRoute @@ -573,11 +593,13 @@ export interface FileRouteTypes { | '/acl/rules' | '/settings/client' | '/settings/edit-openid' + | '/settings/enrollment' | '/settings/gateway-notifications' | '/settings/instance' | '/settings/ldap' | '/settings/openid' | '/settings/smtp' + | '/settings/vpn-stats' | '/user/$username' | '/vpn-overview/$locationId' | '/locations/' @@ -627,11 +649,13 @@ export interface FileRouteTypes { | '/acl/rules' | '/settings/client' | '/settings/edit-openid' + | '/settings/enrollment' | '/settings/gateway-notifications' | '/settings/instance' | '/settings/ldap' | '/settings/openid' | '/settings/smtp' + | '/settings/vpn-stats' | '/user/$username' | '/vpn-overview/$locationId' | '/locations' @@ -683,11 +707,13 @@ export interface FileRouteTypes { | '/_authorized/_default/acl/rules' | '/_authorized/_default/settings/client' | '/_authorized/_default/settings/edit-openid' + | '/_authorized/_default/settings/enrollment' | '/_authorized/_default/settings/gateway-notifications' | '/_authorized/_default/settings/instance' | '/_authorized/_default/settings/ldap' | '/_authorized/_default/settings/openid' | '/_authorized/_default/settings/smtp' + | '/_authorized/_default/settings/vpn-stats' | '/_authorized/_default/user/$username' | '/_authorized/_default/vpn-overview/$locationId' | '/_authorized/_default/locations/' @@ -956,6 +982,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthorizedDefaultUserUsernameRouteImport parentRoute: typeof AuthorizedDefaultRoute } + '/_authorized/_default/settings/vpn-stats': { + id: '/_authorized/_default/settings/vpn-stats' + path: '/settings/vpn-stats' + fullPath: '/settings/vpn-stats' + preLoaderRoute: typeof AuthorizedDefaultSettingsVpnStatsRouteImport + parentRoute: typeof AuthorizedDefaultRoute + } '/_authorized/_default/settings/smtp': { id: '/_authorized/_default/settings/smtp' path: '/settings/smtp' @@ -991,6 +1024,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthorizedDefaultSettingsGatewayNotificationsRouteImport parentRoute: typeof AuthorizedDefaultRoute } + '/_authorized/_default/settings/enrollment': { + id: '/_authorized/_default/settings/enrollment' + path: '/settings/enrollment' + fullPath: '/settings/enrollment' + preLoaderRoute: typeof AuthorizedDefaultSettingsEnrollmentRouteImport + parentRoute: typeof AuthorizedDefaultRoute + } '/_authorized/_default/settings/edit-openid': { id: '/_authorized/_default/settings/edit-openid' path: '/settings/edit-openid' @@ -1111,11 +1151,13 @@ interface AuthorizedDefaultRouteChildren { AuthorizedDefaultAclRulesRoute: typeof AuthorizedDefaultAclRulesRoute AuthorizedDefaultSettingsClientRoute: typeof AuthorizedDefaultSettingsClientRoute AuthorizedDefaultSettingsEditOpenidRoute: typeof AuthorizedDefaultSettingsEditOpenidRoute + AuthorizedDefaultSettingsEnrollmentRoute: typeof AuthorizedDefaultSettingsEnrollmentRoute AuthorizedDefaultSettingsGatewayNotificationsRoute: typeof AuthorizedDefaultSettingsGatewayNotificationsRoute AuthorizedDefaultSettingsInstanceRoute: typeof AuthorizedDefaultSettingsInstanceRoute AuthorizedDefaultSettingsLdapRoute: typeof AuthorizedDefaultSettingsLdapRoute AuthorizedDefaultSettingsOpenidRoute: typeof AuthorizedDefaultSettingsOpenidRoute AuthorizedDefaultSettingsSmtpRoute: typeof AuthorizedDefaultSettingsSmtpRoute + AuthorizedDefaultSettingsVpnStatsRoute: typeof AuthorizedDefaultSettingsVpnStatsRoute AuthorizedDefaultUserUsernameRoute: typeof AuthorizedDefaultUserUsernameRoute AuthorizedDefaultVpnOverviewLocationIdRoute: typeof AuthorizedDefaultVpnOverviewLocationIdRoute AuthorizedDefaultLocationsIndexRoute: typeof AuthorizedDefaultLocationsIndexRoute @@ -1148,6 +1190,8 @@ const AuthorizedDefaultRouteChildren: AuthorizedDefaultRouteChildren = { AuthorizedDefaultSettingsClientRoute: AuthorizedDefaultSettingsClientRoute, AuthorizedDefaultSettingsEditOpenidRoute: AuthorizedDefaultSettingsEditOpenidRoute, + AuthorizedDefaultSettingsEnrollmentRoute: + AuthorizedDefaultSettingsEnrollmentRoute, AuthorizedDefaultSettingsGatewayNotificationsRoute: AuthorizedDefaultSettingsGatewayNotificationsRoute, AuthorizedDefaultSettingsInstanceRoute: @@ -1155,6 +1199,8 @@ const AuthorizedDefaultRouteChildren: AuthorizedDefaultRouteChildren = { AuthorizedDefaultSettingsLdapRoute: AuthorizedDefaultSettingsLdapRoute, AuthorizedDefaultSettingsOpenidRoute: AuthorizedDefaultSettingsOpenidRoute, AuthorizedDefaultSettingsSmtpRoute: AuthorizedDefaultSettingsSmtpRoute, + AuthorizedDefaultSettingsVpnStatsRoute: + AuthorizedDefaultSettingsVpnStatsRoute, AuthorizedDefaultUserUsernameRoute: AuthorizedDefaultUserUsernameRoute, AuthorizedDefaultVpnOverviewLocationIdRoute: AuthorizedDefaultVpnOverviewLocationIdRoute, diff --git a/web/src/routes/_authorized/_default/settings/enrollment.tsx b/web/src/routes/_authorized/_default/settings/enrollment.tsx new file mode 100644 index 0000000000..cc7fb8c77c --- /dev/null +++ b/web/src/routes/_authorized/_default/settings/enrollment.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { SettingsEnrollmentPage } from '../../../../pages/settings/SettingsEnrollmentPage/SettingsEnrollmentPage'; + +export const Route = createFileRoute('/_authorized/_default/settings/enrollment')({ + component: SettingsEnrollmentPage, +}); diff --git a/web/src/routes/_authorized/_default/settings/vpn-stats.tsx b/web/src/routes/_authorized/_default/settings/vpn-stats.tsx new file mode 100644 index 0000000000..83a8d03b85 --- /dev/null +++ b/web/src/routes/_authorized/_default/settings/vpn-stats.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { SettingsVpnStatsPage } from '../../../../pages/settings/SettingsVpnStatsPage/SettingsVpnStatsPage'; + +export const Route = createFileRoute('/_authorized/_default/settings/vpn-stats')({ + component: SettingsVpnStatsPage, +}); diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 7bb6c2ba47..025b81eb4e 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -844,6 +844,7 @@ export interface SettingsEnrollment { enrollment_welcome_email_subject: string; enrollment_use_welcome_message_as_email: boolean; } + export interface SettingsModules { openid_enabled: boolean; wireguard_enabled: boolean; @@ -895,6 +896,17 @@ export interface SettingsGatewayNotifications { gateway_disconnect_notifications_reconnect_notification_enabled: boolean; } +export interface SettingsTimeoutsAndMaintenance { + auth_cookie_timeout_days: number; + disable_stats_purge: boolean; + stats_purge_frequency_hours: number; + stats_purge_threshold_days: number; + enrollment_token_timeout_hours: number; + password_reset_token_timeout_hours: number; + enrollment_session_timeout_minutes: number; + password_reset_session_timeout_minutes: number; +} + export interface SettingsGeneral { defguard_url: string; default_admin_group_name: string; @@ -912,6 +924,7 @@ export type Settings = SettingsBranding & SettingsOpenID & SettingsEnrollment & SettingsSMTP & + SettingsTimeoutsAndMaintenance & SettingsGeneral; export interface OpenIdProviderSettings {