From 6e6c6ff16fd120c7b07bc587b7b208791d3ca101 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 29 Oct 2026 21:41:09 -0700 Subject: [PATCH] inbuxa Relay: the downstream's inbuxa:Relay object and its permissions The first server-side part of docs/spec/features/relay.md ("Registry objects", "Joining and reset"): - inbuxa:Relay/get and /set under urn:inbuxa:jmap, kept in the fork's subspace under E (crates/features/src/relay). A relay has its name, the address and link port the downstream dials, enabled, sendThrough, inboundOnly, and the settings pushed to it, which reuse the link's own RelaySettings and are checked the way the relay would check them. Settings change one at a time as settings/; null puts one back to its default; an unknown name is refused. - A create returns the join token once. The first relay makes the downstream's keys, shared by every node: a P-256 link CA, whose key pin goes in every token, and the X25519 key held mail is sealed to. Each relay gets its own recipient key. An update with rejoin: true issues a new token for a relay that was reset, and forgets its pinned key and recipient key (the spec gains this paragraph). - smtpCertificate and smtpPrivateKey: the key reads back as ****, and **** written back keeps it. The audit log redacts any property ending in privateKey, and records a relay's before-values without its keys. - Updates are compare-and-set on the stored bytes, retried, so a node recording a join and an administrator's edit can't overwrite each other. - Four permissions, ids 690-693 (enum and schema labels): sysRelayGet/Update and sysRelayQueueGet/QueueUpdate. Administrators get all four, granted once to existing administrator roles; nobody in a tenant gets any, and a tenant is refused. - Privacy catalog entry for inbuxa:Relay. tests/src/system/relay.rs: tokens decode with one CA pin for every relay, validation, server-set properties refused, single settings and defaults, the masked key kept, the stale write refused, rejoin, a tenant administrator refused, destroy, and an audit log without the key or a token. --- Cargo.lock | 5 + crates/common/src/auth/permissions.rs | 8 + .../common/src/manager/granted_permissions.rs | 7 +- crates/features/Cargo.toml | 4 + crates/features/src/audit/diff.rs | 3 + crates/features/src/lib.rs | 1 + crates/features/src/relay/mod.rs | 690 ++++++++++++++++++ crates/jmap-proto/src/object/inbuxa_relay.rs | 240 ++++++ crates/jmap-proto/src/object/mod.rs | 1 + crates/jmap-proto/src/references/eval.rs | 3 + crates/jmap-proto/src/references/resolve.rs | 2 + crates/jmap-proto/src/request/method.rs | 7 + crates/jmap-proto/src/request/mod.rs | 2 + crates/jmap-proto/src/request/parser.rs | 15 + crates/jmap-proto/src/response/mod.rs | 14 + crates/jmap/Cargo.toml | 1 + crates/jmap/src/api/auth.rs | 11 + crates/jmap/src/api/request.rs | 26 + crates/jmap/src/changes/get.rs | 1 + crates/jmap/src/inbuxa/audit.rs | 10 + crates/jmap/src/inbuxa/mod.rs | 1 + crates/jmap/src/inbuxa/relay.rs | 464 ++++++++++++ crates/registry/src/schema/enums.rs | 5 + crates/registry/src/schema/enums_impl.rs | 14 +- docs/spec/SPEC.md | 2 +- docs/spec/features/relay.md | 17 +- resources/privacy/catalog.toml | 17 + resources/schema/schema.json.gz | Bin 154196 -> 154259 bytes resources/schema/schema.json.sha256 | 2 +- tests/Cargo.toml | 1 + tests/src/system/mod.rs | 1 + tests/src/system/relay.rs | 354 +++++++++ 32 files changed, 1923 insertions(+), 6 deletions(-) create mode 100644 crates/features/src/relay/mod.rs create mode 100644 crates/jmap-proto/src/object/inbuxa_relay.rs create mode 100644 crates/jmap/src/inbuxa/relay.rs create mode 100644 tests/src/system/relay.rs diff --git a/Cargo.lock b/Cargo.lock index 81a2b7b3..863b93aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3948,14 +3948,17 @@ version = "27.1.0-rc.4" dependencies = [ "ahash", "aho-corasick", + "aws-lc-rs", "base64 0.23.1", "chrono", "chrono-tz", "flate2", + "inbuxa-relay-proto", "jmap_proto", "mail-builder 1.0.0", "mail-parser", "quick-xml 0.41.0", + "rcgen", "regex", "registry", "serde", @@ -4261,6 +4264,7 @@ dependencies = [ "hyper", "hyper-util", "inbuxa-features", + "inbuxa-relay-proto", "jmap-tools", "jmap_proto", "mail-auth", @@ -8632,6 +8636,7 @@ dependencies = [ "imap", "imap_proto", "inbuxa-features", + "inbuxa-relay-proto", "jmap", "jmap-client", "jmap-tools", diff --git a/crates/common/src/auth/permissions.rs b/crates/common/src/auth/permissions.rs index a43dfa0e..123f369a 100644 --- a/crates/common/src/auth/permissions.rs +++ b/crates/common/src/auth/permissions.rs @@ -366,6 +366,14 @@ impl Default for DefaultPermissions { default.superuser.push(permission); } Permission::SysJournalSearch | Permission::SysJournalExport => {} + // inbuxa: a relay serves the whole downstream, never a + // tenant (relay spec, "Registry objects") + Permission::SysRelayGet + | Permission::SysRelayUpdate + | Permission::SysRelayQueueGet + | Permission::SysRelayQueueUpdate => { + default.superuser.push(permission); + } // inbuxa: AL-12: tenant administrators lock and delegate // within their tenant Permission::SysAccountLockGet diff --git a/crates/common/src/manager/granted_permissions.rs b/crates/common/src/manager/granted_permissions.rs index cc0bd204..2e273540 100644 --- a/crates/common/src/manager/granted_permissions.rs +++ b/crates/common/src/manager/granted_permissions.rs @@ -33,7 +33,8 @@ use types::id::Id; /// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and /// the data inventory (personal-data catalog spec), accepting security /// to-do items (security to-do list spec), the deliverability check -/// (deliverability spec), and scheduled reports (scheduled-reports spec). +/// (deliverability spec), scheduled reports (scheduled-reports spec), and +/// inbuxa Relays (relay spec). const ADMIN_GRANTS: &[Permission] = &[ Permission::SysAiExplain, Permission::SysAuditGet, @@ -62,6 +63,10 @@ const ADMIN_GRANTS: &[Permission] = &[ Permission::SysDeliverabilityCheck, Permission::SysScheduledReportGet, Permission::SysScheduledReportUpdate, + Permission::SysRelayGet, + Permission::SysRelayUpdate, + Permission::SysRelayQueueGet, + Permission::SysRelayQueueUpdate, ]; /// Granted to the server-level Compliance Officer role once it exists: diff --git a/crates/features/Cargo.toml b/crates/features/Cargo.toml index a9bc7178..1a9b1af1 100644 --- a/crates/features/Cargo.toml +++ b/crates/features/Cargo.toml @@ -31,6 +31,10 @@ mail-builder = { version = "1.0" } # inbuxa: scheduled reports run at a local time (scheduled-reports spec, RP-15) chrono = { version = "0.4", default-features = false, features = ["std"] } chrono-tz = "0.10" +# inbuxa: the downstream side of inbuxa Relay (relay spec) +inbuxa-relay-proto = { path = "../relay-proto" } +aws-lc-rs = { version = "1" } +rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "aws_lc_rs"] } [dev-dependencies] tokio = { version = "1.53", features = ["macros", "rt"] } diff --git a/crates/features/src/audit/diff.rs b/crates/features/src/audit/diff.rs index 99be9884..55239829 100644 --- a/crates/features/src/audit/diff.rs +++ b/crates/features/src/audit/diff.rs @@ -34,6 +34,7 @@ pub fn is_secret(object: &str, property: &str) -> bool { .any(|name| lower == name.to_ascii_lowercase()) || lower.ends_with("secret") || lower.ends_with("password") + || lower.ends_with("privatekey") || schema::embedded() .and_then(|schema| schema.property(object, property)) .is_some_and(|info| info.secret) @@ -166,6 +167,8 @@ mod tests { assert!(!text.contains("old-key")); // Unchanged secrets aren't mentioned at all assert!(diff("x:AiModel", Some(&before), Some(&before)).is_empty()); + // A private key by any name, like a relay's STARTTLS key + assert!(is_secret("inbuxa:Relay", "smtpPrivateKey")); } #[test] diff --git a/crates/features/src/lib.rs b/crates/features/src/lib.rs index da86f415..35d8be65 100644 --- a/crates/features/src/lib.rs +++ b/crates/features/src/lib.rs @@ -28,6 +28,7 @@ pub mod lock; pub mod mailflow; pub mod masked_email; pub mod privacy; +pub mod relay; // inbuxa: the downstream side of inbuxa Relay (not a rebuild) pub mod scheduled_reports; // inbuxa: scheduled reports and the weekly digest (not a rebuild) pub mod security; pub mod tenancy; diff --git a/crates/features/src/relay/mod.rs b/crates/features/src/relay/mod.rs new file mode 100644 index 00000000..8e79028b --- /dev/null +++ b/crates/features/src/relay/mod.rs @@ -0,0 +1,690 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! inbuxa Relay, the downstream's side (docs/spec/features/relay.md, "The +//! downstream"): the `inbuxa:Relay` objects, and the keys every node of +//! this server or cluster shares to link to them. +//! +//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`), never in the +//! registry, so an upstream schema import never touches them. Every key +//! starts with `E` (edge), then one byte for the kind: +//! +//! - `k`: the downstream's keys ([`DownstreamKeys`]), as JSON. One per +//! server or cluster, made with the first relay. +//! - `r` + relay id (u32): a relay ([`Relay`]), as JSON. +//! +//! Numbers are big-endian. There are few relays, so they're read whole. + +use inbuxa_relay_proto::{ + b64, + pin::{Pin, spki_pin}, + seal, + token::JoinToken, + types::RelaySettings, +}; +use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize, de::DeserializeOwned}; +use store::{ + Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey, + write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue}, +}; +use trc::AddContext; +use types::id::Id; + +const FEATURE: u8 = b'E'; +const KIND_KEYS: u8 = b'k'; +const KIND_RELAY: u8 = b'r'; +const CREATE_ATTEMPTS: usize = 5; + +const MAX_NAME: usize = 100; +const MAX_DESCRIPTION: usize = 2_000; +const MAX_PEM: usize = 64 * 1024; +const MAX_DOMAINS: usize = 5_000; + +/// The keys every node shares: the link CA that issues each node's link +/// client certificate (RL-6), and the key held mail is sealed to (RL-21). +/// Made once, with the first relay, and never shown. +#[derive(Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct DownstreamKeys { + pub link_ca_key_pem: String, + pub link_ca_cert_pem: String, + /// The pin of the link CA's key, which every relay keeps. + #[serde(with = "b64")] + pub link_ca_pin: Pin, + #[serde(with = "b64")] + pub sealing_secret: [u8; 32], + #[serde(with = "b64")] + pub sealing_public: [u8; 32], + pub created_at: u64, +} + +impl std::fmt::Debug for DownstreamKeys { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DownstreamKeys") + .field("created_at", &self.created_at) + .finish_non_exhaustive() + } +} + +/// Where a relay is in joining this downstream (relay spec, "Joining and +/// reset"). +#[derive(Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Join { + /// The token's secret, until the join is done. + #[serde(with = "b64::option", skip_serializing_if = "Option::is_none")] + pub secret: Option<[u8; 32]>, + pub expires_at: u64, + /// The relay's link key, pinned once its proof checked out (RL-7). + #[serde(with = "b64::option", skip_serializing_if = "Option::is_none")] + pub relay_pin: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub joined_at: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize)] +#[serde(rename_all = "camelCase")] +pub enum JoinState { + /// The token is out and the relay hasn't finished joining. + Waiting, + /// The token ran out before the relay joined: it needs a new one. + Expired, + Joined, +} + +impl Join { + pub fn state(&self, now: u64) -> JoinState { + if self.relay_pin.is_some() { + JoinState::Joined + } else if self.expires_at > now { + JoinState::Waiting + } else { + JoinState::Expired + } + } +} + +/// One `inbuxa:Relay`. +#[derive(Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)] +#[serde(rename_all = "camelCase")] +pub struct Relay { + #[serde(default)] + pub id: u32, + pub name: String, + #[serde(default)] + pub description: String, + /// The host the downstream dials. + pub address: String, + #[serde(default = "default_link_port")] + pub link_port: u16, + #[serde(default = "yes")] + pub enabled: bool, + /// Route outbound mail through relays (RL-24). + #[serde(default)] + pub send_through: bool, + /// Takes mail in but sends none (RL-20). + #[serde(default)] + pub inbound_only: bool, + /// What the downstream pushes to the relay, but for the certificate and + /// the keys, which it adds itself. Empty `domains` means every domain + /// this server has. + #[serde(default)] + pub settings: RelaySettings, + /// The STARTTLS certificate chain for the relay's port 25, PEM. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub smtp_certificate: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub smtp_private_key: Option, + /// The key this relay's recipient hashes are made with (RL-11). + #[serde(with = "b64", default)] + pub recipient_key: [u8; 32], + #[serde(default)] + pub join: Join, + #[serde(default)] + pub created_by: String, + #[serde(default)] + pub created_at: u64, + #[serde(default)] + pub updated_at: u64, +} + +impl std::fmt::Debug for Relay { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Relay") + .field("id", &self.id) + .field("name", &self.name) + .field("address", &self.address) + .field("link_port", &self.link_port) + .field("enabled", &self.enabled) + .finish_non_exhaustive() + } +} + +fn default_link_port() -> u16 { + 443 +} + +fn yes() -> bool { + true +} + +/// Why a relay can't be saved: the property at fault, and what's wrong. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Invalid { + pub property: &'static str, + pub reason: String, +} + +fn invalid(property: &'static str, reason: impl Into) -> Result<(), Invalid> { + Err(Invalid { + property, + reason: reason.into(), + }) +} + +/// A host name, loosely: labels of letters, digits and hyphens. +fn is_host_name(name: &str) -> bool { + name.len() <= 253 + && name.contains('.') + && name.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + }) +} + +fn is_pem(text: &str, label: &str) -> bool { + text.contains(&format!("-----BEGIN {label}")) && text.contains("-----END ") +} + +impl Relay { + /// Checks what an administrator sets. The keys are the server's and + /// aren't checked here. + pub fn validate(&self) -> Result<(), Invalid> { + let name = self.name.trim(); + if name.is_empty() || name.len() > MAX_NAME { + return invalid("name", format!("A name of 1 to {MAX_NAME} characters.")); + } + if self.description.len() > MAX_DESCRIPTION { + return invalid("description", "The description is too long."); + } + let address = self.address.trim(); + if !(is_host_name(address) || address.parse::().is_ok()) { + return invalid( + "address", + "The relay's host name or IP address, without a port or scheme.", + ); + } + if self.link_port == 0 { + return invalid("linkPort", "A port from 1 to 65535."); + } + if self.send_through && self.inbound_only { + return invalid( + "inboundOnly", + "A relay that sends no mail can't have mail sent through it.", + ); + } + let settings = &self.settings; + if settings.smtp_certificate.is_some() + || settings.sealing_key.is_some() + || settings.recipient_key.is_some() + { + return invalid( + "settings", + "The certificate is set with smtpCertificate, and the keys are the server's.", + ); + } + if !is_host_name(&settings.hostname) { + return invalid( + "settings", + "hostname: the relay's public name, which its MX record points at.", + ); + } + if settings.domains.len() > MAX_DOMAINS + || settings.domains.iter().any(|domain| !is_host_name(domain)) + { + return invalid( + "settings", + "domains: domain names, or none for every domain on this server.", + ); + } + // What the relay itself would refuse, with the parts the server + // fills in (the keys, and the domains if none are named) in place + let mut check = settings.clone(); + check.sealing_key = Some([0; 32]); + check.recipient_key = Some([0; 32]); + if check.domains.is_empty() { + check.domains.push("example.com".into()); + } + if let Some(problem) = check.problems().into_iter().next() { + return invalid("settings", problem); + } + match (&self.smtp_certificate, &self.smtp_private_key) { + (None, None) => {} + (Some(chain), Some(key)) => { + if chain.len() > MAX_PEM || !is_pem(chain, "CERTIFICATE") { + return invalid("smtpCertificate", "A PEM certificate chain."); + } + if key.len() > MAX_PEM || !is_pem(key, "") || !key.contains("PRIVATE KEY") { + return invalid("smtpPrivateKey", "A PEM private key."); + } + } + (Some(_), None) => { + return invalid("smtpPrivateKey", "The certificate needs its private key."); + } + (None, Some(_)) => { + return invalid("smtpCertificate", "The private key needs its certificate."); + } + } + Ok(()) + } + + /// What the audit log may show of a relay: what an administrator sets, + /// with the private key only as whether there is one. + pub fn audit_view(&self) -> serde_json::Value { + serde_json::json!({ + "name": self.name, + "description": self.description, + "address": self.address, + "linkPort": self.link_port, + "enabled": self.enabled, + "sendThrough": self.send_through, + "inboundOnly": self.inbound_only, + "settings": self.settings, + "smtpCertificate": self.smtp_certificate, + }) + } + + /// The id a relay knows itself by: the object's JMAP id. + pub fn relay_id(&self) -> String { + Id::from(self.id).to_string() + } + + /// Starts a join (relay spec, "Joining and reset", step 1): a new token, + /// a new recipient key, and any pinned key forgotten. Returns the token + /// to show the administrator once. + pub fn start_join(&mut self, keys: &DownstreamKeys, now: u64) -> String { + let token = JoinToken::issue(self.relay_id(), keys.link_ca_pin, now); + self.recipient_key = random_key(); + self.join = Join { + secret: Some(token.secret), + expires_at: token.expires_at, + relay_pin: None, + joined_at: None, + }; + token.encode() + } +} + +/// 32 bytes from the system's random source. +pub fn random_key() -> [u8; 32] { + use aws_lc_rs::rand::SecureRandom; + let mut key = [0u8; 32]; + aws_lc_rs::rand::SystemRandom::new() + .fill(&mut key) + .expect("system random source"); + key +} + +/// New downstream keys: a P-256 link CA, and an X25519 sealing key pair. +pub fn new_downstream_keys(now: u64) -> trc::Result { + let failed = |err: rcgen::Error| { + trc::EventType::Server(trc::ServerEvent::ThreadError) + .into_err() + .details("Could not make the relay link CA") + .reason(err) + }; + let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).map_err(failed)?; + let mut params = rcgen::CertificateParams::new(Vec::::new()).map_err(failed)?; + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Constrained(0)); + params + .distinguished_name + .push(rcgen::DnType::CommonName, "inbuxa relay link CA"); + params.key_usages = vec![ + rcgen::KeyUsagePurpose::KeyCertSign, + rcgen::KeyUsagePurpose::CrlSign, + ]; + // The relay pins the key, not the certificate; the dates only have to + // cover the downstream's life + params.not_before = rcgen::date_time_ymd(2026, 1, 1); + params.not_after = rcgen::date_time_ymd(2126, 1, 1); + let cert = params.self_signed(&key).map_err(failed)?; + let (sealing_secret, sealing_public) = seal::generate_keypair(); + Ok(DownstreamKeys { + link_ca_pin: spki_pin(&rcgen::PublicKeyData::subject_public_key_info(&key)), + link_ca_key_pem: key.serialize_pem(), + link_ca_cert_pem: cert.pem(), + sealing_secret: *sealing_secret, + sealing_public, + created_at: now, + }) +} + +struct Json(T); + +impl Serialize for Json<&T> { + fn serialize(&self) -> trc::Result> { + serde_json::to_vec(self.0).map_err(|err| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("Could not serialize a relay") + .reason(err) + }) + } +} + +impl Deserialize for Json { + fn deserialize(bytes: &[u8]) -> trc::Result { + serde_json::from_slice(bytes).map(Json).map_err(|err| { + trc::StoreEvent::DataCorruption + .into_err() + .details("Invalid relay") + .reason(err) + }) + } +} + +fn class(kind: u8, id: Option) -> ValueClass { + let mut key = Vec::with_capacity(6); + key.push(FEATURE); + key.push(kind); + if let Some(id) = id { + key.extend_from_slice(&id.to_be_bytes()); + } + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key, + }) +} + +fn relay_key(id: u32) -> ValueKey { + ValueKey::from(class(KIND_RELAY, Some(id))) +} + +/// The downstream's keys, if a relay was ever added. +pub async fn keys(data: &Store) -> trc::Result> { + Ok(data + .get_value::>(ValueKey::from(class(KIND_KEYS, None))) + .await + .caused_by(trc::location!())? + .map(|Json(keys)| keys)) +} + +/// The downstream's keys, made now if this is the first relay. Two nodes +/// making them at once keep the same ones: the key must be absent. +pub async fn keys_or_new(data: &Store, now: u64) -> trc::Result { + if let Some(keys) = keys(data).await? { + return Ok(keys); + } + let fresh = new_downstream_keys(now)?; + let mut batch = BatchBuilder::new(); + batch.assert_value(class(KIND_KEYS, None), AssertValue::None); + batch.set(class(KIND_KEYS, None), Json(&fresh).serialize()?); + match data.write(batch.build_all()).await { + Ok(_) => Ok(fresh), + Err(err) + if matches!( + err.as_ref(), + trc::EventType::Store(trc::StoreEvent::AssertValueFailed) + ) => + { + keys(data).await?.ok_or_else(|| { + trc::StoreEvent::UnexpectedError + .into_err() + .details("The relay keys vanished") + }) + } + Err(err) => Err(err.caused_by(trc::location!())), + } +} + +pub async fn get(data: &Store, id: u32) -> trc::Result> { + Ok(data + .get_value::>(relay_key(id)) + .await + .caused_by(trc::location!())? + .map(|Json(relay)| relay)) +} + +/// Every relay, oldest first. +pub async fn all(data: &Store) -> trc::Result> { + let mut relays = Vec::new(); + data.iterate( + IterateParams::new(relay_key(0), relay_key(u32::MAX)), + |_, value| { + if let Ok(Json(relay)) = Json::::deserialize(value) { + relays.push(relay); + } + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + relays.sort_by_key(|relay| relay.id); + Ok(relays) +} + +/// Writes a new relay under the next free id and starts its join. Returns +/// the stored relay and its join token. Two nodes creating relays at once +/// can't take the same id: the key must be absent. +pub async fn create(data: &Store, relay: &Relay, now: u64) -> trc::Result<(Relay, String)> { + let keys = keys_or_new(data, now).await?; + let mut attempt = 0; + loop { + attempt += 1; + let id = all(data).await?.iter().map(|r| r.id).max().unwrap_or(0) + 1; + let mut stored = Relay { + id, + ..relay.clone() + }; + let token = stored.start_join(&keys, now); + let mut batch = BatchBuilder::new(); + batch.assert_value(class(KIND_RELAY, Some(id)), AssertValue::None); + batch.set(class(KIND_RELAY, Some(id)), Json(&stored).serialize()?); + match data.write(batch.build_all()).await { + Ok(_) => return Ok((stored, token)), + Err(err) + if attempt < CREATE_ATTEMPTS + && matches!( + err.as_ref(), + trc::EventType::Store(trc::StoreEvent::AssertValueFailed) + ) => {} + Err(err) => return Err(err.caused_by(trc::location!())), + } + } +} + +/// A relay as stored, with the version [`replace`] checks: a hash of the +/// stored bytes. +struct Versioned(Relay, u64); + +impl Deserialize for Versioned { + fn deserialize(bytes: &[u8]) -> trc::Result { + let Json(relay) = Json::::deserialize(bytes)?; + Ok(Versioned(relay, xxhash_rust::xxh3::xxh3_64(bytes))) + } +} + +/// A relay and its version, for a change made with [`replace`]. +pub async fn get_versioned(data: &Store, id: u32) -> trc::Result> { + Ok(data + .get_value::(relay_key(id)) + .await + .caused_by(trc::location!())? + .map(|Versioned(relay, version)| (relay, version))) +} + +/// Replaces a stored relay (same id) if it is still at `version`. False if +/// something else changed it first, such as a node recording the join +/// while an administrator edits the relay: read it again and retry. +pub async fn replace(data: &Store, relay: &Relay, version: u64) -> trc::Result { + let mut batch = BatchBuilder::new(); + batch.assert_value( + class(KIND_RELAY, Some(relay.id)), + AssertValue::Hash(version), + ); + batch.set(class(KIND_RELAY, Some(relay.id)), Json(relay).serialize()?); + match data.write(batch.build_all()).await { + Ok(_) => Ok(true), + Err(err) + if matches!( + err.as_ref(), + trc::EventType::Store(trc::StoreEvent::AssertValueFailed) + ) => + { + Ok(false) + } + Err(err) => Err(err.caused_by(trc::location!())), + } +} + +pub async fn delete(data: &Store, id: u32) -> trc::Result<()> { + let mut batch = BatchBuilder::new(); + batch.clear(class(KIND_RELAY, Some(id))); + data.write(batch.build_all()) + .await + .caused_by(trc::location!())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use inbuxa_relay_proto::{time, token::JoinToken}; + + fn relay() -> Relay { + Relay { + id: 3, + name: "Edge one".into(), + description: String::new(), + address: "relay1.example.com".into(), + link_port: 443, + enabled: true, + send_through: false, + inbound_only: false, + settings: RelaySettings { + hostname: "mx1.example.com".into(), + ..Default::default() + }, + smtp_certificate: None, + smtp_private_key: None, + recipient_key: [0; 32], + join: Join::default(), + created_by: String::new(), + created_at: 0, + updated_at: 0, + } + } + + #[test] + fn validates() { + assert_eq!(relay().validate(), Ok(())); + let bad = |change: fn(&mut Relay)| { + let mut r = relay(); + change(&mut r); + r.validate().unwrap_err().property + }; + assert_eq!(bad(|r| r.name = " ".into()), "name"); + assert_eq!( + bad(|r| r.address = "https://relay1.example.com".into()), + "address" + ); + assert_eq!( + bad(|r| r.address = "relay1.example.com:443".into()), + "address" + ); + assert_eq!(bad(|r| r.link_port = 0), "linkPort"); + assert_eq!( + bad(|r| { + r.send_through = true; + r.inbound_only = true; + }), + "inboundOnly" + ); + assert_eq!(bad(|r| r.settings.hostname = "mx1".into()), "settings"); + assert_eq!( + bad(|r| r.settings.domains = vec!["not a domain".into()]), + "settings" + ); + assert_eq!(bad(|r| r.settings.held_max_age = 91), "settings"); + assert_eq!(bad(|r| r.settings.sealing_key = Some([1; 32])), "settings"); + assert_eq!( + bad(|r| r.smtp_certificate = Some("x".into())), + "smtpPrivateKey" + ); + + let mut ip = relay(); + ip.address = "2001:db8::25".into(); + assert_eq!(ip.validate(), Ok(())); + } + + #[test] + fn joins() { + let now = time::now(); + let keys = new_downstream_keys(now).unwrap(); + let mut r = relay(); + let token = r.start_join(&keys, now); + let decoded = JoinToken::decode(&token, now).unwrap(); + assert_eq!(decoded.relay_id, Id::from(3u32).to_string()); + assert_eq!(decoded.ca_spki, keys.link_ca_pin); + assert_eq!(Some(decoded.secret), r.join.secret); + assert_eq!(r.join.state(now), JoinState::Waiting); + assert_eq!(r.join.state(decoded.expires_at), JoinState::Expired); + assert_ne!(r.recipient_key, [0; 32]); + + // A new join forgets the pinned key and the old recipient key + r.join.relay_pin = Some([7; 32]); + assert_eq!(r.join.state(decoded.expires_at), JoinState::Joined); + let old_key = r.recipient_key; + let second = r.start_join(&keys, now); + assert_ne!(second, token); + assert_eq!(r.join.relay_pin, None); + assert_ne!(r.recipient_key, old_key); + + // The pin is of the key inside the CA certificate the relay sees + use base64::Engine; + let der = base64::engine::general_purpose::STANDARD + .decode( + keys.link_ca_cert_pem + .lines() + .filter(|line| !line.starts_with("-----")) + .collect::(), + ) + .unwrap(); + let ca_key = rcgen::KeyPair::from_pem(&keys.link_ca_key_pem).unwrap(); + let spki = rcgen::PublicKeyData::subject_public_key_info(&ca_key); + assert!(der.windows(spki.len()).any(|w| w == spki)); + assert_eq!(spki_pin(&spki), keys.link_ca_pin); + + // The sealing key opens what's sealed to its public half + let sealed = seal::seal(&keys.sealing_public, b"aad", b"hello").unwrap(); + assert_eq!( + seal::open(&keys.sealing_secret, b"aad", &sealed).unwrap(), + b"hello" + ); + } + + #[test] + fn stored_form_round_trips() { + let now = time::now(); + let keys = new_downstream_keys(now).unwrap(); + let mut r = relay(); + r.start_join(&keys, now); + let bytes = Json(&r).serialize().unwrap(); + let Json(back) = Json::::deserialize(&bytes).unwrap(); + assert!(back == r); + let bytes = Json(&keys).serialize().unwrap(); + let Json(back) = Json::::deserialize(&bytes).unwrap(); + assert!(back == keys); + // Neither shows its secrets when printed + assert!(!format!("{keys:?}").contains("PRIVATE")); + assert!(!format!("{r:?}").contains("recipient")); + } +} diff --git a/crates/jmap-proto/src/object/inbuxa_relay.rs b/crates/jmap-proto/src/object/inbuxa_relay.rs new file mode 100644 index 00000000..c7584249 --- /dev/null +++ b/crates/jmap-proto/src/object/inbuxa_relay.rs @@ -0,0 +1,240 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:Relay/get` and `/set` under `urn:inbuxa:jmap`: the inbuxa Relays +//! this server or cluster links to (docs/spec/features/relay.md, "Registry +//! objects"). A create returns the join token, once. The set call's +//! `reason` argument, if given, goes into the audit log with the change. + +use crate::{ + object::{AnyId, JmapObject, JmapObjectId}, + request::deserialize::DeserializeArguments, +}; +use jmap_tools::{Element, Key, Property}; +use std::{borrow::Cow, str::FromStr}; +use types::id::Id; + +#[derive(Debug, Clone, Default)] +pub struct Relay; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RelayProperty { + Id, + Name, + Description, + /// The host the downstream dials. + Address, + LinkPort, + Enabled, + SendThrough, + InboundOnly, + /// What is pushed to the relay. + Settings, + SmtpCertificate, + /// Reads back as `****`. + SmtpPrivateKey, + /// `waiting`, `expired` or `joined`. + JoinState, + JoinExpiresAt, + JoinedAt, + /// The relay's link key pin, once joined. + LinkKeyPin, + /// Update only: `true` starts a new join, with a new token. + Rejoin, + /// In a create or rejoin response only. + JoinToken, + CreatedBy, + CreatedAt, + UpdatedAt, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RelayValue { + Id(Id), +} + +impl Property for RelayProperty { + fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option { + // Keys inside settings stay plain keys + match parent { + None => RelayProperty::parse(value), + Some(_) => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + RelayProperty::Id => "id", + RelayProperty::Name => "name", + RelayProperty::Description => "description", + RelayProperty::Address => "address", + RelayProperty::LinkPort => "linkPort", + RelayProperty::Enabled => "enabled", + RelayProperty::SendThrough => "sendThrough", + RelayProperty::InboundOnly => "inboundOnly", + RelayProperty::Settings => "settings", + RelayProperty::SmtpCertificate => "smtpCertificate", + RelayProperty::SmtpPrivateKey => "smtpPrivateKey", + RelayProperty::JoinState => "joinState", + RelayProperty::JoinExpiresAt => "joinExpiresAt", + RelayProperty::JoinedAt => "joinedAt", + RelayProperty::LinkKeyPin => "linkKeyPin", + RelayProperty::Rejoin => "rejoin", + RelayProperty::JoinToken => "joinToken", + RelayProperty::CreatedBy => "createdBy", + RelayProperty::CreatedAt => "createdAt", + RelayProperty::UpdatedAt => "updatedAt", + } + .into() + } +} + +impl RelayProperty { + fn parse(value: &str) -> Option { + hashify::tiny_map!(value.as_bytes(), + b"id" => RelayProperty::Id, + b"name" => RelayProperty::Name, + b"description" => RelayProperty::Description, + b"address" => RelayProperty::Address, + b"linkPort" => RelayProperty::LinkPort, + b"enabled" => RelayProperty::Enabled, + b"sendThrough" => RelayProperty::SendThrough, + b"inboundOnly" => RelayProperty::InboundOnly, + b"settings" => RelayProperty::Settings, + b"smtpCertificate" => RelayProperty::SmtpCertificate, + b"smtpPrivateKey" => RelayProperty::SmtpPrivateKey, + b"joinState" => RelayProperty::JoinState, + b"joinExpiresAt" => RelayProperty::JoinExpiresAt, + b"joinedAt" => RelayProperty::JoinedAt, + b"linkKeyPin" => RelayProperty::LinkKeyPin, + b"rejoin" => RelayProperty::Rejoin, + b"joinToken" => RelayProperty::JoinToken, + b"createdBy" => RelayProperty::CreatedBy, + b"createdAt" => RelayProperty::CreatedAt, + b"updatedAt" => RelayProperty::UpdatedAt, + ) + } +} + +impl FromStr for RelayProperty { + type Err = (); + + fn from_str(s: &str) -> Result { + RelayProperty::parse(s).ok_or(()) + } +} + +impl Element for RelayValue { + type Property = RelayProperty; + + fn try_parse

