Relay: each relay's certificate, ordered by DNS-01 (RL-27) #269
No files matched your search
Generated
+1
@@ -1367,6 +1367,7 @@ dependencies = [
|
||||
"smtp-proto",
|
||||
"socket2 0.6.5",
|
||||
"store",
|
||||
"time",
|
||||
"tinyvec",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
|
||||
@@ -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
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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;
|
||||
|
||||
|
||||
@@ -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<RelayCertificateNeed> {
|
||||
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<i64> {
|
||||
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<Option<Domain>> {
|
||||
let hostname = hostname.trim().trim_end_matches('.').to_ascii_lowercase();
|
||||
let mut found: Option<Domain> = None;
|
||||
for id in self
|
||||
.registry()
|
||||
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Domain))
|
||||
.await?
|
||||
{
|
||||
let Some(domain) = self.registry().object::<Domain>(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<String>,
|
||||
) -> AcmeResult<PemCert> {
|
||||
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::<AcmeProvider>(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::<Vec<_>>())
|
||||
.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));
|
||||
}
|
||||
}
|
||||
@@ -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<Result<(), String>>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
let Json(order) = Json::<CertificateOrder>::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<Option<CertificateOrder>> {
|
||||
Ok(data
|
||||
.get_value::<Json<CertificateOrder>>(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<bool> {
|
||||
let current = data
|
||||
.get_value::<Versioned>(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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<Inner>, 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"))
|
||||
}
|
||||
@@ -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<u32, (String, u16)> = all
|
||||
.into_iter()
|
||||
.filter(|relay| relay.enabled)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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<String> {
|
||||
.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("[email protected]");
|
||||
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::<Vec<_>>();
|
||||
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::<Certificate>()
|
||||
.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"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user