diff --git a/.sqlx/query-f2eb1e5e54fd2d0ede1941eadaf62c5de2e76b4aba0b969b28c624d335a21cec.json b/.sqlx/query-0cf6283fa8e927f0b74ddae4230a6108df8b6d4d4ebab0288d6182a29056d7a5.json similarity index 90% rename from .sqlx/query-f2eb1e5e54fd2d0ede1941eadaf62c5de2e76b4aba0b969b28c624d335a21cec.json rename to .sqlx/query-0cf6283fa8e927f0b74ddae4230a6108df8b6d4d4ebab0288d6182a29056d7a5.json index 7aac0a8693..29abad2393 100644 --- a/.sqlx/query-f2eb1e5e54fd2d0ede1941eadaf62c5de2e76b4aba0b969b28c624d335a21cec.json +++ b/.sqlx/query-0cf6283fa8e927f0b74ddae4230a6108df8b6d4d4ebab0288d6182a29056d7a5.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 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, initial_setup_completed, defguard_url, default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds FROM \"settings\" WHERE id = 1", "describe": { "columns": [ { @@ -289,6 +289,31 @@ "ordinal": 50, "name": "ca_expiry", "type_info": "Timestamp" + }, + { + "ordinal": 51, + "name": "initial_setup_completed", + "type_info": "Bool" + }, + { + "ordinal": 52, + "name": "defguard_url", + "type_info": "Text" + }, + { + "ordinal": 53, + "name": "default_admin_group_name", + "type_info": "Text" + }, + { + "ordinal": 54, + "name": "authentication_period_days", + "type_info": "Int4" + }, + { + "ordinal": 55, + "name": "mfa_code_timeout_seconds", + "type_info": "Int4" } ], "parameters": { @@ -345,8 +370,13 @@ false, true, true, - true + true, + false, + false, + false, + false, + false ] }, - "hash": "f2eb1e5e54fd2d0ede1941eadaf62c5de2e76b4aba0b969b28c624d335a21cec" + "hash": "0cf6283fa8e927f0b74ddae4230a6108df8b6d4d4ebab0288d6182a29056d7a5" } diff --git a/.sqlx/query-34fed9b4c2195113c5548f5107c70d948c232c5817c395bbf44444ffd2261075.json b/.sqlx/query-27f9e867cb4e0740f0fe8635ccf0c609e6de71153d7a6bad4de27434e11a0a7d.json similarity index 89% rename from .sqlx/query-34fed9b4c2195113c5548f5107c70d948c232c5817c395bbf44444ffd2261075.json rename to .sqlx/query-27f9e867cb4e0740f0fe8635ccf0c609e6de71153d7a6bad4de27434e11a0a7d.json index 5bb4e8f78f..32f500a463 100644 --- a/.sqlx/query-34fed9b4c2195113c5548f5107c70d948c232c5817c395bbf44444ffd2261075.json +++ b/.sqlx/query-27f9e867cb4e0740f0fe8635ccf0c609e6de71153d7a6bad4de27434e11a0a7d.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 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, initial_setup_completed = $52, defguard_url = $53, default_admin_group_name = $54, authentication_period_days = $55, mfa_code_timeout_seconds = $56 WHERE id = 1", "describe": { "columns": [], "parameters": { @@ -87,10 +87,15 @@ }, "Bytea", "Bytea", - "Timestamp" + "Timestamp", + "Bool", + "Text", + "Text", + "Int4", + "Int4" ] }, "nullable": [] }, - "hash": "34fed9b4c2195113c5548f5107c70d948c232c5817c395bbf44444ffd2261075" + "hash": "27f9e867cb4e0740f0fe8635ccf0c609e6de71153d7a6bad4de27434e11a0a7d" } diff --git a/.sqlx/query-00454ac37de808986d66b6abd808fb648b288f49586113cea21d889dca9655b9.json b/.sqlx/query-639d5cecd458667c0614ef3834be928a029fd8c53440eb01886e4c76c2367a0b.json similarity index 80% rename from .sqlx/query-00454ac37de808986d66b6abd808fb648b288f49586113cea21d889dca9655b9.json rename to .sqlx/query-639d5cecd458667c0614ef3834be928a029fd8c53440eb01886e4c76c2367a0b.json index e91d531ded..858a46551a 100644 --- a/.sqlx/query-00454ac37de808986d66b6abd808fb648b288f49586113cea21d889dca9655b9.json +++ b/.sqlx/query-639d5cecd458667c0614ef3834be928a029fd8c53440eb01886e4c76c2367a0b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT instance_name, main_logo_url, nav_logo_url, wireguard_enabled, webhooks_enabled, worker_enabled, openid_enabled FROM settings WHERE id = 1", + "query": "SELECT instance_name, main_logo_url, nav_logo_url, wireguard_enabled, webhooks_enabled, worker_enabled, openid_enabled, initial_setup_completed FROM settings WHERE id = 1", "describe": { "columns": [ { @@ -37,6 +37,11 @@ "ordinal": 6, "name": "openid_enabled", "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "initial_setup_completed", + "type_info": "Bool" } ], "parameters": { @@ -49,8 +54,9 @@ false, false, false, + false, false ] }, - "hash": "00454ac37de808986d66b6abd808fb648b288f49586113cea21d889dca9655b9" + "hash": "639d5cecd458667c0614ef3834be928a029fd8c53440eb01886e4c76c2367a0b" } diff --git a/Cargo.lock b/Cargo.lock index cdccff64e9..3f5b47a941 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1134,6 +1134,7 @@ dependencies = [ "defguard_mail", "defguard_proxy_manager", "defguard_session_manager", + "defguard_setup", "defguard_version", "defguard_vpn_stats_purge", "dotenvy", @@ -1188,6 +1189,7 @@ dependencies = [ "tonic", "totp-lite", "tracing", + "url", "utoipa", "uuid", "vergen-git2", @@ -1326,6 +1328,7 @@ dependencies = [ "chrono", "claims", "defguard_common", + "humantime", "lettre", "pulldown-cmark", "reqwest", @@ -1387,6 +1390,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "defguard_setup" +version = "0.0.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "defguard_certs", + "defguard_common", + "defguard_core", + "defguard_version", + "defguard_web_ui", + "reqwest", + "semver", + "serde", + "serde_json", + "sqlx", + "tokio", + "tracing", +] + [[package]] name = "defguard_version" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 676a1baf3e..39d21065d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ defguard_version = { path = "./crates/defguard_version", version = "0.0.0" } defguard_vpn_stats_purge = { path = "./crates/defguard_vpn_stats_purge", version = "0.0.0" } defguard_web_ui = { path = "./crates/defguard_web_ui", version = "0.0.0" } defguard_certs = { path = "./crates/defguard_certs", version = "0.0.0" } +defguard_setup = { path = "./crates/defguard_setup", version = "0.0.0" } model_derive = { path = "./crates/model_derive", version = "0.0.0" } # external dependencies diff --git a/crates/defguard/Cargo.toml b/crates/defguard/Cargo.toml index b9ded3c80f..c48794e088 100644 --- a/crates/defguard/Cargo.toml +++ b/crates/defguard/Cargo.toml @@ -19,6 +19,7 @@ defguard_session_manager = { workspace = true } defguard_version = { workspace = true } defguard_vpn_stats_purge = { workspace = true } defguard_certs = { workspace = true } +defguard_setup = { workspace = true } # external dependencies anyhow = { workspace = true } diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index 3f6c1dbef5..4e5c40380f 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -10,7 +10,7 @@ use defguard_common::{ db::{ init_db, models::{ - Settings, User, + Settings, settings::{initialize_current_settings, update_current_settings}, }, }, @@ -40,6 +40,7 @@ use defguard_event_router::{RouterReceiverSet, run_event_router}; use defguard_mail::{Mail, run_mail_handler}; use defguard_proxy_manager::{ProxyManager, ProxyTxSet}; use defguard_session_manager::{events::SessionManagerEvent, run_session_manager}; +use defguard_setup::setup::run_setup_web_server; use defguard_vpn_stats_purge::run_periodic_stats_purge; use secrecy::ExposeSecret; use tokio::sync::{ @@ -56,10 +57,7 @@ async fn main() -> Result<(), anyhow::Error> { if dotenvy::from_filename(".env.local").is_err() { dotenvy::dotenv().ok(); } - let config = DefGuardConfig::new(); - SERVER_CONFIG - .set(config.clone()) - .expect("Failed to initialize server config."); + let mut config = DefGuardConfig::new(); let subscriber = tracing_subscriber::registry(); defguard_version::tracing::with_version_formatters( @@ -103,6 +101,26 @@ async fn main() -> Result<(), anyhow::Error> { info!("Using HMAC OpenID signing key"); } + // initialize default settings + Settings::init_defaults(&pool).await?; + // initialize global settings struct + initialize_current_settings(&pool).await?; + let mut settings = Settings::get_current_settings(); + + if !settings.initial_setup_completed { + if let Err(err) = + run_setup_web_server(pool.clone(), config.http_bind_address, config.http_port).await + { + error!("Setup web server exited with error: {err}"); + } + } + + config.initialize_post_settings(); + + SERVER_CONFIG + .set(config.clone()) + .expect("Failed to initialize server config."); + // create event channels for services let (api_event_tx, api_event_rx) = unbounded_channel::(); let (bidi_event_tx, bidi_event_rx) = unbounded_channel::(); @@ -125,15 +143,6 @@ async fn main() -> Result<(), anyhow::Error> { let incompatible_components: Arc> = Arc::default(); - // initialize admin user - User::init_admin_user(&pool, config.default_admin_password.expose_secret()).await?; - - // initialize default settings - Settings::init_defaults(&pool).await?; - // initialize global settings struct - initialize_current_settings(&pool).await?; - - let mut settings = Settings::get_current_settings(); if settings.ca_cert_der.is_none() || settings.ca_key_der.is_none() { info!( "No gRPC TLS certificate or key found in settings, generating self-signed certificate for gRPC server." diff --git a/crates/defguard_certs/src/lib.rs b/crates/defguard_certs/src/lib.rs index 1b46c25b63..af7c825367 100644 --- a/crates/defguard_certs/src/lib.rs +++ b/crates/defguard_certs/src/lib.rs @@ -13,7 +13,7 @@ use x509_parser::parse_x509_certificate; const CA_NAME: &str = "Defguard CA"; const NOT_BEFORE_OFFSET_SECS: Duration = Duration::minutes(5); -const DEFAULT_CERT_VALIDITY_DAYS: i64 = 365; +const DEFAULT_CERT_VALIDITY_DAYS: i64 = 1825; #[derive(Debug, Error)] pub enum CertificateError { @@ -137,23 +137,53 @@ impl CertificateAuthority<'_> { } pub fn expiry(&self) -> Result { - get_certificate_expiry(&self.cert_der) + let CertificateInfo { not_after, .. } = parse_certificate_info(&self.cert_der)?; + Ok(not_after) } } -/// Extract the expiry date (not_after) from a certificate. -pub fn get_certificate_expiry(cert_der: &[u8]) -> Result { +pub struct CertificateInfo { + pub subject_common_name: String, + pub not_before: NaiveDateTime, + pub not_after: NaiveDateTime, +} + +pub fn parse_certificate_info(cert_der: &[u8]) -> Result { let (_, parsed) = parse_x509_certificate(cert_der) .map_err(|e| CertificateError::ParsingError(format!("Failed to parse certificate: {e}")))?; - let expiry = parsed.tbs_certificate.validity.not_after.to_datetime(); - Ok(chrono::DateTime::from_timestamp(expiry.unix_timestamp(), 0) - .ok_or_else(|| { - CertificateError::ParsingError(format!( - "Failed to convert certificate expiry {expiry} to NaiveDateTime", - )) - })? - .naive_utc()) + let subject = &parsed.tbs_certificate.subject; + + let cn = subject + .iter_common_name() + .next() + .ok_or_else(|| CertificateError::ParsingError("Common Name not found".to_string()))? + .as_str() + .map_err(|e| { + CertificateError::ParsingError(format!("Failed to parse CN as string: {e}")) + })?; + + let validity = &parsed.tbs_certificate.validity; + let not_before = validity.not_before.to_datetime(); + let not_after = validity.not_after.to_datetime(); + + Ok(CertificateInfo { + subject_common_name: cn.to_string(), + not_before: chrono::DateTime::from_timestamp(not_before.unix_timestamp(), 0) + .ok_or_else(|| { + CertificateError::ParsingError(format!( + "Failed to convert certificate not_before {not_before} to NaiveDateTime", + )) + })? + .naive_utc(), + not_after: chrono::DateTime::from_timestamp(not_after.unix_timestamp(), 0) + .ok_or_else(|| { + CertificateError::ParsingError(format!( + "Failed to convert certificate not_after {not_after} to NaiveDateTime", + )) + })? + .naive_utc(), + }) } pub struct Csr<'a> { @@ -235,6 +265,12 @@ pub fn generate_key_pair() -> Result { Ok(key_pair) } +pub fn parse_pem_certificate(pem_str: &str) -> Result, CertificateError> { + let cert_der = CertificateDer::from_pem_slice(pem_str.as_bytes()) + .map_err(|e| CertificateError::ParsingError(e.to_string()))?; + Ok(cert_der) +} + pub type DnType = rcgen::DnType; pub type RcGenKeyPair = rcgen::KeyPair; @@ -409,4 +445,15 @@ mod tests { expected_email ); } + + #[test] + fn test_parse_pem_certificate() { + // Create a CA and get its PEM representation + let ca = CertificateAuthority::new("Defguard CA", "test@example.com", 365).unwrap(); + let pem = ca.cert_pem().unwrap(); + + // Parse the PEM back to DER and ensure it matches the original + let parsed = parse_pem_certificate(&pem).unwrap(); + assert_eq!(parsed, ca.cert_der); + } } diff --git a/crates/defguard_common/Cargo.toml b/crates/defguard_common/Cargo.toml index 2cbdec8f2a..c077468181 100644 --- a/crates/defguard_common/Cargo.toml +++ b/crates/defguard_common/Cargo.toml @@ -38,6 +38,7 @@ utoipa.workspace = true uuid.workspace = true webauthn-rs.workspace = true x25519-dalek.workspace = true +url = "2.5" [dev-dependencies] matches.workspace = true diff --git a/crates/defguard_common/src/config.rs b/crates/defguard_common/src/config.rs index 1d74044bdc..75347fe015 100644 --- a/crates/defguard_common/src/config.rs +++ b/crates/defguard_common/src/config.rs @@ -15,6 +15,8 @@ use secrecy::{ExposeSecret, SecretString}; use serde::Serialize; use tonic::transport::{Certificate, ClientTlsConfig, Identity}; +use crate::db::models::Settings; + pub static SERVER_CONFIG: OnceLock = OnceLock::new(); pub fn server_config() -> &'static DefGuardConfig { @@ -80,6 +82,8 @@ pub struct DefGuardConfig { 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)] @@ -90,6 +94,7 @@ pub struct DefGuardConfig { #[arg(long, env = "DEFGUARD_WEBAUTHN_RP_ID")] pub webauthn_rp_id: Option, #[arg(long, env = "DEFGUARD_URL", value_parser = Url::parse, default_value = "http://localhost:8000")] + #[deprecated(since = "2.0.0", note = "Use Settings.defguard_url instead")] pub url: Url, #[arg(long, env = "DEFGUARD_GRPC_URL", value_parser = Url::parse, default_value = "http://localhost:50055")] @@ -115,10 +120,15 @@ pub struct DefGuardConfig { #[arg(long, env = "DEFGUARD_MFA_CODE_TIMEOUT", default_value = "60s")] #[serde(skip_serializing)] + #[deprecated( + since = "2.0.0", + note = "Use Settings.default_mfa_code_lifetime instead" + )] pub mfa_code_timeout: Duration, #[arg(long, env = "DEFGUARD_SESSION_TIMEOUT", default_value = "7d")] #[serde(skip_serializing)] + #[deprecated(since = "2.0.0", note = "Use Settings.default_authentication instead")] pub session_timeout: Duration, #[arg( @@ -219,9 +229,7 @@ pub struct InitVpnLocationArgs { impl DefGuardConfig { #[must_use] pub fn new() -> Self { - let mut config = Self::parse(); - config.validate_rp_id(); - config.validate_cookie_domain(); + let config = Self::parse(); config.validate_secret_key(); config } @@ -229,32 +237,30 @@ impl DefGuardConfig { // this is an ugly workaround to avoid `cargo test` args being captured by `clap` #[must_use] pub fn new_test_config() -> Self { - let mut config = Self::parse_from::<[_; 0], String>([]); - config.validate_rp_id(); - config.validate_cookie_domain(); - config + Self::parse_from::<[_; 0], String>([]) } - // Check if RP ID value was provided. - // If not generate it based on URL. - fn validate_rp_id(&mut self) { + /// 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_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( - self.url - .domain() + url.domain() .expect("Unable to get domain for server URL.") .to_string(), ); } } - // Check if cookie domain value was provided. - // If not, generate it based on URL. - fn validate_cookie_domain(&mut self) { + fn initialize_cookie_domain(&mut self, url: &Url) { if self.cookie_domain.is_none() { self.cookie_domain = Some( - self.url - .domain() + url.domain() .expect("Unable to get domain for server URL.") .to_string(), ); @@ -295,17 +301,6 @@ impl DefGuardConfig { } } - /// Returns configured URL with "auth/callback" appended to the path. - #[must_use] - pub fn callback_url(&self) -> Url { - let mut url = self.url.clone(); - // Append "auth/callback" to the URL. - if let Ok(mut path_segments) = url.path_segments_mut() { - path_segments.extend(&["auth", "callback"]); - } - url - } - /// Provide [`ClientTlsConfig`] from paths to cerfiticate, key, and cerfiticate authority (CA). pub fn grpc_client_tls_config(&self) -> Result, io::Error> { if self.grpc_ca.is_none() && (self.grpc_cert.is_none() || self.grpc_key.is_none()) { @@ -348,10 +343,11 @@ mod tests { fn test_generate_rp_id() { unsafe { env::remove_var("DEFGUARD_WEBAUTHN_RP_ID"); - env::set_var("DEFGUARD_URL", "https://defguard.example.com"); } - let config = DefGuardConfig::new(); + 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, @@ -371,10 +367,11 @@ mod tests { fn test_generate_cookie_domain() { unsafe { env::remove_var("DEFGUARD_COOKIE_DOMAIN"); - env::set_var("DEFGUARD_URL", "https://defguard.example.com"); } - let config = DefGuardConfig::new(); + let url = Url::parse("https://defguard.example.com").unwrap(); + let mut config = DefGuardConfig::new(); + config.initialize_cookie_domain(&url); assert_eq!( config.cookie_domain, @@ -389,25 +386,4 @@ mod tests { assert_eq!(config.cookie_domain, Some("example.com".to_string())); } - - #[test] - fn test_callback_url() { - unsafe { - env::set_var("DEFGUARD_URL", "https://defguard.example.com"); - } - let config = DefGuardConfig::new(); - assert_eq!( - config.callback_url().as_str(), - "https://defguard.example.com/auth/callback" - ); - - unsafe { - env::set_var("DEFGUARD_URL", "https://defguard.example.com:8443/path"); - } - let config = DefGuardConfig::new(); - assert_eq!( - config.callback_url().as_str(), - "https://defguard.example.com:8443/path/auth/callback" - ); - } } diff --git a/crates/defguard_common/src/db/models/oauth2token.rs b/crates/defguard_common/src/db/models/oauth2token.rs index c7bc50e521..e550882f84 100644 --- a/crates/defguard_common/src/db/models/oauth2token.rs +++ b/crates/defguard_common/src/db/models/oauth2token.rs @@ -1,7 +1,10 @@ use chrono::{TimeDelta, Utc}; use sqlx::{Error as SqlxError, PgPool, query, query_as}; -use crate::{config::server_config, db::Id, random::gen_alphanumeric}; +use crate::{ + db::{Id, models::Settings}, + random::gen_alphanumeric, +}; pub struct OAuth2Token { pub oauth2authorizedapp_id: Id, @@ -15,7 +18,8 @@ pub struct OAuth2Token { impl OAuth2Token { #[must_use] pub fn new(oauth2authorizedapp_id: Id, redirect_uri: String, scope: String) -> Self { - let timeout = server_config().session_timeout; + let settings = Settings::get_current_settings(); + let timeout = settings.authentication_timeout(); let expiration = Utc::now() + TimeDelta::seconds(timeout.as_secs() as i64); Self { oauth2authorizedapp_id, @@ -29,7 +33,8 @@ impl OAuth2Token { /// Generate new access token, scratching the old one. Changes are reflected in the database. pub async fn refresh_and_save(&mut self, pool: &PgPool) -> Result<(), SqlxError> { - let timeout = server_config().session_timeout; + let settings = Settings::get_current_settings(); + let timeout = settings.authentication_timeout(); let new_access_token = gen_alphanumeric(24); let new_refresh_token = gen_alphanumeric(24); let expiration = Utc::now() + TimeDelta::seconds(timeout.as_secs() as i64); diff --git a/crates/defguard_common/src/db/models/session.rs b/crates/defguard_common/src/db/models/session.rs index 6a8de55ee7..7bc1510cc1 100644 --- a/crates/defguard_common/src/db/models/session.rs +++ b/crates/defguard_common/src/db/models/session.rs @@ -2,7 +2,10 @@ use chrono::{NaiveDateTime, TimeDelta, Utc}; use sqlx::{Error as SqlxError, PgExecutor, PgPool, Type, query, query_as}; use webauthn_rs::prelude::{PasskeyAuthentication, PasskeyRegistration}; -use crate::{config::server_config, db::Id, random::gen_alphanumeric}; +use crate::{ + db::{Id, models::Settings}, + random::gen_alphanumeric, +}; #[derive(Clone, PartialEq, Type)] #[repr(i16)] @@ -36,7 +39,8 @@ impl Session { device_info: Option, ) -> Self { let now = Utc::now(); - let timeout = server_config().session_timeout; + let settings = Settings::get_current_settings(); + let timeout = settings.authentication_timeout(); Self { id: gen_alphanumeric(24), user_id, diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index d2f0d5bcb6..edaab31866 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}; +use std::{collections::HashMap, fmt, time::Duration}; use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; @@ -6,6 +6,7 @@ use sqlx::{PgExecutor, PgPool, Type, query, query_as}; use struct_patch::Patch; use thiserror::Error; use tracing::{debug, info, warn}; +use url::Url; use utoipa::ToSchema; use uuid::Uuid; @@ -148,6 +149,11 @@ pub struct Settings { pub ca_key_der: Option>, pub ca_cert_der: Option>, pub ca_expiry: Option, + pub initial_setup_completed: bool, + pub defguard_url: String, + pub default_admin_group_name: String, + pub authentication_period_days: i32, + pub mfa_code_timeout_seconds: i32, } // Implement manually to avoid exposing the license key. @@ -225,6 +231,15 @@ impl fmt::Debug for Settings { "gateway_disconnect_notifications_reconnect_notification_enabled", &self.gateway_disconnect_notifications_reconnect_notification_enabled, ) + .field("ca_expiry", &self.ca_expiry) + .field("initial_setup_completed", &self.initial_setup_completed) + .field("defguard_url", &self.defguard_url) + .field("default_admin_group_name", &self.default_admin_group_name) + .field( + "authentication_period_days", + &self.authentication_period_days, + ) + .field("mfa_code_timeout_seconds", &self.mfa_code_timeout_seconds) .finish_non_exhaustive() } } @@ -255,7 +270,8 @@ impl Settings { 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 \ + ca_key_der, ca_cert_der, ca_expiry, initial_setup_completed, \ + defguard_url, default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds \ FROM \"settings\" WHERE id = 1", ) .fetch_optional(executor) @@ -335,7 +351,12 @@ impl Settings { openid_username_handling = $48, \ ca_key_der = $49, \ ca_cert_der = $50, \ - ca_expiry = $51 \ + ca_expiry = $51, \ + initial_setup_completed = $52, \ + defguard_url = $53, \ + default_admin_group_name = $54, \ + authentication_period_days = $55, \ + mfa_code_timeout_seconds = $56 \ WHERE id = 1", self.openid_enabled, self.wireguard_enabled, @@ -387,7 +408,12 @@ impl Settings { &self.openid_username_handling as &OpenIdUsernameHandling, &self.ca_key_der as &Option>, &self.ca_cert_der as &Option>, - &self.ca_expiry as &Option + &self.ca_expiry as &Option, + self.initial_setup_completed, + self.defguard_url, + self.default_admin_group_name, + self.authentication_period_days, + self.mfa_code_timeout_seconds ) .execute(executor) .await?; @@ -446,6 +472,27 @@ impl Settings { .as_deref() .is_none_or(|rdn| rdn.is_empty() || Some(rdn) == self.ldap_username_attr.as_deref()) } + + /// Get the DefGuard URL from the current settings + pub fn url() -> Result { + let settings = Settings::get_current_settings(); + Url::parse(&settings.defguard_url) + } + + /// Returns configured URL with "auth/callback" appended to the path. + pub fn callback_url(&self) -> Result { + let mut url = Url::parse(&self.defguard_url)?; + // Append "auth/callback" to the URL. + if let Ok(mut path_segments) = url.path_segments_mut() { + path_segments.extend(&["auth", "callback"]); + } + Ok(url) + } + + #[must_use] + pub fn authentication_timeout(&self) -> Duration { + Duration::from_secs(self.authentication_period_days as u64 * 24 * 3600) + } } #[derive(Serialize)] @@ -457,6 +504,7 @@ pub struct SettingsEssentials { pub webhooks_enabled: bool, pub worker_enabled: bool, pub openid_enabled: bool, + pub initial_setup_completed: bool, } impl SettingsEssentials { @@ -467,7 +515,7 @@ impl SettingsEssentials { query_as!( SettingsEssentials, "SELECT instance_name, main_logo_url, nav_logo_url, wireguard_enabled, \ - webhooks_enabled, worker_enabled, openid_enabled \ + webhooks_enabled, worker_enabled, openid_enabled, initial_setup_completed \ FROM settings WHERE id = 1" ) .fetch_one(executor) @@ -485,6 +533,7 @@ impl From for SettingsEssentials { nav_logo_url: settings.nav_logo_url, instance_name: settings.instance_name, main_logo_url: settings.main_logo_url, + initial_setup_completed: settings.initial_setup_completed, } } } @@ -570,4 +619,22 @@ mod test { assert!(!debug.contains("license")); assert!(!debug.contains(key)); } + + #[test] + fn test_callback_url() { + let mut s = Settings { + defguard_url: "https://defguard.example.com".into(), + ..Default::default() + }; + assert_eq!( + s.callback_url().unwrap().as_str(), + "https://defguard.example.com/auth/callback" + ); + + s.defguard_url = "https://defguard.example.com:8443/path".into(); + assert_eq!( + s.callback_url().unwrap().as_str(), + "https://defguard.example.com:8443/path/auth/callback" + ); + } } diff --git a/crates/defguard_common/src/db/models/user.rs b/crates/defguard_common/src/db/models/user.rs index 2dc5a5a133..0baa1091bb 100644 --- a/crates/defguard_common/src/db/models/user.rs +++ b/crates/defguard_common/src/db/models/user.rs @@ -1,4 +1,7 @@ -use std::{fmt, time::SystemTime}; +use std::{ + fmt, + time::{Duration, SystemTime}, +}; use argon2::{ Argon2, @@ -25,13 +28,12 @@ use utoipa::ToSchema; use super::{ device::{Device, DeviceType, UserDevice}, - group::{Group, Permission}, + group::Group, }; use crate::{ - config::server_config, db::{ Id, NoId, - models::{MFAInfo, Session, WebAuthn}, + models::{MFAInfo, Session, Settings, WebAuthn, group::Permission}, }, random::{gen_alphanumeric, gen_totp_secret}, types::user_info::OAuth2AuthorizedAppInfo, @@ -681,7 +683,8 @@ impl User { /// NOTE: This code will be valid for two time frames. See comment for verify_email_mfa_code(). pub fn generate_email_mfa_code(&self) -> Result { if let Some(email_mfa_secret) = &self.email_mfa_secret { - let timeout = &server_config().mfa_code_timeout; + let settings = Settings::get_current_settings(); + let timeout = Duration::from_secs(settings.mfa_code_timeout_seconds as u64); if let Ok(timestamp) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { let code = totp_custom::( timeout.as_secs(), @@ -717,7 +720,8 @@ impl User { #[must_use] pub fn verify_email_mfa_code(&self, code: &str) -> bool { if let Some(email_mfa_secret) = &self.email_mfa_secret { - let timeout = server_config().mfa_code_timeout.as_secs(); + let settings = Settings::get_current_settings(); + let timeout = settings.mfa_code_timeout_seconds as u64; if let Ok(timestamp) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { let expected_code = totp_custom::( timeout, diff --git a/crates/defguard_common/src/db/models/wireguard.rs b/crates/defguard_common/src/db/models/wireguard.rs index c76d168450..7ba2240612 100644 --- a/crates/defguard_common/src/db/models/wireguard.rs +++ b/crates/defguard_common/src/db/models/wireguard.rs @@ -1181,11 +1181,11 @@ pub async fn networks_stats( mod test { use std::str::FromStr; - use crate::db::setup_pool; use matches::assert_matches; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::*; + use crate::db::setup_pool; // FIXME(mwojcik): rewrite for new stats implementation // #[sqlx::test] diff --git a/crates/defguard_common/src/types/mod.rs b/crates/defguard_common/src/types/mod.rs index e207a5c568..04247caddc 100644 --- a/crates/defguard_common/src/types/mod.rs +++ b/crates/defguard_common/src/types/mod.rs @@ -1,3 +1,5 @@ pub mod group_diff; pub mod proxy; pub mod user_info; + +pub type UrlParseError = url::ParseError; diff --git a/crates/defguard_core/src/appstate.rs b/crates/defguard_core/src/appstate.rs index 326b74ab4b..f17829af29 100644 --- a/crates/defguard_core/src/appstate.rs +++ b/crates/defguard_core/src/appstate.rs @@ -2,7 +2,9 @@ use std::sync::{Arc, Mutex, RwLock}; use axum::extract::FromRef; use axum_extra::extract::cookie::Key; -use defguard_common::{config::server_config, types::proxy::ProxyControlMessage}; +use defguard_common::{ + config::server_config, db::models::Settings, types::proxy::ProxyControlMessage, +}; use defguard_mail::Mail; use reqwest::Client; use secrecy::ExposeSecret; @@ -122,12 +124,13 @@ impl AppState { spawn(Self::handle_triggers(pool.clone(), rx)); let config = server_config(); + let url = Settings::url().expect("Invalid Defguard URL configuration"); let webauthn_builder = WebauthnBuilder::new( config .webauthn_rp_id .as_ref() .expect("Webauth RP ID configuration is required"), - &config.url, + &url, ) .expect("Invalid WebAuthn configuration"); let webauthn = Arc::new( diff --git a/crates/defguard_core/src/auth/mod.rs b/crates/defguard_core/src/auth/mod.rs index 8575ccf38a..7c71d9208c 100644 --- a/crates/defguard_core/src/auth/mod.rs +++ b/crates/defguard_core/src/auth/mod.rs @@ -1,7 +1,8 @@ pub mod failed_login; use axum::{ - extract::{FromRef, FromRequestParts, OptionalFromRequestParts}, + Extension, + extract::{FromRequestParts, OptionalFromRequestParts}, http::request::Parts, }; use axum_client_ip::InsecureClientIp; @@ -13,15 +14,15 @@ use axum_extra::{ use defguard_common::db::{ Id, models::{ - OAuth2Token, Session, SessionState, + OAuth2Token, Session, SessionState, Settings, group::{Group, Permission}, oauth2client::OAuth2Client, user::User, }, }; +use sqlx::PgPool; use crate::{ - appstate::AppState, enterprise::{db::models::api_tokens::ApiToken, is_business_license_active}, error::WebError, handlers::SESSION_COOKIE_NAME, @@ -32,12 +33,12 @@ pub struct SessionExtractor(pub Session); impl FromRequestParts for SessionExtractor where S: Send + Sync, - AppState: FromRef, { type Rejection = WebError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let appstate = AppState::from_ref(state); + // let appstate = AppState::from_ref(state); + let pool = extract_pool(parts, state).await?; // first try to authenticate by API token if one is found in header if is_business_license_active() { @@ -51,7 +52,7 @@ where if let Some(header) = maybe_auth_header { let token_string = header.token(); debug!("Trying to authorize request using API token: {token_string}"); - return match ApiToken::try_find_by_auth_token(&appstate.pool, token_string).await { + return match ApiToken::try_find_by_auth_token(&pool, token_string).await { Ok(Some(api_token)) => { // create a dummy session and don't store it in the DB // since each request needs to be authorized anyway @@ -77,10 +78,10 @@ where let Ok(cookies) = CookieJar::from_request_parts(parts, state).await; if let Some(session_cookie) = cookies.get(SESSION_COOKIE_NAME) { return { - match Session::find_by_id(&appstate.pool, session_cookie.value()).await { + match Session::find_by_id(&pool, session_cookie.value()).await { Ok(Some(session)) => { if session.expired() { - let _result = session.delete(&appstate.pool).await; + let _result = session.delete(&pool).await; Err(WebError::Authorization("Session expired".into())) } else { Ok(Self(session)) @@ -108,7 +109,7 @@ pub struct SessionInfo { impl SessionInfo { #[must_use] - pub fn new(session: Session, user: User, is_admin: bool) -> Self { + pub const fn new(session: Session, user: User, is_admin: bool) -> Self { Self { session, user, @@ -127,14 +128,13 @@ impl SessionInfo { impl FromRequestParts for SessionInfo where S: Send + Sync, - AppState: FromRef, { type Rejection = WebError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let session = SessionExtractor::from_request_parts(parts, state).await?.0; - let appstate = AppState::from_ref(state); - let user = User::find_by_id(&appstate.pool, session.user_id).await?; + let pool = extract_pool(parts, state).await?; + let user = User::find_by_id(&pool, session.user_id).await?; if let Some(user) = user { if user.mfa_enabled @@ -143,10 +143,10 @@ where { return Err(WebError::Authorization("MFA not verified".into())); } - let Ok(groups) = user.member_of(&appstate.pool).await else { + let Ok(groups) = user.member_of(&pool).await else { return Err(WebError::DbError("cannot fetch groups".into())); }; - let is_admin = user.is_admin(&appstate.pool).await?; + let is_admin = user.is_admin(&pool).await?; // non-admin users are not allowed to use token auth if !is_admin && session.state == SessionState::ApiTokenVerified { @@ -156,7 +156,7 @@ where } // Store session info into request extensions so future extractors can use it - let session_info = SessionInfo { + let session_info = Self { session, user, is_admin, @@ -178,7 +178,6 @@ macro_rules! role { impl FromRequestParts for $name where S: Send + Sync, - AppState: FromRef, { type Rejection = WebError; @@ -190,10 +189,10 @@ macro_rules! role { if !session_info.user.is_active { return Err(WebError::Forbidden("user is disabled".into())); } - let appstate = AppState::from_ref(state); + let pool = extract_pool(parts, state).await?; $( let groups_with_permission = Group::find_by_permission( - &appstate.pool, + &pool, $permission, ).await?; let group_names = groups_with_permission.iter().map(|group| group.name.as_str()).collect::>(); @@ -209,6 +208,51 @@ macro_rules! role { role!(AdminRole, Permission::IsAdmin); +/// Special role that allows access if the user is admin or if the initial setup is not yet completed. +pub struct AdminOrSetupRole; + +impl FromRequestParts for AdminOrSetupRole +where + S: Send + Sync, +{ + type Rejection = WebError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let settings = Settings::get_current_settings(); + if !settings.initial_setup_completed { + return Ok(Self {}); + } + let session_info = SessionInfo::from_request_parts(parts, state).await?; + if !session_info.user.is_active { + return Err(WebError::Forbidden("user is disabled".into())); + } + let pool = extract_pool(parts, state).await?; + let groups_with_permission = Group::find_by_permission(&pool, Permission::IsAdmin).await?; + let group_names = groups_with_permission + .iter() + .map(|group| group.name.as_str()) + .collect::>(); + if session_info.contains_any_group(&group_names) { + return Ok(Self {}); + } + Err(WebError::Forbidden("access denied".into())) + } +} + +async fn extract_pool(parts: &mut Parts, state: &S) -> Result +where + S: Send + Sync, +{ + let Extension(pool) = + as FromRequestParts>::from_request_parts(parts, state) + .await + .map_err(|err| { + error!("Failed to extract database pool: {err:?}"); + WebError::ObjectNotFound("Database pool not found".into()) + })?; + Ok(pool) +} + #[derive(Debug)] pub(crate) struct UserClaims { pub email: Option, diff --git a/crates/defguard_core/src/db/models/enrollment.rs b/crates/defguard_core/src/db/models/enrollment.rs index 129610fbea..62b90100b1 100644 --- a/crates/defguard_core/src/db/models/enrollment.rs +++ b/crates/defguard_core/src/db/models/enrollment.rs @@ -1,12 +1,12 @@ use chrono::{NaiveDateTime, TimeDelta, Utc}; use defguard_common::{ VERSION, - config::server_config, db::{ Id, models::{Settings, settings::defaults::WELCOME_EMAIL_SUBJECT, user::User}, }, random::gen_alphanumeric, + types::UrlParseError, }; use defguard_mail::{ Mail, @@ -51,11 +51,14 @@ pub enum TokenError { TemplateErrorInternal(#[from] tera::Error), #[error(transparent)] TemplateError(#[from] TemplateError), + #[error(transparent)] + UrlParseError(#[from] UrlParseError), } impl From for Status { fn from(err: TokenError) -> Self { error!("{err}"); + let unexpected_err_msg = format!("Unexpected error: {err}"); let (code, msg) = match err { TokenError::DbError(_) | TokenError::AdminNotFound @@ -65,7 +68,8 @@ impl From for Status { | TokenError::WelcomeMsgNotConfigured | TokenError::WelcomeEmailNotConfigured | TokenError::TemplateError(_) - | TokenError::TemplateErrorInternal(_) => (Code::Internal, "unexpected error"), + | TokenError::UrlParseError(_) + | TokenError::TemplateErrorInternal(_) => (Code::Internal, unexpected_err_msg.as_str()), TokenError::NotFound | TokenError::SessionExpired | TokenError::TokenUsed => { (Code::Unauthenticated, "invalid token") } @@ -327,12 +331,12 @@ impl Token { let user = self.fetch_user(&mut *transaction).await?; let admin = self.fetch_admin(&mut *transaction).await?; - + let url = Settings::url()?; let mut context = Context::new(); context.insert("first_name", &user.first_name); context.insert("last_name", &user.last_name); context.insert("username", &user.username); - context.insert("defguard_url", &server_config().url); + context.insert("defguard_url", &url); context.insert("defguard_version", &VERSION); if let Some(admin) = admin { diff --git a/crates/defguard_core/src/enterprise/handlers/openid_login.rs b/crates/defguard_core/src/enterprise/handlers/openid_login.rs index 28c82888cc..e40a5bd4a9 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_login.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_login.rs @@ -472,7 +472,8 @@ pub(crate) async fn get_auth_info( }; let config = server_config(); - let (_client_id, client) = make_oidc_client(config.callback_url(), &provider).await?; + let settings = Settings::get_current_settings(); + let (_client_id, client) = make_oidc_client(settings.callback_url()?, &provider).await?; // Generate the redirect URL and the values needed later for callback authenticity verification let mut authorize_url_builder = client @@ -564,11 +565,12 @@ pub(crate) async fn auth_callback( .remove(Cookie::from(CSRF_COOKIE_NAME)); let config = server_config(); + let settings = Settings::get_current_settings(); let mut user = user_from_claims( &appstate.pool, Nonce::new(cookie_nonce), payload.code, - config.callback_url(), + settings.callback_url()?, ) .await?; diff --git a/crates/defguard_core/src/error.rs b/crates/defguard_core/src/error.rs index bb2976c11c..09c2d450cb 100644 --- a/crates/defguard_core/src/error.rs +++ b/crates/defguard_core/src/error.rs @@ -1,7 +1,10 @@ use axum::http::StatusCode; -use defguard_common::db::models::{ - DeviceError, ModelError, WireguardNetworkError, settings::SettingsValidationError, - user::UserError, +use defguard_common::{ + db::models::{ + DeviceError, ModelError, WireguardNetworkError, settings::SettingsValidationError, + user::UserError, + }, + types::UrlParseError, }; use defguard_mail::templates::TemplateError; use thiserror::Error; @@ -79,6 +82,9 @@ pub enum WebError { #[error(transparent)] #[schema(value_type=Object)] CertificateError(#[from] defguard_certs::CertificateError), + #[error(transparent)] + #[schema(value_type=Object)] + UrlParseError(#[from] UrlParseError), } impl From for WebError { @@ -149,6 +155,7 @@ impl From for WebError { | TokenError::WelcomeMsgNotConfigured | TokenError::WelcomeEmailNotConfigured | TokenError::TemplateError(_) + | TokenError::UrlParseError(_) | TokenError::TemplateErrorInternal(_) => { WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) } diff --git a/crates/defguard_core/src/grpc/auth.rs b/crates/defguard_core/src/grpc/auth.rs index b64b7f0f57..6f61986355 100644 --- a/crates/defguard_core/src/grpc/auth.rs +++ b/crates/defguard_core/src/grpc/auth.rs @@ -2,17 +2,14 @@ use std::sync::{Arc, Mutex}; use defguard_common::{ auth::claims::{Claims, ClaimsType}, - db::models::User, + db::models::{Settings, User}, }; use defguard_proto::auth::{AuthenticateRequest, AuthenticateResponse, auth_service_server}; use jsonwebtoken::errors::Error as JWTError; use sqlx::PgPool; use tonic::{Request, Response, Status}; -use crate::{ - auth::failed_login::{FailedLoginMap, check_failed_logins, log_failed_login_attempt}, - server_config, -}; +use crate::auth::failed_login::{FailedLoginMap, check_failed_logins, log_failed_login_attempt}; pub struct AuthServer { pool: PgPool, @@ -30,7 +27,8 @@ impl AuthServer { /// Creates JWT token for specified user fn create_jwt(uid: &str) -> Result { - let timeout = server_config().session_timeout; + let settings = Settings::get_current_settings(); + let timeout = settings.authentication_timeout(); Claims::new( ClaimsType::Auth, uid.into(), diff --git a/crates/defguard_core/src/grpc/mod.rs b/crates/defguard_core/src/grpc/mod.rs index db9308c44b..4e17811cd0 100644 --- a/crates/defguard_core/src/grpc/mod.rs +++ b/crates/defguard_core/src/grpc/mod.rs @@ -5,17 +5,17 @@ use std::{ time::{Duration, Instant}, }; +use defguard_common::{ + auth::claims::ClaimsType, + db::{Id, models::Settings}, + types::UrlParseError, +}; use reqwest::Url; use serde::Serialize; use sqlx::PgPool; use tokio::sync::mpsc::UnboundedSender; use tonic::transport::{Identity, Server, ServerTlsConfig, server::Router}; -use defguard_common::{ - auth::claims::ClaimsType, - db::{Id, models::Settings}, -}; - use self::{auth::AuthServer, interceptor::JwtInterceptor, worker::WorkerServer}; use crate::{ auth::failed_login::FailedLoginMap, @@ -175,22 +175,23 @@ impl InstanceInfo { username: S, enterprise_settings: &EnterpriseSettings, openid_provider: Option>, - ) -> Self { + ) -> Result { let config = server_config(); let openid_display_name = openid_provider .as_ref() .map(|provider| provider.display_name.clone()) .unwrap_or_default(); - InstanceInfo { + let url = Settings::url()?; + Ok(InstanceInfo { id: settings.uuid, name: settings.instance_name, - url: config.url.clone(), + url: url.clone(), proxy_url: config.enrollment_url.clone(), username: username.into(), client_traffic_policy: enterprise_settings.client_traffic_policy, enterprise_enabled: is_business_license_active(), openid_display_name, - } + }) } } diff --git a/crates/defguard_core/src/grpc/utils.rs b/crates/defguard_core/src/grpc/utils.rs index 67d851a0ac..64e1e2e619 100644 --- a/crates/defguard_core/src/grpc/utils.rs +++ b/crates/defguard_core/src/grpc/utils.rs @@ -231,18 +231,21 @@ pub async fn build_device_config_response( user.username, user.id, device.name, device.id ); + let instance_info = InstanceInfo::new( + settings, + &user.username, + &enterprise_settings, + openid_provider, + ) + .map_err(|err| { + error!("Failed to build instance info: {err}"); + Status::internal(format!("unexpected error: {err}")) + })?; + Ok(DeviceConfigResponse { device: Some(device.into()), configs, - instance: Some( - InstanceInfo::new( - settings, - &user.username, - &enterprise_settings, - openid_provider, - ) - .into(), - ), + instance: Some(instance_info.into()), token, }) } diff --git a/crates/defguard_core/src/handlers/app_info.rs b/crates/defguard_core/src/handlers/app_info.rs index 678e82ca4d..861ff821cc 100644 --- a/crates/defguard_core/src/handlers/app_info.rs +++ b/crates/defguard_core/src/handlers/app_info.rs @@ -47,6 +47,7 @@ pub struct AppInfo { license_info: LicenseInfo, ldap_info: LdapInfo, external_openid_enabled: bool, + initial_setup_completed: bool, } pub(crate) async fn get_app_info( @@ -81,6 +82,7 @@ pub(crate) async fn get_app_info( ad: settings.ldap_uses_ad, }, external_openid_enabled, + initial_setup_completed: settings.initial_setup_completed, }; Ok(ApiResponse::json(res, StatusCode::OK)) diff --git a/crates/defguard_core/src/handlers/ca.rs b/crates/defguard_core/src/handlers/ca.rs deleted file mode 100644 index 59b79c8683..0000000000 --- a/crates/defguard_core/src/handlers/ca.rs +++ /dev/null @@ -1,39 +0,0 @@ -use axum::{Json, extract::State}; -use defguard_common::db::models::{Settings, settings::update_current_settings}; -use reqwest::StatusCode; - -use crate::{ - appstate::AppState, - auth::AdminRole, - handlers::{ApiResponse, ApiResult}, -}; - -#[derive(Deserialize, Serialize, Debug)] -pub struct CreateCA { - common_name: String, - email: String, - validity_period_days: u32, -} - -pub async fn create_ca( - _role: AdminRole, - State(appstate): State, - Json(ca_info): Json, -) -> ApiResult { - let mut settings = Settings::get_current_settings(); - let ca = defguard_certs::CertificateAuthority::new( - &ca_info.common_name, - &ca_info.email, - ca_info.validity_period_days, - )?; - - let (cert_der, key_der) = (ca.cert_der().to_vec(), ca.key_pair_der().to_vec()); - - settings.ca_cert_der = Some(cert_der); - settings.ca_key_der = Some(key_der); - settings.ca_expiry = Some(ca.expiry()?); - - update_current_settings(&appstate.pool, settings).await?; - - Ok(ApiResponse::with_status(StatusCode::CREATED)) -} diff --git a/crates/defguard_core/src/handlers/component_setup.rs b/crates/defguard_core/src/handlers/component_setup.rs index 050c6689e1..e004c700c1 100644 --- a/crates/defguard_core/src/handlers/component_setup.rs +++ b/crates/defguard_core/src/handlers/component_setup.rs @@ -1,10 +1,11 @@ use std::{convert::Infallible, time::Duration}; use axum::{ - extract::{Path, Query, State}, + Extension, + extract::{Path, Query}, response::sse::{Event, KeepAlive, Sse}, }; -use defguard_certs::{der_to_pem, get_certificate_expiry}; +use defguard_certs::{der_to_pem, parse_certificate_info}; use defguard_common::{ VERSION, auth::claims::Claims, @@ -22,6 +23,8 @@ use defguard_version::{Version, client::ClientVersionInterceptor}; use futures::Stream; use reqwest::Url; use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use tokio::sync::mpsc::Sender; use tokio_stream::StreamExt; use tonic::{ Request, Status, @@ -30,8 +33,7 @@ use tonic::{ }; use crate::{ - AppState, - auth::AdminRole, + auth::AdminOrSetupRole, version::{MIN_GATEWAY_VERSION, MIN_PROXY_VERSION}, }; @@ -176,9 +178,10 @@ impl SetupFlow { /// It uses Server-Sent Events (SSE) to stream progress updates back to the frontend in real-time. // This is a get request, since HTML's EventSource only supports GET pub async fn setup_proxy_tls_stream( - _admin: AdminRole, - State(appstate): State, + _admin: AdminOrSetupRole, Query(request): Query, + Extension(pool): Extension, + proxy_control_tx: Option>>, ) -> Sse>> { let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel::(); @@ -190,7 +193,7 @@ pub async fn setup_proxy_tls_stream( flow.step(SetupStep::CheckingConfiguration) ); - match Proxy::find_by_address_port(&appstate.pool, &request.ip_or_domain, i32::from(request.grpc_port)).await { + match Proxy::find_by_address_port(&pool, &request.ip_or_domain, i32::from(request.grpc_port)).await { Ok(Some(proxy)) => { yield Ok(flow.error(&format!("An edge Proxy with address {}:{} is already registered with name \"{}\".", request.ip_or_domain, request.grpc_port, proxy.name))); return; @@ -510,7 +513,10 @@ pub async fn setup_proxy_tls_stream( debug!("Certificate successfully delivered to edge proxy"); - let expiry = match get_certificate_expiry(cert.der()) { + let defguard_certs::CertificateInfo { + not_after: expiry, + .. + } = match parse_certificate_info(cert.der()) { Ok(dt) => { dt }, @@ -533,7 +539,7 @@ pub async fn setup_proxy_tls_stream( proxy.certificate_expiry = Some(expiry); - let proxy = match proxy.save(&appstate.pool).await { + let proxy = match proxy.save(&pool).await { Ok(p) => p, Err(err) => { yield Ok(flow.error(&format!("Failed to save proxy to database: {err}"))); @@ -543,9 +549,13 @@ pub async fn setup_proxy_tls_stream( debug!("Edge proxy '{}' registered successfully with ID: {}", request.common_name, proxy.id); debug!("Establishing connection to newly configured edge proxy"); - if let Err(err) = appstate.proxy_control_tx.send(ProxyControlMessage::StartConnection(proxy.id)).await { - yield Ok(flow.error(&format!("Failed send message to connect to proxy after setup: {err}"))); - return; + if let Some(proxy_control_tx) = proxy_control_tx { + if let Err(err) = proxy_control_tx.send(ProxyControlMessage::StartConnection(proxy.id)).await { + yield Ok(flow.error(&format!("Failed send message to connect to proxy after setup: {err}"))); + return; + } + } else { + debug!("Proxy control channel not available; skipping connection initiation"); } debug!("Edge proxy setup completed successfully"); @@ -561,10 +571,10 @@ pub async fn setup_proxy_tls_stream( /// It uses Server-Sent Events (SSE) to stream progress updates back to the frontend in real-time. // This is a get request, since HTML's EventSource only supports GET pub async fn setup_gateway_tls_stream( - _admin: AdminRole, - State(appstate): State, + _admin: AdminOrSetupRole, Query(request): Query, Path(network_id): Path, + Extension(pool): Extension, ) -> Sse>> { let (log_tx, log_rx) = tokio::sync::mpsc::unbounded_channel::(); @@ -579,7 +589,7 @@ pub async fn setup_gateway_tls_stream( let url_str = format!("http://{}:{}", request.ip_or_domain, request.grpc_port); - match Gateway::find_by_url(&appstate.pool, &url_str).await { + match Gateway::find_by_url(&pool, &url_str).await { Ok(Some(gateway)) => { yield Ok(flow.error(&format!("A Gateway with url {} is already registered with hostname \"{:?}\".", url_str, gateway.hostname))); return; @@ -896,7 +906,10 @@ pub async fn setup_gateway_tls_stream( debug!("Certificate successfully delivered to Gateway"); - let expiry = match get_certificate_expiry(cert.der()) { + let defguard_certs::CertificateInfo { + not_after: expiry, + .. + } = match parse_certificate_info(cert.der()) { Ok(dt) => { dt }, @@ -917,7 +930,7 @@ pub async fn setup_gateway_tls_stream( gateway.has_certificate = true; gateway.certificate_expiry = Some(expiry); - if let Err(err) = gateway.save(&appstate.pool).await { + if let Err(err) = gateway.save(&pool).await { yield Ok(flow.error(&format!("Failed to save Gateway to database: {err}"))); return; } diff --git a/crates/defguard_core/src/handlers/forward_auth.rs b/crates/defguard_core/src/handlers/forward_auth.rs index 9799b79f80..5dc93bfff5 100644 --- a/crates/defguard_core/src/handlers/forward_auth.rs +++ b/crates/defguard_core/src/handlers/forward_auth.rs @@ -4,11 +4,11 @@ use axum::{ response::{IntoResponse, Redirect, Response}, }; use axum_extra::extract::cookie::CookieJar; -use defguard_common::db::models::Session; +use defguard_common::db::models::{Session, Settings}; use reqwest::Url; use super::SESSION_COOKIE_NAME; -use crate::{appstate::AppState, error::WebError, server_config}; +use crate::{appstate::AppState, error::WebError}; // Header names static FORWARDED_HOST: &str = "x-forwarded-host"; @@ -90,7 +90,7 @@ pub async fn forward_auth( } fn login_redirect(headers: ForwardAuthHeaders) -> Result { - let server_url = &server_config().url; // prepare redirect URL for login page + let server_url = Settings::url()?; let mut location = server_url.join("/auth/login").map_err(|err| { error!("Failed to prepare redirect URL: {err}"); WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index 092eb60568..cc2223ec6d 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -30,8 +30,7 @@ use crate::{ pub(crate) mod activity_log; pub(crate) mod app_info; pub(crate) mod auth; -pub mod ca; -pub(crate) mod component_setup; +pub mod component_setup; pub(crate) mod forward_auth; pub(crate) mod group; pub mod mail; @@ -40,7 +39,7 @@ pub mod openid_clients; pub mod openid_flow; pub(crate) mod pagination; pub mod proxy; -pub(crate) mod settings; +pub mod settings; pub(crate) mod ssh_authorized_keys; pub(crate) mod support; pub(crate) mod updates; @@ -52,6 +51,7 @@ pub(crate) mod yubikey; pub(crate) static SESSION_COOKIE_NAME: &str = "defguard_session"; 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; #[derive(Default, ToSchema)] @@ -113,6 +113,7 @@ impl From for ApiResponse { | WebError::FirewallError(_) | WebError::ApiEventChannelError(_) | WebError::ActivityLogStreamError(_) + | WebError::UrlParseError(_) | WebError::CertificateError(_) => { error!("{web_error}"); ApiResponse::new( diff --git a/crates/defguard_core/src/handlers/openid_flow.rs b/crates/defguard_core/src/handlers/openid_flow.rs index 19e5e303b3..8c8bcba45a 100644 --- a/crates/defguard_core/src/handlers/openid_flow.rs +++ b/crates/defguard_core/src/handlers/openid_flow.rs @@ -18,7 +18,7 @@ use chrono::Utc; use defguard_common::db::{ Id, NoId, models::{ - AuthCode, OAuth2AuthorizedApp, OAuth2Token, Session, SessionState, User, + AuthCode, OAuth2AuthorizedApp, OAuth2Token, Session, SessionState, Settings, User, oauth2client::OAuth2Client, }, }; @@ -42,14 +42,15 @@ use serde::{ ser::{Serialize, Serializer}, }; use sqlx::PgPool; -use time::Duration; use super::{ApiResponse, ApiResult, SESSION_COOKIE_NAME}; use crate::{ appstate::AppState, auth::{SessionInfo, UserClaims}, error::WebError, - handlers::{SIGN_IN_COOKIE_NAME, mail::send_new_device_ocid_login_email}, + handlers::{ + SIGN_IN_COOKIE_MAX_AGE, SIGN_IN_COOKIE_NAME, mail::send_new_device_ocid_login_email, + }, server_config, }; @@ -346,9 +347,13 @@ fn redirect_to>( fn login_redirect( data: &AuthenticationRequest, private_cookies: PrivateCookieJar, -) -> (StatusCode, HeaderMap, PrivateCookieJar) { +) -> Result<(StatusCode, HeaderMap, PrivateCookieJar), WebError> { let config = server_config(); - let base_url = config.url.join("api/v1/oauth/authorize").unwrap(); + let url = Settings::url()?; + let base_url = url.join("/api/v1/oauth/authorize").map_err(|err| { + error!("Failed to prepare redirect URL: {err}"); + WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) + })?; let cookie = Cookie::build(( SIGN_IN_COOKIE_NAME, format!( @@ -366,8 +371,8 @@ fn login_redirect( .secure(!config.cookie_insecure) .same_site(SameSite::Lax) .http_only(true) - .max_age(Duration::minutes(10)); - redirect_to("/login", private_cookies.add(cookie)) + .max_age(SIGN_IN_COOKIE_MAX_AGE); + Ok(redirect_to("/login", private_cookies.add(cookie))) } /// Authorization Endpoint @@ -418,7 +423,7 @@ pub async fn authorization( session.id, session.user_id ); let _result = session.delete(&appstate.pool).await; - Ok(login_redirect(&data, private_cookies)) + Ok(login_redirect(&data, private_cookies)?) } else { let mut user = User::find_by_id(&appstate.pool, session.user_id) @@ -439,7 +444,7 @@ pub async fn authorization( "MFA not verified for user id {}, redirecting to login", session.user_id ); - return Ok(login_redirect(&data, private_cookies)); + return login_redirect(&data, private_cookies); } // If session is present check if app is in user authorized @@ -489,12 +494,12 @@ pub async fn authorization( "Session {} not found, redirecting to login page", session_cookie.value() ); - Ok(login_redirect(&data, private_cookies)) + Ok(login_redirect(&data, private_cookies)?) } // If no session cookie provided redirect to login } else { info!("Session cookie not provided, redirecting to login page"); - Ok(login_redirect(&data, private_cookies)) + Ok(login_redirect(&data, private_cookies)?) }; } } @@ -520,7 +525,7 @@ pub async fn authorization( Url::parse(&data.redirect_uri).map_err(|_| WebError::Http(StatusCode::BAD_REQUEST))? } else { // Don't allow open redirects (DG25-17) - server_config().url.clone() + Settings::url()? }; { let mut query_pairs = url.query_pairs_mut(); @@ -627,10 +632,10 @@ pub async fn secure_authorization( } let mut url = if is_redirect_allowed { - Url::parse(&data.redirect_uri).map_err(|_| WebError::Http(StatusCode::BAD_REQUEST))? + Url::parse(&data.redirect_uri)? } else { // Don't allow open redirects (DG25-17) - server_config().url.clone() + Settings::url()? }; { let mut query_pairs = url.query_pairs_mut(); @@ -715,7 +720,8 @@ impl TokenRequest { debug!("Scope contains openid, issuing JWT ID token"); let authorization_code = AuthorizationCode::new(code.into()); let issue_time = Utc::now(); - let timeout: std::time::Duration = server_config().session_timeout.into(); + let settings = Settings::get_current_settings(); + let timeout = settings.authentication_timeout(); let expiration = issue_time + timeout; let id_token_claims = IdTokenClaims::new( IssuerUrl::from_url(base_url.clone()), @@ -867,11 +873,13 @@ pub async fn token( }; let config = server_config(); let user_claims = UserClaims::from_user(&user, &client, &token); + let base_url = Settings::url()?; + match form.authorization_code_flow( &auth_code, &token, (&user_claims).into(), - &config.url, + &base_url, client.client_secret, config.openid_key(), group_claims, @@ -1007,11 +1015,11 @@ pub async fn userinfo(State(appstate): State, headers: HeaderMap) -> A // Must be served under /.well-known/openid-configuration pub async fn openid_configuration() -> ApiResult { - let config = server_config(); + let url = Settings::url()?; let provider_metadata = CoreProviderMetadata::new( - IssuerUrl::from_url(config.url.clone()), - AuthUrl::from_url(config.url.join("api/v1/oauth/authorize").unwrap()), - JsonWebKeySetUrl::from_url(config.url.join("api/v1/oauth/discovery/keys").unwrap()), + IssuerUrl::from_url(url.clone()), + AuthUrl::from_url(url.join("api/v1/oauth/authorize")?), + JsonWebKeySetUrl::from_url(url.join("api/v1/oauth/discovery/keys")?), vec![ResponseTypes::new(vec![CoreResponseType::Code])], vec![CoreSubjectIdentifierType::Public], vec![ @@ -1020,9 +1028,7 @@ pub async fn openid_configuration() -> ApiResult { ], EmptyAdditionalProviderMetadata {}, ) - .set_token_endpoint(Some(TokenUrl::from_url( - config.url.join("api/v1/oauth/token").unwrap(), - ))) + .set_token_endpoint(Some(TokenUrl::from_url(url.join("api/v1/oauth/token")?))) .set_scopes_supported(Some(vec![ Scope::new("openid".into()), Scope::new("profile".into()), @@ -1048,7 +1054,7 @@ pub async fn openid_configuration() -> ApiResult { CoreGrantType::RefreshToken, ])) .set_userinfo_endpoint(Some(UserInfoUrl::from_url( - config.url.join("api/v1/oauth/userinfo").unwrap(), + url.join("api/v1/oauth/userinfo")?, ))); Ok(ApiResponse::json(provider_metadata, StatusCode::OK)) diff --git a/crates/defguard_core/src/handlers/settings.rs b/crates/defguard_core/src/handlers/settings.rs index 2bc26f9232..b735b1f465 100644 --- a/crates/defguard_core/src/handlers/settings.rs +++ b/crates/defguard_core/src/handlers/settings.rs @@ -1,4 +1,5 @@ use axum::{ + Extension, extract::{Json, Path, State}, http::StatusCode, }; @@ -6,6 +7,7 @@ use defguard_common::db::models::{ Settings, SettingsEssentials, settings::{LdapSyncStatus, SettingsPatch, update_current_settings}, }; +use sqlx::PgPool; use struct_patch::Patch; use super::{ApiResponse, ApiResult}; @@ -64,9 +66,9 @@ pub async fn update_settings( Ok(ApiResponse::default()) } -pub async fn get_settings_essentials(State(appstate): State) -> ApiResult { +pub async fn get_settings_essentials(Extension(pool): Extension) -> ApiResult { debug!("Retrieving essential settings"); - let mut settings = SettingsEssentials::get_settings_essentials(&appstate.pool).await?; + let mut settings = SettingsEssentials::get_settings_essentials(&pool).await?; if settings.nav_logo_url.is_empty() { settings.nav_logo_url = DEFAULT_NAV_LOGO_URL.into(); } diff --git a/crates/defguard_core/src/handlers/wireguard.rs b/crates/defguard_core/src/handlers/wireguard.rs index 760edb65bc..eb4accf317 100644 --- a/crates/defguard_core/src/handlers/wireguard.rs +++ b/crates/defguard_core/src/handlers/wireguard.rs @@ -49,7 +49,6 @@ use crate::{ allowed_peers::get_location_allowed_peers, handle_imported_devices, handle_mapped_devices, sync_location_allowed_devices, }, - server_config, wg_config::{ImportedDevice, parse_wireguard_config}, }; @@ -1357,27 +1356,6 @@ pub(crate) async fn download_config( } } -pub(crate) async fn create_network_token( - _role: AdminRole, - State(appstate): State, - Path(network_id): Path, -) -> ApiResult { - debug!("Generating a new token for network ID {network_id}"); - let network = find_network(network_id, &appstate.pool).await?; - let token = network.generate_gateway_token().map_err(|_| { - error!("Failed to create token for gateway {}", network.name); - WebError::Authorization(format!( - "Failed to create token for gateway {}", - network.name - )) - })?; - info!("Generated a new token for network ID {network_id}"); - Ok(ApiResponse::new( - json!({"token": token, "grpc_url": server_config().grpc_url.to_string()}), - StatusCode::OK, - )) -} - /// Returns appropriate aggregation level depending on the `from` date param /// If `from` is >= than 6 hours ago, returns `Hour` aggregation /// Otherwise returns `Minute` aggregation diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 4bcdf7d2a7..0061685f44 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -116,7 +116,6 @@ use crate::{ totp_disable, totp_enable, totp_secret, webauthn_end, webauthn_finish, webauthn_init, webauthn_start, }, - ca::create_ca, component_setup::setup_gateway_tls_stream, forward_auth::forward_auth, group::{ @@ -150,10 +149,10 @@ use crate::{ add_webhook, change_enabled, change_webhook, delete_webhook, get_webhook, list_webhooks, }, wireguard::{ - add_device, add_user_devices, change_gateway, create_network, create_network_token, - delete_device, delete_network, devices_stats, download_config, gateway_status, - get_device, import_network, list_devices, list_networks, list_user_devices, - modify_device, modify_network, network_details, network_stats, remove_gateway, + add_device, add_user_devices, change_gateway, create_network, delete_device, + delete_network, devices_stats, download_config, gateway_status, get_device, + import_network, list_devices, list_networks, list_user_devices, modify_device, + modify_network, network_details, network_stats, remove_gateway, }, worker::{create_job, create_worker_token, job_status, list_workers, remove_worker}, }, @@ -166,7 +165,7 @@ pub mod auth; pub mod db; pub mod enrollment_management; pub mod enterprise; -mod error; +pub mod error; pub mod events; pub mod grpc; pub mod handlers; @@ -193,11 +192,11 @@ static PHONE_NUMBER_REGEX: LazyLock = LazyLock::new(|| { mod openapi; /// Simple health-check. -async fn health_check() -> &'static str { +pub async fn health_check() -> &'static str { "alive" } -async fn handle_404() -> (StatusCode, &'static str) { +pub async fn handle_404() -> (StatusCode, &'static str) { (StatusCode::NOT_FOUND, "Not found") } @@ -356,8 +355,6 @@ pub fn build_webapp( .route("/ldap/test", get(test_ldap_settings)) // activity log .route("/activity_log", get(get_activity_log_events)) - // Certificate authority - .route("/ca", post(create_ca)) // Proxy routes .route("/proxy/{proxy_id}", get(proxy_details).put(update_proxy)) // Proxy setup with SSE @@ -519,7 +516,6 @@ pub fn build_webapp( "/network/{network_id}/device/{device_id}/config", get(download_config), ) - .route("/network/{network_id}/token", get(create_network_token)) .route("/network/{network_id}/stats/users", get(devices_stats)) .route("/network/{network_id}/stats", get(network_stats)) .route( @@ -555,7 +551,7 @@ pub fn build_webapp( webapp .with_state(AppState::new( - pool, + pool.clone(), webhook_tx, webhook_rx, wireguard_tx, @@ -563,8 +559,10 @@ pub fn build_webapp( failed_logins, event_tx, incompatible_components, - proxy_control_tx, + proxy_control_tx.clone(), )) + .layer(Extension(pool)) + .layer(Extension(proxy_control_tx)) .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request<_>| { diff --git a/crates/defguard_core/tests/integration/api/acl.rs b/crates/defguard_core/tests/integration/api/acl.rs index d25a34332f..93740547da 100644 --- a/crates/defguard_core/tests/integration/api/acl.rs +++ b/crates/defguard_core/tests/integration/api/acl.rs @@ -423,7 +423,7 @@ async fn test_nonadmin(_: PgPoolOptions, options: PgConnectOptions) { async fn test_related_objects(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let config = init_config(None); + let config = init_config(None, &pool).await; let mut client = make_client_v2(pool.clone(), config).await; authenticate_admin(&mut client).await; @@ -703,7 +703,7 @@ async fn test_invalid_data(_: PgPoolOptions, options: PgConnectOptions) { async fn test_rule_create_modify_state(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let config = init_config(None); + let config = init_config(None, &pool).await; let mut client = make_client_v2(pool.clone(), config).await; authenticate_admin(&mut client).await; @@ -758,7 +758,7 @@ async fn test_rule_create_modify_state(_: PgPoolOptions, options: PgConnectOptio async fn test_rule_delete_state_new(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let config = init_config(None); + let config = init_config(None, &pool).await; let mut client = make_client_v2(pool.clone(), config).await; authenticate_admin(&mut client).await; @@ -777,7 +777,7 @@ async fn test_rule_delete_state_new(_: PgPoolOptions, options: PgConnectOptions) async fn test_rule_delete_state_applied(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let config = init_config(None); + let config = init_config(None, &pool).await; let mut client = make_client_v2(pool.clone(), config).await; authenticate_admin(&mut client).await; @@ -841,7 +841,7 @@ async fn test_rule_duplication(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; // each modification / deletion of parent rule should remove the child and create a new one - let config = init_config(None); + let config = init_config(None, &pool).await; let mut client = make_client_v2(pool.clone(), config).await; authenticate_admin(&mut client).await; @@ -871,7 +871,7 @@ async fn test_rule_duplication(_: PgPoolOptions, options: PgConnectOptions) { async fn test_rule_application(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let config = init_config(None); + let config = init_config(None, &pool).await; let mut client = make_client_v2(pool.clone(), config).await; authenticate_admin(&mut client).await; @@ -963,7 +963,7 @@ async fn test_rule_application(_: PgPoolOptions, options: PgConnectOptions) { async fn test_multiple_rules_application(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let config = init_config(None); + let config = init_config(None, &pool).await; let mut client = make_client_v2(pool.clone(), config).await; authenticate_admin(&mut client).await; @@ -1001,7 +1001,7 @@ async fn test_multiple_rules_application(_: PgPoolOptions, options: PgConnectOpt async fn test_alias_create_modify_state(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let config = init_config(None); + let config = init_config(None, &pool).await; let mut client = make_client_v2(pool.clone(), config).await; authenticate_admin(&mut client).await; @@ -1041,7 +1041,7 @@ async fn test_alias_create_modify_state(_: PgPoolOptions, options: PgConnectOpti async fn test_alias_delete(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let config = init_config(None); + let config = init_config(None, &pool).await; let mut client = make_client_v2(pool.clone(), config).await; authenticate_admin(&mut client).await; @@ -1107,7 +1107,7 @@ async fn test_alias_duplication(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; // each modification of parent alias should remove the child and create a new one - let config = init_config(None); + let config = init_config(None, &pool).await; let mut client = make_client_v2(pool.clone(), config).await; authenticate_admin(&mut client).await; @@ -1133,7 +1133,7 @@ async fn test_alias_duplication(_: PgPoolOptions, options: PgConnectOptions) { async fn test_alias_application(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let config = init_config(None); + let config = init_config(None, &pool).await; let mut client = make_client_v2(pool.clone(), config).await; authenticate_admin(&mut client).await; @@ -1194,7 +1194,7 @@ async fn test_alias_application(_: PgPoolOptions, options: PgConnectOptions) { async fn test_multiple_aliases_application(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let config = init_config(None); + let config = init_config(None, &pool).await; let mut client = make_client_v2(pool.clone(), config).await; authenticate_admin(&mut client).await; diff --git a/crates/defguard_core/tests/integration/api/common/mod.rs b/crates/defguard_core/tests/integration/api/common/mod.rs index 333baf466b..463a3c75db 100644 --- a/crates/defguard_core/tests/integration/api/common/mod.rs +++ b/crates/defguard_core/tests/integration/api/common/mod.rs @@ -155,7 +155,7 @@ pub(crate) async fn make_test_client(pool: PgPool) -> (TestClient, ClientState) .await .expect("Could not bind ephemeral socket"); let port = listener.local_addr().unwrap().port(); - let config = init_config(Some(&format!("http://localhost:{port}"))); + let config = init_config(Some(&format!("http://localhost:{port}")), &pool).await; initialize_users(&pool, &config).await; initialize_current_settings(&pool) .await diff --git a/crates/defguard_core/tests/integration/api/forward_auth.rs b/crates/defguard_core/tests/integration/api/forward_auth.rs index 7525383ca0..25e40430c3 100644 --- a/crates/defguard_core/tests/integration/api/forward_auth.rs +++ b/crates/defguard_core/tests/integration/api/forward_auth.rs @@ -1,4 +1,4 @@ -use defguard_common::config::SERVER_CONFIG; +use defguard_common::db::models::Settings; use defguard_core::handlers::Auth; use reqwest::StatusCode; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; @@ -20,13 +20,10 @@ async fn test_forward_auth(_: PgPoolOptions, options: PgConnectOptions) { .await; assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); let headers = response.headers(); + let url = Settings::url().unwrap(); assert_eq!( headers.get("location").unwrap().to_str().unwrap(), - format!( - "{}auth/login?r={}", - SERVER_CONFIG.get().unwrap().url, - "http://app.example.com/test" - ) + format!("{}auth/login?r={}", url, "http://app.example.com/test") ); // login diff --git a/crates/defguard_core/tests/integration/api/openid.rs b/crates/defguard_core/tests/integration/api/openid.rs index 30d6ed5fc2..42a76f6542 100644 --- a/crates/defguard_core/tests/integration/api/openid.rs +++ b/crates/defguard_core/tests/integration/api/openid.rs @@ -4,7 +4,7 @@ use axum::http::header::ToStrError; use claims::assert_err; use defguard_common::db::{ Id, - models::{OAuth2AuthorizedApp, User, oauth2client::OAuth2Client}, + models::{OAuth2AuthorizedApp, Settings, User, oauth2client::OAuth2Client}, }; use defguard_core::handlers::{Auth, openid_clients::NewOpenIDClient}; use openidconnect::{ @@ -103,7 +103,7 @@ async fn test_openid_client(_: PgPoolOptions, options: PgConnectOptions) { async fn test_openid_flow(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let (client, state) = make_test_client(pool).await; + let (client, _) = make_test_client(pool).await; let auth = Auth::new("admin", "pass123"); let response = client.post("/api/v1/auth").json(&auth).send().await; assert_eq!(response.status(), StatusCode::OK); @@ -264,9 +264,8 @@ async fn test_openid_flow(_: PgPoolOptions, options: PgConnectOptions) { let response = client.post("/api/v1/auth").json(&auth).send().await; assert_eq!(response.status(), StatusCode::OK); - let fallback_url = state - .config - .url + let fallback_url = Settings::url() + .unwrap() .to_string() .trim_end_matches('/') .to_string(); @@ -473,10 +472,9 @@ static FAKE_REDIRECT_URI: &str = "http://test.server.tnt:12345/"; async fn test_openid_authorization_code(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let (client, state) = make_test_client(pool).await; - let config = state.config; + let (client, _) = make_test_client(pool).await; - let issuer_url = IssuerUrl::from_url(config.url.clone()); + let issuer_url = IssuerUrl::from_url(Settings::url().unwrap().clone()); // discover OpenID service let provider_metadata = @@ -578,10 +576,9 @@ async fn dg25_20_test_openid_disabled_client_doesnt_generate_code( ) { let pool = setup_pool(options).await; - let (client, state) = make_test_client(pool).await; - let config = state.config; + let (client, _) = make_test_client(pool).await; - let issuer_url = IssuerUrl::from_url(config.url.clone()); + let issuer_url = IssuerUrl::from_url(Settings::url().unwrap().clone()); // discover OpenID service let provider_metadata = @@ -688,7 +685,7 @@ async fn dg25_25_openid_disabled_client_userinfo_fails( let mut rng = rand::thread_rng(); config.openid_signing_key = RsaPrivateKey::new(&mut rng, 2048).ok(); - let issuer_url = IssuerUrl::from_url(config.url.clone()); + let issuer_url = IssuerUrl::from_url(Settings::url().unwrap().clone()); // discover OpenID service let provider_metadata = @@ -819,7 +816,7 @@ async fn test_openid_authorization_code_with_pkce(_: PgPoolOptions, options: PgC let mut rng = rand::thread_rng(); config.openid_signing_key = RsaPrivateKey::new(&mut rng, 2048).ok(); - let issuer_url = IssuerUrl::from_url(config.url.clone()); + let issuer_url = IssuerUrl::from_url(Settings::url().unwrap().clone()); // discover OpenID service let provider_metadata = @@ -1112,9 +1109,8 @@ async fn dg25_17_test_openid_open_redirects(_: PgPoolOptions, options: PgConnect .ascii_serialization() } - let fallback_url = state - .config - .url + let fallback_url = Settings::url() + .unwrap() .to_string() .trim_end_matches('/') .to_string(); @@ -1277,7 +1273,7 @@ async fn dg25_22_test_respect_openid_scope_in_userinfo( let mut rng = rand::thread_rng(); config.openid_signing_key = RsaPrivateKey::new(&mut rng, 2048).ok(); - let issuer_url = IssuerUrl::from_url(config.url.clone()); + let issuer_url = IssuerUrl::from_url(Settings::url().unwrap().clone()); // discover OpenID service let provider_metadata = diff --git a/crates/defguard_core/tests/integration/common.rs b/crates/defguard_core/tests/integration/common.rs index 6370300b1d..a3801711b9 100644 --- a/crates/defguard_core/tests/integration/common.rs +++ b/crates/defguard_core/tests/integration/common.rs @@ -1,17 +1,30 @@ use defguard_common::{ config::{DefGuardConfig, SERVER_CONFIG}, - db::models::User, + db::models::{ + Settings, User, + settings::{initialize_current_settings, update_current_settings}, + }, }; -use reqwest::Url; use secrecy::ExposeSecret; use sqlx::PgPool; /// Allows overriding the default DefGuard URL for tests, as during the tests, the server has a random port, making the URL unpredictable beforehand. // TODO: Allow customizing the whole config, not just the URL -pub(crate) fn init_config(custom_defguard_url: Option<&str>) -> DefGuardConfig { +pub(crate) async fn init_config( + custom_defguard_url: Option<&str>, + pool: &PgPool, +) -> DefGuardConfig { let url = custom_defguard_url.unwrap_or("http://localhost:8000"); let mut config = DefGuardConfig::new_test_config(); - config.url = Url::parse(url).unwrap(); + 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(); + update_current_settings(pool, settings) + .await + .expect("Could not update current settings in the database"); + config.initialize_post_settings(); let _ = SERVER_CONFIG.set(config.clone()); config } diff --git a/crates/defguard_core/tests/integration/grpc/common/mod.rs b/crates/defguard_core/tests/integration/grpc/common/mod.rs index 02b72fa87b..6bf11063f2 100644 --- a/crates/defguard_core/tests/integration/grpc/common/mod.rs +++ b/crates/defguard_core/tests/integration/grpc/common/mod.rs @@ -132,7 +132,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); + let config = init_config(None, pool).await; initialize_users(pool, &config).await; initialize_current_settings(pool) .await diff --git a/crates/defguard_mail/Cargo.toml b/crates/defguard_mail/Cargo.toml index 854671cb2e..9462d945b5 100644 --- a/crates/defguard_mail/Cargo.toml +++ b/crates/defguard_mail/Cargo.toml @@ -21,6 +21,7 @@ tera.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true +humantime.workspace = true [dev-dependencies] claims.workspace = true diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index 9e8af09b8c..e91263699a 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -3,14 +3,14 @@ use std::collections::HashMap; use chrono::{Datelike, NaiveDateTime, Utc}; use defguard_common::{ VERSION, - config::server_config, db::{ Id, models::{ - Session, + Session, Settings, user::{MFAMethod, User}, }, }, + types::UrlParseError, }; use reqwest::Url; use serde::Serialize; @@ -51,6 +51,8 @@ pub enum TemplateError { MfaError, #[error(transparent)] TemplateError(#[from] tera::Error), + #[error(transparent)] + UrlParseError(#[from] UrlParseError), } struct NoOp(&'static str); @@ -152,7 +154,7 @@ pub fn enrollment_start_mail( // add required context context.insert("enrollment_url", &enrollment_service_url.to_string()); - context.insert("defguard_url", &server_config().url); + context.insert("defguard_url", &Settings::url()?); context.insert("token", enrollment_token); // prepare enrollment service URL @@ -290,7 +292,7 @@ pub fn new_device_ocid_login_mail( let (mut tera, mut context) = get_base_tera(None, Some(session), None, None)?; tera.add_raw_template("mail_base", MAIL_BASE)?; - let url = format!("{}me", server_config().url); + let url = format!("{}me", Settings::url()?); context.insert("oauth2client_name", &oauth2client_name); context.insert("profile_url", &url); @@ -331,7 +333,10 @@ pub fn email_mfa_activation_mail( session: Option<&SessionContext>, ) -> Result { let (mut tera, mut context) = get_base_tera(None, session, None, None)?; - let timeout = server_config().mfa_code_timeout; + let settings = Settings::get_current_settings(); + let timeout = humantime::format_duration(std::time::Duration::from_secs( + settings.mfa_code_timeout_seconds as u64, + )); // zero-pad code to make sure it's always 6 digits long context.insert("code", &format!("{code:0>6}")); context.insert("timeout", &timeout.to_string()); @@ -347,7 +352,10 @@ pub fn email_mfa_code_mail( session: Option<&SessionContext>, ) -> Result { let (mut tera, mut context) = get_base_tera(None, session, None, None)?; - let timeout = server_config().mfa_code_timeout; + let settings = Settings::get_current_settings(); + let timeout = humantime::format_duration(std::time::Duration::from_secs( + settings.mfa_code_timeout_seconds as u64, + )); // zero-pad code to make sure it's always 6 digits long context.insert("code", &format!("{code:0>6}")); context.insert("timeout", &timeout.to_string()); @@ -366,7 +374,7 @@ pub fn email_password_reset_mail( let (mut tera, mut context) = get_base_tera(None, None, ip_address, device_info)?; context.insert("enrollment_url", &service_url.to_string()); - context.insert("defguard_url", &server_config().url); + context.insert("defguard_url", &Settings::url()?); context.insert("token", password_reset_token); service_url.set_path("/password-reset"); @@ -395,7 +403,11 @@ pub fn email_password_reset_success_mail( #[cfg(test)] mod test { use claims::assert_ok; - use defguard_common::config::{DefGuardConfig, SERVER_CONFIG}; + use defguard_common::{ + config::{DefGuardConfig, SERVER_CONFIG}, + db::{models::settings::initialize_current_settings, setup_pool}, + }; + use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::*; @@ -413,6 +425,15 @@ mod test { context } + async fn init_config(pool: &sqlx::PgPool) { + let mut config = DefGuardConfig::new_test_config(); + initialize_current_settings(pool) + .await + .expect("Could not initialize current settings in the database"); + config.initialize_post_settings(); + let _ = SERVER_CONFIG.set(config.clone()); + } + #[test] fn test_mfa_configured_mail() { let mfa_method = MFAMethod::OneTimePassword; @@ -435,9 +456,10 @@ mod test { assert_ok!(test_mail(None)); } - #[test] - fn test_enrollment_start_mail() { - let _ = SERVER_CONFIG.set(DefGuardConfig::new_test_config()); + #[sqlx::test] + async fn test_enrollment_start_mail(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + init_config(&pool).await; assert_ok!(enrollment_start_mail( Context::new(), Url::parse("http://localhost:8080").unwrap(), @@ -445,8 +467,10 @@ mod test { )); } - #[test] - fn test_enrollment_welcome_mail() { + #[sqlx::test] + async fn test_enrollment_welcome_mail(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + init_config(&pool).await; assert_ok!(enrollment_welcome_mail( "Hi there! Welcome to DefGuard.", None, @@ -454,16 +478,20 @@ mod test { )); } - #[test] - fn test_desktop_start_mail() { + #[sqlx::test] + async fn test_desktop_start_mail(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + init_config(&pool).await; let external_context = get_welcome_context(); let url = Url::parse("http://127.0.0.1:8080").unwrap(); let token = "TestToken"; assert_ok!(desktop_start_mail(external_context, &url, token)); } - #[test] - fn test_new_device_added_mail() { + #[sqlx::test] + async fn test_new_device_added_mail(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + init_config(&pool).await; let template_locations: Vec = vec![ TemplateLocation { name: "Test 01".into(), @@ -482,8 +510,10 @@ mod test { None, )); } - #[test] - fn test_gateway_disconnected() { + #[sqlx::test] + async fn test_gateway_disconnected(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + init_config(&pool).await; assert_ok!(gateway_disconnected_mail( "Gateway A", "127.0.0.1", @@ -491,8 +521,10 @@ mod test { )); } - #[test] - fn test_enrollment_admin_notification() { + #[sqlx::test] + async fn test_enrollment_admin_notification(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + init_config(&pool).await; let test_user = UserContext { last_name: "test_last".into(), first_name: "test_first".into(), diff --git a/crates/defguard_proxy_manager/src/enrollment.rs b/crates/defguard_proxy_manager/src/enrollment.rs index 16aea97128..b6541c7f1c 100644 --- a/crates/defguard_proxy_manager/src/enrollment.rs +++ b/crates/defguard_proxy_manager/src/enrollment.rs @@ -219,7 +219,11 @@ impl EnrollmentServer { &user.username, &enterprise_settings, openid_provider, - ); + ) + .map_err(|err| { + error!("Failed to create instance info: {err}"); + Status::internal("unexpected error") + })?; debug!("Instance info {instance_info:?}"); debug!( @@ -849,18 +853,21 @@ impl EnrollmentServer { Status::internal(format!("unexpected error: {err}")) })?; + let instance_info = InstanceInfo::new( + settings, + &user.username, + &enterprise_settings, + openid_provider, + ) + .map_err(|err| { + error!("Failed to create instance info: {err}"); + Status::internal("unexpected error") + })?; + let response = DeviceConfigResponse { device: Some(device.clone().into()), configs: configs.into_iter().map(Into::into).collect(), - instance: Some( - InstanceInfo::new( - settings, - &user.username, - &enterprise_settings, - openid_provider, - ) - .into(), - ), + instance: Some(instance_info.into()), token: Some(token.token), }; diff --git a/crates/defguard_setup/Cargo.toml b/crates/defguard_setup/Cargo.toml new file mode 100644 index 0000000000..65c9026dec --- /dev/null +++ b/crates/defguard_setup/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "defguard_setup" +version = "0.0.0" +edition.workspace = true +license-file.workspace = true +homepage.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +defguard_common.workspace = true +anyhow.workspace = true +axum.workspace = true +defguard_web_ui.workspace = true +semver.workspace = true +sqlx.workspace = true +tokio.workspace = true +defguard_core.workspace = true +defguard_certs.workspace = true +reqwest.workspace = true +serde_json.workspace = true +tracing.workspace = true +serde.workspace = true +chrono.workspace = true +defguard_version.workspace = true diff --git a/crates/defguard_setup/src/handlers.rs b/crates/defguard_setup/src/handlers.rs new file mode 100644 index 0000000000..0b7e145d7f --- /dev/null +++ b/crates/defguard_setup/src/handlers.rs @@ -0,0 +1,237 @@ +use std::sync::{Arc, Mutex}; + +use axum::{Extension, Json}; +use defguard_certs::{der_to_pem, parse_certificate_info, parse_pem_certificate}; +use defguard_common::db::models::{ + Settings, User, group::Group, settings::update_current_settings, +}; +use defguard_core::{ + auth::AdminOrSetupRole, + error::WebError, + handlers::{ApiResponse, ApiResult}, +}; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::PgPool; +use tokio::sync::oneshot; +use tracing::{debug, info}; + +#[derive(Deserialize, Serialize, Debug)] +pub struct CreateAdmin { + first_name: String, + last_name: String, + username: String, + email: String, + password: String, +} + +pub async fn create_admin( + Extension(pool): Extension, + Json(admin): Json, +) -> ApiResult { + info!( + "Creating initial admin user {} ({})", + admin.username, admin.email + ); + User::new( + admin.username, + Some(admin.password.as_str()), + admin.last_name, + admin.first_name, + admin.email, + None, + ) + .save(&pool) + .await?; + + info!("Initial admin user created"); + + Ok(ApiResponse::with_status(StatusCode::CREATED)) +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct GeneralConfig { + defguard_url: String, + default_admin_group_name: String, + default_authentication: u32, + default_mfa_code_lifetime: u32, + admin_username: String, +} + +pub async fn set_general_config( + Extension(pool): Extension, + Json(general_config): Json, +) -> ApiResult { + info!("Applying initial general configuration settings"); + debug!( + "General configuration received: defguard_url={}, default_admin_group_name={}, default_authentication={}, default_mfa_code_lifetime={}, admin_username={}", + general_config.defguard_url, + general_config.default_admin_group_name, + general_config.default_authentication, + general_config.default_mfa_code_lifetime, + general_config.admin_username + ); + let default_admin_group_name = general_config.default_admin_group_name.clone(); + let mut settings = Settings::get_current_settings(); + settings.defguard_url = general_config.defguard_url; + settings.default_admin_group_name = general_config.default_admin_group_name; + settings.authentication_period_days = general_config + .default_authentication + .try_into() + .map_err(|err| { + WebError::BadRequest(format!("Invalid authentication period days: {err}")) + })?; + settings.mfa_code_timeout_seconds = general_config + .default_mfa_code_lifetime + .try_into() + .map_err(|err| WebError::BadRequest(format!("Invalid MFA code timeout seconds: {err}")))?; + update_current_settings(&pool, settings).await?; + debug!("Settings persisted"); + + let admin_group = + if let Some(mut group) = Group::find_by_name(&pool, &default_admin_group_name).await? { + debug!( + "Admin group {} found, marking as admin", + default_admin_group_name + ); + group.is_admin = true; + group.save(&pool).await?; + group + } else { + debug!( + "Admin group {} not found, creating", + default_admin_group_name + ); + let mut group = Group::new(&default_admin_group_name); + group.is_admin = true; + group.save(&pool).await? + }; + + let admin_user = User::find_by_username(&pool, &general_config.admin_username) + .await? + .ok_or_else(|| { + WebError::ObjectNotFound(format!( + "Admin user '{}' not found", + general_config.admin_username + )) + })?; + debug!( + "Assigning admin user {} to admin group {}", + general_config.admin_username, admin_group.name + ); + admin_user.add_to_group(&pool, &admin_group).await?; + + info!("Initial general configuration applied"); + + Ok(ApiResponse::with_status(StatusCode::CREATED)) +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct CreateCA { + common_name: String, + email: String, + validity_period_years: u32, +} + +pub async fn create_ca( + Extension(pool): Extension, + Json(ca_info): Json, +) -> ApiResult { + info!("Creating new certificate authority"); + debug!( + "CA request details: common_name={}, email={}, validity_period_years={}", + ca_info.common_name, ca_info.email, ca_info.validity_period_years + ); + let mut settings = Settings::get_current_settings(); + let ca = defguard_certs::CertificateAuthority::new( + &ca_info.common_name, + &ca_info.email, + ca_info.validity_period_years * 365, + )?; + + let (cert_der, key_der) = (ca.cert_der().to_vec(), ca.key_pair_der().to_vec()); + + settings.ca_cert_der = Some(cert_der); + settings.ca_key_der = Some(key_der); + settings.ca_expiry = Some(ca.expiry()?); + + update_current_settings(&pool, settings).await?; + + info!("Certificate authority created and stored"); + + Ok(ApiResponse::with_status(StatusCode::CREATED)) +} + +pub async fn get_ca() -> ApiResult { + debug!("Fetching certificate authority details"); + let settings = Settings::get_current_settings(); + if let Some(ca_cert_der) = settings.ca_cert_der { + let ca_pem = der_to_pem(&ca_cert_der, defguard_certs::PemLabel::Certificate)?; + let info = parse_certificate_info(&ca_cert_der)?; + let valid_for_days = (info.not_after.and_utc() - chrono::Utc::now()).num_days(); + + debug!( + "Certificate authority details prepared: subject_common_name={}, valid_for_days={}", + info.subject_common_name, valid_for_days + ); + + Ok(ApiResponse::new( + json!({ "ca_cert_pem": ca_pem, "subject_common_name": info.subject_common_name, "not_before": info.not_before, "not_after": info.not_after, "valid_for_days": valid_for_days }), + StatusCode::OK, + )) + } else { + Err(WebError::ObjectNotFound( + "CA certificate not found".to_string(), + )) + } +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct UploadCA { + cert_file: String, +} + +pub async fn upload_ca( + Extension(pool): Extension, + Json(ca_info): Json, +) -> ApiResult { + info!("Uploading existing certificate authority"); + let cert_der = parse_pem_certificate(&ca_info.cert_file)?; + let expiry = parse_certificate_info(&cert_der)?.not_after; + + let mut settings = Settings::get_current_settings(); + settings.ca_cert_der = Some(cert_der.to_vec()); + settings.ca_key_der = None; // Key is not provided when uploading CA + settings.ca_expiry = Some(expiry); + + update_current_settings(&pool, settings).await?; + + info!("Certificate authority uploaded and stored"); + + Ok(ApiResponse::with_status(StatusCode::CREATED)) +} + +pub async fn finish_setup( + _: AdminOrSetupRole, + Extension(pool): Extension, + Extension(setup_shutdown_tx): Extension>>>>, +) -> ApiResult { + info!("Finishing initial setup"); + let mut settings = Settings::get_current_settings(); + settings.initial_setup_completed = true; + update_current_settings(&pool, settings).await?; + if let Some(tx) = setup_shutdown_tx + .lock() + .expect("Failed to lock setup shutdown sender") + .take() + { + let _ = tx.send(()); + info!("Initial setup completed and shutdown signal sent"); + } else { + return Err(WebError::BadRequest( + "Setup shutdown sender no longer available".to_string(), + )); + } + Ok(ApiResponse::with_status(StatusCode::OK)) +} diff --git a/crates/defguard_setup/src/lib.rs b/crates/defguard_setup/src/lib.rs new file mode 100644 index 0000000000..97bcacc111 --- /dev/null +++ b/crates/defguard_setup/src/lib.rs @@ -0,0 +1,2 @@ +pub mod handlers; +pub mod setup; diff --git a/crates/defguard_setup/src/setup.rs b/crates/defguard_setup/src/setup.rs new file mode 100644 index 0000000000..3b26d47114 --- /dev/null +++ b/crates/defguard_setup/src/setup.rs @@ -0,0 +1,86 @@ +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, + sync::{Arc, Mutex}, +}; + +use anyhow::anyhow; +use axum::{ + Extension, Router, + routing::{get, post}, + serve, +}; +use defguard_common::VERSION; +use defguard_core::{ + handle_404, + handlers::{component_setup::setup_proxy_tls_stream, settings::get_settings_essentials}, + health_check, +}; +use defguard_web_ui::{index, svg, web_asset}; +use semver::Version; +use sqlx::PgPool; +use tokio::{net::TcpListener, sync::oneshot::Sender}; +use tracing::{info, instrument}; + +use crate::handlers::{ + create_admin, create_ca, finish_setup, get_ca, set_general_config, upload_ca, +}; + +pub fn build_setup_webapp(pool: PgPool, version: Version, setup_shutdown_tx: Sender<()>) -> Router { + Router::<()>::new() + .route("/", get(index)) + .route("/{*path}", get(index)) + .route("/fonts/{*path}", get(web_asset)) + .route("/assets/{*path}", get(web_asset)) + .route("/svg/{*path}", get(svg)) + .nest( + "/api/v1", + Router::<()>::new() + .route("/health", get(health_check)) + .route("/settings_essentials", get(get_settings_essentials)) + .route("/proxy/setup/stream", get(setup_proxy_tls_stream)) + .nest( + "/initial_setup", + Router::<()>::new() + .route("/ca", post(create_ca).get(get_ca)) + .route("/ca/upload", post(upload_ca)) + .route("/general_config", post(set_general_config)) + .route("/admin", post(create_admin)) + .route("/finish", post(finish_setup)), + ), + ) + .fallback_service(get(handle_404)) + .layer(Extension(pool)) + .layer(Extension(version)) + .layer(Extension(Arc::new(Mutex::new(Some(setup_shutdown_tx))))) +} + +#[instrument(skip_all)] +pub async fn run_setup_web_server( + pool: PgPool, + http_bind_address: Option, + http_port: u16, +) -> Result<(), anyhow::Error> { + let (setup_shutdown_tx, setup_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let setup_webapp = build_setup_webapp( + pool.clone(), + defguard_version::Version::parse(VERSION)?, + setup_shutdown_tx, + ); + + info!("Starting initial setup web server on port {http_port}"); + let addr = SocketAddr::new( + http_bind_address.unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)), + http_port, + ); + let listener = TcpListener::bind(&addr).await?; + serve( + listener, + setup_webapp.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + setup_shutdown_rx.await.ok(); + info!("Shutting down initial setup web server"); + }) + .await + .map_err(|err| anyhow!("Web server can't be started {err}")) +} diff --git a/deny.toml b/deny.toml index 46fbff37c3..14216c9e9e 100644 --- a/deny.toml +++ b/deny.toml @@ -169,6 +169,10 @@ exceptions = [ "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_vpn_stats_purge" }, + { allow = [ + "AGPL-3.0-only", + "AGPL-3.0-or-later", + ], crate = "defguard_setup" }, ] # Some crates don't have (easily) machine readable licensing information, diff --git a/migrations/20260128121256_[2.0.0]_initial_setup_wizard.down.sql b/migrations/20260128121256_[2.0.0]_initial_setup_wizard.down.sql new file mode 100644 index 0000000000..f01bf61d2d --- /dev/null +++ b/migrations/20260128121256_[2.0.0]_initial_setup_wizard.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE settings + DROP COLUMN initial_setup_completed, + DROP COLUMN defguard_url, + DROP COLUMN default_admin_group_name, + DROP COLUMN authentication_period_days, + DROP COLUMN mfa_code_timeout_seconds; diff --git a/migrations/20260128121256_[2.0.0]_initial_setup_wizard.up.sql b/migrations/20260128121256_[2.0.0]_initial_setup_wizard.up.sql new file mode 100644 index 0000000000..d2c4a7a173 --- /dev/null +++ b/migrations/20260128121256_[2.0.0]_initial_setup_wizard.up.sql @@ -0,0 +1,6 @@ +ALTER TABLE settings +ADD COLUMN initial_setup_completed BOOLEAN NOT NULL DEFAULT FALSE, +ADD COLUMN defguard_url TEXT NOT NULL DEFAULT 'http://localhost:8000', +ADD COLUMN default_admin_group_name TEXT NOT NULL DEFAULT 'admin', +ADD COLUMN authentication_period_days INTEGER NOT NULL DEFAULT 7, +ADD COLUMN mfa_code_timeout_seconds INTEGER NOT NULL DEFAULT 60; diff --git a/web/messages/en/initial_wizard.json b/web/messages/en/initial_wizard.json new file mode 100644 index 0000000000..be7b5f3598 --- /dev/null +++ b/web/messages/en/initial_wizard.json @@ -0,0 +1,103 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "initial_setup_wizard_title": "Initial Setup Wizard", + "initial_setup_wizard_subtitle": "This wizard will guide you through the initial configuration of your Defguard instance.", + "initial_setup_welcome_title": "Welcome to Defguard initial configuration wizard.", + "initial_setup_welcome_subtitle": "This wizard walks you through the steps to configure your VPN connection with a simple and intuitive setup process.", + "initial_setup_welcome_button_configure": "Configure Defguard", + + "initial_setup_step_admin_user_label": "Create Admin User", + "initial_setup_step_admin_user_description": "Manage core details and connection parameters for your VPN location.", + "initial_setup_step_general_config_label": "General Configuration", + "initial_setup_step_general_config_description": "Manage core details and connection parameters for your VPN location.", + "initial_setup_step_certificate_authority_label": "Certificate Authority", + "initial_setup_step_certificate_authority_description": "Securing component communication", + "initial_setup_step_certificate_authority_summary_label": "Certificate Authority Summary", + "initial_setup_step_certificate_authority_summary_description": "Securing component communication", + "initial_setup_step_edge_component_label": "Edge Component", + "initial_setup_step_edge_component_description": "Set up your VPN proxy quickly and ensure secure, optimized traffic flow for your users.", + "initial_setup_step_edge_adaptation_label": "Edge Component Adaptation", + "initial_setup_step_edge_adaptation_description": "Review the system’s checks and see if any issues need attention before deployment.", + "initial_setup_step_confirmation_label": "Confirmation", + "initial_setup_step_confirmation_description": "Your configuration was successful. You’re all set.", + + "initial_setup_admin_user_password_rule_required_label": "Field is required", + "initial_setup_admin_user_password_rule_required_message": "Password is required", + "initial_setup_admin_user_password_rule_min_label": "Minimum length of 8", + "initial_setup_admin_user_password_rule_min_message": "Password must be at least 8 characters", + "initial_setup_admin_user_password_rule_number_label": "At least one number required", + "initial_setup_admin_user_password_rule_number_message": "Password must contain at least one number", + "initial_setup_admin_user_password_rule_special_label": "At least one special character", + "initial_setup_admin_user_password_rule_special_message": "Password must contain at least one special character", + "initial_setup_admin_user_password_rule_lower_label": "At least one lowercase character", + "initial_setup_admin_user_password_rule_lower_message": "Password must contain at least one lowercase letter", + "initial_setup_admin_user_password_rule_upper_label": "At least one uppercase character", + "initial_setup_admin_user_password_rule_upper_message": "Password must contain at least one uppercase letter", + "initial_setup_admin_user_error_first_name_required": "First name is required", + "initial_setup_admin_user_error_last_name_required": "Last name is required", + "initial_setup_admin_user_error_username_min": "Username must be at least 3 characters", + "initial_setup_admin_user_error_email_invalid": "Invalid email address", + "initial_setup_admin_user_error_email_required": "Email is required", + "initial_setup_admin_user_label_first_name": "First Name", + "initial_setup_admin_user_label_last_name": "Last Name", + "initial_setup_admin_user_label_username": "Username", + "initial_setup_admin_user_label_email": "Email", + "initial_setup_admin_user_label_password": "Password", + "initial_setup_admin_user_error_create_failed": "Failed to create admin user. Please try again.", + "initial_setup_admin_user_password_checklist_title": "Your password must include:", + + "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_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", + "initial_setup_general_config_label_defguard_url": "Defguard URL", + "initial_setup_general_config_label_admin_group": "Default Admin Group Name", + "initial_setup_general_config_label_auth_period": "Default Authentication Period (days)", + "initial_setup_general_config_label_mfa_timeout": "Default MFA Code Timeout (seconds)", + "initial_setup_general_config_error_save_failed": "Failed to create admin user. Please try again.", + + "initial_setup_ca_validity_one_year": "1 year", + "initial_setup_ca_validity_years": "{years} years", + "initial_setup_ca_error_common_name_required": "Common name is required", + "initial_setup_ca_error_email_invalid": "Invalid email address", + "initial_setup_ca_error_email_required": "Email is required", + "initial_setup_ca_error_validity_min": "Validity period must be at least 1 year", + "initial_setup_ca_error_cert_required": "Certificate file is required", + "initial_setup_ca_error_create_failed": "Failed to create CA. Please review the information and try again.", + "initial_setup_ca_error_upload_failed": "Failed to upload CA. Please ensure the certificate file is valid and try again.", + "initial_setup_ca_option_create_title": "Certificate Authority Setup", + "initial_setup_ca_option_create_description": "By choosing this option, Defguard will create its own certificate authority and automatically configure all components to use its certificates — no manual setup required.", + "initial_setup_ca_label_common_name": "Common Name", + "initial_setup_ca_placeholder_common_name": "Defguard Certificate Authority", + "initial_setup_ca_label_email": "Email", + "initial_setup_ca_placeholder_email": "email@example.com", + "initial_setup_ca_label_validity": "Validity Period", + "initial_setup_ca_option_use_own_title": "Use your own certificate authority", + "initial_setup_ca_option_use_own_description": "Upload your certificate authority certificate and Defguard will use it to issue and configure certificates for components.", + + "initial_setup_ca_generated_title": "Certificate Authority Generated", + "initial_setup_ca_generated_subtitle": "The system created all required certificate files, including the root certificate and private key. You can download these files and continue with the configuration.", + "initial_setup_ca_download_button": "Download CA certificate", + "initial_setup_ca_validated_title": "Certificate Authority Validated", + "initial_setup_ca_validated_subtitle": "Your uploaded Certificate Authority has been successfully validated. All required files were checked and confirmed as correct and ready for use. You can download the validated CA files if needed for your setup.", + "initial_setup_ca_info_title": "Information extracted from uploaded file", + "initial_setup_ca_info_label_common_name": "Common Name", + "initial_setup_ca_info_label_validity": "Validity", + "initial_setup_ca_validity_unknown": "—", + "initial_setup_ca_validity_less_than_year": "Less than a year", + + "initial_setup_confirmation_error_finish_failed": "Failed to finish setup. Please try again.", + "initial_setup_confirmation_header": "General system settings are complete.", + "initial_setup_confirmation_lead": "You've completed the first stage of the setup. Defguard is almost ready to go.", + "initial_setup_confirmation_title": "In order to fully deploy Defguard you need:", + "initial_setup_confirmation_action_title": "Create first location.", + "initial_setup_confirmation_action_subtitle": "To organize users, manage access, track users activity and device monitoring.", + "initial_setup_confirmation_action_time": "Around 3 minutes", + "initial_setup_confirmation_footer": "Once you create your first location, the only step left will be to connect a gateway — and the system will be fully ready to use. This usually takes about n 10–15 minutes, depending on the complexity of your VPN configuration.", + + "initial_setup_controls_back": "Back", + "initial_setup_controls_continue": "Continue", + "initial_setup_controls_next": "Next", + "initial_setup_controls_finish": "Finish" +} diff --git a/web/project.inlang/settings.json b/web/project.inlang/settings.json index 17f836ebbb..39470cce32 100644 --- a/web/project.inlang/settings.json +++ b/web/project.inlang/settings.json @@ -21,7 +21,8 @@ "./messages/{locale}/edge.json", "./messages/{locale}/edge_wizard.json", "./messages/{locale}/settings.json", - "./messages/{locale}/gateway_wizard.json" + "./messages/{locale}/gateway_wizard.json", + "./messages/{locale}/initial_wizard.json" ] } } diff --git a/web/src/pages/AddLocationPage/steps/AddLocationMfaStep.tsx b/web/src/pages/AddLocationPage/steps/AddLocationMfaStep.tsx index 1e5f88e990..24907b56fe 100644 --- a/web/src/pages/AddLocationPage/steps/AddLocationMfaStep.tsx +++ b/web/src/pages/AddLocationPage/steps/AddLocationMfaStep.tsx @@ -84,7 +84,7 @@ export const AddLocationMfaStep = () => { )} { diff --git a/web/src/pages/EdgeSetupPage/steps/SetupEdgeAdaptationStep.tsx b/web/src/pages/EdgeSetupPage/steps/SetupEdgeAdaptationStep.tsx index 2ddc07a0c2..82c9b2186e 100644 --- a/web/src/pages/EdgeSetupPage/steps/SetupEdgeAdaptationStep.tsx +++ b/web/src/pages/EdgeSetupPage/steps/SetupEdgeAdaptationStep.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo } from 'react'; -import { useSSEController } from '../../../hooks/useSSEController'; import { m } from '../../../paraglide/messages'; import { Controls } from '../../../shared/components/Controls/Controls'; import { LoadingStep } from '../../../shared/components/LoadingStep/LoadingStep'; @@ -9,6 +8,7 @@ import { CodeCard } from '../../../shared/defguard-ui/components/CodeCard/CodeCa import { ModalControls } from '../../../shared/defguard-ui/components/ModalControls/ModalControls'; import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; import { ThemeSpacing } from '../../../shared/defguard-ui/types'; +import { useSSEController } from '../../../shared/hooks/useSSEController'; import { EdgeSetupStep } from '../types'; import { useEdgeWizardStore } from '../useEdgeWizardStore'; import type { SetupEvent, SetupStep, SetupStepId } from './types'; diff --git a/web/src/pages/EdgeSetupPage/types.ts b/web/src/pages/EdgeSetupPage/types.ts index de35244910..83729f5cff 100644 --- a/web/src/pages/EdgeSetupPage/types.ts +++ b/web/src/pages/EdgeSetupPage/types.ts @@ -1,7 +1,18 @@ +import type { SetupStepId } from './steps/types'; + export const EdgeSetupStep = { EdgeComponent: 'edgeComponent', EdgeAdaptation: 'edgeAdaptation', Confirmation: 'confirmation', } as const; +export type EdgeAdaptationState = { + isProcessing: boolean; + isComplete: boolean; + currentStep: SetupStepId | null; + errorMessage: string | null; + proxyVersion: string | null; + proxyLogs: string[]; +}; + export type EdgeSetupStepValue = (typeof EdgeSetupStep)[keyof typeof EdgeSetupStep]; diff --git a/web/src/pages/EdgeSetupPage/useEdgeWizardStore.tsx b/web/src/pages/EdgeSetupPage/useEdgeWizardStore.tsx index 4301ede973..3773f40877 100644 --- a/web/src/pages/EdgeSetupPage/useEdgeWizardStore.tsx +++ b/web/src/pages/EdgeSetupPage/useEdgeWizardStore.tsx @@ -1,17 +1,11 @@ import { omit } from 'lodash-es'; import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; -import type { SetupStepId } from './steps/types'; -import { EdgeSetupStep, type EdgeSetupStepValue } from './types'; - -type EdgeAdaptationState = { - isProcessing: boolean; - isComplete: boolean; - currentStep: SetupStepId | null; - errorMessage: string | null; - proxyVersion: string | null; - proxyLogs: string[]; -}; +import { + type EdgeAdaptationState, + EdgeSetupStep, + type EdgeSetupStepValue, +} from './types'; type StoreValues = { activeStep: EdgeSetupStepValue; diff --git a/web/src/pages/GatewaySetupPage/steps/SetupConfirmationStep.tsx b/web/src/pages/GatewaySetupPage/steps/SetupConfirmationStep.tsx index 30e4983335..66f0437965 100644 --- a/web/src/pages/GatewaySetupPage/steps/SetupConfirmationStep.tsx +++ b/web/src/pages/GatewaySetupPage/steps/SetupConfirmationStep.tsx @@ -26,9 +26,9 @@ export const SetupConfirmationStep = () => { return ( -

{m.edge_setup_confirmation_title()}

+

{m.gateway_setup_confirmation_title()}

-

{m.edge_setup_confirmation_subtitle()}

+

{m.gateway_setup_confirmation_subtitle()}

{ return (
- {steps.map((step, index) => ( + {steps.map((step) => ( { const { data: locations } = useSuspenseQuery(getLocationsQueryOptions); - // Auto start gateway setup modal - useEffect(() => { - const gatewaySetupStartup = useLocationsPageStore.getState().networkGatewayStartup; - - if (isPresent(gatewaySetupStartup)) { - const handleOpen = async () => { - const enrollData = (await api.location.getGatewayToken(gatewaySetupStartup)).data; - openModal(ModalName.GatewaySetup, { - data: enrollData, - networkId: gatewaySetupStartup, - }); - useLocationsPageStore.setState({ - networkGatewayStartup: undefined, - }); - }; - handleOpen(); - } - }, []); - return ( <> diff --git a/web/src/pages/SetupPage/SetupPage.tsx b/web/src/pages/SetupPage/SetupPage.tsx index 7ccfa6047d..ec4d3a4ab6 100644 --- a/web/src/pages/SetupPage/SetupPage.tsx +++ b/web/src/pages/SetupPage/SetupPage.tsx @@ -1,90 +1,131 @@ -import './style.scss'; +import { useNavigate } from '@tanstack/react-router'; +import { type ReactNode, useEffect, useMemo } from 'react'; +import { m } from '../../paraglide/messages'; import { Controls } from '../../shared/components/Controls/Controls'; -import { NavLogo } from '../../shared/components/Navigation/assets/NavLogo'; -import { AppText } from '../../shared/defguard-ui/components/AppText/AppText'; +import type { WizardPageStep } from '../../shared/components/wizard/types'; +import { WizardPage } from '../../shared/components/wizard/WizardPage/WizardPage'; import { Button } from '../../shared/defguard-ui/components/Button/Button'; -import { ExternalLink } from '../../shared/defguard-ui/components/ExternalLink/ExternalLink'; import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; -import { TextStyle, ThemeSpacing, ThemeVariable } from '../../shared/defguard-ui/types'; -import fileIcon from './assets/file_icon.png'; -import worldMap from './assets/world_map.mp4'; -import worldMapPoster from './assets/world_map_poster.png'; +import { ThemeSpacing } from '../../shared/defguard-ui/types'; +import { useApp } from '../../shared/hooks/useApp'; +import worldMap from './assets/world-map.png'; +import { SetupAdminUserStep } from './steps/SetupAdminUserStep'; +import { SetupCertificateAuthorityStep } from './steps/SetupCertificateAuthorityStep'; +import { SetupCertificateAuthoritySummaryStep } from './steps/SetupCertificateAuthoritySummaryStep'; +import { SetupConfirmationStep } from './steps/SetupConfirmationStep'; +import { SetupEdgeAdaptationStep } from './steps/SetupEdgeAdaptationStep'; +import { SetupEdgeComponentStep } from './steps/SetupEdgeComponentStep'; +import { SetupGeneralConfigStep } from './steps/SetupGeneralConfigStep'; +import { SetupPageStep, type SetupPageStepValue } from './types'; +import { useSetupWizardStore } from './useSetupWizardStore'; export const SetupPage = () => { - return ( -
-
-
-
- -
-
-
-

Welcome to Defguard initial configuration wizard.

- - - {`We have detected your previous Defguard instance and here is what's going to happen`} - - - -
-
-
- -
-
- -
-
-

{`Before installation, we recommend reading our documentation to understand the system architecture and core components.`}

-
- - {`Read documentation`} - -
-
-
-
-
- -
-
- -
-
+ const activeStep = useSetupWizardStore((s) => s.activeStep); + const settingsEssentials = useApp((s) => s.settingsEssentials); + const showWelcome = useSetupWizardStore((s) => s.showWelcome); + const navigate = useNavigate(); + + const stepsConfig = useMemo( + (): Record => ({ + adminUser: { + id: SetupPageStep.AdminUser, + order: 1, + label: m.initial_setup_step_admin_user_label(), + description: m.initial_setup_step_admin_user_description(), + }, + generalConfig: { + id: SetupPageStep.GeneralConfig, + order: 2, + label: m.initial_setup_step_general_config_label(), + description: m.initial_setup_step_general_config_description(), + }, + certificateAuthority: { + id: SetupPageStep.CertificateAuthority, + order: 3, + label: m.initial_setup_step_certificate_authority_label(), + description: m.initial_setup_step_certificate_authority_description(), + }, + certificateAuthoritySummary: { + id: SetupPageStep.CASummary, + order: 4, + label: m.initial_setup_step_certificate_authority_summary_label(), + description: m.initial_setup_step_certificate_authority_summary_description(), + }, + edgeComponent: { + id: SetupPageStep.EdgeComponent, + order: 5, + label: m.initial_setup_step_edge_component_label(), + description: m.initial_setup_step_edge_component_description(), + }, + edgeAdaptation: { + id: SetupPageStep.EdgeAdaptation, + order: 6, + label: m.initial_setup_step_edge_adaptation_label(), + description: m.initial_setup_step_edge_adaptation_description(), + }, + confirmation: { + id: SetupPageStep.Confirmation, + order: 7, + label: m.initial_setup_step_confirmation_label(), + description: m.initial_setup_step_confirmation_description(), + }, + }), + [], + ); + + const stepsComponents = useMemo( + (): Record => ({ + adminUser: , + generalConfig: , + certificateAuthority: , + certificateAuthoritySummary: , + edgeComponent: , + edgeAdaptation: , + confirmation: , + }), + [], + ); + + const handleStartWizard = () => { + useSetupWizardStore.getState().setActiveStep(SetupPageStep.AdminUser); + useSetupWizardStore.setState({ showWelcome: false }); + }; + + const WelcomePageContent = () => ( +
+ + +
); + + useEffect(() => { + if (settingsEssentials.initial_setup_completed) { + navigate({ to: '/vpn-overview', replace: true }); + } + }, [settingsEssentials.initial_setup_completed, navigate]); + + return ( + {}} + subtitle={m.initial_setup_wizard_subtitle()} + title={m.initial_setup_wizard_title()} + steps={stepsConfig} + id="setup-wizard" + showWelcome={showWelcome} + welcomePageConfig={{ + title: m.initial_setup_welcome_title(), + subtitle: m.initial_setup_welcome_subtitle(), + content: , + media: , + }} + > + {stepsComponents[activeStep]} + + ); }; diff --git a/web/src/pages/SetupPage/assets/ca.png b/web/src/pages/SetupPage/assets/ca.png new file mode 100644 index 0000000000..d226215667 Binary files /dev/null and b/web/src/pages/SetupPage/assets/ca.png differ diff --git a/web/src/pages/SetupPage/assets/file_icon.png b/web/src/pages/SetupPage/assets/file-icon.png similarity index 100% rename from web/src/pages/SetupPage/assets/file_icon.png rename to web/src/pages/SetupPage/assets/file-icon.png diff --git a/web/src/pages/SetupPage/assets/location.png b/web/src/pages/SetupPage/assets/location.png new file mode 100644 index 0000000000..0a5e538213 Binary files /dev/null and b/web/src/pages/SetupPage/assets/location.png differ diff --git a/web/src/pages/SetupPage/assets/world_map_poster.png b/web/src/pages/SetupPage/assets/world-map.png similarity index 100% rename from web/src/pages/SetupPage/assets/world_map_poster.png rename to web/src/pages/SetupPage/assets/world-map.png diff --git a/web/src/pages/SetupPage/assets/world_map.mp4 b/web/src/pages/SetupPage/assets/world_map.mp4 deleted file mode 100644 index ebcf111adb..0000000000 Binary files a/web/src/pages/SetupPage/assets/world_map.mp4 and /dev/null differ diff --git a/web/src/pages/SetupPage/steps/SetupAdminUserStep.tsx b/web/src/pages/SetupPage/steps/SetupAdminUserStep.tsx new file mode 100644 index 0000000000..6cc9475710 --- /dev/null +++ b/web/src/pages/SetupPage/steps/SetupAdminUserStep.tsx @@ -0,0 +1,286 @@ +import './style.scss'; + +import { useStore } from '@tanstack/react-form'; +import { useMutation } from '@tanstack/react-query'; +import clsx from 'clsx'; +import { useMemo } from 'react'; +import z from 'zod'; +import { useShallow } from 'zustand/react/shallow'; +import { m } from '../../../paraglide/messages'; +import api from '../../../shared/api/api'; +import { WizardCard } from '../../../shared/components/wizard/WizardCard/WizardCard'; +import { Icon } from '../../../shared/defguard-ui/components/Icon'; +import { ModalControls } from '../../../shared/defguard-ui/components/ModalControls/ModalControls'; +import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { Snackbar } from '../../../shared/defguard-ui/providers/snackbar/snackbar'; +import { ThemeSpacing } from '../../../shared/defguard-ui/types'; +import { useAppForm, withForm } from '../../../shared/form'; +import { formChangeLogic } from '../../../shared/formLogic'; +import { SetupPageStep } from '../types'; +import { useSetupWizardStore } from '../useSetupWizardStore'; + +type FormFields = StoreValues; + +type StoreValues = { + first_name: string; + last_name: string; + username: string; + email: string; + password: string; +}; + +const passwordRules = [ + { + id: 'required', + label: m.initial_setup_admin_user_password_rule_required_label(), + message: m.initial_setup_admin_user_password_rule_required_message(), + test: (value: string) => value.length > 0, + apply: (schema: z.ZodString) => + schema.min(1, m.initial_setup_admin_user_password_rule_required_message()), + }, + { + id: 'min', + label: m.initial_setup_admin_user_password_rule_min_label(), + message: m.initial_setup_admin_user_password_rule_min_message(), + test: (value: string) => value.length >= 8, + apply: (schema: z.ZodString) => + schema.min(8, m.initial_setup_admin_user_password_rule_min_message()), + }, + { + id: 'number', + label: m.initial_setup_admin_user_password_rule_number_label(), + message: m.initial_setup_admin_user_password_rule_number_message(), + test: (value: string) => /[0-9]/.test(value), + apply: (schema: z.ZodString) => + schema.regex(/[0-9]/, m.initial_setup_admin_user_password_rule_number_message()), + }, + { + id: 'special', + label: m.initial_setup_admin_user_password_rule_special_label(), + message: m.initial_setup_admin_user_password_rule_special_message(), + test: (value: string) => /[!@#$%^&*(),.?":{}|<>]/.test(value), + apply: (schema: z.ZodString) => + schema.regex( + /[!@#$%^&*(),.?":{}|<>]/, + m.initial_setup_admin_user_password_rule_special_message(), + ), + }, + { + id: 'lower', + label: m.initial_setup_admin_user_password_rule_lower_label(), + message: m.initial_setup_admin_user_password_rule_lower_message(), + test: (value: string) => /[a-z]/.test(value), + apply: (schema: z.ZodString) => + schema.regex(/[a-z]/, m.initial_setup_admin_user_password_rule_lower_message()), + }, + { + id: 'upper', + label: m.initial_setup_admin_user_password_rule_upper_label(), + message: m.initial_setup_admin_user_password_rule_upper_message(), + test: (value: string) => /[A-Z]/.test(value), + apply: (schema: z.ZodString) => + schema.regex(/[A-Z]/, m.initial_setup_admin_user_password_rule_upper_message()), + }, +]; + +const passwordSchema = passwordRules.reduce( + (schema, rule) => rule.apply(schema), + z.string(), +); + +export const SetupAdminUserStep = () => { + const setActiveStep = useSetupWizardStore((s) => s.setActiveStep); + const defaultValues = useSetupWizardStore( + useShallow( + (s): FormFields => ({ + first_name: s.admin_first_name, + last_name: s.admin_last_name, + username: s.admin_username, + email: s.admin_email, + password: s.admin_password, + }), + ), + ); + + const formSchema = useMemo( + () => + z.object({ + first_name: z + .string() + .min(1, m.initial_setup_admin_user_error_first_name_required()), + last_name: z + .string() + .min(1, m.initial_setup_admin_user_error_last_name_required()), + username: z.string().min(3, m.initial_setup_admin_user_error_username_min()), + email: z + .email(m.initial_setup_admin_user_error_email_invalid()) + .min(1, m.initial_setup_admin_user_error_email_required()), + password: passwordSchema, + }), + [], + ); + + const { mutate, isPending } = useMutation({ + mutationFn: api.initial_setup.createAdminUser, + meta: { + invalidate: ['setupStatus'], + }, + onSuccess: () => { + setActiveStep(SetupPageStep.GeneralConfig); + }, + onError: (error) => { + Snackbar.error(m.initial_setup_admin_user_error_create_failed()); + console.error('Failed to create admin user:', error); + }, + }); + + const form = useAppForm({ + defaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: ({ value }) => { + useSetupWizardStore.setState({ + admin_first_name: value.first_name, + admin_last_name: value.last_name, + admin_username: value.username, + admin_email: value.email, + admin_password: value.password, + }); + mutate({ + first_name: value.first_name, + last_name: value.last_name, + username: value.username, + email: value.email, + password: value.password, + }); + }, + }); + + const handleNext = () => { + form.handleSubmit(); + }; + + return ( + +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + className="setup-admin-user" + > + +
+ + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + +
+ + {(field) => ( + + )} + + + +
+
+ +
+
+ +
+ ); +}; + +const PasswordChecklist = withForm({ + defaultValues: { + first_name: '', + last_name: '', + username: '', + email: '', + password: '', + }, + render: ({ form }) => { + const password = useStore(form.store, (state) => state.values.password ?? ''); + const isPristine = useStore( + form.store, + (state) => state.fieldMeta.password?.isPristine ?? true, + ); + + const checks = passwordRules.map((rule) => ({ + id: rule.id, + label: rule.label, + passed: rule.test(password), + })); + + return ( +
+

{m.initial_setup_admin_user_password_checklist_title()}

+
    + {checks.map((item) => { + const checked = !isPristine && item.passed; + const iconKind = checked ? 'check-filled' : 'empty-point'; + + return ( +
  • + + {item.label} +
  • + ); + })} +
+
+ ); + }, +}); diff --git a/web/src/pages/SetupPage/steps/SetupCertificateAuthorityStep.tsx b/web/src/pages/SetupPage/steps/SetupCertificateAuthorityStep.tsx new file mode 100644 index 0000000000..086699eab6 --- /dev/null +++ b/web/src/pages/SetupPage/steps/SetupCertificateAuthorityStep.tsx @@ -0,0 +1,287 @@ +import { useMutation } from '@tanstack/react-query'; +import { useCallback, useMemo } from 'react'; +import z from 'zod'; +import { useShallow } from 'zustand/react/shallow'; +import { m } from '../../../paraglide/messages'; +import api from '../../../shared/api/api'; +import { WizardCard } from '../../../shared/components/wizard/WizardCard/WizardCard'; +import { InteractiveBlock } from '../../../shared/defguard-ui/components/InteractiveBlock/InteractiveBlock'; +import { ModalControls } from '../../../shared/defguard-ui/components/ModalControls/ModalControls'; +import type { SelectOption } from '../../../shared/defguard-ui/components/Select/types'; +import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { Snackbar } from '../../../shared/defguard-ui/providers/snackbar/snackbar'; +import { ThemeSpacing } from '../../../shared/defguard-ui/types'; +import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; +import { useAppForm } from '../../../shared/form'; +import { formChangeLogic } from '../../../shared/formLogic'; +import { CAOption, type CAOptionType, SetupPageStep } from '../types'; +import { useSetupWizardStore } from '../useSetupWizardStore'; +import './style.scss'; + +type ValidityValue = 1 | 2 | 3 | 5 | 10; + +const validityOptions: SelectOption[] = [ + { key: 1, label: m.initial_setup_ca_validity_one_year(), value: 1 }, + { key: 2, label: m.initial_setup_ca_validity_years({ years: 2 }), value: 2 }, + { key: 3, label: m.initial_setup_ca_validity_years({ years: 3 }), value: 3 }, + { key: 5, label: m.initial_setup_ca_validity_years({ years: 5 }), value: 5 }, + { key: 10, label: m.initial_setup_ca_validity_years({ years: 10 }), value: 10 }, +]; + +type CreateCAFormFields = CreateCAStoreValues; + +type CreateCAStoreValues = { + ca_common_name: string; + ca_email: string; + ca_validity_period_years: number; +}; + +type UploadCAFormFields = UploadCAStoreValues; + +type UploadCAStoreValues = { + ca_cert_file: File | null; +}; + +const readFileAsText = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }); +}; + +export const SetupCertificateAuthorityStep = () => { + const setActiveStep = useSetupWizardStore((s) => s.setActiveStep); + const caOption = useSetupWizardStore((s) => s.ca_option); + const setCAOption = useCallback((option: CAOptionType) => { + useSetupWizardStore.setState({ ca_option: option }); + }, []); + + const createCAdefaultValues = useSetupWizardStore( + useShallow( + (s): CreateCAFormFields => ({ + ca_common_name: s.ca_common_name, + ca_email: s.ca_email, + ca_validity_period_years: s.ca_validity_period_years, + }), + ), + ); + + const uploadCAdefaultValues: UploadCAFormFields = { + ca_cert_file: undefined as unknown as File, + }; + + const createFormSchema = useMemo( + () => + z.object({ + ca_common_name: z + .string() + .min(1, m.initial_setup_ca_error_common_name_required()), + ca_email: z + .email(m.initial_setup_ca_error_email_invalid()) + .min(1, m.initial_setup_ca_error_email_required()), + ca_validity_period_years: z + .number() + .min(1, m.initial_setup_ca_error_validity_min()), + }), + [], + ); + + const uploadFormSchema = useMemo( + () => + z.object({ + ca_cert_file: z + .file() + .refine((file) => isPresent(file), m.initial_setup_ca_error_cert_required()), + }), + [], + ); + + const { mutate: createCA, isPending: isCreatingCA } = useMutation({ + mutationFn: api.initial_setup.createCA, + onSuccess: () => { + setActiveStep(SetupPageStep.CASummary); + }, + onError: (error) => { + console.error('Failed to create CA:', error); + Snackbar.error(m.initial_setup_ca_error_create_failed()); + }, + meta: { + invalidate: ['initial_setup', 'ca'], + }, + }); + + const { mutate: uploadCA, isPending: isUploadingCA } = useMutation({ + mutationFn: api.initial_setup.uploadCA, + onSuccess: () => { + setActiveStep(SetupPageStep.CASummary); + }, + onError: (error) => { + console.error('Failed to upload CA:', error); + Snackbar.error(m.initial_setup_ca_error_upload_failed()); + }, + meta: { + invalidate: ['initial_setup', 'ca'], + }, + }); + + const createForm = useAppForm({ + defaultValues: createCAdefaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: createFormSchema, + onChange: createFormSchema, + }, + onSubmit: ({ value }) => { + useSetupWizardStore.setState({ + ca_common_name: value.ca_common_name, + ca_email: value.ca_email, + ca_validity_period_years: value.ca_validity_period_years, + }); + createCA({ + common_name: value.ca_common_name, + email: value.ca_email, + validity_period_years: value.ca_validity_period_years, + }); + }, + }); + + const uploadForm = useAppForm({ + defaultValues: uploadCAdefaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: uploadFormSchema, + onChange: uploadFormSchema, + }, + onSubmit: async ({ value }) => { + if (!value.ca_cert_file) return; + const certContent = await readFileAsText(value.ca_cert_file); + uploadCA({ cert_file: certContent }); + }, + }); + + const CreateCAForm = () => { + const form = createForm; + return ( +
+
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + +
+
+ ); + }; + + // const UploadCAForm = () => { + // const form = uploadForm; + // return ( + //
{ + // e.stopPropagation(); + // e.preventDefault(); + // form.handleSubmit(); + // }} + // > + // + // + // {(field) => } + // + // + // + //
+ // ); + // }; + + const handleBack = () => { + setActiveStep(SetupPageStep.GeneralConfig); + }; + + const handleNext = () => { + if (caOption === CAOption.Create) { + createForm.handleSubmit(); + } else if (caOption === CAOption.UseOwn) { + uploadForm.handleSubmit(); + } + }; + + const isPending = isCreatingCA || isUploadingCA; + + return ( + + setCAOption(CAOption.Create)} + content={m.initial_setup_ca_option_create_description()} + > + + + {caOption === CAOption.Create && } + + + {/* Temporarily disabled */} + {/* + + setCAOption(CAOption.UseOwn)} + content="Upload your certificate authority certificate and Defguard will use it to issue and configure certificates for components." + > + {caOption === CAOption.UseOwn && } + */} + + + + ); +}; diff --git a/web/src/pages/SetupPage/steps/SetupCertificateAuthoritySummaryStep.tsx b/web/src/pages/SetupPage/steps/SetupCertificateAuthoritySummaryStep.tsx new file mode 100644 index 0000000000..1134b7b5a3 --- /dev/null +++ b/web/src/pages/SetupPage/steps/SetupCertificateAuthoritySummaryStep.tsx @@ -0,0 +1,122 @@ +import { useQuery } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { m } from '../../../paraglide/messages'; +import api from '../../../shared/api/api'; +import { ActionCard } from '../../../shared/components/ActionCard/ActionCard'; +import { WizardCard } from '../../../shared/components/wizard/WizardCard/WizardCard'; +import { Button } from '../../../shared/defguard-ui/components/Button/Button'; +import { Divider } from '../../../shared/defguard-ui/components/Divider/Divider'; +import { ModalControls } from '../../../shared/defguard-ui/components/ModalControls/ModalControls'; +import { ThemeSpacing } from '../../../shared/defguard-ui/types'; +import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; +import { downloadFile } from '../../../shared/utils/download'; +import caIcon from '../assets/ca.png'; +import { CAOption, SetupPageStep } from '../types'; +import { useSetupWizardStore } from '../useSetupWizardStore'; +import './style.scss'; + +export const SetupCertificateAuthoritySummaryStep = () => { + const setActiveStep = useSetupWizardStore((s) => s.setActiveStep); + const caOption = useSetupWizardStore((s) => s.ca_option); + + const { data: caData, isFetching } = useQuery({ + queryKey: ['initial_setup', 'ca'], + queryFn: api.initial_setup.getCA, + select: (resp) => resp.data, + }); + + const handleDownloadCA = useCallback(() => { + const caPem = caData?.ca_cert_pem; + if (!isPresent(caPem)) return; + const blob = new Blob([caPem], { + type: 'application/x-pem-file;charset=utf-8', + }); + downloadFile(blob, 'defguard-ca', 'pem'); + }, [caData?.ca_cert_pem]); + + const handleBack = () => { + setActiveStep(SetupPageStep.CertificateAuthority); + }; + + const handleNext = () => { + setActiveStep(SetupPageStep.EdgeComponent); + }; + + const downloadCA = () => { + return ( + +
+ +
+ ))} +
+ +
+ ); +}; diff --git a/web/src/pages/SetupPage/steps/SetupEdgeComponentStep.tsx b/web/src/pages/SetupPage/steps/SetupEdgeComponentStep.tsx new file mode 100644 index 0000000000..3f0a4c826d --- /dev/null +++ b/web/src/pages/SetupPage/steps/SetupEdgeComponentStep.tsx @@ -0,0 +1,138 @@ +import { useMemo } from 'react'; +import z from 'zod'; +import { useShallow } from 'zustand/react/shallow'; +import { m } from '../../../paraglide/messages'; +import { WizardCard } from '../../../shared/components/wizard/WizardCard/WizardCard'; +import { ModalControls } from '../../../shared/defguard-ui/components/ModalControls/ModalControls'; +import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { ThemeSpacing } from '../../../shared/defguard-ui/types'; +import { useAppForm } from '../../../shared/form'; +import { formChangeLogic } from '../../../shared/formLogic'; +import { validateIpOrDomain } from '../../../shared/validators'; +import { SetupPageStep } from '../types'; +import { useSetupWizardStore } from '../useSetupWizardStore'; + +type FormFields = StoreValues; + +type StoreValues = { + common_name: string; + ip_or_domain: string; + grpc_port: number; + public_domain: string; +}; + +export const SetupEdgeComponentStep = () => { + const setActiveStep = useSetupWizardStore((s) => s.setActiveStep); + + const defaultValues = useSetupWizardStore( + useShallow( + (s): FormFields => ({ + common_name: s.common_name, + ip_or_domain: s.ip_or_domain, + grpc_port: s.grpc_port, + public_domain: s.public_domain, + }), + ), + ); + + const handleNext = () => { + form.handleSubmit(); + }; + + const formSchema = useMemo( + () => + z.object({ + common_name: z + .string() + .min(1, m.edge_setup_component_error_common_name_required()), + ip_or_domain: z + .string() + .min(1, m.edge_setup_component_error_ip_or_domain_required()) + .refine((val) => validateIpOrDomain(val, false, true)), + grpc_port: z + .number() + .min(1, m.edge_setup_component_error_grpc_port_required()) + .max(65535, m.edge_setup_component_error_grpc_port_max()), + public_domain: z + .string() + .min(1, m.edge_setup_component_error_public_domain_required()), + }), + [], + ); + + const form = useAppForm({ + defaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: ({ value }) => { + useSetupWizardStore.setState({ + ...value, + }); + setActiveStep(SetupPageStep.EdgeAdaptation); + }, + }); + + return ( + +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + +
+ +
+ ); +}; diff --git a/web/src/pages/SetupPage/steps/SetupGeneralConfigStep.tsx b/web/src/pages/SetupPage/steps/SetupGeneralConfigStep.tsx new file mode 100644 index 0000000000..44de1c055e --- /dev/null +++ b/web/src/pages/SetupPage/steps/SetupGeneralConfigStep.tsx @@ -0,0 +1,161 @@ +import { useMutation } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import z from 'zod'; +import { useShallow } from 'zustand/react/shallow'; +import { m } from '../../../paraglide/messages'; +import api from '../../../shared/api/api'; +import { WizardCard } from '../../../shared/components/wizard/WizardCard/WizardCard'; +import { ModalControls } from '../../../shared/defguard-ui/components/ModalControls/ModalControls'; +import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { Snackbar } from '../../../shared/defguard-ui/providers/snackbar/snackbar'; +import { ThemeSpacing } from '../../../shared/defguard-ui/types'; +import { useAppForm } from '../../../shared/form'; +import { formChangeLogic } from '../../../shared/formLogic'; +import { SetupPageStep } from '../types'; +import { useSetupWizardStore } from '../useSetupWizardStore'; + +type FormFields = StoreValues; + +type StoreValues = { + defguard_url: string; + default_admin_group_name: string; + default_authentication: number; + default_mfa_code_lifetime: number; +}; + +export const SetupGeneralConfigStep = () => { + const setActiveStep = useSetupWizardStore((s) => s.setActiveStep); + const defaultValues = useSetupWizardStore( + useShallow( + (s): FormFields => ({ + defguard_url: s.defguard_url, + default_admin_group_name: s.default_admin_group_name, + default_authentication: s.default_authentication_period_days, + default_mfa_code_lifetime: s.default_mfa_code_timeout_seconds, + }), + ), + ); + + const formSchema = useMemo( + () => + z.object({ + defguard_url: z + .url(m.initial_setup_general_config_error_invalid_url()) + .min(1, m.initial_setup_general_config_error_defguard_url_required()), + default_admin_group_name: z + .string() + .min(1, m.initial_setup_general_config_error_admin_group_required()), + default_authentication: z + .number() + .min(1, m.initial_setup_general_config_error_auth_period_min()), + default_mfa_code_lifetime: z + .number() + .min(60, m.initial_setup_general_config_error_mfa_timeout_min()), + }), + [], + ); + + const { mutate, isPending } = useMutation({ + mutationFn: api.initial_setup.setGeneralConfig, + meta: { + invalidate: ['setupStatus'], + }, + onSuccess: () => { + setActiveStep(SetupPageStep.CertificateAuthority); + }, + onError: (error) => { + Snackbar.error(m.initial_setup_general_config_error_save_failed()); + console.error('Failed to create admin user:', error); + }, + }); + + const form = useAppForm({ + defaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: formSchema, + onChange: formSchema, + }, + onSubmit: ({ value }) => { + useSetupWizardStore.setState({ + defguard_url: value.defguard_url, + default_admin_group_name: value.default_admin_group_name, + default_authentication_period_days: value.default_authentication, + default_mfa_code_timeout_seconds: value.default_mfa_code_lifetime, + }); + mutate({ + defguard_url: value.defguard_url, + default_admin_group_name: value.default_admin_group_name, + default_authentication: value.default_authentication, + default_mfa_code_lifetime: value.default_mfa_code_lifetime, + admin_username: useSetupWizardStore.getState().admin_username, + }); + }, + }); + + const handleNext = () => { + form.handleSubmit(); + }; + + return ( + +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + +
+ +
+ ); +}; diff --git a/web/src/pages/SetupPage/steps/style.scss b/web/src/pages/SetupPage/steps/style.scss new file mode 100644 index 0000000000..cc52c12525 --- /dev/null +++ b/web/src/pages/SetupPage/steps/style.scss @@ -0,0 +1,140 @@ +.wizard-card { + .admin-user-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-lg); + + & > :nth-child(odd):last-child { + grid-column: 1 / -1; + } + } + + .modal-controls { + & > .buttons { + width: 100%; + display: flex; + justify-content: flex-end; + gap: var(--spacing-md); + + // 1 button = align to right + // 2 buttons = space between + &:has(button:nth-child(2)) { + justify-content: space-between; + } + } + } + + .password-checklist { + & > p { + font: var(--t-body-sm-600); + padding-bottom: var(--spacing-md); + } + + ul { + display: flex; + flex-flow: column; + row-gap: var(--spacing-sm); + + li { + display: flex; + flex-flow: row; + align-items: center; + column-gap: var(--spacing-sm); + + span { + font: var(--t-body-sm-500); + color: var(--fg-muted); + } + + &.active { + span { + color: var(--fg-success); + } + } + + .icon[data-kind='check-filled'] { + path { + fill: var(--fg-success); + } + } + } + } + } + + .ca-info { + .ca-info-title { + font: var(--t-body-sm-500); + } + + .ca-info-grid { + display: grid; + grid-template-columns: 180px 1fr; + gap: var(--spacing-md) var(--spacing-lg); + align-items: center; + } + + .ca-info-label { + color: var(--fg-neutral); + font: var(--t-body-md-400); + } + + .ca-info-value { + color: var(--fg-faded); + font: var(--t-body-md-400); + } + } + + .ca-settings { + padding: var(--spacing-xl); + border-radius: var(--radius-lg); + border: var(--border-1) solid var(--border-default); + + form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-xl) var(--spacing-md); + + & > :nth-child(odd):last-child { + grid-column: span 2; + } + } + } + + .confirmation { + & > .header { + h4 { + color: var(--fg-success); + } + + p { + color: var(--fg-neutral); + font: var(--t-body-lg-400); + } + } + + & > .content { + & > .title { + color: var(--fg-faded); + font: var(--t-body-md-500); + } + + & > .subtitle { + color: var(--fg-muted); + font: var(--t-body-md-400); + } + } + + .action-card { + .content { + display: flex; + align-items: center; + gap: var(--spacing-sm); + + & > p { + color: var(--fg-neutral); + font: var(--t-body-sm-500); + } + } + } + } +} diff --git a/web/src/pages/SetupPage/style.scss b/web/src/pages/SetupPage/style.scss deleted file mode 100644 index ddc476fb58..0000000000 --- a/web/src/pages/SetupPage/style.scss +++ /dev/null @@ -1,132 +0,0 @@ -/* stylelint-disable no-descending-specificity */ -#setup-page { - background-color: var(--bg-muted); - width: 100%; - min-height: 100dvh; -} - -#setup-page > .content-limiter { - display: flex; - flex-flow: row; - align-items: flex-start; - justify-content: center; -} - -#setup-page .page-grid { - display: flex; - flex-flow: column; - align-items: flex-start; - justify-content: flex-start; - width: 100%; - max-width: 1120px; - box-sizing: border-box; - min-height: 100dvh; -} - -#setup-page header { - height: 60px; - display: flex; - flex-flow: row; - align-items: center; - justify-content: flex-start; - padding-top: var(--spacing-2xl); - padding-bottom: var(--spacing-4xl); -} - -#setup-page #content-card { - display: grid; - grid-template-columns: 667fr 443fr; - border-radius: var(--radius-xxl); - background-color: var(--bg-default); - box-shadow: var(--menu-shadow); - overflow: hidden; - - & > .main-track { - box-sizing: border-box; - padding: var(--spacing-4xl); - } - - & > .image { - height: 100%; - width: 100%; - max-width: 100%; - position: relative; - overflow: hidden; - - video { - width: 100%; - min-height: 657px; - min-width: 443px; - object-fit: cover; - } - } -} - -#docs-card { - display: grid; - grid-template-columns: 54px 1fr; - background-color: transparent; - border: var(--border-1) solid var(--border-disabled); - border-radius: var(--radius-lg); - padding: var(--spacing-md); - column-gap: var(--spacing-2xl); - - .image-track { - width: 100%; - max-width: 100%; - overflow: hidden; - - img { - width: 100%; - } - } - - .content { - display: flex; - flex-flow: column; - row-gap: var(--spacing-sm); - - p { - font: var(--t-body-sm-400); - color: var(--fg-muted); - } - } -} - -#setup-page footer { - display: flex; - flex-flow: row; - align-items: center; - justify-content: flex-start; - width: 100%; - margin-top: auto; - padding-top: var(--spacing-4xl); - padding-bottom: var(--spacing-2xl); - - & > div:nth-child(2) { - margin-left: auto; - } - - div p { - font: var(--t-body-xs-400); - color: var(--fg-muted); - - span { - color: inherit; - font: inherit; - } - } - - div:nth-child(1) { - a { - color: inherit; - } - } - - div:nth-child(2) { - a { - color: var(--fg-action); - text-decoration: none; - } - } -} diff --git a/web/src/pages/SetupPage/types.ts b/web/src/pages/SetupPage/types.ts new file mode 100644 index 0000000000..9ddb44c03d --- /dev/null +++ b/web/src/pages/SetupPage/types.ts @@ -0,0 +1,19 @@ +export const SetupPageStep = { + // Welcome: 'welcome', + AdminUser: 'adminUser', + GeneralConfig: 'generalConfig', + CertificateAuthority: 'certificateAuthority', + CASummary: 'certificateAuthoritySummary', + EdgeComponent: 'edgeComponent', + EdgeAdaptation: 'edgeAdaptation', + Confirmation: 'confirmation', +} as const; + +export const CAOption = { + Create: 'create', + UseOwn: 'useOwn', +} as const; + +export type SetupPageStepValue = (typeof SetupPageStep)[keyof typeof SetupPageStep]; + +export type CAOptionType = (typeof CAOption)[keyof typeof CAOption]; diff --git a/web/src/pages/SetupPage/useSetupWizardStore.tsx b/web/src/pages/SetupPage/useSetupWizardStore.tsx new file mode 100644 index 0000000000..3e233a07ad --- /dev/null +++ b/web/src/pages/SetupPage/useSetupWizardStore.tsx @@ -0,0 +1,122 @@ +import { omit } from 'lodash-es'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import type { EdgeAdaptationState } from '../EdgeSetupPage/types'; +import { type CAOptionType, SetupPageStep, type SetupPageStepValue } from './types'; + +const edgeAdaptationStateDefaults: EdgeAdaptationState = { + isProcessing: false, + isComplete: false, + currentStep: null, + errorMessage: null, + proxyVersion: null, + proxyLogs: [], +}; + +type StoreValues = { + showWelcome: boolean; + activeStep: SetupPageStepValue; + // Admin config + admin_first_name: string; + admin_last_name: string; + admin_username: string; + admin_email: string; + admin_password: string; + // General config + defguard_url: string; + default_admin_group_name: string; + default_authentication_period_days: number; + default_mfa_code_timeout_seconds: number; + // CA settings + ca_common_name: string; + ca_email: string; + ca_validity_period_years: number; + ca_cert_file: File | null; + ca_option: CAOptionType | null; + // Edge settings + common_name: string; + ip_or_domain: string; + grpc_port: number; + public_domain: string; + edgeAdaptationState: EdgeAdaptationState; +}; + +type StoreMethods = { + reset: () => void; + start: (values?: Partial) => void; + setActiveStep: (step: SetupPageStepValue) => void; + updateValues: (values: Partial) => void; + resetEdgeAdaptationState: () => void; + setEdgeAdaptationState: (state: Partial) => void; +}; + +const defaults: StoreValues = { + showWelcome: true, + activeStep: SetupPageStep.AdminUser, + // Admin config + admin_first_name: '', + admin_last_name: '', + admin_username: '', + admin_email: '', + admin_password: '', + // General config + defguard_url: '', + default_admin_group_name: 'admin', + default_authentication_period_days: 30, + default_mfa_code_timeout_seconds: 300, + // CA settings + ca_common_name: '', + ca_email: '', + ca_validity_period_years: 5, + ca_cert_file: null, + ca_option: null, + // Edge settings + common_name: '', + ip_or_domain: '', + grpc_port: 50051, + public_domain: '', + edgeAdaptationState: edgeAdaptationStateDefaults, +}; + +export const useSetupWizardStore = create()( + persist( + (set) => ({ + ...defaults, + reset: () => + set({ + ...defaults, + showWelcome: true, + }), + start: (initial) => { + set({ + ...defaults, + ...initial, + activeStep: SetupPageStep.AdminUser, + }); + }, + setActiveStep: (step) => set({ activeStep: step }), + updateValues: (values) => set(values), + resetEdgeAdaptationState: () => + set(() => ({ + edgeAdaptationState: { ...edgeAdaptationStateDefaults }, + })), + setEdgeAdaptationState: (state: Partial) => + set((s) => ({ + edgeAdaptationState: { ...s.edgeAdaptationState, ...state }, + })), + }), + { + name: 'setup-wizard-store', + storage: createJSONStorage(() => sessionStorage), + partialize: (state) => + omit(state, [ + 'reset', + 'start', + 'setActiveStep', + 'updateValues', + 'resetEdgeAdaptationState', + 'setEdgeAdaptationState', + ]), + }, + ), +); diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 421695fee1..f1d8040d34 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -20,12 +20,12 @@ import { Route as AuthMfaRouteImport } from './routes/auth/mfa' import { Route as AuthLoginRouteImport } from './routes/auth/login' import { Route as AuthLoadingRouteImport } from './routes/auth/loading' import { Route as AuthCallbackRouteImport } from './routes/auth/callback' +import { Route as WizardSetupWizardRouteImport } from './routes/_wizard/setup-wizard' import { Route as AuthorizedDefaultRouteImport } from './routes/_authorized/_default' import { Route as AuthMfaWebauthnRouteImport } from './routes/auth/mfa/webauthn' import { Route as AuthMfaTotpRouteImport } from './routes/auth/mfa/totp' import { Route as AuthMfaRecoveryRouteImport } from './routes/auth/mfa/recovery' import { Route as AuthMfaEmailRouteImport } from './routes/auth/mfa/email' -import { Route as AuthorizedWizardSetupWizardRouteImport } from './routes/_authorized/_wizard/setup-wizard' import { Route as AuthorizedWizardGatewayWizardRouteImport } from './routes/_authorized/_wizard/gateway-wizard' import { Route as AuthorizedWizardEdgeWizardRouteImport } from './routes/_authorized/_wizard/edge-wizard' import { Route as AuthorizedWizardAddLocationRouteImport } from './routes/_authorized/_wizard/add-location' @@ -112,6 +112,11 @@ const AuthCallbackRoute = AuthCallbackRouteImport.update({ path: '/callback', getParentRoute: () => AuthRoute, } as any) +const WizardSetupWizardRoute = WizardSetupWizardRouteImport.update({ + id: '/_wizard/setup-wizard', + path: '/setup-wizard', + getParentRoute: () => rootRouteImport, +} as any) const AuthorizedDefaultRoute = AuthorizedDefaultRouteImport.update({ id: '/_default', getParentRoute: () => AuthorizedRoute, @@ -136,12 +141,6 @@ const AuthMfaEmailRoute = AuthMfaEmailRouteImport.update({ path: '/email', getParentRoute: () => AuthMfaRoute, } as any) -const AuthorizedWizardSetupWizardRoute = - AuthorizedWizardSetupWizardRouteImport.update({ - id: '/_wizard/setup-wizard', - path: '/setup-wizard', - getParentRoute: () => AuthorizedRoute, - } as any) const AuthorizedWizardGatewayWizardRoute = AuthorizedWizardGatewayWizardRouteImport.update({ id: '/_wizard/gateway-wizard', @@ -328,11 +327,11 @@ const AuthorizedDefaultEdgeEdgeIdEditRoute = export interface FileRoutesByFullPath { '/404': typeof R404Route - '/': typeof AuthorizedDefaultRouteWithChildren '/auth': typeof AuthRouteWithChildren '/consent': typeof ConsentRoute '/playground': typeof PlaygroundRoute '/snackbar': typeof SnackbarRoute + '/setup-wizard': typeof WizardSetupWizardRoute '/auth/callback': typeof AuthCallbackRoute '/auth/loading': typeof AuthLoadingRoute '/auth/login': typeof AuthLoginRoute @@ -348,7 +347,6 @@ export interface FileRoutesByFullPath { '/add-location': typeof AuthorizedWizardAddLocationRoute '/edge-wizard': typeof AuthorizedWizardEdgeWizardRoute '/gateway-wizard': typeof AuthorizedWizardGatewayWizardRoute - '/setup-wizard': typeof AuthorizedWizardSetupWizardRoute '/auth/mfa/email': typeof AuthMfaEmailRoute '/auth/mfa/recovery': typeof AuthMfaRecoveryRoute '/auth/mfa/totp': typeof AuthMfaTotpRoute @@ -368,19 +366,19 @@ export interface FileRoutesByFullPath { '/settings/smtp': typeof AuthorizedDefaultSettingsSmtpRoute '/user/$username': typeof AuthorizedDefaultUserUsernameRoute '/vpn-overview/$locationId': typeof AuthorizedDefaultVpnOverviewLocationIdRoute - '/edge/': typeof AuthorizedDefaultEdgeIndexRoute - '/locations/': typeof AuthorizedDefaultLocationsIndexRoute - '/settings/': typeof AuthorizedDefaultSettingsIndexRoute - '/vpn-overview/': typeof AuthorizedDefaultVpnOverviewIndexRoute + '/edge': typeof AuthorizedDefaultEdgeIndexRoute + '/locations': typeof AuthorizedDefaultLocationsIndexRoute + '/settings': typeof AuthorizedDefaultSettingsIndexRoute + '/vpn-overview': typeof AuthorizedDefaultVpnOverviewIndexRoute '/edge/$edgeId/edit': typeof AuthorizedDefaultEdgeEdgeIdEditRoute '/locations/$locationId/edit': typeof AuthorizedDefaultLocationsLocationIdEditRoute } export interface FileRoutesByTo { '/404': typeof R404Route - '/': typeof AuthorizedDefaultRouteWithChildren '/consent': typeof ConsentRoute '/playground': typeof PlaygroundRoute '/snackbar': typeof SnackbarRoute + '/setup-wizard': typeof WizardSetupWizardRoute '/auth/callback': typeof AuthCallbackRoute '/auth/loading': typeof AuthLoadingRoute '/auth/login': typeof AuthLoginRoute @@ -396,7 +394,6 @@ export interface FileRoutesByTo { '/add-location': typeof AuthorizedWizardAddLocationRoute '/edge-wizard': typeof AuthorizedWizardEdgeWizardRoute '/gateway-wizard': typeof AuthorizedWizardGatewayWizardRoute - '/setup-wizard': typeof AuthorizedWizardSetupWizardRoute '/auth/mfa/email': typeof AuthMfaEmailRoute '/auth/mfa/recovery': typeof AuthMfaRecoveryRoute '/auth/mfa/totp': typeof AuthMfaTotpRoute @@ -432,6 +429,7 @@ export interface FileRoutesById { '/playground': typeof PlaygroundRoute '/snackbar': typeof SnackbarRoute '/_authorized/_default': typeof AuthorizedDefaultRouteWithChildren + '/_wizard/setup-wizard': typeof WizardSetupWizardRoute '/auth/callback': typeof AuthCallbackRoute '/auth/loading': typeof AuthLoadingRoute '/auth/login': typeof AuthLoginRoute @@ -447,7 +445,6 @@ export interface FileRoutesById { '/_authorized/_wizard/add-location': typeof AuthorizedWizardAddLocationRoute '/_authorized/_wizard/edge-wizard': typeof AuthorizedWizardEdgeWizardRoute '/_authorized/_wizard/gateway-wizard': typeof AuthorizedWizardGatewayWizardRoute - '/_authorized/_wizard/setup-wizard': typeof AuthorizedWizardSetupWizardRoute '/auth/mfa/email': typeof AuthMfaEmailRoute '/auth/mfa/recovery': typeof AuthMfaRecoveryRoute '/auth/mfa/totp': typeof AuthMfaTotpRoute @@ -478,11 +475,11 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/404' - | '/' | '/auth' | '/consent' | '/playground' | '/snackbar' + | '/setup-wizard' | '/auth/callback' | '/auth/loading' | '/auth/login' @@ -498,7 +495,6 @@ export interface FileRouteTypes { | '/add-location' | '/edge-wizard' | '/gateway-wizard' - | '/setup-wizard' | '/auth/mfa/email' | '/auth/mfa/recovery' | '/auth/mfa/totp' @@ -518,19 +514,19 @@ export interface FileRouteTypes { | '/settings/smtp' | '/user/$username' | '/vpn-overview/$locationId' - | '/edge/' - | '/locations/' - | '/settings/' - | '/vpn-overview/' + | '/edge' + | '/locations' + | '/settings' + | '/vpn-overview' | '/edge/$edgeId/edit' | '/locations/$locationId/edit' fileRoutesByTo: FileRoutesByTo to: | '/404' - | '/' | '/consent' | '/playground' | '/snackbar' + | '/setup-wizard' | '/auth/callback' | '/auth/loading' | '/auth/login' @@ -546,7 +542,6 @@ export interface FileRouteTypes { | '/add-location' | '/edge-wizard' | '/gateway-wizard' - | '/setup-wizard' | '/auth/mfa/email' | '/auth/mfa/recovery' | '/auth/mfa/totp' @@ -581,6 +576,7 @@ export interface FileRouteTypes { | '/playground' | '/snackbar' | '/_authorized/_default' + | '/_wizard/setup-wizard' | '/auth/callback' | '/auth/loading' | '/auth/login' @@ -596,7 +592,6 @@ export interface FileRouteTypes { | '/_authorized/_wizard/add-location' | '/_authorized/_wizard/edge-wizard' | '/_authorized/_wizard/gateway-wizard' - | '/_authorized/_wizard/setup-wizard' | '/auth/mfa/email' | '/auth/mfa/recovery' | '/auth/mfa/totp' @@ -631,6 +626,7 @@ export interface RootRouteChildren { ConsentRoute: typeof ConsentRoute PlaygroundRoute: typeof PlaygroundRoute SnackbarRoute: typeof SnackbarRoute + WizardSetupWizardRoute: typeof WizardSetupWizardRoute } declare module '@tanstack/react-router' { @@ -666,7 +662,7 @@ declare module '@tanstack/react-router' { '/_authorized': { id: '/_authorized' path: '' - fullPath: '/' + fullPath: '' preLoaderRoute: typeof AuthorizedRouteImport parentRoute: typeof rootRouteImport } @@ -712,10 +708,17 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthCallbackRouteImport parentRoute: typeof AuthRoute } + '/_wizard/setup-wizard': { + id: '/_wizard/setup-wizard' + path: '/setup-wizard' + fullPath: '/setup-wizard' + preLoaderRoute: typeof WizardSetupWizardRouteImport + parentRoute: typeof rootRouteImport + } '/_authorized/_default': { id: '/_authorized/_default' path: '' - fullPath: '/' + fullPath: '' preLoaderRoute: typeof AuthorizedDefaultRouteImport parentRoute: typeof AuthorizedRoute } @@ -747,13 +750,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthMfaEmailRouteImport parentRoute: typeof AuthMfaRoute } - '/_authorized/_wizard/setup-wizard': { - id: '/_authorized/_wizard/setup-wizard' - path: '/setup-wizard' - fullPath: '/setup-wizard' - preLoaderRoute: typeof AuthorizedWizardSetupWizardRouteImport - parentRoute: typeof AuthorizedRoute - } '/_authorized/_wizard/gateway-wizard': { id: '/_authorized/_wizard/gateway-wizard' path: '/gateway-wizard' @@ -827,28 +823,28 @@ declare module '@tanstack/react-router' { '/_authorized/_default/vpn-overview/': { id: '/_authorized/_default/vpn-overview/' path: '/vpn-overview' - fullPath: '/vpn-overview/' + fullPath: '/vpn-overview' preLoaderRoute: typeof AuthorizedDefaultVpnOverviewIndexRouteImport parentRoute: typeof AuthorizedDefaultRoute } '/_authorized/_default/settings/': { id: '/_authorized/_default/settings/' path: '/settings' - fullPath: '/settings/' + fullPath: '/settings' preLoaderRoute: typeof AuthorizedDefaultSettingsIndexRouteImport parentRoute: typeof AuthorizedDefaultRoute } '/_authorized/_default/locations/': { id: '/_authorized/_default/locations/' path: '/locations' - fullPath: '/locations/' + fullPath: '/locations' preLoaderRoute: typeof AuthorizedDefaultLocationsIndexRouteImport parentRoute: typeof AuthorizedDefaultRoute } '/_authorized/_default/edge/': { id: '/_authorized/_default/edge/' path: '/edge' - fullPath: '/edge/' + fullPath: '/edge' preLoaderRoute: typeof AuthorizedDefaultEdgeIndexRouteImport parentRoute: typeof AuthorizedDefaultRoute } @@ -1051,7 +1047,6 @@ interface AuthorizedRouteChildren { AuthorizedWizardAddLocationRoute: typeof AuthorizedWizardAddLocationRoute AuthorizedWizardEdgeWizardRoute: typeof AuthorizedWizardEdgeWizardRoute AuthorizedWizardGatewayWizardRoute: typeof AuthorizedWizardGatewayWizardRoute - AuthorizedWizardSetupWizardRoute: typeof AuthorizedWizardSetupWizardRoute } const AuthorizedRouteChildren: AuthorizedRouteChildren = { @@ -1061,7 +1056,6 @@ const AuthorizedRouteChildren: AuthorizedRouteChildren = { AuthorizedWizardAddLocationRoute: AuthorizedWizardAddLocationRoute, AuthorizedWizardEdgeWizardRoute: AuthorizedWizardEdgeWizardRoute, AuthorizedWizardGatewayWizardRoute: AuthorizedWizardGatewayWizardRoute, - AuthorizedWizardSetupWizardRoute: AuthorizedWizardSetupWizardRoute, } const AuthorizedRouteWithChildren = AuthorizedRoute._addFileChildren( @@ -1110,6 +1104,7 @@ const rootRouteChildren: RootRouteChildren = { ConsentRoute: ConsentRoute, PlaygroundRoute: PlaygroundRoute, SnackbarRoute: SnackbarRoute, + WizardSetupWizardRoute: WizardSetupWizardRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 6171afc02d..870e8dcff5 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -1,6 +1,7 @@ import type { QueryClient } from '@tanstack/react-query'; import { createRootRouteWithContext, Outlet, redirect } from '@tanstack/react-router'; import { AppLoaderPage } from '../pages/AppLoaderPage/AppLoaderPage'; +import { useSetupWizardStore } from '../pages/SetupPage/useSetupWizardStore'; import api from '../shared/api/api'; import { SnackbarManager } from '../shared/defguard-ui/providers/snackbar/SnackbarManager'; import { isPresent } from '../shared/defguard-ui/utils/isPresent'; @@ -13,13 +14,45 @@ interface RouterContext { export const Route = createRootRouteWithContext()({ component: RootComponent, beforeLoad: async ({ location }) => { + const appInfo = await api.settings + .getSettingsEssentials() + .catch((err) => { + console.error('Failed to fetch settings essentials:', err); + return null; + }) + .then((res) => res?.data); + + if ( + // Tries to access any route but setup is not completed + appInfo && + !appInfo.initial_setup_completed && + !location.pathname.startsWith('/setup-wizard') + ) { + useSetupWizardStore.getState().reset(); + throw redirect({ to: '/setup-wizard', replace: true }); + } else if ( + // Tries to access setup wizard but setup is already completed + appInfo?.initial_setup_completed && + location.pathname.startsWith('/setup-wizard') + ) { + throw redirect({ to: '/vpn-overview', replace: true }); + } + // only auto check for auth state if route is not in /auth flow if (location.pathname.startsWith('/auth')) { return; } + if (!isPresent(useAuth.getState().user)) { try { - const { data: user } = await api.user.getMe(); + const { data: user } = await api.user.getMe().catch(() => { + throw redirect({ to: '/auth', replace: true }); + }); + + if (!user.id) { + throw redirect({ to: '/auth', replace: true }); + } + useAuth.getState().setUser(user); if (user.is_admin) { throw redirect({ to: '/vpn-overview', replace: true }); diff --git a/web/src/routes/_authorized/_wizard/setup-wizard.tsx b/web/src/routes/_authorized/_wizard/setup-wizard.tsx deleted file mode 100644 index 69069126f4..0000000000 --- a/web/src/routes/_authorized/_wizard/setup-wizard.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { SetupPage } from '../../../pages/SetupPage/SetupPage'; - -export const Route = createFileRoute('/_authorized/_wizard/setup-wizard')({ - component: SetupPage, -}); diff --git a/web/src/routes/_wizard/setup-wizard.tsx b/web/src/routes/_wizard/setup-wizard.tsx new file mode 100644 index 0000000000..74949383a8 --- /dev/null +++ b/web/src/routes/_wizard/setup-wizard.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { SetupPage } from '../../pages/SetupPage/SetupPage'; + +export const Route = createFileRoute('/_wizard/setup-wizard')({ + component: SetupPage, +}); diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index 66ba817d74..c10e8a1dd2 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -33,6 +33,8 @@ import type { ChangeAccountActiveRequest, ChangeWebhookStateRequest, CreateActivityLogStreamRequest, + CreateAdminRequest, + CreateCARequest, CreateGroupRequest, DeleteApiTokenRequest, DeleteAuthKeyRequest, @@ -49,7 +51,7 @@ import type { EditOpenIdClientActiveStateRequest, EnableMfaMethodResponse, GatewayStatus, - GatewayTokenResponse, + GetCAResponse, GroupInfo, GroupsResponse, IpValidation, @@ -69,12 +71,15 @@ import type { PaginatedResponse, RenameApiTokenRequest, RenameAuthKeyRequest, + SetGeneralConfigRequest, Settings, SettingsEnterprise, + SettingsEssentials, StartEnrollmentRequest, StartEnrollmentResponse, TestDirectorySyncResponse, TotpInitResponse, + UploadCARequest, User, UserChangePasswordRequest, UserDevice, @@ -101,6 +106,16 @@ const api = { } return res; }, + initial_setup: { + createCA: (data: CreateCARequest) => client.post('/initial_setup/ca', data), + getCA: () => client.get('/initial_setup/ca'), + uploadCA: (data: UploadCARequest) => client.post('/initial_setup/ca/upload', data), + createAdminUser: (data: CreateAdminRequest) => + client.post('/initial_setup/admin', data), + setGeneralConfig: (data: SetGeneralConfigRequest) => + client.post('/initial_setup/general_config', data), + finishSetup: () => client.post('/initial_setup/finish'), + }, openid: { authInfo: () => client.get(`/openid/auth_info`), callback: (data: unknown) => client.post(`/openid/callback`, data), @@ -289,8 +304,6 @@ const api = { : undefined, }, }), - getGatewayToken: (networkId: number) => - client.get(`/network/${networkId}/token`), getLocationGatewaysStatus: (id: number) => client.get(`/network/${id}/gateways`), deleteGateway: ({ gatewayId, networkId }: DeleteGatewayRequest) => @@ -344,6 +357,7 @@ const api = { getEnterpriseSettings: () => client.get('/settings_enterprise'), patchEnterpriseSettings: (data: Partial) => client.patch('/settings_enterprise', data), + getSettingsEssentials: () => client.get('/settings_essentials'), }, openIdProvider: { getOpenIdProvider: () => client.get('/openid/provider'), diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 5851e73744..bc3c070111 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -7,6 +7,41 @@ export interface GatewayTokenResponse { grpc_url: string; token: string; } + +export interface CreateCARequest { + common_name: string; + email: string; + validity_period_years: number; +} + +export interface GetCAResponse { + ca_cert_pem: string; + subject_common_name: string; + not_before: string; + not_after: string; + valid_for_days: number; +} + +export interface UploadCARequest { + cert_file: string; +} + +export interface CreateAdminRequest { + first_name: string; + last_name: string; + username: string; + email: string; + password: string; +} + +export interface SetGeneralConfigRequest { + defguard_url: string; + default_admin_group_name: string; + default_authentication: number; + default_mfa_code_lifetime: number; + admin_username: string; +} + export interface ValidateDeviceIpsRequest { ips: string[]; locationId: number; @@ -588,6 +623,10 @@ export interface SettingsEnterprise { only_client_activation: boolean; } +export interface SettingsEssentials { + initial_setup_completed: boolean; +} + export const SmtpEncryption = { None: 'None', StartTls: 'StartTls', diff --git a/web/src/shared/components/GatewaysStatusBadge/GatewaysStatusBadge.tsx b/web/src/shared/components/GatewaysStatusBadge/GatewaysStatusBadge.tsx index 7d9194d202..b766ef6be6 100644 --- a/web/src/shared/components/GatewaysStatusBadge/GatewaysStatusBadge.tsx +++ b/web/src/shared/components/GatewaysStatusBadge/GatewaysStatusBadge.tsx @@ -20,13 +20,13 @@ import type { IconKindValue } from '../../defguard-ui/components/Icon/icon-types import { InteractionBox } from '../../defguard-ui/components/InteractionBox/InteractionBox'; import './style.scss'; import { useMutation } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { useGatewayWizardStore } from '../../../pages/GatewaySetupPage/useGatewayWizardStore'; import api from '../../api/api'; import { Divider } from '../../defguard-ui/components/Divider/Divider'; import { Icon } from '../../defguard-ui/components/Icon'; import { SizedBox } from '../../defguard-ui/components/SizedBox/SizedBox'; import { ThemeSpacing } from '../../defguard-ui/types'; -import { openModal } from '../../hooks/modalControls/modalsSubjects'; -import { ModalName } from '../../hooks/modalControls/modalTypes'; type Status = 'all' | 'none' | 'some'; @@ -148,16 +148,7 @@ const FloatingMenu = ({ const networkId = status[0].network_id as number; const connected = useMemo(() => status.filter((gw) => gw.connected), [status]); const disconnected = useMemo(() => status.filter((gw) => !gw.connected), [status]); - - const { mutate: getToken, isPending } = useMutation({ - mutationFn: api.location.getGatewayToken, - onSuccess: ({ data }) => { - openModal(ModalName.GatewaySetup, { - data: data, - networkId, - }); - }, - }); + const navigate = useNavigate(); const { mutate: removeGw } = useMutation({ mutationFn: api.location.deleteGateway, @@ -223,9 +214,9 @@ const FloatingMenu = ({ size="big" variant="outlined" text="Add more gateways" - loading={isPending} onClick={() => { - getToken(networkId); + useGatewayWizardStore.getState().start({ network_id: networkId }); + navigate({ to: '/gateway-wizard', replace: true }); }} /> diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index 9fb775859b..86d8f0ca9d 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit 9fb775859bdbd7636130b9d79ec163a661f0a3c0 +Subproject commit 86d8f0ca9d68f1ddeaada0c8060b050f4aeafe58 diff --git a/web/src/shared/hooks/useApp.tsx b/web/src/shared/hooks/useApp.tsx index 60c222ad8c..8be265f222 100644 --- a/web/src/shared/hooks/useApp.tsx +++ b/web/src/shared/hooks/useApp.tsx @@ -1,9 +1,10 @@ import { create } from 'zustand'; -import type { ApplicationInfo } from '../api/types'; +import type { ApplicationInfo, SettingsEssentials } from '../api/types'; type StoreValues = { navigationOpen: boolean; appInfo: ApplicationInfo; + settingsEssentials: SettingsEssentials; }; type Store = StoreValues; @@ -30,6 +31,9 @@ const defaults: StoreValues = { smtp_enabled: false, version: '', }, + settingsEssentials: { + initial_setup_completed: false, + }, }; export const useApp = create(() => ({ ...defaults })); diff --git a/web/src/hooks/useSSEController.tsx b/web/src/shared/hooks/useSSEController.tsx similarity index 100% rename from web/src/hooks/useSSEController.tsx rename to web/src/shared/hooks/useSSEController.tsx diff --git a/web/src/shared/providers/AppConfigProvider.tsx b/web/src/shared/providers/AppConfigProvider.tsx index 814d143296..24ea130872 100644 --- a/web/src/shared/providers/AppConfigProvider.tsx +++ b/web/src/shared/providers/AppConfigProvider.tsx @@ -24,5 +24,22 @@ export const AppConfigProvider = ({ children }: PropsWithChildren) => { } }, [appInfoResponse]); + const { data: settingsEssentials } = useQuery({ + queryFn: api.settings.getSettingsEssentials, + queryKey: ['settings-essentials'], + enabled: isAuthenticated, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchOnMount: true, + }); + + useEffect(() => { + if (isPresent(settingsEssentials)) { + useApp.setState({ + settingsEssentials: settingsEssentials.data, + }); + } + }, [settingsEssentials]); + return <>{children}; };