(key: &Key<'_, Self::Property>, value: &str) -> Option { + match key { + Key::Property(RelayProperty::Id) => Id::from_str(value).ok().map(RelayValue::Id), + _ => None, + } + } + + fn to_cow(&self) -> Cow<'static, str> { + match self { + RelayValue::Id(id) => id.to_string().into(), + } + } +} + +/// The set call's own argument: why, for the audit log. +#[derive(Debug, Clone, Default)] +pub struct RelaySetArguments { + pub reason: Option, +} + +impl<'de> DeserializeArguments<'de> for RelaySetArguments { + fn deserialize_argument(&mut self, key: &str, map: &mut A) -> Result<(), A::Error> + where + A: serde::de::MapAccess<'de>, + { + if key == "reason" { + self.reason = map.next_value()?; + } else { + let _ = map.next_value::()?; + } + Ok(()) + } +} + +impl JmapObject for Relay { + type Property = RelayProperty; + + type Element = RelayValue; + + type Id = Id; + + type Filter = (); + + type Comparator = (); + + type GetArguments = (); + + type SetArguments<'de> = RelaySetArguments; + + type QueryArguments = (); + + type CopyArguments = (); + + type ParseArguments = (); + + const ID_PROPERTY: Self::Property = RelayProperty::Id; +} + +impl From for RelayValue { + fn from(id: Id) -> Self { + RelayValue::Id(id) + } +} + +impl JmapObjectId for RelayValue { + fn as_id(&self) -> Option { + match self { + RelayValue::Id(id) => Some(*id), + } + } + + fn as_any_id(&self) -> Option { + match self { + RelayValue::Id(id) => Some(AnyId::Id(*id)), + } + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, new_id: AnyId) -> bool { + if let AnyId::Id(id) = new_id { + *self = RelayValue::Id(id); + true + } else { + false + } + } +} + +impl JmapObjectId for RelayProperty { + fn as_id(&self) -> Option { + None + } + + fn as_any_id(&self) -> Option { + None + } + + fn as_id_ref(&self) -> Option<&str> { + None + } + + fn try_set_id(&mut self, _: AnyId) -> bool { + false + } +} diff --git a/crates/jmap-proto/src/object/mod.rs b/crates/jmap-proto/src/object/mod.rs index 647a63e6..ad828cd8 100644 --- a/crates/jmap-proto/src/object/mod.rs +++ b/crates/jmap-proto/src/object/mod.rs @@ -42,6 +42,7 @@ pub mod inbuxa_legal_hold; // inbuxa: legal hold pub mod inbuxa_log_settings; // inbuxa: personal-data catalog, D1 pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off +pub mod inbuxa_relay; // inbuxa: inbuxa Relay pub mod inbuxa_report_export; // inbuxa: scheduled reports, RP-19 pub mod inbuxa_scheduled_report; // inbuxa: scheduled reports pub mod inbuxa_scheduled_report_settings; // inbuxa: scheduled reports diff --git a/crates/jmap-proto/src/references/eval.rs b/crates/jmap-proto/src/references/eval.rs index cff58a46..193e234b 100644 --- a/crates/jmap-proto/src/references/eval.rs +++ b/crates/jmap-proto/src/references/eval.rs @@ -88,6 +88,9 @@ impl Response<'_> { GetResponseMethod::MailRule(response) => { response.eval_jptr(path, &mut results) } + GetResponseMethod::Relay(response) => { + response.eval_jptr(path, &mut results) + } GetResponseMethod::SecurityAcceptance(response) => { response.eval_jptr(path, &mut results) } diff --git a/crates/jmap-proto/src/references/resolve.rs b/crates/jmap-proto/src/references/resolve.rs index c32a12f5..40200f36 100644 --- a/crates/jmap-proto/src/references/resolve.rs +++ b/crates/jmap-proto/src/references/resolve.rs @@ -55,6 +55,7 @@ impl Response<'_> { GetRequestMethod::AccountLock(request) => request.resolve_references(self)?, GetRequestMethod::LegalHold(request) => request.resolve_references(self)?, GetRequestMethod::MailRule(request) => request.resolve_references(self)?, + GetRequestMethod::Relay(request) => request.resolve_references(self)?, GetRequestMethod::SecurityAcceptance(request) => { request.resolve_references(self)? } @@ -149,6 +150,7 @@ impl Response<'_> { SetRequestMethod::MailRule(request) => { request.resolve_references(self, 1, false)? } + SetRequestMethod::Relay(request) => request.resolve_references(self, 1, false)?, SetRequestMethod::SecurityAcceptance(request) => { request.resolve_references(self, 1, false)? } diff --git a/crates/jmap-proto/src/request/method.rs b/crates/jmap-proto/src/request/method.rs index 423a1992..ddc2dcc5 100644 --- a/crates/jmap-proto/src/request/method.rs +++ b/crates/jmap-proto/src/request/method.rs @@ -68,6 +68,7 @@ pub enum MethodObject { ProtocolPolicy, // inbuxa: DLP and mail flow rules MailRule, + Relay, // inbuxa: accepted security to-do items SecurityAcceptance, // inbuxa: the deliverability check @@ -124,6 +125,7 @@ impl MethodObject { | MethodObject::LegalHold | MethodObject::HoldExport | MethodObject::MailRule + | MethodObject::Relay | MethodObject::SecurityAcceptance | MethodObject::HeldMessage | MethodObject::DeliverabilityReport @@ -336,6 +338,8 @@ impl MethodName { (MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set", (MethodFunction::Get, MethodObject::MailRule) => "inbuxa:MailRule/get", (MethodFunction::Set, MethodObject::MailRule) => "inbuxa:MailRule/set", + (MethodFunction::Get, MethodObject::Relay) => "inbuxa:Relay/get", + (MethodFunction::Set, MethodObject::Relay) => "inbuxa:Relay/set", (MethodFunction::Get, MethodObject::SecurityAcceptance) => { "inbuxa:SecurityAcceptance/get" } @@ -534,6 +538,8 @@ impl MethodName { "inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set), "inbuxa:MailRule/get" => (MethodObject::MailRule, MethodFunction::Get), "inbuxa:MailRule/set" => (MethodObject::MailRule, MethodFunction::Set), + "inbuxa:Relay/get" => (MethodObject::Relay, MethodFunction::Get), + "inbuxa:Relay/set" => (MethodObject::Relay, MethodFunction::Set), "inbuxa:SecurityAcceptance/get" => (MethodObject::SecurityAcceptance, MethodFunction::Get), "inbuxa:SecurityAcceptance/set" => (MethodObject::SecurityAcceptance, MethodFunction::Set), "inbuxa:DeliverabilityReport/get" => (MethodObject::DeliverabilityReport, MethodFunction::Get), @@ -630,6 +636,7 @@ impl Display for MethodObject { MethodObject::AccountLock => "inbuxa:AccountLock", MethodObject::LegalHold => "inbuxa:LegalHold", MethodObject::MailRule => "inbuxa:MailRule", + MethodObject::Relay => "inbuxa:Relay", MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance", MethodObject::DeliverabilityReport => "inbuxa:DeliverabilityReport", MethodObject::DeliverabilitySettings => "inbuxa:DeliverabilitySettings", diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 37c50211..807b13ec 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -127,6 +127,7 @@ pub enum GetRequestMethod { AccountLock(Box>), LegalHold(Box>), MailRule(Box>), + Relay(Box>), SecurityAcceptance( Box>, ), @@ -183,6 +184,7 @@ pub enum SetRequestMethod<'x> { AccountLock(Box>), LegalHold(Box>), MailRule(Box>), + Relay(Box>), SecurityAcceptance( Box>, ), diff --git a/crates/jmap-proto/src/request/parser.rs b/crates/jmap-proto/src/request/parser.rs index 1b554cc7..a598c0bf 100644 --- a/crates/jmap-proto/src/request/parser.rs +++ b/crates/jmap-proto/src/request/parser.rs @@ -689,6 +689,21 @@ impl<'de> Visitor<'de> for CallVisitor { return Err(de::Error::invalid_length(1, &self)); } }, + // inbuxa: inbuxa Relay + (MethodFunction::Get, MethodObject::Relay) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Relay(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, + (MethodFunction::Set, MethodObject::Relay) => match seq.next_element() { + Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Relay(value)), + Err(err) => RequestMethod::invalid(err), + Ok(None) => { + return Err(de::Error::invalid_length(1, &self)); + } + }, // inbuxa: accepted security to-do items (MethodFunction::Get, MethodObject::SecurityAcceptance) => match seq.next_element() { Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::SecurityAcceptance(value)), diff --git a/crates/jmap-proto/src/response/mod.rs b/crates/jmap-proto/src/response/mod.rs index 07c8baeb..ccbab85e 100644 --- a/crates/jmap-proto/src/response/mod.rs +++ b/crates/jmap-proto/src/response/mod.rs @@ -112,6 +112,7 @@ pub enum GetResponseMethod { AccountLock(GetResponse), LegalHold(GetResponse), MailRule(GetResponse), + Relay(GetResponse), SecurityAcceptance(GetResponse), DeliverabilityReport( GetResponse, @@ -166,6 +167,7 @@ pub enum SetResponseMethod { AccountLock(Box>), LegalHold(Box>), MailRule(Box>), + Relay(Box>), SecurityAcceptance( Box>, ), @@ -1033,6 +1035,18 @@ impl<'x> From> for Respon } } +impl<'x> From> for ResponseMethod<'x> { + fn from(value: GetResponse) -> Self { + ResponseMethod::Get(GetResponseMethod::Relay(value)) + } +} + +impl<'x> From> for ResponseMethod<'x> { + fn from(value: SetResponse) -> Self { + ResponseMethod::Set(SetResponseMethod::Relay(Box::new(value))) + } +} + // inbuxa: journaling impl<'x> From> for ResponseMethod<'x> diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index 16c4f4d8..f35c9ee4 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -20,6 +20,7 @@ email = { path = "../email" } groupware = { path = "../groupware" } registry = { path = "../registry" } inbuxa-features = { path = "../features" } +inbuxa-relay-proto = { path = "../relay-proto" } calcard = { version = "0.3" } smtp-proto = { version = "0.2" } mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] } diff --git a/crates/jmap/src/api/auth.rs b/crates/jmap/src/api/auth.rs index 70531164..b1040249 100644 --- a/crates/jmap/src/api/auth.rs +++ b/crates/jmap/src/api/auth.rs @@ -116,6 +116,8 @@ impl JmapAuthorization for AccessToken { Permission::SysDlpPolicyGet } } + // inbuxa: inbuxa Relay + GetRequestMethod::Relay(_) => Permission::SysRelayGet, // inbuxa: journaling (JR-18) GetRequestMethod::Journal(_) => Permission::SysJournalGet, GetRequestMethod::JournalEntry(_) => Permission::SysJournalSearch, @@ -310,6 +312,14 @@ impl JmapAuthorization for AccessToken { .details("You are not authorized to change mail rules")) } } + // inbuxa: inbuxa Relay; a new join token is an update + SetRequestMethod::Relay(s) => validate_set( + s, + self, + Permission::SysRelayUpdate, + Permission::SysRelayUpdate, + Permission::SysRelayUpdate, + ), // inbuxa: journaling (JR-18) SetRequestMethod::Journal(s) => validate_set( s, @@ -566,6 +576,7 @@ impl JmapAuthorization for AccessToken { | MethodObject::LegalHold | MethodObject::HoldExport | MethodObject::MailRule + | MethodObject::Relay | MethodObject::SecurityAcceptance | MethodObject::DeliverabilityReport | MethodObject::DeliverabilitySettings diff --git a/crates/jmap/src/api/request.rs b/crates/jmap/src/api/request.rs index 35c93dfa..dd528df2 100644 --- a/crates/jmap/src/api/request.rs +++ b/crates/jmap/src/api/request.rs @@ -290,6 +290,9 @@ impl RequestHandler for Server { SetResponseMethod::MailRule(set_response) => { set_response.update_created_ids(&mut response); } + SetResponseMethod::Relay(set_response) => { + set_response.update_created_ids(&mut response); + } SetResponseMethod::SecurityAcceptance(set_response) => { set_response.update_created_ids(&mut response); } @@ -556,6 +559,13 @@ impl RequestHandler for Server { .await? .into() } + // inbuxa: inbuxa Relay + GetRequestMethod::Relay(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + crate::inbuxa::relay::get(self, access_token, *req) + .await? + .into() + } // inbuxa: accepted security to-do items GetRequestMethod::SecurityAcceptance(mut req) => { resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; @@ -1112,6 +1122,22 @@ impl RequestHandler for Server { .await? .into() } + SetRequestMethod::Relay(mut req) => { + resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; + let reason = req.arguments.reason.clone(); + crate::inbuxa::audit::recorded( + self, + access_token, + session, + &method_name.obj.to_string(), + None, + reason, + *req, + |req| Box::pin(crate::inbuxa::relay::set(self, access_token, req)), + ) + .await? + .into() + } // inbuxa: SS-26, every acceptance made or removed is in the // audit log SetRequestMethod::SecurityAcceptance(mut req) => { diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 7e221fd0..bf105c9c 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -431,6 +431,7 @@ impl IntermediateChangesResponse { | MethodObject::LegalHold | MethodObject::HoldExport | MethodObject::MailRule + | MethodObject::Relay | MethodObject::SecurityAcceptance | MethodObject::DeliverabilityReport | MethodObject::DeliverabilitySettings diff --git a/crates/jmap/src/inbuxa/audit.rs b/crates/jmap/src/inbuxa/audit.rs index 0e84d1e1..f019b763 100644 --- a/crates/jmap/src/inbuxa/audit.rs +++ b/crates/jmap/src/inbuxa/audit.rs @@ -488,6 +488,16 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid) -> O } MaybeInvalid::Invalid(_) => None, }, + // A relay as an administrator sets it: never its keys or token + "inbuxa:Relay" => match id { + MaybeInvalid::Value(id) => { + inbuxa_features::relay::get(data, u32::try_from(id.id()).ok()?) + .await + .ok()? + .map(|relay| relay.audit_view()) + } + MaybeInvalid::Invalid(_) => None, + }, _ => None, } } diff --git a/crates/jmap/src/inbuxa/mod.rs b/crates/jmap/src/inbuxa/mod.rs index 3e6ff895..cfb20291 100644 --- a/crates/jmap/src/inbuxa/mod.rs +++ b/crates/jmap/src/inbuxa/mod.rs @@ -32,6 +32,7 @@ pub mod masked_email; pub mod masked_secrets; // inbuxa: "****" is never saved as a secret pub mod oidc_directory; // inbuxa: http-directory-services-13 pub mod protocol_policy; +pub mod relay; // inbuxa: inbuxa Relay pub mod scheduled_reports; // inbuxa: scheduled reports and the weekly digest pub mod security_acceptance; pub mod sharing_policy; diff --git a/crates/jmap/src/inbuxa/relay.rs b/crates/jmap/src/inbuxa/relay.rs new file mode 100644 index 00000000..36a47750 --- /dev/null +++ b/crates/jmap/src/inbuxa/relay.rs @@ -0,0 +1,464 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:Relay` (docs/spec/features/relay.md, "Registry objects"): the +//! inbuxa Relays this server or cluster links to. A relay serves the whole +//! downstream, so nobody in a tenant reaches them. A create returns the +//! join token, once; so does an update with `rejoin: true`, which starts +//! 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}; +use inbuxa_relay_proto::{b64, types::RelaySettings}; +use jmap_proto::{ + error::set::SetError, + method::{ + get::{GetRequest, GetResponse}, + set::{SetRequest, SetResponse}, + }, + object::inbuxa_relay::{Relay, RelayProperty as P, RelayValue}, + request::IntoValid, + types::date::UTCDate, +}; +use jmap_tools::{Key, Map, Property, Value}; +use registry::schema::prelude::MASKED_PASSWORD; +use std::borrow::Cow; +use store::write::now; +use types::id::Id; + +type RValue = Value<'static, P, RelayValue>; +type JsonMap = serde_json::Map; + +/// Tries at an update that keeps meeting another write. +const UPDATE_ATTEMPTS: usize = 5; + +const ALL: &[P] = &[ + P::Id, + P::Name, + P::Description, + P::Address, + P::LinkPort, + P::Enabled, + P::SendThrough, + P::InboundOnly, + P::Settings, + P::SmtpCertificate, + P::SmtpPrivateKey, + P::JoinState, + P::JoinExpiresAt, + P::JoinedAt, + P::LinkKeyPin, + P::CreatedBy, + P::CreatedAt, + P::UpdatedAt, +]; + +/// Properties the server sets; a client that sends them is refused. +const SERVER_SET: &[P] = &[ + P::Id, + P::JoinState, + P::JoinExpiresAt, + P::JoinedAt, + P::LinkKeyPin, + P::JoinToken, + P::CreatedBy, + P::CreatedAt, + P::UpdatedAt, +]; + +fn server_level(access_token: &AccessToken) -> trc::Result<()> { + if access_token.tenant_id().is_some() { + Err(trc::JmapEvent::Forbidden + .into_err() + .details("A relay serves the whole server, not a tenant.")) + } else { + Ok(()) + } +} + +fn json_to_value(json: serde_json::Value) -> RValue { + match json { + serde_json::Value::Null => Value::Null, + serde_json::Value::Bool(b) => Value::Bool(b), + serde_json::Value::Number(n) => { + if let Some(n) = n.as_u64() { + Value::Number(n.into()) + } else if let Some(n) = n.as_i64() { + Value::Number(n.into()) + } else { + Value::Number(n.as_f64().unwrap_or_default().into()) + } + } + serde_json::Value::String(s) => Value::Str(Cow::Owned(s)), + serde_json::Value::Array(items) => { + Value::Array(items.into_iter().map(json_to_value).collect()) + } + serde_json::Value::Object(map) => { + let mut out = Map::with_capacity(map.len()); + for (key, value) in map { + out.insert_unchecked(Key::Owned(key), json_to_value(value)); + } + Value::Object(out) + } + } +} + +fn date(seconds: u64) -> RValue { + Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into()) +} + +fn text(value: Option<&str>) -> RValue { + value.map_or(Value::Null, |value| Value::Str(value.to_string().into())) +} + +fn to_value(relay: &StoredRelay, properties: &[P], now: u64) -> RValue { + let state = relay.join.state(now); + let mut out = Map::with_capacity(properties.len()); + for property in properties { + let value = match property { + P::Id => Value::Element(RelayValue::Id(Id::from(relay.id))), + P::Name => text(Some(&relay.name)), + P::Description => text(Some(&relay.description)), + P::Address => text(Some(&relay.address)), + P::LinkPort => Value::Number(u64::from(relay.link_port).into()), + P::Enabled => Value::Bool(relay.enabled), + P::SendThrough => Value::Bool(relay.send_through), + P::InboundOnly => Value::Bool(relay.inbound_only), + 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::JoinState => text(Some(match state { + JoinState::Waiting => "waiting", + JoinState::Expired => "expired", + JoinState::Joined => "joined", + })), + P::JoinExpiresAt if state != JoinState::Joined => date(relay.join.expires_at), + P::JoinedAt => relay.join.joined_at.map_or(Value::Null, date), + P::LinkKeyPin => text(relay.join.relay_pin.map(|pin| b64::encode(&pin)).as_deref()), + P::CreatedBy => text(Some(&relay.created_by)), + P::CreatedAt => date(relay.created_at), + P::UpdatedAt => date(relay.updated_at), + _ => Value::Null, + }; + out.insert_unchecked(Key::Property(property.clone()), value); + } + Value::Object(out) +} + +/// What a client sent: the properties it may set, `rejoin`, and changes to +/// single settings sent as `settings/`. +#[derive(Default)] +struct Sent { + properties: JsonMap, + settings: JsonMap, + rejoin: bool, +} + +fn client_json(value: Value<'_, P, RelayValue>) -> Result> { + let mut sent = Sent::default(); + for (key, value) in value.into_expanded_object() { + match &key { + Key::Property(p) if SERVER_SET.contains(p) => { + return Err(SetError::invalid_properties() + .with_property(p.clone()) + .with_description("The server sets this.")); + } + Key::Property(P::Rejoin) => match value.as_bool() { + Some(rejoin) => sent.rejoin = rejoin, + None => { + return Err(SetError::invalid_properties() + .with_property(P::Rejoin) + .with_description("true, to start a new join.")); + } + }, + Key::Property(p) => { + sent.properties + .insert(p.to_cow().into_owned(), value.into()); + } + _ => match key.to_string().strip_prefix("settings/") { + Some(name) if !name.is_empty() && !name.contains('/') => { + sent.settings.insert(name.to_string(), value.into()); + } + _ => { + return Err( + SetError::invalid_properties().with_property(key.clone().into_owned()) + ); + } + }, + } + } + Ok(sent) +} + +/// The settings an administrator may name: everything the relay takes but +/// for the certificate and the keys, which the server adds. +fn settable_settings() -> Vec { + match serde_json::to_value(RelaySettings::default()) { + Ok(serde_json::Value::Object(map)) => map.into_iter().map(|(name, _)| name).collect(), + _ => Vec::new(), + } +} + +/// Merges `changes` into a settings object: a `null` puts a setting back to +/// its default. Refuses a name the relay doesn't have. +fn merge_settings(settings: &mut JsonMap, changes: JsonMap) -> Result<(), SetError

