Relay: each relay's certificate, ordered by DNS-01 (RL-27) #269

Merged
jcoffey-dev merged 1 commits from relay/certificates into main 2026-10-30 23:27:25 +00:00
13 changed files with 766 additions and 7 deletions

No files matched your search

Generated
+1
View File
@@ -1367,6 +1367,7 @@ dependencies = [
"smtp-proto",
"socket2 0.6.5",
"store",
"time",
"tinyvec",
"tokio",
"tokio-rustls",
+2
View File
@@ -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
+3
View File
@@ -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;
+219
View File
@@ -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));
}
}
+175
View File
@@ -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 &current {
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);
}
}
+4
View File
@@ -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,
+27 -4
View File
@@ -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"))
}
+5 -1
View File
@@ -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)
+1
View File
@@ -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;
+26 -2
View File
@@ -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:
+150
View File
@@ -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"
);
}