diff --git a/.sqlx/query-4a1fba6c990265bc278d4e1534f06a96461ecb5edf023ab88d71f9887fc0f2f2.json b/.sqlx/query-217e11c616a96fdd002fbf31bb0a37735aec6f1bac187cc4a331207154996346.json similarity index 95% rename from .sqlx/query-4a1fba6c990265bc278d4e1534f06a96461ecb5edf023ab88d71f9887fc0f2f2.json rename to .sqlx/query-217e11c616a96fdd002fbf31bb0a37735aec6f1bac187cc4a331207154996346.json index fa1068e52..f52051d44 100644 --- a/.sqlx/query-4a1fba6c990265bc278d4e1534f06a96461ecb5edf023ab88d71f9887fc0f2f2.json +++ b/.sqlx/query-217e11c616a96fdd002fbf31bb0a37735aec6f1bac187cc4a331207154996346.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT openid_enabled, wireguard_enabled, webhooks_enabled, worker_enabled, challenge_template, instance_name, main_logo_url, nav_logo_url, smtp_server, smtp_port, smtp_encryption \"smtp_encryption: _\", smtp_user, smtp_password \"smtp_password?: SecretStringWrapper\", smtp_sender, enrollment_vpn_step_optional, enrollment_welcome_message, enrollment_welcome_email, enrollment_welcome_email_subject, enrollment_use_welcome_message_as_email, uuid, ldap_url, ldap_bind_username, ldap_bind_password \"ldap_bind_password?: SecretStringWrapper\", ldap_group_search_base, ldap_user_search_base, ldap_user_obj_class, ldap_group_obj_class, ldap_username_attr, ldap_groupname_attr, ldap_group_member_attr, ldap_member_attr, openid_create_account, license, gateway_disconnect_notifications_enabled, ldap_use_starttls, ldap_tls_verify_cert, gateway_disconnect_notifications_inactivity_threshold, gateway_disconnect_notifications_reconnect_notification_enabled, ldap_sync_status \"ldap_sync_status: LdapSyncStatus\", ldap_enabled, ldap_sync_enabled, ldap_is_authoritative, ldap_sync_interval, ldap_user_auxiliary_obj_classes, ldap_uses_ad, ldap_user_rdn_attr, ldap_sync_groups, openid_username_handling \"openid_username_handling: OpenidUsernameHandling\" FROM \"settings\" WHERE id = 1", + "query": "SELECT openid_enabled, wireguard_enabled, webhooks_enabled, worker_enabled, challenge_template, instance_name, main_logo_url, nav_logo_url, smtp_server, smtp_port, smtp_encryption \"smtp_encryption: _\", smtp_user, smtp_password \"smtp_password?: SecretStringWrapper\", smtp_sender, enrollment_vpn_step_optional, enrollment_welcome_message, enrollment_welcome_email, enrollment_welcome_email_subject, enrollment_use_welcome_message_as_email, uuid, ldap_url, ldap_bind_username, ldap_bind_password \"ldap_bind_password?: SecretStringWrapper\", ldap_group_search_base, ldap_user_search_base, ldap_user_obj_class, ldap_group_obj_class, ldap_username_attr, ldap_groupname_attr, ldap_group_member_attr, ldap_member_attr, openid_create_account, license, gateway_disconnect_notifications_enabled, ldap_use_starttls, ldap_tls_verify_cert, gateway_disconnect_notifications_inactivity_threshold, gateway_disconnect_notifications_reconnect_notification_enabled, ldap_sync_status \"ldap_sync_status: LdapSyncStatus\", ldap_enabled, ldap_sync_enabled, ldap_is_authoritative, ldap_sync_interval, ldap_user_auxiliary_obj_classes, ldap_uses_ad, ldap_user_rdn_attr, ldap_sync_groups, openid_username_handling \"openid_username_handling: OpenidUsernameHandling\", ca_key_der, ca_cert_der FROM \"settings\" WHERE id = 1", "describe": { "columns": [ { @@ -274,6 +274,16 @@ } } } + }, + { + "ordinal": 48, + "name": "ca_key_der", + "type_info": "Bytea" + }, + { + "ordinal": 49, + "name": "ca_cert_der", + "type_info": "Bytea" } ], "parameters": { @@ -327,8 +337,10 @@ false, true, false, - false + false, + true, + true ] }, - "hash": "4a1fba6c990265bc278d4e1534f06a96461ecb5edf023ab88d71f9887fc0f2f2" + "hash": "217e11c616a96fdd002fbf31bb0a37735aec6f1bac187cc4a331207154996346" } diff --git a/.sqlx/query-3491725f35609e9b219c4d613cffd28a14cf37e546dfcabdfd78889dc1ef247f.json b/.sqlx/query-bc64228b7c366e1a12db1397d0f66a2ea59a46b7aec66b86522bb0251be3b07a.json similarity index 94% rename from .sqlx/query-3491725f35609e9b219c4d613cffd28a14cf37e546dfcabdfd78889dc1ef247f.json rename to .sqlx/query-bc64228b7c366e1a12db1397d0f66a2ea59a46b7aec66b86522bb0251be3b07a.json index beabc1823..4a1c1ceec 100644 --- a/.sqlx/query-3491725f35609e9b219c4d613cffd28a14cf37e546dfcabdfd78889dc1ef247f.json +++ b/.sqlx/query-bc64228b7c366e1a12db1397d0f66a2ea59a46b7aec66b86522bb0251be3b07a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"settings\" SET openid_enabled = $1, wireguard_enabled = $2, webhooks_enabled = $3, worker_enabled = $4, challenge_template = $5, instance_name = $6, main_logo_url = $7, nav_logo_url = $8, smtp_server = $9, smtp_port = $10, smtp_encryption = $11, smtp_user = $12, smtp_password = $13, smtp_sender = $14, enrollment_vpn_step_optional = $15, enrollment_welcome_message = $16, enrollment_welcome_email = $17, enrollment_welcome_email_subject = $18, enrollment_use_welcome_message_as_email = $19, uuid = $20, ldap_url = $21, ldap_bind_username = $22, ldap_bind_password = $23, ldap_group_search_base = $24, ldap_user_search_base = $25, ldap_user_obj_class = $26, ldap_group_obj_class = $27, ldap_username_attr = $28, ldap_groupname_attr = $29, ldap_group_member_attr = $30, ldap_member_attr = $31, ldap_use_starttls = $32, ldap_tls_verify_cert = $33, openid_create_account = $34, license = $35, gateway_disconnect_notifications_enabled = $36, gateway_disconnect_notifications_inactivity_threshold = $37, gateway_disconnect_notifications_reconnect_notification_enabled = $38, ldap_sync_status = $39, ldap_enabled = $40, ldap_sync_enabled = $41, ldap_is_authoritative = $42, ldap_sync_interval = $43, ldap_user_auxiliary_obj_classes = $44, ldap_uses_ad = $45, ldap_user_rdn_attr = $46, ldap_sync_groups = $47, openid_username_handling = $48 WHERE id = 1", + "query": "UPDATE \"settings\" SET openid_enabled = $1, wireguard_enabled = $2, webhooks_enabled = $3, worker_enabled = $4, challenge_template = $5, instance_name = $6, main_logo_url = $7, nav_logo_url = $8, smtp_server = $9, smtp_port = $10, smtp_encryption = $11, smtp_user = $12, smtp_password = $13, smtp_sender = $14, enrollment_vpn_step_optional = $15, enrollment_welcome_message = $16, enrollment_welcome_email = $17, enrollment_welcome_email_subject = $18, enrollment_use_welcome_message_as_email = $19, uuid = $20, ldap_url = $21, ldap_bind_username = $22, ldap_bind_password = $23, ldap_group_search_base = $24, ldap_user_search_base = $25, ldap_user_obj_class = $26, ldap_group_obj_class = $27, ldap_username_attr = $28, ldap_groupname_attr = $29, ldap_group_member_attr = $30, ldap_member_attr = $31, ldap_use_starttls = $32, ldap_tls_verify_cert = $33, openid_create_account = $34, license = $35, gateway_disconnect_notifications_enabled = $36, gateway_disconnect_notifications_inactivity_threshold = $37, gateway_disconnect_notifications_reconnect_notification_enabled = $38, ldap_sync_status = $39, ldap_enabled = $40, ldap_sync_enabled = $41, ldap_is_authoritative = $42, ldap_sync_interval = $43, ldap_user_auxiliary_obj_classes = $44, ldap_uses_ad = $45, ldap_user_rdn_attr = $46, ldap_sync_groups = $47, openid_username_handling = $48, ca_key_der = $49, ca_cert_der = $50 WHERE id = 1", "describe": { "columns": [], "parameters": { @@ -84,10 +84,12 @@ ] } } - } + }, + "Bytea", + "Bytea" ] }, "nullable": [] }, - "hash": "3491725f35609e9b219c4d613cffd28a14cf37e546dfcabdfd78889dc1ef247f" + "hash": "bc64228b7c366e1a12db1397d0f66a2ea59a46b7aec66b86522bb0251be3b07a" } diff --git a/Cargo.lock b/Cargo.lock index 557dfb66d..ce1a729fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -195,7 +195,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" dependencies = [ - "asn1-rs-derive", + "asn1-rs-derive 0.5.1", "asn1-rs-impl", "displaydoc", "nom 7.1.3", @@ -205,6 +205,22 @@ dependencies = [ "time", ] +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive 0.6.0", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.17", + "time", +] + [[package]] name = "asn1-rs-derive" version = "0.5.1" @@ -217,6 +233,18 @@ dependencies = [ "synstructure", ] +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "asn1-rs-impl" version = "0.2.0" @@ -1096,6 +1124,7 @@ version = "0.0.0" dependencies = [ "anyhow", "bytes", + "defguard_certs", "defguard_common", "defguard_core", "defguard_event_logger", @@ -1109,6 +1138,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "defguard_certs" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "rcgen", + "rustls-pki-types", + "serde", + "sqlx", + "thiserror 2.0.17", +] + [[package]] name = "defguard_common" version = "1.6.0" @@ -1160,6 +1201,7 @@ dependencies = [ "bytes", "chrono", "claims", + "defguard_certs", "defguard_common", "defguard_mail", "defguard_proto", @@ -1286,6 +1328,7 @@ name = "defguard_proxy_manager" version = "0.0.0" dependencies = [ "axum", + "defguard_certs", "defguard_common", "defguard_core", "defguard_mail", @@ -1353,7 +1396,21 @@ version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ - "asn1-rs", + "asn1-rs 0.6.2", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs 0.7.1", "displaydoc", "nom 7.1.3", "num-bigint", @@ -3359,7 +3416,16 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" dependencies = [ - "asn1-rs", + "asn1-rs 0.6.2", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs 0.7.1", ] [[package]] @@ -4191,6 +4257,20 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rcgen" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ec0a99f2de91c3cddc84b37e7db80e4d96b743e05607f647eb236fc0455907f" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser 0.18.0", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -6381,7 +6461,7 @@ checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b" dependencies = [ "base64 0.21.7", "base64urlsafedata", - "der-parser", + "der-parser 9.0.0", "hex", "nom 7.1.3", "openssl", @@ -6397,7 +6477,7 @@ dependencies = [ "uuid", "webauthn-attestation-ca", "webauthn-rs-proto", - "x509-parser", + "x509-parser 0.16.0", ] [[package]] @@ -6790,17 +6870,35 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" dependencies = [ - "asn1-rs", + "asn1-rs 0.6.2", "data-encoding", - "der-parser", + "der-parser 9.0.0", "lazy_static", "nom 7.1.3", - "oid-registry", + "oid-registry 0.7.1", "rusticata-macros", "thiserror 1.0.69", "time", ] +[[package]] +name = "x509-parser" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3e137310115a65136898d2079f003ce33331a6c4b0d51f1531d1be082b6425" +dependencies = [ + "asn1-rs 0.7.1", + "data-encoding", + "der-parser 10.0.0", + "lazy_static", + "nom 7.1.3", + "oid-registry 0.8.1", + "ring", + "rusticata-macros", + "thiserror 2.0.17", + "time", +] + [[package]] name = "yasna" version = "0.5.2" @@ -6808,6 +6906,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" dependencies = [ "num-bigint", + "time", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 177460539..e2a2d38e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ defguard_proxy_manager = { path = "./crates/defguard_proxy_manager", version = " defguard_session_manager = { path = "./crates/defguard_session_manager", version = "0.0.0" } defguard_version = { path = "./crates/defguard_version", version = "0.0.0" } defguard_web_ui = { path = "./crates/defguard_web_ui", version = "0.0.0" } +defguard_certs = { path = "./crates/defguard_certs", version = "0.0.0" } model_derive = { path = "./crates/model_derive", version = "0.0.0" } # external dependencies @@ -120,6 +121,8 @@ webauthn-rs = { version = "0.5", features = [ ] } webauthn-rs-proto = "0.5" x25519-dalek = { version = "2.0", features = ["static_secrets"] } +rcgen = { version = "0.14", features = ["x509-parser", "pem"] } +rustls-pki-types = "1.13" [profile.release] codegen-units = 1 diff --git a/crates/defguard/Cargo.toml b/crates/defguard/Cargo.toml index c6125e672..ca511eb6b 100644 --- a/crates/defguard/Cargo.toml +++ b/crates/defguard/Cargo.toml @@ -16,6 +16,7 @@ defguard_event_logger = { workspace = true } defguard_mail = { workspace = true } defguard_proxy_manager = { workspace = true } defguard_version = { workspace = true } +defguard_certs = { workspace = true } # external dependencies anyhow = { workspace = true } diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index 99de57601..4f3b12a63 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -12,7 +12,7 @@ use defguard_common::{ models::{ Settings, User, - settings::initialize_current_settings, + settings::{initialize_current_settings, update_current_settings}, // wireguard_peer_stats::WireguardPeerStats, }, }, @@ -128,6 +128,22 @@ async fn main() -> Result<(), anyhow::Error> { // initialize global settings struct initialize_current_settings(&pool).await?; + let mut settings = Settings::get_current_settings(); + if settings.ca_cert_der.is_none() || settings.ca_key_der.is_none() { + info!( + "No gRPC TLS certificate or key found in settings, generating self-signed certificate for gRPC server." + ); + + let ca = defguard_certs::CertificateAuthority::new()?; + + let (cert_der, key_der) = (ca.cert_der().to_vec(), ca.key_pair_der().to_vec()); + + settings.ca_cert_der = Some(cert_der); + settings.ca_key_der = Some(key_der); + + update_current_settings(&pool, settings).await?; + } + // read grpc TLS cert and key let grpc_cert = config .grpc_cert diff --git a/crates/defguard_certs/Cargo.toml b/crates/defguard_certs/Cargo.toml new file mode 100644 index 000000000..8ea34cbeb --- /dev/null +++ b/crates/defguard_certs/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "defguard_certs" +version = "0.0.0" +edition.workspace = true +license-file.workspace = true +homepage.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +base64.workspace = true +rcgen.workspace = true +serde.workspace = true +sqlx.workspace = true +thiserror.workspace = true +rustls-pki-types.workspace = true diff --git a/crates/defguard_certs/src/lib.rs b/crates/defguard_certs/src/lib.rs new file mode 100644 index 000000000..574505b21 --- /dev/null +++ b/crates/defguard_certs/src/lib.rs @@ -0,0 +1,249 @@ +use base64::{Engine, prelude::BASE64_STANDARD}; +use rcgen::{ + BasicConstraints, Certificate, CertificateParams, CertificateSigningRequestParams, IsCa, + Issuer, KeyPair, SigningKey, +}; +use rustls_pki_types::{CertificateDer, CertificateSigningRequestDer, pem::PemObject}; +use thiserror::Error; + +const CA_NAME: &str = "Defguard CA"; +const CA_ORG: &str = "Defguard"; + +#[derive(Debug, Error)] +pub enum CertificateError { + #[error("Certificate generation error: {0}")] + RCGenError(#[from] rcgen::Error), + #[error("Failed to parse: {0}")] + ParsingError(String), + #[error(transparent)] + IoError(#[from] std::io::Error), +} + +pub struct CertificateAuthority<'a> { + issuer: Issuer<'a, KeyPair>, + cert_der: CertificateDer<'a>, +} + +impl CertificateAuthority<'_> { + pub fn from_ca_cert_pem( + ca_cert_pem: &str, + ca_key_pair: &str, + ) -> Result { + let key_pair = KeyPair::from_pem(ca_key_pair)?; + let cert_der = CertificateDer::from_pem_slice(ca_cert_pem.as_bytes()) + .map_err(|e| CertificateError::ParsingError(e.to_string()))?; + let issuer = Issuer::from_ca_cert_der(&cert_der, key_pair)?; + Ok(CertificateAuthority { issuer, cert_der }) + } + + pub fn from_cert_der_key_pair( + ca_cert_der: &[u8], + ca_key_pair: &[u8], + ) -> Result { + let key_pair = KeyPair::try_from(ca_key_pair)?; + let cert_der = CertificateDer::from(ca_cert_der.to_vec()); + let issuer = Issuer::from_ca_cert_der(&cert_der, key_pair)?; + Ok(CertificateAuthority { issuer, cert_der }) + } + + pub fn from_key_cert_params( + key_pair: KeyPair, + ca_cert_params: CertificateParams, + ) -> Result { + let cert = ca_cert_params.self_signed(&key_pair)?; + let issuer = Issuer::new(ca_cert_params, key_pair); + let cert_der = cert.der().clone(); + Ok(CertificateAuthority { issuer, cert_der }) + } + + pub fn new() -> Result { + let mut ca_params = CertificateParams::new(vec![CA_NAME.to_string()])?; + + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + ca_params + .distinguished_name + .push(rcgen::DnType::OrganizationName, CA_ORG); + ca_params + .distinguished_name + .push(rcgen::DnType::CommonName, CA_NAME); + + let ca_key_pair = KeyPair::generate()?; + + CertificateAuthority::from_key_cert_params(ca_key_pair, ca_params) + } + + pub fn sign_csr(&self, csr: &Csr) -> Result { + let csr = csr.params()?; + let cert = csr.signed_by(&self.issuer)?; + Ok(cert) + } + + pub fn cert_pem(&self) -> Result { + der_to_pem(self.cert_der.as_ref(), PemLabel::Certificate) + } + + #[must_use] + pub fn cert_der(&self) -> &[u8] { + self.cert_der.as_ref() + } + + #[must_use] + pub fn key_pair_der(&self) -> &[u8] { + self.issuer.key().serialized_der() + } +} + +pub struct Csr<'a> { + csr: CertificateSigningRequestDer<'a>, +} + +impl Csr<'_> { + pub fn new( + key_pair: &impl SigningKey, + subject_alt_names: &[String], + dinstinguished_name: Vec<(rcgen::DnType, &str)>, + ) -> Result { + let mut csr_params = CertificateParams::new(subject_alt_names.to_vec())?; + for (dn_type, value) in dinstinguished_name { + csr_params.distinguished_name.push(dn_type, value); + } + let request = csr_params.serialize_request(key_pair)?; + let csr = request.der().clone(); + Ok(Self { csr }) + } + + pub fn from_der(csr_der: &[u8]) -> Result { + let csr = CertificateSigningRequestDer::from(csr_der.to_vec()); + Ok(Self { csr }) + } + + pub fn params(&self) -> Result { + let params = CertificateSigningRequestParams::from_der(&self.csr) + .map_err(|e| CertificateError::ParsingError(e.to_string()))?; + Ok(params) + } + + #[must_use] + pub fn to_der(&self) -> &[u8] { + self.csr.as_ref() + } +} + +#[derive(Debug, Copy, Clone)] +pub enum PemLabel { + Certificate, + PrivateKey, + PublicKey, +} + +impl PemLabel { + #[must_use] + pub const fn as_str(&self) -> &str { + match self { + Self::Certificate => "CERTIFICATE", + Self::PrivateKey => "PRIVATE KEY", + Self::PublicKey => "PUBLIC KEY", + } + } +} + +pub fn der_to_pem(der: &[u8], label: PemLabel) -> Result { + let b64 = BASE64_STANDARD.encode(der); + let pem_string = format!( + "-----BEGIN {}-----\n{}\n-----END {}-----", + label.as_str(), + b64.as_bytes() + .chunks(64) + .map(|chunk| std::str::from_utf8(chunk) + .map_err(|e| CertificateError::ParsingError(e.to_string()))) + .collect::, _>>()? + .join("\n"), + label.as_str(), + ); + Ok(pem_string) +} + +pub fn cert_der_to_pem(cert_der: &[u8]) -> Result { + der_to_pem(cert_der, PemLabel::Certificate) +} + +pub fn generate_key_pair() -> Result { + let key_pair = KeyPair::generate()?; + Ok(key_pair) +} + +pub type DnType = rcgen::DnType; +pub type RcGenKeyPair = rcgen::KeyPair; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_to_from_der() { + let key_pair = KeyPair::generate().unwrap(); + let csr = Csr::new( + &key_pair, + &["example.com".to_string()], + vec![(rcgen::DnType::CommonName, "example.com")], + ) + .unwrap(); + let der = csr.to_der(); + let csr_loaded = Csr::from_der(der).unwrap(); + assert_eq!(csr.to_der(), csr_loaded.to_der()); + } + + #[test] + fn test_ca_creation() { + let ca = CertificateAuthority::new().unwrap(); + let key = ca.issuer.key(); + let der = &ca.cert_der; + let pem_string = cert_der_to_pem(der.as_ref()).unwrap(); + let ca_loaded = + CertificateAuthority::from_ca_cert_pem(&pem_string, &key.serialize_pem()).unwrap(); + assert_eq!(ca.cert_der, ca_loaded.cert_der); + } + + #[test] + fn test_sign_csr() { + let ca = CertificateAuthority::new().unwrap(); + let cert_key_pair = KeyPair::generate().unwrap(); + let csr = Csr::new( + &cert_key_pair, + &["example.com".to_string(), "www.example.com".to_string()], + vec![ + (rcgen::DnType::CommonName, "example.com"), + (rcgen::DnType::OrganizationName, "Example Org"), + ], + ) + .unwrap(); + let signed_cert: Certificate = ca.sign_csr(&csr).unwrap(); + assert!(signed_cert.pem().contains("BEGIN CERTIFICATE")); + } + + #[test] + fn test_der_to_pem() { + assert_eq!(PemLabel::Certificate.as_str(), "CERTIFICATE"); + assert_eq!(PemLabel::PrivateKey.as_str(), "PRIVATE KEY"); + assert_eq!(PemLabel::PublicKey.as_str(), "PUBLIC KEY"); + + // chunking: make sure lines are 64 chars except last + let data = vec![0u8; 200]; + let pem = der_to_pem(&data, PemLabel::PublicKey).unwrap(); + assert!(pem.starts_with("-----BEGIN PUBLIC KEY-----")); + assert!(pem.ends_with("-----END PUBLIC KEY-----")); + let inner_lines: Vec<&str> = pem + .lines() + .skip(1) + .take_while(|l| !l.starts_with("-----END")) + .collect(); + assert!(inner_lines.len() >= 2); + for (i, line) in inner_lines.iter().enumerate() { + if i + 1 < inner_lines.len() { + assert_eq!(line.len(), 64); + } else { + assert!(line.len() <= 64); + } + } + } +} diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index 018aa0b91..ca20e0e2e 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -143,6 +143,8 @@ pub struct Settings { pub gateway_disconnect_notifications_enabled: bool, pub gateway_disconnect_notifications_inactivity_threshold: i32, pub gateway_disconnect_notifications_reconnect_notification_enabled: bool, + pub ca_key_der: Option>, + pub ca_cert_der: Option>, } // Implement manually to avoid exposing the license key. @@ -249,7 +251,8 @@ impl Settings { ldap_enabled, ldap_sync_enabled, ldap_is_authoritative, \ ldap_sync_interval, ldap_user_auxiliary_obj_classes, ldap_uses_ad, \ ldap_user_rdn_attr, ldap_sync_groups, \ - openid_username_handling \"openid_username_handling: OpenidUsernameHandling\" \ + openid_username_handling \"openid_username_handling: OpenidUsernameHandling\", \ + ca_key_der, ca_cert_der \ FROM \"settings\" WHERE id = 1", ) .fetch_optional(executor) @@ -326,7 +329,9 @@ impl Settings { ldap_uses_ad = $45, \ ldap_user_rdn_attr = $46, \ ldap_sync_groups = $47, \ - openid_username_handling = $48 \ + openid_username_handling = $48, \ + ca_key_der = $49, \ + ca_cert_der = $50 \ WHERE id = 1", self.openid_enabled, self.wireguard_enabled, @@ -376,6 +381,8 @@ impl Settings { self.ldap_user_rdn_attr, &self.ldap_sync_groups as &Vec, &self.openid_username_handling as &OpenidUsernameHandling, + &self.ca_key_der as &Option>, + &self.ca_cert_der as &Option>, ) .execute(executor) .await?; diff --git a/crates/defguard_core/Cargo.toml b/crates/defguard_core/Cargo.toml index 31c179484..a38bb6e89 100644 --- a/crates/defguard_core/Cargo.toml +++ b/crates/defguard_core/Cargo.toml @@ -15,6 +15,7 @@ defguard_proto = { workspace = true } defguard_web_ui = { workspace = true } defguard_version = { workspace = true } model_derive = { workspace = true } +defguard_certs = { workspace = true } # external dependencies anyhow = { workspace = true } diff --git a/crates/defguard_core/src/grpc/mod.rs b/crates/defguard_core/src/grpc/mod.rs index 73edecd08..0f80f6d4c 100644 --- a/crates/defguard_core/src/grpc/mod.rs +++ b/crates/defguard_core/src/grpc/mod.rs @@ -5,17 +5,16 @@ use std::{ time::{Duration, Instant}, }; +use defguard_common::{ + auth::claims::ClaimsType, + db::{Id, models::Settings}, +}; use reqwest::Url; use serde::Serialize; use sqlx::PgPool; use tokio::sync::mpsc::UnboundedSender; use tonic::transport::{Identity, Server, ServerTlsConfig, server::Router}; -use defguard_common::{ - auth::claims::ClaimsType, - db::{Id, models::Settings}, -}; - use self::{auth::AuthServer, interceptor::JwtInterceptor, worker::WorkerServer}; use crate::{ auth::failed_login::FailedLoginMap, diff --git a/crates/defguard_proxy_manager/Cargo.toml b/crates/defguard_proxy_manager/Cargo.toml index 18d4acd0b..195048078 100644 --- a/crates/defguard_proxy_manager/Cargo.toml +++ b/crates/defguard_proxy_manager/Cargo.toml @@ -14,6 +14,7 @@ defguard_core.workspace = true defguard_mail.workspace = true defguard_proto.workspace = true defguard_version.workspace = true +defguard_certs.workspace = true openidconnect.workspace = true reqwest.workspace = true semver.workspace = true diff --git a/crates/defguard_proxy_manager/src/lib.rs b/crates/defguard_proxy_manager/src/lib.rs index f71b9b289..1ca2649c3 100644 --- a/crates/defguard_proxy_manager/src/lib.rs +++ b/crates/defguard_proxy_manager/src/lib.rs @@ -1,32 +1,12 @@ use std::{ collections::HashMap, - fs::read_to_string, str::FromStr, sync::{Arc, RwLock}, time::Duration, }; -use axum::http::Uri; -use openidconnect::{AuthorizationCode, Nonce, Scope, core::CoreAuthenticationFlow}; -use reqwest::Url; -use semver::Version; -use sqlx::PgPool; -use thiserror::Error; -use tokio::{ - sync::{ - broadcast::Sender, - mpsc::{self, UnboundedSender}, - }, - task::JoinSet, - time::sleep, -}; -use tokio_stream::wrappers::UnboundedReceiverStream; -use tonic::{ - Code, Streaming, - transport::{Certificate, ClientTlsConfig, Endpoint}, -}; - -use defguard_common::{VERSION, config::server_config}; +use defguard_certs::der_to_pem; +use defguard_common::{VERSION, config::server_config, db::models::Settings}; use defguard_core::{ db::models::enrollment::{ENROLLMENT_TOKEN_TYPE, Token, TokenError}, enrollment_management::clear_unused_enrollment_tokens, @@ -46,12 +26,31 @@ use defguard_core::{ }; use defguard_mail::Mail; use defguard_proto::proxy::{ - AuthCallbackResponse, AuthInfoResponse, CoreError, CoreRequest, CoreResponse, core_request, - core_response, proxy_client::ProxyClient, + AuthCallbackResponse, AuthInfoResponse, CoreError, CoreRequest, CoreResponse, DerPayload, + InitialSetupInfo, core_request, core_response, proxy_client::ProxyClient, + proxy_setup_client::ProxySetupClient, }; use defguard_version::{ ComponentInfo, DefguardComponent, client::ClientVersionInterceptor, get_tracing_variables, }; +use openidconnect::{AuthorizationCode, Nonce, Scope, core::CoreAuthenticationFlow, url}; +use reqwest::Url; +use semver::Version; +use sqlx::PgPool; +use thiserror::Error; +use tokio::{ + sync::{ + broadcast::Sender, + mpsc::{self, UnboundedSender}, + }, + task::JoinSet, + time::sleep, +}; +use tokio_stream::wrappers::UnboundedReceiverStream; +use tonic::{ + Code, Streaming, + transport::{Certificate, ClientTlsConfig, Endpoint}, +}; use crate::{enrollment::EnrollmentServer, password_reset::PasswordResetServer}; @@ -62,8 +61,25 @@ pub(crate) mod password_reset; extern crate tracing; const TEN_SECS: Duration = Duration::from_secs(10); +const PROXY_AFTER_SETUP_CONNECT_DELAY: Duration = Duration::from_secs(1); static VERSION_ZERO: Version = Version::new(0, 0, 0); +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub(crate) enum Scheme { + Http, + Https, +} + +impl Scheme { + #[must_use] + pub const fn as_str(&self) -> &str { + match self { + Self::Http => "http", + Self::Https => "https", + } + } +} + #[derive(Error, Debug)] pub enum ProxyError { #[error(transparent)] @@ -78,6 +94,18 @@ pub enum ProxyError { SqlxError(#[from] sqlx::Error), #[error(transparent)] TokenError(#[from] TokenError), + #[error(transparent)] + CertificateError(#[from] defguard_certs::CertificateError), + #[error(transparent)] + UrlParseError(#[from] url::ParseError), + #[error("Missing proxy configuration: {0}")] + MissingConfiguration(String), + #[error("URL error: {0}")] + UrlError(String), + #[error(transparent)] + Transport(#[from] tonic::Status), + #[error("Connection timeout: {0}")] + ConnectionTimeout(String), } /// Maintains routing state for proxy-specific responses by associating @@ -173,10 +201,10 @@ impl ProxyOrchestrator { }; let proxies = vec![Proxy::new( self.pool.clone(), - Uri::from_str(url)?, + Url::from_str(url)?, &self.tx, Arc::clone(&self.router), - )?]; + )]; let mut tasks = JoinSet::>::new(); for proxy in proxies { tasks.spawn(proxy.run(self.tx.clone(), self.incompatible_components.clone())); @@ -203,7 +231,7 @@ pub struct ProxyTxSet { impl ProxyTxSet { #[must_use] - pub fn new( + pub const fn new( wireguard: Sender, mail: UnboundedSender, bidi_events: UnboundedSender, @@ -225,51 +253,56 @@ impl ProxyTxSet { /// `ProxyOrchestrator`. struct Proxy { pool: PgPool, - /// Proxy server gRPC URI - endpoint: Endpoint, /// gRPC servers services: ProxyServices, /// Router shared between proxies and the orchestrator router: Arc>, + /// Proxy server gRPC URL + url: Url, } impl Proxy { - pub fn new( - pool: PgPool, - uri: Uri, - tx: &ProxyTxSet, - router: Arc>, - ) -> Result { - let endpoint = Endpoint::from(uri); + pub fn new(pool: PgPool, url: Url, tx: &ProxyTxSet, router: Arc>) -> Self { + // Instantiate gRPC servers. + let services = ProxyServices::new(&pool, tx); - // Set endpoint keep-alive to avoid connectivity issues in proxied deployments. + Self { + pool, + services, + router, + url, + } + } + + fn endpoint(&self, scheme: Scheme) -> Result { + let mut url = self.url.clone(); + + url.set_scheme(scheme.as_str()).map_err(|()| { + ProxyError::UrlError(format!("Failed to set {scheme:?} scheme on URL {url}")) + })?; + let endpoint = Endpoint::from_shared(url.to_string())?; let endpoint = endpoint .http2_keep_alive_interval(TEN_SECS) .tcp_keepalive(Some(TEN_SECS)) .keep_alive_while_idle(true); - // Setup certs. - let config = server_config(); - let endpoint = if let Some(ca) = &config.proxy_grpc_ca { - let ca = read_to_string(ca).map_err(|err| { - error!("Failed to read CA certificate: {err:?}"); - ProxyError::CaCertReadError(err) - })?; - let tls = ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca)); + let endpoint = if scheme == Scheme::Https { + let settings = Settings::get_current_settings(); + let Some(ca_cert_der) = settings.ca_cert_der else { + return Err(ProxyError::MissingConfiguration( + "Core CA is not setup, can't create a Proxy endpoint.".to_string(), + )); + }; + + let cert_pem = der_to_pem(&ca_cert_der, defguard_certs::PemLabel::Certificate)?; + let tls = ClientTlsConfig::new().ca_certificate(Certificate::from_pem(&cert_pem)); + endpoint.tls_config(tls)? } else { - endpoint.tls_config(ClientTlsConfig::new().with_enabled_roots())? + endpoint }; - // Instantiate gRPC servers. - let services = ProxyServices::new(&pool, tx); - - Ok(Self { - pool, - endpoint, - services, - router, - }) + Ok(endpoint) } /// Establishes and maintains a gRPC bidirectional stream to the proxy. @@ -283,10 +316,21 @@ impl Proxy { incompatible_components: Arc>, ) -> Result<(), ProxyError> { loop { - debug!("Connecting to proxy at {}", self.endpoint.uri()); + // TODO: When we will have proxy table, we should first check in DB if we already configured + // this proxy, and only perform initial setup if not. + if let Err(err) = self.perform_initial_setup().await { + warn!( + "Failed to perform initial Proxy setup: {err}. Will try to connect anyway as proxy may be already setup." + ); + } else { + sleep(PROXY_AFTER_SETUP_CONNECT_DELAY).await; + } + + let endpoint = self.endpoint(Scheme::Https)?; + + debug!("Connecting to proxy at {}", endpoint.uri()); let interceptor = ClientVersionInterceptor::new(Version::parse(VERSION)?); - let mut client = - ProxyClient::with_interceptor(self.endpoint.connect_lazy(), interceptor); + let mut client = ProxyClient::with_interceptor(endpoint.connect_lazy(), interceptor); let (tx, rx) = mpsc::unbounded_channel(); let response = match client.bidi(UnboundedReceiverStream::new(rx)).await { Ok(response) => response, @@ -296,14 +340,14 @@ impl Proxy { error!( "Failed to connect to proxy @ {}, version check failed, retrying in \ 10s: {err}", - self.endpoint.uri() + endpoint.uri() ); // TODO push event } err => { error!( "Failed to connect to proxy @ {}, retrying in 10s: {err}", - self.endpoint.uri() + endpoint.uri() ); } } @@ -336,13 +380,73 @@ impl Proxy { } IncompatibleComponents::remove_proxy(&incompatible_components); - info!("Connected to proxy at {}", self.endpoint.uri()); + info!("Connected to proxy at {}", endpoint.uri()); let mut resp_stream = response.into_inner(); self.message_loop(tx, tx_set.wireguard.clone(), &mut resp_stream) .await?; } } + /// Attempt to perform an initial setup of the target proxy. + /// If the proxy doesn't have signed gRPC certificates by Core yet, + /// this step will perform the signing. Otherwise, the step will be skipped + /// by instantly sending the "Done" message by both parties. + pub async fn perform_initial_setup(&self) -> Result<(), ProxyError> { + let endpoint = self.endpoint(Scheme::Http)?; + + let interceptor = ClientVersionInterceptor::new(Version::parse(VERSION)?); + let mut client = ProxySetupClient::with_interceptor(endpoint.connect_lazy(), interceptor); + let Some(hostname) = self.url.host_str() else { + return Err(ProxyError::UrlError( + "Proxy URL missing hostname".to_string(), + )); + }; + + let csr = client + .start(InitialSetupInfo { + cert_hostname: hostname.to_string(), + }) + .await? + .into_inner(); + + let csr = defguard_certs::Csr::from_der(&csr.der_data)?; + + let settings = Settings::get_current_settings(); + + let ca_cert_der = settings.ca_cert_der.ok_or_else(|| { + ProxyError::MissingConfiguration( + "CA certificate DER not found in settings for proxy gRPC bidi stream".to_string(), + ) + })?; + let ca_key_pair = settings.ca_key_der.ok_or_else(|| { + ProxyError::MissingConfiguration( + "CA key pairs DER not found in settings for proxy gRPC bidi stream".to_string(), + ) + })?; + + let ca = defguard_certs::CertificateAuthority::from_cert_der_key_pair( + &ca_cert_der, + &ca_key_pair, + )?; + + match ca.sign_csr(&csr) { + Ok(cert) => { + let response = DerPayload { + der_data: cert.der().to_vec(), + }; + client.send_cert(response).await?; + info!( + "Signed CSR received from proxy during initial setup and sent back the certificate" + ); + } + Err(err) => { + error!("Failed to sign CSR: {err}"); + } + } + + Ok(()) + } + /// Processes incoming requests from the proxy over an active gRPC stream. /// /// This loop receives `CoreRequest` messages from the proxy, dispatches @@ -794,7 +898,7 @@ impl Proxy { } } Err(err) => { - error!("Disconnected from proxy at {}: {err}", self.endpoint.uri()); + error!("Disconnected from proxy at {}: {err}", self.url); debug!("waiting 10s to re-establish the connection"); sleep(TEN_SECS).await; break 'message; diff --git a/deny.toml b/deny.toml index b6b55bf1b..4097d67ed 100644 --- a/deny.toml +++ b/deny.toml @@ -110,41 +110,57 @@ confidence-threshold = 0.8 # aren't accepted for every possible crate as with the normal allow list exceptions = [ { allow = [ - "AGPL-3.0-only", "AGPL-3.0-or-later", + "AGPL-3.0-only", + "AGPL-3.0-or-later", ], crate = "defguard" }, { allow = [ - "AGPL-3.0-only", "AGPL-3.0-or-later", + "AGPL-3.0-only", + "AGPL-3.0-or-later", ], crate = "defguard_common" }, { allow = [ - "AGPL-3.0-only", "AGPL-3.0-or-later", + "AGPL-3.0-only", + "AGPL-3.0-or-later", ], crate = "defguard_core" }, { allow = [ - "AGPL-3.0-only", "AGPL-3.0-or-later", + "AGPL-3.0-only", + "AGPL-3.0-or-later", ], crate = "defguard_mail" }, { allow = [ - "AGPL-3.0-only", "AGPL-3.0-or-later", + "AGPL-3.0-only", + "AGPL-3.0-or-later", ], crate = "defguard_proto" }, { allow = [ - "AGPL-3.0-only", "AGPL-3.0-or-later", + "AGPL-3.0-only", + "AGPL-3.0-or-later", ], crate = "defguard_web_ui" }, { allow = [ - "AGPL-3.0-only", "AGPL-3.0-or-later", + "AGPL-3.0-only", + "AGPL-3.0-or-later", ], crate = "defguard_event_router" }, { allow = [ - "AGPL-3.0-only", "AGPL-3.0-or-later", + "AGPL-3.0-only", + "AGPL-3.0-or-later", ], crate = "defguard_event_logger" }, { allow = [ - "AGPL-3.0-only", "AGPL-3.0-or-later", + "AGPL-3.0-only", + "AGPL-3.0-or-later", ], crate = "defguard_proxy_manager" }, { allow = [ - "AGPL-3.0-only", "AGPL-3.0-or-later", + "AGPL-3.0-only", + "AGPL-3.0-or-later", ], crate = "defguard_session_manager" }, { allow = [ - "AGPL-3.0-only", "AGPL-3.0-or-later", + "AGPL-3.0-only", + "AGPL-3.0-or-later", ], crate = "defguard_version" }, { allow = [ - "AGPL-3.0-only", "AGPL-3.0-or-later", + "AGPL-3.0-only", + "AGPL-3.0-or-later", ], crate = "model_derive" }, + { allow = [ + "AGPL-3.0-only", + "AGPL-3.0-or-later", + ], crate = "defguard_certs" }, ] # Some crates don't have (easily) machine readable licensing information, diff --git a/migrations/20251218140442_core_ca.down.sql b/migrations/20251218140442_core_ca.down.sql new file mode 100644 index 000000000..070c45ebb --- /dev/null +++ b/migrations/20251218140442_core_ca.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE settings DROP COLUMN ca_key_der; +ALTER TABLE settings DROP COLUMN ca_cert_der; diff --git a/migrations/20251218140442_core_ca.up.sql b/migrations/20251218140442_core_ca.up.sql new file mode 100644 index 000000000..eb1daffd0 --- /dev/null +++ b/migrations/20251218140442_core_ca.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE settings ADD COLUMN ca_key_der BYTEA DEFAULT NULL; +ALTER TABLE settings ADD COLUMN ca_cert_der BYTEA DEFAULT NULL; diff --git a/proto b/proto index c4291c96b..c48340f72 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit c4291c96beab42ab720008d996392c5bb1ea21c1 +Subproject commit c48340f72b9de3a69cf71318c75ff1361ebd7897