> { + let known = settable_settings(); + for (name, value) in changes { + if !known.contains(&name) { + return Err(SetError::invalid_properties() + .with_property(P::Settings) + .with_description(format!("There is no setting {name}."))); + } + if value.is_null() { + settings.remove(&name); + } else { + settings.insert(name, value); + } + } + Ok(()) +} + +/// The relay `json` describes, checked whole. +fn parse(mut json: JsonMap) -> Result> { + // Nulls are defaults, inside settings too + json.retain(|_, value| !value.is_null()); + if let Some(serde_json::Value::Object(settings)) = json.get_mut("settings") { + let given = std::mem::take(settings); + merge_settings(settings, given)?; + } + let relay: StoredRelay = + serde_json::from_value(serde_json::Value::Object(json)).map_err(|err| { + SetError::invalid_properties().with_description(format!("Not a valid relay: {err}")) + })?; + relay.validate().map_err(|invalid| { + SetError::invalid_properties() + .with_property(invalid.property.parse::

().unwrap_or(P::Settings)) + .with_description(invalid.reason) + })?; + Ok(relay) +} + +fn relay_id(id: Id) -> Option { + u32::try_from(id.id()).ok() +} + +fn token_value(id: Option, token: String) -> RValue { + let mut out = Map::with_capacity(2); + if let Some(id) = id { + out.insert_unchecked( + Key::Property(P::Id), + Value::Element(RelayValue::Id(Id::from(id))), + ); + } + out.insert_unchecked(Key::Property(P::JoinToken), Value::Str(token.into())); + Value::Object(out) +} + +/// `current` with what was sent applied: each property replacing its own +/// and each `settings/` its one setting, checked whole. The keys, the +/// join and the record keeping stay the server's. +fn apply(current: &StoredRelay, sent: &Sent) -> Result> { + let mut json = match serde_json::to_value(current) { + Ok(serde_json::Value::Object(map)) => map, + _ => JsonMap::new(), + }; + json.extend(sent.properties.clone()); + // `****` written back keeps the stored key + if sent + .properties + .get("smtpPrivateKey") + .and_then(|v| v.as_str()) + == Some(MASKED_PASSWORD) + { + json.insert( + "smtpPrivateKey".into(), + current.smtp_private_key.clone().into(), + ); + } + if !sent.settings.is_empty() { + let mut settings = match json.remove("settings") { + Some(serde_json::Value::Object(settings)) => settings, + _ => JsonMap::new(), + }; + merge_settings(&mut settings, sent.settings.clone())?; + json.insert("settings".into(), settings.into()); + } + let next = parse(json)?; + Ok(StoredRelay { + id: current.id, + recipient_key: current.recipient_key, + join: current.join.clone(), + created_by: current.created_by.clone(), + created_at: current.created_at, + updated_at: current.updated_at, + ..next + }) +} + +/// `inbuxa:Relay/get`: every relay, oldest first. +pub async fn get( + server: &Server, + access_token: &AccessToken, + mut request: GetRequest, +) -> trc::Result> { + server_level(access_token)?; + let properties = request.unwrap_properties(ALL); + let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?; + let mut response = GetResponse { + account_id: request.account_id.into(), + state: None, + list: Vec::new(), + not_found, + }; + let all = relays::all(server.store()).await?; + let now = now(); + match ids { + None => { + response.list = all + .iter() + .map(|relay| to_value(relay, &properties, now)) + .collect() + } + Some(ids) => { + for id in ids { + match relay_id(id).and_then(|id| all.iter().find(|r| r.id == id)) { + Some(relay) => response.list.push(to_value(relay, &properties, now)), + None => response.push_not_found(id), + } + } + } + } + Ok(response) +} + +/// `inbuxa:Relay/set`: add relays (each with a join token), change them, +/// start a new join, or remove them. +pub async fn set( + server: &Server, + access_token: &AccessToken, + mut request: SetRequest<'_, Relay>, +) -> trc::Result> { + server_level(access_token)?; + let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?; + let data = server.store(); + let actor = server.audit_actor(access_token).await; + + for (client_id, value) in request.unwrap_create() { + let relay = match client_json(value).and_then(|sent| { + if sent.rejoin { + return Err(SetError::invalid_properties() + .with_property(P::Rejoin) + .with_description("A new relay gets its join token anyway.")); + } + if sent + .properties + .get("smtpPrivateKey") + .and_then(|v| v.as_str()) + == Some(MASKED_PASSWORD) + { + return Err(SetError::invalid_properties() + .with_property(P::SmtpPrivateKey) + .with_description("There is no stored key for **** to keep.")); + } + let mut json = sent.properties; + if !sent.settings.is_empty() { + let mut settings = match json.remove("settings") { + Some(serde_json::Value::Object(settings)) => settings, + _ => JsonMap::new(), + }; + merge_settings(&mut settings, sent.settings)?; + json.insert("settings".into(), settings.into()); + } + parse(json) + }) { + Ok(relay) => relay, + Err(error) => { + response.not_created.append(client_id, error); + continue; + } + }; + let at = now(); + let relay = StoredRelay { + created_by: actor.name.clone(), + created_at: at, + updated_at: at, + ..relay + }; + let (stored, token) = relays::create(data, &relay, at).await?; + response + .created + .insert(client_id, token_value(Some(stored.id), token)); + } + + for (id, value) in request.unwrap_update().into_valid() { + let sent = match client_json(value) { + Ok(sent) => sent, + Err(error) => { + response.not_updated.append(id, error); + continue; + } + }; + let mut outcome = None; + // A node may record the join while this runs: start over from the + // relay as it is then, a few times + for _ in 0..UPDATE_ATTEMPTS { + let Some((current, version)) = (match relay_id(id) { + Some(relay_id) => relays::get_versioned(data, relay_id).await?, + None => None, + }) else { + outcome = Some(Err(SetError::not_found())); + break; + }; + let mut next = match apply(¤t, &sent) { + Ok(next) => next, + Err(error) => { + outcome = Some(Err(error)); + break; + } + }; + let at = now(); + let token = if sent.rejoin { + let keys = relays::keys_or_new(data, at).await?; + Some(next.start_join(&keys, at)) + } else { + None + }; + if next != current { + next.updated_at = at; + if !relays::replace(data, &next, version).await? { + continue; + } + } + outcome = Some(Ok(token)); + break; + } + match outcome { + Some(Ok(token)) => response + .updated + .append(id, token.map(|token| token_value(None, token))), + Some(Err(error)) => response.not_updated.append(id, error), + None => response.not_updated.append( + id, + SetError::forbidden().with_description("The relay kept changing; try again."), + ), + } + } + + for id in request.unwrap_destroy().into_valid() { + let Some(current) = (match relay_id(id) { + Some(relay_id) => relays::get(data, relay_id).await?, + None => None, + }) else { + response.not_destroyed.append(id, SetError::not_found()); + continue; + }; + relays::delete(data, current.id).await?; + response.destroyed.push(id); + } + + Ok(response) +} diff --git a/crates/registry/src/schema/enums.rs b/crates/registry/src/schema/enums.rs index 0552e0cc..60020ad7 100644 --- a/crates/registry/src/schema/enums.rs +++ b/crates/registry/src/schema/enums.rs @@ -1780,6 +1780,11 @@ pub enum Permission { // inbuxa: scheduled reports and the weekly digest SysScheduledReportGet = 688, SysScheduledReportUpdate = 689, + // inbuxa: inbuxa Relay + SysRelayGet = 690, + SysRelayUpdate = 691, + SysRelayQueueGet = 692, + SysRelayQueueUpdate = 693, SysAccountGet = 219, SysAccountCreate = 220, SysAccountUpdate = 221, diff --git a/crates/registry/src/schema/enums_impl.rs b/crates/registry/src/schema/enums_impl.rs index 0a2716e1..c74c4fb1 100644 --- a/crates/registry/src/schema/enums_impl.rs +++ b/crates/registry/src/schema/enums_impl.rs @@ -7160,6 +7160,10 @@ impl EnumImpl for Permission { b"sysDeliverabilityCheck" => Permission::SysDeliverabilityCheck, b"sysScheduledReportGet" => Permission::SysScheduledReportGet, b"sysScheduledReportUpdate" => Permission::SysScheduledReportUpdate, + b"sysRelayGet" => Permission::SysRelayGet, + b"sysRelayUpdate" => Permission::SysRelayUpdate, + b"sysRelayQueueGet" => Permission::SysRelayQueueGet, + b"sysRelayQueueUpdate" => Permission::SysRelayQueueUpdate, b"sysAccountGet" => Permission::SysAccountGet, b"sysAccountCreate" => Permission::SysAccountCreate, b"sysAccountUpdate" => Permission::SysAccountUpdate, @@ -7866,6 +7870,10 @@ impl EnumImpl for Permission { Permission::SysDeliverabilityCheck => "sysDeliverabilityCheck", Permission::SysScheduledReportGet => "sysScheduledReportGet", Permission::SysScheduledReportUpdate => "sysScheduledReportUpdate", + Permission::SysRelayGet => "sysRelayGet", + Permission::SysRelayUpdate => "sysRelayUpdate", + Permission::SysRelayQueueGet => "sysRelayQueueGet", + Permission::SysRelayQueueUpdate => "sysRelayQueueUpdate", Permission::SysAccountGet => "sysAccountGet", Permission::SysAccountCreate => "sysAccountCreate", Permission::SysAccountUpdate => "sysAccountUpdate", @@ -8565,6 +8573,10 @@ impl EnumImpl for Permission { 687 => Some(Permission::SysDeliverabilityCheck), 688 => Some(Permission::SysScheduledReportGet), 689 => Some(Permission::SysScheduledReportUpdate), + 690 => Some(Permission::SysRelayGet), + 691 => Some(Permission::SysRelayUpdate), + 692 => Some(Permission::SysRelayQueueGet), + 693 => Some(Permission::SysRelayQueueUpdate), 219 => Some(Permission::SysAccountGet), 220 => Some(Permission::SysAccountCreate), 221 => Some(Permission::SysAccountUpdate), @@ -9009,7 +9021,7 @@ impl EnumImpl for Permission { } } - const COUNT: usize = 690; + const COUNT: usize = 694; } impl serde::Serialize for Permission { diff --git a/docs/spec/SPEC.md b/docs/spec/SPEC.md index a4d5ec75..5d108389 100644 --- a/docs/spec/SPEC.md +++ b/docs/spec/SPEC.md @@ -379,7 +379,7 @@ Not a rebuild: the **deliverability check** is INBUXA's own design (inbuxa-draft Not a rebuild: **scheduled reports and the weekly digest** are INBUXA's own design (inbuxa-drafts `specs/scheduled-reports.md`). An administrator picks sections, a schedule in a time zone and recipients on this server; every node looks for due reports once a minute, one claims each run with the task lock, and the report is built from data the server already keeps and mailed DKIM-signed: `inbuxa:ScheduledReport`, `inbuxa:ScheduledReportSettings` and `inbuxa:ReportExport` (a download: a ZIP of a summary and CSVs) (`crates/jmap/src/inbuxa/scheduled_reports.rs`, `crates/features/src/scheduled_reports/`, `crates/services/src/inbuxa_scheduled_reports.rs`, `crates/smtp/src/reporting/inbuxa_send.rs`), and the `sysScheduledReportGet` and `sysScheduledReportUpdate` permissions. The weekly digest is a built-in report, on by default. -Not a rebuild: **inbuxa-relay** is INBUXA's own design (`features/relay.md`, settled 2026-10-29, not started). It's a small edge appliance that takes mail in on port 25 for one downstream server or cluster, and sends that downstream's outgoing mail on. The downstream dials out to it over JMAP (`urn:inbuxa:jmap:relay`), so the protected network exposes no mail port, and it is administered only from the downstream's inbuxa-admin. +Not a rebuild: **inbuxa-relay** is INBUXA's own design (`features/relay.md`, settled 2026-10-29, in progress). It's a small edge appliance that takes mail in on port 25 for one downstream server or cluster, and sends that downstream's outgoing mail on. The downstream dials out to it over JMAP (`urn:inbuxa:jmap:relay`), so the protected network exposes no mail port, and it is administered only from the downstream's inbuxa-admin. ## 5. The web front ends diff --git a/docs/spec/features/relay.md b/docs/spec/features/relay.md index ce9b0890..ca56e3f7 100644 --- a/docs/spec/features/relay.md +++ b/docs/spec/features/relay.md @@ -149,6 +149,10 @@ the protocol the rest of the suite already speaks. relay deletes the secret, and the downstream pushes the first settings and recipient set. The relay opens port 25 only once it has both. +A relay that was reset joins again with a new token. In inbuxa-admin, an +update with `rejoin: true` returns one, once, and forgets the relay's +pinned link key and its recipient key. + - **RL-8.** A join token works once. A wrong proof, an expired token, or a second join attempt fails, and the relay stays unjoined. - **RL-9.** `inbuxa-relay status` prints the link state, the number of @@ -413,7 +417,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), and read-only status mirrored from the relay: link state per node, version, queue counts, `lastSeen`. Create 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), 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 | Permissions: `sysRelayGet`, `sysRelayUpdate`, `sysRelayQueueGet`, @@ -659,7 +663,16 @@ No questions are open. ## Implementation status -Not started. Order: +In progress. Done so far: + +- `crates/relay-proto`: the link's shared types, joining, recipient + hashing and sealing (PR #244). +- `crates/relay`: the relay binary, inbound in held and live mode (PR #245). +- On the downstream: `inbuxa:Relay` and its permissions, the link CA and + sealing key, and join tokens. Status, `inbuxa:RelayMessage` and the link + service are not done yet. + +Order: 1. This spec (settled 2026-10-29). 2. The relay: the link (join, pins, settings, recipients) and held-mode diff --git a/resources/privacy/catalog.toml b/resources/privacy/catalog.toml index 436620b8..92bdb1dd 100644 --- a/resources/privacy/catalog.toml +++ b/resources/privacy/catalog.toml @@ -113,6 +113,23 @@ exceptions = ["contact", "content"] actions = ["contact", "content"] createdBy = ["identifier"] +[object."inbuxa:Relay"] +file = "inbuxa_relay.rs" +default = "none" +whose = ["administrator"] +where = ["data-store"] +scope = "server" +retention = "unbounded" +[object."inbuxa:Relay".properties] +name = ["content"] +description = ["content"] +address = ["network"] +settings = ["network"] +smtpCertificate = ["network"] +smtpPrivateKey = ["credential"] +joinToken = ["credential"] +createdBy = ["identifier"] + [object."inbuxa:Journal"] file = "inbuxa_journal.rs" default = "none" diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index ba26e8f9ab0fbae6fa6157acdd77f302b4b5705d..916d09915f58691627f668f4fd50059ade609c36 100644 GIT binary patch delta 29538 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 RTvJzD?hlxE2*l+B4*)HFi_ri8 delta 29528 zcmV(&K;gfWwF%U;34pW#XXk%6jwDGF{40SuV5b{fscKPG)z!Us2c$@<#1db{q*T*$ z=o2C{gUk>kBeLQ_DFy6*Uo&&}2zNJgk4TC$H;V%bETx;-7yf>jo0}Dms!}EX=D7R2 z*t=ZYy~hCL+zA*9^t_$u7+>Y_zodVB?OOE>-1s|wrUOwLG&@BGH&K6URIFRHgtvtt zSZL$BFYL;La7}FHG#Kx}13W=b#ZN;`ATFNr+__H3wXyr;x)e~+xj$|~a*R)zX!q5Z z7??@ODGM8Iqfpg>b_A_g2=!4vloT0y4PVjLRG0@b$HF58v{)b z@mzI3K$oQc2ufGm>;->T)XWdvH9x*M^{Y_vIR3|tcw1F7y!xGCIE3x4bv=ky3DOb? zv*-6=!I2#JJ6cun$|&<9i69J-lYuOY=OuaXl!Xn^7gh03XWN{DhR)y>fiG{OwknQf z0k$eW2)KHk!EU>hD;8b9&d;we16A5$IFlK~0Gr)`FL#!GB7=XkTHMBWB`<*ZN&cmJ z^#WYE!ww4@uc(};7wG$&i10B~Ai3c!P*m3*R55PE9Ou1fij*KoPY#H$9s`2MAAFGw z_)t>;^=57AXBLP$bbNwDPjp#RjZ%pmhecGAePR6|m@l;3Tl-sX_tgw{UBP70%dXpR zc`zNXu$N| zmdU^|357x`m!)#s9|Ln31x(s1H=A(AdIp{^aO0>}hM|AA&G=wCnP0L6K^{>O-W`i# zCE@X5Mp$JvTfq}#wU)!9Tz{gytP-29W?FSe10P{<7ViwA&NsX_oKkFRq)`a$fj

  • Uz)|xPA(UV)8j<* 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 Hv;q$RprwD4 diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index d4e520ec..f426104e 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -sumxHhh5m_-OICu3KBWCIwgVgSpXxw-jcNAvXt63ATc \ No newline at end of file +Hc6Z_iXa_4ma9Pch8CLDiLJ7nuLZC_hz5_jqi4Dp2x4 \ No newline at end of file diff --git a/tests/Cargo.toml b/tests/Cargo.toml index b321b75e..ddff2ffb 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -45,6 +45,7 @@ smtp = { path = "../crates/smtp", features = ["test_mode"] } common = { path = "../crates/common", features = ["test_mode"] } registry = { path = "../crates/registry" } inbuxa-features = { path = "../crates/features" } +inbuxa-relay-proto = { path = "../crates/relay-proto" } email = { path = "../crates/email", features = ["test_mode"] } spam-filter = { path = "../crates/spam-filter", features = ["test_mode"] } trc = { path = "../crates/trc", features = [] } diff --git a/tests/src/system/mod.rs b/tests/src/system/mod.rs index 66a22a2b..6edbdc7a 100644 --- a/tests/src/system/mod.rs +++ b/tests/src/system/mod.rs @@ -31,6 +31,7 @@ pub mod monitoring; pub mod oidc; pub mod purge; pub mod quota; +pub mod relay; // inbuxa: inbuxa Relay pub mod reload; // inbuxa: reloads and build errors pub mod scheduled_reports; // inbuxa: scheduled reports and the weekly digest pub mod security; diff --git a/tests/src/system/relay.rs b/tests/src/system/relay.rs new file mode 100644 index 00000000..85e5af17 --- /dev/null +++ b/tests/src/system/relay.rs @@ -0,0 +1,354 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `inbuxa:Relay` (docs/spec/features/relay.md, "Registry objects"): a +//! create returns a join token once, every relay is pinned to the one link +//! CA, settings change one at a time, a private key reads back as `****` +//! and is kept when written back that way, a rejoin issues a new token, +//! nobody in a tenant reaches relays, and the audit log never holds the +//! key or a token. + +use crate::utils::{ + account::Account, + server::{TestServer, TestServerBuilder}, +}; +use inbuxa_relay_proto::{time, token::JoinToken}; +use registry::schema::{ + prelude::{ObjectType, Property}, + structs::{CertificateManagement, DkimManagement, DnsManagement, Domain, Tenant, UserRoles}, +}; +use serde_json::{Value, json}; +use std::str::FromStr; + +const USING: &[&str] = &[ + "urn:ietf:params:jmap:core", + "urn:inbuxa:jmap", + "urn:inbuxa:jmap:registry", +]; + +const KEY_PEM: &str = + "-----BEGIN PRIVATE KEY-----\nTUFSS0VSLXJlbGF5LXByaXZhdGUta2V5\n-----END PRIVATE KEY-----\n"; +const CERT_PEM: &str = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n"; + +async fn call(account: &Account, method: &str, mut arguments: Value) -> (String, Value) { + if arguments.get("accountId").is_none() { + arguments["accountId"] = account.id_string().into(); + } + let response = account + .jmap_request(USING, json!([[method, arguments, "0"]])) + .await; + let call = response + .0 + .pointer("/methodResponses/0") + .cloned() + .unwrap_or_else(|| panic!("{method}: {}", response.0)); + ( + call[0].as_str().unwrap_or_default().to_string(), + call[1].clone(), + ) +} + +fn relay(name: &str) -> Value { + json!({ + "name": name, + "address": "relay1.example.net", + "settings": {"hostname": "mx1.example.com"} + }) +} + +async fn get(account: &Account, id: &str) -> Value { + let (name, response) = call(account, "inbuxa:Relay/get", json!({"ids": [id]})).await; + assert_eq!(name, "inbuxa:Relay/get", "{response}"); + response["list"][0].clone() +} + +pub async fn test(test: &mut TestServer) { + println!("Running inbuxa Relay tests..."); + let admin = test.account("admin@example.com"); + let now = time::now(); + + // Created with a join token, shown once, for the shared link CA + let (_, response) = call( + admin, + "inbuxa:Relay/set", + json!({"create": {"a": relay("Edge one"), "b": relay("Edge two")}}), + ) + .await; + let id = response["created"]["a"]["id"] + .as_str() + .unwrap_or_else(|| panic!("relay created: {response}")) + .to_string(); + let token = response["created"]["a"]["joinToken"] + .as_str() + .unwrap() + .to_string(); + let first = JoinToken::decode(&token, now).expect("a join token"); + let second = + JoinToken::decode(response["created"]["b"]["joinToken"].as_str().unwrap(), now).unwrap(); + assert_eq!(first.relay_id, id); + assert_eq!(first.ca_spki, second.ca_spki, "one link CA for every relay"); + assert_ne!(first.secret, second.secret); + let other = response["created"]["b"]["id"].as_str().unwrap().to_string(); + + let relay_a = get(admin, &id).await; + assert_eq!(relay_a["joinState"], "waiting", "{relay_a}"); + assert_eq!(relay_a["linkPort"], 443); + assert_eq!(relay_a["enabled"], true); + assert_eq!(relay_a["settings"]["heldMaxAge"], 30); + assert_eq!(relay_a["settings"]["hostname"], "mx1.example.com"); + assert!(relay_a["settings"].get("sealingKey").is_none(), "{relay_a}"); + assert!( + relay_a["settings"].get("recipientKey").is_none(), + "{relay_a}" + ); + assert_eq!(relay_a["smtpPrivateKey"], Value::Null); + assert_eq!(relay_a["createdBy"], "admin@example.com"); + assert!(relay_a.get("joinToken").is_none(), "{relay_a}"); + let (_, response) = call( + admin, + "inbuxa:Relay/get", + json!({"ids": [&id], "properties": ["joinToken"]}), + ) + .await; + assert_eq!(response["list"][0]["joinToken"], Value::Null, "{response}"); + + // Checked when written + let mut no_hostname = relay("x"); + no_hostname["settings"] = json!({}); + let mut unknown_setting = relay("x"); + unknown_setting["settings"]["noSuchSetting"] = 1.into(); + let mut server_set = relay("x"); + server_set["joinState"] = "joined".into(); + let mut rejoin = relay("x"); + rejoin["rejoin"] = true.into(); + let mut masked = relay("x"); + masked["smtpCertificate"] = CERT_PEM.into(); + masked["smtpPrivateKey"] = "****".into(); + let mut keys = relay("x"); + keys["settings"]["sealingKey"] = "AAAA".into(); + let (_, response) = call( + admin, + "inbuxa:Relay/set", + json!({"create": { + "a": no_hostname, "b": unknown_setting, "c": server_set, + "d": rejoin, "e": masked, "f": keys + }}), + ) + .await; + for (client_id, property) in [ + ("a", "settings"), + ("b", "settings"), + ("c", "joinState"), + ("d", "rejoin"), + ("e", "smtpPrivateKey"), + ("f", "settings"), + ] { + assert_eq!( + response["notCreated"][client_id]["properties"][0], property, + "{client_id}: {response}" + ); + } + assert!( + response["notCreated"]["b"]["description"] + .as_str() + .is_some_and(|d| d.contains("noSuchSetting")), + "{response}" + ); + + // One setting at a time; the rest stay as they were + let (_, response) = call( + admin, + "inbuxa:Relay/set", + json!({"update": {&id: { + "settings/heldMaxAge": 10, + "settings/maxRecipients": 20, + "name": "Edge north", + "smtpCertificate": CERT_PEM, + "smtpPrivateKey": KEY_PEM + }}}), + ) + .await; + assert!(response["updated"].get(&id).is_some(), "{response}"); + let relay_a = get(admin, &id).await; + assert_eq!(relay_a["name"], "Edge north"); + assert_eq!(relay_a["settings"]["heldMaxAge"], 10); + assert_eq!(relay_a["settings"]["maxRecipients"], 20); + assert_eq!(relay_a["settings"]["hostname"], "mx1.example.com"); + assert_eq!(relay_a["smtpPrivateKey"], "****", "{relay_a}"); + assert_eq!(relay_a["smtpCertificate"], CERT_PEM); + + // A setting put back to its default, and the key written back masked + let (_, response) = call( + admin, + "inbuxa:Relay/set", + json!({"update": {&id: {"settings/heldMaxAge": null, "smtpPrivateKey": "****"}}}), + ) + .await; + assert!(response["updated"].get(&id).is_some(), "{response}"); + assert_eq!(get(admin, &id).await["settings"]["heldMaxAge"], 30); + let stored = inbuxa_features::relay::get( + test.server.store(), + u32::try_from(types::id::Id::from_str(&id).unwrap().id()).unwrap(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + stored.smtp_private_key.as_deref(), + Some(KEY_PEM), + "key kept" + ); + + // A change made from a stale read is refused, so a join being recorded + // and an administrator's edit can't overwrite each other + let (mut stored, version) = + inbuxa_features::relay::get_versioned(test.server.store(), stored.id) + .await + .unwrap() + .unwrap(); + stored.description = "changed".into(); + assert!( + !inbuxa_features::relay::replace(test.server.store(), &stored, version ^ 1) + .await + .unwrap() + ); + assert!( + inbuxa_features::relay::replace(test.server.store(), &stored, version) + .await + .unwrap() + ); + assert_eq!(get(admin, &id).await["description"], "changed"); + + // Out of range, a bad setting name, and a server property: refused + let (_, response) = call( + admin, + "inbuxa:Relay/set", + json!({"update": { + &id: {"settings/heldMaxAge": 91}, + &other: {"settings/nope": 1} + }}), + ) + .await; + assert_eq!(response["notUpdated"][&id]["properties"][0], "settings"); + assert_eq!(response["notUpdated"][&other]["properties"][0], "settings"); + let (_, response) = call( + admin, + "inbuxa:Relay/set", + json!({"update": {&id: {"linkKeyPin": "AAAA"}}}), + ) + .await; + assert_eq!( + response["notUpdated"][&id]["properties"][0], "linkKeyPin", + "{response}" + ); + + // A rejoin issues a new token for the same relay + let (_, response) = call( + admin, + "inbuxa:Relay/set", + json!({"update": {&id: {"rejoin": true}}}), + ) + .await; + let again = JoinToken::decode( + response["updated"][&id]["joinToken"] + .as_str() + .unwrap_or_else(|| panic!("rejoin token: {response}")), + now, + ) + .unwrap(); + assert_eq!(again.relay_id, id); + assert_eq!(again.ca_spki, first.ca_spki); + assert_ne!(again.secret, first.secret); + assert_eq!(get(admin, &id).await["joinState"], "waiting"); + + // Nobody in a tenant reaches relays + let tenant = admin + .registry_create_object(Tenant { + name: "Relay tenant".to_string(), + ..Default::default() + }) + .await; + admin + .registry_create_object(Domain { + name: "relays.example.org".to_string(), + is_enabled: true, + member_tenant_id: Some(tenant), + certificate_management: CertificateManagement::Manual, + dns_management: DnsManagement::Manual, + dkim_management: DkimManagement::Manual, + ..Default::default() + }) + .await; + let t_admin = admin + .create_user_account( + "tadmin@relays.example.org", + "tenant-admin-secret-8841", + "Tenant admin", + &[], + vec![], + ) + .await; + admin + .registry_update_object( + ObjectType::Account, + t_admin.id(), + json!({Property::Roles: UserRoles::Admin}), + ) + .await; + for (method, arguments) in [ + ("inbuxa:Relay/get", json!({"ids": null})), + ( + "inbuxa:Relay/set", + json!({"create": {"x": relay("Tenant's own")}}), + ), + ] { + let (name, response) = call(&t_admin, method, arguments).await; + assert_eq!(name, "error", "{method} by a tenant: {response}"); + } + + // Destroyed + let (_, response) = call(admin, "inbuxa:Relay/set", json!({"destroy": [&other]})).await; + assert_eq!(response["destroyed"][0], other.as_str(), "{response}"); + let (_, response) = call(admin, "inbuxa:Relay/get", json!({"ids": [&other]})).await; + assert_eq!(response["notFound"][0], other.as_str(), "{response}"); + + // Every change audited, with neither the key nor a token in it + let (_, response) = call( + admin, + "inbuxa:AuditEvent/query", + json!({"filter": {"targetKind": "inbuxa:Relay"}}), + ) + .await; + let ids = response["ids"].clone(); + assert!( + ids.as_array().is_some_and(|ids| ids.len() >= 6), + "creates, updates, rejoin and destroy audited: {response}" + ); + let (_, response) = call(admin, "inbuxa:AuditEvent/get", json!({"ids": ids})).await; + let text = response.to_string(); + assert!(!text.contains("TUFSS0VS"), "the private key was audited"); + assert!(!text.contains(&token), "a join token was audited"); + assert!(!text.contains(&again.encode()), "a join token was audited"); + + call(admin, "inbuxa:Relay/set", json!({"destroy": [&id]})).await; +} + +#[ignore] +#[tokio::test(flavor = "multi_thread")] +pub async fn relay_tests() { + let mut test = TestServerBuilder::new("relay_tests") + .await + .with_default_listeners() + .await + .build() + .await; + let admin = test.create_admin_account("admin@example.com").await; + test.insert_account(admin); + self::test(&mut test).await; + if test.is_reset() { + test.temp_dir.delete(); + } +} -- 2.54.0