From 417d909f8f451f2145bd49049c1da45a0694308b Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Thu, 16 Apr 2026 14:10:13 +0200 Subject: [PATCH 01/20] initial implementation of LE cert refresh utility thread --- .../defguard_common/src/db/models/settings.rs | 16 ++ .../src/handlers/component_setup.rs | 34 +-- crates/defguard_core/src/utility_thread.rs | 194 +++++++++++++++++- 3 files changed, 210 insertions(+), 34 deletions(-) diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index a5273bd81..5a2350f30 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -86,6 +86,10 @@ pub enum SettingsUrlError { DefguardUrlUsesIpAddress(String), #[error("Invalid WebAuthn configuration for defguard_url `{0}`: {1}")] InvalidWebauthnConfiguration(String, String), + #[error("Unparsable Edge url: {0}")] + UnparsableEdgeUrl(String), + #[error("Edge url missing hostname: {0}")] + EdgeUrlMissingHostname(String), } #[derive(Error, Debug)] @@ -766,10 +770,22 @@ impl Settings { Ok(secret_key) } + #[must_use] pub fn proxy_public_url(&self) -> Result { Url::parse(&self.public_proxy_url) } + #[must_use] + pub fn proxy_hostname(&self) -> Result { + let url = self + .proxy_public_url() + .map_err(|_err| SettingsUrlError::UnparsableEdgeUrl(self.public_proxy_url.clone()))?; + Ok(url + .host_str() + .ok_or_else(|| SettingsUrlError::EdgeUrlMissingHostname(self.public_proxy_url.clone()))? + .to_string()) + } + #[allow(deprecated)] fn apply_from_config(&mut self, config: &DefGuardConfig) { let minute = 60; diff --git a/crates/defguard_core/src/handlers/component_setup.rs b/crates/defguard_core/src/handlers/component_setup.rs index 2c1a2bf22..320541cd8 100644 --- a/crates/defguard_core/src/handlers/component_setup.rs +++ b/crates/defguard_core/src/handlers/component_setup.rs @@ -1060,7 +1060,7 @@ pub async fn setup_gateway_tls_stream( } /// Maximum time (seconds) allowed for the ACME flow to complete end-to-end. -const ACME_TIMEOUT_SECS: u64 = 300; +pub const ACME_TIMEOUT_SECS: u64 = 300; #[derive(Debug, Serialize)] struct AcmeSetupResponse { @@ -1095,7 +1095,7 @@ fn acme_error_event(step: &'static str, message: String, logs: Option &'static str { +pub fn acme_step_name(step: AcmeStep) -> &'static str { match step { AcmeStep::Unspecified | AcmeStep::Connecting => "Connecting", AcmeStep::CheckingDomain => "CheckingDomain", @@ -1104,7 +1104,7 @@ fn acme_step_name(step: AcmeStep) -> &'static str { } } -fn parse_cert_expiry(cert_pem: &str) -> Option { +pub 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()?; @@ -1114,35 +1114,14 @@ fn parse_cert_expiry(cert_pem: &str) -> Option { .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 Edge 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 Edge 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 /// `(error_message, log_lines)` on failure where `log_lines` are the proxy log entries /// collected during the ACME run (sent by the proxy via an [`AcmeLogs`] event). -async fn call_proxy_trigger_acme( +pub async fn call_proxy_trigger_acme( pool: &PgPool, proxy_host: &str, proxy_port: u16, @@ -1259,10 +1238,11 @@ pub async fn stream_proxy_acme( } }; - let domain = match public_proxy_hostname() { + let settings = Settings::get_current_settings(); + let domain = match settings.proxy_hostname() { Ok(domain) => domain, Err(message) => { - yield Ok(acme_error_event("Connecting", message, None)); + yield Ok(acme_error_event("Connecting", message.to_string(), None)); return; } }; diff --git a/crates/defguard_core/src/utility_thread.rs b/crates/defguard_core/src/utility_thread.rs index 15da196d6..9c2a5fa01 100644 --- a/crates/defguard_core/src/utility_thread.rs +++ b/crates/defguard_core/src/utility_thread.rs @@ -1,13 +1,14 @@ use std::{collections::HashSet, time::Duration}; -use chrono::Utc; +use chrono::{NaiveDateTime, TimeDelta, Utc}; use defguard_common::db::{ Id, - models::{WireguardNetwork, wireguard::ServiceLocationMode}, + models::{Certificates, ProxyCertSource, Settings, WireguardNetwork, proxy::Proxy, wireguard::ServiceLocationMode}, }; +use defguard_proto::proxy::{AcmeStep, acme_issue_event}; use sqlx::PgPool; use tokio::{ - sync::broadcast::Sender, + sync::{broadcast::Sender, mpsc::{UnboundedSender, unbounded_channel}}, time::{Instant, sleep}, }; use tracing::Instrument; @@ -20,10 +21,7 @@ use crate::{ is_business_license_active, ldap::{do_ldap_sync, sync::get_ldap_sync_interval}, limits::update_counts, - }, - grpc::GatewayEvent, - location_management::allowed_peers::get_location_allowed_peers, - updates::do_new_version_check, + }, grpc::GatewayEvent, handlers::component_setup::{ACME_TIMEOUT_SECS, acme_step_name, call_proxy_trigger_acme, parse_cert_expiry}, location_management::allowed_peers::get_location_allowed_peers, updates::do_new_version_check }; // Times in seconds @@ -32,6 +30,9 @@ const COUNT_UPDATE_INTERVAL: u64 = 60 * 60; const UPDATES_CHECK_INTERVAL: u64 = 60 * 60 * 6; const EXPIRED_ACL_RULES_CHECK_INTERVAL: u64 = 60 * 5; const ENTERPRISE_STATUS_CHECK_INTERVAL: u64 = 60 * 5; +// const LETSENCRYPT_EXPIRY_CHECK_INTERVAL: u64 = 60 * 60 * 24; +const LETSENCRYPT_EXPIRY_CHECK_INTERVAL: u64 = 60 * 2; +const LETSENCRYPT_EXPIRY_THRESHOLD: TimeDelta = TimeDelta::days(14); const ACL_EXPIRY_SYSTEM_ACTOR: &str = "system:acl-expiry"; #[instrument(skip_all)] @@ -45,6 +46,7 @@ pub async fn run_utility_thread( let mut last_ldap_sync = Instant::now(); let mut last_expired_acl_rules_check = Instant::now(); let mut last_enterprise_status_check = Instant::now(); + let mut last_letsencrypt_expiry_check = Instant::now(); // helper variable which stores previous enterprise features status let mut enterprise_enabled = is_business_license_active(); @@ -95,11 +97,21 @@ pub async fn run_utility_thread( } }; + let letsencrypt_refresh_task = || async { + if let Err(e) = do_letsencrypt_refresh(pool) + .instrument(info_span!("letsencrypt_refresh_task")) + .await + { + error!("There was an error while performing letsencrypt refresh task: {e}"); + } + }; + directory_sync_task().await; count_update_task().await; updates_check_task().await; ldap_sync_task().await; expired_acl_rules_task().await; + letsencrypt_refresh_task().await; loop { sleep(UTILITY_THREAD_MAIN_SLEEP_TIME).await; @@ -156,7 +168,175 @@ pub async fn run_utility_thread( } last_enterprise_status_check = Instant::now(); } + + // Check LE cert expiry dates and refresh if necessary + if last_letsencrypt_expiry_check.elapsed().as_secs() >= LETSENCRYPT_EXPIRY_CHECK_INTERVAL { + letsencrypt_refresh_task().await; + last_letsencrypt_expiry_check = Instant::now(); + } + } +} + +async fn do_letsencrypt_refresh(pool: &PgPool) -> Result<(), anyhow::Error> { + debug!("Performing letsencrypt cert validity check"); + let Some(certs) = Certificates::get(pool).await? else { + warn!("Missing certificates configuration, aborting letsencrypt expiry check"); + return Ok(()); + }; + + if certs.proxy_http_cert_source != ProxyCertSource::LetsEncrypt { + info!("Edge certificate source is {:?}, skipping letsencrypt expiry check", certs.proxy_http_cert_source); + return Ok(()); + } + + let Some(expiry) = certs.proxy_http_cert_expiry else { + info!("Edge certificate has no expiry date, skipping letsencrypt refresh certificate refresh"); + return Ok(()); + }; + + let expire_in = expiry - Utc::now().naive_utc(); + if expire_in > LETSENCRYPT_EXPIRY_THRESHOLD { + info!("Letsencrypt certificates expire in {} days, skipping refresh", expire_in.num_days()); + return Ok(()); + } + + info!("Letsencrypt certificates expire in {} days, performing certificate refresh", expire_in.num_days()); + let settings = Settings::get_current_settings(); + let domain = settings.proxy_hostname()?; + let account_credentials_json = certs.acme_account_credentials.clone().unwrap_or_default(); + let Ok(proxies) = Proxy::list(&pool).await else { + error!("Failed to load Edge list from DB"); + return Ok(()); + }; + let Some(proxy) = proxies.into_iter().next() else { + warn!("No Edge found in database, aborting letsencrypt expiry check"); + return Ok(()); + }; + + let proxy_host = proxy.address.clone(); + let proxy_port = proxy.port as u16; + info!( + "Triggering ACME HTTP-01 via Edge gRPC TriggerAcme for domain: {domain} \ + Edge={proxy_host}:{proxy_port}" + ); + + let (progress_tx, mut progress_rx) = + unbounded_channel::(); + let (result_tx, result_rx) = + tokio::sync::oneshot::channel::)>>(); + + let pool_clone = pool.clone(); + let domain_clone = domain.clone(); + let acct_creds_clone = account_credentials_json.clone(); + tokio::spawn(async move { + let result = call_proxy_trigger_acme( + &pool_clone, + &proxy_host, + proxy_port, + domain_clone, + acct_creds_clone, + progress_tx, + ) + .await; + let _ = result_tx.send(result); + }); + + let mut current_step: &'static str = "Connecting"; + let deadline = tokio::time::Instant::now() + + tokio::time::Duration::from_secs(ACME_TIMEOUT_SECS); + + // Drain progress steps until the ACME task finishes (channel closed) or times out. + loop { + tokio::select! { + maybe_step = progress_rx.recv() => { + match maybe_step { + Some(step) => { + current_step = acme_step_name(step); + // yield Ok(acme_event(current_step)); + } + None => { + // progress_tx dropped - ACME task finished; stop polling progress. + break; + } + } + } + + () = tokio::time::sleep_until(deadline) => { + error!( + "ACME certificate issuance timed out after \ + {ACME_TIMEOUT_SECS} seconds." + ); + return Ok(()); + } + } } + + // 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 = + ProxyCertSource::LetsEncrypt; + if let Err(e) = updated_certs.save(pool).await { + error!( "Failed to save certificate: {e}"); + // yield Ok(acme_error_event( + // "Installing", + // format!("Failed to save certificate: {e}"), + // None, + // )); + return Ok(()); + } + } + Err(e) => { + error!( "Failed to reload certificates for saving: {e}"); + // yield Ok(acme_error_event( + // "Installing", + // format!("Failed to reload certificates for saving: {e}"), + // None, + // )); + return Ok(()); + } + } + + // TODO(jck): broadcast new certs + // // Post-wizard: broadcast certs to the proxy via bidi channel. + // if let Some(ref tx) = proxy_control_tx { + // let msg = ProxyControlMessage::BroadcastHttpsCerts { + // cert_pem, + // key_pem, + // }; + // if let Err(e) = tx.send(msg).await { + // error!("Failed to broadcast HttpsCerts to Edge: {e}"); + // } + // } + + info!("ACME certificate issued and saved for domain: {domain}"); + // yield Ok(acme_event("Done")); + } + Ok(Err((acme_err, logs))) => { + let msg = format!("ACME issuance failed: {acme_err}"); + error!("{msg}"); + // yield Ok(acme_error_event(current_step, msg, Some(logs))); + } + Err(_) => { + error!( "ACME task terminated unexpectedly."); + // yield Ok(acme_error_event( + // current_step, + // "ACME task terminated unexpectedly.".to_string(), + // None, + // )); + } + } + + Ok(()) } /// Check if enterprise status has changed and perform any necessary actions From 55fbeb7667f1cc374da673718ca42a6d97c9976e Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 17 Apr 2026 07:48:47 +0200 Subject: [PATCH 02/20] cleanup --- crates/defguard_core/src/utility_thread.rs | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/crates/defguard_core/src/utility_thread.rs b/crates/defguard_core/src/utility_thread.rs index 9c2a5fa01..4dfbbddca 100644 --- a/crates/defguard_core/src/utility_thread.rs +++ b/crates/defguard_core/src/utility_thread.rs @@ -287,21 +287,11 @@ async fn do_letsencrypt_refresh(pool: &PgPool) -> Result<(), anyhow::Error> { ProxyCertSource::LetsEncrypt; if let Err(e) = updated_certs.save(pool).await { error!( "Failed to save certificate: {e}"); - // yield Ok(acme_error_event( - // "Installing", - // format!("Failed to save certificate: {e}"), - // None, - // )); return Ok(()); } } Err(e) => { error!( "Failed to reload certificates for saving: {e}"); - // yield Ok(acme_error_event( - // "Installing", - // format!("Failed to reload certificates for saving: {e}"), - // None, - // )); return Ok(()); } } @@ -319,20 +309,15 @@ async fn do_letsencrypt_refresh(pool: &PgPool) -> Result<(), anyhow::Error> { // } info!("ACME certificate issued and saved for domain: {domain}"); - // yield Ok(acme_event("Done")); } Ok(Err((acme_err, logs))) => { let msg = format!("ACME issuance failed: {acme_err}"); error!("{msg}"); - // yield Ok(acme_error_event(current_step, msg, Some(logs))); + return Ok(()); } Err(_) => { error!( "ACME task terminated unexpectedly."); - // yield Ok(acme_error_event( - // current_step, - // "ACME task terminated unexpectedly.".to_string(), - // None, - // )); + return Ok(()); } } From 6ff0c3f0b1d38efb0d22948dd4f0e2ed0803e41a Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 17 Apr 2026 08:15:35 +0200 Subject: [PATCH 03/20] update proxy cert after successful refresh --- crates/defguard/src/main.rs | 4 +- crates/defguard_core/src/utility_thread.rs | 102 ++++++++++++--------- 2 files changed, 60 insertions(+), 46 deletions(-) diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index 2d45e63ba..a4226e8b0 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -284,9 +284,9 @@ async fn main() -> Result<(), anyhow::Error> { settings.stats_purge_threshold() ), if settings.enable_stats_purge => error!("Periodic stats purge task returned early: {res:?}"), - res = run_periodic_license_check(&pool, proxy_control_tx) => + res = run_periodic_license_check(&pool, proxy_control_tx.clone()) => error!("Periodic license check task returned early: {res:?}"), - res = run_utility_thread(&pool, gateway_tx.clone()) => + res = run_utility_thread(&pool, gateway_tx.clone(), proxy_control_tx) => error!("Utility thread returned early: {res:?}"), res = run_event_router( RouterReceiverSet::new( diff --git a/crates/defguard_core/src/utility_thread.rs b/crates/defguard_core/src/utility_thread.rs index 4dfbbddca..83dd99bf1 100644 --- a/crates/defguard_core/src/utility_thread.rs +++ b/crates/defguard_core/src/utility_thread.rs @@ -1,14 +1,23 @@ use std::{collections::HashSet, time::Duration}; use chrono::{NaiveDateTime, TimeDelta, Utc}; -use defguard_common::db::{ - Id, - models::{Certificates, ProxyCertSource, Settings, WireguardNetwork, proxy::Proxy, wireguard::ServiceLocationMode}, +use defguard_common::{ + db::{ + Id, + models::{ + Certificates, ProxyCertSource, Settings, WireguardNetwork, proxy::Proxy, + wireguard::ServiceLocationMode, + }, + }, + types::proxy::ProxyControlMessage, }; use defguard_proto::proxy::{AcmeStep, acme_issue_event}; use sqlx::PgPool; use tokio::{ - sync::{broadcast::Sender, mpsc::{UnboundedSender, unbounded_channel}}, + sync::{ + broadcast::Sender, + mpsc::{self, UnboundedSender, unbounded_channel}, + }, time::{Instant, sleep}, }; use tracing::Instrument; @@ -21,7 +30,13 @@ use crate::{ is_business_license_active, ldap::{do_ldap_sync, sync::get_ldap_sync_interval}, limits::update_counts, - }, grpc::GatewayEvent, handlers::component_setup::{ACME_TIMEOUT_SECS, acme_step_name, call_proxy_trigger_acme, parse_cert_expiry}, location_management::allowed_peers::get_location_allowed_peers, updates::do_new_version_check + }, + grpc::GatewayEvent, + handlers::component_setup::{ + ACME_TIMEOUT_SECS, acme_step_name, call_proxy_trigger_acme, parse_cert_expiry, + }, + location_management::allowed_peers::get_location_allowed_peers, + updates::do_new_version_check, }; // Times in seconds @@ -39,6 +54,7 @@ const ACL_EXPIRY_SYSTEM_ACTOR: &str = "system:acl-expiry"; pub async fn run_utility_thread( pool: &PgPool, wireguard_tx: Sender, + proxy_control_tx: mpsc::Sender, ) -> Result<(), anyhow::Error> { let mut last_count_update = Instant::now(); let mut last_directory_sync = Instant::now(); @@ -98,7 +114,7 @@ pub async fn run_utility_thread( }; let letsencrypt_refresh_task = || async { - if let Err(e) = do_letsencrypt_refresh(pool) + if let Err(e) = do_letsencrypt_refresh(pool, proxy_control_tx.clone()) .instrument(info_span!("letsencrypt_refresh_task")) .await { @@ -177,7 +193,10 @@ pub async fn run_utility_thread( } } -async fn do_letsencrypt_refresh(pool: &PgPool) -> Result<(), anyhow::Error> { +async fn do_letsencrypt_refresh( + pool: &PgPool, + proxy_control_tx: mpsc::Sender, +) -> Result<(), anyhow::Error> { debug!("Performing letsencrypt cert validity check"); let Some(certs) = Certificates::get(pool).await? else { warn!("Missing certificates configuration, aborting letsencrypt expiry check"); @@ -185,22 +204,33 @@ async fn do_letsencrypt_refresh(pool: &PgPool) -> Result<(), anyhow::Error> { }; if certs.proxy_http_cert_source != ProxyCertSource::LetsEncrypt { - info!("Edge certificate source is {:?}, skipping letsencrypt expiry check", certs.proxy_http_cert_source); + info!( + "Edge certificate source is {:?}, skipping letsencrypt expiry check", + certs.proxy_http_cert_source + ); return Ok(()); } let Some(expiry) = certs.proxy_http_cert_expiry else { - info!("Edge certificate has no expiry date, skipping letsencrypt refresh certificate refresh"); + info!( + "Edge certificate has no expiry date, skipping letsencrypt refresh certificate refresh" + ); return Ok(()); }; let expire_in = expiry - Utc::now().naive_utc(); if expire_in > LETSENCRYPT_EXPIRY_THRESHOLD { - info!("Letsencrypt certificates expire in {} days, skipping refresh", expire_in.num_days()); + info!( + "Letsencrypt certificate expires in {} days, skipping refresh", + expire_in.num_days() + ); return Ok(()); } - info!("Letsencrypt certificates expire in {} days, performing certificate refresh", expire_in.num_days()); + info!( + "Letsencrypt certificates expire in {} days, performing certificate refresh", + expire_in.num_days() + ); let settings = Settings::get_current_settings(); let domain = settings.proxy_hostname()?; let account_credentials_json = certs.acme_account_credentials.clone().unwrap_or_default(); @@ -211,7 +241,7 @@ async fn do_letsencrypt_refresh(pool: &PgPool) -> Result<(), anyhow::Error> { let Some(proxy) = proxies.into_iter().next() else { warn!("No Edge found in database, aborting letsencrypt expiry check"); return Ok(()); - }; + }; let proxy_host = proxy.address.clone(); let proxy_port = proxy.port as u16; @@ -220,8 +250,7 @@ async fn do_letsencrypt_refresh(pool: &PgPool) -> Result<(), anyhow::Error> { Edge={proxy_host}:{proxy_port}" ); - let (progress_tx, mut progress_rx) = - unbounded_channel::(); + let (progress_tx, mut progress_rx) = unbounded_channel::(); let (result_tx, result_rx) = tokio::sync::oneshot::channel::)>>(); @@ -241,23 +270,16 @@ async fn do_letsencrypt_refresh(pool: &PgPool) -> Result<(), anyhow::Error> { let _ = result_tx.send(result); }); - let mut current_step: &'static str = "Connecting"; - let deadline = tokio::time::Instant::now() - + tokio::time::Duration::from_secs(ACME_TIMEOUT_SECS); + let deadline = + tokio::time::Instant::now() + tokio::time::Duration::from_secs(ACME_TIMEOUT_SECS); // Drain progress steps until the ACME task finishes (channel closed) or times out. loop { tokio::select! { maybe_step = progress_rx.recv() => { - match maybe_step { - Some(step) => { - current_step = acme_step_name(step); - // yield Ok(acme_event(current_step)); - } - None => { - // progress_tx dropped - ACME task finished; stop polling progress. - break; - } + if maybe_step.is_none() { + // progress_tx dropped - ACME task finished; stop polling progress. + break; } } @@ -281,32 +303,24 @@ async fn do_letsencrypt_refresh(pool: &PgPool) -> Result<(), anyhow::Error> { 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 = - ProxyCertSource::LetsEncrypt; + updated_certs.acme_account_credentials = Some(new_account_credentials_json); + updated_certs.proxy_http_cert_source = ProxyCertSource::LetsEncrypt; if let Err(e) = updated_certs.save(pool).await { - error!( "Failed to save certificate: {e}"); + error!("Failed to save certificate: {e}"); return Ok(()); } } Err(e) => { - error!( "Failed to reload certificates for saving: {e}"); + error!("Failed to reload certificates for saving: {e}"); return Ok(()); } } - // TODO(jck): broadcast new certs - // // Post-wizard: broadcast certs to the proxy via bidi channel. - // if let Some(ref tx) = proxy_control_tx { - // let msg = ProxyControlMessage::BroadcastHttpsCerts { - // cert_pem, - // key_pem, - // }; - // if let Err(e) = tx.send(msg).await { - // error!("Failed to broadcast HttpsCerts to Edge: {e}"); - // } - // } + // Broadcast certs to the proxy via bidi channel + let msg = ProxyControlMessage::BroadcastHttpsCerts { cert_pem, key_pem }; + if let Err(e) = proxy_control_tx.send(msg).await { + error!("Failed to broadcast HttpsCerts to Edge: {e}"); + } info!("ACME certificate issued and saved for domain: {domain}"); } @@ -316,7 +330,7 @@ async fn do_letsencrypt_refresh(pool: &PgPool) -> Result<(), anyhow::Error> { return Ok(()); } Err(_) => { - error!( "ACME task terminated unexpectedly."); + error!("ACME task terminated unexpectedly."); return Ok(()); } } From 96d15792cf777077b266cfb4a6bdb64acb1ff5ea Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 17 Apr 2026 10:31:45 +0200 Subject: [PATCH 04/20] send email on failure --- crates/defguard_core/src/utility_thread.rs | 28 ++++++++++++++----- crates/defguard_mail/src/mail.rs | 14 ++++++++-- crates/defguard_mail/src/templates.rs | 21 ++++++++++++++ .../letsencrypt-cert-refresh-failed.mjml | 18 ++++++++++++ .../letsencrypt-cert-refresh-failed.text | 4 +++ ..._[2.0.0]_letsencrypt_cert_refresh.down.sql | 1 + ...40_[2.0.0]_letsencrypt_cert_refresh.up.sql | 3 ++ 7 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.mjml create mode 100644 crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.text create mode 100644 migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.down.sql create mode 100644 migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql diff --git a/crates/defguard_core/src/utility_thread.rs b/crates/defguard_core/src/utility_thread.rs index 83dd99bf1..49264b3ee 100644 --- a/crates/defguard_core/src/utility_thread.rs +++ b/crates/defguard_core/src/utility_thread.rs @@ -5,12 +5,13 @@ use defguard_common::{ db::{ Id, models::{ - Certificates, ProxyCertSource, Settings, WireguardNetwork, proxy::Proxy, + Certificates, ProxyCertSource, Settings, User, WireguardNetwork, proxy::Proxy, wireguard::ServiceLocationMode, }, }, types::proxy::ProxyControlMessage, }; +use defguard_mail::templates::{self, TemplateError}; use defguard_proto::proxy::{AcmeStep, acme_issue_event}; use sqlx::PgPool; use tokio::{ @@ -193,6 +194,17 @@ pub async fn run_utility_thread( } } +async fn send_le_refresh_failed_emails(pool: &PgPool, domain: &str, logs: &[String]) -> Result<(), anyhow::Error> { + let mut conn = pool.begin().await?; + let admin_users = User::find_admins(&mut *conn).await?; + for user in admin_users { + templates::letsencrypt_cert_refresh_failed_mail(&user.email, &mut conn, domain, &logs.join("\n")) + .await?; + } + + Ok(()) +} + async fn do_letsencrypt_refresh( pool: &PgPool, proxy_control_tx: mpsc::Sender, @@ -205,7 +217,7 @@ async fn do_letsencrypt_refresh( if certs.proxy_http_cert_source != ProxyCertSource::LetsEncrypt { info!( - "Edge certificate source is {:?}, skipping letsencrypt expiry check", + "Edge certificate source is {:?}, skipping Letsencrypt expiry check", certs.proxy_http_cert_source ); return Ok(()); @@ -213,7 +225,7 @@ async fn do_letsencrypt_refresh( let Some(expiry) = certs.proxy_http_cert_expiry else { info!( - "Edge certificate has no expiry date, skipping letsencrypt refresh certificate refresh" + "Edge certificate has no expiry date, skipping Letsencrypt refresh certificate refresh" ); return Ok(()); }; @@ -228,7 +240,7 @@ async fn do_letsencrypt_refresh( } info!( - "Letsencrypt certificates expire in {} days, performing certificate refresh", + "Letsencrypt certificate expires in {} days, performing certificate refresh", expire_in.num_days() ); let settings = Settings::get_current_settings(); @@ -239,7 +251,7 @@ async fn do_letsencrypt_refresh( return Ok(()); }; let Some(proxy) = proxies.into_iter().next() else { - warn!("No Edge found in database, aborting letsencrypt expiry check"); + warn!("No Edge found in database, aborting Letsencrypt expiry check"); return Ok(()); }; @@ -325,8 +337,10 @@ async fn do_letsencrypt_refresh( info!("ACME certificate issued and saved for domain: {domain}"); } Ok(Err((acme_err, logs))) => { - let msg = format!("ACME issuance failed: {acme_err}"); - error!("{msg}"); + error!("ACME issuance failed: {acme_err}"); + if let Err(err) = send_le_refresh_failed_emails(pool, &domain, &logs).await { + error!("Sending letsencrypt refresh email notification failed: {err}"); + } return Ok(()); } Err(_) => { diff --git a/crates/defguard_mail/src/mail.rs b/crates/defguard_mail/src/mail.rs index 31a06c2d4..1e55a659c 100644 --- a/crates/defguard_mail/src/mail.rs +++ b/crates/defguard_mail/src/mail.rs @@ -300,6 +300,8 @@ pub enum MailMessage { UserImportBlocked, /// Enrollment notification for admins. EnrollmentNotification, + /// Letsencrypt certificate refresh failed. + LetsencryptCertRefreshFailed, } impl MailMessage { @@ -332,6 +334,7 @@ impl MailMessage { Self::PasswordResetDone => "Defguard: Password reset success".to_string(), Self::UserImportBlocked => "User import blocked".to_string(), Self::EnrollmentNotification => "Defguard: User enrollment completed".to_string(), + Self::LetsencryptCertRefreshFailed => "Defguard: automatic Letsencrypt certificate refresh failed".to_string(), } } @@ -354,6 +357,7 @@ impl MailMessage { Self::PasswordResetDone => "password-reset-done", Self::UserImportBlocked => "user-import-blocked", Self::EnrollmentNotification => "enrollment-admin-notification", + Self::LetsencryptCertRefreshFailed => "letsencrypt-cert-refresh-failed", } } @@ -377,7 +381,10 @@ impl MailMessage { Self::UserImportBlocked => include_str!("../templates/plain-notification.mjml"), Self::EnrollmentNotification => { include_str!("../templates/enrollment-admin-notification.mjml") - } + }, + Self::LetsencryptCertRefreshFailed => { + include_str!("../templates/letsencrypt-cert-refresh-failed.mjml") + }, } } @@ -401,7 +408,10 @@ impl MailMessage { Self::UserImportBlocked => include_str!("../templates/plain-notification.text"), Self::EnrollmentNotification => { include_str!("../templates/enrollment-admin-notification.text") - } + }, + Self::LetsencryptCertRefreshFailed => { + include_str!("../templates/letsencrypt-cert-refresh-failed.text") + }, } } diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index a9887497b..3f3b50c02 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -363,6 +363,27 @@ pub async fn gateway_disconnected_mail( Ok(()) } +/// Notification about failed Letsencrypt cert refresh process. +pub async fn letsencrypt_cert_refresh_failed_mail( + to: &str, + conn: &mut PgConnection, + domain: &str, + logs: &str, +) -> Result<(), TemplateError> { + let (mut tera, mut context) = get_base_tera_mjml(Context::new(), None, None, None)?; + + context.insert("domain", domain); + context.insert("logs", logs); + + let now = Utc::now(); + let attachment = Attachment::new(format!("defguard-letsencrypt-refresh-logs-{now}.txt"), logs.into()); + let message = MailMessage::LetsencryptCertRefreshFailed; + message.fill_context(conn, &mut context).await?; + message.mail(&mut tera, &context, to)?.set_attachments(vec![attachment]).send_and_forget(); + + Ok(()) +} + /// Notification about reconnected Gateway. pub async fn gateway_reconnected_mail( to: &str, diff --git a/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.mjml b/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.mjml new file mode 100644 index 000000000..0cf45cdaa --- /dev/null +++ b/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.mjml @@ -0,0 +1,18 @@ +{% import "macros.mjml" as macros %} +{% extends "base.mjml" %} +{% block content %} + +{{ macros::email_header() }} + + + +

+ {content} +

+
+
+
+ +{{ macros::footer_divider() }} + +{% endblock content %} diff --git a/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.text b/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.text new file mode 100644 index 000000000..ea5e805f8 --- /dev/null +++ b/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.text @@ -0,0 +1,4 @@ +{{ title }} +{% if subtitle %}{{ subtitle }}{% endif %} + +TODO diff --git a/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.down.sql b/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.down.sql new file mode 100644 index 000000000..fcb01f639 --- /dev/null +++ b/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.down.sql @@ -0,0 +1 @@ +DELETE FROM mail_context where "template" = 'letsencrypt-cert-refresh-failed'; diff --git a/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql b/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql new file mode 100644 index 000000000..9ae9ea907 --- /dev/null +++ b/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql @@ -0,0 +1,3 @@ +INSERT INTO mail_context (template, section, language_tag, text) VALUES + ('letsencrypt-cert-refresh-failed', 'title', 'en_US', 'Letsencrypt certificate refresh failed'), + ('letsencrypt-cert-refresh-failed', 'content', 'en_US', 'Automatic Letsencrypt certificate refresh has failed. Please verify attached log file.'); From e217436cb25306730983a25ae7d7a9b9643fb5ef Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 17 Apr 2026 11:03:29 +0200 Subject: [PATCH 05/20] fix the template --- .../templates/letsencrypt-cert-refresh-failed.mjml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.mjml b/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.mjml index 0cf45cdaa..c958a15e0 100644 --- a/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.mjml +++ b/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.mjml @@ -7,7 +7,7 @@

- {content} + {{ content }}

From 8c93c99b2f77f2b15b22be29b5e9a926aa48042a Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 17 Apr 2026 11:20:45 +0200 Subject: [PATCH 06/20] letsencrypt module --- .../defguard_common/src/db/models/settings.rs | 2 - .../src/handlers/component_setup.rs | 2 - crates/defguard_core/src/letsencrypt.rs | 182 ++++++++++++++++++ crates/defguard_core/src/lib.rs | 1 + crates/defguard_core/src/utility_thread.rs | 177 +---------------- crates/defguard_mail/src/mail.rs | 12 +- crates/defguard_mail/src/templates.rs | 10 +- 7 files changed, 202 insertions(+), 184 deletions(-) create mode 100644 crates/defguard_core/src/letsencrypt.rs diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index 5a2350f30..c14be688a 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -770,12 +770,10 @@ impl Settings { Ok(secret_key) } - #[must_use] pub fn proxy_public_url(&self) -> Result { Url::parse(&self.public_proxy_url) } - #[must_use] pub fn proxy_hostname(&self) -> Result { let url = self .proxy_public_url() diff --git a/crates/defguard_core/src/handlers/component_setup.rs b/crates/defguard_core/src/handlers/component_setup.rs index 320541cd8..d18f74fc3 100644 --- a/crates/defguard_core/src/handlers/component_setup.rs +++ b/crates/defguard_core/src/handlers/component_setup.rs @@ -1114,8 +1114,6 @@ pub fn parse_cert_expiry(cert_pem: &str) -> Option { .ok() } - - /// Connects to the proxy's permanent `Proxy` gRPC service and calls `TriggerAcme`. /// /// Returns `(cert_pem, key_pem, account_credentials_json)` on success, or diff --git a/crates/defguard_core/src/letsencrypt.rs b/crates/defguard_core/src/letsencrypt.rs new file mode 100644 index 000000000..2c27171b4 --- /dev/null +++ b/crates/defguard_core/src/letsencrypt.rs @@ -0,0 +1,182 @@ +use chrono::{TimeDelta, Utc}; +use defguard_common::{ + db::models::{Certificates, ProxyCertSource, Settings, User, proxy::Proxy}, + types::proxy::ProxyControlMessage, +}; +use defguard_mail::templates; +use defguard_proto::proxy::AcmeStep; +use sqlx::PgPool; +use tokio::sync::mpsc::{self, unbounded_channel}; + +use crate::handlers::component_setup::{ + ACME_TIMEOUT_SECS, call_proxy_trigger_acme, parse_cert_expiry, +}; + +const LETSENCRYPT_EXPIRY_THRESHOLD: TimeDelta = TimeDelta::days(14); + +async fn send_le_refresh_failed_emails( + pool: &PgPool, + domain: &str, + logs: &[String], +) -> Result<(), anyhow::Error> { + let mut conn = pool.begin().await?; + let admin_users = User::find_admins(&mut *conn).await?; + for user in admin_users { + templates::letsencrypt_cert_refresh_failed_mail( + &user.email, + &mut conn, + domain, + &logs.join("\n"), + ) + .await?; + } + + Ok(()) +} + +pub(crate) async fn do_letsencrypt_refresh( + pool: &PgPool, + proxy_control_tx: mpsc::Sender, +) -> Result<(), anyhow::Error> { + debug!("Performing letsencrypt cert validity check"); + let Some(certs) = Certificates::get(pool).await? else { + warn!("Missing certificates configuration, aborting letsencrypt expiry check"); + return Ok(()); + }; + + if certs.proxy_http_cert_source != ProxyCertSource::LetsEncrypt { + info!( + "Edge certificate source is {:?}, skipping Letsencrypt expiry check", + certs.proxy_http_cert_source + ); + return Ok(()); + } + + let Some(expiry) = certs.proxy_http_cert_expiry else { + info!( + "Edge certificate has no expiry date, skipping Letsencrypt refresh certificate refresh" + ); + return Ok(()); + }; + + let expire_in = expiry - Utc::now().naive_utc(); + if expire_in > LETSENCRYPT_EXPIRY_THRESHOLD { + info!( + "Letsencrypt certificate expires in {} days, skipping refresh", + expire_in.num_days() + ); + return Ok(()); + } + + info!( + "Letsencrypt certificate expires in {} days, performing certificate refresh", + expire_in.num_days() + ); + let settings = Settings::get_current_settings(); + let domain = settings.proxy_hostname()?; + let account_credentials_json = certs.acme_account_credentials.clone().unwrap_or_default(); + let Ok(proxies) = Proxy::list(pool).await else { + error!("Failed to load Edge list from DB"); + return Ok(()); + }; + let Some(proxy) = proxies.into_iter().next() else { + warn!("No Edge found in database, aborting Letsencrypt expiry check"); + return Ok(()); + }; + + let proxy_host = proxy.address.clone(); + let proxy_port = proxy.port as u16; + info!( + "Triggering ACME HTTP-01 via Edge gRPC TriggerAcme for domain: {domain} \ + Edge={proxy_host}:{proxy_port}" + ); + + let (progress_tx, mut progress_rx) = unbounded_channel::(); + let (result_tx, result_rx) = + tokio::sync::oneshot::channel::)>>(); + + let pool_clone = pool.clone(); + let domain_clone = domain.clone(); + let acct_creds_clone = account_credentials_json.clone(); + tokio::spawn(async move { + let result = call_proxy_trigger_acme( + &pool_clone, + &proxy_host, + proxy_port, + domain_clone, + acct_creds_clone, + progress_tx, + ) + .await; + let _ = result_tx.send(result); + }); + + let deadline = + tokio::time::Instant::now() + tokio::time::Duration::from_secs(ACME_TIMEOUT_SECS); + + // Drain progress steps until the ACME task finishes (channel closed) or times out. + loop { + tokio::select! { + maybe_step = progress_rx.recv() => { + if maybe_step.is_none() { + // progress_tx dropped - ACME task finished; stop polling progress. + break; + } + } + + () = tokio::time::sleep_until(deadline) => { + error!( + "ACME certificate issuance timed out after \ + {ACME_TIMEOUT_SECS} seconds." + ); + return Ok(()); + } + } + } + + // 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 = ProxyCertSource::LetsEncrypt; + if let Err(e) = updated_certs.save(pool).await { + error!("Failed to save certificate: {e}"); + return Ok(()); + } + } + Err(e) => { + error!("Failed to reload certificates for saving: {e}"); + return Ok(()); + } + } + + // Broadcast certs to the proxy via bidi channel + let msg = ProxyControlMessage::BroadcastHttpsCerts { cert_pem, key_pem }; + if let Err(e) = proxy_control_tx.send(msg).await { + error!("Failed to broadcast HttpsCerts to Edge: {e}"); + } + + info!("ACME certificate issued and saved for domain: {domain}"); + } + Ok(Err((acme_err, logs))) => { + error!("ACME issuance failed: {acme_err}"); + if let Err(err) = send_le_refresh_failed_emails(pool, &domain, &logs).await { + error!("Sending letsencrypt refresh email notification failed: {err}"); + } + return Ok(()); + } + Err(_) => { + error!("ACME task terminated unexpectedly."); + return Ok(()); + } + } + + Ok(()) +} diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index ad0997577..6da8350ac 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -186,6 +186,7 @@ pub mod events; pub mod grpc; pub mod handlers; pub mod headers; +pub mod letsencrypt; pub mod location_management; pub mod setup_logs; pub mod support; diff --git a/crates/defguard_core/src/utility_thread.rs b/crates/defguard_core/src/utility_thread.rs index 49264b3ee..8e0f260d9 100644 --- a/crates/defguard_core/src/utility_thread.rs +++ b/crates/defguard_core/src/utility_thread.rs @@ -1,24 +1,16 @@ use std::{collections::HashSet, time::Duration}; -use chrono::{NaiveDateTime, TimeDelta, Utc}; +use chrono::Utc; use defguard_common::{ db::{ Id, - models::{ - Certificates, ProxyCertSource, Settings, User, WireguardNetwork, proxy::Proxy, - wireguard::ServiceLocationMode, - }, + models::{WireguardNetwork, wireguard::ServiceLocationMode}, }, types::proxy::ProxyControlMessage, }; -use defguard_mail::templates::{self, TemplateError}; -use defguard_proto::proxy::{AcmeStep, acme_issue_event}; use sqlx::PgPool; use tokio::{ - sync::{ - broadcast::Sender, - mpsc::{self, UnboundedSender, unbounded_channel}, - }, + sync::{broadcast::Sender, mpsc}, time::{Instant, sleep}, }; use tracing::Instrument; @@ -33,9 +25,7 @@ use crate::{ limits::update_counts, }, grpc::GatewayEvent, - handlers::component_setup::{ - ACME_TIMEOUT_SECS, acme_step_name, call_proxy_trigger_acme, parse_cert_expiry, - }, + letsencrypt::do_letsencrypt_refresh, location_management::allowed_peers::get_location_allowed_peers, updates::do_new_version_check, }; @@ -48,7 +38,6 @@ const EXPIRED_ACL_RULES_CHECK_INTERVAL: u64 = 60 * 5; const ENTERPRISE_STATUS_CHECK_INTERVAL: u64 = 60 * 5; // const LETSENCRYPT_EXPIRY_CHECK_INTERVAL: u64 = 60 * 60 * 24; const LETSENCRYPT_EXPIRY_CHECK_INTERVAL: u64 = 60 * 2; -const LETSENCRYPT_EXPIRY_THRESHOLD: TimeDelta = TimeDelta::days(14); const ACL_EXPIRY_SYSTEM_ACTOR: &str = "system:acl-expiry"; #[instrument(skip_all)] @@ -194,164 +183,6 @@ pub async fn run_utility_thread( } } -async fn send_le_refresh_failed_emails(pool: &PgPool, domain: &str, logs: &[String]) -> Result<(), anyhow::Error> { - let mut conn = pool.begin().await?; - let admin_users = User::find_admins(&mut *conn).await?; - for user in admin_users { - templates::letsencrypt_cert_refresh_failed_mail(&user.email, &mut conn, domain, &logs.join("\n")) - .await?; - } - - Ok(()) -} - -async fn do_letsencrypt_refresh( - pool: &PgPool, - proxy_control_tx: mpsc::Sender, -) -> Result<(), anyhow::Error> { - debug!("Performing letsencrypt cert validity check"); - let Some(certs) = Certificates::get(pool).await? else { - warn!("Missing certificates configuration, aborting letsencrypt expiry check"); - return Ok(()); - }; - - if certs.proxy_http_cert_source != ProxyCertSource::LetsEncrypt { - info!( - "Edge certificate source is {:?}, skipping Letsencrypt expiry check", - certs.proxy_http_cert_source - ); - return Ok(()); - } - - let Some(expiry) = certs.proxy_http_cert_expiry else { - info!( - "Edge certificate has no expiry date, skipping Letsencrypt refresh certificate refresh" - ); - return Ok(()); - }; - - let expire_in = expiry - Utc::now().naive_utc(); - if expire_in > LETSENCRYPT_EXPIRY_THRESHOLD { - info!( - "Letsencrypt certificate expires in {} days, skipping refresh", - expire_in.num_days() - ); - return Ok(()); - } - - info!( - "Letsencrypt certificate expires in {} days, performing certificate refresh", - expire_in.num_days() - ); - let settings = Settings::get_current_settings(); - let domain = settings.proxy_hostname()?; - let account_credentials_json = certs.acme_account_credentials.clone().unwrap_or_default(); - let Ok(proxies) = Proxy::list(&pool).await else { - error!("Failed to load Edge list from DB"); - return Ok(()); - }; - let Some(proxy) = proxies.into_iter().next() else { - warn!("No Edge found in database, aborting Letsencrypt expiry check"); - return Ok(()); - }; - - let proxy_host = proxy.address.clone(); - let proxy_port = proxy.port as u16; - info!( - "Triggering ACME HTTP-01 via Edge gRPC TriggerAcme for domain: {domain} \ - Edge={proxy_host}:{proxy_port}" - ); - - let (progress_tx, mut progress_rx) = unbounded_channel::(); - let (result_tx, result_rx) = - tokio::sync::oneshot::channel::)>>(); - - let pool_clone = pool.clone(); - let domain_clone = domain.clone(); - let acct_creds_clone = account_credentials_json.clone(); - tokio::spawn(async move { - let result = call_proxy_trigger_acme( - &pool_clone, - &proxy_host, - proxy_port, - domain_clone, - acct_creds_clone, - progress_tx, - ) - .await; - let _ = result_tx.send(result); - }); - - let deadline = - tokio::time::Instant::now() + tokio::time::Duration::from_secs(ACME_TIMEOUT_SECS); - - // Drain progress steps until the ACME task finishes (channel closed) or times out. - loop { - tokio::select! { - maybe_step = progress_rx.recv() => { - if maybe_step.is_none() { - // progress_tx dropped - ACME task finished; stop polling progress. - break; - } - } - - () = tokio::time::sleep_until(deadline) => { - error!( - "ACME certificate issuance timed out after \ - {ACME_TIMEOUT_SECS} seconds." - ); - return Ok(()); - } - } - } - - // 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 = ProxyCertSource::LetsEncrypt; - if let Err(e) = updated_certs.save(pool).await { - error!("Failed to save certificate: {e}"); - return Ok(()); - } - } - Err(e) => { - error!("Failed to reload certificates for saving: {e}"); - return Ok(()); - } - } - - // Broadcast certs to the proxy via bidi channel - let msg = ProxyControlMessage::BroadcastHttpsCerts { cert_pem, key_pem }; - if let Err(e) = proxy_control_tx.send(msg).await { - error!("Failed to broadcast HttpsCerts to Edge: {e}"); - } - - info!("ACME certificate issued and saved for domain: {domain}"); - } - Ok(Err((acme_err, logs))) => { - error!("ACME issuance failed: {acme_err}"); - if let Err(err) = send_le_refresh_failed_emails(pool, &domain, &logs).await { - error!("Sending letsencrypt refresh email notification failed: {err}"); - } - return Ok(()); - } - Err(_) => { - error!("ACME task terminated unexpectedly."); - return Ok(()); - } - } - - Ok(()) -} - /// Check if enterprise status has changed and perform any necessary actions async fn enterprise_status_check( pool: &PgPool, diff --git a/crates/defguard_mail/src/mail.rs b/crates/defguard_mail/src/mail.rs index 1e55a659c..770ee2447 100644 --- a/crates/defguard_mail/src/mail.rs +++ b/crates/defguard_mail/src/mail.rs @@ -334,7 +334,9 @@ impl MailMessage { Self::PasswordResetDone => "Defguard: Password reset success".to_string(), Self::UserImportBlocked => "User import blocked".to_string(), Self::EnrollmentNotification => "Defguard: User enrollment completed".to_string(), - Self::LetsencryptCertRefreshFailed => "Defguard: automatic Letsencrypt certificate refresh failed".to_string(), + Self::LetsencryptCertRefreshFailed => { + "Defguard: automatic Letsencrypt certificate refresh failed".to_string() + } } } @@ -381,10 +383,10 @@ impl MailMessage { Self::UserImportBlocked => include_str!("../templates/plain-notification.mjml"), Self::EnrollmentNotification => { include_str!("../templates/enrollment-admin-notification.mjml") - }, + } Self::LetsencryptCertRefreshFailed => { include_str!("../templates/letsencrypt-cert-refresh-failed.mjml") - }, + } } } @@ -408,10 +410,10 @@ impl MailMessage { Self::UserImportBlocked => include_str!("../templates/plain-notification.text"), Self::EnrollmentNotification => { include_str!("../templates/enrollment-admin-notification.text") - }, + } Self::LetsencryptCertRefreshFailed => { include_str!("../templates/letsencrypt-cert-refresh-failed.text") - }, + } } } diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index 3f3b50c02..8c9a8e60e 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -376,10 +376,16 @@ pub async fn letsencrypt_cert_refresh_failed_mail( context.insert("logs", logs); let now = Utc::now(); - let attachment = Attachment::new(format!("defguard-letsencrypt-refresh-logs-{now}.txt"), logs.into()); + let attachment = Attachment::new( + format!("defguard-letsencrypt-refresh-logs-{now}.txt"), + logs.into(), + ); let message = MailMessage::LetsencryptCertRefreshFailed; message.fill_context(conn, &mut context).await?; - message.mail(&mut tera, &context, to)?.set_attachments(vec![attachment]).send_and_forget(); + message + .mail(&mut tera, &context, to)? + .set_attachments(vec![attachment]) + .send_and_forget(); Ok(()) } From d3a19c840ab3260fb01150c4d5cd2bbd580b6506 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 17 Apr 2026 11:28:25 +0200 Subject: [PATCH 07/20] move shared code to letsencrypt mod --- .../src/handlers/component_setup.rs | 128 +------------ crates/defguard_core/src/letsencrypt.rs | 168 +++++++++++++++--- 2 files changed, 145 insertions(+), 151 deletions(-) diff --git a/crates/defguard_core/src/handlers/component_setup.rs b/crates/defguard_core/src/handlers/component_setup.rs index d18f74fc3..636e80844 100644 --- a/crates/defguard_core/src/handlers/component_setup.rs +++ b/crates/defguard_core/src/handlers/component_setup.rs @@ -42,7 +42,7 @@ use futures::Stream; use reqwest::Url; use serde::{Deserialize, Serialize}; use sqlx::PgPool; -use tokio::sync::mpsc::{Sender, UnboundedReceiver, UnboundedSender, unbounded_channel}; +use tokio::sync::mpsc::{Sender, UnboundedReceiver, unbounded_channel}; use tokio_stream::StreamExt; use tonic::{ Request, Status, @@ -52,10 +52,7 @@ use tonic::{ use tracing::Instrument; use crate::{ - auth::{AdminOrSetupRole, SessionInfo}, - enterprise::is_enterprise_license_active, - setup_logs::scope_setup_logs, - version::{MIN_GATEWAY_VERSION, MIN_PROXY_VERSION}, + auth::{AdminOrSetupRole, SessionInfo}, enterprise::is_enterprise_license_active, letsencrypt::{ACME_TIMEOUT_SECS, acme_step_name, call_proxy_trigger_acme, parse_cert_expiry}, setup_logs::scope_setup_logs, version::{MIN_GATEWAY_VERSION, MIN_PROXY_VERSION} }; const TOKEN_CLIENT_ID: &str = "Defguard Core"; @@ -1059,9 +1056,6 @@ pub async fn setup_gateway_tls_stream( Sse::new(stream).keep_alive(KeepAlive::default()) } -/// Maximum time (seconds) allowed for the ACME flow to complete end-to-end. -pub const ACME_TIMEOUT_SECS: u64 = 300; - #[derive(Debug, Serialize)] struct AcmeSetupResponse { step: &'static str, @@ -1094,124 +1088,6 @@ fn acme_error_event(step: &'static str, message: String, logs: Option &'static str { - match step { - AcmeStep::Unspecified | AcmeStep::Connecting => "Connecting", - AcmeStep::CheckingDomain => "CheckingDomain", - AcmeStep::ValidatingDomain => "ValidatingDomain", - AcmeStep::IssuingCertificate => "IssuingCertificate", - } -} - -pub 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() -} - -/// Connects to the proxy's permanent `Proxy` gRPC service and calls `TriggerAcme`. -/// -/// Returns `(cert_pem, key_pem, account_credentials_json)` on success, or -/// `(error_message, log_lines)` on failure where `log_lines` are the proxy log entries -/// collected during the ACME run (sent by the proxy via an [`AcmeLogs`] event). -pub async fn call_proxy_trigger_acme( - pool: &PgPool, - proxy_host: &str, - proxy_port: u16, - domain: String, - account_credentials_json: String, - progress_tx: UnboundedSender, -) -> Result<(String, String, String), (String, Vec)> { - let certs = Certificates::get_or_default(pool) - .await - .map_err(|e| (format!("Failed to load certificates: {e}"), Vec::new()))?; - let ca_cert_der = certs.ca_cert_der.ok_or_else(|| { - ( - "CA certificate not found in settings".to_string(), - Vec::new(), - ) - })?; - - let cert_pem = der_to_pem(&ca_cert_der, defguard_certs::PemLabel::Certificate) - .map_err(|e| (format!("Failed to convert CA cert to PEM: {e}"), Vec::new()))?; - - let endpoint_str = format!("https://{proxy_host}:{proxy_port}"); - let endpoint = Endpoint::from_shared(endpoint_str) - .map_err(|e| (format!("Failed to build Edge endpoint: {e}"), Vec::new()))? - .http2_keep_alive_interval(Duration::from_secs(5)) - .tcp_keepalive(Some(Duration::from_secs(5))) - .keep_alive_while_idle(true); - - let tls = ClientTlsConfig::new().ca_certificate(Certificate::from_pem(cert_pem)); - let endpoint = endpoint.tls_config(tls).map_err(|e| { - ( - format!("Failed to configure TLS for Edge endpoint: {e}"), - Vec::new(), - ) - })?; - - let version = Version::parse(VERSION) - .map_err(|e| (format!("Failed to parse core version: {e}"), Vec::new()))?; - let version_interceptor = ClientVersionInterceptor::new(version); - - let mut client = - ProxyClient::with_interceptor(endpoint.connect_lazy(), move |req: Request<()>| { - version_interceptor.clone().call(req) - }); - - let mut stream = client - .trigger_acme(AcmeChallenge { - domain: domain.clone(), - account_credentials_json, - }) - .await - .map_err(|e| (format!("TriggerAcme RPC failed: {e}"), Vec::new()))? - .into_inner(); - - let mut collected_logs: Vec = Vec::new(); - - loop { - match stream.message().await { - Ok(Some(event)) => match event.payload { - Some(acme_issue_event::Payload::Progress(p)) => { - if let Ok(step) = AcmeStep::try_from(p.step) { - let _ = progress_tx.send(step); - } - } - Some(acme_issue_event::Payload::Certificate(cert)) => { - return Ok((cert.cert_pem, cert.key_pem, cert.account_credentials_json)); - } - Some(acme_issue_event::Payload::Logs(AcmeLogs { lines })) => { - collected_logs = lines; - } - None => { - return Err(( - "TriggerAcme stream sent an event with no payload".to_string(), - collected_logs, - )); - } - }, - Ok(None) => { - return Err(( - "TriggerAcme stream ended without delivering a certificate".to_string(), - collected_logs, - )); - } - Err(e) => { - return Err(( - format!("Failed to read TriggerAcme response: {e}"), - collected_logs, - )); - } - } - } -} - /// Streams Let's Encrypt certificate issuance progress as Server-Sent Events. /// /// Delegates the ACME HTTP-01 process to the proxy component via the `TriggerAcme` diff --git a/crates/defguard_core/src/letsencrypt.rs b/crates/defguard_core/src/letsencrypt.rs index 2c27171b4..1d0cf9ab5 100644 --- a/crates/defguard_core/src/letsencrypt.rs +++ b/crates/defguard_core/src/letsencrypt.rs @@ -1,4 +1,4 @@ -use chrono::{TimeDelta, Utc}; +use chrono::{NaiveDateTime, TimeDelta, Utc}; use defguard_common::{ db::models::{Certificates, ProxyCertSource, Settings, User, proxy::Proxy}, types::proxy::ProxyControlMessage, @@ -6,34 +6,14 @@ use defguard_common::{ use defguard_mail::templates; use defguard_proto::proxy::AcmeStep; use sqlx::PgPool; -use tokio::sync::mpsc::{self, unbounded_channel}; +use tokio::sync::mpsc::{self, UnboundedSender, unbounded_channel}; -use crate::handlers::component_setup::{ - ACME_TIMEOUT_SECS, call_proxy_trigger_acme, parse_cert_expiry, -}; +use crate::handlers::component_setup::{call_proxy_trigger_acme, parse_cert_expiry}; +/// Maximum time (seconds) allowed for the ACME flow to complete end-to-end. +pub const ACME_TIMEOUT_SECS: u64 = 300; const LETSENCRYPT_EXPIRY_THRESHOLD: TimeDelta = TimeDelta::days(14); -async fn send_le_refresh_failed_emails( - pool: &PgPool, - domain: &str, - logs: &[String], -) -> Result<(), anyhow::Error> { - let mut conn = pool.begin().await?; - let admin_users = User::find_admins(&mut *conn).await?; - for user in admin_users { - templates::letsencrypt_cert_refresh_failed_mail( - &user.email, - &mut conn, - domain, - &logs.join("\n"), - ) - .await?; - } - - Ok(()) -} - pub(crate) async fn do_letsencrypt_refresh( pool: &PgPool, proxy_control_tx: mpsc::Sender, @@ -180,3 +160,141 @@ pub(crate) async fn do_letsencrypt_refresh( Ok(()) } + +async fn send_le_refresh_failed_emails( + pool: &PgPool, + domain: &str, + logs: &[String], +) -> Result<(), anyhow::Error> { + let mut conn = pool.begin().await?; + let admin_users = User::find_admins(&mut *conn).await?; + for user in admin_users { + templates::letsencrypt_cert_refresh_failed_mail( + &user.email, + &mut conn, + domain, + &logs.join("\n"), + ) + .await?; + } + + Ok(()) +} + +pub(crate) 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() +} + +/// Maps a proto [`AcmeStep`] to the SSE step string expected by the frontend. +pub(crate) fn acme_step_name(step: AcmeStep) -> &'static str { + match step { + AcmeStep::Unspecified | AcmeStep::Connecting => "Connecting", + AcmeStep::CheckingDomain => "CheckingDomain", + AcmeStep::ValidatingDomain => "ValidatingDomain", + AcmeStep::IssuingCertificate => "IssuingCertificate", + } +} + +/// Connects to the proxy's permanent `Proxy` gRPC service and calls `TriggerAcme`. +/// +/// Returns `(cert_pem, key_pem, account_credentials_json)` on success, or +/// `(error_message, log_lines)` on failure where `log_lines` are the proxy log entries +/// collected during the ACME run (sent by the proxy via an [`AcmeLogs`] event). +pub(crate) async fn call_proxy_trigger_acme( + pool: &PgPool, + proxy_host: &str, + proxy_port: u16, + domain: String, + account_credentials_json: String, + progress_tx: UnboundedSender, +) -> Result<(String, String, String), (String, Vec)> { + let certs = Certificates::get_or_default(pool) + .await + .map_err(|e| (format!("Failed to load certificates: {e}"), Vec::new()))?; + let ca_cert_der = certs.ca_cert_der.ok_or_else(|| { + ( + "CA certificate not found in settings".to_string(), + Vec::new(), + ) + })?; + + let cert_pem = der_to_pem(&ca_cert_der, defguard_certs::PemLabel::Certificate) + .map_err(|e| (format!("Failed to convert CA cert to PEM: {e}"), Vec::new()))?; + + let endpoint_str = format!("https://{proxy_host}:{proxy_port}"); + let endpoint = Endpoint::from_shared(endpoint_str) + .map_err(|e| (format!("Failed to build Edge endpoint: {e}"), Vec::new()))? + .http2_keep_alive_interval(Duration::from_secs(5)) + .tcp_keepalive(Some(Duration::from_secs(5))) + .keep_alive_while_idle(true); + + let tls = ClientTlsConfig::new().ca_certificate(Certificate::from_pem(cert_pem)); + let endpoint = endpoint.tls_config(tls).map_err(|e| { + ( + format!("Failed to configure TLS for Edge endpoint: {e}"), + Vec::new(), + ) + })?; + + let version = Version::parse(VERSION) + .map_err(|e| (format!("Failed to parse core version: {e}"), Vec::new()))?; + let version_interceptor = ClientVersionInterceptor::new(version); + + let mut client = + ProxyClient::with_interceptor(endpoint.connect_lazy(), move |req: Request<()>| { + version_interceptor.clone().call(req) + }); + + let mut stream = client + .trigger_acme(AcmeChallenge { + domain: domain.clone(), + account_credentials_json, + }) + .await + .map_err(|e| (format!("TriggerAcme RPC failed: {e}"), Vec::new()))? + .into_inner(); + + let mut collected_logs: Vec = Vec::new(); + + loop { + match stream.message().await { + Ok(Some(event)) => match event.payload { + Some(acme_issue_event::Payload::Progress(p)) => { + if let Ok(step) = AcmeStep::try_from(p.step) { + let _ = progress_tx.send(step); + } + } + Some(acme_issue_event::Payload::Certificate(cert)) => { + return Ok((cert.cert_pem, cert.key_pem, cert.account_credentials_json)); + } + Some(acme_issue_event::Payload::Logs(AcmeLogs { lines })) => { + collected_logs = lines; + } + None => { + return Err(( + "TriggerAcme stream sent an event with no payload".to_string(), + collected_logs, + )); + } + }, + Ok(None) => { + return Err(( + "TriggerAcme stream ended without delivering a certificate".to_string(), + collected_logs, + )); + } + Err(e) => { + return Err(( + format!("Failed to read TriggerAcme response: {e}"), + collected_logs, + )); + } + } + } +} From cbd74b77d2c92bb81e63b3eff3816bd6e9200692 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 17 Apr 2026 11:31:24 +0200 Subject: [PATCH 08/20] refactor proxy_hostname function --- crates/defguard_common/src/db/models/settings.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index c14be688a..208e3ea81 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -778,10 +778,12 @@ impl Settings { let url = self .proxy_public_url() .map_err(|_err| SettingsUrlError::UnparsableEdgeUrl(self.public_proxy_url.clone()))?; - Ok(url + let hostname = url .host_str() .ok_or_else(|| SettingsUrlError::EdgeUrlMissingHostname(self.public_proxy_url.clone()))? - .to_string()) + .to_string(); + + Ok(hostname) } #[allow(deprecated)] From b57757d2bcc63e8cf3306402a423716977f2d776 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 17 Apr 2026 11:46:13 +0200 Subject: [PATCH 09/20] fix imports --- .../src/handlers/component_setup.rs | 16 ++++++++-------- crates/defguard_core/src/letsencrypt.rs | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/defguard_core/src/handlers/component_setup.rs b/crates/defguard_core/src/handlers/component_setup.rs index 636e80844..5486a1020 100644 --- a/crates/defguard_core/src/handlers/component_setup.rs +++ b/crates/defguard_core/src/handlers/component_setup.rs @@ -10,7 +10,6 @@ use axum::{ extract::{Path, Query}, response::sse::{Event, KeepAlive, Sse}, }; -use chrono::NaiveDateTime; use defguard_certs::der_to_pem; use defguard_common::{ VERSION, @@ -32,10 +31,7 @@ use defguard_common::{ use defguard_proto::{ common::{CertificateInfo, DerPayload}, gateway::gateway_setup_client::GatewaySetupClient, - proxy::{ - AcmeChallenge, AcmeLogs, AcmeStep, acme_issue_event, proxy_client::ProxyClient, - proxy_setup_client::ProxySetupClient, - }, + proxy::{AcmeStep, proxy_setup_client::ProxySetupClient}, }; use defguard_version::{Version, client::ClientVersionInterceptor}; use futures::Stream; @@ -52,7 +48,11 @@ use tonic::{ use tracing::Instrument; use crate::{ - auth::{AdminOrSetupRole, SessionInfo}, enterprise::is_enterprise_license_active, letsencrypt::{ACME_TIMEOUT_SECS, acme_step_name, call_proxy_trigger_acme, parse_cert_expiry}, setup_logs::scope_setup_logs, version::{MIN_GATEWAY_VERSION, MIN_PROXY_VERSION} + auth::{AdminOrSetupRole, SessionInfo}, + enterprise::is_enterprise_license_active, + letsencrypt::{ACME_TIMEOUT_SECS, acme_step_name, call_proxy_trigger_acme, parse_cert_expiry}, + setup_logs::scope_setup_logs, + version::{MIN_GATEWAY_VERSION, MIN_PROXY_VERSION}, }; const TOKEN_CLIENT_ID: &str = "Defguard Core"; @@ -1115,8 +1115,8 @@ pub async fn stream_proxy_acme( let settings = Settings::get_current_settings(); let domain = match settings.proxy_hostname() { Ok(domain) => domain, - Err(message) => { - yield Ok(acme_error_event("Connecting", message.to_string(), None)); + Err(err) => { + yield Ok(acme_error_event("Connecting", err.to_string(), None)); return; } }; diff --git a/crates/defguard_core/src/letsencrypt.rs b/crates/defguard_core/src/letsencrypt.rs index 1d0cf9ab5..7c698c929 100644 --- a/crates/defguard_core/src/letsencrypt.rs +++ b/crates/defguard_core/src/letsencrypt.rs @@ -1,14 +1,24 @@ +use std::time::Duration; + use chrono::{NaiveDateTime, TimeDelta, Utc}; +use defguard_certs::der_to_pem; use defguard_common::{ + VERSION, db::models::{Certificates, ProxyCertSource, Settings, User, proxy::Proxy}, types::proxy::ProxyControlMessage, }; use defguard_mail::templates; -use defguard_proto::proxy::AcmeStep; +use defguard_proto::proxy::{ + AcmeChallenge, AcmeLogs, AcmeStep, acme_issue_event, proxy_client::ProxyClient, +}; +use defguard_version::{Version, client::ClientVersionInterceptor}; use sqlx::PgPool; use tokio::sync::mpsc::{self, UnboundedSender, unbounded_channel}; - -use crate::handlers::component_setup::{call_proxy_trigger_acme, parse_cert_expiry}; +use tonic::{ + Request, + service::Interceptor, + transport::{Certificate, ClientTlsConfig, Endpoint}, +}; /// Maximum time (seconds) allowed for the ACME flow to complete end-to-end. pub const ACME_TIMEOUT_SECS: u64 = 300; From 85c4b98ab4a087d48d438f7a21486e59915cac37 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 17 Apr 2026 12:06:58 +0200 Subject: [PATCH 10/20] dedicated LetsencryptError --- crates/defguard_core/src/letsencrypt.rs | 55 +++++++++++++++++++------ 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/crates/defguard_core/src/letsencrypt.rs b/crates/defguard_core/src/letsencrypt.rs index 7c698c929..ba51eb4c8 100644 --- a/crates/defguard_core/src/letsencrypt.rs +++ b/crates/defguard_core/src/letsencrypt.rs @@ -13,6 +13,7 @@ use defguard_proto::proxy::{ }; use defguard_version::{Version, client::ClientVersionInterceptor}; use sqlx::PgPool; +use thiserror::Error; use tokio::sync::mpsc::{self, UnboundedSender, unbounded_channel}; use tonic::{ Request, @@ -24,12 +25,37 @@ use tonic::{ pub const ACME_TIMEOUT_SECS: u64 = 300; const LETSENCRYPT_EXPIRY_THRESHOLD: TimeDelta = TimeDelta::days(14); +#[derive(Debug, Error)] +pub(crate) enum LetsencryptError { + #[error("Failed to load certificates: {0}")] + CertificatesLoadFailed(sqlx::Error), + #[error("Failed to resolve proxy hostname: {0}")] + ProxyHostnameFailed(String), + #[error("Failed to load Edge list from DB: {0}")] + ProxyListLoadFailed(sqlx::Error), + #[error("No Edge found in database")] + NoProxyFound, + #[error("ACME certificate issuance timed out after {timeout_secs} seconds")] + AcmeTimedOut { timeout_secs: u64 }, + #[error("Failed to reload certificates for saving: {0}")] + CertificateReloadFailed(sqlx::Error), + #[error("Failed to save certificate: {0}")] + CertificateSaveFailed(sqlx::Error), + #[error("ACME issuance failed: {0}")] + AcmeIssuanceFailed(String), + #[error("ACME task terminated unexpectedly")] + AcmeTaskTerminatedUnexpectedly, +} + pub(crate) async fn do_letsencrypt_refresh( pool: &PgPool, proxy_control_tx: mpsc::Sender, -) -> Result<(), anyhow::Error> { +) -> Result<(), LetsencryptError> { debug!("Performing letsencrypt cert validity check"); - let Some(certs) = Certificates::get(pool).await? else { + let Some(certs) = Certificates::get(pool) + .await + .map_err(LetsencryptError::CertificatesLoadFailed)? + else { warn!("Missing certificates configuration, aborting letsencrypt expiry check"); return Ok(()); }; @@ -63,15 +89,16 @@ pub(crate) async fn do_letsencrypt_refresh( expire_in.num_days() ); let settings = Settings::get_current_settings(); - let domain = settings.proxy_hostname()?; + let domain = settings + .proxy_hostname() + .map_err(|err| LetsencryptError::ProxyHostnameFailed(err.to_string()))?; let account_credentials_json = certs.acme_account_credentials.clone().unwrap_or_default(); - let Ok(proxies) = Proxy::list(pool).await else { - error!("Failed to load Edge list from DB"); - return Ok(()); - }; + let proxies = Proxy::list(pool) + .await + .map_err(LetsencryptError::ProxyListLoadFailed)?; let Some(proxy) = proxies.into_iter().next() else { warn!("No Edge found in database, aborting Letsencrypt expiry check"); - return Ok(()); + return Err(LetsencryptError::NoProxyFound); }; let proxy_host = proxy.address.clone(); @@ -119,7 +146,9 @@ pub(crate) async fn do_letsencrypt_refresh( "ACME certificate issuance timed out after \ {ACME_TIMEOUT_SECS} seconds." ); - return Ok(()); + return Err(LetsencryptError::AcmeTimedOut { + timeout_secs: ACME_TIMEOUT_SECS, + }); } } } @@ -138,12 +167,12 @@ pub(crate) async fn do_letsencrypt_refresh( updated_certs.proxy_http_cert_source = ProxyCertSource::LetsEncrypt; if let Err(e) = updated_certs.save(pool).await { error!("Failed to save certificate: {e}"); - return Ok(()); + return Err(LetsencryptError::CertificateSaveFailed(e)); } } Err(e) => { error!("Failed to reload certificates for saving: {e}"); - return Ok(()); + return Err(LetsencryptError::CertificateReloadFailed(e)); } } @@ -160,11 +189,11 @@ pub(crate) async fn do_letsencrypt_refresh( if let Err(err) = send_le_refresh_failed_emails(pool, &domain, &logs).await { error!("Sending letsencrypt refresh email notification failed: {err}"); } - return Ok(()); + return Err(LetsencryptError::AcmeIssuanceFailed(acme_err)); } Err(_) => { error!("ACME task terminated unexpectedly."); - return Ok(()); + return Err(LetsencryptError::AcmeTaskTerminatedUnexpectedly); } } From 35c7f1a7199eaba4d532d89cd289a5d04ecf4e8b Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 17 Apr 2026 12:41:20 +0200 Subject: [PATCH 11/20] tests --- crates/defguard_core/src/letsencrypt.rs | 456 ++++++++++++++++++ .../letsencrypt-cert-refresh-failed.text | 4 +- ...40_[2.0.0]_letsencrypt_cert_refresh.up.sql | 2 +- 3 files changed, 458 insertions(+), 4 deletions(-) diff --git a/crates/defguard_core/src/letsencrypt.rs b/crates/defguard_core/src/letsencrypt.rs index ba51eb4c8..7489ce251 100644 --- a/crates/defguard_core/src/letsencrypt.rs +++ b/crates/defguard_core/src/letsencrypt.rs @@ -22,7 +22,10 @@ use tonic::{ }; /// Maximum time (seconds) allowed for the ACME flow to complete end-to-end. +#[cfg(not(test))] pub const ACME_TIMEOUT_SECS: u64 = 300; +#[cfg(test)] +pub const ACME_TIMEOUT_SECS: u64 = 1; const LETSENCRYPT_EXPIRY_THRESHOLD: TimeDelta = TimeDelta::days(14); #[derive(Debug, Error)] @@ -337,3 +340,456 @@ pub(crate) async fn call_proxy_trigger_acme( } } } + +#[cfg(test)] +mod tests { + use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, + pin::Pin, + sync::Once, + sync::Arc, + time::Duration, + }; + + use defguard_certs::{CertificateAuthority, Csr, DnType, PemLabel, generate_key_pair}; + use defguard_common::{ + db::{ + models::{Certificates, ProxyCertSource, Settings, User, proxy::Proxy}, + setup_pool, + }, + secret::SecretStringWrapper, + types::proxy::ProxyControlMessage, + }; + use defguard_proto::proxy::{ + AcmeCertificate, AcmeIssueEvent, AcmeLogs, AcmeProgress, AcmeStep, proxy_server, + }; + use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + use tokio::{ + net::TcpListener, + sync::{Mutex, mpsc}, + task::JoinHandle, + time::{sleep, timeout}, + }; + use std::str::FromStr; + use tokio_stream::{self as stream}; + use tonic::{ + Request, Response, Status, Streaming, + transport::{Identity, Server, ServerTlsConfig}, + }; + + use super::{ACME_TIMEOUT_SECS, LetsencryptError, do_letsencrypt_refresh}; + + const TEST_ACCOUNT_JSON: &str = r#"{"account_url":"https://acme.example/account/1"}"#; + + enum MockAcmeBehavior { + Success { + cert_pem: String, + key_pem: String, + account_credentials_json: String, + logs: Vec, + }, + RpcError(Status), + Hang, + } + + struct MockProxyService { + behavior: Arc>, + } + + #[tonic::async_trait] + impl proxy_server::Proxy for MockProxyService { + type BidiStream = + Pin> + Send>>; + type TriggerAcmeStream = + Pin> + Send>>; + + async fn bidi( + &self, + _request: Request>, + ) -> Result, Status> { + Ok(Response::new(Box::pin(stream::empty()))) + } + + async fn purge(&self, _request: Request<()>) -> Result, Status> { + Ok(Response::new(())) + } + + async fn trigger_acme( + &self, + _request: Request, + ) -> Result, Status> { + let behavior = self.behavior.lock().await; + match &*behavior { + MockAcmeBehavior::Success { + cert_pem, + key_pem, + account_credentials_json, + logs, + } => { + let mut events = vec![Ok(AcmeIssueEvent { + payload: Some(defguard_proto::proxy::acme_issue_event::Payload::Progress( + AcmeProgress { + step: AcmeStep::CheckingDomain as i32, + }, + )), + })]; + if !logs.is_empty() { + events.push(Ok(AcmeIssueEvent { + payload: Some(defguard_proto::proxy::acme_issue_event::Payload::Logs( + AcmeLogs { + lines: logs.clone(), + }, + )), + })); + } + events.push(Ok(AcmeIssueEvent { + payload: Some(defguard_proto::proxy::acme_issue_event::Payload::Certificate( + AcmeCertificate { + cert_pem: cert_pem.clone(), + key_pem: key_pem.clone(), + account_credentials_json: account_credentials_json.clone(), + }, + )), + })); + Ok(Response::new(Box::pin(stream::iter(events)))) + } + MockAcmeBehavior::RpcError(status) => Err(status.clone()), + MockAcmeBehavior::Hang => { + Ok(Response::new(Box::pin(stream::pending::>()))) + } + } + } + } + + struct MockAcmeServer { + port: u16, + task: JoinHandle<()>, + } + + impl MockAcmeServer { + async fn start( + ca: &CertificateAuthority<'_>, + common_name: &str, + behavior: MockAcmeBehavior, + ) -> Self { + init_rustls_crypto_provider(); + let identity = make_server_identity(ca, common_name); + let listener = TcpListener::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)) + .await + .expect("failed to bind mock ACME server"); + let port = listener.local_addr().expect("missing local addr").port(); + let service = MockProxyService { + behavior: Arc::new(Mutex::new(behavior)), + }; + let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener); + let task = tokio::spawn(async move { + Server::builder() + .tls_config(ServerTlsConfig::new().identity(identity)) + .expect("failed to configure TLS for mock ACME server") + .add_service(proxy_server::ProxyServer::new(service)) + .serve_with_incoming(incoming) + .await + .expect("mock ACME server failed"); + }); + + tokio::task::yield_now().await; + + Self { port, task } + } + } + + impl Drop for MockAcmeServer { + fn drop(&mut self) { + self.task.abort(); + } + } + + fn make_server_identity(ca: &CertificateAuthority<'_>, common_name: &str) -> Identity { + let key_pair = generate_key_pair().expect("failed to generate key pair"); + let san = vec![common_name.to_string()]; + let dn = vec![(DnType::CommonName, common_name)]; + let csr = Csr::new(&key_pair, &san, dn).expect("failed to create CSR"); + let cert = ca.sign_csr(&csr).expect("failed to sign server cert"); + let cert_pem = + defguard_certs::der_to_pem(cert.der(), PemLabel::Certificate).expect("cert PEM"); + let key_pem = defguard_certs::der_to_pem( + key_pair.serialize_der().as_slice(), + PemLabel::PrivateKey, + ) + .expect("key PEM"); + Identity::from_pem(cert_pem, key_pem) + } + + fn init_rustls_crypto_provider() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + rustls::crypto::ring::default_provider() + .install_default() + .ok(); + }); + } + + async fn seed_settings(pool: &sqlx::PgPool, hostname: &str) { + defguard_common::db::models::settings::initialize_current_settings(pool) + .await + .expect("failed to initialize settings"); + let mut settings = Settings::get_current_settings(); + settings.public_proxy_url = format!("https://{hostname}"); + settings.smtp_server = Some("smtp.example.com".into()); + settings.smtp_port = Some(587); + settings.smtp_sender = Some("noreply@example.com".into()); + settings.smtp_user = Some(String::new()); + settings.smtp_password = Some(SecretStringWrapper::from_str("").unwrap()); + defguard_common::db::models::settings::set_settings(Some(settings)); + } + + async fn seed_admin(pool: &sqlx::PgPool) { + let _ = User::new("admin", None, "Admin", "User", "admin@example.com", None) + .save(pool) + .await + .expect("failed to save admin user"); + } + + fn make_ca() -> CertificateAuthority<'static> { + CertificateAuthority::new("Test CA", "test@example.com", 365) + .expect("failed to create CA") + } + + async fn seed_ca(pool: &sqlx::PgPool, ca: &CertificateAuthority<'_>) { + Certificates { + ca_cert_der: Some(ca.cert_der().to_vec()), + ca_key_der: Some(ca.key_pair_der().to_vec()), + ca_expiry: Some(ca.expiry().expect("missing CA expiry")), + ..Default::default() + } + .save(pool) + .await + .expect("failed to save CA certs"); + } + + async fn seed_letsencrypt_cert( + pool: &sqlx::PgPool, + ca: &CertificateAuthority<'_>, + common_name: &str, + valid_for_days: i64, + ) { + let key_pair = generate_key_pair().expect("failed to generate key pair"); + let san = vec![common_name.to_string()]; + let dn = vec![(DnType::CommonName, common_name)]; + let csr = Csr::new(&key_pair, &san, dn).expect("failed to create CSR"); + let cert = ca + .sign_csr_with_validity(&csr, valid_for_days) + .expect("failed to sign cert"); + let cert_pem = + defguard_certs::der_to_pem(cert.der(), PemLabel::Certificate).expect("cert PEM"); + let key_pem = defguard_certs::der_to_pem( + key_pair.serialize_der().as_slice(), + PemLabel::PrivateKey, + ) + .expect("key PEM"); + let expiry = super::parse_cert_expiry(&cert_pem).expect("expected cert expiry"); + + let mut certs = Certificates::get_or_default(pool) + .await + .expect("failed to load certificates"); + certs.proxy_http_cert_source = ProxyCertSource::LetsEncrypt; + 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.acme_account_credentials = Some(TEST_ACCOUNT_JSON.to_string()); + certs.save(pool).await.expect("failed to save LE certs"); + } + + async fn create_proxy(pool: &sqlx::PgPool, address: &str, port: u16) { + let mut proxy = Proxy::new("test-proxy", address, i32::from(port), "tester"); + proxy.enabled = true; + proxy.save(pool).await.expect("failed to save proxy"); + } + + async fn drain_broadcasts( + rx: &mut mpsc::Receiver, + ) -> Vec<(String, String)> { + sleep(Duration::from_millis(50)).await; + let mut broadcasts = Vec::new(); + while let Ok(message) = rx.try_recv() { + if let ProxyControlMessage::BroadcastHttpsCerts { cert_pem, key_pem } = message { + broadcasts.push((cert_pem, key_pem)); + } + } + broadcasts + } + + #[sqlx::test] + async fn letsencrypt_refresh_skips_when_certificate_not_due( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + let ca = make_ca(); + seed_settings(&pool, "refresh.example.com").await; + seed_ca(&pool, &ca).await; + seed_letsencrypt_cert(&pool, &ca, "refresh.example.com", 89).await; + + let certs_before = Certificates::get_or_default(&pool) + .await + .expect("failed to load certificates"); + + let (proxy_control_tx, mut proxy_control_rx) = mpsc::channel(8); + let result = do_letsencrypt_refresh(&pool, proxy_control_tx).await; + + assert!(result.is_ok(), "expected skip to succeed, got {result:?}"); + + let certs_after = Certificates::get_or_default(&pool) + .await + .expect("failed to reload certificates"); + assert_eq!(certs_after.proxy_http_cert_pem, certs_before.proxy_http_cert_pem); + assert_eq!(certs_after.proxy_http_cert_key_pem, certs_before.proxy_http_cert_key_pem); + assert!(drain_broadcasts(&mut proxy_control_rx).await.is_empty()); + } + + #[sqlx::test] + async fn letsencrypt_refresh_returns_no_proxy_found_when_due( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + let ca = make_ca(); + seed_settings(&pool, "refresh.example.com").await; + seed_ca(&pool, &ca).await; + seed_letsencrypt_cert(&pool, &ca, "refresh.example.com", 1).await; + + let (proxy_control_tx, _proxy_control_rx) = mpsc::channel(8); + let result = do_letsencrypt_refresh(&pool, proxy_control_tx).await; + + assert!(matches!(result, Err(LetsencryptError::NoProxyFound))); + } + + #[sqlx::test] + async fn letsencrypt_refresh_success_persists_certificate_and_broadcasts( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + let ca = make_ca(); + seed_settings(&pool, "localhost").await; + seed_ca(&pool, &ca).await; + seed_letsencrypt_cert(&pool, &ca, "localhost", 1).await; + + let (new_cert_pem, new_key_pem) = { + let key_pair = generate_key_pair().expect("failed to generate key pair"); + let san = vec!["localhost".to_string()]; + let dn = vec![(DnType::CommonName, "localhost")]; + let csr = Csr::new(&key_pair, &san, dn).expect("failed to create CSR"); + let cert = ca.sign_csr(&csr).expect("failed to sign cert"); + ( + defguard_certs::der_to_pem(cert.der(), PemLabel::Certificate).expect("cert PEM"), + defguard_certs::der_to_pem( + key_pair.serialize_der().as_slice(), + PemLabel::PrivateKey, + ) + .expect("key PEM"), + ) + }; + + let mock_server = MockAcmeServer::start( + &ca, + "localhost", + MockAcmeBehavior::Success { + cert_pem: new_cert_pem.clone(), + key_pem: new_key_pem.clone(), + account_credentials_json: r#"{"account_url":"https://acme.example/account/2"}"#.to_string(), + logs: vec!["proxy log line".to_string()], + }, + ) + .await; + create_proxy(&pool, "localhost", mock_server.port).await; + + let (proxy_control_tx, mut proxy_control_rx) = mpsc::channel(8); + let result = do_letsencrypt_refresh(&pool, proxy_control_tx).await; + + assert!(result.is_ok(), "expected successful refresh, got {result:?}"); + + let certs = Certificates::get_or_default(&pool) + .await + .expect("failed to reload certificates"); + assert_eq!(certs.proxy_http_cert_pem.as_deref(), Some(new_cert_pem.as_str())); + assert_eq!( + certs.proxy_http_cert_key_pem.as_deref(), + Some(new_key_pem.as_str()) + ); + assert_eq!( + certs.acme_account_credentials.as_deref(), + Some(r#"{"account_url":"https://acme.example/account/2"}"#) + ); + assert_eq!(certs.acme_domain.as_deref(), Some("localhost")); + assert_eq!(certs.proxy_http_cert_source, ProxyCertSource::LetsEncrypt); + assert!(certs.proxy_http_cert_expiry.is_some()); + + let broadcasts = drain_broadcasts(&mut proxy_control_rx).await; + assert_eq!(broadcasts.len(), 1); + assert_eq!(broadcasts[0].0, new_cert_pem); + assert_eq!(broadcasts[0].1, new_key_pem); + } + + #[sqlx::test] + async fn letsencrypt_refresh_returns_acme_issuance_failed_on_rpc_error( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + let ca = make_ca(); + seed_settings(&pool, "localhost").await; + seed_ca(&pool, &ca).await; + seed_admin(&pool).await; + seed_letsencrypt_cert(&pool, &ca, "localhost", 1).await; + + let mock_server = MockAcmeServer::start( + &ca, + "localhost", + MockAcmeBehavior::RpcError(Status::unavailable("rpc unavailable")), + ) + .await; + create_proxy(&pool, "localhost", mock_server.port).await; + + let (proxy_control_tx, _proxy_control_rx) = mpsc::channel(8); + let result = do_letsencrypt_refresh(&pool, proxy_control_tx).await; + + assert!(matches!( + result, + Err(LetsencryptError::AcmeIssuanceFailed(message)) if message.contains("TriggerAcme RPC failed") + )); + } + + #[sqlx::test] + async fn letsencrypt_refresh_returns_acme_timed_out_when_stream_hangs( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + let ca = make_ca(); + seed_settings(&pool, "localhost").await; + seed_ca(&pool, &ca).await; + seed_admin(&pool).await; + seed_letsencrypt_cert(&pool, &ca, "localhost", 1).await; + + let mock_server = + MockAcmeServer::start(&ca, "localhost", MockAcmeBehavior::Hang).await; + create_proxy(&pool, "localhost", mock_server.port).await; + + let (proxy_control_tx, _proxy_control_rx) = mpsc::channel(8); + let result = timeout( + Duration::from_secs(ACME_TIMEOUT_SECS + 5), + do_letsencrypt_refresh(&pool, proxy_control_tx), + ) + .await + .expect("refresh should finish before outer timeout"); + + assert!(matches!( + result, + Err(LetsencryptError::AcmeTimedOut { timeout_secs }) if timeout_secs == ACME_TIMEOUT_SECS + )); + + drop(mock_server); + sleep(Duration::from_millis(50)).await; + } +} diff --git a/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.text b/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.text index ea5e805f8..724b0693a 100644 --- a/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.text +++ b/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.text @@ -1,4 +1,2 @@ {{ title }} -{% if subtitle %}{{ subtitle }}{% endif %} - -TODO +{% if content %}{{ content }}{% endif %} diff --git a/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql b/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql index 9ae9ea907..e3d30193b 100644 --- a/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql +++ b/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql @@ -1,3 +1,3 @@ INSERT INTO mail_context (template, section, language_tag, text) VALUES ('letsencrypt-cert-refresh-failed', 'title', 'en_US', 'Letsencrypt certificate refresh failed'), - ('letsencrypt-cert-refresh-failed', 'content', 'en_US', 'Automatic Letsencrypt certificate refresh has failed. Please verify attached log file.'); + ('letsencrypt-cert-refresh-failed', 'content', 'en_US', 'Automatic Letsencrypt certificate refresh has failed. Please verify Edge setup.'); From 4ab759303cc82cfa71eca68a3c6f24ba665142f8 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 17 Apr 2026 12:58:11 +0200 Subject: [PATCH 12/20] docs --- crates/defguard_core/src/letsencrypt.rs | 15 +++++++++++++++ ...073540_[2.0.0]_letsencrypt_cert_refresh.up.sql | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/defguard_core/src/letsencrypt.rs b/crates/defguard_core/src/letsencrypt.rs index 7489ce251..e83e49f05 100644 --- a/crates/defguard_core/src/letsencrypt.rs +++ b/crates/defguard_core/src/letsencrypt.rs @@ -50,6 +50,12 @@ pub(crate) enum LetsencryptError { AcmeTaskTerminatedUnexpectedly, } +/// Refreshes the proxy HTTPS certificate through the Edge ACME flow when the +/// currently stored Let's Encrypt certificate is close to expiry. +/// +/// Returns `Ok(())` when refresh is not needed or when renewal completes +/// successfully. Returns [`LetsencryptError`] only for operational failures in +/// the refresh flow itself. pub(crate) async fn do_letsencrypt_refresh( pool: &PgPool, proxy_control_tx: mpsc::Sender, @@ -203,6 +209,11 @@ pub(crate) async fn do_letsencrypt_refresh( Ok(()) } +/// Sends a failed Let's Encrypt refresh notification email to all active +/// administrators. +/// +/// The provided log lines are joined into a single text attachment and sent +/// with the notification email. async fn send_le_refresh_failed_emails( pool: &PgPool, domain: &str, @@ -223,6 +234,10 @@ async fn send_le_refresh_failed_emails( Ok(()) } +/// Parses the expiry timestamp from a PEM-encoded certificate. +/// +/// Returns the certificate `not_after` value, or `None` if the PEM cannot be +/// parsed or the expiry cannot be extracted. pub(crate) 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}")) diff --git a/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql b/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql index e3d30193b..b55686672 100644 --- a/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql +++ b/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql @@ -1,3 +1,3 @@ INSERT INTO mail_context (template, section, language_tag, text) VALUES ('letsencrypt-cert-refresh-failed', 'title', 'en_US', 'Letsencrypt certificate refresh failed'), - ('letsencrypt-cert-refresh-failed', 'content', 'en_US', 'Automatic Letsencrypt certificate refresh has failed. Please verify Edge setup.'); + ('letsencrypt-cert-refresh-failed', 'content', 'en_US', 'Automatic Letsencrypt certificate refresh has failed. Please verify your Edge setup.'); From 23600639cba198908f9c8c0dbad81c7ddca1e5a9 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 17 Apr 2026 13:03:38 +0200 Subject: [PATCH 13/20] remove unnecessary args --- crates/defguard_core/src/letsencrypt.rs | 91 +++++++++++++------------ crates/defguard_mail/src/templates.rs | 4 -- 2 files changed, 49 insertions(+), 46 deletions(-) diff --git a/crates/defguard_core/src/letsencrypt.rs b/crates/defguard_core/src/letsencrypt.rs index e83e49f05..06f8f2011 100644 --- a/crates/defguard_core/src/letsencrypt.rs +++ b/crates/defguard_core/src/letsencrypt.rs @@ -195,7 +195,7 @@ pub(crate) async fn do_letsencrypt_refresh( } Ok(Err((acme_err, logs))) => { error!("ACME issuance failed: {acme_err}"); - if let Err(err) = send_le_refresh_failed_emails(pool, &domain, &logs).await { + if let Err(err) = send_le_refresh_failed_emails(pool, &logs).await { error!("Sending letsencrypt refresh email notification failed: {err}"); } return Err(LetsencryptError::AcmeIssuanceFailed(acme_err)); @@ -216,19 +216,13 @@ pub(crate) async fn do_letsencrypt_refresh( /// with the notification email. async fn send_le_refresh_failed_emails( pool: &PgPool, - domain: &str, logs: &[String], ) -> Result<(), anyhow::Error> { let mut conn = pool.begin().await?; let admin_users = User::find_admins(&mut *conn).await?; for user in admin_users { - templates::letsencrypt_cert_refresh_failed_mail( - &user.email, - &mut conn, - domain, - &logs.join("\n"), - ) - .await?; + templates::letsencrypt_cert_refresh_failed_mail(&user.email, &mut conn, &logs.join("\n")) + .await?; } Ok(()) @@ -361,8 +355,8 @@ mod tests { use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, pin::Pin, - sync::Once, sync::Arc, + sync::Once, time::Duration, }; @@ -379,13 +373,13 @@ mod tests { AcmeCertificate, AcmeIssueEvent, AcmeLogs, AcmeProgress, AcmeStep, proxy_server, }; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + use std::str::FromStr; use tokio::{ net::TcpListener, sync::{Mutex, mpsc}, task::JoinHandle, time::{sleep, timeout}, }; - use std::str::FromStr; use tokio_stream::{self as stream}; use tonic::{ Request, Response, Status, Streaming, @@ -413,8 +407,12 @@ mod tests { #[tonic::async_trait] impl proxy_server::Proxy for MockProxyService { - type BidiStream = - Pin> + Send>>; + type BidiStream = Pin< + Box< + dyn tokio_stream::Stream> + + Send, + >, + >; type TriggerAcmeStream = Pin> + Send>>; @@ -458,20 +456,22 @@ mod tests { })); } events.push(Ok(AcmeIssueEvent { - payload: Some(defguard_proto::proxy::acme_issue_event::Payload::Certificate( - AcmeCertificate { - cert_pem: cert_pem.clone(), - key_pem: key_pem.clone(), - account_credentials_json: account_credentials_json.clone(), - }, - )), + payload: Some( + defguard_proto::proxy::acme_issue_event::Payload::Certificate( + AcmeCertificate { + cert_pem: cert_pem.clone(), + key_pem: key_pem.clone(), + account_credentials_json: account_credentials_json.clone(), + }, + ), + ), })); Ok(Response::new(Box::pin(stream::iter(events)))) } MockAcmeBehavior::RpcError(status) => Err(status.clone()), - MockAcmeBehavior::Hang => { - Ok(Response::new(Box::pin(stream::pending::>()))) - } + MockAcmeBehavior::Hang => Ok(Response::new(Box::pin(stream::pending::< + Result, + >()))), } } } @@ -527,11 +527,9 @@ mod tests { let cert = ca.sign_csr(&csr).expect("failed to sign server cert"); let cert_pem = defguard_certs::der_to_pem(cert.der(), PemLabel::Certificate).expect("cert PEM"); - let key_pem = defguard_certs::der_to_pem( - key_pair.serialize_der().as_slice(), - PemLabel::PrivateKey, - ) - .expect("key PEM"); + let key_pem = + defguard_certs::der_to_pem(key_pair.serialize_der().as_slice(), PemLabel::PrivateKey) + .expect("key PEM"); Identity::from_pem(cert_pem, key_pem) } @@ -566,8 +564,7 @@ mod tests { } fn make_ca() -> CertificateAuthority<'static> { - CertificateAuthority::new("Test CA", "test@example.com", 365) - .expect("failed to create CA") + CertificateAuthority::new("Test CA", "test@example.com", 365).expect("failed to create CA") } async fn seed_ca(pool: &sqlx::PgPool, ca: &CertificateAuthority<'_>) { @@ -597,11 +594,9 @@ mod tests { .expect("failed to sign cert"); let cert_pem = defguard_certs::der_to_pem(cert.der(), PemLabel::Certificate).expect("cert PEM"); - let key_pem = defguard_certs::der_to_pem( - key_pair.serialize_der().as_slice(), - PemLabel::PrivateKey, - ) - .expect("key PEM"); + let key_pem = + defguard_certs::der_to_pem(key_pair.serialize_der().as_slice(), PemLabel::PrivateKey) + .expect("key PEM"); let expiry = super::parse_cert_expiry(&cert_pem).expect("expected cert expiry"); let mut certs = Certificates::get_or_default(pool) @@ -657,8 +652,14 @@ mod tests { let certs_after = Certificates::get_or_default(&pool) .await .expect("failed to reload certificates"); - assert_eq!(certs_after.proxy_http_cert_pem, certs_before.proxy_http_cert_pem); - assert_eq!(certs_after.proxy_http_cert_key_pem, certs_before.proxy_http_cert_key_pem); + assert_eq!( + certs_after.proxy_http_cert_pem, + certs_before.proxy_http_cert_pem + ); + assert_eq!( + certs_after.proxy_http_cert_key_pem, + certs_before.proxy_http_cert_key_pem + ); assert!(drain_broadcasts(&mut proxy_control_rx).await.is_empty()); } @@ -712,7 +713,8 @@ mod tests { MockAcmeBehavior::Success { cert_pem: new_cert_pem.clone(), key_pem: new_key_pem.clone(), - account_credentials_json: r#"{"account_url":"https://acme.example/account/2"}"#.to_string(), + account_credentials_json: r#"{"account_url":"https://acme.example/account/2"}"# + .to_string(), logs: vec!["proxy log line".to_string()], }, ) @@ -722,12 +724,18 @@ mod tests { let (proxy_control_tx, mut proxy_control_rx) = mpsc::channel(8); let result = do_letsencrypt_refresh(&pool, proxy_control_tx).await; - assert!(result.is_ok(), "expected successful refresh, got {result:?}"); + assert!( + result.is_ok(), + "expected successful refresh, got {result:?}" + ); let certs = Certificates::get_or_default(&pool) .await .expect("failed to reload certificates"); - assert_eq!(certs.proxy_http_cert_pem.as_deref(), Some(new_cert_pem.as_str())); + assert_eq!( + certs.proxy_http_cert_pem.as_deref(), + Some(new_cert_pem.as_str()) + ); assert_eq!( certs.proxy_http_cert_key_pem.as_deref(), Some(new_key_pem.as_str()) @@ -787,8 +795,7 @@ mod tests { seed_admin(&pool).await; seed_letsencrypt_cert(&pool, &ca, "localhost", 1).await; - let mock_server = - MockAcmeServer::start(&ca, "localhost", MockAcmeBehavior::Hang).await; + let mock_server = MockAcmeServer::start(&ca, "localhost", MockAcmeBehavior::Hang).await; create_proxy(&pool, "localhost", mock_server.port).await; let (proxy_control_tx, _proxy_control_rx) = mpsc::channel(8); diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index 8c9a8e60e..99ca8bb3d 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -367,14 +367,10 @@ pub async fn gateway_disconnected_mail( pub async fn letsencrypt_cert_refresh_failed_mail( to: &str, conn: &mut PgConnection, - domain: &str, logs: &str, ) -> Result<(), TemplateError> { let (mut tera, mut context) = get_base_tera_mjml(Context::new(), None, None, None)?; - context.insert("domain", domain); - context.insert("logs", logs); - let now = Utc::now(); let attachment = Attachment::new( format!("defguard-letsencrypt-refresh-logs-{now}.txt"), From 4cee6087c9d01c6a437fa21ed947a88e24edaf54 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 17 Apr 2026 13:47:18 +0200 Subject: [PATCH 14/20] check le certs every 24 hours --- crates/defguard_core/src/utility_thread.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/defguard_core/src/utility_thread.rs b/crates/defguard_core/src/utility_thread.rs index 8e0f260d9..4a4589fe2 100644 --- a/crates/defguard_core/src/utility_thread.rs +++ b/crates/defguard_core/src/utility_thread.rs @@ -36,8 +36,7 @@ const COUNT_UPDATE_INTERVAL: u64 = 60 * 60; const UPDATES_CHECK_INTERVAL: u64 = 60 * 60 * 6; const EXPIRED_ACL_RULES_CHECK_INTERVAL: u64 = 60 * 5; const ENTERPRISE_STATUS_CHECK_INTERVAL: u64 = 60 * 5; -// const LETSENCRYPT_EXPIRY_CHECK_INTERVAL: u64 = 60 * 60 * 24; -const LETSENCRYPT_EXPIRY_CHECK_INTERVAL: u64 = 60 * 2; +const LETSENCRYPT_EXPIRY_CHECK_INTERVAL: u64 = 60 * 60 * 24; const ACL_EXPIRY_SYSTEM_ACTOR: &str = "system:acl-expiry"; #[instrument(skip_all)] From e14c4f3ed5ea886bb95e5c00e0db75003028958b Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 17 Apr 2026 14:27:28 +0200 Subject: [PATCH 15/20] terminate acme thread on timeout --- crates/defguard_core/src/letsencrypt.rs | 77 +++++++------------ crates/defguard_mail/src/templates.rs | 2 + .../letsencrypt-cert-refresh-failed.mjml | 5 ++ .../letsencrypt-cert-refresh-failed.text | 3 + 4 files changed, 37 insertions(+), 50 deletions(-) diff --git a/crates/defguard_core/src/letsencrypt.rs b/crates/defguard_core/src/letsencrypt.rs index 06f8f2011..8a30ba4e7 100644 --- a/crates/defguard_core/src/letsencrypt.rs +++ b/crates/defguard_core/src/letsencrypt.rs @@ -46,8 +46,6 @@ pub(crate) enum LetsencryptError { CertificateSaveFailed(sqlx::Error), #[error("ACME issuance failed: {0}")] AcmeIssuanceFailed(String), - #[error("ACME task terminated unexpectedly")] - AcmeTaskTerminatedUnexpectedly, } /// Refreshes the proxy HTTPS certificate through the Edge ACME flow when the @@ -117,53 +115,21 @@ pub(crate) async fn do_letsencrypt_refresh( Edge={proxy_host}:{proxy_port}" ); - let (progress_tx, mut progress_rx) = unbounded_channel::(); - let (result_tx, result_rx) = - tokio::sync::oneshot::channel::)>>(); + let (progress_tx, _progress_rx) = unbounded_channel::(); - let pool_clone = pool.clone(); - let domain_clone = domain.clone(); - let acct_creds_clone = account_credentials_json.clone(); - tokio::spawn(async move { - let result = call_proxy_trigger_acme( - &pool_clone, + match tokio::time::timeout( + tokio::time::Duration::from_secs(ACME_TIMEOUT_SECS), + call_proxy_trigger_acme( + pool, &proxy_host, proxy_port, - domain_clone, - acct_creds_clone, + domain.clone(), + account_credentials_json, progress_tx, - ) - .await; - let _ = result_tx.send(result); - }); - - let deadline = - tokio::time::Instant::now() + tokio::time::Duration::from_secs(ACME_TIMEOUT_SECS); - - // Drain progress steps until the ACME task finishes (channel closed) or times out. - loop { - tokio::select! { - maybe_step = progress_rx.recv() => { - if maybe_step.is_none() { - // progress_tx dropped - ACME task finished; stop polling progress. - break; - } - } - - () = tokio::time::sleep_until(deadline) => { - error!( - "ACME certificate issuance timed out after \ - {ACME_TIMEOUT_SECS} seconds." - ); - return Err(LetsencryptError::AcmeTimedOut { - timeout_secs: ACME_TIMEOUT_SECS, - }); - } - } - } - - // Progress channel closed - collect the final result. - match result_rx.await { + ), + ) + .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 { @@ -195,14 +161,19 @@ pub(crate) async fn do_letsencrypt_refresh( } Ok(Err((acme_err, logs))) => { error!("ACME issuance failed: {acme_err}"); - if let Err(err) = send_le_refresh_failed_emails(pool, &logs).await { + if let Err(err) = send_le_refresh_failed_emails(pool, &acme_err, &logs).await { error!("Sending letsencrypt refresh email notification failed: {err}"); } return Err(LetsencryptError::AcmeIssuanceFailed(acme_err)); } Err(_) => { - error!("ACME task terminated unexpectedly."); - return Err(LetsencryptError::AcmeTaskTerminatedUnexpectedly); + error!( + "ACME certificate issuance timed out after \ + {ACME_TIMEOUT_SECS} seconds." + ); + return Err(LetsencryptError::AcmeTimedOut { + timeout_secs: ACME_TIMEOUT_SECS, + }); } } @@ -216,13 +187,19 @@ pub(crate) async fn do_letsencrypt_refresh( /// with the notification email. async fn send_le_refresh_failed_emails( pool: &PgPool, + error_message: &str, logs: &[String], ) -> Result<(), anyhow::Error> { let mut conn = pool.begin().await?; let admin_users = User::find_admins(&mut *conn).await?; for user in admin_users { - templates::letsencrypt_cert_refresh_failed_mail(&user.email, &mut conn, &logs.join("\n")) - .await?; + templates::letsencrypt_cert_refresh_failed_mail( + &user.email, + &mut conn, + error_message, + &logs.join("\n"), + ) + .await?; } Ok(()) diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index 99ca8bb3d..9880165d8 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -367,9 +367,11 @@ pub async fn gateway_disconnected_mail( pub async fn letsencrypt_cert_refresh_failed_mail( to: &str, conn: &mut PgConnection, + error_message: &str, logs: &str, ) -> Result<(), TemplateError> { let (mut tera, mut context) = get_base_tera_mjml(Context::new(), None, None, None)?; + context.insert("error_message", error_message); let now = Utc::now(); let attachment = Attachment::new( diff --git a/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.mjml b/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.mjml index c958a15e0..d009ce228 100644 --- a/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.mjml +++ b/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.mjml @@ -9,6 +9,11 @@

{{ content }}

+ {% if error_message %} +

+ Error: {{ error_message }} +

+ {% endif %}
diff --git a/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.text b/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.text index 724b0693a..a027aa331 100644 --- a/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.text +++ b/crates/defguard_mail/templates/letsencrypt-cert-refresh-failed.text @@ -1,2 +1,5 @@ {{ title }} {% if content %}{{ content }}{% endif %} +{% if error_message %} +Error: {{ error_message }} +{% endif %} From 5469d1a8ba712a291ffddaa5ab2395694573d10b Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Sun, 19 Apr 2026 07:57:53 +0200 Subject: [PATCH 16/20] handle empty proxy url case separately --- crates/defguard_common/src/db/models/settings.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index 208e3ea81..5efb492c0 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -86,6 +86,8 @@ pub enum SettingsUrlError { DefguardUrlUsesIpAddress(String), #[error("Invalid WebAuthn configuration for defguard_url `{0}`: {1}")] InvalidWebauthnConfiguration(String, String), + #[error("Public Edge URL is not configured")] + PublicEdgeUrlEmpty, #[error("Unparsable Edge url: {0}")] UnparsableEdgeUrl(String), #[error("Edge url missing hostname: {0}")] @@ -775,6 +777,9 @@ impl Settings { } pub fn proxy_hostname(&self) -> Result { + if self.public_proxy_url.trim().is_empty() { + return Err(SettingsUrlError::PublicEdgeUrlEmpty); + } let url = self .proxy_public_url() .map_err(|_err| SettingsUrlError::UnparsableEdgeUrl(self.public_proxy_url.clone()))?; From 37a98a8feb2fc2b95684a89e0b512a54d53179ee Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Sun, 19 Apr 2026 08:05:07 +0200 Subject: [PATCH 17/20] fix letsencrypt spelling --- crates/defguard_mail/src/mail.rs | 2 +- .../20260417073540_[2.0.0]_letsencrypt_cert_refresh.down.sql | 2 +- .../20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/defguard_mail/src/mail.rs b/crates/defguard_mail/src/mail.rs index 770ee2447..f26eb7317 100644 --- a/crates/defguard_mail/src/mail.rs +++ b/crates/defguard_mail/src/mail.rs @@ -335,7 +335,7 @@ impl MailMessage { Self::UserImportBlocked => "User import blocked".to_string(), Self::EnrollmentNotification => "Defguard: User enrollment completed".to_string(), Self::LetsencryptCertRefreshFailed => { - "Defguard: automatic Letsencrypt certificate refresh failed".to_string() + "Defguard: automatic Let's Encrypt certificate refresh failed".to_string() } } } diff --git a/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.down.sql b/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.down.sql index fcb01f639..f409fb742 100644 --- a/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.down.sql +++ b/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.down.sql @@ -1 +1 @@ -DELETE FROM mail_context where "template" = 'letsencrypt-cert-refresh-failed'; +DELETE FROM mail_context WHERE "template" = 'letsencrypt-cert-refresh-failed'; diff --git a/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql b/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql index b55686672..7594b1072 100644 --- a/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql +++ b/migrations/20260417073540_[2.0.0]_letsencrypt_cert_refresh.up.sql @@ -1,3 +1,3 @@ INSERT INTO mail_context (template, section, language_tag, text) VALUES - ('letsencrypt-cert-refresh-failed', 'title', 'en_US', 'Letsencrypt certificate refresh failed'), - ('letsencrypt-cert-refresh-failed', 'content', 'en_US', 'Automatic Letsencrypt certificate refresh has failed. Please verify your Edge setup.'); + ('letsencrypt-cert-refresh-failed', 'title', 'en_US', 'Let''s Encrypt certificate refresh failed'), + ('letsencrypt-cert-refresh-failed', 'content', 'en_US', 'Automatic Let''s Encrypt certificate refresh has failed. Please verify your Edge setup.'); From cea3c12df58d0f3a63915f51714a197d4fbcefd3 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Sun, 19 Apr 2026 08:17:40 +0200 Subject: [PATCH 18/20] move letsencrypt expiry check so that it's not skipped --- crates/defguard_core/src/utility_thread.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/defguard_core/src/utility_thread.rs b/crates/defguard_core/src/utility_thread.rs index 4a4589fe2..ee49a4851 100644 --- a/crates/defguard_core/src/utility_thread.rs +++ b/crates/defguard_core/src/utility_thread.rs @@ -151,6 +151,12 @@ pub async fn run_utility_thread( last_expired_acl_rules_check = Instant::now(); } + // Check LE cert expiry dates and refresh if necessary + if last_letsencrypt_expiry_check.elapsed().as_secs() >= LETSENCRYPT_EXPIRY_CHECK_INTERVAL { + letsencrypt_refresh_task().await; + last_letsencrypt_expiry_check = Instant::now(); + } + // Check if enterprise features got enabled or disabled if last_enterprise_status_check.elapsed().as_secs() >= ENTERPRISE_STATUS_CHECK_INTERVAL { let new_enterprise_enabled = is_business_license_active(); @@ -173,12 +179,6 @@ pub async fn run_utility_thread( } last_enterprise_status_check = Instant::now(); } - - // Check LE cert expiry dates and refresh if necessary - if last_letsencrypt_expiry_check.elapsed().as_secs() >= LETSENCRYPT_EXPIRY_CHECK_INTERVAL { - letsencrypt_refresh_task().await; - last_letsencrypt_expiry_check = Instant::now(); - } } } From 5de961e113ef2ad0dc256a3293748230c6f57e6a Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Sun, 19 Apr 2026 08:23:17 +0200 Subject: [PATCH 19/20] don't continue in enterprise status check Prevents accidentally skipping utility jobs placed after the enterprise status check. --- crates/defguard_core/src/utility_thread.rs | 31 +++++++++++----------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/crates/defguard_core/src/utility_thread.rs b/crates/defguard_core/src/utility_thread.rs index ee49a4851..aa5ad68c8 100644 --- a/crates/defguard_core/src/utility_thread.rs +++ b/crates/defguard_core/src/utility_thread.rs @@ -160,24 +160,23 @@ pub async fn run_utility_thread( // Check if enterprise features got enabled or disabled if last_enterprise_status_check.elapsed().as_secs() >= ENTERPRISE_STATUS_CHECK_INTERVAL { let new_enterprise_enabled = is_business_license_active(); - if new_enterprise_enabled == enterprise_enabled { - continue; - } - debug!( - "Enterprise feature status changed from {enterprise_enabled} to \ + last_enterprise_status_check = Instant::now(); + if new_enterprise_enabled != enterprise_enabled { + debug!( + "Enterprise feature status changed from {enterprise_enabled} to \ {new_enterprise_enabled}" - ); - if let Err(err) = - enterprise_status_check(pool, wireguard_tx.clone(), new_enterprise_enabled) - .instrument(info_span!("enterprise_status_check")) - .await - { - error!("Failed to check enterprise status: {err}"); - } else { - // update status - enterprise_enabled = new_enterprise_enabled; + ); + if let Err(err) = + enterprise_status_check(pool, wireguard_tx.clone(), new_enterprise_enabled) + .instrument(info_span!("enterprise_status_check")) + .await + { + error!("Failed to check enterprise status: {err}"); + } else { + // update status + enterprise_enabled = new_enterprise_enabled; + } } - last_enterprise_status_check = Instant::now(); } } } From dcc4f8e3f9819d66ca034ecc9055cdd9946cd393 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 20 Apr 2026 08:42:40 +0200 Subject: [PATCH 20/20] ACME_TIMEOUT: Duration instead of u64 --- .../src/handlers/component_setup.rs | 7 +++---- crates/defguard_core/src/letsencrypt.rs | 21 ++++++++++--------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/defguard_core/src/handlers/component_setup.rs b/crates/defguard_core/src/handlers/component_setup.rs index 5486a1020..90924344a 100644 --- a/crates/defguard_core/src/handlers/component_setup.rs +++ b/crates/defguard_core/src/handlers/component_setup.rs @@ -50,7 +50,7 @@ use tracing::Instrument; use crate::{ auth::{AdminOrSetupRole, SessionInfo}, enterprise::is_enterprise_license_active, - letsencrypt::{ACME_TIMEOUT_SECS, acme_step_name, call_proxy_trigger_acme, parse_cert_expiry}, + letsencrypt::{ACME_TIMEOUT, acme_step_name, call_proxy_trigger_acme, parse_cert_expiry}, setup_logs::scope_setup_logs, version::{MIN_GATEWAY_VERSION, MIN_PROXY_VERSION}, }; @@ -1175,8 +1175,7 @@ pub async fn stream_proxy_acme( }); let mut current_step: &'static str = "Connecting"; - let deadline = tokio::time::Instant::now() - + tokio::time::Duration::from_secs(ACME_TIMEOUT_SECS); + let deadline = tokio::time::Instant::now() + ACME_TIMEOUT; // Drain progress steps until the ACME task finishes (channel closed) or times out. loop { @@ -1199,7 +1198,7 @@ pub async fn stream_proxy_acme( current_step, format!( "ACME certificate issuance timed out after \ - {ACME_TIMEOUT_SECS} seconds." + {} seconds.", ACME_TIMEOUT.as_secs() ), None, )); diff --git a/crates/defguard_core/src/letsencrypt.rs b/crates/defguard_core/src/letsencrypt.rs index 8a30ba4e7..601cc3ff5 100644 --- a/crates/defguard_core/src/letsencrypt.rs +++ b/crates/defguard_core/src/letsencrypt.rs @@ -23,9 +23,9 @@ use tonic::{ /// Maximum time (seconds) allowed for the ACME flow to complete end-to-end. #[cfg(not(test))] -pub const ACME_TIMEOUT_SECS: u64 = 300; +pub const ACME_TIMEOUT: Duration = Duration::from_secs(300); #[cfg(test)] -pub const ACME_TIMEOUT_SECS: u64 = 1; +pub const ACME_TIMEOUT: Duration = Duration::from_secs(1); const LETSENCRYPT_EXPIRY_THRESHOLD: TimeDelta = TimeDelta::days(14); #[derive(Debug, Error)] @@ -38,8 +38,8 @@ pub(crate) enum LetsencryptError { ProxyListLoadFailed(sqlx::Error), #[error("No Edge found in database")] NoProxyFound, - #[error("ACME certificate issuance timed out after {timeout_secs} seconds")] - AcmeTimedOut { timeout_secs: u64 }, + #[error("ACME certificate issuance timed out after {} seconds", timeout.as_secs())] + AcmeTimedOut { timeout: Duration }, #[error("Failed to reload certificates for saving: {0}")] CertificateReloadFailed(sqlx::Error), #[error("Failed to save certificate: {0}")] @@ -118,7 +118,7 @@ pub(crate) async fn do_letsencrypt_refresh( let (progress_tx, _progress_rx) = unbounded_channel::(); match tokio::time::timeout( - tokio::time::Duration::from_secs(ACME_TIMEOUT_SECS), + ACME_TIMEOUT, call_proxy_trigger_acme( pool, &proxy_host, @@ -169,10 +169,11 @@ pub(crate) async fn do_letsencrypt_refresh( Err(_) => { error!( "ACME certificate issuance timed out after \ - {ACME_TIMEOUT_SECS} seconds." + {}.", + ACME_TIMEOUT.as_secs(), ); return Err(LetsencryptError::AcmeTimedOut { - timeout_secs: ACME_TIMEOUT_SECS, + timeout: ACME_TIMEOUT, }); } } @@ -363,7 +364,7 @@ mod tests { transport::{Identity, Server, ServerTlsConfig}, }; - use super::{ACME_TIMEOUT_SECS, LetsencryptError, do_letsencrypt_refresh}; + use super::{ACME_TIMEOUT, LetsencryptError, do_letsencrypt_refresh}; const TEST_ACCOUNT_JSON: &str = r#"{"account_url":"https://acme.example/account/1"}"#; @@ -777,7 +778,7 @@ mod tests { let (proxy_control_tx, _proxy_control_rx) = mpsc::channel(8); let result = timeout( - Duration::from_secs(ACME_TIMEOUT_SECS + 5), + ACME_TIMEOUT + Duration::from_secs(5), do_letsencrypt_refresh(&pool, proxy_control_tx), ) .await @@ -785,7 +786,7 @@ mod tests { assert!(matches!( result, - Err(LetsencryptError::AcmeTimedOut { timeout_secs }) if timeout_secs == ACME_TIMEOUT_SECS + Err(LetsencryptError::AcmeTimedOut { timeout }) if timeout == ACME_TIMEOUT )); drop(mock_server);