diff --git a/Cargo.lock b/Cargo.lock index 61a8e310bf..a5bb989fbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1405,6 +1405,7 @@ dependencies = [ "reqwest", "rsa", "rust-ini", + "rustls", "secrecy", "semver", "serde", diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index 08ea537f08..fc84610450 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -233,6 +233,7 @@ async fn main() -> Result<(), anyhow::Error> { update_counts(&pool).await?; let (proxy_control_tx, proxy_control_rx) = channel::(100); + let (web_reload_tx, _web_reload_rx) = tokio::sync::broadcast::channel::<()>(8); let proxy_secret_key = settings.secret_key_required()?; let proxy_manager = ProxyManager::new( pool.clone(), @@ -270,6 +271,7 @@ async fn main() -> Result<(), anyhow::Error> { webhook_tx, webhook_rx, gateway_tx.clone(), + web_reload_tx, pool.clone(), failed_logins, api_event_tx, diff --git a/crates/defguard_certs/src/lib.rs b/crates/defguard_certs/src/lib.rs index 4fb9f3e124..2751875d81 100644 --- a/crates/defguard_certs/src/lib.rs +++ b/crates/defguard_certs/src/lib.rs @@ -9,7 +9,10 @@ use rcgen::{ use rustls_pki_types::{CertificateDer, CertificateSigningRequestDer, pem::PemObject}; use thiserror::Error; use time::{Duration, OffsetDateTime}; -use x509_parser::parse_x509_certificate; +use x509_parser::{ + extensions::{GeneralName, ParsedExtension}, + parse_x509_certificate, +}; const CA_NAME: &str = "Defguard CA"; const NOT_BEFORE_OFFSET_SECS: Duration = Duration::minutes(5); @@ -144,6 +147,7 @@ impl CertificateAuthority<'_> { pub struct CertificateInfo { pub subject_common_name: String, + pub subject_email: Option, pub not_before: NaiveDateTime, pub not_after: NaiveDateTime, pub serial: String, @@ -158,6 +162,19 @@ impl CertificateInfo { let subject = &parsed.tbs_certificate.subject; let serial = parsed.raw_serial_as_string(); + let subject_email = parsed + .tbs_certificate + .extensions() + .iter() + .filter_map(|ext| match ext.parsed_extension() { + ParsedExtension::SubjectAlternativeName(san) => Some(san), + _ => None, + }) + .flat_map(|san| san.general_names.iter()) + .find_map(|name| match name { + GeneralName::RFC822Name(email) => Some(email.to_string()), + _ => None, + }); let cn = subject .iter_common_name() @@ -174,6 +191,7 @@ impl CertificateInfo { Ok(Self { subject_common_name: cn.to_string(), + subject_email, not_before: chrono::DateTime::from_timestamp(not_before.unix_timestamp(), 0) .ok_or_else(|| { CertificateError::ParsingError(format!( @@ -425,30 +443,15 @@ mod tests { #[test] fn test_ca_email() { - use x509_parser::parse_x509_certificate; - let expected_email = "contact@defguard.net"; let ca = CertificateAuthority::new("Test CA", expected_email, 365).unwrap(); - let (_rem, parsed) = parse_x509_certificate(ca.cert_der()).unwrap(); + let info = CertificateInfo::from_der(ca.cert_der()).unwrap(); - let san_ext = parsed - .tbs_certificate - .extensions() - .iter() - .find(|ext| ext.oid == x509_parser::oid_registry::OID_X509_EXT_SUBJECT_ALT_NAME) - .expect("Subject Alternative Name extension not found"); - - let san_value = san_ext.value; - - let email_bytes = expected_email.as_bytes(); - let email_found = san_value - .windows(email_bytes.len()) - .any(|window| window == email_bytes); - - assert!( - email_found, - "Email '{expected_email}' should be present in Subject Alternative Names" + assert_eq!( + info.subject_email.as_deref(), + Some(expected_email), + "Email should be parsed from Subject Alternative Names" ); } diff --git a/crates/defguard_common/src/types/proxy.rs b/crates/defguard_common/src/types/proxy.rs index 9867894f9d..503f5996f1 100644 --- a/crates/defguard_common/src/types/proxy.rs +++ b/crates/defguard_common/src/types/proxy.rs @@ -14,6 +14,7 @@ pub enum ProxyControlMessage { cert_pem: String, key_pem: String, }, + ClearHttpsCerts, } #[derive(ToSchema, Serialize)] diff --git a/crates/defguard_core/Cargo.toml b/crates/defguard_core/Cargo.toml index 600a46cb88..b089f91a46 100644 --- a/crates/defguard_core/Cargo.toml +++ b/crates/defguard_core/Cargo.toml @@ -46,6 +46,7 @@ rand = { workspace = true } reqwest = { workspace = true } rsa = { workspace = true } rust-ini = { workspace = true } +rustls = { workspace = true } secrecy = { workspace = true } semver = { workspace = true } serde = { workspace = true } diff --git a/crates/defguard_core/src/appstate.rs b/crates/defguard_core/src/appstate.rs index 644a93914d..7243cf6a4e 100644 --- a/crates/defguard_core/src/appstate.rs +++ b/crates/defguard_core/src/appstate.rs @@ -30,6 +30,7 @@ pub struct AppState { pub pool: PgPool, tx: UnboundedSender, pub wireguard_tx: Sender, + pub web_reload_tx: tokio::sync::broadcast::Sender<()>, pub failed_logins: Arc>, key: Key, pub event_tx: UnboundedSender, @@ -116,6 +117,7 @@ impl AppState { tx: UnboundedSender, rx: UnboundedReceiver, wireguard_tx: Sender, + web_reload_tx: tokio::sync::broadcast::Sender<()>, key: Key, failed_logins: Arc>, event_tx: UnboundedSender, @@ -128,6 +130,7 @@ impl AppState { pool, tx, wireguard_tx, + web_reload_tx, failed_logins, key, event_tx, diff --git a/crates/defguard_core/src/cert_settings.rs b/crates/defguard_core/src/cert_settings.rs new file mode 100644 index 0000000000..586468e68e --- /dev/null +++ b/crates/defguard_core/src/cert_settings.rs @@ -0,0 +1,302 @@ +use axum_server::tls_rustls::RustlsConfig; +use defguard_certs::{ + CertificateAuthority, CertificateInfo, Csr, DnType, PemLabel, der_to_pem, generate_key_pair, + parse_pem_certificate, +}; +use defguard_common::db::models::{ + Certificates, CoreCertSource, ProxyCertSource, settings::update_current_settings, +}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use utoipa::ToSchema; + +use crate::error::WebError; + +/// Ensures cert & key pair are valid to avoid bricking the web server after restart. +async fn validate_uploaded_cert_pair(cert_pem: &str, key_pem: &str) -> Result<(), WebError> { + let _ = rustls::crypto::ring::default_provider().install_default(); + + RustlsConfig::from_pem(cert_pem.as_bytes().to_vec(), key_pem.as_bytes().to_vec()) + .await + .map(|_| ()) + .map_err(|_| WebError::BadRequest("Invalid certificate or private key PEM".to_string())) +} + +/// SSL configuration type for Defguard's internal (core) web server. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum InternalSslType { + /// No SSL - plain HTTP, user manages reverse proxy / SSL termination themselves. + None, + /// Generate certificates using Defguard's internal Certificate Authority. + DefguardCa, + /// Upload a custom certificate and private key. + OwnCert, +} + +#[derive(Serialize, Deserialize, Debug, ToSchema)] +pub struct InternalUrlSettingsConfig { + pub ssl_type: InternalSslType, + pub cert_pem: Option, + pub key_pem: Option, +} + +#[derive(Serialize, Debug, ToSchema)] +pub struct CertInfoResponse { + pub common_name: String, + pub valid_for_days: i64, + pub not_before: String, + pub not_after: String, +} + +/// SSL configuration type for the external (proxy) web server. +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSslType { + /// No SSL - plain HTTP, user manages reverse proxy / SSL termination themselves. + #[default] + None, + /// Obtain a certificate via ACME / Let's Encrypt. + LetsEncrypt, + /// Generate certificates using Defguard's internal Certificate Authority. + DefguardCa, + /// Upload a custom certificate and private key. + OwnCert, +} + +#[derive(Serialize, Deserialize, Debug, ToSchema)] +pub struct ExternalUrlSettingsConfig { + pub ssl_type: ExternalSslType, + pub cert_pem: Option, + pub key_pem: Option, +} + +/// Core logic for applying internal URL certificate settings using the current Defguard URL. +/// Returns cert info if a certificate was generated/uploaded, `None` for `ssl_type = None`. +pub async fn apply_internal_url_settings( + pool: &PgPool, + defguard_url: &str, + config: InternalUrlSettingsConfig, +) -> Result, WebError> { + debug!( + "Internal URL certificate settings received: defguard_url={}, ssl_type={:?}", + defguard_url, config.ssl_type, + ); + + let mut settings = defguard_common::db::models::Settings::get_current_settings(); + settings.defguard_url = defguard_url.to_string(); + update_current_settings(pool, settings).await?; + + let mut certs = Certificates::get_or_default(pool) + .await + .map_err(WebError::from)?; + + let cert_info = match config.ssl_type { + InternalSslType::None => { + certs.core_http_cert_source = CoreCertSource::None; + certs.core_http_cert_pem = None; + certs.core_http_cert_key_pem = None; + certs.core_http_cert_expiry = None; + certs.save(pool).await.map_err(WebError::from)?; + None + } + InternalSslType::DefguardCa => { + let hostname = reqwest::Url::parse(defguard_url) + .ok() + .and_then(|u| u.host_str().map(ToString::to_string)) + .unwrap_or_else(|| defguard_url.to_string()); + + let ca_cert_der = certs.ca_cert_der.as_ref().ok_or_else(|| { + WebError::BadRequest( + "CA certificate is not present; generate a CA first".to_string(), + ) + })?; + let ca_key_der = certs.ca_key_der.as_ref().ok_or_else(|| { + WebError::BadRequest("CA private key not available for signing".to_string()) + })?; + + let ca = CertificateAuthority::from_cert_der_key_pair(ca_cert_der, ca_key_der)?; + let key_pair = generate_key_pair()?; + let san = vec![hostname.clone()]; + let dn = vec![(DnType::CommonName, hostname.as_str())]; + let csr = Csr::new(&key_pair, &san, dn)?; + let server_cert = ca.sign_csr(&csr)?; + + let cert_der = server_cert.der().to_vec(); + let cert_pem = der_to_pem(&cert_der, PemLabel::Certificate)?; + let key_pem = der_to_pem(key_pair.serialize_der().as_slice(), PemLabel::PrivateKey)?; + let info = CertificateInfo::from_der(&cert_der)?; + let valid_for_days = (info.not_after.and_utc() - chrono::Utc::now()).num_days(); + let expiry = info.not_after; + + certs.core_http_cert_source = CoreCertSource::SelfSigned; + certs.core_http_cert_pem = Some(cert_pem); + certs.core_http_cert_key_pem = Some(key_pem); + certs.core_http_cert_expiry = Some(expiry); + certs.save(pool).await.map_err(WebError::from)?; + + Some(CertInfoResponse { + common_name: info.subject_common_name, + valid_for_days, + not_before: info.not_before.to_string(), + not_after: info.not_after.to_string(), + }) + } + InternalSslType::OwnCert => { + let cert_pem_str = config.cert_pem.ok_or_else(|| { + WebError::BadRequest("cert_pem is required for own_cert".to_string()) + })?; + let key_pem_str = config.key_pem.ok_or_else(|| { + WebError::BadRequest("key_pem is required for own_cert".to_string()) + })?; + + validate_uploaded_cert_pair(&cert_pem_str, &key_pem_str).await?; + + let cert_der = parse_pem_certificate(&cert_pem_str)?; + let info = CertificateInfo::from_der(cert_der.as_ref())?; + let valid_for_days = (info.not_after.and_utc() - chrono::Utc::now()).num_days(); + let expiry = info.not_after; + + certs.core_http_cert_source = CoreCertSource::Custom; + certs.core_http_cert_pem = Some(cert_pem_str); + certs.core_http_cert_key_pem = Some(key_pem_str); + certs.core_http_cert_expiry = Some(expiry); + certs.save(pool).await.map_err(WebError::from)?; + + Some(CertInfoResponse { + common_name: info.subject_common_name, + valid_for_days, + not_before: info.not_before.to_string(), + not_after: info.not_after.to_string(), + }) + } + }; + + Ok(cert_info) +} + +/// Core logic for applying external URL certificate settings using the current public proxy URL. +/// Returns cert info if a certificate was generated/uploaded, `None` otherwise. +pub async fn apply_external_url_settings( + pool: &PgPool, + public_proxy_url: &str, + config: ExternalUrlSettingsConfig, +) -> Result, WebError> { + debug!( + "External URL certificate settings received: public_proxy_url={}, ssl_type={:?}", + public_proxy_url, config.ssl_type, + ); + + let mut certs = Certificates::get_or_default(pool) + .await + .map_err(WebError::from)?; + + let hostname = if matches!( + config.ssl_type, + ExternalSslType::LetsEncrypt | ExternalSslType::DefguardCa + ) { + let url = public_proxy_url.trim(); + if url.is_empty() { + return Err(WebError::BadRequest( + "Public proxy URL is not configured".to_string(), + )); + } + + reqwest::Url::parse(url) + .ok() + .and_then(|u| u.host_str().map(ToString::to_string)) + .filter(|host| !host.is_empty()) + .unwrap_or_else(|| url.to_string()) + } else { + String::new() + }; + + let cert_info = match config.ssl_type { + ExternalSslType::None => { + certs.proxy_http_cert_source = ProxyCertSource::None; + certs.acme_domain = None; + certs.acme_account_credentials = None; + certs.proxy_http_cert_pem = None; + certs.proxy_http_cert_key_pem = None; + certs.proxy_http_cert_expiry = None; + certs.save(pool).await.map_err(WebError::from)?; + None + } + ExternalSslType::LetsEncrypt => { + debug!( + "Validated Let's Encrypt configuration for domain {hostname}; \ + deferring persistence until ACME succeeds" + ); + None + } + ExternalSslType::DefguardCa => { + let ca_cert_der = certs.ca_cert_der.as_ref().ok_or_else(|| { + WebError::BadRequest( + "CA certificate is not present; generate a CA first".to_string(), + ) + })?; + let ca_key_der = certs.ca_key_der.as_ref().ok_or_else(|| { + WebError::BadRequest("CA private key not available for signing".to_string()) + })?; + + let ca = CertificateAuthority::from_cert_der_key_pair(ca_cert_der, ca_key_der)?; + let key_pair = generate_key_pair()?; + let san = vec![hostname.clone()]; + let dn = vec![(DnType::CommonName, hostname.as_str())]; + let csr = Csr::new(&key_pair, &san, dn)?; + let server_cert = ca.sign_csr(&csr)?; + + let cert_der = server_cert.der().to_vec(); + let cert_pem = der_to_pem(&cert_der, PemLabel::Certificate)?; + let key_pem = der_to_pem(key_pair.serialize_der().as_slice(), PemLabel::PrivateKey)?; + let info = CertificateInfo::from_der(&cert_der)?; + let valid_for_days = (info.not_after.and_utc() - chrono::Utc::now()).num_days(); + let expiry = info.not_after; + + certs.proxy_http_cert_source = ProxyCertSource::SelfSigned; + certs.acme_domain = None; + certs.proxy_http_cert_pem = Some(cert_pem); + certs.proxy_http_cert_key_pem = Some(key_pem); + certs.proxy_http_cert_expiry = Some(expiry); + certs.save(pool).await.map_err(WebError::from)?; + + Some(CertInfoResponse { + common_name: info.subject_common_name, + valid_for_days, + not_before: info.not_before.to_string(), + not_after: info.not_after.to_string(), + }) + } + ExternalSslType::OwnCert => { + let cert_pem_str = config.cert_pem.ok_or_else(|| { + WebError::BadRequest("cert_pem is required for own_cert".to_string()) + })?; + let key_pem_str = config.key_pem.ok_or_else(|| { + WebError::BadRequest("key_pem is required for own_cert".to_string()) + })?; + + validate_uploaded_cert_pair(&cert_pem_str, &key_pem_str).await?; + + let cert_der = parse_pem_certificate(&cert_pem_str)?; + let info = CertificateInfo::from_der(cert_der.as_ref())?; + let valid_for_days = (info.not_after.and_utc() - chrono::Utc::now()).num_days(); + let expiry = info.not_after; + + certs.proxy_http_cert_source = ProxyCertSource::Custom; + certs.acme_domain = None; + certs.proxy_http_cert_pem = Some(cert_pem_str); + certs.proxy_http_cert_key_pem = Some(key_pem_str); + certs.proxy_http_cert_expiry = Some(expiry); + certs.save(pool).await.map_err(WebError::from)?; + + Some(CertInfoResponse { + common_name: info.subject_common_name, + valid_for_days, + not_before: info.not_before.to_string(), + not_after: info.not_after.to_string(), + }) + } + }; + + Ok(cert_info) +} diff --git a/crates/defguard_core/src/enterprise/firewall/tests/mod.rs b/crates/defguard_core/src/enterprise/firewall/tests/mod.rs index 7047773d5d..e9295bc11b 100644 --- a/crates/defguard_core/src/enterprise/firewall/tests/mod.rs +++ b/crates/defguard_core/src/enterprise/firewall/tests/mod.rs @@ -27,7 +27,7 @@ use crate::enterprise::{ AclRuleInfo, AclRuleNetwork, AclRuleUser, AliasKind, PortRange, RuleState, }, firewall::try_get_location_firewall_config, - license::{License, LicenseTier, set_cached_license}, + license::{License, LicenseTier, SupportType, set_cached_license}, }; mod all_locations; @@ -55,7 +55,7 @@ fn set_test_license_business() { customer_id: "0c4dcb5400544d47ad8617fcdf2704cb".into(), limits: None, subscription: false, - support_type: crate::enterprise::license::SupportType::Basic, + support_type: SupportType::Basic, tier: LicenseTier::Business, valid_until: None, version_date_limit: None, diff --git a/crates/defguard_core/src/handlers/component_setup.rs b/crates/defguard_core/src/handlers/component_setup.rs index ae557096b5..94fc628f8b 100644 --- a/crates/defguard_core/src/handlers/component_setup.rs +++ b/crates/defguard_core/src/handlers/component_setup.rs @@ -10,6 +10,7 @@ use axum::{ extract::{Path, Query}, response::sse::{Event, KeepAlive, Sse}, }; +use chrono::NaiveDateTime; use defguard_certs::der_to_pem; use defguard_common::{ VERSION, @@ -17,7 +18,7 @@ use defguard_common::{ db::{ Id, models::{ - Certificates, + Certificates, Settings, certificates::ProxyCertSource, gateway::Gateway, initial_setup_wizard::{InitialSetupState, InitialSetupStep}, @@ -1115,6 +1116,39 @@ fn acme_step_name(step: AcmeStep) -> &'static str { } } +fn parse_cert_expiry(cert_pem: &str) -> Option { + let der = defguard_certs::parse_pem_certificate(cert_pem) + .map_err(|e| warn!("Failed to parse ACME cert PEM for expiry: {e}")) + .ok()?; + defguard_certs::CertificateInfo::from_der(&der) + .map(|info| info.not_after) + .map_err(|e| warn!("Failed to extract expiry from ACME cert: {e}")) + .ok() +} + +fn public_proxy_hostname() -> Result { + let public_proxy_url = Settings::get_current_settings().public_proxy_url; + let url = public_proxy_url.trim(); + + if url.is_empty() { + return Err( + "Public proxy URL is not configured. Please re-submit the external URL settings \ + with a Let's Encrypt domain." + .to_string(), + ); + } + + Url::parse(url) + .ok() + .and_then(|u| u.host_str().map(ToString::to_string)) + .filter(|host| !host.is_empty()) + .ok_or_else(|| { + "Public proxy URL is not configured with a valid hostname. Please re-submit the \ + external URL settings with a valid domain." + .to_string() + }) +} + /// Connects to the proxy's permanent `Proxy` gRPC service and calls `TriggerAcme`. /// /// Returns `(cert_pem, key_pem, account_credentials_json)` on success, or @@ -1233,16 +1267,10 @@ pub async fn stream_proxy_acme( } }; - let domain = match certs.acme_domain.clone() { - Some(d) if !d.is_empty() => d, - _ => { - yield Ok(acme_error_event( - "Connecting", - "No ACME domain configured. Please re-submit the external URL settings \ - with a Let's Encrypt domain." - .to_string(), - None, - )); + let domain = match public_proxy_hostname() { + Ok(domain) => domain, + Err(message) => { + yield Ok(acme_error_event("Connecting", message, None)); return; } }; @@ -1340,10 +1368,13 @@ pub async fn stream_proxy_acme( // Progress channel closed - collect the final result. match result_rx.await { Ok(Ok((cert_pem, key_pem, new_account_credentials_json))) => { + let acme_cert_expiry = parse_cert_expiry(&cert_pem); match Certificates::get_or_default(&pool).await { Ok(mut updated_certs) => { + updated_certs.acme_domain = Some(domain.clone()); updated_certs.proxy_http_cert_pem = Some(cert_pem.clone()); updated_certs.proxy_http_cert_key_pem = Some(key_pem.clone()); + updated_certs.proxy_http_cert_expiry = acme_cert_expiry; updated_certs.acme_account_credentials = Some(new_account_credentials_json); updated_certs.proxy_http_cert_source = diff --git a/crates/defguard_core/src/handlers/core_certs.rs b/crates/defguard_core/src/handlers/core_certs.rs index 43ed0493d2..bf301248ae 100644 --- a/crates/defguard_core/src/handlers/core_certs.rs +++ b/crates/defguard_core/src/handlers/core_certs.rs @@ -1,162 +1,232 @@ -use axum::{Json, extract::State, http::StatusCode}; -use defguard_certs::{CertificateAuthority, Csr, DnType, generate_key_pair}; -use defguard_common::db::models::{Certificates, CoreCertSource}; -use utoipa::ToSchema; +use axum::{Extension, Json, extract::State, http::StatusCode}; +use defguard_certs::{CertificateInfo, der_to_pem, parse_pem_certificate}; +use defguard_common::db::models::Certificates; +use serde_json::json; +use sqlx::PgPool; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, + cert_settings::{ + ExternalSslType, ExternalUrlSettingsConfig, InternalUrlSettingsConfig, + apply_external_url_settings, apply_internal_url_settings, + }, error::WebError, handlers::{ApiResponse, ApiResult}, }; -/// Upload a custom PEM certificate + private key for core HTTPS. -#[derive(Serialize, Deserialize, ToSchema)] -pub struct CoreCustomCertUpload { - /// PEM-encoded certificate chain. - pub cert_pem: String, - /// PEM-encoded private key. - pub key_pem: String, +fn cert_common_name(cert_pem: Option<&str>) -> Option { + let cert_der = parse_pem_certificate(cert_pem?).ok()?; + let cert_info = CertificateInfo::from_der(cert_der.as_ref()).ok()?; + Some(cert_info.subject_common_name) +} + +/// Broadcast HTTPS certificate updates to all connected proxies. +async fn broadcast_proxy_https_certs(appstate: &AppState, cert_pem: String, key_pem: String) { + if let Err(err) = appstate + .proxy_control_tx + .send( + defguard_common::types::proxy::ProxyControlMessage::BroadcastHttpsCerts { + cert_pem, + key_pem, + }, + ) + .await + { + error!("Failed to broadcast HttpsCerts to proxies: {err:?}"); + } +} + +/// Tell all connected proxies to clear their active web HTTPS certificates and serve on HTTP. +async fn clear_proxy_https_certs(appstate: &AppState) { + if let Err(err) = appstate + .proxy_control_tx + .send(defguard_common::types::proxy::ProxyControlMessage::ClearHttpsCerts) + .await + { + error!("Failed to broadcast ClearHttpsCerts to proxies: {err:?}"); + } +} + +fn reload_core_web_server(appstate: &AppState) { + if let Err(err) = appstate.web_reload_tx.send(()) { + error!("Failed to trigger core web server reload: {err:?}"); + } } #[utoipa::path( post, - path = "/api/v1/core/cert/upload", - request_body = CoreCustomCertUpload, + path = "/api/v1/core/cert/internal_url_settings", + request_body = InternalUrlSettingsConfig, responses( - (status = 200, description = "Custom certificate uploaded.", body = ApiResponse), + (status = 201, description = "Internal URL certificate settings applied.", body = ApiResponse), + (status = 400, description = "Invalid request.", body = ApiResponse), (status = 401, description = "Unauthorized.", body = ApiResponse), (status = 403, description = "Forbidden.", body = ApiResponse), (status = 500, description = "Internal server error.", body = ApiResponse) ), security(("cookie" = []), ("api_token" = [])) )] -pub(crate) async fn core_cert_upload( +pub(crate) async fn set_internal_url_settings( + State(appstate): State, _role: AdminRole, session: SessionInfo, - State(appstate): State, - Json(data): Json, + Extension(pool): Extension, + Json(config): Json, ) -> ApiResult { debug!( - "User {} uploading custom core certificate", + "User {} applying core internal URL certificate settings", session.user.username ); - - let mut certs = Certificates::get_or_default(&appstate.pool) - .await - .map_err(|err| { - error!("Failed to load certificates: {err}"); - WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) - })?; - certs.core_http_cert_pem = Some(data.cert_pem); - certs.core_http_cert_key_pem = Some(data.key_pem); - certs.core_http_cert_source = CoreCertSource::Custom; - certs.save(&appstate.pool).await.map_err(|err| { - error!("Failed to save custom core cert: {err}"); - WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) - })?; - + let settings = defguard_common::db::models::Settings::get_current_settings(); + let cert_info = apply_internal_url_settings(&pool, &settings.defguard_url, config).await?; + reload_core_web_server(&appstate); info!( - "User {} uploaded custom core certificate", + "User {} applied core internal URL certificate settings", session.user.username ); - Ok(ApiResponse::default()) -} -/// Provision a core HTTPS certificate signed by the built-in Core CA. -#[derive(Serialize, Deserialize, ToSchema)] -pub struct CoreSelfSignedCertRequest { - /// List of Subject Alternative Names (domain names or IP addresses). - pub san: Vec, + Ok(ApiResponse::new( + json!({ "cert_info": cert_info }), + StatusCode::CREATED, + )) } #[utoipa::path( post, - path = "/api/v1/core/cert/self-signed", - request_body = CoreSelfSignedCertRequest, + path = "/api/v1/proxy/cert/external_url_settings", + request_body = ExternalUrlSettingsConfig, responses( - (status = 200, description = "Self-signed certificate provisioned.", body = ApiResponse), - (status = 400, description = "Invalid request (e.g. CA not configured).", body = ApiResponse), + (status = 201, description = "External URL certificate settings applied.", body = ApiResponse), + (status = 400, description = "Invalid request.", body = ApiResponse), (status = 401, description = "Unauthorized.", body = ApiResponse), (status = 403, description = "Forbidden.", body = ApiResponse), (status = 500, description = "Internal server error.", body = ApiResponse) ), security(("cookie" = []), ("api_token" = [])) )] -pub(crate) async fn core_cert_self_signed( +pub(crate) async fn set_external_url_settings( + State(appstate): State, _role: AdminRole, session: SessionInfo, - State(appstate): State, - Json(data): Json, + Extension(pool): Extension, + Json(config): Json, ) -> ApiResult { debug!( - "User {} provisioning self-signed core certificate", + "User {} applying proxy external URL certificate settings", session.user.username ); - - let mut certs = Certificates::get_or_default(&appstate.pool) - .await - .map_err(|err| { - error!("Failed to load certificates: {err}"); - WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) - })?; - - let (ca_cert_der, ca_key_der) = match (certs.ca_cert_der.clone(), certs.ca_key_der.clone()) { - (Some(c), Some(k)) => (c, k), - _ => { - warn!("CA not configured; cannot issue self-signed core cert"); - return Ok(ApiResponse::json( - serde_json::json!({"msg": "Core CA is not configured"}), - StatusCode::BAD_REQUEST, - )); + let settings = defguard_common::db::models::Settings::get_current_settings(); + let ssl_type = config.ssl_type.clone(); + let cert_info = apply_external_url_settings(&pool, &settings.public_proxy_url, config).await?; + + match ssl_type { + ExternalSslType::DefguardCa | ExternalSslType::OwnCert => { + let certs = Certificates::get_or_default(&pool) + .await + .map_err(WebError::from)?; + if let Some((cert_pem, key_pem)) = certs.proxy_http_cert_pair() { + broadcast_proxy_https_certs(&appstate, cert_pem.to_owned(), key_pem.to_owned()) + .await; + } } - }; - - let ca = - CertificateAuthority::from_cert_der_key_pair(&ca_cert_der, &ca_key_der).map_err(|err| { - error!("Failed to load Core CA: {err}"); - WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) - })?; - - let leaf_key = generate_key_pair().map_err(|err| { - error!("Failed to generate leaf key pair: {err}"); - WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) - })?; - - let Some(common_name) = data.san.first() else { - return Err(WebError::BadRequest( - "At least one SAN entry is required to issue a certificate".to_string(), - )); - }; - - let csr = Csr::new( - &leaf_key, - &data.san, - vec![(DnType::CommonName, common_name.as_str())], - ) - .map_err(|err| { - error!("Failed to build CSR: {err}"); - WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) - })?; + ExternalSslType::None => { + clear_proxy_https_certs(&appstate).await; + } + ExternalSslType::LetsEncrypt => {} + } + info!( + "User {} applied proxy external URL certificate settings", + session.user.username + ); - let signed = ca - .sign_csr(&csr) - .map_err(|err: defguard_certs::CertificateError| { - error!("Failed to sign CSR with Core CA: {err}"); - WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) - })?; + Ok(ApiResponse::new( + json!({ "cert_info": cert_info }), + StatusCode::CREATED, + )) +} - certs.core_http_cert_pem = Some(signed.pem()); - certs.core_http_cert_key_pem = Some(leaf_key.serialize_pem()); - certs.core_http_cert_source = CoreCertSource::SelfSigned; - certs.save(&appstate.pool).await.map_err(|err| { - error!("Failed to save self-signed core cert: {err}"); - WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) - })?; +#[utoipa::path( + get, + path = "/api/v1/core/cert/ca", + responses( + (status = 200, description = "CA cert data", body = ApiResponse), + (status = 400, description = "Invalid request (e.g. CA not configured).", body = ApiResponse), + (status = 401, description = "Unauthorized.", body = ApiResponse), + (status = 403, description = "Forbidden.", body = ApiResponse), + (status = 500, description = "Internal server error.", body = ApiResponse) + ), + security(("cookie" = []), ("api_token" = [])) +)] +pub(crate) async fn get_ca( + _role: AdminRole, + session: SessionInfo, + Extension(pool): Extension, +) -> ApiResult { + debug!( + "User {} fetching certificate authority details", + session.user.username + ); + let certs = Certificates::get_or_default(&pool) + .await + .map_err(WebError::from)?; + if let Some(ca_cert_der) = certs.ca_cert_der { + let ca_pem = der_to_pem(&ca_cert_der, defguard_certs::PemLabel::Certificate)?; + let info = CertificateInfo::from_der(&ca_cert_der)?; + let valid_for_days = (info.not_after.and_utc() - chrono::Utc::now()).num_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, + "ca_expiry": certs.ca_expiry, + "subject_email": info.subject_email, + }), + StatusCode::OK, + )) + } else { + Err(WebError::ObjectNotFound( + "CA certificate not found".to_string(), + )) + } +} - info!( - "User {} provisioned self-signed core certificate (SAN: {:?})", - session.user.username, data.san +#[utoipa::path( + get, + path = "/api/v1/core/cert/certs", + responses( + (status = 200, description = "Core & edge cert data", body = ApiResponse), + (status = 400, description = "Invalid request (e.g. CA not configured).", body = ApiResponse), + (status = 401, description = "Unauthorized.", body = ApiResponse), + (status = 403, description = "Forbidden.", body = ApiResponse), + (status = 500, description = "Internal server error.", body = ApiResponse) + ), + security(("cookie" = []), ("api_token" = [])) +)] +pub(crate) async fn get_certs( + _role: AdminRole, + session: SessionInfo, + Extension(pool): Extension, +) -> ApiResult { + debug!( + "User {} fetching core and edge certificate details", + session.user.username ); - Ok(ApiResponse::default()) + let certs = Certificates::get_or_default(&pool) + .await + .map_err(WebError::from)?; + Ok(ApiResponse::new( + json!({ + "core_http_cert_source": certs.core_http_cert_source, + "core_http_cert_expiry": certs.core_http_cert_expiry, + "core_http_cert_domain": cert_common_name(certs.core_http_cert_pem.as_deref()), + "proxy_http_cert_source": certs.proxy_http_cert_source, + "proxy_http_cert_expiry": certs.proxy_http_cert_expiry, + "proxy_http_cert_domain": cert_common_name(certs.proxy_http_cert_pem.as_deref()), + }), + StatusCode::OK, + )) } diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index 3fcbae9e1d..09c748e5ee 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -34,7 +34,7 @@ pub(crate) mod activity_log; pub(crate) mod app_info; pub mod auth; pub mod component_setup; -pub(crate) mod core_certs; +pub mod core_certs; pub(crate) mod forward_auth; pub mod gateway; pub(crate) mod group; diff --git a/crates/defguard_core/src/handlers/proxy.rs b/crates/defguard_core/src/handlers/proxy.rs index 0cc06fb7af..3f1769dd23 100644 --- a/crates/defguard_core/src/handlers/proxy.rs +++ b/crates/defguard_core/src/handlers/proxy.rs @@ -4,12 +4,8 @@ use axum::{ http::StatusCode, }; use chrono::Utc; -use defguard_certs::{CertificateAuthority, Csr, DnType, generate_key_pair}; use defguard_common::{ - db::{ - Id, - models::{Certificates, ProxyCertSource, proxy::Proxy}, - }, + db::{Id, models::proxy::Proxy}, types::proxy::{ProxyControlMessage, ProxyInfo}, }; use serde_json::Value; @@ -18,7 +14,6 @@ use utoipa::ToSchema; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, - error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, handlers::{ApiResponse, ApiResult}, }; @@ -223,180 +218,3 @@ pub(crate) async fn delete_proxy( Ok(ApiResponse::default()) } - -/// Upload a custom PEM certificate + private key for proxy HTTPS. -/// -/// Sets `proxy_cert_source = custom` and immediately broadcasts the cert to all -/// connected proxies so they restart with HTTPS. -#[derive(Serialize, Deserialize, ToSchema)] -pub struct CustomCertUpload { - /// PEM-encoded certificate chain. - pub cert_pem: String, - /// PEM-encoded private key. - pub key_pem: String, -} - -#[utoipa::path( - post, - path = "/api/v1/proxy/cert/upload", - request_body = CustomCertUpload, - responses( - (status = 200, description = "Custom certificate uploaded and broadcast to all proxies.", body = ApiResponse), - (status = 401, description = "Unauthorized.", body = ApiResponse), - (status = 403, description = "Forbidden.", body = ApiResponse), - (status = 500, description = "Internal server error.", body = ApiResponse) - ), - security(("cookie" = []), ("api_token" = [])) -)] -pub(crate) async fn proxy_cert_upload( - _role: AdminRole, - session: SessionInfo, - State(appstate): State, - Json(data): Json, -) -> ApiResult { - debug!( - "User {} uploading custom proxy certificate", - session.user.username - ); - - let mut certs = Certificates::get_or_default(&appstate.pool) - .await - .map_err(|err| { - error!("Failed to load certificates: {err}"); - WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) - })?; - certs.proxy_http_cert_pem = Some(data.cert_pem.clone()); - certs.proxy_http_cert_key_pem = Some(data.key_pem.clone()); - certs.proxy_http_cert_source = ProxyCertSource::Custom; - certs.save(&appstate.pool).await.map_err(|err| { - error!("Failed to save custom proxy cert: {err}"); - WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) - })?; - - broadcast_https_certs(&appstate, data.cert_pem, data.key_pem).await; - - info!( - "User {} uploaded custom proxy certificate", - session.user.username - ); - Ok(ApiResponse::default()) -} - -/// Provision a proxy HTTPS certificate signed by the built-in Core CA. -/// -/// The certificate is issued for the given SANs (hostnames / IPs), signed -/// with the Core CA stored in settings, saved as `proxy_cert_source = self_signed`, -/// and broadcast to all connected proxies. -#[derive(Serialize, Deserialize, ToSchema)] -pub struct SelfSignedCertRequest { - /// List of Subject Alternative Names (domain names or IP addresses). - pub san: Vec, -} - -#[utoipa::path( - post, - path = "/api/v1/proxy/cert/self-signed", - request_body = SelfSignedCertRequest, - responses( - (status = 200, description = "Self-signed certificate provisioned and broadcast.", body = ApiResponse), - (status = 400, description = "Invalid request (e.g. CA not configured).", body = ApiResponse), - (status = 401, description = "Unauthorized.", body = ApiResponse), - (status = 403, description = "Forbidden.", body = ApiResponse), - (status = 500, description = "Internal server error.", body = ApiResponse) - ), - security(("cookie" = []), ("api_token" = [])) -)] -pub(crate) async fn proxy_cert_self_signed( - _role: AdminRole, - session: SessionInfo, - State(appstate): State, - Json(data): Json, -) -> ApiResult { - debug!( - "User {} provisioning self-signed proxy certificate", - session.user.username - ); - - let mut certs = Certificates::get_or_default(&appstate.pool) - .await - .map_err(|err| { - error!("Failed to load certificates: {err}"); - WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) - })?; - - let (Some(ca_cert_der), Some(ca_key_der)) = - (certs.ca_cert_der.clone(), certs.ca_key_der.clone()) - else { - warn!("CA not configured; cannot issue self-signed proxy cert"); - return Ok(ApiResponse::json( - serde_json::json!({"msg": "Core CA is not configured"}), - StatusCode::BAD_REQUEST, - )); - }; - - // Build CA from stored DER blobs. - let ca = - CertificateAuthority::from_cert_der_key_pair(&ca_cert_der, &ca_key_der).map_err(|err| { - error!("Failed to load Core CA: {err}"); - WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) - })?; - - let Some(common_name) = data.san.first() else { - return Err(WebError::BadRequest( - "At least one SAN entry is required to issue a certificate".to_string(), - )); - }; - - // Generate a new leaf key pair + CSR. - let leaf_key = generate_key_pair().map_err(|err| { - error!("Failed to generate leaf key pair: {err}"); - WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) - })?; - - let csr = Csr::new( - &leaf_key, - &data.san, - vec![(DnType::CommonName, common_name.as_str())], - ) - .map_err(|err| { - error!("Failed to build CSR: {err}"); - WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) - })?; - - let signed = ca - .sign_csr(&csr) - .map_err(|err: defguard_certs::CertificateError| { - error!("Failed to sign CSR with Core CA: {err}"); - WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) - })?; - - let cert_pem = signed.pem(); - let key_pem = leaf_key.serialize_pem(); - - certs.proxy_http_cert_pem = Some(cert_pem.clone()); - certs.proxy_http_cert_key_pem = Some(key_pem.clone()); - certs.proxy_http_cert_source = ProxyCertSource::SelfSigned; - certs.save(&appstate.pool).await.map_err(|err| { - error!("Failed to save self-signed proxy cert: {err}"); - WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) - })?; - - broadcast_https_certs(&appstate, cert_pem, key_pem).await; - - info!( - "User {} provisioned self-signed proxy certificate (SAN: {:?})", - session.user.username, data.san - ); - Ok(ApiResponse::default()) -} - -/// Broadcast an `HttpsCerts` message to all currently connected proxies via the proxy manager. -async fn broadcast_https_certs(appstate: &AppState, cert_pem: String, key_pem: String) { - if let Err(err) = appstate - .proxy_control_tx - .send(ProxyControlMessage::BroadcastHttpsCerts { cert_pem, key_pem }) - .await - { - error!("Failed to broadcast HttpsCerts to proxies: {err:?}"); - } -} diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index addbdbdb33..7042efa8bb 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -2,6 +2,7 @@ use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, sync::{Arc, LazyLock, Mutex, RwLock}, + time::Duration, }; use anyhow::anyhow; @@ -116,7 +117,7 @@ use crate::{ webauthn_start, }, component_setup::setup_gateway_tls_stream, - core_certs::{core_cert_self_signed, core_cert_upload}, + core_certs::{get_ca, get_certs, set_external_url_settings, set_internal_url_settings}, forward_auth::forward_auth, gateway::{delete_gateway, gateway_details, gateway_list, update_gateway}, group::{ @@ -137,10 +138,7 @@ use crate::{ authorization, discovery_keys, openid_configuration, secure_authorization, token, userinfo, }, - proxy::{ - delete_proxy, proxy_cert_self_signed, proxy_cert_upload, proxy_details, proxy_list, - update_proxy, - }, + proxy::{delete_proxy, proxy_details, proxy_list, update_proxy}, resource_display::get_locations_display, settings::{ get_settings, get_settings_essentials, patch_settings, set_default_branding, @@ -177,6 +175,7 @@ use crate::{ pub mod appstate; pub mod auth; +pub mod cert_settings; pub mod db; pub mod enrollment_management; pub mod enterprise; @@ -224,6 +223,7 @@ pub fn build_webapp( webhook_tx: UnboundedSender, webhook_rx: UnboundedReceiver, wireguard_tx: Sender, + web_reload_tx: tokio::sync::broadcast::Sender<()>, worker_state: Arc>, pool: PgPool, key: Key, @@ -379,11 +379,17 @@ pub fn build_webapp( "/proxy/{proxy_id}", get(proxy_details).put(update_proxy).delete(delete_proxy), ) - .route("/proxy/cert/upload", post(proxy_cert_upload)) - .route("/proxy/cert/self-signed", post(proxy_cert_self_signed)) + .route( + "/proxy/cert/external_url_settings", + post(set_external_url_settings), + ) // Core HTTPS cert routes - .route("/core/cert/upload", post(core_cert_upload)) - .route("/core/cert/self-signed", post(core_cert_self_signed)) + .route( + "/core/cert/internal_url_settings", + post(set_internal_url_settings), + ) + .route("/core/cert/ca", get(get_ca)) + .route("/core/cert/certs", get(get_certs)) // Gateway routes .route("/gateway", get(gateway_list)) .route( @@ -618,6 +624,7 @@ pub fn build_webapp( webhook_tx, webhook_rx, wireguard_tx, + web_reload_tx, key, failed_logins, event_tx, @@ -647,6 +654,7 @@ pub async fn run_web_server( webhook_tx: UnboundedSender, webhook_rx: UnboundedReceiver, wireguard_tx: Sender, + web_reload_tx: tokio::sync::broadcast::Sender<()>, pool: PgPool, failed_logins: Arc>, event_tx: UnboundedSender, @@ -656,21 +664,13 @@ pub async fn run_web_server( let settings = Settings::get_current_settings(); let key = Key::from(settings.secret_key_required()?.as_bytes()); - // Read certs before build_webapp consumes the pool. - let tls_cert_pair = Certificates::get_or_default(&pool) - .await - .map(|c| { - c.core_http_cert_pair() - .map(|(cert, key)| (cert.to_owned(), key.to_owned())) - }) - .unwrap_or(None); - let webapp = build_webapp( webhook_tx, webhook_rx, wireguard_tx, + web_reload_tx.clone(), worker_state, - pool, + pool.clone(), key, failed_logins, event_tx, @@ -687,19 +687,67 @@ pub async fn run_web_server( server_config.http_port, ); - if let Some((cert_pem, key_pem)) = tls_cert_pair { - let tls_config = RustlsConfig::from_pem(cert_pem.into_bytes(), key_pem.into_bytes()) - .await - .map_err(|err| anyhow!("Failed to load TLS config: {err}"))?; - axum_server::bind_rustls(addr, tls_config) - .serve(webapp.into_make_service_with_connect_info::()) - .await - .map_err(|err| anyhow!("Web server error: {err}")) - } else { - axum_server::bind(addr) - .serve(webapp.into_make_service_with_connect_info::()) + let mut web_reload_rx = web_reload_tx.subscribe(); + + loop { + let handle = axum_server::Handle::new(); + let handle_clone = handle.clone(); + let app = webapp + .clone() + .into_make_service_with_connect_info::(); + let current_tls_cert_pair = Certificates::get_or_default(&pool) .await - .map_err(|err| anyhow!("Web server error: {err}")) + .map(|c| { + c.core_http_cert_pair() + .map(|(cert, key)| (cert.to_owned(), key.to_owned())) + }) + .unwrap_or(None); + + let mut server_task = tokio::spawn(async move { + if let Some((cert_pem, key_pem)) = current_tls_cert_pair { + let tls_config = + RustlsConfig::from_pem(cert_pem.into_bytes(), key_pem.into_bytes()) + .await + .map_err(|err| anyhow!("Failed to load TLS config: {err}"))?; + axum_server::bind_rustls(addr, tls_config) + .handle(handle_clone.clone()) + .serve(app) + .await + .map_err(|err| anyhow!("Web server error: {err}")) + } else { + axum_server::bind(addr) + .handle(handle_clone) + .serve(app) + .await + .map_err(|err| anyhow!("Web server error: {err}")) + } + }); + + tokio::select! { + result = &mut server_task => { + match result { + Ok(result) => return result, + Err(err) => return Err(anyhow!("Web server task panicked: {err}")), + } + } + result = web_reload_rx.recv() => { + match result { + Ok(()) => { + info!("Received core web server reload request, restarting listener"); + handle.graceful_shutdown(Some(Duration::from_secs(30))); + let _ = server_task.await; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + info!("Missed core web server reload signal, restarting listener"); + handle.graceful_shutdown(Some(Duration::from_secs(30))); + let _ = server_task.await; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + return Err(anyhow!("Core web reload channel closed unexpectedly")); + } + } + } + } } } diff --git a/crates/defguard_core/tests/integration/api/common/mod.rs b/crates/defguard_core/tests/integration/api/common/mod.rs index 499cb46a4e..700924dc2a 100644 --- a/crates/defguard_core/tests/integration/api/common/mod.rs +++ b/crates/defguard_core/tests/integration/api/common/mod.rs @@ -7,6 +7,7 @@ use std::{ }; use axum_extra::extract::cookie::Key; +use defguard_certs::{CertificateAuthority, Csr, DnType, PemLabel, der_to_pem, generate_key_pair}; pub use defguard_common::db::setup_pool; use defguard_common::{ VERSION, @@ -133,11 +134,13 @@ pub(crate) async fn make_base_client( .unwrap() .as_bytes(), ); + let (web_reload_tx, _web_reload_rx) = broadcast::channel::<()>(8); let webapp = build_webapp( tx, rx, wg_tx, + web_reload_tx, worker_state, pool, key, @@ -246,6 +249,18 @@ pub(crate) async fn get_db_device(pool: &PgPool, device_id: Id) -> Device { Device::find_by_id(pool, device_id).await.unwrap().unwrap() } +pub(crate) fn generate_test_cert_pem(common_name: &str) -> (String, String) { + let ca = CertificateAuthority::new("Test CA", "test@example.com", 365).unwrap(); + let key_pair = generate_key_pair().unwrap(); + let san = vec![common_name.to_string()]; + let dn = vec![(DnType::CommonName, common_name)]; + let csr = Csr::new(&key_pair, &san, dn).unwrap(); + let cert = ca.sign_csr(&csr).unwrap(); + let cert_pem = der_to_pem(cert.der(), PemLabel::Certificate).unwrap(); + let key_pem = der_to_pem(key_pair.serialize_der().as_slice(), PemLabel::PrivateKey).unwrap(); + (cert_pem, key_pem) +} + /// Set minimal SMTP fields on a [`Settings`] so that `smtp_configured()` returns `true`. pub(crate) fn configure_smtp(settings: &mut Settings) { settings.smtp_server = Some("smtp.example.com".into()); diff --git a/crates/defguard_core/tests/integration/api/core_certs.rs b/crates/defguard_core/tests/integration/api/core_certs.rs index 8a8ea7a986..a46aae51e2 100644 --- a/crates/defguard_core/tests/integration/api/core_certs.rs +++ b/crates/defguard_core/tests/integration/api/core_certs.rs @@ -1,11 +1,13 @@ use defguard_certs::CertificateAuthority; -use defguard_common::db::models::{Certificates, CoreCertSource}; +use defguard_common::db::models::{ + Certificates, CoreCertSource, Settings, settings::update_current_settings, +}; use defguard_core::handlers::Auth; use reqwest::StatusCode; use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use super::common::{make_test_client, setup_pool}; +use super::common::{generate_test_cert_pem, make_test_client, setup_pool}; async fn seed_ca(pool: &sqlx::PgPool) { let ca = CertificateAuthority::new("Test CA", "test@example.com", 365).unwrap(); @@ -19,21 +21,13 @@ async fn seed_ca(pool: &sqlx::PgPool) { } #[sqlx::test] -async fn test_core_cert_endpoints(_: PgPoolOptions, options: PgConnectOptions) { +async fn test_internal_url_settings_endpoint(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; let (client, _) = make_test_client(pool.clone()).await; - // unauthenticated requests return 401 let response = client - .post("/api/v1/core/cert/upload") - .json(&json!({"cert_pem": "c", "key_pem": "k"})) - .send() - .await; - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - - let response = client - .post("/api/v1/core/cert/self-signed") - .json(&json!({"san": ["localhost"]})) + .post("/api/v1/core/cert/internal_url_settings") + .json(&json!({ "ssl_type": "none" })) .send() .await; assert_eq!(response.status(), StatusCode::UNAUTHORIZED); @@ -42,42 +36,39 @@ async fn test_core_cert_endpoints(_: PgPoolOptions, options: PgConnectOptions) { let response = client.post("/api/v1/auth").json(&auth).send().await; assert_eq!(response.status(), StatusCode::OK); - // self-signed without CA returns 400 - let response = client - .post("/api/v1/core/cert/self-signed") - .json(&json!({"san": ["localhost"]})) - .send() - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - // upload custom cert - let cert_pem = "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n"; - let key_pem = "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n"; + let mut settings = Settings::get_current_settings(); + settings.defguard_url = "https://defguard.example.com".into(); + update_current_settings(&pool, settings).await.unwrap(); let response = client - .post("/api/v1/core/cert/upload") - .json(&json!({"cert_pem": cert_pem, "key_pem": key_pem})) + .post("/api/v1/core/cert/internal_url_settings") + .json(&json!({ "ssl_type": "none" })) .send() .await; - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::CREATED); let saved = Certificates::get(&pool).await.unwrap().unwrap(); - assert_eq!(saved.core_http_cert_source, CoreCertSource::Custom); - assert_eq!(saved.core_http_cert_pem.as_deref(), Some(cert_pem)); - assert_eq!(saved.core_http_cert_key_pem.as_deref(), Some(key_pem)); + assert_eq!(saved.core_http_cert_source, CoreCertSource::None); + assert!(saved.core_http_cert_pem.is_none()); + assert!(saved.core_http_cert_key_pem.is_none()); + assert!(saved.core_http_cert_expiry.is_none()); - // self-signed with CA present seed_ca(&pool).await; let response = client - .post("/api/v1/core/cert/self-signed") - .json(&json!({"san": ["localhost"]})) + .post("/api/v1/core/cert/internal_url_settings") + .json(&json!({ "ssl_type": "defguard_ca" })) .send() .await; - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::CREATED); + + let body: serde_json::Value = response.json::().await; + assert!(!body["cert_info"].is_null()); + assert_eq!(body["cert_info"]["common_name"], "defguard.example.com"); let saved = Certificates::get(&pool).await.unwrap().unwrap(); assert_eq!(saved.core_http_cert_source, CoreCertSource::SelfSigned); + assert!(saved.core_http_cert_expiry.is_some()); assert!( saved .core_http_cert_pem @@ -85,11 +76,45 @@ async fn test_core_cert_endpoints(_: PgPoolOptions, options: PgConnectOptions) { .unwrap_or("") .contains("BEGIN CERTIFICATE") ); - assert!( - saved - .core_http_cert_key_pem - .as_deref() - .unwrap_or("") - .contains("BEGIN") - ); + + let (cert_pem, key_pem) = generate_test_cert_pem("uploaded.example.com"); + let response = client + .post("/api/v1/core/cert/internal_url_settings") + .json(&json!({ + "ssl_type": "own_cert", + "cert_pem": cert_pem, + "key_pem": key_pem + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let body: serde_json::Value = response.json::().await; + assert_eq!(body["cert_info"]["common_name"], "uploaded.example.com"); + + let saved = Certificates::get(&pool).await.unwrap().unwrap(); + assert_eq!(saved.core_http_cert_source, CoreCertSource::Custom); + assert!(saved.core_http_cert_expiry.is_some()); + + let (_, mismatched_key_pem) = generate_test_cert_pem("different.example.com"); + let response = client + .post("/api/v1/core/cert/internal_url_settings") + .json(&json!({ + "ssl_type": "own_cert", + "cert_pem": cert_pem, + "key_pem": mismatched_key_pem + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = client + .post("/api/v1/core/cert/internal_url_settings") + .json(&json!({ + "ssl_type": "own_cert", + "cert_pem": "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n" + })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); } diff --git a/crates/defguard_core/tests/integration/api/proxy_certs.rs b/crates/defguard_core/tests/integration/api/proxy_certs.rs index 147c211d84..619906077f 100644 --- a/crates/defguard_core/tests/integration/api/proxy_certs.rs +++ b/crates/defguard_core/tests/integration/api/proxy_certs.rs @@ -16,7 +16,10 @@ use defguard_certs::CertificateAuthority; use defguard_common::{ VERSION, db::{ - models::{Certificates, ProxyCertSource, Settings, settings::initialize_current_settings}, + models::{ + Certificates, ProxyCertSource, Settings, + settings::{initialize_current_settings, update_current_settings}, + }, setup_pool, }, types::proxy::ProxyControlMessage, @@ -45,7 +48,7 @@ use tokio::{ }, }; -use super::common::client::TestClient; +use super::common::{client::TestClient, generate_test_cert_pem}; use crate::common::{init_config, initialize_users}; // Mock: captures messages sent to the proxy manager channel. @@ -70,6 +73,21 @@ impl ProxyBroadcastCapture { } results } + + async fn drain_clear_https_certs(&mut self) -> usize { + let mut results = 0; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + loop { + match self.rx.try_recv() { + Ok(ProxyControlMessage::ClearHttpsCerts) => { + results += 1; + } + Ok(_) => {} + Err(_) => break, + } + } + results + } } // Test client builder that exposes the proxy-control receiver. @@ -117,11 +135,13 @@ async fn make_test_client_with_proxy_rx( .unwrap() .as_bytes(), ); + let (web_reload_tx, _web_reload_rx) = broadcast::channel::<()>(8); let webapp = build_webapp( tx, rx, wg_tx, + web_reload_tx, worker_state, pool.clone(), key, @@ -159,322 +179,149 @@ async fn login_admin(client: &mut TestClient) { assert_eq!(resp.status(), StatusCode::OK); } -/// Unauthenticated requests to both endpoints must return 401. +/// When no cert is configured (default state), proxy_http_cert_pair() returns None. #[sqlx::test] -async fn test_proxy_cert_endpoints_require_auth(_: PgPoolOptions, opts: PgConnectOptions) { +async fn test_proxy_cert_pair_none_by_default(_: PgPoolOptions, opts: PgConnectOptions) { let pool = setup_pool(opts).await; - let (client, _capture, _pool) = make_test_client_with_proxy_rx(pool).await; - - let fake_cert = "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n"; - let fake_key = "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n"; - - let resp = client - .post("/api/v1/proxy/cert/upload") - .json(&json!({"cert_pem": fake_cert, "key_pem": fake_key})) - .send() - .await; - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + // Initialize DB without touching the certificates table (fresh schema). + initialize_current_settings(&pool).await.unwrap(); - let resp = client - .post("/api/v1/proxy/cert/self-signed") - .json(&json!({"san": ["proxy.example.com"]})) - .send() - .await; - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let certs = Certificates::get_or_default(&pool).await.unwrap(); + assert_eq!(certs.proxy_http_cert_source, ProxyCertSource::None); + assert!( + certs.proxy_http_cert_pair().is_none(), + "No cert must be configured by default" + ); } -/// Uploading a custom cert stores it in the DB with source=Custom and broadcasts -/// the exact same PEM strings to the proxy manager channel. #[sqlx::test] -async fn test_proxy_cert_upload_persists_and_broadcasts(_: PgPoolOptions, opts: PgConnectOptions) { +async fn test_external_url_settings_endpoint(_: PgPoolOptions, opts: PgConnectOptions) { let pool = setup_pool(opts).await; let (mut client, mut capture, pool) = make_test_client_with_proxy_rx(pool).await; - login_admin(&mut client).await; - - let cert_pem = "-----BEGIN CERTIFICATE-----\ncustom_cert\n-----END CERTIFICATE-----\n"; - let key_pem = "-----BEGIN PRIVATE KEY-----\ncustom_key\n-----END PRIVATE KEY-----\n"; - let resp = client - .post("/api/v1/proxy/cert/upload") - .json(&json!({"cert_pem": cert_pem, "key_pem": key_pem})) + let response = client + .post("/api/v1/proxy/cert/external_url_settings") + .json(&json!({ "ssl_type": "none" })) .send() .await; - assert_eq!(resp.status(), StatusCode::OK); - - // DB persistence - let saved = Certificates::get(&pool).await.unwrap().unwrap(); - assert_eq!(saved.proxy_http_cert_source, ProxyCertSource::Custom); - assert_eq!(saved.proxy_http_cert_pem.as_deref(), Some(cert_pem)); - assert_eq!(saved.proxy_http_cert_key_pem.as_deref(), Some(key_pem)); - - // Broadcast mock: exactly one BroadcastHttpsCerts with the correct PEM values - let broadcasts = capture.drain_broadcast_certs().await; - assert_eq!(broadcasts.len(), 1, "Expected exactly one broadcast"); - assert_eq!(broadcasts[0].0, cert_pem); - assert_eq!(broadcasts[0].1, key_pem); -} + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); -/// proxy_http_cert_pair() returns Some after a custom upload. -#[sqlx::test] -async fn test_proxy_cert_pair_accessible_after_upload(_: PgPoolOptions, opts: PgConnectOptions) { - let pool = setup_pool(opts).await; - let (mut client, _capture, pool) = make_test_client_with_proxy_rx(pool).await; login_admin(&mut client).await; - let cert_pem = "-----BEGIN CERTIFICATE-----\npair_test\n-----END CERTIFICATE-----\n"; - let key_pem = "-----BEGIN PRIVATE KEY-----\npair_key\n-----END PRIVATE KEY-----\n"; + let mut settings = Settings::get_current_settings(); + settings.public_proxy_url = "https://edge.example.com".into(); + update_current_settings(&pool, settings).await.unwrap(); - client - .post("/api/v1/proxy/cert/upload") - .json(&json!({"cert_pem": cert_pem, "key_pem": key_pem})) + let response = client + .post("/api/v1/proxy/cert/external_url_settings") + .json(&json!({ "ssl_type": "none" })) .send() .await; + assert_eq!(response.status(), StatusCode::CREATED); let saved = Certificates::get(&pool).await.unwrap().unwrap(); - assert_eq!( - saved.proxy_http_cert_pair(), - Some((cert_pem, key_pem)), - "proxy_http_cert_pair() must return the stored PEM pair" - ); -} - -/// Requesting a self-signed cert when no CA is configured returns 400. -#[sqlx::test] -async fn test_proxy_cert_self_signed_without_ca(_: PgPoolOptions, opts: PgConnectOptions) { - let pool = setup_pool(opts).await; - let (mut client, _capture, _pool) = make_test_client_with_proxy_rx(pool).await; - login_admin(&mut client).await; - - let resp = client - .post("/api/v1/proxy/cert/self-signed") - .json(&json!({"san": ["proxy.example.com"]})) + assert_eq!(saved.proxy_http_cert_source, ProxyCertSource::None); + assert!(saved.proxy_http_cert_pem.is_none()); + assert!(saved.proxy_http_cert_key_pem.is_none()); + assert!(saved.proxy_http_cert_expiry.is_none()); + assert!(saved.acme_domain.is_none()); + assert_eq!(capture.drain_clear_https_certs().await, 1); + + let response = client + .post("/api/v1/proxy/cert/external_url_settings") + .json(&json!({ "ssl_type": "lets_encrypt" })) .send() .await; - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); -} + assert_eq!(response.status(), StatusCode::CREATED); + + let body: serde_json::Value = response.json().await; + assert!(body["cert_info"].is_null()); + + let saved = Certificates::get(&pool).await.unwrap().unwrap(); + assert_eq!(saved.proxy_http_cert_source, ProxyCertSource::None); + assert!(saved.acme_domain.is_none()); + assert!(saved.proxy_http_cert_pem.is_none()); + assert!(capture.drain_broadcast_certs().await.is_empty()); -/// With a CA present, a self-signed cert is issued, saved as SelfSigned, and -/// the resulting PEM is broadcast to the proxy manager. -#[sqlx::test] -async fn test_proxy_cert_self_signed_with_ca(_: PgPoolOptions, opts: PgConnectOptions) { - let pool = setup_pool(opts).await; - let (mut client, mut capture, pool) = make_test_client_with_proxy_rx(pool).await; seed_ca(&pool).await; - login_admin(&mut client).await; - let resp = client - .post("/api/v1/proxy/cert/self-signed") - .json(&json!({"san": ["proxy.example.com"]})) + let response = client + .post("/api/v1/proxy/cert/external_url_settings") + .json(&json!({ "ssl_type": "defguard_ca" })) .send() .await; - assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::CREATED); + + let body: serde_json::Value = response.json().await; + assert!(!body["cert_info"].is_null()); + assert_eq!(body["cert_info"]["common_name"], "edge.example.com"); - // DB: source set to SelfSigned, valid PEM stored let saved = Certificates::get(&pool).await.unwrap().unwrap(); assert_eq!(saved.proxy_http_cert_source, ProxyCertSource::SelfSigned); + assert!(saved.proxy_http_cert_expiry.is_some()); assert!( saved .proxy_http_cert_pem .as_deref() .unwrap_or("") - .contains("BEGIN CERTIFICATE"), - "cert_pem must be a valid PEM certificate" - ); - assert!( - saved - .proxy_http_cert_key_pem - .as_deref() - .unwrap_or("") - .contains("BEGIN"), - "key_pem must be a valid PEM key" + .contains("BEGIN CERTIFICATE") ); + assert!(saved.acme_domain.is_none()); - // Broadcast mock: one BroadcastHttpsCerts with matching PEM content let broadcasts = capture.drain_broadcast_certs().await; assert_eq!(broadcasts.len(), 1, "Expected exactly one broadcast"); - let (broadcasted_cert, broadcasted_key) = &broadcasts[0]; - assert!( - broadcasted_cert.contains("BEGIN CERTIFICATE"), - "Broadcasted cert must be valid PEM" - ); - assert!( - broadcasted_key.contains("BEGIN"), - "Broadcasted key must be valid PEM" - ); - // Broadcast must match what was persisted - assert_eq!( - saved.proxy_http_cert_pem.as_deref(), - Some(broadcasted_cert.as_str()) - ); - assert_eq!( - saved.proxy_http_cert_key_pem.as_deref(), - Some(broadcasted_key.as_str()) - ); -} - -/// An empty SAN list returns 400 - at least one SAN is required to issue a cert. -#[sqlx::test] -async fn test_proxy_cert_self_signed_empty_san(_: PgPoolOptions, opts: PgConnectOptions) { - let pool = setup_pool(opts).await; - let (mut client, _capture, pool) = make_test_client_with_proxy_rx(pool).await; - seed_ca(&pool).await; - login_admin(&mut client).await; - - let resp = client - .post("/api/v1/proxy/cert/self-signed") - .json(&json!({"san": []})) - .send() - .await; - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); -} - -/// Multiple SANs are all included in the issued certificate. -#[sqlx::test] -async fn test_proxy_cert_self_signed_multiple_sans(_: PgPoolOptions, opts: PgConnectOptions) { - let pool = setup_pool(opts).await; - let (mut client, _capture, pool) = make_test_client_with_proxy_rx(pool).await; - seed_ca(&pool).await; - login_admin(&mut client).await; - - let resp = client - .post("/api/v1/proxy/cert/self-signed") - .json(&json!({"san": ["proxy.example.com", "proxy2.example.com", "192.168.1.1"]})) - .send() - .await; - assert_eq!(resp.status(), StatusCode::OK); - - let saved = Certificates::get(&pool).await.unwrap().unwrap(); - assert_eq!(saved.proxy_http_cert_source, ProxyCertSource::SelfSigned); - assert!(saved.proxy_http_cert_pem.is_some()); -} - -/// Uploading a second custom cert overwrites the previous one (idempotent upsert). -#[sqlx::test] -async fn test_proxy_cert_upload_overwrites_previous(_: PgPoolOptions, opts: PgConnectOptions) { - let pool = setup_pool(opts).await; - let (mut client, mut capture, pool) = make_test_client_with_proxy_rx(pool).await; - login_admin(&mut client).await; - - let first_cert = "-----BEGIN CERTIFICATE-----\nfirst\n-----END CERTIFICATE-----\n"; - let first_key = "-----BEGIN PRIVATE KEY-----\nfirst_key\n-----END PRIVATE KEY-----\n"; - client - .post("/api/v1/proxy/cert/upload") - .json(&json!({"cert_pem": first_cert, "key_pem": first_key})) + assert!(broadcasts[0].0.contains("BEGIN CERTIFICATE")); + assert!(broadcasts[0].1.contains("BEGIN PRIVATE KEY")); + + let (cert_pem, key_pem) = generate_test_cert_pem("uploaded-edge.example.com"); + let expected_cert_pem = cert_pem.clone(); + let expected_key_pem = key_pem.clone(); + let response = client + .post("/api/v1/proxy/cert/external_url_settings") + .json(&json!({ + "ssl_type": "own_cert", + "cert_pem": cert_pem, + "key_pem": key_pem + })) .send() .await; + assert_eq!(response.status(), StatusCode::CREATED); - let second_cert = "-----BEGIN CERTIFICATE-----\nsecond\n-----END CERTIFICATE-----\n"; - let second_key = "-----BEGIN PRIVATE KEY-----\nsecond_key\n-----END PRIVATE KEY-----\n"; - let resp = client - .post("/api/v1/proxy/cert/upload") - .json(&json!({"cert_pem": second_cert, "key_pem": second_key})) - .send() - .await; - assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = response.json().await; + assert_eq!( + body["cert_info"]["common_name"], + "uploaded-edge.example.com" + ); - // Only the latest cert must be stored let saved = Certificates::get(&pool).await.unwrap().unwrap(); assert_eq!(saved.proxy_http_cert_source, ProxyCertSource::Custom); - assert_eq!(saved.proxy_http_cert_pem.as_deref(), Some(second_cert)); - assert_eq!(saved.proxy_http_cert_key_pem.as_deref(), Some(second_key)); - - // Both uploads must have triggered a broadcast - let broadcasts = capture.drain_broadcast_certs().await; - assert_eq!(broadcasts.len(), 2, "Expected one broadcast per upload"); - assert_eq!(broadcasts[1].0, second_cert); -} - -/// After a self-signed cert is issued, source transitions from the previous -/// state (Custom) to SelfSigned and the old PEM is replaced. -#[sqlx::test] -async fn test_proxy_cert_self_signed_overwrites_custom(_: PgPoolOptions, opts: PgConnectOptions) { - let pool = setup_pool(opts).await; - let (mut client, _capture, pool) = make_test_client_with_proxy_rx(pool).await; - seed_ca(&pool).await; - login_admin(&mut client).await; + assert!(saved.proxy_http_cert_expiry.is_some()); + assert!(saved.acme_domain.is_none()); - // First, upload a custom cert. - client - .post("/api/v1/proxy/cert/upload") + let (_, mismatched_key_pem) = generate_test_cert_pem("different-edge.example.com"); + let response = client + .post("/api/v1/proxy/cert/external_url_settings") .json(&json!({ - "cert_pem": "-----BEGIN CERTIFICATE-----\ncustom\n-----END CERTIFICATE-----\n", - "key_pem": "-----BEGIN PRIVATE KEY-----\ncustom_key\n-----END PRIVATE KEY-----\n" + "ssl_type": "own_cert", + "cert_pem": expected_cert_pem, + "key_pem": mismatched_key_pem })) .send() .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); - // Now provision a self-signed one. - let resp = client - .post("/api/v1/proxy/cert/self-signed") - .json(&json!({"san": ["proxy.example.com"]})) - .send() - .await; - assert_eq!(resp.status(), StatusCode::OK); - - let saved = Certificates::get(&pool).await.unwrap().unwrap(); - assert_eq!( - saved.proxy_http_cert_source, - ProxyCertSource::SelfSigned, - "Source must be SelfSigned after re-provisioning" - ); - assert!( - saved - .proxy_http_cert_pem - .as_deref() - .unwrap_or("") - .contains("BEGIN CERTIFICATE"), - "Stored cert must be a valid CA-signed PEM, not the old custom one" - ); -} - -/// A non-admin user (regular user) must receive 403 on both endpoints. -#[sqlx::test] -async fn test_proxy_cert_endpoints_require_admin_role(_: PgPoolOptions, opts: PgConnectOptions) { - let pool = setup_pool(opts).await; - let (client, _capture, _pool) = make_test_client_with_proxy_rx(pool).await; - - // Log in as the regular test user (hpotter) seeded by initialize_users() - let auth = Auth::new("hpotter", "pass123"); - let resp = client.post("/api/v1/auth").json(&auth).send().await; - assert_eq!(resp.status(), StatusCode::OK); - - let fake_cert = "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n"; - let fake_key = "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----\n"; - - let resp = client - .post("/api/v1/proxy/cert/upload") - .json(&json!({"cert_pem": fake_cert, "key_pem": fake_key})) - .send() - .await; - assert_eq!( - resp.status(), - StatusCode::FORBIDDEN, - "Non-admin must not upload proxy cert" - ); + let broadcasts = capture.drain_broadcast_certs().await; + assert_eq!(broadcasts.len(), 1, "Expected exactly one broadcast"); + assert_eq!(broadcasts[0].0, expected_cert_pem); + assert_eq!(broadcasts[0].1, expected_key_pem); - let resp = client - .post("/api/v1/proxy/cert/self-signed") - .json(&json!({"san": ["proxy.example.com"]})) + let response = client + .post("/api/v1/proxy/cert/external_url_settings") + .json(&json!({ + "ssl_type": "own_cert", + "cert_pem": "-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n" + })) .send() .await; - assert_eq!( - resp.status(), - StatusCode::FORBIDDEN, - "Non-admin must not provision self-signed proxy cert" - ); -} - -/// When no cert is configured (default state), proxy_http_cert_pair() returns None. -#[sqlx::test] -async fn test_proxy_cert_pair_none_by_default(_: PgPoolOptions, opts: PgConnectOptions) { - let pool = setup_pool(opts).await; - // Initialize DB without touching the certificates table (fresh schema). - initialize_current_settings(&pool).await.unwrap(); - - let certs = Certificates::get_or_default(&pool).await.unwrap(); - assert_eq!(certs.proxy_http_cert_source, ProxyCertSource::None); - assert!( - certs.proxy_http_cert_pair().is_none(), - "No cert must be configured by default" - ); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); } diff --git a/crates/defguard_proxy_manager/Cargo.toml b/crates/defguard_proxy_manager/Cargo.toml index 5596045cb9..81a0045f5b 100644 --- a/crates/defguard_proxy_manager/Cargo.toml +++ b/crates/defguard_proxy_manager/Cargo.toml @@ -44,4 +44,3 @@ serde.workspace = true serde_json.workspace = true tower.workspace = true totp-lite.workspace = true - diff --git a/crates/defguard_proxy_manager/src/lib.rs b/crates/defguard_proxy_manager/src/lib.rs index b356971464..cb6c546be8 100644 --- a/crates/defguard_proxy_manager/src/lib.rs +++ b/crates/defguard_proxy_manager/src/lib.rs @@ -372,6 +372,19 @@ impl ProxyManager { } } } + Some(ProxyControlMessage::ClearHttpsCerts) => { + debug!("Broadcasting ClearHttpsCerts to all connected proxies"); + let msg = CoreResponse { + id: 0, + payload: Some(core_response::Payload::ClearHttpsCerts(())), + }; + if let Ok(map) = handler_tx_map.read() { + for (pid, tx) in map.iter() { + debug!("Sending ClearHttpsCerts to proxy {pid}"); + let _ = tx.send(msg.clone()); + } + } + } None => { debug!("Proxy control channel closed"); break; diff --git a/crates/defguard_setup/src/handlers/auto_wizard.rs b/crates/defguard_setup/src/handlers/auto_wizard.rs index 48ee636117..41270faed2 100644 --- a/crates/defguard_setup/src/handlers/auto_wizard.rs +++ b/crates/defguard_setup/src/handlers/auto_wizard.rs @@ -1,12 +1,8 @@ use axum::{Extension, Json}; -use defguard_certs::{ - CertificateAuthority, CertificateInfo, Csr, PemLabel, der_to_pem, generate_key_pair, - parse_pem_certificate, -}; +use defguard_certs::{PemLabel, der_to_pem}; use defguard_common::{ db::models::{ Certificates, WireguardNetwork, - certificates::{CoreCertSource, ProxyCertSource}, initial_setup_wizard::InitialSetupStep, settings::update_current_settings, setup_auto_adoption::{AutoAdoptionWizardState, AutoAdoptionWizardStep}, @@ -17,10 +13,16 @@ use defguard_common::{ }; use defguard_core::{ auth::AdminOrSetupRole, + cert_settings::{ + CertInfoResponse, ExternalSslType, + ExternalUrlSettingsConfig as CoreExternalUrlSettingsConfig, InternalSslType, + InternalUrlSettingsConfig as CoreInternalUrlSettingsConfig, + apply_external_url_settings as apply_core_external_url_settings, + apply_internal_url_settings as apply_core_internal_url_settings, + }, error::WebError, handlers::{ApiResponse, ApiResult}, }; -use rcgen::DnType; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -55,18 +57,6 @@ pub(crate) async fn advance_auto_wizard_to_step( Ok(()) } -/// SSL configuration type for Defguard's internal (core) web server. -#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum InternalSslType { - /// No SSL - plain HTTP, user manages reverse proxy / SSL termination themselves. - None, - /// Generate certificates using Defguard's internal Certificate Authority. - DefguardCa, - /// Upload a custom certificate and private key. - OwnCert, -} - #[derive(Deserialize, Serialize, Debug)] pub struct InternalUrlSettingsConfig { defguard_url: String, @@ -75,118 +65,22 @@ pub struct InternalUrlSettingsConfig { key_pem: Option, } -#[derive(Serialize, Debug)] -pub struct CertInfoResponse { - pub common_name: String, - pub valid_for_days: i64, - pub not_before: String, - pub not_after: String, -} - /// Core logic for applying internal URL settings and configuring SSL for the core web server. /// Returns the cert info if a certificate was generated/uploaded, `None` for `ssl_type = None`. pub(crate) async fn apply_internal_url_settings( pool: &PgPool, config: InternalUrlSettingsConfig, ) -> Result, WebError> { - debug!( - "Internal URL settings received: defguard_url={}, ssl_type={:?}", - config.defguard_url, config.ssl_type, - ); - - let mut settings = defguard_common::db::models::Settings::get_current_settings(); - settings.defguard_url = config.defguard_url.clone(); - update_current_settings(pool, settings).await?; - - let mut certs = Certificates::get_or_default(pool) - .await - .map_err(WebError::from)?; - - let cert_info = match config.ssl_type { - InternalSslType::None => { - certs.core_http_cert_source = CoreCertSource::None; - certs.core_http_cert_pem = None; - certs.core_http_cert_key_pem = None; - certs.core_http_cert_expiry = None; - certs.save(pool).await.map_err(WebError::from)?; - None - } - InternalSslType::DefguardCa => { - // Extract hostname from defguard_url for the SAN. - let hostname = reqwest::Url::parse(&config.defguard_url) - .ok() - .and_then(|u| u.host_str().map(ToString::to_string)) - .unwrap_or_else(|| config.defguard_url.clone()); - - // CA must already be present at this point. - if certs.ca_cert_der.is_none() { - return Err(WebError::BadRequest( - "CA certificate is not present; generate a CA first".to_string(), - )); - } - - // Generate server certificate signed by the CA. - let ca_cert_der = certs.ca_cert_der.as_ref().expect("CA cert must be present"); - let ca_key_der = certs.ca_key_der.as_ref().ok_or_else(|| { - WebError::BadRequest("CA private key not available for signing".to_string()) - })?; - - let ca = CertificateAuthority::from_cert_der_key_pair(ca_cert_der, ca_key_der)?; - let key_pair = generate_key_pair()?; - let san = vec![hostname.clone()]; - let dn = vec![(DnType::CommonName, hostname.as_str())]; - let csr = Csr::new(&key_pair, &san, dn)?; - let server_cert = ca.sign_csr(&csr)?; - - let cert_der = server_cert.der().to_vec(); - let cert_pem = der_to_pem(&cert_der, PemLabel::Certificate)?; - let key_pem = der_to_pem(key_pair.serialize_der().as_slice(), PemLabel::PrivateKey)?; - let info = CertificateInfo::from_der(&cert_der)?; - let valid_for_days = (info.not_after.and_utc() - chrono::Utc::now()).num_days(); - let expiry = info.not_after; - - certs.core_http_cert_source = CoreCertSource::SelfSigned; - certs.core_http_cert_pem = Some(cert_pem); - certs.core_http_cert_key_pem = Some(key_pem); - certs.core_http_cert_expiry = Some(expiry); - certs.save(pool).await.map_err(WebError::from)?; - - Some(CertInfoResponse { - common_name: info.subject_common_name, - valid_for_days, - not_before: info.not_before.to_string(), - not_after: info.not_after.to_string(), - }) - } - InternalSslType::OwnCert => { - let cert_pem_str = config.cert_pem.ok_or_else(|| { - WebError::BadRequest("cert_pem is required for own_cert".to_string()) - })?; - let key_pem_str = config.key_pem.ok_or_else(|| { - WebError::BadRequest("key_pem is required for own_cert".to_string()) - })?; - - let cert_der = parse_pem_certificate(&cert_pem_str)?; - let info = CertificateInfo::from_der(cert_der.as_ref())?; - let valid_for_days = (info.not_after.and_utc() - chrono::Utc::now()).num_days(); - let expiry = info.not_after; - - certs.core_http_cert_source = CoreCertSource::Custom; - certs.core_http_cert_pem = Some(cert_pem_str); - certs.core_http_cert_key_pem = Some(key_pem_str); - certs.core_http_cert_expiry = Some(expiry); - certs.save(pool).await.map_err(WebError::from)?; - - Some(CertInfoResponse { - common_name: info.subject_common_name.clone(), - valid_for_days, - not_before: info.not_before.to_string(), - not_after: info.not_after.to_string(), - }) - } - }; - - Ok(cert_info) + apply_core_internal_url_settings( + pool, + &config.defguard_url, + CoreInternalUrlSettingsConfig { + ssl_type: config.ssl_type, + cert_pem: config.cert_pem, + key_pem: config.key_pem, + }, + ) + .await } /// Updates internal URL settings and configures SSL for the core web server. @@ -241,21 +135,6 @@ pub async fn get_internal_ssl_info( )) } -/// SSL configuration type for the external (proxy) web server. -#[derive(Default, Deserialize, Serialize, Debug, Clone, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum ExternalSslType { - /// No SSL - plain HTTP, user manages reverse proxy / SSL termination themselves. - #[default] - None, - /// Obtain certificate via ACME / Let's Encrypt. - LetsEncrypt, - /// Generate certificate using Defguard's internal Certificate Authority. - DefguardCa, - /// Upload a custom certificate and private key. - OwnCert, -} - #[derive(Deserialize, Serialize, Debug)] pub struct ExternalUrlSettingsConfig { public_proxy_url: String, @@ -301,115 +180,20 @@ pub(crate) async fn apply_external_url_settings( pool: &PgPool, config: ExternalUrlSettingsConfig, ) -> Result, WebError> { - debug!( - "External URL settings received: public_proxy_url={}, ssl_type={:?}", - config.public_proxy_url, config.ssl_type, - ); - let mut settings = defguard_common::db::models::Settings::get_current_settings(); settings.public_proxy_url = config.public_proxy_url.clone(); update_current_settings(pool, settings).await?; - let mut certs = Certificates::get_or_default(pool) - .await - .map_err(WebError::from)?; - - let cert_info = match config.ssl_type { - ExternalSslType::None => { - certs.proxy_http_cert_source = ProxyCertSource::None; - certs.proxy_http_cert_pem = None; - certs.proxy_http_cert_key_pem = None; - certs.proxy_http_cert_expiry = None; - certs.save(pool).await.map_err(WebError::from)?; - None - } - ExternalSslType::LetsEncrypt => { - let hostname = reqwest::Url::parse(&config.public_proxy_url) - .ok() - .and_then(|u| u.host_str().map(ToString::to_string)) - .unwrap_or_else(|| config.public_proxy_url.clone()); - certs.proxy_http_cert_source = ProxyCertSource::LetsEncrypt; - certs.acme_domain = Some(hostname); - certs.proxy_http_cert_pem = None; - certs.proxy_http_cert_key_pem = None; - certs.proxy_http_cert_expiry = None; - certs.save(pool).await.map_err(WebError::from)?; - None - } - ExternalSslType::DefguardCa => { - let hostname = reqwest::Url::parse(&config.public_proxy_url) - .ok() - .and_then(|u| u.host_str().map(ToString::to_string)) - .unwrap_or_else(|| config.public_proxy_url.clone()); - - // CA must already be present at this point. - if certs.ca_cert_der.is_none() { - return Err(WebError::BadRequest( - "CA certificate is not present; generate a CA first".to_string(), - )); - } - - let ca_cert_der = certs.ca_cert_der.as_ref().expect("CA cert must be present"); - let ca_key_der = certs.ca_key_der.as_ref().ok_or_else(|| { - WebError::BadRequest("CA private key not available for signing".to_string()) - })?; - - let ca = CertificateAuthority::from_cert_der_key_pair(ca_cert_der, ca_key_der)?; - let key_pair = generate_key_pair()?; - let san = vec![hostname.clone()]; - let dn = vec![(DnType::CommonName, hostname.as_str())]; - let csr = Csr::new(&key_pair, &san, dn)?; - let server_cert = ca.sign_csr(&csr)?; - - let cert_der = server_cert.der().to_vec(); - let cert_pem = der_to_pem(&cert_der, PemLabel::Certificate)?; - let key_pem = der_to_pem(key_pair.serialize_der().as_slice(), PemLabel::PrivateKey)?; - let info = CertificateInfo::from_der(&cert_der)?; - let valid_for_days = (info.not_after.and_utc() - chrono::Utc::now()).num_days(); - let expiry = info.not_after; - - certs.proxy_http_cert_source = ProxyCertSource::SelfSigned; - certs.proxy_http_cert_pem = Some(cert_pem); - certs.proxy_http_cert_key_pem = Some(key_pem); - certs.proxy_http_cert_expiry = Some(expiry); - certs.save(pool).await.map_err(WebError::from)?; - - Some(CertInfoResponse { - common_name: info.subject_common_name, - valid_for_days, - not_before: info.not_before.to_string(), - not_after: info.not_after.to_string(), - }) - } - ExternalSslType::OwnCert => { - let cert_pem_str = config.cert_pem.ok_or_else(|| { - WebError::BadRequest("cert_pem is required for own_cert".to_string()) - })?; - let key_pem_str = config.key_pem.ok_or_else(|| { - WebError::BadRequest("key_pem is required for own_cert".to_string()) - })?; - - let cert_der = parse_pem_certificate(&cert_pem_str)?; - let info = CertificateInfo::from_der(cert_der.as_ref())?; - let valid_for_days = (info.not_after.and_utc() - chrono::Utc::now()).num_days(); - let expiry = info.not_after; - - certs.proxy_http_cert_source = ProxyCertSource::Custom; - certs.proxy_http_cert_pem = Some(cert_pem_str); - certs.proxy_http_cert_key_pem = Some(key_pem_str); - certs.proxy_http_cert_expiry = Some(expiry); - certs.save(pool).await.map_err(WebError::from)?; - - Some(CertInfoResponse { - common_name: info.subject_common_name.clone(), - valid_for_days, - not_before: info.not_before.to_string(), - not_after: info.not_after.to_string(), - }) - } - }; - - Ok(cert_info) + apply_core_external_url_settings( + pool, + &config.public_proxy_url, + CoreExternalUrlSettingsConfig { + ssl_type: config.ssl_type, + cert_pem: config.cert_pem, + key_pem: config.key_pem, + }, + ) + .await } /// Returns external SSL certificate info (for the "Download CA certificate" step). diff --git a/crates/defguard_setup/src/handlers/initial_wizard.rs b/crates/defguard_setup/src/handlers/initial_wizard.rs index b7c86d518d..1ccb5f7c8c 100644 --- a/crates/defguard_setup/src/handlers/initial_wizard.rs +++ b/crates/defguard_setup/src/handlers/initial_wizard.rs @@ -405,7 +405,13 @@ pub async fn get_ca(_: AdminOrSetupRole, Extension(pool): Extension) -> } 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 }), + 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 { diff --git a/crates/defguard_setup/src/migration.rs b/crates/defguard_setup/src/migration.rs index 077a755d4f..6903f3611d 100644 --- a/crates/defguard_setup/src/migration.rs +++ b/crates/defguard_setup/src/migration.rs @@ -70,6 +70,7 @@ pub fn build_migration_webapp( let (webhook_tx, webhook_rx) = mpsc::unbounded_channel::(); let (event_tx, event_rx) = mpsc::unbounded_channel::(); let (wireguard_tx, wireguard_rx) = broadcast::channel::(64); + let (web_reload_tx, _web_reload_rx) = broadcast::channel::<()>(8); let (proxy_control_tx, proxy_control_rx) = mpsc::channel(32); let incompatible_components = Arc::new(RwLock::new(IncompatibleComponents::default())); let key = Key::from( @@ -83,6 +84,7 @@ pub fn build_migration_webapp( webhook_tx, webhook_rx, wireguard_tx.clone(), + web_reload_tx, key, failed_logins.clone(), event_tx, diff --git a/crates/defguard_setup/tests/auto_wizard_url_settings.rs b/crates/defguard_setup/tests/auto_wizard_url_settings.rs index dd4d03c18f..f5cb72be56 100644 --- a/crates/defguard_setup/tests/auto_wizard_url_settings.rs +++ b/crates/defguard_setup/tests/auto_wizard_url_settings.rs @@ -262,7 +262,7 @@ async fn test_external_url_settings_all_ssl_types(_: PgPoolOptions, options: PgC assert_eq!(certs.proxy_http_cert_source, ProxyCertSource::None); assert!(certs.proxy_http_cert_pem.is_none()); - // ssl_type = lets_encrypt: stores ACME domain, does not issue cert yet + // ssl_type = lets_encrypt: validates settings, does not issue or persist cert state yet let resp = client .post("/api/v1/initial_setup/auto_wizard/external_url_settings") .json( @@ -274,8 +274,8 @@ async fn test_external_url_settings_all_ssl_types(_: PgPoolOptions, options: PgC assert_eq!(resp.status(), StatusCode::CREATED); let certs = Certificates::get_or_default(&pool).await.unwrap(); - assert_eq!(certs.proxy_http_cert_source, ProxyCertSource::LetsEncrypt); - assert_eq!(certs.acme_domain.as_deref(), Some("proxy.example.com")); + assert_eq!(certs.proxy_http_cert_source, ProxyCertSource::None); + assert!(certs.acme_domain.is_none()); assert!(certs.proxy_http_cert_pem.is_none()); assert!(certs.proxy_http_cert_key_pem.is_none()); diff --git a/proto b/proto index 0e247a24c3..f600c25c27 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 0e247a24c3ae78052501dd052af4b73ab8f1e368 +Subproject commit f600c25c2715d798225cc2441c4f79fdbba48af8 diff --git a/web/messages/en/initial_wizard.json b/web/messages/en/initial_wizard.json index c48e547872..2e3db55141 100644 --- a/web/messages/en/initial_wizard.json +++ b/web/messages/en/initial_wizard.json @@ -137,7 +137,7 @@ "initial_setup_auto_adoption_internal_url_settings_upload_key_button": "Upload key file", "initial_setup_auto_adoption_internal_url_ssl_no_ssl_title": "Custom configuration", "initial_setup_auto_adoption_internal_url_ssl_no_ssl_description": "You have opted to deploy a custom Reverse Proxy and manage your own SSL certificates. Please ensure your proxy configuration correctly routes traffic to the:", - "initial_setup_auto_adoption_internal_url_ssl_no_ssl_port": "Defguard Edge service via TCP port 8080", + "initial_setup_auto_adoption_internal_url_ssl_no_ssl_port": "Defguard Core service via TCP port 8000", "initial_setup_auto_adoption_internal_url_ssl_ca_title": "Certificate Generated", "initial_setup_auto_adoption_internal_url_ssl_ca_description": "You have chosen to secure Core with Defguard's internal CA, here you can download the CA Certificate Root File, please import it in your browser.", "initial_setup_auto_adoption_internal_url_ssl_ca_download": "Download certificate", @@ -163,7 +163,7 @@ "initial_setup_auto_adoption_external_url_settings_upload_key_button": "Upload key file", "initial_setup_auto_adoption_external_url_ssl_no_ssl_title": "Custom configuration", "initial_setup_auto_adoption_external_url_ssl_no_ssl_description": "You have opted to deploy a custom Reverse Proxy and manage your own SSL certificates. Please ensure your proxy configuration correctly routes traffic to the:", - "initial_setup_auto_adoption_external_url_ssl_no_ssl_port": "Defguard Core service via TCP port 8000", + "initial_setup_auto_adoption_external_url_ssl_no_ssl_port": "Defguard Edge service via TCP port 8080", "initial_setup_auto_adoption_external_url_ssl_lets_encrypt_connecting": "Connecting \"Let's Encrypt\"® to obtain certificate", "initial_setup_auto_adoption_external_url_ssl_lets_encrypt_checking_domain": "Checking domain DNS resolution", "initial_setup_auto_adoption_external_url_ssl_lets_encrypt_validating": "Validating with Let's Encrypt the domain name", diff --git a/web/messages/en/settings.json b/web/messages/en/settings.json index fbb9297585..d161343287 100644 --- a/web/messages/en/settings.json +++ b/web/messages/en/settings.json @@ -5,6 +5,7 @@ "settings_breadcrumb_instance": "Instance settings", "settings_breadcrumb_password_reset": "Password reset", "settings_breadcrumb_client_behavior": "Client behavior", + "settings_breadcrumb_certificates": "Certificates", "settings_client_title": "Client settings", "settings_instance_title": "Instance settings", "settings_instance_subtitle": "Here you can configure general instance parameters.", @@ -27,6 +28,58 @@ "settings_instance_helper_session_duration": "", "settings_instance_label_password_reset_session_expiration": "Password reset session expiration", "settings_instance_helper_password_reset_session_expiration": "", + "settings_certs_ca_title": "Defguard Certificate Authority", + "settings_certs_ca_description": "Manage certificates automatically generated by Defguard for secure communication. These certificates are used to authenticate devices and services, ensuring encrypted connections across your network.", + "settings_certs_ca_summary_title": "Certificate authority summary", + "settings_certs_ca_summary": "The system created all required certificate files, including the root certificate and private key. You can download these files and continue with the configuration.", + "settings_certs_ca_certificate_validated_title": "Certificate Validated", + "settings_certs_ca_certificate_validated": "Your uploaded Certificate has been successfully validated. All required files were checked and confirmed as correct and ready for use. You can download the validated certificate files if needed for your setup.", + "settings_certs_ca_information_extracted": "Information extracted from uploaded files", + "settings_certs_ca_download": "Download CA certificate", + "settings_certs_ca_email": "Email", + "settings_certs_valid_until": "Valid until", + "settings_certs_domain": "Certificate domain", + "settings_certs_certs_title": "Edge/Core Certificates", + "settings_certs_certs_description": "Add and manage certificates issued outside of Defguard. Use them to integrate existing infrastructure and maintain secure connections with external systems and services.", + "settings_certs_certs_core_title": "Core certificate", + "settings_certs_core_wizard_title": "Internal URL Settings", + "settings_certs_core_wizard_subtitle": "Reconfigure how Defguard secures the internal Core URL.", + "settings_certs_core_wizard_step_internal_url_settings_label": "Internal URL Settings", + "settings_certs_core_wizard_step_internal_url_settings_description": "Choose how SSL should be configured for the internal Core URL.", + "settings_certs_core_wizard_step_internal_url_ssl_config_label": "Internal URL SSL Configuration", + "settings_certs_core_wizard_step_internal_url_ssl_config_description": "Review the SSL configuration generated from your selected certificate type.", + "settings_certs_core_wizard_step_summary_label": "Summary", + "settings_certs_core_wizard_step_summary_description": "Review the result of the certificate configuration.", + "settings_certs_core_wizard_summary_success_title": "Configuration succeeded", + "settings_certs_core_wizard_summary_success_description": "Your Core certificate settings have been saved successfully.", + "settings_certs_core_wizard_summary_ok": "OK", + "settings_certs_warning_expiring": "Expiring certificate", + "settings_certs_warning_expired": "Expired certificate", + "settings_certs_status_valid": "Valid", + "settings_certs_status_expiring": "Expires soon", + "settings_certs_status_expired": "Expired", + "settings_certs_status_unknown": "Unknown", + "settings_certs_certs_none_title": "No SSL certificate", + "settings_certs_certs_none_description": "You have opted to deploy a custom Reverse Proxy and manage your own SSL certificates.", + "settings_certs_certs_internal_title": "Defguard Internal CA", + "settings_certs_certs_internal_description": "Manage certificates automatically generated by Defguard for secure communication.", + "settings_certs_certs_custom_title": "Your own certificate", + "settings_certs_certs_custom_description": "You are using your own, custom SSL certificate.", + "settings_certs_certs_change": "Change certificate", + "settings_certs_certs_letsencrypt_title": "\"Let's Encrypt\"® certificate", + "settings_certs_certs_letsencrypt_description": "You are using a \"Let's Encrypt\"® issued certificate.", + "settings_certs_certs_edge_title": "Edge certificate", + "settings_certs_edge_wizard_title": "External URL Settings", + "settings_certs_edge_wizard_subtitle": "Reconfigure how Defguard secures the public Edge URL.", + "settings_certs_edge_wizard_step_external_url_settings_label": "External URL Settings", + "settings_certs_edge_wizard_step_external_url_settings_description": "Choose how SSL should be configured for the public Edge URL.", + "settings_certs_edge_wizard_step_external_url_ssl_config_label": "External URL SSL Configuration", + "settings_certs_edge_wizard_step_external_url_ssl_config_description": "Review the SSL configuration generated from your selected certificate type.", + "settings_certs_edge_wizard_step_summary_label": "Summary", + "settings_certs_edge_wizard_step_summary_description": "Review the result of the certificate configuration.", + "settings_certs_edge_wizard_summary_success_title": "Configuration succeeded", + "settings_certs_edge_wizard_summary_success_description": "Your Edge certificate settings have been saved successfully.", + "settings_certs_edge_wizard_summary_ok": "OK", "settings_vpn_stats_toggle_title": "Stats purge", "settings_vpn_stats_label_purge_frequency": "Stats purge frequency", "settings_vpn_stats_helper_purge_frequency": "", @@ -87,6 +140,7 @@ "settings_tab_general": "General", "settings_tab_notifications": "Notifications", "settings_tab_identity_providers": "External identity providers", + "settings_tab_certificates": "Certificates", "settings_tab_activity_streaming": "Activity streaming", "settings_tab_license": "License", "settings_general_section_instance_content": "Configure your instance general settings.", diff --git a/web/src/pages/MigrationWizardPage/steps/MigrationWizardInternalUrlSslConfigStep.tsx b/web/src/pages/MigrationWizardPage/steps/MigrationWizardInternalUrlSslConfigStep.tsx index 301722d196..b02fac48e4 100644 --- a/web/src/pages/MigrationWizardPage/steps/MigrationWizardInternalUrlSslConfigStep.tsx +++ b/web/src/pages/MigrationWizardPage/steps/MigrationWizardInternalUrlSslConfigStep.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { m } from '../../../paraglide/messages'; import api from '../../../shared/api/api'; import { Controls } from '../../../shared/components/Controls/Controls'; +import { InternalSslResult } from '../../../shared/components/certificates/InternalSslResult/InternalSslResult'; 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'; @@ -42,95 +43,15 @@ export const MigrationWizardInternalUrlSslConfigStep = () => { ); }; - const renderContent = () => { - if (sslType === 'none') { - return ( -
-
-

- {m.initial_setup_auto_adoption_internal_url_ssl_no_ssl_title()} -

-

{m.initial_setup_auto_adoption_internal_url_ssl_no_ssl_description()}

-
- -
    -
  • {m.initial_setup_auto_adoption_internal_url_ssl_no_ssl_port()}
  • -
-
- ); - } - - if (sslType === 'defguard_ca') { - return ( -
-
- -
-
-
-

{m.initial_setup_auto_adoption_internal_url_ssl_ca_title()}

-

{m.initial_setup_auto_adoption_internal_url_ssl_ca_description()}

-
-
-
-
-
- ); - } - - if (sslType === 'own_cert' && certInfo) { - return ( -
-
- -
-
-
-

{m.initial_setup_auto_adoption_internal_url_ssl_own_title()}

-

{m.initial_setup_auto_adoption_internal_url_ssl_own_description()}

-
-
-

- {m.initial_setup_auto_adoption_internal_url_ssl_own_info_title()} -

- -
-
- - {m.initial_setup_auto_adoption_internal_url_ssl_own_common_name()} - - {certInfo.common_name} -
-
- - {m.initial_setup_auto_adoption_internal_url_ssl_own_validity()} - - - {m.initial_setup_auto_adoption_internal_url_ssl_own_validity_days({ - days: certInfo.valid_for_days, - })} - -
-
-
-
-
- ); - } - - return null; - }; - return ( - {renderContent()} + diff --git a/web/src/pages/SettingsCoreCertificateWizardPage/SettingsCoreCertificateWizardPage.tsx b/web/src/pages/SettingsCoreCertificateWizardPage/SettingsCoreCertificateWizardPage.tsx new file mode 100644 index 0000000000..f52d78efe7 --- /dev/null +++ b/web/src/pages/SettingsCoreCertificateWizardPage/SettingsCoreCertificateWizardPage.tsx @@ -0,0 +1,68 @@ +import { useNavigate } from '@tanstack/react-router'; +import { type ReactNode, useEffect, useMemo } from 'react'; +import { m } from '../../paraglide/messages'; +import type { WizardPageStep } from '../../shared/components/wizard/types'; +import { WizardPage } from '../../shared/components/wizard/WizardPage/WizardPage'; +import { SettingsCoreCertificateWizardInternalUrlSettingsStep } from './steps/SettingsCoreCertificateWizardInternalUrlSettingsStep'; +import { SettingsCoreCertificateWizardInternalUrlSslConfigStep } from './steps/SettingsCoreCertificateWizardInternalUrlSslConfigStep'; +import { SettingsCoreCertificateWizardSummaryStep } from './steps/SettingsCoreCertificateWizardSummaryStep'; +import { + SettingsCoreCertificateWizardStep, + type SettingsCoreCertificateWizardStepValue, +} from './types'; +import { useSettingsCoreCertificateWizardStore } from './useSettingsCoreCertificateWizardStore'; + +const steps: Record = { + internalUrlSettings: , + internalUrlSslConfig: , + summary: , +}; + +export const SettingsCoreCertificateWizardPage = () => { + const activeStep = useSettingsCoreCertificateWizardStore((s) => s.activeStep); + const navigate = useNavigate(); + + useEffect(() => () => useSettingsCoreCertificateWizardStore.getState().reset(), []); + + const stepsConfig = useMemo( + (): Record => ({ + internalUrlSettings: { + id: SettingsCoreCertificateWizardStep.InternalUrlSettings, + order: 1, + label: m.settings_certs_core_wizard_step_internal_url_settings_label(), + description: + m.settings_certs_core_wizard_step_internal_url_settings_description(), + }, + internalUrlSslConfig: { + id: SettingsCoreCertificateWizardStep.InternalUrlSslConfig, + order: 2, + label: m.settings_certs_core_wizard_step_internal_url_ssl_config_label(), + description: + m.settings_certs_core_wizard_step_internal_url_ssl_config_description(), + }, + summary: { + id: SettingsCoreCertificateWizardStep.Summary, + order: 3, + label: m.settings_certs_core_wizard_step_summary_label(), + description: m.settings_certs_core_wizard_step_summary_description(), + }, + }), + [], + ); + + return ( + { + useSettingsCoreCertificateWizardStore.getState().reset(); + void navigate({ to: '/settings/certs', replace: true }); + }} + > + {steps[activeStep]} + + ); +}; diff --git a/web/src/pages/SettingsCoreCertificateWizardPage/steps/SettingsCoreCertificateWizardInternalUrlSettingsStep.tsx b/web/src/pages/SettingsCoreCertificateWizardPage/steps/SettingsCoreCertificateWizardInternalUrlSettingsStep.tsx new file mode 100644 index 0000000000..967bc26a3e --- /dev/null +++ b/web/src/pages/SettingsCoreCertificateWizardPage/steps/SettingsCoreCertificateWizardInternalUrlSettingsStep.tsx @@ -0,0 +1,165 @@ +import { useMutation } from '@tanstack/react-query'; +import z from 'zod'; +import { m } from '../../../paraglide/messages'; +import api from '../../../shared/api/api'; +import type { InternalSslType } from '../../../shared/api/types'; +import { Controls } from '../../../shared/components/Controls/Controls'; +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 { Helper } from '../../../shared/defguard-ui/components/Helper/Helper'; +import { Radio } from '../../../shared/defguard-ui/components/Radio/Radio'; +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 '../../SetupPage/autoAdoption/steps/style.scss'; +import { SettingsCoreCertificateWizardStep } from '../types'; +import { useSettingsCoreCertificateWizardStore } from '../useSettingsCoreCertificateWizardStore'; + +export const SettingsCoreCertificateWizardInternalUrlSettingsStep = () => { + const storedSslType = useSettingsCoreCertificateWizardStore((s) => s.internal_ssl_type); + + const formSchema = z.object({ + ssl_type: z.custom(), + cert_pem_file: z.custom().nullable(), + key_pem_file: z.custom().nullable(), + }); + + const { mutate, isPending } = useMutation({ + mutationFn: api.core.setInternalUrlSettings, + meta: { + invalidate: [ + ['core', 'cert', 'certs'], + ['core', 'cert', 'ca'], + ], + }, + onSuccess: (response) => { + useSettingsCoreCertificateWizardStore.setState({ + internal_ssl_type: form.getFieldValue('ssl_type'), + internal_ssl_cert_info: response.data.cert_info ?? null, + activeStep: SettingsCoreCertificateWizardStep.InternalUrlSslConfig, + }); + }, + onError: (error) => { + Snackbar.error(m.settings_msg_save_failed()); + console.error('Failed to save core internal URL settings:', error); + }, + }); + + const form = useAppForm({ + defaultValues: { + ssl_type: (storedSslType ?? 'none') as InternalSslType, + cert_pem_file: null as File | null, + key_pem_file: null as File | null, + }, + validationLogic: formChangeLogic, + validators: { onSubmit: formSchema, onChange: formSchema }, + onSubmit: async ({ value }) => { + if ( + value.ssl_type === 'own_cert' && + (!value.cert_pem_file || !value.key_pem_file) + ) { + Snackbar.error( + m.initial_setup_auto_adoption_internal_url_settings_upload_files_required(), + ); + return; + } + + mutate({ + ssl_type: value.ssl_type, + cert_pem: value.cert_pem_file ? await value.cert_pem_file.text() : undefined, + key_pem: value.key_pem_file ? await value.key_pem_file.text() : undefined, + }); + }, + }); + + return ( + +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + +

{m.initial_setup_auto_adoption_internal_url_settings_url_description()}

+ + s.values.ssl_type}> + {(sslType) => ( +
+
+ form.setFieldValue('ssl_type', 'none')} + /> + + {m.initial_setup_auto_adoption_internal_url_settings_ssl_option_none_help()} + +
+ +
+ form.setFieldValue('ssl_type', 'defguard_ca')} + /> + + {m.initial_setup_auto_adoption_internal_url_settings_ssl_option_defguard_ca_help()} + +
+ +
+ form.setFieldValue('ssl_type', 'own_cert')} + /> + + {m.initial_setup_auto_adoption_internal_url_settings_ssl_option_own_cert_help()} + +
+ {sslType === 'own_cert' && ( +
+ + + {(field) => ( + + )} + + + + {(field) => ( + + )} + +
+ )} +
+ )} +
+
+
+ + + +
+
+
+
+ ); +}; diff --git a/web/src/pages/SettingsCoreCertificateWizardPage/steps/SettingsCoreCertificateWizardInternalUrlSslConfigStep.tsx b/web/src/pages/SettingsCoreCertificateWizardPage/steps/SettingsCoreCertificateWizardInternalUrlSslConfigStep.tsx new file mode 100644 index 0000000000..68615ed19b --- /dev/null +++ b/web/src/pages/SettingsCoreCertificateWizardPage/steps/SettingsCoreCertificateWizardInternalUrlSslConfigStep.tsx @@ -0,0 +1,70 @@ +import { useQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { m } from '../../../paraglide/messages'; +import api from '../../../shared/api/api'; +import { Controls } from '../../../shared/components/Controls/Controls'; +import { InternalSslResult } from '../../../shared/components/certificates/InternalSslResult/InternalSslResult'; +import { WizardCard } from '../../../shared/components/wizard/WizardCard/WizardCard'; +import { Button } from '../../../shared/defguard-ui/components/Button/Button'; +import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { ThemeSpacing } from '../../../shared/defguard-ui/types'; +import { downloadFile } from '../../../shared/utils/download'; +import caIcon from '../../SetupPage/assets/ca.png'; +import '../../SetupPage/autoAdoption/steps/style.scss'; +import { SettingsCoreCertificateWizardStep } from '../types'; +import { useSettingsCoreCertificateWizardStore } from '../useSettingsCoreCertificateWizardStore'; + +export const SettingsCoreCertificateWizardInternalUrlSslConfigStep = () => { + const sslType = useSettingsCoreCertificateWizardStore((s) => s.internal_ssl_type); + const certInfo = useSettingsCoreCertificateWizardStore((s) => s.internal_ssl_cert_info); + + useEffect(() => { + if (sslType === null) { + useSettingsCoreCertificateWizardStore.setState({ + activeStep: SettingsCoreCertificateWizardStep.InternalUrlSettings, + }); + } + }, [sslType]); + + const { data: caData } = useQuery({ + queryKey: ['core', 'cert', 'ca'], + queryFn: api.core.getCA, + enabled: sslType === 'defguard_ca', + select: (response) => response.data, + }); + + const handleDownloadCaCert = () => { + if (!caData?.ca_cert_pem) return; + downloadFile( + new Blob([caData.ca_cert_pem], { type: 'application/x-pem-file' }), + 'defguard-ca', + 'pem', + ); + }; + + return ( + + + + +