diff --git a/Cargo.lock b/Cargo.lock index 07252fae..364aab69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1367,6 +1367,7 @@ dependencies = [ "smtp-proto", "socket2 0.6.5", "store", + "time", "tinyvec", "tokio", "tokio-rustls", diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 3c31b0fd..e581bf85 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -102,6 +102,8 @@ foundation = [] [dev-dependencies] tokio = { version = "1.53", features = ["full"] } +# inbuxa: relay certificate tests (relay spec, RL-27) +time = "0.3" [lints] workspace = true diff --git a/crates/common/src/network/acme/mod.rs b/crates/common/src/network/acme/mod.rs index 455b1d5a..dd677a80 100644 --- a/crates/common/src/network/acme/mod.rs +++ b/crates/common/src/network/acme/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ pub mod account; @@ -9,6 +11,7 @@ pub mod directory; pub mod http; pub mod jose; pub mod order; +pub mod relay; // inbuxa: relay certificates (relay spec, RL-27) pub mod renew; pub mod resolver; diff --git a/crates/common/src/network/acme/relay.rs b/crates/common/src/network/acme/relay.rs new file mode 100644 index 00000000..f1514332 --- /dev/null +++ b/crates/common/src/network/acme/relay.rs @@ -0,0 +1,219 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! A relay's SMTP certificate (relay spec, RL-27). This server orders it +//! for the relay's public name by DNS-01, through the DNS server of the +//! domain that covers the name, with that domain's ACME provider: the relay +//! never runs ACME and opens no port 80. Without such a domain, an +//! administrator uploads one. + +use crate::{ + Server, + network::acme::{ + AcmeDnsParameters, AcmeError, AcmeResult, ChallengeType, ParsedCert, PemCert, + directory::AcmeRequestBuilder, + }, +}; +use registry::schema::{ + prelude::ObjectType, + structs::{AcmeProvider, CertificateManagement, DnsManagement, Domain}, +}; +use store::registry::RegistryQuery; +use types::id::Id; + +/// A relay's certificate is renewed this long before it runs out. +pub const RELAY_RENEW_BEFORE: u64 = 30 * 86_400; + +/// Why a relay needs a certificate ordered, if it does. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RelayCertificateNeed { + Missing, + /// It doesn't name the relay's public name, which changed, say. + OtherName, + /// It runs out within [`RELAY_RENEW_BEFORE`], or has. + Expiring, +} + +impl RelayCertificateNeed { + pub fn as_str(&self) -> &'static str { + match self { + RelayCertificateNeed::Missing => "The relay has no certificate", + RelayCertificateNeed::OtherName => "The relay's certificate is for another name", + RelayCertificateNeed::Expiring => "The relay's certificate runs out within 30 days", + } + } +} + +fn names(san: &str, hostname: &str) -> bool { + let san = san.trim_end_matches('.'); + san.eq_ignore_ascii_case(hostname) + || san.strip_prefix("*.").is_some_and(|parent| { + hostname + .split_once('.') + .is_some_and(|(_, rest)| rest.eq_ignore_ascii_case(parent)) + }) +} + +/// Whether the certificate in `chain_pem` will do for `hostname` at `now`. +/// One that can't be read is as good as none. +pub fn relay_certificate_need( + chain_pem: Option<&str>, + hostname: &str, + now: u64, +) -> Option { + let hostname = hostname.trim().trim_end_matches('.'); + let Some(parsed) = chain_pem.and_then(|pem| ParsedCert::parse(pem).ok()) else { + return Some(RelayCertificateNeed::Missing); + }; + if !parsed.sans.iter().any(|san| names(san, hostname)) { + return Some(RelayCertificateNeed::OtherName); + } + let not_after = parsed.valid_not_after.timestamp().max(0) as u64; + (not_after < now + RELAY_RENEW_BEFORE).then_some(RelayCertificateNeed::Expiring) +} + +/// When the certificate in `chain_pem` runs out, if it can be read. +pub fn relay_certificate_not_after(chain_pem: &str) -> Option { + ParsedCert::parse(chain_pem) + .ok() + .map(|parsed| parsed.valid_not_after.timestamp()) +} + +impl Server { + /// The domain whose DNS and certificates this server manages that + /// covers `hostname`, the closest one if several do. + pub async fn relay_acme_domain(&self, hostname: &str) -> AcmeResult> { + let hostname = hostname.trim().trim_end_matches('.').to_ascii_lowercase(); + let mut found: Option = None; + for id in self + .registry() + .query::>(RegistryQuery::new(ObjectType::Domain)) + .await? + { + let Some(domain) = self.registry().object::(id).await? else { + continue; + }; + let name = domain.name.to_ascii_lowercase(); + let covers = hostname == name || hostname.ends_with(&format!(".{name}")); + if covers + && matches!(domain.dns_management, DnsManagement::Automatic(_)) + && matches!( + domain.certificate_management, + CertificateManagement::Automatic(_) + ) + && found + .as_ref() + .is_none_or(|f| f.name.len() < domain.name.len()) + { + found = Some(domain); + } + } + Ok(found) + } + + /// Orders a certificate for a relay's public name, by DNS-01 through + /// `domain`'s DNS server, whatever challenge its ACME provider uses for + /// the domain itself. `reuse_key_pem` keeps the relay's key. + pub async fn acme_relay_certificate( + &self, + domain: &Domain, + hostname: &str, + reuse_key_pem: Option, + ) -> AcmeResult { + let (DnsManagement::Automatic(dns), CertificateManagement::Automatic(cert)) = + (&domain.dns_management, &domain.certificate_management) + else { + return Err(AcmeError::Invalid(format!( + "{} doesn't have both its DNS and its certificates managed here", + domain.name + ))); + }; + let Some(provider) = self + .registry() + .object::(cert.acme_provider_id) + .await? + else { + return Err(AcmeError::Invalid(format!( + "ACME provider with ID {} not found", + cert.acme_provider_id + ))); + }; + let updater = self + .build_dns_updater(dns.dns_server_id) + .await? + .map_err(|err| AcmeError::Invalid(format!("Failed to build DNS updater: {err}")))?; + let mut request = AcmeRequestBuilder::new(provider).await?; + request.challenge = ChallengeType::Dns01; + let hostname = hostname.trim().trim_end_matches('.').to_ascii_lowercase(); + request + .renew( + self, + vec![hostname], + reuse_key_pem, + Some(AcmeDnsParameters { + updater, + origin: dns.origin.clone(), + }), + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rcgen::{CertificateParams, KeyPair}; + + fn certificate(names: &[&str], days: i64) -> String { + let mut params = + CertificateParams::new(names.iter().map(|n| n.to_string()).collect::>()) + .unwrap(); + let now = time::OffsetDateTime::now_utc(); + params.not_before = now - time::Duration::days(1); + params.not_after = now + time::Duration::days(days); + params + .self_signed(&KeyPair::generate().unwrap()) + .unwrap() + .pem() + } + + #[test] + fn when_a_relay_needs_a_certificate() { + let now = store::write::now(); + let host = "mx1.example.com"; + assert_eq!( + relay_certificate_need(None, host, now), + Some(RelayCertificateNeed::Missing) + ); + assert_eq!( + relay_certificate_need(Some("not a certificate"), host, now), + Some(RelayCertificateNeed::Missing) + ); + let good = certificate(&[host], 90); + assert_eq!(relay_certificate_need(Some(&good), host, now), None); + assert_eq!( + relay_certificate_need(Some(&good), "MX1.example.com.", now), + None + ); + assert_eq!( + relay_certificate_need(Some(&good), "mx2.example.com", now), + Some(RelayCertificateNeed::OtherName) + ); + let soon = certificate(&[host], 20); + assert_eq!( + relay_certificate_need(Some(&soon), host, now), + Some(RelayCertificateNeed::Expiring) + ); + // A wildcard names one level down, no more + let wild = certificate(&["*.example.com"], 90); + assert_eq!(relay_certificate_need(Some(&wild), host, now), None); + assert_eq!( + relay_certificate_need(Some(&wild), "a.mx1.example.com", now), + Some(RelayCertificateNeed::OtherName) + ); + assert!(relay_certificate_not_after(&good).is_some_and(|at| at > now as i64)); + } +} diff --git a/crates/features/src/relay/certificate.rs b/crates/features/src/relay/certificate.rs new file mode 100644 index 00000000..097db7c3 --- /dev/null +++ b/crates/features/src/relay/certificate.rs @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The last time this server ordered a relay's certificate (relay spec, +//! RL-27), kept under `c` + relay id as JSON. A node claims an order by +//! writing it first, so only one node of a cluster orders at a time, and a +//! failed order waits before the next one, as ACME rate limits ask. + +use super::{FEATURE, Json}; +use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; +use store::{ + Deserialize, SUBSPACE_INBUXA, Serialize, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue}, +}; +use trc::AddContext; + +const KIND_CERTIFICATE: u8 = b'c'; + +/// After a failed order, the next one waits this long. +pub const RETRY_AFTER_FAILURE: u64 = 6 * 3_600; +/// An order not done in this long is taken as lost, and may start again. +pub const ORDER_TIMEOUT: u64 = 3_600; + +#[derive(Debug, Clone, PartialEq, Eq, Default, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct CertificateOrder { + /// When it started. + pub at: u64, + /// Done: whether it failed, and why. Absent while it runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub done: Option>, +} + +impl CertificateOrder { + /// Whether another order may start at `now`. + pub fn may_order_again(&self, now: u64) -> bool { + let wait = match self.done { + Some(Err(_)) => RETRY_AFTER_FAILURE, + // A certificate that still won't do an hour after it was got: + // the name changed, or it was taken off + Some(Ok(())) | None => ORDER_TIMEOUT, + }; + self.at + wait <= now + } + + /// Why the last order failed, if it did. + pub fn error(&self) -> Option<&str> { + match &self.done { + Some(Err(error)) => Some(error), + _ => None, + } + } +} + +pub(super) fn certificate_class(relay_id: u32) -> ValueClass { + let mut key = Vec::with_capacity(6); + key.push(FEATURE); + key.push(KIND_CERTIFICATE); + key.extend_from_slice(&relay_id.to_be_bytes()); + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + +struct Versioned(CertificateOrder, u64); + +impl Deserialize for Versioned { + fn deserialize(bytes: &[u8]) -> trc::Result { + let Json(order) = Json::::deserialize(bytes)?; + Ok(Versioned(order, xxhash_rust::xxh3::xxh3_64(bytes))) + } +} + +/// The last order for a relay's certificate, if there was one. +pub async fn last_order(data: &Store, relay_id: u32) -> trc::Result> { + Ok(data + .get_value::>(ValueKey::from(certificate_class(relay_id))) + .await + .caused_by(trc::location!())? + .map(|Json(order)| order)) +} + +/// Starts an order for a relay's certificate, unless one is running or +/// the last one failed too recently. True if this node is to order it. +pub async fn claim_order(data: &Store, relay_id: u32, now: u64) -> trc::Result { + let current = data + .get_value::(ValueKey::from(certificate_class(relay_id))) + .await + .caused_by(trc::location!())?; + let assert = match ¤t { + Some(Versioned(order, _)) if !order.may_order_again(now) => return Ok(false), + Some(Versioned(_, version)) => AssertValue::Hash(*version), + None => AssertValue::None, + }; + let mut batch = BatchBuilder::new(); + batch.assert_value(certificate_class(relay_id), assert); + batch.set( + certificate_class(relay_id), + Json(&CertificateOrder { + at: now, + done: None, + }) + .serialize()?, + ); + match data.write(batch.build_all()).await { + Ok(_) => Ok(true), + // Another node claimed it first + Err(err) + if matches!( + err.as_ref(), + trc::EventType::Store(trc::StoreEvent::AssertValueFailed) + ) => + { + Ok(false) + } + Err(err) => Err(err.caused_by(trc::location!())), + } +} + +/// Records how a claimed order ended. +pub async fn finish_order( + data: &Store, + relay_id: u32, + at: u64, + result: Result<(), String>, +) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + batch.set( + certificate_class(relay_id), + Json(&CertificateOrder { + at, + done: Some(result), + }) + .serialize()?, + ); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn orders_wait_their_turn() { + let running = CertificateOrder { + at: 1_000, + done: None, + }; + assert!(!running.may_order_again(1_000 + ORDER_TIMEOUT - 1)); + assert!(running.may_order_again(1_000 + ORDER_TIMEOUT)); + assert_eq!(running.error(), None); + + let failed = CertificateOrder { + at: 1_000, + done: Some(Err("rate limited".into())), + }; + assert!(!failed.may_order_again(1_000 + ORDER_TIMEOUT)); + assert!(failed.may_order_again(1_000 + RETRY_AFTER_FAILURE)); + assert_eq!(failed.error(), Some("rate limited")); + + let done = CertificateOrder { + at: 1_000, + done: Some(Ok(())), + }; + assert!(done.may_order_again(1_000 + ORDER_TIMEOUT)); + assert_eq!(done.error(), None); + } +} diff --git a/crates/features/src/relay/mod.rs b/crates/features/src/relay/mod.rs index c0971564..20ef599f 100644 --- a/crates/features/src/relay/mod.rs +++ b/crates/features/src/relay/mod.rs @@ -20,11 +20,14 @@ //! - `i` + relay id (u32) + `Inbound` id: a message taken from the relay. //! - `o` + relay id (u32) + `Outbound` id: a message handed to the relay //! ([`outbound::Handed`]), as JSON. +//! - `c` + relay id (u32): the last order for the relay's certificate +//! ([`certificate::CertificateOrder`]), as JSON. //! - `d`: what the relays last put in the domains' DNS records, hashed //! ([`dns::dns_change`]). //! //! Numbers are big-endian. There are few relays, so they're read whole. +pub mod certificate; pub mod dns; pub mod link; pub mod messages; @@ -582,6 +585,7 @@ pub async fn delete(data: &Store, id: u32) -> trc::Result<()> { let nodes = link::statuses(data, id).await?; let mut batch = BatchBuilder::new(); batch.clear(class(KIND_RELAY, Some(id))); + batch.clear(certificate::certificate_class(id)); link::clear_statuses(&mut batch, id, &nodes); data.write(batch.build_all()) .await diff --git a/crates/jmap-proto/src/object/inbuxa_relay.rs b/crates/jmap-proto/src/object/inbuxa_relay.rs index fa0ffb65..0b06c34f 100644 --- a/crates/jmap-proto/src/object/inbuxa_relay.rs +++ b/crates/jmap-proto/src/object/inbuxa_relay.rs @@ -38,6 +38,10 @@ pub enum RelayProperty { SmtpCertificate, /// Reads back as `****`. SmtpPrivateKey, + /// When the stored certificate runs out, if it can be read. + CertificateNotAfter, + /// Why the last certificate order failed, if it did (RL-27). + CertificateError, /// `waiting`, `expired` or `joined`. JoinState, JoinExpiresAt, @@ -83,6 +87,8 @@ impl Property for RelayProperty { RelayProperty::Settings => "settings", RelayProperty::SmtpCertificate => "smtpCertificate", RelayProperty::SmtpPrivateKey => "smtpPrivateKey", + RelayProperty::CertificateNotAfter => "certificateNotAfter", + RelayProperty::CertificateError => "certificateError", RelayProperty::JoinState => "joinState", RelayProperty::JoinExpiresAt => "joinExpiresAt", RelayProperty::JoinedAt => "joinedAt", @@ -113,6 +119,8 @@ impl RelayProperty { b"settings" => RelayProperty::Settings, b"smtpCertificate" => RelayProperty::SmtpCertificate, b"smtpPrivateKey" => RelayProperty::SmtpPrivateKey, + b"certificateNotAfter" => RelayProperty::CertificateNotAfter, + b"certificateError" => RelayProperty::CertificateError, b"joinState" => RelayProperty::JoinState, b"joinExpiresAt" => RelayProperty::JoinExpiresAt, b"joinedAt" => RelayProperty::JoinedAt, diff --git a/crates/jmap/src/inbuxa/relay.rs b/crates/jmap/src/inbuxa/relay.rs index b28c97f0..59a7d9b9 100644 --- a/crates/jmap/src/inbuxa/relay.rs +++ b/crates/jmap/src/inbuxa/relay.rs @@ -11,8 +11,10 @@ //! over with a relay that was reset. The request layer records every //! change in the audit log, with the private key and the token left out. -use common::{Server, auth::AccessToken}; -use inbuxa_features::relay::{self as relays, JoinState, Relay as StoredRelay, link::NodeLink}; +use common::{Server, auth::AccessToken, network::acme::relay::relay_certificate_not_after}; +use inbuxa_features::relay::{ + self as relays, JoinState, Relay as StoredRelay, certificate::CertificateOrder, link::NodeLink, +}; use inbuxa_relay_proto::{b64, types::RelaySettings}; use jmap_proto::{ error::set::SetError, @@ -49,6 +51,8 @@ const ALL: &[P] = &[ P::Settings, P::SmtpCertificate, P::SmtpPrivateKey, + P::CertificateNotAfter, + P::CertificateError, P::JoinState, P::JoinExpiresAt, P::JoinedAt, @@ -62,6 +66,8 @@ const ALL: &[P] = &[ /// Properties the server sets; a client that sends them is refused. const SERVER_SET: &[P] = &[ P::Id, + P::CertificateNotAfter, + P::CertificateError, P::JoinState, P::JoinExpiresAt, P::JoinedAt, @@ -140,7 +146,13 @@ fn links_value(links: &[NodeLink]) -> RValue { json_to_value(serde_json::Value::Array(list)) } -fn to_value(relay: &StoredRelay, links: &[NodeLink], properties: &[P], now: u64) -> RValue { +fn to_value( + relay: &StoredRelay, + links: &[NodeLink], + order: Option<&CertificateOrder>, + properties: &[P], + now: u64, +) -> RValue { let state = relay.join.state(now); let mut out = Map::with_capacity(properties.len()); for property in properties { @@ -157,6 +169,12 @@ fn to_value(relay: &StoredRelay, links: &[NodeLink], properties: &[P], now: u64) P::Settings => json_to_value(serde_json::to_value(&relay.settings).unwrap_or_default()), P::SmtpCertificate => text(relay.smtp_certificate.as_deref()), P::SmtpPrivateKey => text(relay.smtp_private_key.as_ref().map(|_| MASKED_PASSWORD)), + P::CertificateNotAfter => relay + .smtp_certificate + .as_deref() + .and_then(relay_certificate_not_after) + .map_or(Value::Null, |at| date(at.max(0) as u64)), + P::CertificateError => text(order.and_then(|order| order.error())), P::JoinState => text(Some(match state { JoinState::Waiting => "waiting", JoinState::Expired => "expired", @@ -363,9 +381,14 @@ pub async fn get( } else { Vec::new() }; + let order = if properties.contains(&P::CertificateError) { + relays::certificate::last_order(data, relay.id).await? + } else { + None + }; response .list - .push(to_value(relay, &links, &properties, now)); + .push(to_value(relay, &links, order.as_ref(), &properties, now)); } Ok(response) } diff --git a/crates/services/src/inbuxa_relay_certificates.rs b/crates/services/src/inbuxa_relay_certificates.rs new file mode 100644 index 00000000..b56de13e --- /dev/null +++ b/crates/services/src/inbuxa_relay_certificates.rs @@ -0,0 +1,145 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Each relay's SMTP certificate (relay spec, RL-27). A joined relay whose +//! certificate is missing, for another name, or runs out within 30 days +//! gets one ordered by DNS-01 for its public name, when a domain whose DNS +//! and certificates this server manages covers that name. One node orders +//! it; the relay gets it with its settings over every link. Without such a +//! domain, nothing is ordered, and an administrator uploads one. + +use common::{ + BuildServer, Inner, + network::acme::relay::{RelayCertificateNeed, relay_certificate_need}, +}; +use inbuxa_features::relay::{self as relays, Relay, certificate}; +use std::sync::Arc; +use store::write::now; + +/// Orders the certificates the relays need, each in its own task. +pub(crate) async fn order_relay_certificates(inner: &Arc, all: &[Relay]) { + let server = inner.build_server(); + let data = server.store(); + let now = now(); + for relay in all + .iter() + .filter(|r| r.enabled && r.join.relay_pin.is_some()) + { + let hostname = relay.settings.hostname.trim().to_string(); + if hostname.is_empty() { + continue; + } + let Some(need) = relay_certificate_need(relay.smtp_certificate.as_deref(), &hostname, now) + else { + continue; + }; + let domain = match server.relay_acme_domain(&hostname).await { + Ok(Some(domain)) => domain, + // Nothing manages the name: an administrator uploads one + Ok(None) => continue, + Err(err) => { + trc::event!( + Acme(trc::AcmeEvent::Error), + Hostname = hostname, + Reason = err.to_string(), + Details = "Failed to look up the domain for a relay's certificate", + ); + continue; + } + }; + match certificate::claim_order(data, relay.id, now).await { + Ok(true) => {} + Ok(false) => continue, + Err(err) => { + trc::error!(err.details("Failed to claim a relay certificate order")); + continue; + } + } + // Renewing keeps the relay's key; a new name gets a new one + let reuse_key = (need == RelayCertificateNeed::Expiring) + .then(|| relay.smtp_private_key.clone()) + .flatten(); + let inner = inner.clone(); + let (relay_id, relay_name) = (relay.id, relay.name.clone()); + tokio::spawn(async move { + let server = inner.build_server(); + let data = server.store(); + trc::event!( + Acme(trc::AcmeEvent::OrderStart), + Hostname = hostname.clone(), + Domain = domain.name.clone(), + Details = format!("{} (relay {relay_name})", need.as_str()), + ); + let result = match server + .acme_relay_certificate(&domain, &hostname, reuse_key) + .await + { + Ok(pem) => install(data, relay_id, &hostname, pem.certificate, pem.private_key) + .await + .map_err(|err| err.to_string()), + Err(err) => Err(err.to_string()), + }; + match &result { + Ok(()) => { + trc::event!( + Acme(trc::AcmeEvent::OrderCompleted), + Hostname = hostname.clone(), + Domain = domain.name.clone(), + Details = format!("Installed on relay {relay_name}"), + ); + relays::changed(); + } + Err(error) => trc::event!( + Acme(trc::AcmeEvent::OrderInvalid), + Hostname = hostname.clone(), + Domain = domain.name.clone(), + Reason = error.clone(), + Details = format!("No certificate for relay {relay_name}"), + ), + } + if let Err(err) = certificate::finish_order(data, relay_id, now, result).await { + trc::error!(err.details("Failed to record a relay certificate order")); + } + }); + } +} + +/// Puts the certificate on the relay, unless its public name changed +/// while it was ordered. +async fn install( + data: &store::Store, + relay_id: u32, + hostname: &str, + chain_pem: String, + key_pem: String, +) -> trc::Result<()> { + for _ in 0..5 { + let Some((mut relay, version)) = relays::get_versioned(data, relay_id).await? else { + return Err(trc::StoreEvent::NotFound + .into_err() + .details("The relay was removed")); + }; + if !relay + .settings + .hostname + .trim() + .eq_ignore_ascii_case(hostname) + { + return Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("The relay's public name changed while its certificate was ordered")); + } + relay.smtp_certificate = Some(chain_pem.clone()); + relay.smtp_private_key = Some(key_pem.clone()); + relay.updated_at = now(); + if relays::replace(data, &relay, version).await? { + return Ok(()); + } + } + Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("The relay kept changing")) +} diff --git a/crates/services/src/inbuxa_relay_link.rs b/crates/services/src/inbuxa_relay_link.rs index 224a71d5..8cda90ff 100644 --- a/crates/services/src/inbuxa_relay_link.rs +++ b/crates/services/src/inbuxa_relay_link.rs @@ -28,7 +28,9 @@ //! linked node with the lowest id takes them. A node with no link to a //! joined relay for an hour says so with `relay.link-down` (RL-T23). -use crate::task_manager::dns::queue_relay_dns; +use crate::{ + inbuxa_relay_certificates::order_relay_certificates, task_manager::dns::queue_relay_dns, +}; use common::{ BuildServer, Inner, Server, config::server::{DEFAULT_TLS_TIMEOUT, ServerProtocol}, @@ -193,6 +195,8 @@ async fn reconcile( queue_relay_dns(server).await?; relays::dns::record_dns(data, hash).await?; } + // Their certificates, ordered when missing or running out (RL-27) + order_relay_certificates(inner, &all).await; let wanted: HashMap = all .into_iter() .filter(|relay| relay.enabled) diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index 5004d130..255e8527 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -26,6 +26,7 @@ pub mod broadcast; pub mod inbuxa_deliverability; // inbuxa: the deliverability check pub mod inbuxa_lock_expiry; pub mod inbuxa_log_retention; // inbuxa: personal-data catalog, D1 +pub mod inbuxa_relay_certificates; // inbuxa: inbuxa Relays' certificates pub mod inbuxa_relay_link; // inbuxa: links to inbuxa Relays pub mod inbuxa_scheduled_reports; // inbuxa: scheduled reports and the weekly digest pub mod state_manager; diff --git a/docs/spec/features/relay.md b/docs/spec/features/relay.md index 7f9e6bb6..d19ebb8b 100644 --- a/docs/spec/features/relay.md +++ b/docs/spec/features/relay.md @@ -507,7 +507,7 @@ When no link is up, or after RL-13: | Object | What it holds | |---|---| -| `inbuxa:Relay` | One per relay: `name`, `address`, `linkPort`, `enabled`, the settings pushed to the relay, `sendThrough` (route outbound mail through relays), `inboundOnly` (takes mail in but sends none, e.g. while its IP isn't warmed up or isn't in SPF yet), `mxPreference` (its MX preference, 10 by default, RL-25), and read-only status mirrored from the relay: link state per node, version, queue counts, `lastSeen`. Create, or an update with `rejoin: true`, returns `joinToken` once | +| `inbuxa:Relay` | One per relay: `name`, `address`, `linkPort`, `enabled`, the settings pushed to the relay, `sendThrough` (route outbound mail through relays), `inboundOnly` (takes mail in but sends none, e.g. while its IP isn't warmed up or isn't in SPF yet), `mxPreference` (its MX preference, 10 by default, RL-25), `certificateNotAfter` and `certificateError` (when its certificate runs out, and why the last order failed, RL-27), and read-only status mirrored from the relay: link state per node, version, queue counts, `lastSeen`. Create, or an update with `rejoin: true`, returns `joinToken` once | | `inbuxa:RelayMessage` | Read-only view of a relay's `Inbound` and `Outbound` queues, fetched over the link when asked. Never message content. `/get`, `/query` (filter `relayId`, `direction`; newest first) and `/set` destroy only: discards held mail, or cancels an outbound message, whose recipients fail here with no DSN as a delivery cancelled in the queue does. Each id is the relay's id and a hash of the relay's own message id, so nothing is stored | Permissions: `sysRelayGet`, `sysRelayUpdate`, `sysRelayQueueGet`, @@ -896,7 +896,31 @@ In progress. Done so far: - Tested: the joined test relay is example.com's only MX host, at preference 10 and then 20 after a change. SPF names it and this server, and the relay's host gets its own `a` policy. -- Still to come in step 5: certificates (RL-27) and upgrades (RL-29). +- Certificates (RL-27). A joined, enabled relay needs one when it has + none, when its certificate doesn't name its public name (a wildcard + names one level down), or when it runs out within 30 days. Its public + name must be under a domain whose DNS and certificates are both + Automatic here; the closest such domain is used, with its ACME + provider and its DNS server. The order is always DNS-01, whatever + challenge the provider uses for the domain itself, and is for the + public name alone, with a key of its own: never the domain's wildcard + key, which mustn't leave this server. A renewal keeps the relay's key; + a new name gets a new one. +- The link service checks at each scan, and one node orders: it claims + the order first under `c` + relay id, and a running claim lasts an + hour. A failed order waits six hours before the next, for the ACME + provider's rate limits, and its error is the relay's + `certificateError`. The certificate goes on the relay unless its + public name changed meanwhile, and every link pushes it with the + settings. Without such a domain nothing is ordered, and an + administrator uploads `smtpCertificate` and `smtpPrivateKey`. One + uploaded that still does is left alone; one that stops doing is + replaced, when a domain allows it. +- Tested against pebble: a joined relay named under a DNS-01 domain gets + a certificate for its name alone, isn't among this server's own + certificates, shows `certificateNotAfter`, isn't ordered again, and + its order record goes with it when it's removed. +- Still to come in step 5: upgrades (RL-29). Order: diff --git a/tests/src/automation/acme.rs b/tests/src/automation/acme.rs index 461bc892..f7b580d8 100644 --- a/tests/src/automation/acme.rs +++ b/tests/src/automation/acme.rs @@ -27,6 +27,7 @@ use registry::{ types::{datetime::UTCDateTime, id::ObjectId, map::Map}, }; use serde_json::json; +use std::str::FromStr; use store::{registry::write::RegistryWrite, write::now}; use x509_parser::parse_x509_certificate; use x509_parser::pem::Pem; @@ -398,6 +399,7 @@ pub async fn test(test: &TestServer) { let mut sans = certificate.subject_alternative_names.into_inner(); sans.sort(); assert_eq!(sans, vec!["*.dns.org".to_string(), "dns.org".to_string()]); + relay_certificate(test).await; account.registry_destroy_all(ObjectType::Certificate).await; account.registry_destroy_all(ObjectType::Task).await; @@ -896,3 +898,151 @@ fn top_issuer_common_name(chain: &str) -> Option { .next() .map(str::to_string) } + +/// A relay's certificate (relay spec, RL-27): a joined relay named under +/// dns.org, whose DNS and certificates are managed here, gets one for its +/// public name alone, by DNS-01, without asking. +async fn relay_certificate(test: &TestServer) { + println!("Running relay certificate tests..."); + let account = test.account("admin@example.org"); + let using = ["urn:ietf:params:jmap:core", "urn:inbuxa:jmap"]; + let response = account + .jmap_request( + &using, + json!([["inbuxa:Relay/set", { + "accountId": account.id_string(), + "create": {"r": { + "name": "Edge", + "address": "127.0.0.1", + "linkPort": 9, + "settings": {"hostname": "mx1.dns.org"} + }} + }, "0"]]), + ) + .await + .0; + let id = response + .pointer("/methodResponses/0/1/created/r/id") + .and_then(|id| id.as_str()) + .unwrap_or_else(|| panic!("relay created: {response}")) + .to_string(); + let relay_id = u32::try_from(types::id::Id::from_str(&id).unwrap().id()).unwrap(); + + // Joined: its link key pinned, as a node does when it joins + let data = test.server.store(); + let (mut relay, version) = inbuxa_features::relay::get_versioned(data, relay_id) + .await + .unwrap() + .unwrap(); + relay.join.secret = None; + relay.join.relay_pin = Some([7; 32]); + assert!( + inbuxa_features::relay::replace(data, &relay, version) + .await + .unwrap() + ); + inbuxa_features::relay::changed(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + let relay = loop { + let relay = inbuxa_features::relay::get(data, relay_id) + .await + .unwrap() + .unwrap(); + if relay.smtp_certificate.is_some() { + break relay; + } + let order = inbuxa_features::relay::certificate::last_order(data, relay_id) + .await + .unwrap(); + assert!( + order.as_ref().and_then(|o| o.error()).is_none(), + "order failed: {order:?}" + ); + assert!( + std::time::Instant::now() < deadline, + "no certificate for the relay" + ); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + }; + let pem = relay.smtp_certificate.as_deref().unwrap(); + let block = Pem::iter_from_buffer(pem.as_bytes()) + .next() + .unwrap() + .unwrap(); + let (_, cert) = parse_x509_certificate(&block.contents).unwrap(); + let names = cert + .subject_alternative_name() + .unwrap() + .unwrap() + .value + .general_names + .iter() + .map(|name| name.to_string()) + .collect::>(); + assert_eq!(names, ["DNSName(mx1.dns.org)"], "its name alone"); + assert!( + relay + .smtp_private_key + .as_deref() + .is_some_and(|key| key.contains("PRIVATE KEY")) + ); + // Not one of this server's own certificates + assert!( + account + .registry_get_all::() + .await + .into_iter() + .all(|(_, c)| !c + .subject_alternative_names + .iter() + .any(|n| n == "mx1.dns.org")) + ); + + // Shown on the relay, and nothing is ordered again + let response = account + .jmap_request( + &using, + json!([["inbuxa:Relay/get", { + "accountId": account.id_string(), + "ids": [&id], + "properties": ["certificateNotAfter", "certificateError", "smtpPrivateKey"] + }, "0"]]), + ) + .await + .0; + let got = response.pointer("/methodResponses/0/1/list/0").unwrap(); + assert!(got["certificateNotAfter"].as_str().is_some(), "{got}"); + assert_eq!(got["certificateError"], serde_json::Value::Null, "{got}"); + assert_eq!(got["smtpPrivateKey"], "****", "{got}"); + let order = inbuxa_features::relay::certificate::last_order(data, relay_id) + .await + .unwrap() + .unwrap(); + inbuxa_features::relay::changed(); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + assert_eq!( + inbuxa_features::relay::certificate::last_order(data, relay_id) + .await + .unwrap(), + Some(order), + "a good certificate isn't ordered again" + ); + + account + .jmap_request( + &using, + json!([["inbuxa:Relay/set", { + "accountId": account.id_string(), + "destroy": [&id] + }, "0"]]), + ) + .await; + assert_eq!( + inbuxa_features::relay::certificate::last_order(data, relay_id) + .await + .unwrap(), + None, + "forgotten with the relay" + ); +}