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 ba26e8f9..916d0991 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ 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(); + } +}