diff --git a/.sqlx/query-42a2fa6799182a1c23c2e7a472ba40a55f4030b7586c59035d4d09a976deb408.json b/.sqlx/query-596e16f2eeadb84764f11fa160723aed8fda4a3cd543b9cd020978fa66c6fea1.json similarity index 81% rename from .sqlx/query-42a2fa6799182a1c23c2e7a472ba40a55f4030b7586c59035d4d09a976deb408.json rename to .sqlx/query-596e16f2eeadb84764f11fa160723aed8fda4a3cd543b9cd020978fa66c6fea1.json index f965fe541..43ef6989f 100644 --- a/.sqlx/query-42a2fa6799182a1c23c2e7a472ba40a55f4030b7586c59035d4d09a976deb408.json +++ b/.sqlx/query-596e16f2eeadb84764f11fa160723aed8fda4a3cd543b9cd020978fa66c6fea1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT ca_cert_der, ca_key_der, ca_expiry, proxy_http_cert_source AS \"proxy_http_cert_source: ProxyCertSource\", proxy_http_cert_pem, proxy_http_cert_key_pem, proxy_http_cert_expiry, acme_domain, acme_account_credentials, core_http_cert_source AS \"core_http_cert_source: CoreCertSource\", core_http_cert_pem, core_http_cert_key_pem, core_http_cert_expiry FROM certificates WHERE id = 1", + "query": "SELECT ca_cert_der, ca_key_der, ca_expiry, proxy_http_cert_source \"proxy_http_cert_source: ProxyCertSource\", proxy_http_cert_pem, proxy_http_cert_key_pem, proxy_http_cert_expiry, acme_domain, acme_account_credentials, core_http_cert_source \"core_http_cert_source: CoreCertSource\", core_http_cert_pem, core_http_cert_key_pem, core_http_cert_expiry FROM certificates WHERE id = 1", "describe": { "columns": [ { @@ -88,5 +88,5 @@ true ] }, - "hash": "42a2fa6799182a1c23c2e7a472ba40a55f4030b7586c59035d4d09a976deb408" + "hash": "596e16f2eeadb84764f11fa160723aed8fda4a3cd543b9cd020978fa66c6fea1" } diff --git a/.sqlx/query-fcf4fce68b9353dd5720d326554da41a9d6c3716261637bf9d26b2231d94cbae.json b/.sqlx/query-fcf4fce68b9353dd5720d326554da41a9d6c3716261637bf9d26b2231d94cbae.json new file mode 100644 index 000000000..432263fad --- /dev/null +++ b/.sqlx/query-fcf4fce68b9353dd5720d326554da41a9d6c3716261637bf9d26b2231d94cbae.json @@ -0,0 +1,161 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE aclrule SET state = 'expired'::aclrule_state, modified_at = NOW(), modified_by = $1 WHERE state = 'applied'::aclrule_state AND expires < NOW() RETURNING id, parent_id, state \"state: _\", name, allow_all_users, deny_all_users, allow_all_groups, deny_all_groups, allow_all_network_devices, deny_all_network_devices, all_locations, addresses, ports, protocols, enabled, expires, any_address, any_port, any_protocol, use_manual_destination_settings, modified_at, modified_by", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "parent_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "state: _", + "type_info": { + "Custom": { + "name": "aclrule_state", + "kind": { + "Enum": [ + "applied", + "new", + "modified", + "deleted", + "expired" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "allow_all_users", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "deny_all_users", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "allow_all_groups", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "deny_all_groups", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "allow_all_network_devices", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "deny_all_network_devices", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "all_locations", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "addresses", + "type_info": "InetArray" + }, + { + "ordinal": 12, + "name": "ports", + "type_info": "Int4RangeArray" + }, + { + "ordinal": 13, + "name": "protocols", + "type_info": "Int4Array" + }, + { + "ordinal": 14, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 15, + "name": "expires", + "type_info": "Timestamp" + }, + { + "ordinal": 16, + "name": "any_address", + "type_info": "Bool" + }, + { + "ordinal": 17, + "name": "any_port", + "type_info": "Bool" + }, + { + "ordinal": 18, + "name": "any_protocol", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "use_manual_destination_settings", + "type_info": "Bool" + }, + { + "ordinal": 20, + "name": "modified_at", + "type_info": "Timestamp" + }, + { + "ordinal": 21, + "name": "modified_by", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "fcf4fce68b9353dd5720d326554da41a9d6c3716261637bf9d26b2231d94cbae" +} diff --git a/Cargo.lock b/Cargo.lock index 16a2bcd94..5c744740c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2046,9 +2046,9 @@ dependencies = [ [[package]] name = "enum-as-inner" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +checksum = "0359ee92f81184d7985519e474bda2a5738476334edd3746c9b1265c067afe70" dependencies = [ "heck", "proc-macro2", @@ -3576,9 +3576,9 @@ dependencies = [ [[package]] name = "mrml" -version = "5.1.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f2d4de127b05e0abf5bfe406ca8c766bb16e9150b040ff1525bccc20ee7c132" +checksum = "7a117348b480944b0de34666b23cb64c5a63f5de691826bdb8a0657bb43a57ff" dependencies = [ "enum-as-inner", "enum_dispatch", diff --git a/crates/defguard_certs/src/lib.rs b/crates/defguard_certs/src/lib.rs index 2751875d8..9d0c34d13 100644 --- a/crates/defguard_certs/src/lib.rs +++ b/crates/defguard_certs/src/lib.rs @@ -247,7 +247,7 @@ impl Csr<'_> { } } -#[derive(Debug, Copy, Clone)] +#[derive(Clone, Copy)] pub enum PemLabel { Certificate, PrivateKey, diff --git a/crates/defguard_common/src/db/models/certificates.rs b/crates/defguard_common/src/db/models/certificates.rs index 1d000a1ae..91245cebf 100644 --- a/crates/defguard_common/src/db/models/certificates.rs +++ b/crates/defguard_common/src/db/models/certificates.rs @@ -1,6 +1,6 @@ use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; -use sqlx::{PgExecutor, query, query_as}; +use sqlx::{PgExecutor, Type, query, query_as}; use utoipa::ToSchema; /// Certificate source for the proxy HTTP/HTTPS listener. @@ -9,9 +9,7 @@ use utoipa::ToSchema; /// - `SelfSigned`: cert issued by the Core CA /// - `LetsEncrypt`: cert obtained via ACME/Let's Encrypt /// - `Custom`: admin-uploaded PEM cert + key -#[derive( - Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, ToSchema, sqlx::Type, -)] +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema, Type)] #[sqlx(type_name = "text", rename_all = "snake_case")] pub enum ProxyCertSource { #[default] @@ -26,9 +24,7 @@ pub enum ProxyCertSource { /// - `None`: no cert configured, core runs plain HTTP /// - `SelfSigned`: cert issued by the Core CA /// - `Custom`: admin-uploaded PEM cert + key -#[derive( - Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, ToSchema, sqlx::Type, -)] +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema, Type)] #[sqlx(type_name = "text", rename_all = "snake_case")] pub enum CoreCertSource { #[default] @@ -41,7 +37,7 @@ pub enum CoreCertSource { /// /// Holds the Core CA (used to sign gRPC TLS certs for gateways/proxies), /// the proxy HTTP/HTTPS cert, and the core web server HTTPS cert. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Default)] pub struct Certificates { // Core CA pub ca_cert_der: Option>, @@ -75,13 +71,13 @@ impl Certificates { ca_cert_der, \ ca_key_der, \ ca_expiry, \ - proxy_http_cert_source AS \"proxy_http_cert_source: ProxyCertSource\", \ + proxy_http_cert_source \"proxy_http_cert_source: ProxyCertSource\", \ proxy_http_cert_pem, \ proxy_http_cert_key_pem, \ proxy_http_cert_expiry, \ acme_domain, \ acme_account_credentials, \ - core_http_cert_source AS \"core_http_cert_source: CoreCertSource\", \ + core_http_cert_source \"core_http_cert_source: CoreCertSource\", \ core_http_cert_pem, \ core_http_cert_key_pem, \ core_http_cert_expiry \ diff --git a/crates/defguard_core/src/db/models/enrollment.rs b/crates/defguard_core/src/db/models/enrollment.rs index 083f89da6..16b326dde 100644 --- a/crates/defguard_core/src/db/models/enrollment.rs +++ b/crates/defguard_core/src/db/models/enrollment.rs @@ -246,14 +246,14 @@ impl Token { where E: PgExecutor<'e>, { - debug!("Fetch admin data."); + debug!("Fetch admin data"); if self.admin_id.is_none() { - debug!("Admin don't have id. Stop fetching data..."); + debug!("Admin doesn't have ID; stop fetching data"); return Ok(None); } let admin_id = self.admin_id.unwrap(); - debug!("Trying to find admin using id {admin_id}"); + debug!("Trying to find admin using ID {admin_id}"); let user = User::find_by_id(executor, admin_id).await?; debug!("Fetched admin {user:?}."); @@ -267,7 +267,7 @@ impl Token { where E: PgExecutor<'e>, { - debug!("Deleting unused tokens for the user."); + debug!("Deleting unused tokens for the user"); let result = query!( "DELETE FROM token \ WHERE user_id = $1 \ @@ -277,7 +277,7 @@ impl Token { .execute(executor) .await?; info!( - "Deleted {} unused enrollment tokens for the user.", + "Deleted {} unused enrollment tokens for the user", result.rows_affected() ); diff --git a/crates/defguard_core/src/enterprise/db/models/acl.rs b/crates/defguard_core/src/enterprise/db/models/acl.rs index e0bba7542..932944210 100644 --- a/crates/defguard_core/src/enterprise/db/models/acl.rs +++ b/crates/defguard_core/src/enterprise/db/models/acl.rs @@ -309,7 +309,7 @@ impl Default for AclRule { impl AclRule { pub(crate) fn stamp_modified(&mut self, actor: &str) { self.modified_at = Utc::now().naive_utc(); - self.modified_by = actor.to_owned(); + actor.clone_into(&mut self.modified_by); } } @@ -1538,7 +1538,7 @@ impl Default for AclAlias { impl AclAlias { pub(crate) fn stamp_modified(&mut self, actor: &str) { self.modified_at = Utc::now().naive_utc(); - self.modified_by = actor.to_owned(); + actor.clone_into(&mut self.modified_by); } } diff --git a/crates/defguard_core/src/enterprise/ldap/mod.rs b/crates/defguard_core/src/enterprise/ldap/mod.rs index 8436d7196..541a5f7a5 100644 --- a/crates/defguard_core/src/enterprise/ldap/mod.rs +++ b/crates/defguard_core/src/enterprise/ldap/mod.rs @@ -576,7 +576,7 @@ impl LDAPConnection { info!("Found LDAP user with DN: {dn}"); user_from_searchentry(&entry, &user.username, None) } - None => Err(LdapError::ObjectNotFound(format!("User {dn} not found",))), + None => Err(LdapError::ObjectNotFound(format!("User {dn} not found"))), } } diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index ad0997577..13b410d64 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -697,13 +697,10 @@ pub async fn run_web_server( let app = webapp .clone() .into_make_service_with_connect_info::(); - let current_tls_cert_pair = Certificates::get_or_default(&pool) - .await - .map(|c| { - c.core_http_cert_pair() - .map(|(cert, key)| (cert.to_owned(), key.to_owned())) - }) - .unwrap_or(None); + let current_tls_cert_pair = Certificates::get_or_default(&pool).await.map_or(None, |c| { + c.core_http_cert_pair() + .map(|(cert, key)| (cert.to_owned(), key.to_owned())) + }); let mut server_task = tokio::spawn(async move { if let Some((cert_pem, key_pem)) = current_tls_cert_pair { diff --git a/crates/defguard_core/src/utility_thread.rs b/crates/defguard_core/src/utility_thread.rs index 15da196d6..7f5c8cfd1 100644 --- a/crates/defguard_core/src/utility_thread.rs +++ b/crates/defguard_core/src/utility_thread.rs @@ -1,11 +1,12 @@ use std::{collections::HashSet, time::Duration}; -use chrono::Utc; -use defguard_common::db::{ - Id, - models::{WireguardNetwork, wireguard::ServiceLocationMode}, +use chrono::{NaiveDateTime, TimeDelta, Utc}; +use defguard_common::db::models::{ + Certificates, CoreCertSource, ProxyCertSource, User, WireguardNetwork, + wireguard::ServiceLocationMode, }; -use sqlx::PgPool; +use defguard_mail::templates; +use sqlx::{PgConnection, PgPool, query_as}; use tokio::{ sync::broadcast::Sender, time::{Instant, sleep}, @@ -32,6 +33,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 CERTIFICATE_EXPIRY_CHECK_INTERVAL: u64 = 60 * 60 * 24; // 1 day const ACL_EXPIRY_SYSTEM_ACTOR: &str = "system:acl-expiry"; #[instrument(skip_all)] @@ -45,6 +47,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_certificate_check = Instant::now(); // helper variable which stores previous enterprise features status let mut enterprise_enabled = is_business_license_active(); @@ -100,6 +103,7 @@ pub async fn run_utility_thread( updates_check_task().await; ldap_sync_task().await; expired_acl_rules_task().await; + check_certificates(pool).await; loop { sleep(UTILITY_THREAD_MAIN_SLEEP_TIME).await; @@ -142,7 +146,7 @@ pub async fn run_utility_thread( } debug!( "Enterprise feature status changed from {enterprise_enabled} to \ - {new_enterprise_enabled}" + {new_enterprise_enabled}" ); if let Err(err) = enterprise_status_check(pool, wireguard_tx.clone(), new_enterprise_enabled) @@ -156,6 +160,14 @@ pub async fn run_utility_thread( } last_enterprise_status_check = Instant::now(); } + + // Check certificates. + if last_certificate_check.elapsed().as_secs() >= CERTIFICATE_EXPIRY_CHECK_INTERVAL { + check_certificates(pool) + .instrument(info_span!("check_certificates")) + .await; + last_certificate_check = Instant::now(); + } } } @@ -231,21 +243,21 @@ async fn expired_acl_rules_check( wireguard_tx: Sender, ) -> Result<(), anyhow::Error> { // mark relevant rules as expired - let updated_rules = sqlx::query_as::<_, AclRule>( - "UPDATE aclrule SET state = 'expired'::aclrule_state, modified_at = $1, modified_by = $2 \ + let updated_rules = query_as!( + AclRule, + "UPDATE aclrule SET state = 'expired'::aclrule_state, modified_at = NOW(), \ + modified_by = $1 \ WHERE state = 'applied'::aclrule_state AND expires < NOW() \ - RETURNING id, parent_id, state, name, allow_all_users, deny_all_users, allow_all_groups, \ - deny_all_groups, allow_all_network_devices, deny_all_network_devices, all_locations, \ - addresses, ports, protocols, enabled, expires, any_address, any_port, any_protocol, \ - use_manual_destination_settings, modified_at, modified_by", + RETURNING id, parent_id, state \"state: _\", name, allow_all_users, deny_all_users, \ + allow_all_groups, deny_all_groups, allow_all_network_devices, deny_all_network_devices, \ + all_locations, addresses, ports, protocols, enabled, expires, any_address, any_port, \ + any_protocol, use_manual_destination_settings, modified_at, modified_by", + ACL_EXPIRY_SYSTEM_ACTOR ) - .bind(Utc::now().naive_utc()) - .bind(ACL_EXPIRY_SYSTEM_ACTOR) .fetch_all(pool) .await?; - // send firewall config updates to locations which have been affected by updated - // rules + // Send firewall config updates to locations which have been affected by updated rules. debug!( "Marked {} ACL rules as expired. Sending firewall config updates to affected locations.", updated_rules.len() @@ -260,10 +272,10 @@ async fn expired_acl_rules_check( } } - let affected_locations: Vec> = affected_locations.into_iter().collect(); + let affected_locations = affected_locations.into_iter().collect::>(); debug!( - "{} locations affected by expired ACL rules. Sending gateway firewall update events \ - for each location", + "{} locations affected by expired ACL rules. Sending gateway firewall update events for \ + each location", affected_locations.len() ); @@ -288,3 +300,81 @@ async fn expired_acl_rules_check( Ok(()) } + +/// Check if certificate is about to expire, or got expired. Send mail accordingly. +async fn expiry_check(conn: &mut PgConnection, certificate_type: &str, expiry: NaiveDateTime) { + const TIME_CHECK: &[TimeDelta] = &[ + TimeDelta::days(-14), + TimeDelta::days(-7), + TimeDelta::days(-3), + TimeDelta::days(-1), + TimeDelta::days(0), + ]; + + let now = Utc::now().naive_utc(); + let time_delta = now - expiry; + for check in TIME_CHECK { + if check.num_days() == time_delta.num_days() { + // Send email to admins. + if time_delta.num_days() >= 0 { + debug!("Certificate {certificate_type} has expired; notifying admins"); + } else { + debug!("Certificate {certificate_type} is about to expire; notifying admins"); + } + let Ok(admin_users) = User::find_admins(&mut *conn).await else { + error!("Failed to fetch admins from database"); + return; + }; + for user in admin_users { + let _ = if time_delta.num_days() >= 0 { + templates::certificate_expired_mail( + &user.email, + &mut *conn, + certificate_type, + expiry, + ) + .await + } else { + templates::certificate_expiration_mail( + &user.email, + &mut *conn, + certificate_type, + expiry, + ) + .await + }; + } + } + } +} + +/// Check if any of the certificates are about to expire, or got expired. +async fn check_certificates(pool: &PgPool) { + let cert = match Certificates::get(pool).await { + Ok(Some(cert)) => cert, + Ok(None) => { + debug!("No certificates in the databae"); + return; + } + Err(err) => { + error!("Failed to fetch certificates: {err}"); + return; + } + }; + + let Ok(mut conn) = pool.begin().await else { + error!("Failed to create database transaction"); + return; + }; + if let ProxyCertSource::Custom = cert.proxy_http_cert_source { + if let Some(proxy_http_cert_expiry) = cert.proxy_http_cert_expiry { + expiry_check(&mut conn, "Edge HTTPS", proxy_http_cert_expiry).await; + } + } + + if let CoreCertSource::Custom = cert.core_http_cert_source { + if let Some(core_http_cert_expiry) = cert.core_http_cert_expiry { + expiry_check(&mut conn, "Core HTTPS", core_http_cert_expiry).await; + } + } +} diff --git a/crates/defguard_core/src/version.rs b/crates/defguard_core/src/version.rs index 2e5fbcb6e..c2521942f 100644 --- a/crates/defguard_core/src/version.rs +++ b/crates/defguard_core/src/version.rs @@ -184,8 +184,8 @@ impl IncompatibleComponents { .expect("Failed to read-lock IncompatibleComponents") .proxy .as_ref() - .filter(|proxy| (now - proxy.created) > OUTDATED_COMPONENT_LIFETIME) - .is_some() + .as_ref() + .is_some_and(|proxy| (now - proxy.created) > OUTDATED_COMPONENT_LIFETIME) { return true; } diff --git a/crates/defguard_event_logger/src/description.rs b/crates/defguard_event_logger/src/description.rs index fdbf21570..03b34d587 100644 --- a/crates/defguard_event_logger/src/description.rs +++ b/crates/defguard_event_logger/src/description.rs @@ -239,7 +239,7 @@ pub fn get_defguard_event_description(event: &DefguardEvent) -> Option { new_name.clone().unwrap_or_default() )), DefguardEvent::ClientConfigurationTokenAdded { user } => { - Some(format!("Added client configuration token for user {user}",)) + Some(format!("Added client configuration token for user {user}")) } DefguardEvent::UserSnatBindingAdded { user, binding } => Some(format!( "Devices owned by user {user} bound to public IP {}", diff --git a/crates/defguard_mail/Cargo.toml b/crates/defguard_mail/Cargo.toml index 9ada5ddf2..307689d59 100644 --- a/crates/defguard_mail/Cargo.toml +++ b/crates/defguard_mail/Cargo.toml @@ -24,7 +24,7 @@ tracing.workspace = true humantime.workspace = true image = "0.25" # match with qrforge -mrml = "5.1" +mrml = "6.0" qrforge = {version = "0.1", default-features = false, features = ["image"]} [dev-dependencies] diff --git a/crates/defguard_mail/src/mail.rs b/crates/defguard_mail/src/mail.rs index 31a06c2d4..407d9636e 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, + CertificateExpiration, + CertificateExpired, } impl MailMessage { @@ -332,6 +334,8 @@ 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::CertificateExpiration => "Defguard: Certificate expiration".to_string(), + Self::CertificateExpired => "Defguard: Certificate has expired".to_string(), } } @@ -354,6 +358,8 @@ impl MailMessage { Self::PasswordResetDone => "password-reset-done", Self::UserImportBlocked => "user-import-blocked", Self::EnrollmentNotification => "enrollment-admin-notification", + Self::CertificateExpiration => "certificate-expiration", + Self::CertificateExpired => "certificate-expired", } } @@ -378,6 +384,9 @@ impl MailMessage { Self::EnrollmentNotification => { include_str!("../templates/enrollment-admin-notification.mjml") } + Self::CertificateExpiration | Self::CertificateExpired => { + include_str!("../templates/certificate-expiration.mjml") + } } } @@ -402,6 +411,9 @@ impl MailMessage { Self::EnrollmentNotification => { include_str!("../templates/enrollment-admin-notification.text") } + Self::CertificateExpiration | Self::CertificateExpired => { + include_str!("../templates/certificate-expiration.text") + } } } diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index a9887497b..5ce18eef7 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -484,3 +484,47 @@ pub async fn password_reset_success_mail( Ok(()) } + +/// Certificate is about to expire. +pub async fn certificate_expiration_mail( + to: &str, + conn: &mut PgConnection, + certificate_type: &str, + expiration: NaiveDateTime, +) -> Result<(), TemplateError> { + let (mut tera, mut context) = get_base_tera_mjml(Context::new(), None, None, None)?; + + context.insert("cert_type", certificate_type); + context.insert( + "exp_date", + &expiration.format(MAIL_DATETIME_FORMAT).to_string(), + ); + + let message = MailMessage::CertificateExpiration; + message.fill_context(conn, &mut context).await?; + message.mail(&mut tera, &context, to)?.send_and_forget(); + + Ok(()) +} + +/// Certificate has expired. +pub async fn certificate_expired_mail( + to: &str, + conn: &mut PgConnection, + certificate_type: &str, + expiration: NaiveDateTime, +) -> Result<(), TemplateError> { + let (mut tera, mut context) = get_base_tera_mjml(Context::new(), None, None, None)?; + + context.insert("cert_type", certificate_type); + context.insert( + "exp_date", + &expiration.format(MAIL_DATETIME_FORMAT).to_string(), + ); + + let message = MailMessage::CertificateExpired; + message.fill_context(conn, &mut context).await?; + message.mail(&mut tera, &context, to)?.send_and_forget(); + + Ok(()) +} diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_mail/src/tests.rs index b857e395e..8b8bc8031 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_mail/src/tests.rs @@ -29,6 +29,11 @@ fn dg25_8_server_side_template_injection() { assert!(tera.render("text", &Context::new()).is_err()); } +/// Delay, so send_and_forget() can process the message. +async fn delay() { + tokio::time::sleep(Duration::from_secs(2)).await; +} + /// Set SMTP settings from environment variables. async fn set_smtp_settings(pool: &PgPool) { let config = DefGuardConfig::new_test_config(); @@ -66,8 +71,7 @@ fn send_desktop_start(_: PgPoolOptions, options: PgConnectOptions) { .await .unwrap(); - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; + delay().await; } #[ignore = "requires SMTP server"] @@ -101,8 +105,7 @@ fn send_new_device_added(_: PgPoolOptions, options: PgConnectOptions) { .await .unwrap(); - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; + delay().await; } #[ignore = "requires SMTP server"] @@ -124,8 +127,7 @@ fn send_mfa_code(_: PgPoolOptions, options: PgConnectOptions) { .await .unwrap(); - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; + delay().await; } #[ignore = "requires SMTP server"] @@ -148,8 +150,7 @@ fn send_new_account(_: PgPoolOptions, options: PgConnectOptions) { .await .unwrap(); - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; + delay().await; } #[ignore = "requires SMTP server"] @@ -171,8 +172,7 @@ fn send_mfa_activation(_: PgPoolOptions, options: PgConnectOptions) { .await .unwrap(); - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; + delay().await; } #[ignore = "requires SMTP server"] @@ -196,8 +196,7 @@ fn send_enrollment_admin_notification(_: PgPoolOptions, options: PgConnectOption .await .unwrap(); - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; + delay().await; } #[ignore = "requires SMTP server"] @@ -220,8 +219,7 @@ fn send_gateway_disconnected_mail(_: PgPoolOptions, options: PgConnectOptions) { .await .unwrap(); - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; + delay().await; } #[ignore = "requires SMTP server"] @@ -244,8 +242,7 @@ fn send_gateway_reconnected_mail(_: PgPoolOptions, options: PgConnectOptions) { .await .unwrap(); - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; + delay().await; } #[ignore = "requires SMTP server"] @@ -264,8 +261,7 @@ fn send_mfa_configured_mail(_: PgPoolOptions, options: PgConnectOptions) { .await .unwrap(); - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; + delay().await; } #[ignore = "requires SMTP server"] @@ -280,8 +276,7 @@ fn send_new_device_login_mail(_: PgPoolOptions, options: PgConnectOptions) { .await .unwrap(); - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; + delay().await; } #[ignore = "requires SMTP server"] @@ -303,8 +298,7 @@ fn send_new_device_oidc_login_mail(_: PgPoolOptions, options: PgConnectOptions) .await .unwrap(); - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; + delay().await; } #[ignore = "requires SMTP server"] @@ -327,8 +321,7 @@ fn send_password_reset_mail(_: PgPoolOptions, options: PgConnectOptions) { .await .unwrap(); - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; + delay().await; } #[ignore = "requires SMTP server"] @@ -342,8 +335,7 @@ fn send_password_reset_success_mail(_: PgPoolOptions, options: PgConnectOptions) .await .unwrap(); - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; + delay().await; } #[ignore = "requires SMTP server"] @@ -357,8 +349,7 @@ fn send_test_mail(_: PgPoolOptions, options: PgConnectOptions) { .await .unwrap(); - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; + delay().await; } #[ignore = "requires SMTP server"] @@ -376,8 +367,7 @@ fn send_support_data_mail(_: PgPoolOptions, options: PgConnectOptions) { .await .unwrap(); - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; + delay().await; } #[ignore = "requires SMTP server"] @@ -390,8 +380,47 @@ fn send_enrollment_welcome_mail(_: PgPoolOptions, options: PgConnectOptions) { templates::enrollment_welcome_mail(&env::var("SMTP_TO").unwrap(), markdown, None, None) .unwrap(); - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; + delay().await; +} + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_certificate_expiration(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let expiration = Utc::now().naive_utc(); + let mut conn = pool.begin().await.unwrap(); + templates::certificate_expiration_mail( + &env::var("SMTP_TO").unwrap(), + &mut conn, + "Dummy", + expiration, + ) + .await + .unwrap(); + + delay().await; +} + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_certificate_expired(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let expiration = Utc::now().naive_utc(); + let mut conn = pool.begin().await.unwrap(); + templates::certificate_expired_mail( + &env::var("SMTP_TO").unwrap(), + &mut conn, + "Dummy", + expiration, + ) + .await + .unwrap(); + + delay().await; } #[test] diff --git a/crates/defguard_mail/templates/certificate-expiration.mjml b/crates/defguard_mail/templates/certificate-expiration.mjml new file mode 100644 index 000000000..9d4e58816 --- /dev/null +++ b/crates/defguard_mail/templates/certificate-expiration.mjml @@ -0,0 +1,32 @@ +{% import "macros.mjml" as macros %} +{% extends "base.mjml" %} +{% block content %} + +{{ macros::email_header() }} + + + + + + + {{ label_cert_type }} + + + {{ cert_type }} + + + + + {{ label_exp_date }} + + + {{ exp_date }} + + + + + + +{{ macros::footer_divider() }} + +{% endblock content %} diff --git a/crates/defguard_mail/templates/certificate-expiration.text b/crates/defguard_mail/templates/certificate-expiration.text new file mode 100644 index 000000000..adaa52b03 --- /dev/null +++ b/crates/defguard_mail/templates/certificate-expiration.text @@ -0,0 +1,5 @@ +{{ title }} +{% if subtitle %}{{ subtitle }}{% endif %} + +{{ label_cert_type }}: {{ cert_type }} +{{ label_exp_date }}: {{ exp_date }} diff --git a/crates/defguard_proxy_manager/src/servers/enrollment.rs b/crates/defguard_proxy_manager/src/servers/enrollment.rs index d950e0dc9..3df56e6cb 100644 --- a/crates/defguard_proxy_manager/src/servers/enrollment.rs +++ b/crates/defguard_proxy_manager/src/servers/enrollment.rs @@ -837,13 +837,13 @@ impl EnrollmentServer { }) .collect::>(); - let template_locations: Vec = configs + let template_locations = configs .iter() .map(|c| TemplateLocation { name: c.network_name.clone(), assigned_ips: c.address.as_csv(), }) - .collect(); + .collect::>(); debug!( "Sending device created mail for device {}, user {}({:?})", diff --git a/migrations/20260417104806_[2.0.0]_mjml_cert_exp.down.sql b/migrations/20260417104806_[2.0.0]_mjml_cert_exp.down.sql new file mode 100644 index 000000000..c4d604192 --- /dev/null +++ b/migrations/20260417104806_[2.0.0]_mjml_cert_exp.down.sql @@ -0,0 +1 @@ +DELETE FROM mail_context WHERE template = 'certificate-expiration' OR template = 'certificate-expired'; diff --git a/migrations/20260417104806_[2.0.0]_mjml_cert_exp.up.sql b/migrations/20260417104806_[2.0.0]_mjml_cert_exp.up.sql new file mode 100644 index 000000000..deeada2ff --- /dev/null +++ b/migrations/20260417104806_[2.0.0]_mjml_cert_exp.up.sql @@ -0,0 +1,9 @@ +INSERT INTO mail_context (template, section, language_tag, text) VALUES + ('certificate-expiration', 'title', 'en_US', 'You’re receiving this email because your certificate is about to expire.'), + ('certificate-expiration', 'subtitle', 'en_US', 'Please, review the details below and renew your certificate to maintain secure connectivity.'), + ('certificate-expiration', 'label_cert_type', 'en_US', 'Certificate type'), + ('certificate-expiration', 'label_exp_date', 'en_US', 'Expiration date'), + ('certificate-expired', 'title', 'en_US', 'You’re receiving this email because your certificate has expired.'), + ('certificate-expired', 'subtitle', 'en_US', 'Please, review the details below and renew your certificate to maintain secure connectivity.'), + ('certificate-expired', 'label_cert_type', 'en_US', 'Certificate type'), + ('certificate-expired', 'label_exp_date', 'en_US', 'Expiration date');