From 3eb5879bb3b85512a3ca5c9c2dc90d568b6c51f0 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 29 Oct 2026 20:33:00 -0700 Subject: [PATCH] inbuxa-relay: the relay binary, inbound in held and live mode The edge appliance from docs/spec/features/relay.md, inbound side: - join, run, status and reset; INBUXA_RELAY_JOIN_TOKEN joins a container on its first run (RL-2, RL-8, RL-9, RL-10) - the link: TLS 1.3 with a client certificate required and chained to the pinned downstream CA, else a TLS alert and nothing more (RL-5, RL-6); JMAP over HTTP/1.1 and RFC 8887 WebSocket with push; uploads and downloads - the JMAP methods: Relay/join, get, set; RecipientDomain and Recipient sync with Recipient/replace; Inbound get, query, changes, claim, set; RelayEvent get, changes, set - the door on port 25, closed until the first settings and recipient set: real recipients only (RL-1), no AUTH or relaying (RL-4), per-IP limits, early talkers dropped, STARTTLS, DSN parameters passed down - the spool: sealed to the downstream as the message arrives and fsynced before 250 (RL-21), leases per connection (RL-15), held mail expiry, 452 when 90% full - live mode: the SMTP reply waits for the downstream's verdict, and falls back to held after liveVerdictTimeout (RL-12, RL-13) An end-to-end test drives it over real sockets as a downstream would. Release build: 5.4 MB; 6.3 MB resident when idle. Not here yet: outbound (phase 4), DNSBL zones, Relay/upgrade, the static musl build and image (RL-31), and the server side. --- Cargo.lock | 23 + Cargo.toml | 1 + crates/relay-proto/src/lib.rs | 2 +- crates/relay/Cargo.toml | 37 ++ crates/relay/src/app.rs | 302 ++++++++++++ crates/relay/src/events.rs | 227 +++++++++ crates/relay/src/link/jmap.rs | 831 ++++++++++++++++++++++++++++++++ crates/relay/src/link/mod.rs | 563 ++++++++++++++++++++++ crates/relay/src/link/tls.rs | 227 +++++++++ crates/relay/src/main.rs | 357 ++++++++++++++ crates/relay/src/recipients.rs | 205 ++++++++ crates/relay/src/smtp.rs | 852 +++++++++++++++++++++++++++++++++ crates/relay/src/spool.rs | 725 ++++++++++++++++++++++++++++ crates/relay/src/state.rs | 247 ++++++++++ crates/relay/src/tests_e2e.rs | 640 +++++++++++++++++++++++++ 15 files changed, 5238 insertions(+), 1 deletion(-) create mode 100644 crates/relay/Cargo.toml create mode 100644 crates/relay/src/app.rs create mode 100644 crates/relay/src/events.rs create mode 100644 crates/relay/src/link/jmap.rs create mode 100644 crates/relay/src/link/mod.rs create mode 100644 crates/relay/src/link/tls.rs create mode 100644 crates/relay/src/main.rs create mode 100644 crates/relay/src/recipients.rs create mode 100644 crates/relay/src/smtp.rs create mode 100644 crates/relay/src/spool.rs create mode 100644 crates/relay/src/state.rs create mode 100644 crates/relay/src/tests_e2e.rs diff --git a/Cargo.lock b/Cargo.lock index f7ce97c2..81a2b7b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3971,6 +3971,29 @@ dependencies = [ "zip", ] +[[package]] +name = "inbuxa-relay" +version = "27.1.0-rc.4" +dependencies = [ + "aws-lc-rs", + "futures-util", + "http-body-util", + "hyper", + "hyper-util", + "inbuxa-relay-proto", + "rcgen", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "smtp-proto", + "tokio", + "tokio-rustls", + "tokio-tungstenite 0.30.0", + "x509-parser", +] + [[package]] name = "inbuxa-relay-proto" version = "27.1.0-rc.4" diff --git a/Cargo.toml b/Cargo.toml index 3affb2f9..c731904f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ members = [ "crates/migration", "crates/features", "crates/relay-proto", + "crates/relay", "tests", ] diff --git a/crates/relay-proto/src/lib.rs b/crates/relay-proto/src/lib.rs index c5b32dd5..a41ac32d 100644 --- a/crates/relay-proto/src/lib.rs +++ b/crates/relay-proto/src/lib.rs @@ -30,7 +30,7 @@ pub const PROTOCOL_VERSION: u32 = 1; pub const CORE_CAPABILITY: &str = "urn:ietf:params:jmap:core"; /// Base64url without padding, for every byte field on the wire. -pub(crate) mod b64 { +pub mod b64 { use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use serde::{Deserialize, Deserializer, Serializer, de::Error}; diff --git a/crates/relay/Cargo.toml b/crates/relay/Cargo.toml new file mode 100644 index 00000000..110518a5 --- /dev/null +++ b/crates/relay/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "inbuxa-relay" +description = "inbuxa Relay: a small edge appliance that takes mail in for one downstream inbuxa server and sends its mail on (docs/spec/features/relay.md)" +license = "AGPL-3.0-only" +version.workspace = true +edition = "2024" + +[[bin]] +name = "inbuxa-relay" +path = "src/main.rs" + +# The footprint rule (RL-30): no inbuxa server crate. Everything here is +# small, and most of it the server already uses. +[dependencies] +inbuxa-relay-proto = { path = "../relay-proto" } +aws-lc-rs = { version = "1" } +smtp-proto = { version = "0.2.3" } +tokio = { version = "1.53", features = ["rt-multi-thread", "net", "io-util", "time", "sync", "macros", "signal", "fs"] } +rustls = { version = "0.23.43", default-features = false, features = ["std", "aws_lc_rs", "tls12"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } +rustls-pki-types = { version = "1", features = ["std"] } +rustls-webpki = { version = "0.103", default-features = false, features = ["std", "aws-lc-rs"] } +rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "aws_lc_rs"] } +x509-parser = { version = "0.18" } +hyper = { version = "1.11.1", features = ["server", "http1"] } +hyper-util = { version = "0.1.20", features = ["tokio"] } +http-body-util = "0.1.5" +tokio-tungstenite = "0.30" +futures-util = "0.3.34" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[lints] +workspace = true + +[dev-dependencies] +hyper = { version = "1.11.1", features = ["client", "http1"] } diff --git a/crates/relay/src/app.rs b/crates/relay/src/app.rs new file mode 100644 index 00000000..9514ba2d --- /dev/null +++ b/crates/relay/src/app.rs @@ -0,0 +1,302 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! What the link and the SMTP door share. + +use crate::{ + events::Events, + recipients::Recipients, + spool::Spool, + state::{self, Identity, Paths}, +}; +use inbuxa_relay_proto::{ + time, + types::{Mode, RelaySettings, RelayStatus, SpoolStatus, type_name}, +}; +use std::{ + collections::HashMap, + net::IpAddr, + sync::{ + Arc, Mutex, RwLock, + atomic::{AtomicU32, AtomicU64, Ordering}, + }, +}; +use tokio::sync::{broadcast, watch}; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// A data type's new state, for push. +#[derive(Debug, Clone)] +pub struct Changed { + pub type_name: &'static str, + pub state: String, +} + +pub struct App { + pub paths: Paths, + pub identity: Mutex, + /// The settings the downstream last set, if any. + pub settings: watch::Sender>>, + settings_state: AtomicU64, + pub recipients: RwLock, + pub spool: Mutex, + pub events: Mutex, + pub push: broadcast::Sender, + /// Link connections, and those with push on for `Inbound`. + pub link_clients: AtomicU32, + pub live_clients: AtomicU32, + next_conn: AtomicU64, + pub started_at: u64, + pub last_error: Mutex>, + pub limiter: Mutex, + pub self_upgrade: bool, +} + +impl App { + pub fn open(paths: Paths, identity: Identity) -> std::io::Result { + let settings = state::load_settings(&paths)?.map(Arc::new); + let recipients = Recipients::load(&paths)?; + let spool = Spool::open(&paths.spool_in())?; + let events = Events::open(paths.events())?; + // Uploads don't survive a restart. + if let Ok(dir) = std::fs::read_dir(paths.blobs()) { + for entry in dir.flatten() { + let _ = std::fs::remove_file(entry.path()); + } + } + let (push, _) = broadcast::channel(256); + Ok(App { + paths, + identity: Mutex::new(identity), + settings: watch::Sender::new(settings), + settings_state: AtomicU64::new(1), + recipients: RwLock::new(recipients), + spool: Mutex::new(spool), + events: Mutex::new(events), + push, + link_clients: AtomicU32::new(0), + live_clients: AtomicU32::new(0), + next_conn: AtomicU64::new(1), + started_at: time::now(), + last_error: Mutex::new(None), + limiter: Mutex::new(Limiter::default()), + self_upgrade: std::env::var_os("INBUXA_RELAY_CONTAINER").is_none(), + }) + } + + pub fn conn_id(&self) -> u64 { + self.next_conn.fetch_add(1, Ordering::Relaxed) + } + + pub fn settings(&self) -> Option> { + self.settings.borrow().clone() + } + + pub fn relay_state(&self) -> String { + self.settings_state.load(Ordering::Relaxed).to_string() + } + + /// The `Relay` object changed (settings or status worth telling). + pub fn relay_changed(&self) { + let state = self.settings_state.fetch_add(1, Ordering::Relaxed) + 1; + self.notify(type_name::RELAY, state.to_string()); + } + + pub fn notify(&self, type_name: &'static str, state: String) { + let _ = self.push.send(Changed { type_name, state }); + } + + pub fn inbound_changed(&self) { + let state = self.spool.lock().expect("spool").state(); + self.notify(type_name::INBOUND, state); + } + + pub fn record(&self, kind: &str, data: serde_json::Map) { + let state = { + let mut events = self.events.lock().expect("events"); + events.record(kind, data); + events.state() + }; + self.notify(type_name::RELAY_EVENT, state); + } + + pub fn set_error(&self, error: impl Into) { + let error = error.into(); + eprintln!("inbuxa-relay: {error}"); + *self.last_error.lock().expect("last_error") = Some(error); + self.relay_changed(); + } + + /// Port 25 stays closed until the downstream has sent valid settings + /// and a recipient set (spec "Joining and reset", step 5). + pub fn mode(&self) -> Mode { + let configured = self.settings().is_some_and(|s| s.problems().is_empty()) + && self.recipients.read().expect("recipients").is_loaded(); + if !configured { + Mode::Unconfigured + } else if self.live_clients.load(Ordering::Relaxed) > 0 { + Mode::Live + } else { + Mode::Held + } + } + + pub fn status(&self) -> RelayStatus { + let (inbound, bytes) = { + let spool = self.spool.lock().expect("spool"); + (spool.count(), spool.bytes()) + }; + RelayStatus { + started_at: time::utc_date(self.started_at), + relay_version: VERSION.to_string(), + link_clients: self.link_clients.load(Ordering::Relaxed), + mode: self.mode(), + spool: SpoolStatus { + inbound, + outbound: 0, + bytes, + }, + recipient_state: self.recipients.read().expect("recipients").state(), + certificate_not_after: None, + last_error: self.last_error.lock().expect("last_error").clone(), + self_upgrade: self.self_upgrade, + } + } + + /// Whether the spool is too full for new mail: past 90% of the cap. + pub fn spool_full(&self, settings: &RelaySettings) -> bool { + let bytes = self.spool.lock().expect("spool").bytes(); + bytes.saturating_mul(10) >= settings.spool_max_bytes.saturating_mul(9) + || disk_nearly_full(&self.paths.spool_in()) + } + + /// Writes `status.json`, for `inbuxa-relay status`. + pub fn write_status(&self) { + if let Err(e) = state::write_json(&self.paths.status(), &self.status()) { + eprintln!("inbuxa-relay: could not write status: {e}"); + } + } +} + +#[cfg(all(target_os = "linux", target_pointer_width = "64"))] +fn disk_nearly_full(path: &std::path::Path) -> bool { + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + unsafe extern "C" { + fn statvfs(path: *const std::ffi::c_char, buf: *mut std::ffi::c_void) -> i32; + } + let Ok(cpath) = CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + // On 64-bit Linux, glibc and musl both lay struct statvfs out as eight + // bytes per field: bsize, frsize, blocks, bfree, bavail, ... in 112 + // bytes. The buffer is larger than that, so a different layout can + // only give a wrong answer, never a write past the end. + let mut buf = [0u64; 32]; + // SAFETY: a valid C string, and a buffer larger than struct statvfs. + let rc = unsafe { statvfs(cpath.as_ptr(), buf.as_mut_ptr().cast()) }; + let (blocks, avail) = (buf[2], buf[4]); + rc == 0 && blocks > 0 && avail.saturating_mul(10) < blocks +} + +#[cfg(not(all(target_os = "linux", target_pointer_width = "64")))] +fn disk_nearly_full(_: &std::path::Path) -> bool { + false +} + +/// Per-IP limits at the door (spec "At the door, always", step 1). +#[derive(Default)] +pub struct Limiter { + ips: HashMap, +} + +#[derive(Default)] +struct IpCount { + minute: u64, + connections: u32, + messages: u32, + open: u32, +} + +impl Limiter { + fn entry(&mut self, ip: IpAddr, now: u64) -> &mut IpCount { + let minute = now / 60; + let count = self.ips.entry(ip).or_default(); + if count.minute != minute { + count.minute = minute; + count.connections = 0; + count.messages = 0; + } + count + } + + /// A new session from `ip`: allowed, or not. + pub fn connect(&mut self, ip: IpAddr, s: &RelaySettings, now: u64) -> bool { + let count = self.entry(ip, now); + if count.connections >= s.rate_limits.connections_per_minute + || count.open >= s.rate_limits.concurrent_sessions + { + return false; + } + count.connections += 1; + count.open += 1; + true + } + + pub fn disconnect(&mut self, ip: IpAddr) { + if let Some(count) = self.ips.get_mut(&ip) { + count.open = count.open.saturating_sub(1); + } + } + + /// A new message from `ip`: allowed, or not. + pub fn message(&mut self, ip: IpAddr, s: &RelaySettings, now: u64) -> bool { + let count = self.entry(ip, now); + if count.messages >= s.rate_limits.messages_per_minute { + return false; + } + count.messages += 1; + true + } + + /// Forgets idle addresses. + pub fn sweep(&mut self, now: u64) { + let minute = now / 60; + self.ips.retain(|_, c| c.open > 0 || c.minute == minute); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use inbuxa_relay_proto::types::RateLimits; + + #[test] + fn limits_per_minute_and_open() { + let s = RelaySettings { + rate_limits: RateLimits { + connections_per_minute: 3, + messages_per_minute: 2, + concurrent_sessions: 2, + }, + ..Default::default() + }; + let ip: IpAddr = "192.0.2.1".parse().unwrap(); + let mut l = Limiter::default(); + assert!(l.connect(ip, &s, 0)); + assert!(l.connect(ip, &s, 0)); + assert!(!l.connect(ip, &s, 0), "two open at once"); + l.disconnect(ip); + assert!(l.connect(ip, &s, 0)); + l.disconnect(ip); + assert!(!l.connect(ip, &s, 0), "three a minute"); + assert!(l.connect(ip, &s, 60), "a new minute"); + assert!(l.message(ip, &s, 60)); + assert!(l.message(ip, &s, 60)); + assert!(!l.message(ip, &s, 60)); + l.sweep(200); + assert!(l.ips.contains_key(&ip), "still open"); + } +} diff --git a/crates/relay/src/events.rs b/crates/relay/src/events.rs new file mode 100644 index 00000000..d75740db --- /dev/null +++ b/crates/relay/src/events.rs @@ -0,0 +1,227 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Relay events, for the downstream's history and tracer. Kept until the +//! downstream acknowledges them (RL-22), in an append-only file that is +//! rewritten when events are acknowledged. +//! +//! Events carry no message content. They name addresses only where the +//! downstream already has them: the envelope of a message it receives. + +use crate::state::write_atomic; +use inbuxa_relay_proto::{time, types::RelayEvent}; +use serde_json::{Map, Value}; +use std::{ + collections::VecDeque, + fs::{self, OpenOptions}, + io::{self, BufRead, Write}, + path::PathBuf, +}; + +/// The most events kept; past it, the oldest go. +const MAX_EVENTS: usize = 10_000; + +pub struct Events { + path: PathBuf, + list: VecDeque<(u64, RelayEvent)>, + next: u64, + modseq: u64, + floor: u64, + destroyed: VecDeque<(u64, String)>, +} + +impl Events { + pub fn open(path: PathBuf) -> io::Result { + let mut list = VecDeque::new(); + let mut next = 1; + match fs::File::open(&path) { + Ok(file) => { + for line in io::BufReader::new(file).lines() { + let line = line?; + if let Ok(event) = serde_json::from_str::(&line) { + let seq = event + .id + .strip_prefix('e') + .and_then(|n| n.parse().ok()) + .unwrap_or(next); + next = next.max(seq + 1); + list.push_back((seq, event)); + } + } + } + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + // A state from before the restart can't be answered: the client + // starts over with a full /get. + Ok(Events { + path, + list, + next, + modseq: next, + floor: next, + destroyed: VecDeque::new(), + }) + } + + pub fn state(&self) -> String { + self.modseq.to_string() + } + + /// Records an event. A failure to write it is reported, not fatal: an + /// event must never hold mail. + pub fn record(&mut self, kind: &str, data: Map) -> String { + let seq = self.next; + self.next += 1; + self.modseq += 1; + let event = RelayEvent { + id: format!("e{seq}"), + at: time::utc_date(time::now()), + kind: kind.to_string(), + data, + }; + let line = serde_json::to_string(&event).expect("event serializes"); + let written = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + .and_then(|mut f| writeln!(f, "{line}")); + if let Err(e) = written { + eprintln!("inbuxa-relay: could not record an event: {e}"); + } + let id = event.id.clone(); + self.list.push_back((self.modseq, event)); + if self.list.len() > MAX_EVENTS { + self.drop_oldest(); + } + id + } + + fn drop_oldest(&mut self) { + while self.list.len() > MAX_EVENTS { + if let Some((_, event)) = self.list.pop_front() { + self.modseq += 1; + self.destroyed.push_back((self.modseq, event.id)); + } + } + self.trim_tombstones(); + let _ = self.rewrite(); + } + + fn trim_tombstones(&mut self) { + while self.destroyed.len() > MAX_EVENTS { + if let Some((seq, _)) = self.destroyed.pop_front() { + self.floor = seq; + } + } + } + + pub fn get(&self, ids: Option<&[String]>) -> (Vec, Vec) { + match ids { + None => (self.list.iter().map(|(_, e)| e.clone()).collect(), vec![]), + Some(ids) => { + let mut found = Vec::new(); + let mut missing = Vec::new(); + for id in ids { + match self.list.iter().find(|(_, e)| &e.id == id) { + Some((_, e)) => found.push(e.clone()), + None => missing.push(id.clone()), + } + } + (found, missing) + } + } + } + + /// `(created, destroyed)` since `since`, or `None` if it can't say. + pub fn changes(&self, since: &str) -> Option<(Vec, Vec)> { + let since: u64 = since.parse().ok()?; + if since < self.floor || since > self.modseq { + return None; + } + let created = self + .list + .iter() + .filter(|(seq, _)| *seq > since) + .map(|(_, e)| e.id.clone()) + .collect(); + let destroyed = self + .destroyed + .iter() + .filter(|(seq, _)| *seq > since) + .map(|(_, id)| id.clone()) + .collect(); + Some((created, destroyed)) + } + + /// The downstream has the events: forget them. Returns those removed. + pub fn acknowledge(&mut self, ids: &[String]) -> Vec { + let mut removed = Vec::new(); + self.list.retain(|(_, e)| { + if ids.contains(&e.id) { + removed.push(e.id.clone()); + false + } else { + true + } + }); + for id in &removed { + self.modseq += 1; + self.destroyed.push_back((self.modseq, id.clone())); + } + self.trim_tombstones(); + if !removed.is_empty() + && let Err(e) = self.rewrite() + { + eprintln!("inbuxa-relay: could not rewrite the event file: {e}"); + } + removed + } + + fn rewrite(&self) -> io::Result<()> { + let mut data = Vec::new(); + for (_, e) in &self.list { + data.extend_from_slice(&serde_json::to_vec(e).map_err(io::Error::other)?); + data.push(b'\n'); + } + write_atomic(&self.path, &data) + } +} + +/// Builds an event's data from pairs. +pub fn data(pairs: [(&str, Value); N]) -> Map { + pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn record_ack_reload() { + let dir = std::env::temp_dir().join(format!("inbuxa-relay-events-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("events.jsonl"); + + let mut ev = Events::open(path.clone()).unwrap(); + let s0 = ev.state(); + let a = ev.record("inbound.accepted", data([("id", json!("i1"))])); + let b = ev.record("smtp.rejected", data([("reply", json!("550 5.1.1"))])); + assert_eq!(ev.changes(&s0).unwrap().0, vec![a.clone(), b.clone()]); + let s1 = ev.state(); + assert_eq!(ev.acknowledge(std::slice::from_ref(&a)), vec![a.clone()]); + assert_eq!(ev.changes(&s1).unwrap().1, vec![a.clone()]); + + let ev = Events::open(path).unwrap(); + let (list, missing) = ev.get(Some(&[a.clone(), b.clone()])); + assert_eq!(list.len(), 1); + assert_eq!(list[0].id, b); + assert_eq!(missing, vec![a]); + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/relay/src/link/jmap.rs b/crates/relay/src/link/jmap.rs new file mode 100644 index 00000000..ed498e30 --- /dev/null +++ b/crates/relay/src/link/jmap.rs @@ -0,0 +1,831 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The relay's JMAP methods (spec "The relay's JMAP API"). +//! +//! One account, `relay`. No back-references: the downstream doesn't need +//! them. Before the join completes, only `Relay/join`, then `Relay/get` and +//! `Relay/set`, answer; everything else is `forbidden`. + +use crate::{app::App, link::tls::cert_pin, state::JoinStage}; +use inbuxa_relay_proto::{ + CAPABILITY, CORE_CAPABILITY, b64, + jmap::{Invocation, MethodError, Request, Response, method}, + recipient::{RecipientDomain, RecipientHash}, + time, token, + types::{RELAY_SINGLETON, RelaySettings, Verdict, type_name}, +}; +use rustls_pki_types::{CertificateDer, pem::PemObject}; +use serde_json::{Map, Value, json}; +use std::sync::Arc; + +pub const ACCOUNT_ID: &str = "relay"; +pub const MAX_CALLS: usize = 64; +const MAX_CHANGES: usize = 5000; +const CLAIM_LIMIT_MAX: usize = 100; +const LEASE_DEFAULT: u64 = 300; +const LEASE_MAX: u64 = 3600; + +/// A request-level failure (RFC 8620 ยง3.6.1). +#[derive(Debug)] +pub struct RequestError { + pub kind: &'static str, + pub status: u16, + pub detail: String, +} + +/// Who is calling: the connection, for leases. +pub struct Caller { + pub conn: u64, +} + +type Failure = (MethodError, Option); +type MethodResult = Result; + +fn err(kind: MethodError, text: impl Into) -> Failure { + (kind, Some(text.into())) +} + +pub fn handle(app: &App, caller: &Caller, request: Request) -> Result { + for cap in [CORE_CAPABILITY, CAPABILITY] { + if !request.using.iter().any(|u| u == cap) { + return Err(RequestError { + kind: inbuxa_relay_proto::jmap::request_error::UNKNOWN_CAPABILITY, + status: 400, + detail: format!("the request must use {cap}"), + }); + } + } + if request.method_calls.len() > MAX_CALLS { + return Err(RequestError { + kind: inbuxa_relay_proto::jmap::request_error::LIMIT, + status: 400, + detail: format!("at most {MAX_CALLS} method calls a request"), + }); + } + let mut responses = Vec::with_capacity(request.method_calls.len()); + for call in &request.method_calls { + let result = call_one(app, caller, call); + responses.push(match result { + Ok(Value::Object(args)) => { + Invocation(call.name().to_string(), args, call.call_id().into()) + } + Ok(_) => call.error(MethodError::ServerFail, None), + Err((kind, text)) => call.error(kind, text), + }); + } + Ok(Response { + method_responses: responses, + session_state: "1".into(), + created_ids: None, + }) +} + +fn call_one(app: &App, caller: &Caller, call: &Invocation) -> MethodResult { + let args = call.args(); + if args.get("accountId").and_then(Value::as_str) != Some(ACCOUNT_ID) { + return Err(err(MethodError::AccountNotFound, "the account is `relay`")); + } + let stage = app.identity.lock().expect("identity").stage; + let allowed = match stage { + JoinStage::Joined => true, + JoinStage::Waiting => call.name() == method::RELAY_JOIN, + JoinStage::Answered => matches!(call.name(), method::RELAY_GET | method::RELAY_SET), + }; + if !allowed { + return Err(err( + MethodError::Forbidden, + "the relay has not finished joining", + )); + } + match call.name() { + method::RELAY_JOIN => relay_join(app, args), + method::RELAY_GET => relay_get(app, args), + method::RELAY_SET => relay_set(app, args), + method::RECIPIENT_DOMAIN_GET => recipient_domain_get(app), + method::RECIPIENT_DOMAIN_SET => recipient_domain_set(app, args), + method::RECIPIENT_SET => recipient_set(app, args), + method::RECIPIENT_CHANGES => recipient_changes(app, args), + method::RECIPIENT_REPLACE => recipient_replace(app, args), + method::INBOUND_GET => inbound_get(app, args), + method::INBOUND_QUERY => inbound_query(app), + method::INBOUND_CHANGES => inbound_changes(app, args), + method::INBOUND_CLAIM => inbound_claim(app, caller, args), + method::INBOUND_SET => inbound_set(app, args), + method::RELAY_EVENT_GET => event_get(app, args), + method::RELAY_EVENT_CHANGES => event_changes(app, args), + method::RELAY_EVENT_SET => event_set(app, args), + _ => Err((MethodError::UnknownMethod, None)), + } +} + +fn string_list(args: &Map, key: &str) -> Result>, Failure> { + match args.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(items)) => items + .iter() + .map(|v| v.as_str().map(str::to_string)) + .collect::>>() + .map(Some) + .ok_or_else(|| { + err( + MethodError::InvalidArguments, + format!("{key} must be a list of strings"), + ) + }), + Some(_) => Err(err( + MethodError::InvalidArguments, + format!("{key} must be a list"), + )), + } +} + +fn object<'a>( + args: &'a Map, + key: &str, +) -> Result>, Failure> { + match args.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Object(map)) => Ok(Some(map)), + Some(_) => Err(err( + MethodError::InvalidArguments, + format!("{key} must be an object"), + )), + } +} + +fn set_error(kind: &str, description: impl Into) -> Value { + json!({ "type": kind, "description": description.into() }) +} + +// ---- Relay ---------------------------------------------------------------- + +fn relay_join(app: &App, args: &Map) -> MethodResult { + let nonce = args + .get("nonce") + .and_then(Value::as_str) + .and_then(b64::decode) + .filter(|n| (16..=64).contains(&n.len())) + .ok_or_else(|| { + err( + MethodError::InvalidArguments, + "nonce: 16 to 64 bytes, base64url", + ) + })?; + let mut identity = app.identity.lock().expect("identity"); + if identity.stage != JoinStage::Waiting { + return Err(err( + MethodError::Forbidden, + "the relay has already answered a join", + )); + } + if time::now() >= identity.join_expires_at { + return Err(err( + MethodError::Forbidden, + "the join token has expired: reset and join again", + )); + } + let secret = identity + .join_secret + .ok_or_else(|| err(MethodError::Forbidden, "no join secret"))?; + let pin = CertificateDer::from_pem_slice(identity.link_cert_pem.as_bytes()) + .ok() + .and_then(|der| cert_pin(&der)) + .ok_or_else(|| { + err( + MethodError::ServerFail, + "the link certificate is unreadable", + ) + })?; + let proof = token::join_proof(&secret, &nonce, &pin); + identity.stage = JoinStage::Answered; + identity + .save(&app.paths) + .map_err(|e| err(MethodError::ServerFail, e.to_string()))?; + drop(identity); + app.record("relay.join-answered", Map::new()); + Ok(json!({ + "accountId": ACCOUNT_ID, + "proof": b64::encode(&proof), + "relayPin": b64::encode(&pin), + })) +} + +fn relay_object(app: &App) -> Value { + let settings = app.settings().map(|s| (*s).clone()).unwrap_or_default(); + let mut value = serde_json::to_value(&settings).expect("settings serialize"); + let obj = value.as_object_mut().expect("object"); + // Secrets the relay holds are never read back. + obj.remove("recipientKey"); + if let Some(Value::Object(cert)) = obj.get_mut("smtpCertificate") { + cert.remove("keyPem"); + } + obj.insert("id".into(), Value::String(RELAY_SINGLETON.into())); + obj.insert( + "status".into(), + serde_json::to_value(app.status()).expect("status serializes"), + ); + obj.insert( + "relayVersion".into(), + Value::String(crate::app::VERSION.into()), + ); + value +} + +fn relay_get(app: &App, args: &Map) -> MethodResult { + let ids = string_list(args, "ids")?; + let wanted = ids + .as_ref() + .is_none_or(|ids| ids.iter().any(|i| i == RELAY_SINGLETON)); + let not_found: Vec = ids + .unwrap_or_default() + .into_iter() + .filter(|i| i != RELAY_SINGLETON) + .collect(); + Ok(json!({ + "accountId": ACCOUNT_ID, + "state": app.relay_state(), + "list": if wanted { vec![relay_object(app)] } else { vec![] }, + "notFound": not_found, + })) +} + +/// Read-only on `Relay`: what only the relay sets. +const RELAY_READ_ONLY: &[&str] = &["id", "status", "relayVersion"]; + +fn relay_set(app: &App, args: &Map) -> MethodResult { + let old_state = app.relay_state(); + if let Some(if_in) = args.get("ifInState").and_then(Value::as_str) + && if_in != old_state + { + return Err((MethodError::StateMismatch, None)); + } + let mut not_updated = Map::new(); + let mut updated = Map::new(); + for key in ["create", "destroy"] { + if args.get(key).is_some_and(|v| !v.is_null()) { + return Err(err( + MethodError::InvalidArguments, + "Relay can only be updated", + )); + } + } + if let Some(update) = object(args, "update")? { + for (id, patch) in update { + if id != RELAY_SINGLETON { + not_updated.insert( + id.clone(), + set_error("notFound", "the only Relay is singleton"), + ); + continue; + } + match apply_settings(app, patch) { + Ok(()) => { + updated.insert(id.clone(), Value::Null); + } + Err(e) => { + not_updated.insert(id.clone(), e); + } + } + } + } + Ok(json!({ + "accountId": ACCOUNT_ID, + "oldState": old_state, + "newState": app.relay_state(), + "updated": updated, + "notUpdated": not_updated, + })) +} + +fn apply_settings(app: &App, patch: &Value) -> Result<(), Value> { + let Value::Object(patch) = patch else { + return Err(set_error("invalidPatch", "a patch is an object")); + }; + let current = app.settings().map(|s| (*s).clone()).unwrap_or_default(); + let mut merged = serde_json::to_value(¤t).expect("settings serialize"); + let defaults = serde_json::to_value(RelaySettings::default()).expect("settings serialize"); + let obj = merged.as_object_mut().expect("object"); + for (key, value) in patch { + if key.contains('/') { + return Err(set_error("invalidPatch", "set whole properties, not paths")); + } + if RELAY_READ_ONLY.contains(&key.as_str()) { + return Err(set_error( + "invalidProperties", + format!("{key} is set by the relay"), + )); + } + if value.is_null() { + match defaults.get(key) { + Some(default) => obj.insert(key.clone(), default.clone()), + None => obj.remove(key), + }; + } else { + obj.insert(key.clone(), value.clone()); + } + } + // An unchanged key on the certificate keeps the stored one. + if let (Some(Value::Object(new)), Some(old)) = + (obj.get_mut("smtpCertificate"), ¤t.smtp_certificate) + && new.get("keyPem").is_none_or(|k| k.as_str() == Some("")) + && new.get("chainPem").and_then(Value::as_str) == Some(old.chain_pem.as_str()) + { + new.insert("keyPem".into(), Value::String(old.key_pem.clone())); + } + let settings: RelaySettings = serde_json::from_value(merged) + .map_err(|e| set_error("invalidProperties", e.to_string()))?; + let problems = settings.problems(); + if !problems.is_empty() { + return Err(set_error("invalidProperties", problems.join("; "))); + } + if let Some(cert) = &settings.smtp_certificate + && let Err(e) = crate::smtp::tls_config(&settings.hostname, Some(cert)) + { + return Err(set_error( + "invalidProperties", + format!("smtpCertificate: {e}"), + )); + } + crate::state::save_settings(&app.paths, &settings) + .map_err(|e| set_error("serverFail", e.to_string()))?; + app.settings.send_replace(Some(Arc::new(settings))); + + // The first Relay/set after the proof completes the join. + let mut identity = app.identity.lock().expect("identity"); + let joined_now = identity.stage == JoinStage::Answered; + if joined_now { + identity.stage = JoinStage::Joined; + identity.join_secret = None; + identity + .save(&app.paths) + .map_err(|e| set_error("serverFail", e.to_string()))?; + } + drop(identity); + if joined_now { + app.record("relay.joined", Map::new()); + } + app.relay_changed(); + Ok(()) +} + +// ---- Recipients ----------------------------------------------------------- + +fn recipient_domain_get(app: &App) -> MethodResult { + let recipients = app.recipients.read().expect("recipients"); + let list: Vec = recipients + .domains() + .map(|d| { + let mut v = serde_json::to_value(d).expect("domain serializes"); + v["id"] = Value::String(d.domain.clone()); + v + }) + .collect(); + Ok(json!({ + "accountId": ACCOUNT_ID, + "state": recipients.state(), + "list": list, + "notFound": [], + })) +} + +fn recipient_domain_set(app: &App, args: &Map) -> MethodResult { + let mut recipients = app.recipients.write().expect("recipients"); + let old_state = recipients.state(); + let mut created = Map::new(); + let mut not_created = Map::new(); + let mut updated = Map::new(); + let mut not_updated = Map::new(); + let mut destroyed = Vec::new(); + let mut not_destroyed = Map::new(); + if let Some(create) = object(args, "create")? { + for (key, value) in create { + match serde_json::from_value::(value.clone()) { + Ok(domain) if !domain.domain.is_empty() => { + let id = domain.domain.trim_end_matches('.').to_lowercase(); + recipients.put_domain(domain); + created.insert(key.clone(), json!({ "id": id })); + } + Ok(_) => { + not_created.insert( + key.clone(), + set_error("invalidProperties", "domain is required"), + ); + } + Err(e) => { + not_created.insert(key.clone(), set_error("invalidProperties", e.to_string())); + } + } + } + } + if let Some(update) = object(args, "update")? { + for (id, patch) in update { + let Some(current) = recipients.domain(id).cloned() else { + not_updated.insert(id.clone(), set_error("notFound", "no such domain")); + continue; + }; + let mut merged = serde_json::to_value(¤t).expect("domain serializes"); + if let (Some(obj), Value::Object(patch)) = (merged.as_object_mut(), patch) { + for (k, v) in patch { + obj.insert(k.clone(), v.clone()); + } + } + match serde_json::from_value::(merged) { + Ok(domain) if domain.domain == current.domain => { + recipients.put_domain(domain); + updated.insert(id.clone(), Value::Null); + } + Ok(_) => { + not_updated.insert( + id.clone(), + set_error("invalidProperties", "domain can't change"), + ); + } + Err(e) => { + not_updated.insert(id.clone(), set_error("invalidProperties", e.to_string())); + } + } + } + } + for id in string_list(args, "destroy")?.unwrap_or_default() { + if recipients.remove_domain(&id) { + destroyed.push(id); + } else { + not_destroyed.insert(id, set_error("notFound", "no such domain")); + } + } + let new_state = save_recipients(app, &recipients)?; + Ok(json!({ + "accountId": ACCOUNT_ID, + "oldState": old_state, + "newState": new_state, + "created": created, + "notCreated": not_created, + "updated": updated, + "notUpdated": not_updated, + "destroyed": destroyed, + "notDestroyed": not_destroyed, + })) +} + +fn save_recipients( + app: &App, + recipients: &crate::recipients::Recipients, +) -> Result { + recipients + .save(&app.paths) + .map_err(|e| err(MethodError::ServerFail, e.to_string()))?; + let state = recipients.state(); + app.notify(type_name::RECIPIENT, state.clone()); + app.notify(type_name::RECIPIENT_DOMAIN, state.clone()); + Ok(state) +} + +fn parse_hash(text: &str) -> Option { + b64::decode(text).and_then(|b| b.try_into().ok()) +} + +fn recipient_set(app: &App, args: &Map) -> MethodResult { + let mut created = Map::new(); + let mut not_created = Map::new(); + let mut add = Vec::new(); + if let Some(create) = object(args, "create")? { + for (key, value) in create { + match value + .get("hash") + .and_then(Value::as_str) + .and_then(parse_hash) + { + Some(hash) => { + add.push(hash); + created.insert(key.clone(), json!({ "id": b64::encode(&hash) })); + } + None => { + not_created.insert( + key.clone(), + set_error("invalidProperties", "hash: 32 bytes, base64url"), + ); + } + } + } + } + let mut remove = Vec::new(); + let mut destroyed = Vec::new(); + let mut not_destroyed = Map::new(); + for id in string_list(args, "destroy")?.unwrap_or_default() { + match parse_hash(&id) { + Some(hash) => { + remove.push(hash); + destroyed.push(id); + } + None => { + not_destroyed.insert(id, set_error("notFound", "not a recipient id")); + } + } + } + let mut recipients = app.recipients.write().expect("recipients"); + let old_state = recipients.state(); + recipients.apply(&add, &remove); + let new_state = save_recipients(app, &recipients)?; + drop(recipients); + app.relay_changed(); + Ok(json!({ + "accountId": ACCOUNT_ID, + "oldState": old_state, + "newState": new_state, + "created": created, + "notCreated": not_created, + "updated": {}, + "destroyed": destroyed, + "notDestroyed": not_destroyed, + })) +} + +fn recipient_changes(app: &App, args: &Map) -> MethodResult { + let since = args.get("sinceState").and_then(Value::as_str).unwrap_or(""); + let state = app.recipients.read().expect("recipients").state(); + if since != state { + // The relay doesn't log recipient changes: the downstream holds the + // set, and sends it again with Recipient/replace. + return Err((MethodError::CannotCalculateChanges, None)); + } + Ok(json!({ + "accountId": ACCOUNT_ID, + "oldState": since, + "newState": state, + "hasMoreChanges": false, + "created": [], "updated": [], "destroyed": [], + })) +} + +fn recipient_replace(app: &App, args: &Map) -> MethodResult { + let blob_id = args + .get("blobId") + .and_then(Value::as_str) + .filter(|id| crate::link::is_blob_id(id)) + .ok_or_else(|| err(MethodError::InvalidArguments, "blobId: an uploaded blob"))?; + let path = app.paths.blobs().join(blob_id); + let file = + std::fs::read(&path).map_err(|_| err(MethodError::InvalidArguments, "no such blob"))?; + let mut recipients = app.recipients.write().expect("recipients"); + let old_state = recipients.state(); + let count = recipients + .replace(&file) + .map_err(|e| err(MethodError::InvalidArguments, e))?; + let new_state = save_recipients(app, &recipients)?; + drop(recipients); + let _ = std::fs::remove_file(path); + app.record( + "recipients.replaced", + crate::events::data([("count", json!(count))]), + ); + app.relay_changed(); + Ok(json!({ + "accountId": ACCOUNT_ID, + "oldState": old_state, + "newState": new_state, + "count": count, + })) +} + +// ---- Inbound -------------------------------------------------------------- + +fn inbound_get(app: &App, args: &Map) -> MethodResult { + let ids = string_list(args, "ids")?; + let now = time::now(); + let spool = app.spool.lock().expect("spool"); + let ids = ids.unwrap_or_else(|| spool.ids(now)); + let mut list = Vec::new(); + let mut not_found = Vec::new(); + for id in ids { + match spool.get(&id, now) { + Some(inbound) => list.push(serde_json::to_value(inbound).expect("inbound serializes")), + None => not_found.push(id), + } + } + Ok(json!({ + "accountId": ACCOUNT_ID, + "state": spool.state(), + "list": list, + "notFound": not_found, + })) +} + +fn inbound_query(app: &App) -> MethodResult { + let spool = app.spool.lock().expect("spool"); + let ids = spool.ids(time::now()); + Ok(json!({ + "accountId": ACCOUNT_ID, + "queryState": spool.state(), + "canCalculateChanges": false, + "position": 0, + "total": ids.len(), + "ids": ids, + })) +} + +fn inbound_changes(app: &App, args: &Map) -> MethodResult { + let since = args + .get("sinceState") + .and_then(Value::as_str) + .ok_or_else(|| err(MethodError::InvalidArguments, "sinceState is required"))?; + let max = args + .get("maxChanges") + .and_then(Value::as_u64) + .map_or(MAX_CHANGES, |m| (m as usize).clamp(1, MAX_CHANGES)); + let spool = app.spool.lock().expect("spool"); + let changes = spool + .changes(since, max) + .ok_or((MethodError::CannotCalculateChanges, None))?; + Ok(json!({ + "accountId": ACCOUNT_ID, + "oldState": changes.old_state, + "newState": changes.new_state, + "hasMoreChanges": changes.has_more, + "created": changes.created, + "updated": changes.updated, + "destroyed": changes.destroyed, + })) +} + +fn inbound_claim(app: &App, caller: &Caller, args: &Map) -> MethodResult { + let limit = args + .get("limit") + .and_then(Value::as_u64) + .map_or(10, |l| (l as usize).clamp(1, CLAIM_LIMIT_MAX)); + let lease = args + .get("leaseSeconds") + .and_then(Value::as_u64) + .map_or(LEASE_DEFAULT, |l| l.clamp(10, LEASE_MAX)); + let (ids, state) = { + let mut spool = app.spool.lock().expect("spool"); + let ids = spool.claim(caller.conn, limit, lease, time::now()); + (ids, spool.state()) + }; + if !ids.is_empty() { + app.notify(type_name::INBOUND, state.clone()); + } + Ok(json!({ + "accountId": ACCOUNT_ID, + "ids": ids, + "newState": state, + })) +} + +fn inbound_set(app: &App, args: &Map) -> MethodResult { + let mut updated = Map::new(); + let mut not_updated = Map::new(); + let mut destroyed = Vec::new(); + let mut not_destroyed = Map::new(); + if object(args, "create")?.is_some_and(|c| !c.is_empty()) { + return Err(err( + MethodError::InvalidArguments, + "only the relay creates Inbound", + )); + } + let mut spool = app.spool.lock().expect("spool"); + let old_state = spool.state(); + if let Some(update) = object(args, "update")? { + for (id, patch) in update { + let verdict = patch + .get("verdict") + .cloned() + .ok_or(()) + .and_then(|v| serde_json::from_value::(v).map_err(|_| ())); + let only_verdict = patch.as_object().is_some_and(|p| p.len() == 1); + match verdict { + Ok(verdict) if only_verdict => match spool.set_verdict(id, verdict) { + Ok(()) => { + updated.insert(id.clone(), Value::Null); + } + Err(e) => { + let (kind, text) = match e { + crate::spool::VerdictError::NotFound => ("notFound", "no such message"), + crate::spool::VerdictError::NotPending => ( + "invalidProperties", + "the message isn't waiting for a verdict", + ), + crate::spool::VerdictError::Invalid => { + ("invalidProperties", "the reply doesn't fit the verdict") + } + }; + not_updated.insert(id.clone(), set_error(kind, text)); + } + }, + _ => { + not_updated.insert( + id.clone(), + set_error("invalidProperties", "only verdict can be set"), + ); + } + } + } + } + for id in string_list(args, "destroy")?.unwrap_or_default() { + if spool.destroy(&id) { + destroyed.push(id); + } else { + not_destroyed.insert(id, set_error("notFound", "no such message")); + } + } + let new_state = spool.state(); + drop(spool); + if new_state != old_state { + app.notify(type_name::INBOUND, new_state.clone()); + } + Ok(json!({ + "accountId": ACCOUNT_ID, + "oldState": old_state, + "newState": new_state, + "updated": updated, + "notUpdated": not_updated, + "destroyed": destroyed, + "notDestroyed": not_destroyed, + })) +} + +// ---- RelayEvent ----------------------------------------------------------- + +fn event_get(app: &App, args: &Map) -> MethodResult { + let ids = string_list(args, "ids")?; + let events = app.events.lock().expect("events"); + let (list, not_found) = events.get(ids.as_deref()); + Ok(json!({ + "accountId": ACCOUNT_ID, + "state": events.state(), + "list": list, + "notFound": not_found, + })) +} + +fn event_changes(app: &App, args: &Map) -> MethodResult { + let since = args + .get("sinceState") + .and_then(Value::as_str) + .ok_or_else(|| err(MethodError::InvalidArguments, "sinceState is required"))?; + let events = app.events.lock().expect("events"); + let (created, destroyed) = events + .changes(since) + .ok_or((MethodError::CannotCalculateChanges, None))?; + Ok(json!({ + "accountId": ACCOUNT_ID, + "oldState": since, + "newState": events.state(), + "hasMoreChanges": false, + "created": created, + "updated": [], + "destroyed": destroyed, + })) +} + +fn event_set(app: &App, args: &Map) -> MethodResult { + if object(args, "create")?.is_some_and(|c| !c.is_empty()) + || object(args, "update")?.is_some_and(|u| !u.is_empty()) + { + return Err(err( + MethodError::InvalidArguments, + "events can only be destroyed", + )); + } + let ids = string_list(args, "destroy")?.unwrap_or_default(); + let mut events = app.events.lock().expect("events"); + let old_state = events.state(); + let destroyed = events.acknowledge(&ids); + let not_destroyed: Map = ids + .iter() + .filter(|id| !destroyed.contains(id)) + .map(|id| (id.clone(), set_error("notFound", "no such event"))) + .collect(); + let new_state = events.state(); + drop(events); + if new_state != old_state { + app.notify(type_name::RELAY_EVENT, new_state.clone()); + } + Ok(json!({ + "accountId": ACCOUNT_ID, + "oldState": old_state, + "newState": new_state, + "created": {}, + "updated": {}, + "destroyed": destroyed, + "notDestroyed": not_destroyed, + })) +} + +/// The state of one type, for `StateChange`s and the session. +pub fn states(app: &App) -> Vec<(&'static str, String)> { + let recipient = app.recipients.read().expect("recipients").state(); + vec![ + (type_name::RELAY, app.relay_state()), + (type_name::RECIPIENT, recipient.clone()), + (type_name::RECIPIENT_DOMAIN, recipient), + (type_name::INBOUND, app.spool.lock().expect("spool").state()), + ( + type_name::RELAY_EVENT, + app.events.lock().expect("events").state(), + ), + ] +} diff --git a/crates/relay/src/link/mod.rs b/crates/relay/src/link/mod.rs new file mode 100644 index 00000000..d7e65390 --- /dev/null +++ b/crates/relay/src/link/mod.rs @@ -0,0 +1,563 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The link (RL-5): JMAP over HTTP/1.1 and WebSocket (RFC 8887), behind +//! pinned mutual TLS. The downstream dials in; nothing here dials out. +//! +//! Routes: `GET /jmap/session`, `POST /jmap/api`, `GET /jmap/ws`, +//! `POST /jmap/upload/relay` and `GET /jmap/download/relay/`. + +pub mod jmap; +pub mod tls; + +use crate::app::App; +use futures_util::{SinkExt, StreamExt}; +use http_body_util::{BodyExt, Full, Limited, StreamBody, combinators::UnsyncBoxBody}; +use hyper::{ + Method, Request, Response, StatusCode, + body::{Bytes, Frame, Incoming}, + header, + service::service_fn, +}; +use hyper_util::rt::TokioIo; +use inbuxa_relay_proto::{ + CAPABILITY, CORE_CAPABILITY, PROTOCOL_VERSION, + jmap::{ + self as proto, Account, ClientFrame, ServerFrame, Session, WEBSOCKET_CAPABILITY, + websocket_capability, + }, + types::{Capability, type_name}, +}; +use std::{ + collections::{BTreeMap, HashMap}, + net::IpAddr, + sync::{Arc, Mutex, atomic::Ordering}, + time::Duration, +}; +use tokio::{io::AsyncReadExt, net::TcpListener}; +use tokio_rustls::TlsAcceptor; +use tokio_tungstenite::{ + WebSocketStream, + tungstenite::{Message, handshake::derive_accept_key, protocol::Role}, +}; + +/// Largest upload: a recipient file for about two million addresses. +pub const MAX_BLOB_SIZE: u64 = 64 * 1024 * 1024; +/// Largest JMAP request. +const MAX_REQUEST_SIZE: usize = 10 * 1024 * 1024; +/// TLS handshakes a minute from one address. +const HANDSHAKES_PER_MINUTE: u32 = 30; + +type Body = UnsyncBoxBody; + +fn full(bytes: impl Into) -> Body { + Full::new(bytes.into()) + .map_err(|never| match never {}) + .boxed_unsync() +} + +fn json_response(status: StatusCode, value: &impl serde::Serialize) -> Response { + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/json") + .header(header::CACHE_CONTROL, "no-store") + .body(full(serde_json::to_vec(value).expect("serializes"))) + .expect("response") +} + +fn problem(status: StatusCode, kind: &str, detail: &str) -> Response { + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/problem+json") + .body(full( + serde_json::to_vec(&serde_json::json!({ + "type": kind, "status": status.as_u16(), "detail": detail + })) + .expect("serializes"), + )) + .expect("response") +} + +pub fn is_blob_id(id: &str) -> bool { + id.len() == 25 && id.starts_with('b') && id[1..].bytes().all(|b| b.is_ascii_hexdigit()) +} + +fn new_blob_id() -> String { + let mut id = crate::spool::new_id(); + id.replace_range(0..1, "b"); + id +} + +#[derive(Default)] +struct Handshakes { + ips: HashMap, +} + +impl Handshakes { + fn allow(&mut self, ip: IpAddr, now: u64) -> bool { + let minute = now / 60; + if self.ips.len() > 10_000 { + self.ips.retain(|_, (m, _)| *m == minute); + } + let entry = self.ips.entry(ip).or_insert((minute, 0)); + if entry.0 != minute { + *entry = (minute, 0); + } + entry.1 += 1; + entry.1 <= HANDSHAKES_PER_MINUTE + } +} + +/// Runs the link listener until the process ends. +pub async fn serve(app: Arc, listener: TcpListener) -> std::io::Result<()> { + let config = { + let identity = app.identity.lock().expect("identity"); + tls::server_config( + &identity.link_key_pem, + &identity.link_cert_pem, + identity.ca_pin, + ) + .map_err(std::io::Error::other)? + }; + let acceptor = TlsAcceptor::from(Arc::new(config)); + if let Ok(addr) = listener.local_addr() { + eprintln!("inbuxa-relay: link listening on {addr}"); + } + let handshakes = Arc::new(Mutex::new(Handshakes::default())); + loop { + let (tcp, peer) = match listener.accept().await { + Ok(pair) => pair, + Err(e) => { + eprintln!("inbuxa-relay: link accept failed: {e}"); + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + }; + let now = inbuxa_relay_proto::time::now(); + if !handshakes.lock().expect("handshakes").allow(peer.ip(), now) { + continue; + } + let app = app.clone(); + let acceptor = acceptor.clone(); + tokio::spawn(async move { + let tls = + match tokio::time::timeout(Duration::from_secs(10), acceptor.accept(tcp)).await { + Ok(Ok(tls)) => tls, + // No client certificate, the wrong CA, or a timeout: the + // peer gets a TLS alert and nothing more. + _ => return, + }; + let conn = app.conn_id(); + let service = { + let app = app.clone(); + service_fn(move |req| { + let app = app.clone(); + async move { Ok::<_, std::convert::Infallible>(route(app, conn, req).await) } + }) + }; + let _ = hyper::server::conn::http1::Builder::new() + .timer(hyper_util::rt::TokioTimer::new()) + .header_read_timeout(Duration::from_secs(30)) + .serve_connection(TokioIo::new(tls), service) + .with_upgrades() + .await; + let released = app.spool.lock().expect("spool").release_conn(conn); + if released { + app.inbound_changed(); + } + }); + } +} + +async fn route(app: Arc, conn: u64, req: Request) -> Response { + let path = req.uri().path().to_string(); + match (req.method(), path.as_str()) { + (&Method::GET, "/jmap/session" | "/.well-known/jmap") => session(&app, &req), + (&Method::POST, "/jmap/api") => api(app, conn, req).await, + (&Method::GET, "/jmap/ws") => websocket(app, conn, req), + (&Method::POST, "/jmap/upload/relay") => upload(app, req).await, + (&Method::GET, p) if p.starts_with("/jmap/download/relay/") => { + download(app, &p["/jmap/download/relay/".len()..]).await + } + _ => problem(StatusCode::NOT_FOUND, "about:blank", "not found"), + } +} + +fn base_url(req: &Request) -> String { + let host = req + .headers() + .get(header::HOST) + .and_then(|h| h.to_str().ok()) + .filter(|h| h.bytes().all(|b| b.is_ascii_graphic())) + .unwrap_or("relay.invalid"); + format!("https://{host}") +} + +fn session(app: &App, req: &Request) -> Response { + let base = base_url(req); + let capability = Capability { + protocol_version: PROTOCOL_VERSION, + relay_version: crate::app::VERSION.into(), + max_blob_size: MAX_BLOB_SIZE, + features: vec![], + }; + let ws_url = format!("{}/jmap/ws", base.replacen("https://", "wss://", 1)); + let core = serde_json::json!({ + "maxSizeUpload": MAX_BLOB_SIZE, + "maxConcurrentUpload": 4, + "maxSizeRequest": MAX_REQUEST_SIZE, + "maxConcurrentRequests": 8, + "maxCallsInRequest": jmap::MAX_CALLS, + "maxObjectsInGet": 5000, + "maxObjectsInSet": 5000, + "collationAlgorithms": [], + }); + let capability = serde_json::to_value(capability).expect("serializes"); + let session = Session { + capabilities: BTreeMap::from([ + (CORE_CAPABILITY.to_string(), core), + (CAPABILITY.to_string(), capability.clone()), + ( + WEBSOCKET_CAPABILITY.to_string(), + websocket_capability(&ws_url), + ), + ]), + accounts: BTreeMap::from([( + jmap::ACCOUNT_ID.to_string(), + Account { + name: app.identity.lock().expect("identity").relay_id.clone(), + is_personal: false, + is_read_only: false, + account_capabilities: BTreeMap::from([(CAPABILITY.to_string(), capability)]), + }, + )]), + primary_accounts: BTreeMap::from([(CAPABILITY.to_string(), jmap::ACCOUNT_ID.to_string())]), + username: "downstream".into(), + api_url: format!("{base}/jmap/api"), + download_url: format!("{base}/jmap/download/{{accountId}}/{{blobId}}"), + upload_url: format!("{base}/jmap/upload/{{accountId}}"), + event_source_url: String::new(), + state: "1".into(), + }; + json_response(StatusCode::OK, &session) +} + +async fn api(app: Arc, conn: u64, req: Request) -> Response { + let body = match Limited::new(req.into_body(), MAX_REQUEST_SIZE) + .collect() + .await + { + Ok(body) => body.to_bytes(), + Err(_) => { + return problem( + StatusCode::PAYLOAD_TOO_LARGE, + proto::request_error::LIMIT, + "the request is too large", + ); + } + }; + let request: proto::Request = match serde_json::from_slice::(&body) { + Err(_) => { + return problem( + StatusCode::BAD_REQUEST, + proto::request_error::NOT_JSON, + "not JSON", + ); + } + Ok(value) => match serde_json::from_value(value) { + Ok(request) => request, + Err(e) => { + return problem( + StatusCode::BAD_REQUEST, + proto::request_error::NOT_REQUEST, + &e.to_string(), + ); + } + }, + }; + match jmap::handle(&app, &jmap::Caller { conn }, request) { + Ok(response) => json_response(StatusCode::OK, &response), + Err(e) => problem( + StatusCode::from_u16(e.status).unwrap_or(StatusCode::BAD_REQUEST), + e.kind, + &e.detail, + ), + } +} + +async fn upload(app: Arc, req: Request) -> Response { + let body = match Limited::new(req.into_body(), MAX_BLOB_SIZE as usize) + .collect() + .await + { + Ok(body) => body.to_bytes(), + Err(_) => { + return problem( + StatusCode::PAYLOAD_TOO_LARGE, + proto::request_error::LIMIT, + "the upload is too large", + ); + } + }; + let blob_id = new_blob_id(); + let path = app.paths.blobs().join(&blob_id); + if let Err(e) = tokio::fs::write(&path, &body).await { + app.set_error(format!("could not store an upload: {e}")); + return problem( + StatusCode::INTERNAL_SERVER_ERROR, + "about:blank", + "could not store the upload", + ); + } + json_response( + StatusCode::CREATED, + &serde_json::json!({ + "accountId": jmap::ACCOUNT_ID, + "blobId": blob_id, + "type": "application/octet-stream", + "size": body.len(), + }), + ) +} + +/// Downloads a held message: the sealed stream, exactly as stored. +async fn download(app: Arc, rest: &str) -> Response { + let blob_id = rest.split('/').next().unwrap_or(""); + if !crate::spool::is_valid_id(blob_id) || !app.spool.lock().expect("spool").contains(blob_id) { + return problem(StatusCode::NOT_FOUND, "about:blank", "no such blob"); + } + let path = app.spool.lock().expect("spool").msg_path(blob_id); + let file = match tokio::fs::File::open(&path).await { + Ok(file) => file, + Err(_) => return problem(StatusCode::NOT_FOUND, "about:blank", "no such blob"), + }; + let len = file.metadata().await.map(|m| m.len()).unwrap_or(0); + let stream = futures_util::stream::unfold(file, |mut file| async move { + let mut buf = vec![0u8; 64 * 1024]; + match file.read(&mut buf).await { + Ok(0) => None, + Ok(n) => { + buf.truncate(n); + Some((Ok(Frame::data(Bytes::from(buf))), file)) + } + Err(e) => Some((Err(e), file)), + } + }); + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header(header::CONTENT_LENGTH, len) + .header(header::CACHE_CONTROL, "no-store") + .body(StreamBody::new(stream).boxed_unsync()) + .expect("response") +} + +fn websocket(app: Arc, conn: u64, mut req: Request) -> Response { + let headers = req.headers(); + let is_upgrade = headers + .get(header::UPGRADE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.eq_ignore_ascii_case("websocket")); + let protocols_ok = headers + .get_all(header::SEC_WEBSOCKET_PROTOCOL) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|v| v.split(',')) + .any(|p| p.trim() == "jmap"); + let Some(key) = headers.get(header::SEC_WEBSOCKET_KEY).cloned() else { + return problem( + StatusCode::BAD_REQUEST, + "about:blank", + "not a WebSocket request", + ); + }; + if !is_upgrade || !protocols_ok { + return problem( + StatusCode::BAD_REQUEST, + "about:blank", + "a WebSocket with the jmap subprotocol is required", + ); + } + let accept = derive_accept_key(key.as_bytes()); + let upgrade = hyper::upgrade::on(&mut req); + tokio::spawn(async move { + let Ok(upgraded) = upgrade.await else { + return; + }; + let ws = WebSocketStream::from_raw_socket(TokioIo::new(upgraded), Role::Server, None).await; + run_websocket(app, conn, ws).await; + }); + Response::builder() + .status(StatusCode::SWITCHING_PROTOCOLS) + .header(header::UPGRADE, "websocket") + .header(header::CONNECTION, "Upgrade") + .header(header::SEC_WEBSOCKET_ACCEPT, accept) + .header(header::SEC_WEBSOCKET_PROTOCOL, "jmap") + .body(full(Bytes::new())) + .expect("response") +} + +/// Push settings for one WebSocket. +struct PushState { + /// `None` when push is off; `Some(None)` for every type. + types: Option>>, + live: bool, +} + +impl PushState { + fn wants(&self, type_name: &str) -> bool { + match &self.types { + None => false, + Some(None) => true, + Some(Some(types)) => types.iter().any(|t| t == type_name), + } + } +} + +async fn run_websocket(app: Arc, conn: u64, ws: WebSocketStream) +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + app.link_clients.fetch_add(1, Ordering::Relaxed); + app.relay_changed(); + let (mut sink, mut stream) = ws.split(); + let mut changes = app.push.subscribe(); + let mut push = PushState { + types: None, + live: false, + }; + let idle = Duration::from_secs(120); + loop { + tokio::select! { + frame = tokio::time::timeout(idle, stream.next()) => { + let message = match frame { + Ok(Some(Ok(message))) => message, + // Closed, broken, or silent past two pings. + _ => break, + }; + let text = match message { + Message::Text(text) => text.to_string(), + Message::Ping(data) => { + if sink.send(Message::Pong(data)).await.is_err() { break; } + continue; + } + Message::Pong(_) => continue, + Message::Close(_) => break, + Message::Binary(_) | Message::Frame(_) => { + let reply = ServerFrame::RequestError { + request_id: None, + kind: proto::request_error::NOT_JSON.into(), + status: 400, + detail: Some("frames are JSON text".into()), + }; + if send(&mut sink, &reply).await.is_err() { break; } + continue; + } + }; + if text.len() > MAX_REQUEST_SIZE { + break; + } + let reply = match serde_json::from_str::(&text) { + Err(e) => Some(ServerFrame::RequestError { + request_id: None, + kind: proto::request_error::NOT_REQUEST.into(), + status: 400, + detail: Some(e.to_string()), + }), + Ok(ClientFrame::Request { id, request }) => { + Some(match jmap::handle(&app, &jmap::Caller { conn }, request) { + Ok(response) => ServerFrame::Response { request_id: id, response }, + Err(e) => ServerFrame::RequestError { + request_id: id, + kind: e.kind.into(), + status: e.status, + detail: Some(e.detail), + }, + }) + } + Ok(ClientFrame::WebSocketPushEnable { data_types, .. }) => { + push.types = Some(data_types); + set_live(&app, &mut push); + // Send the current states, so the client starts level. + Some(state_change(&app, &push)) + } + Ok(ClientFrame::WebSocketPushDisable) => { + push.types = None; + set_live(&app, &mut push); + None + } + }; + if let Some(reply) = reply + && send(&mut sink, &reply).await.is_err() + { + break; + } + } + change = changes.recv() => { + match change { + Ok(change) if push.wants(change.type_name) => { + let frame = ServerFrame::StateChange { + changed: BTreeMap::from([( + jmap::ACCOUNT_ID.to_string(), + BTreeMap::from([(change.type_name.to_string(), change.state)]), + )]), + push_state: None, + }; + if send(&mut sink, &frame).await.is_err() { break; } + } + Ok(_) => {} + // Missed some: send every state again. + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + if push.types.is_some() && send(&mut sink, &state_change(&app, &push)).await.is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + } + } + push.types = None; + set_live(&app, &mut push); + app.link_clients.fetch_sub(1, Ordering::Relaxed); + if app.spool.lock().expect("spool").release_conn(conn) { + app.inbound_changed(); + } + app.relay_changed(); +} + +fn set_live(app: &App, push: &mut PushState) { + let live = push.wants(type_name::INBOUND); + if live != push.live { + push.live = live; + if live { + app.live_clients.fetch_add(1, Ordering::Relaxed); + } else { + app.live_clients.fetch_sub(1, Ordering::Relaxed); + } + app.relay_changed(); + } +} + +fn state_change(app: &App, push: &PushState) -> ServerFrame { + let states = jmap::states(app) + .into_iter() + .filter(|(t, _)| push.wants(t)) + .map(|(t, s)| (t.to_string(), s)) + .collect(); + ServerFrame::StateChange { + changed: BTreeMap::from([(jmap::ACCOUNT_ID.to_string(), states)]), + push_state: None, + } +} + +async fn send(sink: &mut S, frame: &ServerFrame) -> Result<(), ()> +where + S: futures_util::Sink + Unpin, +{ + let text = serde_json::to_string(frame).expect("frame serializes"); + sink.send(Message::Text(text.into())).await.map_err(|_| ()) +} diff --git a/crates/relay/src/link/tls.rs b/crates/relay/src/link/tls.rs new file mode 100644 index 00000000..2ab22518 --- /dev/null +++ b/crates/relay/src/link/tls.rs @@ -0,0 +1,227 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! TLS on the link (RL-5, RL-6, RL-7): TLS 1.3 only, and a client +//! certificate required, chained to the downstream link CA whose key is +//! pinned. A client without one gets a TLS alert and nothing else. + +use inbuxa_relay_proto::pin::{Pin, pins_match, spki_pin}; +use rustls::{ + DigitallySignedStruct, DistinguishedName, Error, SignatureScheme, + client::danger::HandshakeSignatureValid, + crypto::{CryptoProvider, WebPkiSupportedAlgorithms}, + server::danger::{ClientCertVerified, ClientCertVerifier}, +}; +use rustls_pki_types::{CertificateDer, PrivateKeyDer, UnixTime, pem::PemObject}; +use std::sync::Arc; + +pub fn provider() -> Arc { + Arc::new(rustls::crypto::aws_lc_rs::default_provider()) +} + +/// The pin of a certificate's key. +pub fn cert_pin(cert: &[u8]) -> Option { + let (_, parsed) = x509_parser::parse_x509_certificate(cert).ok()?; + Some(spki_pin(parsed.tbs_certificate.subject_pki.raw)) +} + +/// A new link key and its self-signed certificate, as PEM. +pub fn new_link_identity(relay_id: &str) -> Result<(String, String), rcgen::Error> { + let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?; + let mut params = rcgen::CertificateParams::new(vec![format!("{relay_id}.relay.invalid")])?; + params.distinguished_name.push( + rcgen::DnType::CommonName, + format!("inbuxa-relay {relay_id}"), + ); + // The pin is the identity; the dates only have to cover its 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)?; + Ok((key.serialize_pem(), cert.pem())) +} + +/// The link's server config. +pub fn server_config( + key_pem: &str, + cert_pem: &str, + ca_pin: Pin, +) -> Result { + let provider = provider(); + let cert = CertificateDer::from_pem_slice(cert_pem.as_bytes()) + .map_err(|e| format!("link certificate: {e}"))?; + let key = + PrivateKeyDer::from_pem_slice(key_pem.as_bytes()).map_err(|e| format!("link key: {e}"))?; + let verifier = Arc::new(PinnedCaVerifier { + ca_pin, + algorithms: provider.signature_verification_algorithms, + }); + let mut config = rustls::ServerConfig::builder_with_provider(provider) + .with_protocol_versions(&[&rustls::version::TLS13]) + .map_err(|e| e.to_string())? + .with_client_cert_verifier(verifier) + .with_single_cert(vec![cert], key) + .map_err(|e| format!("link certificate: {e}"))?; + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + Ok(config) +} + +/// Accepts a client certificate only if one of the certificates the client +/// sent has the pinned CA key and signed the client's own certificate, +/// which must be valid now and for client authentication. +#[derive(Debug)] +pub struct PinnedCaVerifier { + ca_pin: Pin, + algorithms: WebPkiSupportedAlgorithms, +} + +impl ClientCertVerifier for PinnedCaVerifier { + fn offer_client_auth(&self) -> bool { + true + } + + fn client_auth_mandatory(&self) -> bool { + true + } + + fn root_hint_subjects(&self) -> &[DistinguishedName] { + &[] + } + + fn verify_client_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + now: UnixTime, + ) -> Result { + let ca = intermediates + .iter() + .find(|c| cert_pin(c).is_some_and(|pin| pins_match(&pin, &self.ca_pin))) + .ok_or(Error::InvalidCertificate( + rustls::CertificateError::UnknownIssuer, + ))?; + let anchor = webpki::anchor_from_trusted_cert(ca) + .map_err(|_| Error::InvalidCertificate(rustls::CertificateError::BadEncoding))?; + let ee = webpki::EndEntityCert::try_from(end_entity) + .map_err(|_| Error::InvalidCertificate(rustls::CertificateError::BadEncoding))?; + ee.verify_for_usage( + self.algorithms.all, + &[anchor], + &[], + now, + webpki::KeyUsage::client_auth(), + None, + None, + ) + .map_err(|e| { + Error::InvalidCertificate(match e { + webpki::Error::CertExpired { .. } => rustls::CertificateError::Expired, + webpki::Error::CertNotValidYet { .. } => rustls::CertificateError::NotValidYet, + _ => rustls::CertificateError::BadSignature, + }) + })?; + Ok(ClientCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + // The link is TLS 1.3 only. + Err(Error::PeerIncompatible( + rustls::PeerIncompatible::Tls12NotOffered, + )) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature(message, cert, dss, &self.algorithms) + } + + fn supported_verify_schemes(&self) -> Vec { + self.algorithms.supported_schemes() + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + /// A test downstream: a CA and a node certificate it issued. + pub struct TestCa { + pub ca_der: Vec, + pub node_cert_der: Vec, + pub node_key_der: Vec, + } + + pub fn test_ca(eku_client: bool) -> TestCa { + let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); + let mut ca_params = rcgen::CertificateParams::new(vec![]).unwrap(); + ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + ca_params + .distinguished_name + .push(rcgen::DnType::CommonName, "test link CA"); + let ca = ca_params.self_signed(&ca_key).unwrap(); + let issuer = rcgen::Issuer::new(ca_params, ca_key); + + let node_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap(); + let mut node_params = rcgen::CertificateParams::new(vec!["node1.invalid".into()]).unwrap(); + if eku_client { + node_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth]; + } + let node = node_params.signed_by(&node_key, &issuer).unwrap(); + TestCa { + ca_der: ca.der().to_vec(), + node_cert_der: node.der().to_vec(), + node_key_der: node_key.serialize_der(), + } + } + + fn verifier(pin: Pin) -> PinnedCaVerifier { + PinnedCaVerifier { + ca_pin: pin, + algorithms: provider().signature_verification_algorithms, + } + } + + #[test] + fn accepts_only_the_pinned_ca() { + let ca = test_ca(true); + let pin = cert_pin(&ca.ca_der).unwrap(); + let ee = CertificateDer::from(ca.node_cert_der.clone()); + let chain = [CertificateDer::from(ca.ca_der.clone())]; + let now = UnixTime::now(); + assert!(verifier(pin).verify_client_cert(&ee, &chain, now).is_ok()); + assert!( + verifier([0; 32]) + .verify_client_cert(&ee, &chain, now) + .is_err() + ); + assert!(verifier(pin).verify_client_cert(&ee, &[], now).is_err()); + + // A certificate from another CA, sent with the pinned CA's cert. + let other = test_ca(true); + let other_ee = CertificateDer::from(other.node_cert_der); + assert!( + verifier(pin) + .verify_client_cert(&other_ee, &chain, now) + .is_err() + ); + } + + #[test] + fn link_identity_pins() { + let (key, cert) = new_link_identity("r1").unwrap(); + let der = CertificateDer::from_pem_slice(cert.as_bytes()).unwrap(); + assert!(cert_pin(&der).is_some()); + assert!(server_config(&key, &cert, [1; 32]).is_ok()); + } +} diff --git a/crates/relay/src/main.rs b/crates/relay/src/main.rs new file mode 100644 index 00000000..107418f6 --- /dev/null +++ b/crates/relay/src/main.rs @@ -0,0 +1,357 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! inbuxa Relay: takes mail in on port 25 for one downstream inbuxa server +//! or cluster, and sends that downstream's mail on +//! (docs/spec/features/relay.md). +//! +//! It is administered only from the downstream's inbuxa-admin (RL-2). The +//! command line can join, run, show status, and reset; nothing else. + +mod app; +mod events; +mod link; +mod recipients; +mod smtp; +mod spool; +mod state; +#[cfg(test)] +mod tests_e2e; + +use app::App; +use inbuxa_relay_proto::{time, token::JoinToken}; +use state::{Identity, JoinStage, Paths, RunLock}; +use std::{net::SocketAddr, process::ExitCode, sync::Arc, time::Duration}; + +const USAGE: &str = "\ +inbuxa-relay: the inbuxa edge relay + +Usage: + inbuxa-relay join join the downstream that made the token + inbuxa-relay run run the relay + inbuxa-relay status show the link, queue counts and version + inbuxa-relay reset [--discard-held] + forget the downstream and start over + +Options: + --state state directory (default /var/lib/inbuxa-relay) + --link-listen link address and port (default [::]:443) + +Everything else is set from the downstream's inbuxa-admin. + +The container image reads INBUXA_RELAY_JOIN_TOKEN on its first run, so it +can join and run in one step. +"; + +struct Args { + command: String, + token: Option, + state: String, + link_listen: SocketAddr, + discard_held: bool, +} + +fn parse_args() -> Result { + let mut args = std::env::args().skip(1); + let mut command = None; + let mut token = None; + let mut state = state::DEFAULT_DIR.to_string(); + let mut link_listen: SocketAddr = "[::]:443".parse().expect("address"); + let mut discard_held = false; + while let Some(arg) = args.next() { + match arg.as_str() { + "--state" => state = args.next().ok_or("--state needs a directory")?, + "--link-listen" => { + link_listen = args + .next() + .ok_or("--link-listen needs an address")? + .parse() + .map_err(|_| "--link-listen: an address and port, like [::]:443")?; + } + "--discard-held" => discard_held = true, + "-h" | "--help" | "help" => return Err(String::new()), + "-V" | "--version" => { + println!("inbuxa-relay {}", app::VERSION); + std::process::exit(0); + } + _ if arg.starts_with('-') => return Err(format!("unknown option {arg}")), + _ if command.is_none() => command = Some(arg), + _ if command.as_deref() == Some("join") && token.is_none() => token = Some(arg), + _ => return Err(format!("unexpected argument {arg}")), + } + } + Ok(Args { + command: command.ok_or_else(String::new)?, + token, + state, + link_listen, + discard_held, + }) +} + +fn main() -> ExitCode { + let args = match parse_args() { + Ok(args) => args, + Err(e) => { + if !e.is_empty() { + eprintln!("inbuxa-relay: {e}\n"); + } + eprint!("{USAGE}"); + return ExitCode::from(2); + } + }; + let paths = Paths::new(&args.state); + let result = match args.command.as_str() { + "join" => match &args.token { + Some(token) => join(&paths, token), + None => Err("join needs the token from inbuxa-admin".into()), + }, + "run" => run(&paths, args.link_listen), + "status" => status(&paths), + "reset" => reset(&paths, args.discard_held), + other => Err(format!("unknown command {other}")), + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("inbuxa-relay: {e}"); + ExitCode::FAILURE + } + } +} + +fn join(paths: &Paths, token_text: &str) -> Result<(), String> { + if Identity::load(paths).map_err(|e| e.to_string())?.is_some() { + return Err( + "this relay already belongs to a downstream: run `inbuxa-relay reset` first".into(), + ); + } + let token = JoinToken::decode(token_text, time::now()).map_err(|e| e.to_string())?; + paths.create_dirs().map_err(|e| e.to_string())?; + let (key, cert) = link::tls::new_link_identity(&token.relay_id).map_err(|e| e.to_string())?; + let identity = Identity { + relay_id: token.relay_id.clone(), + ca_pin: token.ca_spki, + join_secret: Some(token.secret), + join_expires_at: token.expires_at, + stage: JoinStage::Waiting, + link_key_pem: key, + link_cert_pem: cert, + }; + identity.save(paths).map_err(|e| e.to_string())?; + let hours = token.expires_at.saturating_sub(time::now()).div_ceil(3600); + println!( + "Joined, waiting for the downstream. Start the relay with `inbuxa-relay run`; \ + the downstream has {hours} hour{} to finish the join.", + if hours == 1 { "" } else { "s" } + ); + Ok(()) +} + +fn run(paths: &Paths, link_listen: SocketAddr) -> Result<(), String> { + paths.create_dirs().map_err(|e| e.to_string())?; + let _lock = RunLock::acquire(paths).map_err(|e| e.to_string())?; + let identity = match Identity::load(paths).map_err(|e| e.to_string())? { + Some(identity) => identity, + None => match std::env::var("INBUXA_RELAY_JOIN_TOKEN") { + Ok(token) if !token.trim().is_empty() => { + join(paths, &token)?; + Identity::load(paths) + .map_err(|e| e.to_string())? + .ok_or("the join didn't save")? + } + _ => { + return Err( + "this relay hasn't joined a downstream: run `inbuxa-relay join `".into(), + ); + } + }, + }; + let app = Arc::new(App::open(Paths::new(&paths.root), identity).map_err(|e| e.to_string())?); + // A relay is small: a few threads carry it, whatever the host has. + let threads = std::thread::available_parallelism().map_or(2, |n| n.get().clamp(2, 4)); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(threads) + .enable_all() + .build() + .map_err(|e| e.to_string())?; + runtime.block_on(async move { + eprintln!("inbuxa-relay {} starting", app::VERSION); + let listener = tokio::net::TcpListener::bind(link_listen) + .await + .map_err(|e| format!("could not listen on {link_listen}: {e}"))?; + let link = tokio::spawn(link::serve(app.clone(), listener)); + tokio::spawn(smtp::serve(app.clone())); + tokio::spawn(sweeper(app.clone())); + tokio::select! { + result = link => match result { + Ok(Err(e)) => Err(format!("the link stopped: {e}")), + _ => Err("the link stopped".to_string()), + }, + _ = shutdown() => { + app.write_status(); + eprintln!("inbuxa-relay: stopping"); + Ok(()) + } + } + }) +} + +async fn shutdown() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + let mut term = signal(SignalKind::terminate()).expect("SIGTERM handler"); + tokio::select! { + _ = term.recv() => {} + _ = tokio::signal::ctrl_c() => {} + } + } + #[cfg(not(unix))] + let _ = tokio::signal::ctrl_c().await; +} + +/// Every few seconds: ends run-out leases, expires old held mail, forgets +/// idle addresses and stale uploads, and writes `status.json`. +async fn sweeper(app: Arc) { + let mut tick = tokio::time::interval(Duration::from_secs(5)); + loop { + tick.tick().await; + let now = time::now(); + let max_age = app.settings().map(|s| u64::from(s.held_max_age) * 86_400); + let (leases, expired) = { + let mut spool = app.spool.lock().expect("spool"); + let leases = spool.sweep_leases(now); + let expired = max_age + .map(|age| spool.expire(now, age)) + .unwrap_or_default(); + (leases, expired) + }; + for meta in &expired { + app.record( + "inbound.expired", + events::data([ + ("id", serde_json::json!(meta.id)), + ( + "receivedAt", + serde_json::json!(time::utc_date(meta.received_at)), + ), + ]), + ); + } + if leases || !expired.is_empty() { + app.inbound_changed(); + } + app.limiter.lock().expect("limiter").sweep(now); + if let Ok(dir) = std::fs::read_dir(app.paths.blobs()) { + for entry in dir.flatten() { + let old = entry + .metadata() + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.elapsed().ok()) + .is_some_and(|age| age > Duration::from_secs(3600)); + if old { + let _ = std::fs::remove_file(entry.path()); + } + } + } + app.write_status(); + } +} + +fn status(paths: &Paths) -> Result<(), String> { + let Some(identity) = Identity::load(paths).map_err(|e| e.to_string())? else { + println!("Not joined to a downstream."); + return Ok(()); + }; + println!("inbuxa-relay {}", app::VERSION); + println!("Relay: {}", identity.relay_id); + println!( + "Joined: {}", + match identity.stage { + JoinStage::Waiting => "waiting for the downstream", + JoinStage::Answered => "answered, waiting for the first settings", + JoinStage::Joined => "yes", + } + ); + let running = RunLock::acquire(paths).is_err(); + let status: Option = + state::read_json(&paths.status()).map_err(|e| e.to_string())?; + match (running, status) { + (true, Some(s)) => { + println!("Running: yes, since {}", s.started_at); + println!("Mode: {:?}", s.mode); + println!("Linked: {} connection(s)", s.link_clients); + println!( + "Held mail: {} message(s), {} bytes", + s.spool.inbound, s.spool.bytes + ); + if let Some(e) = s.last_error { + println!("Last error: {e}"); + } + } + _ => { + let held = std::fs::read_dir(paths.spool_in()) + .map(|d| { + d.flatten() + .filter(|e| e.file_name().to_string_lossy().ends_with(".json")) + .count() + }) + .unwrap_or(0); + println!("Running: no"); + println!("Held mail: {held} message(s)"); + } + } + Ok(()) +} + +fn reset(paths: &Paths, discard_held: bool) -> Result<(), String> { + if !paths.root.exists() { + println!("Nothing to reset."); + return Ok(()); + } + let _lock = + RunLock::acquire(paths).map_err(|_| "stop the relay before resetting it".to_string())?; + let held = std::fs::read_dir(paths.spool_in()) + .map(|d| { + d.flatten() + .filter(|e| e.file_name().to_string_lossy().ends_with(".json")) + .count() + }) + .unwrap_or(0); + if held > 0 && !discard_held { + return Err(format!( + "{held} held message(s) are waiting for the downstream. They are sealed to it, \ + so after a reset nobody can read them. Run `inbuxa-relay reset --discard-held` \ + to delete them." + )); + } + for entry in std::fs::read_dir(&paths.root) + .map_err(|e| e.to_string())? + .flatten() + { + if entry.file_name() == "run.lock" { + continue; + } + let path = entry.path(); + let removed = if path.is_dir() { + std::fs::remove_dir_all(&path) + } else { + std::fs::remove_file(&path) + }; + removed.map_err(|e| format!("{}: {e}", path.display()))?; + } + println!( + "Reset. {}The relay belongs to no downstream; join it again with a new token.", + if held > 0 { + format!("{held} held message(s) deleted. ") + } else { + String::new() + } + ); + Ok(()) +} diff --git a/crates/relay/src/recipients.rs b/crates/relay/src/recipients.rs new file mode 100644 index 00000000..93d5cf50 --- /dev/null +++ b/crates/relay/src/recipients.rs @@ -0,0 +1,205 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The accepted-recipient set the downstream pushes (RL-1, RL-11), kept in +//! memory and in the state directory. + +use crate::state::{Paths, read_json, write_json}; +use inbuxa_relay_proto::{ + b64, + recipient::{self, Lookup, RecipientDomain, RecipientHash}, +}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, HashSet}, + io, +}; + +#[derive(Default)] +pub struct Recipients { + /// Bumped on every change; 0 until the downstream sends a set. + state: u64, + domains: BTreeMap, + hashes: HashSet, +} + +#[derive(Serialize, Deserialize)] +struct Stored { + state: u64, + domains: Vec, + hashes: Vec, +} + +impl Recipients { + pub fn load(paths: &Paths) -> io::Result { + let Some(stored) = read_json::(&paths.recipients())? else { + return Ok(Recipients::default()); + }; + let mut hashes = HashSet::with_capacity(stored.hashes.len()); + for text in &stored.hashes { + let hash = b64::decode(text) + .and_then(|b| RecipientHash::try_from(b).ok()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "recipient hash"))?; + hashes.insert(hash); + } + Ok(Recipients { + state: stored.state, + domains: stored + .domains + .into_iter() + .map(|d| (d.domain.clone(), d)) + .collect(), + hashes, + }) + } + + pub fn save(&self, paths: &Paths) -> io::Result<()> { + let mut hashes: Vec = self.hashes.iter().map(|h| b64::encode(h)).collect(); + hashes.sort_unstable(); + write_json( + &paths.recipients(), + &Stored { + state: self.state, + domains: self.domains.values().cloned().collect(), + hashes, + }, + ) + } + + /// Whether the downstream has sent a recipient set yet. + pub fn is_loaded(&self) -> bool { + self.state > 0 + } + + pub fn state(&self) -> String { + self.state.to_string() + } + + pub fn domains(&self) -> impl Iterator { + self.domains.values() + } + + pub fn domain(&self, domain: &str) -> Option<&RecipientDomain> { + self.domains.get(domain) + } + + fn bump(&mut self) { + self.state += 1; + } + + pub fn put_domain(&mut self, mut domain: RecipientDomain) { + domain.domain = domain.domain.trim_end_matches('.').to_lowercase(); + self.domains.insert(domain.domain.clone(), domain); + self.bump(); + } + + pub fn remove_domain(&mut self, domain: &str) -> bool { + let removed = self.domains.remove(domain).is_some(); + if removed { + self.bump(); + } + removed + } + + /// Applies one `Recipient/set`: adds and removes hashes. + pub fn apply(&mut self, create: &[RecipientHash], destroy: &[RecipientHash]) { + for hash in destroy { + self.hashes.remove(hash); + } + self.hashes.extend(create.iter().copied()); + self.bump(); + } + + /// `Recipient/replace`: a file of 32-byte hashes, swapped in whole. + pub fn replace(&mut self, file: &[u8]) -> Result { + if !file.len().is_multiple_of(32) { + return Err("the file is not a whole number of 32-byte hashes"); + } + self.hashes = file.as_chunks::<32>().0.iter().copied().collect(); + self.bump(); + Ok(self.hashes.len()) + } + + /// RL-1: what to answer at RCPT for `address`. `served` says whether + /// the settings list the domain; a served domain without rules of its + /// own has no catch-all and no sub-addressing. + pub fn lookup(&self, address: &str, key: &[u8; 32], served: impl Fn(&str) -> bool) -> Lookup { + let Some((_, domain)) = recipient::split(address) else { + return Lookup::BadAddress; + }; + if !served(&domain) { + return Lookup::NotServed; + } + let rules = self + .domains + .get(&domain) + .cloned() + .unwrap_or_else(|| RecipientDomain { + domain: domain.clone(), + catch_all: false, + subaddress_separator: None, + }); + recipient::lookup( + address, + key, + |_| Some(&rules), + |hash| self.hashes.contains(hash), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use inbuxa_relay_proto::recipient::hash; + + #[test] + fn replace_apply_persist() { + let dir = std::env::temp_dir().join(format!("inbuxa-relay-rcpt-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let paths = Paths::new(&dir); + let key = [9u8; 32]; + + let mut r = Recipients::default(); + assert!(!r.is_loaded()); + r.put_domain(RecipientDomain { + domain: "Example.COM".into(), + catch_all: false, + subaddress_separator: Some("+".into()), + }); + let mut file = Vec::new(); + file.extend_from_slice(&hash(&key, "a@example.com")); + file.extend_from_slice(&hash(&key, "b@example.com")); + assert_eq!(r.replace(&file), Ok(2)); + assert_eq!( + r.replace(&file[..31]), + Err("the file is not a whole number of 32-byte hashes") + ); + r.apply( + &[hash(&key, "c@example.com")], + &[hash(&key, "b@example.com")], + ); + r.save(&paths).unwrap(); + + let r = Recipients::load(&paths).unwrap(); + let all = |_: &str| true; + assert_eq!(r.lookup("a+x@example.com", &key, all), Lookup::Accept); + assert_eq!(r.lookup("b@example.com", &key, all), Lookup::UnknownUser); + assert_eq!(r.lookup("c@example.com", &key, all), Lookup::Accept); + // A domain with rules but no longer in the settings isn't served. + assert_eq!( + r.lookup("a@example.com", &key, |_| false), + Lookup::NotServed + ); + // A served domain without rules: exact addresses only. + let mut r = r; + r.apply(&[hash(&key, "d+x@example.net")], &[]); + assert_eq!(r.lookup("d+x@example.net", &key, all), Lookup::Accept); + assert_eq!(r.lookup("d@example.net", &key, all), Lookup::UnknownUser); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/relay/src/smtp.rs b/crates/relay/src/smtp.rs new file mode 100644 index 00000000..f03e01b9 --- /dev/null +++ b/crates/relay/src/smtp.rs @@ -0,0 +1,852 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The door: SMTP on port 25 (spec "Inbound"). +//! +//! It takes mail only for the downstream's real recipients (RL-1), offers +//! no AUTH and no relaying (RL-4), seals each message to the spool as it +//! arrives (RL-21), and in live mode holds its answer until the downstream +//! gives a verdict (RL-12, RL-13). The relay adds no header: the downstream +//! writes the trace from the session facts it is given. + +use crate::{ + app::App, + events::data, + spool::{Incoming, Meta}, +}; +use inbuxa_relay_proto::{ + recipient::Lookup, + time, + types::{ + Address, Envelope, Mode, RelaySettings, Session as SessionFacts, SmtpCertificate, TlsInfo, + Verdict, + }, +}; +use rustls_pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}; +use serde_json::json; +use smtp_proto::{ + Error as ParseError, MAIL_BODY_7BIT, MAIL_BODY_8BITMIME, MAIL_BODY_BINARYMIME, MAIL_REQUIRETLS, + MAIL_RET_FULL, MAIL_RET_HDRS, MAIL_SMTPUTF8, MailFrom, RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, + RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, RcptTo, Request, + request::receiver::{DataReceiver, RequestReceiver}, +}; +use std::{ + collections::{BTreeMap, HashMap}, + net::SocketAddr, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + task::JoinHandle, +}; +use tokio_rustls::TlsAcceptor; + +const COMMAND_TIMEOUT: Duration = Duration::from_secs(300); +const DATA_TIMEOUT: Duration = Duration::from_secs(600); +const GREET_PAUSE: Duration = Duration::from_millis(300); +const MAX_ERRORS: u32 = 10; + +/// The STARTTLS config for port 25: the downstream's certificate, or a +/// self-signed one for the hostname until it sends one. Senders accept a +/// self-signed certificate opportunistically, as they do any. +pub fn tls_config( + hostname: &str, + cert: Option<&SmtpCertificate>, +) -> Result { + let (chain, key) = match cert { + Some(cert) => { + let chain: Vec> = + CertificateDer::pem_slice_iter(cert.chain_pem.as_bytes()) + .collect::>() + .map_err(|e| format!("certificate chain: {e}"))?; + if chain.is_empty() { + return Err("the certificate chain is empty".into()); + } + let key = PrivateKeyDer::from_pem_slice(cert.key_pem.as_bytes()) + .map_err(|e| format!("private key: {e}"))?; + (chain, key) + } + None => { + let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256) + .map_err(|e| e.to_string())?; + let cert = rcgen::CertificateParams::new(vec![hostname.to_string()]) + .and_then(|p| p.self_signed(&key)) + .map_err(|e| e.to_string())?; + ( + vec![cert.der().clone()], + PrivateKeyDer::try_from(key.serialize_der()).map_err(|e| e.to_string())?, + ) + } + }; + rustls::ServerConfig::builder_with_provider(crate::link::tls::provider()) + .with_safe_default_protocol_versions() + .map_err(|e| e.to_string())? + .with_no_client_auth() + .with_single_cert(chain, key) + .map_err(|e| e.to_string()) +} + +/// The STARTTLS config, rebuilt when the settings change. +#[derive(Default)] +struct TlsCache { + current: Mutex)>>, +} + +impl TlsCache { + fn get(&self, settings: &RelaySettings) -> Option> { + let key = format!( + "{}|{}", + settings.hostname, + settings + .smtp_certificate + .as_ref() + .map_or("", |c| c.chain_pem.as_str()) + ); + let mut current = self.current.lock().expect("tls cache"); + if let Some((k, config)) = current.as_ref() + && *k == key + { + return Some(config.clone()); + } + match tls_config(&settings.hostname, settings.smtp_certificate.as_ref()) { + Ok(config) => { + let config = Arc::new(config); + *current = Some((key, config.clone())); + Some(config) + } + Err(e) => { + eprintln!("inbuxa-relay: STARTTLS is off: {e}"); + None + } + } + } +} + +/// Keeps port 25 listening on the configured addresses while the relay is +/// configured, and closed otherwise. +pub async fn serve(app: Arc) { + let tls = Arc::new(TlsCache::default()); + let mut listeners: HashMap> = HashMap::new(); + let mut settings_rx = app.settings.subscribe(); + loop { + let wanted: Vec = match (app.mode(), app.settings()) { + (Mode::Unconfigured, _) | (_, None) => vec![], + (_, Some(s)) => s + .smtp_listen + .iter() + .filter_map(|a| a.parse().ok()) + .collect(), + }; + listeners.retain(|addr, task| { + let keep = wanted.contains(addr) && !task.is_finished(); + if !keep { + task.abort(); + eprintln!("inbuxa-relay: port 25 closed on {addr}"); + } + keep + }); + for addr in wanted { + if listeners.contains_key(&addr) { + continue; + } + match TcpListener::bind(addr).await { + Ok(listener) => { + eprintln!("inbuxa-relay: SMTP listening on {addr}"); + let app = app.clone(); + let tls = tls.clone(); + listeners.insert(addr, tokio::spawn(accept_loop(app, tls, listener))); + } + Err(e) => app.set_error(format!("could not listen on {addr}: {e}")), + } + } + // Settings changes come by watch; the recipient set and the mode + // are checked every few seconds. + let _ = tokio::time::timeout(Duration::from_secs(3), settings_rx.changed()).await; + } +} + +async fn accept_loop(app: Arc, tls: Arc, listener: TcpListener) { + loop { + let (tcp, peer) = match listener.accept().await { + Ok(pair) => pair, + Err(_) => { + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + }; + let app = app.clone(); + let tls = tls.clone(); + tokio::spawn(async move { + handle_connection(app, tls, tcp, peer).await; + }); + } +} + +async fn handle_connection( + app: Arc, + tls: Arc, + mut tcp: TcpStream, + peer: SocketAddr, +) { + let Some(settings) = app.settings() else { + return; + }; + let local = tcp.local_addr().unwrap_or(peer); + let allowed = app + .limiter + .lock() + .expect("limiter") + .connect(peer.ip(), &settings, time::now()); + if !allowed { + let _ = tcp + .write_all(b"421 4.7.0 Too many connections, try again later\r\n") + .await; + return; + } + // An early talker sends before the greeting: not a real MTA. + let mut peek = [0u8; 1]; + if let Ok(Ok(n)) = tokio::time::timeout(GREET_PAUSE, tcp.peek(&mut peek)).await + && n > 0 + { + let _ = tcp.write_all(b"554 5.5.1 Wait for the greeting\r\n").await; + app.limiter.lock().expect("limiter").disconnect(peer.ip()); + return; + } + let mut session = Session::new(app.clone(), settings.clone(), peer, local); + let banner = format!("220 {} ESMTP\r\n", settings.hostname); + if tcp.write_all(banner.as_bytes()).await.is_ok() { + match session.run(tcp, None).await { + End::StartTls(tcp) => { + let config = tls.get(&settings); + if let Some(config) = config { + match tokio::time::timeout( + Duration::from_secs(30), + TlsAcceptor::from(config).accept(tcp), + ) + .await + { + Ok(Ok(stream)) => { + let (_, conn) = stream.get_ref(); + let info = TlsInfo { + version: conn + .protocol_version() + .map_or("unknown".into(), |v| format!("{v:?}")), + cipher: conn + .negotiated_cipher_suite() + .map_or("unknown".into(), |c| format!("{:?}", c.suite())), + }; + session.reset_after_tls(info); + session.run(stream, None).await; + } + _ => session.tls_failed = true, + } + } + } + End::Closed => {} + } + } + app.limiter.lock().expect("limiter").disconnect(peer.ip()); + session.record_end(); +} + +enum End { + Closed, + StartTls(S), +} + +/// A command, owned, so it outlives the read buffer. +enum Cmd { + Ehlo(String), + Helo(String), + Mail(MailFrom), + Rcpt(RcptTo), + Data, + Rset, + Noop, + Quit, + Help, + Vrfy, + StartTls, + NotOffered, +} + +impl Cmd { + fn from(req: Request>) -> Cmd { + match req { + Request::Ehlo { host } => Cmd::Ehlo(host.into_owned()), + Request::Helo { host } => Cmd::Helo(host.into_owned()), + Request::Mail { from } => Cmd::Mail(from.into_owned()), + Request::Rcpt { to } => Cmd::Rcpt(to.into_owned()), + Request::Data => Cmd::Data, + Request::Rset => Cmd::Rset, + Request::Noop { .. } => Cmd::Noop, + Request::Quit => Cmd::Quit, + Request::Help { .. } => Cmd::Help, + Request::Vrfy { .. } => Cmd::Vrfy, + Request::StartTls => Cmd::StartTls, + // No AUTH, no ETRN or ATRN, no BDAT, BURL, EXPN or LHLO. + _ => Cmd::NotOffered, + } + } +} + +struct Session { + app: Arc, + settings: Arc, + peer: SocketAddr, + local: SocketAddr, + helo: Option, + tls: Option, + tls_failed: bool, + mail_from: Option
, + rcpt_to: Vec
, + errors: u32, + accepted: u32, + refused_rcpts: u32, + out: Vec, +} + +enum Next { + Continue, + Close, + StartTls, + Data(Box), +} + +struct DataState { + rx: DataReceiver, + incoming: Option>, + buf: Vec, + too_big: bool, +} + +impl Session { + fn new( + app: Arc, + settings: Arc, + peer: SocketAddr, + local: SocketAddr, + ) -> Self { + Session { + app, + settings, + peer, + local, + helo: None, + tls: None, + tls_failed: false, + mail_from: None, + rcpt_to: Vec::new(), + errors: 0, + accepted: 0, + refused_rcpts: 0, + out: Vec::with_capacity(512), + } + } + + fn reset_after_tls(&mut self, info: TlsInfo) { + self.tls = Some(info); + self.helo = None; + self.reset(); + } + + fn reset(&mut self) { + self.mail_from = None; + self.rcpt_to.clear(); + } + + fn reply(&mut self, line: &str) { + self.out.extend_from_slice(line.as_bytes()); + self.out.extend_from_slice(b"\r\n"); + } + + fn error(&mut self, line: &str) { + self.errors += 1; + self.reply(line); + } + + async fn flush(&mut self, stream: &mut S) -> std::io::Result<()> { + if !self.out.is_empty() { + stream.write_all(&self.out).await?; + stream.flush().await?; + self.out.clear(); + } + Ok(()) + } + + async fn run( + &mut self, + mut stream: S, + _: Option<()>, + ) -> End { + let mut buf = vec![0u8; 8192]; + let mut receiver = RequestReceiver::default(); + let mut data: Option = None; + loop { + let timeout = if data.is_some() { + DATA_TIMEOUT + } else { + COMMAND_TIMEOUT + }; + let n = match tokio::time::timeout(timeout, stream.read(&mut buf)).await { + Ok(Ok(0)) | Ok(Err(_)) => break, + Ok(Ok(n)) => n, + Err(_) => { + self.reply("421 4.4.2 Timeout, closing"); + let _ = self.flush(&mut stream).await; + break; + } + }; + let mut iter = buf[..n].iter(); + loop { + if let Some(state) = data.as_mut() { + let done = state.rx.ingest(&mut iter, &mut state.buf); + if let Err(e) = self.take_data(state, done).await { + self.app + .set_error(format!("could not write to the spool: {e}")); + if let Some(incoming) = state.incoming.take() { + incoming.abort().await; + } + state.too_big = false; + self.reply("451 4.3.0 Temporary failure, try again later"); + // Discard the rest of the message. + if !done { + break; + } + } + if !done { + break; + } + let state = data.take().expect("data"); + self.end_of_data(state).await; + continue; + } + let cmd = match receiver.ingest(&mut iter) { + Ok(req) => Ok(Cmd::from(req)), + Err(ParseError::NeedsMoreData { .. }) => break, + Err(e) => Err(e), + }; + let next = match cmd { + Ok(cmd) => self.command(cmd).await, + Err(e) => { + self.parse_error(e); + Next::Continue + } + }; + match next { + Next::Continue => {} + Next::Close => { + let _ = self.flush(&mut stream).await; + return End::Closed; + } + Next::StartTls => { + // Anything pipelined after STARTTLS is dropped. + if self.flush(&mut stream).await.is_err() { + return End::Closed; + } + return End::StartTls(stream); + } + Next::Data(incoming) => { + data = Some(DataState { + rx: DataReceiver::new(), + incoming: Some(incoming), + buf: Vec::with_capacity(64 * 1024), + too_big: false, + }); + // The 354 goes out now; the sender waits for it. + if self.flush(&mut stream).await.is_err() { + break; + } + } + } + if self.errors >= MAX_ERRORS { + self.reply("421 4.7.0 Too many errors, closing"); + let _ = self.flush(&mut stream).await; + return End::Closed; + } + } + if self.flush(&mut stream).await.is_err() { + break; + } + } + if let Some(mut state) = data + && let Some(incoming) = state.incoming.take() + { + incoming.abort().await; + } + End::Closed + } + + /// Moves received message bytes to the sealer. A byte is held back + /// until the end is known: the end of data takes back the last CR. + async fn take_data(&mut self, state: &mut DataState, done: bool) -> std::io::Result<()> { + let keep = if done { 0 } else { 1.min(state.buf.len()) }; + let ready = state.buf.len() - keep; + if ready == 0 { + return Ok(()); + } + if !state.too_big + && let Some(incoming) = state.incoming.as_mut() + { + if incoming.size() + ready as u64 > self.settings.max_message_size { + state.too_big = true; + } else { + incoming.write(&state.buf[..ready]).await?; + } + } + state.buf.drain(..ready); + Ok(()) + } + + async fn end_of_data(&mut self, mut state: DataState) { + let Some(incoming) = state.incoming.take() else { + // The spool failed mid-message; the 451 is already out. + self.reset(); + return; + }; + if state.too_big { + incoming.abort().await; + self.reset(); + self.reply("552 5.3.4 Message too big"); + return; + } + let size = incoming.size(); + let (id, file_len) = match incoming.finish().await { + Ok(done) => done, + Err(e) => { + self.app + .set_error(format!("could not write to the spool: {e}")); + self.reset(); + self.reply("451 4.3.0 Temporary failure, try again later"); + return; + } + }; + let meta = Meta { + id: id.clone(), + received_at: time::now(), + envelope: Envelope { + mail_from: self.mail_from.take().unwrap_or_else(|| Address::new("")), + rcpt_to: std::mem::take(&mut self.rcpt_to), + }, + session: SessionFacts { + remote_ip: self.peer.ip(), + remote_port: self.peer.port(), + local_ip: self.local.ip(), + helo: self.helo.clone().unwrap_or_default(), + tls: self.tls.clone(), + ptr: None, + }, + size, + door_checks: vec![], + }; + let dir = self.app.paths.spool_in(); + let committed = { + let (dir, meta) = (dir.clone(), meta.clone()); + tokio::task::spawn_blocking(move || crate::spool::Spool::commit_meta(&dir, &meta)) + .await + .unwrap_or_else(|e| Err(std::io::Error::other(e))) + }; + if let Err(e) = committed { + let _ = tokio::fs::remove_file(dir.join(format!("{id}.msg"))).await; + self.app + .set_error(format!("could not write to the spool: {e}")); + self.reply("451 4.3.0 Temporary failure, try again later"); + return; + } + // The message is on disk: from here the sender may be told 250. + let live = self.app.mode() == Mode::Live; + let waiter = self + .app + .spool + .lock() + .expect("spool") + .insert(meta, file_len, live); + self.app.inbound_changed(); + self.accepted += 1; + let verdict = match waiter { + Some(rx) => { + let wait = Duration::from_secs(u64::from(self.settings.live_verdict_timeout)); + match tokio::time::timeout(wait, rx).await { + Ok(Ok(verdict)) => Some(verdict), + Ok(Err(_)) => None, + Err(_) => { + // RL-13: accept and hold. + self.app.spool.lock().expect("spool").stop_waiting(&id); + self.app.inbound_changed(); + None + } + } + } + None => None, + }; + let mode = if live { "live" } else { "held" }; + match verdict { + Some(Verdict::Reject(r)) | Some(Verdict::Tempfail(r)) => { + self.accepted -= 1; + self.app.record( + "inbound.refused", + data([("id", json!(id)), ("code", json!(r.code))]), + ); + self.reply(&format!("{} {} {}", r.code, r.enhanced, r.text)); + } + Some(Verdict::Accept) => { + self.app.record( + "inbound.accepted", + data([ + ("id", json!(id)), + ("mode", json!("live")), + ("size", json!(size)), + ]), + ); + self.reply(&format!("250 2.0.0 Accepted {id}")); + } + None => { + self.app.record( + "inbound.accepted", + data([ + ("id", json!(id)), + ("mode", json!(mode)), + ("held", json!(true)), + ("size", json!(size)), + ]), + ); + self.reply(&format!("250 2.0.0 Accepted {id}")); + } + } + } + + fn parse_error(&mut self, e: ParseError) { + let line = match e { + ParseError::UnknownCommand => "500 5.5.1 Unknown command", + ParseError::InvalidSenderAddress => "501 5.1.7 Bad sender address", + ParseError::InvalidRecipientAddress => "501 5.1.3 Bad recipient address", + ParseError::SyntaxError { .. } | ParseError::InvalidParameter { .. } => { + "501 5.5.4 Syntax error" + } + ParseError::UnsupportedParameter { .. } => "504 5.5.4 Parameter not supported", + ParseError::ResponseTooLong => "500 5.5.6 Line too long", + _ => "500 5.5.2 Syntax error", + }; + self.error(line); + } + + async fn command(&mut self, cmd: Cmd) -> Next { + match cmd { + Cmd::Ehlo(host) => { + self.reset(); + self.helo = Some(host); + let mut lines = vec![ + self.settings.hostname.clone(), + "PIPELINING".into(), + format!("SIZE {}", self.settings.max_message_size), + "8BITMIME".into(), + "ENHANCEDSTATUSCODES".into(), + "SMTPUTF8".into(), + "DSN".into(), + ]; + if self.tls.is_none() && !self.tls_failed { + lines.push("STARTTLS".into()); + } + let last = lines.len() - 1; + for (i, line) in lines.iter().enumerate() { + let sep = if i == last { ' ' } else { '-' }; + self.reply(&format!("250{sep}{line}")); + } + } + Cmd::Helo(host) => { + self.reset(); + self.helo = Some(host); + let line = format!("250 {}", self.settings.hostname); + self.reply(&line); + } + Cmd::StartTls => { + if self.tls.is_some() { + self.error("503 5.5.1 Already in TLS"); + } else { + self.reply("220 2.0.0 Ready to start TLS"); + return Next::StartTls; + } + } + Cmd::Mail(from) => self.mail(from), + Cmd::Rcpt(to) => self.rcpt(to), + Cmd::Data => return self.data().await, + Cmd::Rset => { + self.reset(); + self.reply("250 2.0.0 OK"); + } + Cmd::Noop => self.reply("250 2.0.0 OK"), + Cmd::Quit => { + self.reply("221 2.0.0 Bye"); + return Next::Close; + } + Cmd::Help => self.reply("214 2.0.0 See RFC 5321"), + Cmd::Vrfy => self.reply("252 2.5.0 Cannot verify, but will accept the message"), + Cmd::NotOffered => self.error("502 5.5.1 Command not offered"), + } + Next::Continue + } + + fn mail(&mut self, from: MailFrom) { + if self.helo.is_none() { + return self.error("503 5.5.1 Say EHLO first"); + } + if self.mail_from.is_some() { + return self.error("503 5.5.1 A transaction is already open"); + } + if self.settings.max_message_size > 0 && from.size as u64 > self.settings.max_message_size { + return self.reply("552 5.3.4 Message too big"); + } + if self.app.spool_full(&self.settings) { + return self.reply("452 4.3.1 Insufficient storage, try again later"); + } + let allowed = self.app.limiter.lock().expect("limiter").message( + self.peer.ip(), + &self.settings, + time::now(), + ); + if !allowed { + return self.reply("450 4.7.0 Too many messages, try again later"); + } + let mut params = BTreeMap::new(); + if from.size > 0 { + params.insert("SIZE".into(), Some(from.size.to_string())); + } + let body = if from.flags & MAIL_BODY_BINARYMIME != 0 { + Some("BINARYMIME") + } else if from.flags & MAIL_BODY_8BITMIME != 0 { + Some("8BITMIME") + } else if from.flags & MAIL_BODY_7BIT != 0 { + Some("7BIT") + } else { + None + }; + if let Some(body) = body { + params.insert("BODY".into(), Some(body.into())); + } + if from.flags & MAIL_SMTPUTF8 != 0 { + params.insert("SMTPUTF8".into(), None); + } + if from.flags & MAIL_REQUIRETLS != 0 { + params.insert("REQUIRETLS".into(), None); + } + if from.flags & MAIL_RET_FULL != 0 { + params.insert("RET".into(), Some("FULL".into())); + } else if from.flags & MAIL_RET_HDRS != 0 { + params.insert("RET".into(), Some("HDRS".into())); + } + if let Some(env_id) = from.env_id { + params.insert("ENVID".into(), Some(env_id)); + } + self.mail_from = Some(Address { + email: from.address, + parameters: (!params.is_empty()).then_some(params), + }); + self.reply("250 2.1.0 OK"); + } + + fn rcpt(&mut self, to: RcptTo) { + if self.mail_from.is_none() { + return self.error("503 5.5.1 MAIL first"); + } + if self.rcpt_to.len() as u32 >= self.settings.max_recipients { + return self.reply("452 4.5.3 Too many recipients"); + } + if self + .rcpt_to + .iter() + .any(|a| a.email.eq_ignore_ascii_case(&to.address)) + { + return self.reply("250 2.1.5 OK"); + } + let Some(key) = self.settings.recipient_key else { + return self.reply("451 4.3.0 Not ready, try again later"); + }; + let settings = self.settings.clone(); + let lookup = + self.app + .recipients + .read() + .expect("recipients") + .lookup(&to.address, &key, |d| settings.serves(d)); + match lookup { + Lookup::Accept => { + let mut params = BTreeMap::new(); + let notify: Vec<&str> = [ + (RCPT_NOTIFY_SUCCESS, "SUCCESS"), + (RCPT_NOTIFY_FAILURE, "FAILURE"), + (RCPT_NOTIFY_DELAY, "DELAY"), + ] + .into_iter() + .filter(|(flag, _)| to.flags & flag != 0) + .map(|(_, name)| name) + .collect(); + if to.flags & RCPT_NOTIFY_NEVER != 0 { + params.insert("NOTIFY".into(), Some("NEVER".into())); + } else if !notify.is_empty() { + params.insert("NOTIFY".into(), Some(notify.join(","))); + } + if let Some(orcpt) = to.orcpt { + params.insert("ORCPT".into(), Some(orcpt)); + } + self.rcpt_to.push(Address { + email: to.address, + parameters: (!params.is_empty()).then_some(params), + }); + self.reply("250 2.1.5 OK"); + } + Lookup::UnknownUser => { + self.refused_rcpts += 1; + self.error("550 5.1.1 Mailbox does not exist"); + } + Lookup::NotServed => { + self.refused_rcpts += 1; + self.error("550 5.7.1 Relay not allowed"); + } + Lookup::BadAddress => self.error("501 5.1.3 Bad recipient address"), + } + } + + async fn data(&mut self) -> Next { + if self.mail_from.is_none() { + self.error("503 5.5.1 MAIL first"); + return Next::Continue; + } + if self.rcpt_to.is_empty() { + self.error("554 5.5.1 No valid recipients"); + return Next::Continue; + } + let Some(sealing_key) = self.settings.sealing_key else { + self.reply("451 4.3.0 Not ready, try again later"); + return Next::Continue; + }; + match crate::spool::Spool::begin(&self.app.paths.spool_in(), &sealing_key).await { + Ok(incoming) => { + self.reply("354 Start mail input; end with ."); + Next::Data(Box::new(incoming)) + } + Err(e) => { + self.app + .set_error(format!("could not write to the spool: {e}")); + self.reply("451 4.3.0 Temporary failure, try again later"); + Next::Continue + } + } + } + + fn record_end(&self) { + self.app.record( + "smtp.session", + data([ + ("remoteIp", json!(self.peer.ip().to_string())), + ("tls", json!(self.tls.is_some())), + ("accepted", json!(self.accepted)), + ("refusedRecipients", json!(self.refused_rcpts)), + ]), + ); + } +} diff --git a/crates/relay/src/spool.rs b/crates/relay/src/spool.rs new file mode 100644 index 00000000..e2fbd2ae --- /dev/null +++ b/crates/relay/src/spool.rs @@ -0,0 +1,725 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The inbound spool (spec "The spool", RL-15, RL-16, RL-21). +//! +//! Each message is two files in `spool/in/`: `.msg`, the sealed +//! stream, written and fsynced first; then `.json`, its properties, +//! written atomically. The `.json` is the commit point: a `.msg` without +//! one is a message that was never acknowledged, and is deleted at start. +//! The index lives in memory and is rebuilt from the `.json` files. +//! +//! Leases and live verdict waiters are runtime only. After a restart every +//! message is held and unleased; the downstream deduplicates by id (RL-16). + +use crate::state::{set_private, sync_dir, write_atomic}; +use inbuxa_relay_proto::{ + seal::Sealer, + time, + types::{DoorCheck, Envelope, Inbound, InboundState, Session, Verdict}, +}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, VecDeque}, + fs, io, + path::{Path, PathBuf}, +}; +use tokio::{io::AsyncWriteExt, sync::oneshot}; + +/// How many destroyed ids `/changes` can still report. +const TOMBSTONES: usize = 10_000; + +/// A message's stored properties. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Meta { + pub id: String, + pub received_at: u64, + pub envelope: Envelope, + pub session: Session, + pub size: u64, + #[serde(default)] + pub door_checks: Vec, +} + +struct Lease { + conn: u64, + until: u64, +} + +struct Entry { + meta: Meta, + file_len: u64, + created: u64, + modseq: u64, + lease: Option, + /// Set while the SMTP session waits for a live verdict. + waiter: Option>, +} + +impl Entry { + fn state(&self, now: u64) -> InboundState { + if self.lease.as_ref().is_some_and(|l| l.until > now) { + InboundState::Claimed + } else if self.waiter.is_some() { + InboundState::Pending + } else { + InboundState::Held + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Changes { + pub old_state: String, + pub new_state: String, + pub created: Vec, + pub updated: Vec, + pub destroyed: Vec, + pub has_more: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerdictError { + NotFound, + /// The message isn't waiting for a verdict. + NotPending, + /// The reply isn't valid for its kind. + Invalid, +} + +pub struct Spool { + dir: PathBuf, + entries: BTreeMap, + modseq: u64, + tombstones: VecDeque<(u64, String)>, + /// `/changes` can't go back past this. + floor: u64, + bytes: u64, +} + +pub fn new_id() -> String { + use aws_lc_rs::rand::SecureRandom; + let mut bytes = [0u8; 12]; + aws_lc_rs::rand::SystemRandom::new() + .fill(&mut bytes) + .expect("system random source"); + let mut id = String::with_capacity(25); + id.push('i'); + for b in bytes { + id.push_str(&format!("{b:02x}")); + } + id +} + +/// Whether `id` is one of ours: never a path. +pub fn is_valid_id(id: &str) -> bool { + id.len() == 25 && id.starts_with('i') && id[1..].bytes().all(|b| b.is_ascii_hexdigit()) +} + +impl Spool { + /// Opens the spool, deleting what was never committed. + pub fn open(dir: &Path) -> io::Result { + fs::create_dir_all(dir)?; + let mut spool = Spool { + dir: dir.to_path_buf(), + entries: BTreeMap::new(), + modseq: 1, + tombstones: VecDeque::new(), + floor: 1, + bytes: 0, + }; + let mut msgs = Vec::new(); + for entry in fs::read_dir(dir)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + if let Some(id) = name.strip_suffix(".json") { + if !is_valid_id(id) { + continue; + } + let meta: Meta = match fs::read(entry.path()) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + { + Some(meta) => meta, + None => { + eprintln!("inbuxa-relay: unreadable spool entry {id}, skipped"); + continue; + } + }; + let file_len = fs::metadata(spool.msg_path(id)).map(|m| m.len()).ok(); + let Some(file_len) = file_len else { + let _ = fs::remove_file(entry.path()); + continue; + }; + spool.bytes += file_len; + spool.entries.insert( + id.to_string(), + Entry { + meta, + file_len, + created: 1, + modseq: 1, + lease: None, + waiter: None, + }, + ); + } else if name.ends_with(".msg") || name.ends_with(".tmp") { + msgs.push((name, entry.path())); + } + } + for (name, path) in msgs { + let id = name.split('.').next().unwrap_or(""); + if name.ends_with(".tmp") || !spool.entries.contains_key(id) { + let _ = fs::remove_file(path); + } + } + Ok(spool) + } + + pub fn msg_path(&self, id: &str) -> PathBuf { + self.dir.join(format!("{id}.msg")) + } + + fn meta_path(&self, id: &str) -> PathBuf { + self.dir.join(format!("{id}.json")) + } + + pub fn state(&self) -> String { + self.modseq.to_string() + } + + pub fn count(&self) -> u64 { + self.entries.len() as u64 + } + + pub fn bytes(&self) -> u64 { + self.bytes + } + + pub fn contains(&self, id: &str) -> bool { + self.entries.contains_key(id) + } + + fn bump(&mut self) -> u64 { + self.modseq += 1; + self.modseq + } + + /// Starts writing a new message in the spool directory `dir`, sealed + /// to `sealing_key`. Needs no lock: the file is new and unlisted. + pub async fn begin(dir: &Path, sealing_key: &[u8; 32]) -> io::Result { + let id = new_id(); + let tmp = dir.join(format!("{id}.msg.tmp")); + let (sealer, header) = Sealer::new(sealing_key, id.as_bytes()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp) + .await?; + set_private(&tmp, false)?; + file.write_all(&header).await?; + Ok(Incoming { + id, + tmp, + file, + sealer, + out: Vec::with_capacity(70 * 1024), + plaintext: 0, + written: header.len() as u64, + }) + } + + /// Adds a committed message. With `wait`, the message is pending and + /// the receiver gets the downstream's verdict. + pub fn insert( + &mut self, + meta: Meta, + file_len: u64, + wait: bool, + ) -> Option> { + let modseq = self.bump(); + let (tx, rx) = if wait { + let (tx, rx) = oneshot::channel(); + (Some(tx), Some(rx)) + } else { + (None, None) + }; + self.bytes += file_len; + self.entries.insert( + meta.id.clone(), + Entry { + meta, + file_len, + created: modseq, + modseq, + lease: None, + waiter: tx, + }, + ); + rx + } + + /// The JMAP object for one message. + pub fn get(&self, id: &str, now: u64) -> Option { + let e = self.entries.get(id)?; + let state = e.state(now); + Some(Inbound { + id: e.meta.id.clone(), + received_at: time::utc_date(e.meta.received_at), + state, + claimed_until: (state == InboundState::Claimed) + .then(|| time::utc_date(e.lease.as_ref().map_or(0, |l| l.until))), + envelope: e.meta.envelope.clone(), + session: e.meta.session.clone(), + size: e.meta.size, + blob_id: e.meta.id.clone(), + door_checks: e.meta.door_checks.clone(), + }) + } + + /// Ids, pending first, then by arrival. + pub fn ids(&self, now: u64) -> Vec { + let mut ids: Vec<(&Entry, bool)> = self + .entries + .values() + .map(|e| (e, e.state(now) == InboundState::Pending)) + .collect(); + ids.sort_by_key(|(e, pending)| (!pending, e.meta.received_at, e.meta.id.clone())); + ids.into_iter().map(|(e, _)| e.meta.id.clone()).collect() + } + + pub fn changes(&self, since: &str, max: usize) -> Option { + let since: u64 = since.parse().ok()?; + if since < self.floor || since > self.modseq { + return None; + } + let mut created = Vec::new(); + let mut updated = Vec::new(); + for (id, e) in &self.entries { + if e.created > since { + created.push(id.clone()); + } else if e.modseq > since { + updated.push(id.clone()); + } + } + let destroyed: Vec = self + .tombstones + .iter() + .filter(|(seq, _)| *seq > since) + .map(|(_, id)| id.clone()) + .collect(); + let total = created.len() + updated.len() + destroyed.len(); + if total > max { + // Too much at once: the client starts over with /query. + return None; + } + Some(Changes { + old_state: since.to_string(), + new_state: self.state(), + created, + updated, + destroyed, + has_more: false, + }) + } + + /// RL-15: leases up to `limit` unleased messages to `conn`. + pub fn claim(&mut self, conn: u64, limit: usize, lease_secs: u64, now: u64) -> Vec { + let mut out = Vec::new(); + for id in self.ids(now) { + if out.len() >= limit { + break; + } + let e = self.entries.get(&id).expect("listed"); + if e.lease.as_ref().is_some_and(|l| l.until > now) { + continue; + } + let modseq = self.bump(); + let e = self.entries.get_mut(&id).expect("listed"); + e.lease = Some(Lease { + conn, + until: now + lease_secs, + }); + e.modseq = modseq; + out.push(id); + } + out + } + + /// Ends `conn`'s leases, when its link closes. True if any ended. + pub fn release_conn(&mut self, conn: u64) -> bool { + let ids: Vec = self + .entries + .iter() + .filter(|(_, e)| e.lease.as_ref().is_some_and(|l| l.conn == conn)) + .map(|(id, _)| id.clone()) + .collect(); + for id in &ids { + let modseq = self.bump(); + let e = self.entries.get_mut(id).expect("listed"); + e.lease = None; + e.modseq = modseq; + } + !ids.is_empty() + } + + /// Ends leases that ran out. True if any did. + pub fn sweep_leases(&mut self, now: u64) -> bool { + let ids: Vec = self + .entries + .iter() + .filter(|(_, e)| e.lease.as_ref().is_some_and(|l| l.until <= now)) + .map(|(id, _)| id.clone()) + .collect(); + for id in &ids { + let modseq = self.bump(); + let e = self.entries.get_mut(id).expect("listed"); + e.lease = None; + e.modseq = modseq; + } + !ids.is_empty() + } + + /// RL-12: the downstream's verdict on a pending message. A reject or + /// tempfail removes the message: the sender keeps it. + pub fn set_verdict(&mut self, id: &str, verdict: Verdict) -> Result<(), VerdictError> { + if !verdict.is_valid() { + return Err(VerdictError::Invalid); + } + let e = self.entries.get_mut(id).ok_or(VerdictError::NotFound)?; + let waiter = e.waiter.take().ok_or(VerdictError::NotPending)?; + let keep = matches!(verdict, Verdict::Accept); + // The session may have given up a moment ago; then it holds. + let delivered = waiter.send(verdict).is_ok(); + if keep || !delivered { + let modseq = self.bump(); + if let Some(e) = self.entries.get_mut(id) { + e.modseq = modseq; + } + } else { + self.remove(id); + } + Ok(()) + } + + /// RL-13: the wait ran out, so the message is held. + pub fn stop_waiting(&mut self, id: &str) { + let modseq = self.bump(); + if let Some(e) = self.entries.get_mut(id) + && e.waiter.take().is_some() + { + e.modseq = modseq; + } + } + + /// The downstream took the message: delete it. A pending message is + /// thereby accepted. + pub fn destroy(&mut self, id: &str) -> bool { + if let Some(waiter) = self.entries.get_mut(id).and_then(|e| e.waiter.take()) { + let _ = waiter.send(Verdict::Accept); + } + self.remove(id) + } + + fn remove(&mut self, id: &str) -> bool { + let Some(e) = self.entries.remove(id) else { + return false; + }; + self.bytes = self.bytes.saturating_sub(e.file_len); + let _ = fs::remove_file(self.meta_path(id)); + let _ = fs::remove_file(self.msg_path(id)); + let _ = sync_dir(&self.dir); + let modseq = self.bump(); + self.tombstones.push_back((modseq, id.to_string())); + while self.tombstones.len() > TOMBSTONES { + if let Some((seq, _)) = self.tombstones.pop_front() { + self.floor = seq; + } + } + true + } + + /// Deletes held mail older than `max_age_secs`, unleased and not + /// pending. Returns what was deleted. + pub fn expire(&mut self, now: u64, max_age_secs: u64) -> Vec { + let old: Vec = self + .entries + .iter() + .filter(|(_, e)| { + e.meta.received_at + max_age_secs <= now && e.state(now) == InboundState::Held + }) + .map(|(id, _)| id.clone()) + .collect(); + let mut out = Vec::new(); + for id in old { + if let Some(e) = self.entries.get(&id) { + out.push(e.meta.clone()); + } + self.remove(&id); + } + out + } + + /// Writes a message's properties into the spool directory `dir`: the + /// commit point. Blocking (it fsyncs); needs no lock. + pub fn commit_meta(dir: &Path, meta: &Meta) -> io::Result<()> { + write_atomic( + &dir.join(format!("{}.json", meta.id)), + &serde_json::to_vec(meta).map_err(io::Error::other)?, + ) + } +} + +/// A message being received, sealed as it arrives. +pub struct Incoming { + pub id: String, + tmp: PathBuf, + file: tokio::fs::File, + sealer: Sealer, + out: Vec, + plaintext: u64, + written: u64, +} + +impl Incoming { + pub fn size(&self) -> u64 { + self.plaintext + } + + pub async fn write(&mut self, data: &[u8]) -> io::Result<()> { + self.plaintext += data.len() as u64; + self.sealer.update(data, &mut self.out); + if self.out.len() >= 64 * 1024 { + self.flush().await?; + } + Ok(()) + } + + async fn flush(&mut self) -> io::Result<()> { + self.file.write_all(&self.out).await?; + self.written += self.out.len() as u64; + self.out.clear(); + Ok(()) + } + + /// Seals the last chunk, fsyncs and renames the file into place. + /// Returns its id and length; the caller commits the properties, whose + /// write also fsyncs the directory. A failure leaves a `.tmp` that the + /// next start deletes. + pub async fn finish(self) -> io::Result<(String, u64)> { + let Incoming { + id, + tmp, + mut file, + sealer, + mut out, + written, + .. + } = self; + sealer.finish(&mut out); + file.write_all(&out).await?; + file.sync_all().await?; + let dest = tmp.with_file_name(format!("{id}.msg")); + tokio::fs::rename(&tmp, &dest).await?; + Ok((id, written + out.len() as u64)) + } + + pub async fn abort(self) { + drop(self.file); + let _ = tokio::fs::remove_file(&self.tmp).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use inbuxa_relay_proto::{ + seal, + types::{Address, Reply}, + }; + use std::net::{IpAddr, Ipv4Addr}; + + fn meta(id: &str, received_at: u64) -> Meta { + Meta { + id: id.into(), + received_at, + envelope: Envelope { + mail_from: Address::new("a@example.org"), + rcpt_to: vec![Address::new("b@example.com")], + }, + session: Session { + remote_ip: IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), + remote_port: 40000, + local_ip: IpAddr::V4(Ipv4Addr::new(203, 0, 113, 10)), + helo: "mx.example.org".into(), + tls: None, + ptr: None, + }, + size: 10, + door_checks: vec![], + } + } + + fn dir(name: &str) -> PathBuf { + let d = + std::env::temp_dir().join(format!("inbuxa-relay-spool-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + d + } + + async fn store( + spool: &mut Spool, + key: &[u8; 32], + body: &[u8], + at: u64, + wait: bool, + ) -> (String, Option>) { + let mut inc = Spool::begin(&spool.dir.clone(), key).await.unwrap(); + inc.write(body).await.unwrap(); + let (id, len) = inc.finish().await.unwrap(); + let m = Meta { + size: body.len() as u64, + ..meta(&id, at) + }; + Spool::commit_meta(&spool.dir.clone(), &m).unwrap(); + let rx = spool.insert(m, len, wait); + (id, rx) + } + + #[tokio::test] + async fn sealed_on_disk_and_survives_restart() { + let d = dir("restart"); + let (secret, public) = seal::generate_keypair(); + let mut spool = Spool::open(&d).unwrap(); + let (id, _) = store( + &mut spool, + &public, + b"Subject: MARKER-91\r\n\r\nhi\r\n", + 100, + false, + ) + .await; + // An uncommitted file from a crash. + fs::write(d.join(format!("{}.msg", new_id())), b"junk").unwrap(); + fs::write(d.join(format!("{}.msg.tmp", new_id())), b"junk").unwrap(); + + let raw = fs::read(spool.msg_path(&id)).unwrap(); + assert!(!raw.windows(9).any(|w| w == b"MARKER-91")); + assert_eq!( + seal::open(&secret, id.as_bytes(), &raw).unwrap(), + b"Subject: MARKER-91\r\n\r\nhi\r\n" + ); + + let spool = Spool::open(&d).unwrap(); + assert_eq!(spool.count(), 1); + assert_eq!(spool.get(&id, 100).unwrap().state, InboundState::Held); + assert_eq!(fs::read_dir(&d).unwrap().count(), 2); + fs::remove_dir_all(d).unwrap(); + } + + #[tokio::test] + async fn claims_leases_and_changes() { + let d = dir("claims"); + let (_, public) = seal::generate_keypair(); + let mut spool = Spool::open(&d).unwrap(); + let s0 = spool.state(); + let (a, _) = store(&mut spool, &public, b"a", 10, false).await; + let (b, _rx) = store(&mut spool, &public, b"b", 20, true).await; + assert_eq!(spool.ids(30), vec![b.clone(), a.clone()], "pending first"); + + let ch = spool.changes(&s0, 100).unwrap(); + assert_eq!(ch.created.len(), 2); + + let s1 = spool.state(); + assert_eq!(spool.claim(1, 1, 300, 30), vec![b.clone()]); + assert_eq!(spool.claim(2, 5, 300, 30), vec![a.clone()]); + assert!(spool.claim(3, 5, 300, 30).is_empty()); + assert_eq!(spool.get(&a, 30).unwrap().state, InboundState::Claimed); + let ch = spool.changes(&s1, 100).unwrap(); + assert_eq!(ch.updated.len(), 2); + + assert!(spool.release_conn(2)); + assert_eq!(spool.claim(3, 5, 300, 30), vec![a.clone()]); + assert!(spool.sweep_leases(400)); + assert_eq!(spool.get(&a, 400).unwrap().state, InboundState::Held); + + let s2 = spool.state(); + assert!(spool.destroy(&a)); + assert!(!spool.destroy(&a)); + assert_eq!(spool.changes(&s2, 100).unwrap().destroyed, vec![a]); + assert!(spool.changes("0", 100).is_none()); + fs::remove_dir_all(d).unwrap(); + } + + #[tokio::test] + async fn verdicts() { + let d = dir("verdicts"); + let (_, public) = seal::generate_keypair(); + let mut spool = Spool::open(&d).unwrap(); + let reject = Verdict::Reject(Reply { + code: 550, + enhanced: "5.7.1".into(), + text: "spam".into(), + }); + + let (a, rx) = store(&mut spool, &public, b"a", 10, true).await; + assert_eq!(spool.set_verdict(&a, reject.clone()), Ok(())); + assert_eq!(rx.unwrap().await.unwrap(), reject); + assert!(!spool.contains(&a), "a rejected message leaves the spool"); + + let (b, rx) = store(&mut spool, &public, b"b", 10, true).await; + assert_eq!(spool.set_verdict(&b, Verdict::Accept), Ok(())); + assert_eq!(rx.unwrap().await.unwrap(), Verdict::Accept); + assert!(spool.contains(&b), "accepted mail waits for destroy"); + assert_eq!( + spool.set_verdict(&b, Verdict::Accept), + Err(VerdictError::NotPending) + ); + + let (c, rx) = store(&mut spool, &public, b"c", 10, true).await; + spool.stop_waiting(&c); + assert!(rx.unwrap().await.is_err()); + assert_eq!(spool.get(&c, 10).unwrap().state, InboundState::Held); + + let (d2, rx) = store(&mut spool, &public, b"d", 10, true).await; + assert!(spool.destroy(&d2)); + assert_eq!(rx.unwrap().await.unwrap(), Verdict::Accept); + + let bad = Verdict::Reject(Reply { + code: 250, + enhanced: "2.0.0".into(), + text: "x".into(), + }); + assert_eq!(spool.set_verdict(&c, bad), Err(VerdictError::Invalid)); + fs::remove_dir_all(d).unwrap(); + } + + #[tokio::test] + async fn expiry_skips_leased_and_pending() { + let d = dir("expiry"); + let (_, public) = seal::generate_keypair(); + let mut spool = Spool::open(&d).unwrap(); + let (a, _) = store(&mut spool, &public, b"a", 0, false).await; + let (b, _) = store(&mut spool, &public, b"b", 0, false).await; + let (_c, _rx) = store(&mut spool, &public, b"c", 0, true).await; + // The pending one, then one of the held two. + assert_eq!(spool.claim(1, 2, 1000, 50).len(), 2); + let gone = spool.expire(100, 100); + assert_eq!(gone.len(), 1); + assert!(gone[0].id == a || gone[0].id == b); + assert_eq!(spool.count(), 2); + fs::remove_dir_all(d).unwrap(); + } + + #[test] + fn ids_are_never_paths() { + assert!(is_valid_id(&new_id())); + assert!(!is_valid_id("../../etc/passwd")); + assert!(!is_valid_id("i0123456789abcdef0123456/")); + } +} diff --git a/crates/relay/src/state.rs b/crates/relay/src/state.rs new file mode 100644 index 00000000..0e06379a --- /dev/null +++ b/crates/relay/src/state.rs @@ -0,0 +1,247 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! The state directory (RL-22): the link key, the pins, the cached +//! settings, the recipient set, the spool and the unacknowledged events. +//! Everything else is rebuilt at start. + +use inbuxa_relay_proto::{b64, pin::Pin, types::RelaySettings}; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File, OpenOptions}, + io::{self, Write}, + path::{Path, PathBuf}, +}; + +pub const DEFAULT_DIR: &str = "/var/lib/inbuxa-relay"; + +pub struct Paths { + pub root: PathBuf, +} + +impl Paths { + pub fn new(root: impl Into) -> Self { + Paths { root: root.into() } + } + + pub fn identity(&self) -> PathBuf { + self.root.join("identity.json") + } + + pub fn settings(&self) -> PathBuf { + self.root.join("settings.json") + } + + pub fn recipients(&self) -> PathBuf { + self.root.join("recipients.json") + } + + pub fn status(&self) -> PathBuf { + self.root.join("status.json") + } + + pub fn events(&self) -> PathBuf { + self.root.join("events.jsonl") + } + + pub fn lock(&self) -> PathBuf { + self.root.join("run.lock") + } + + pub fn spool_in(&self) -> PathBuf { + self.root.join("spool").join("in") + } + + pub fn blobs(&self) -> PathBuf { + self.root.join("blobs") + } + + pub fn create_dirs(&self) -> io::Result<()> { + for dir in [self.root.clone(), self.spool_in(), self.blobs()] { + fs::create_dir_all(&dir)?; + set_private(&dir, true)?; + } + Ok(()) + } +} + +/// Owner-only permissions: the directory holds a private key. +#[cfg(unix)] +pub fn set_private(path: &Path, dir: bool) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + path, + fs::Permissions::from_mode(if dir { 0o700 } else { 0o600 }), + ) +} + +#[cfg(not(unix))] +pub fn set_private(_: &Path, _: bool) -> io::Result<()> { + Ok(()) +} + +/// Writes a file so that it is either the old or the new content, never a +/// mix: a temporary name, fsync, rename, then fsync of the directory. +pub fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> { + let dir = path.parent().unwrap_or(Path::new(".")); + let tmp = dir.join(format!( + ".{}.tmp", + path.file_name().and_then(|n| n.to_str()).unwrap_or("file") + )); + { + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&tmp)?; + set_private(&tmp, false)?; + file.write_all(data)?; + file.sync_all()?; + } + fs::rename(&tmp, path)?; + sync_dir(dir) +} + +pub fn sync_dir(dir: &Path) -> io::Result<()> { + File::open(dir)?.sync_all() +} + +pub fn read_json Deserialize<'de>>(path: &Path) -> io::Result> { + match fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("{path:?}: {e}"))), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +pub fn write_json(path: &Path, value: &T) -> io::Result<()> { + write_atomic( + path, + &serde_json::to_vec_pretty(value).map_err(io::Error::other)?, + ) +} + +/// Where the relay is in joining (spec "Joining and reset"). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum JoinStage { + /// Waiting for the downstream's `Relay/join`. + Waiting, + /// The proof was given once; the first `Relay/set` completes the join. + Answered, + /// Joined: the secret is gone. + Joined, +} + +/// Who the relay is, and who it belongs to. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Identity { + pub relay_id: String, + /// The downstream link CA's key pin (RL-6). + #[serde(with = "b64")] + pub ca_pin: Pin, + /// The join token's secret, until the join completes. + #[serde(default, with = "b64::option", skip_serializing_if = "Option::is_none")] + pub join_secret: Option<[u8; 32]>, + /// When the join token expires: no `Relay/join` after it. + pub join_expires_at: u64, + pub stage: JoinStage, + /// The relay's link key and its self-signed certificate (RL-7). + pub link_key_pem: String, + pub link_cert_pem: String, +} + +impl std::fmt::Debug for Identity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Identity") + .field("relay_id", &self.relay_id) + .field("stage", &self.stage) + .finish_non_exhaustive() + } +} + +impl Identity { + pub fn load(paths: &Paths) -> io::Result> { + read_json(&paths.identity()) + } + + pub fn save(&self, paths: &Paths) -> io::Result<()> { + write_json(&paths.identity(), self) + } +} + +pub fn load_settings(paths: &Paths) -> io::Result> { + read_json(&paths.settings()) +} + +pub fn save_settings(paths: &Paths, settings: &RelaySettings) -> io::Result<()> { + write_json(&paths.settings(), settings) +} + +/// Held while the relay runs, so `reset` can't pull the state out from +/// under it. +pub struct RunLock { + _file: File, +} + +impl RunLock { + pub fn acquire(paths: &Paths) -> io::Result { + let file = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(paths.lock())?; + match file.try_lock() { + Ok(()) => Ok(RunLock { _file: file }), + Err(fs::TryLockError::WouldBlock) => Err(io::Error::new( + io::ErrorKind::WouldBlock, + "inbuxa-relay is already running with this state directory", + )), + Err(fs::TryLockError::Error(e)) => Err(e), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + pub fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "inbuxa-relay-test-{name}-{}-{}", + std::process::id(), + inbuxa_relay_proto::time::now() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn atomic_write_replaces() { + let dir = temp_dir("atomic"); + let path = dir.join("f.json"); + write_atomic(&path, b"one").unwrap(); + write_atomic(&path, b"two").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"two"); + assert!(!dir.join(".f.json.tmp").exists()); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn lock_is_exclusive() { + let dir = temp_dir("lock"); + let paths = Paths::new(&dir); + let first = RunLock::acquire(&paths).unwrap(); + assert!(RunLock::acquire(&paths).is_err()); + drop(first); + assert!(RunLock::acquire(&paths).is_ok()); + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/relay/src/tests_e2e.rs b/crates/relay/src/tests_e2e.rs new file mode 100644 index 00000000..2273aa8c --- /dev/null +++ b/crates/relay/src/tests_e2e.rs @@ -0,0 +1,640 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! End to end, over real sockets: a test downstream joins the relay, sets +//! it up over the link, and mail goes through the door in held and live +//! mode (spec acceptance tests RL-T1 to RL-T9). + +use crate::{ + app::App, + link::{ + self, + tls::{cert_pin, tests::test_ca}, + }, + smtp, + state::{Identity, Paths}, +}; +use futures_util::{SinkExt, StreamExt}; +use http_body_util::{BodyExt, Full}; +use hyper::body::Bytes; +use hyper_util::rt::TokioIo; +use inbuxa_relay_proto::{ + b64, recipient, seal, time, + token::{self, JoinToken}, +}; +use rustls::{ + DigitallySignedStruct, SignatureScheme, + client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, +}; +use rustls_pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; +use serde_json::{Value, json}; +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::{TcpListener, TcpStream}, +}; +use tokio_rustls::{TlsConnector, client::TlsStream}; +use tokio_tungstenite::{WebSocketStream, tungstenite::Message}; + +/// Takes any server certificate, and keeps it so the test can pin it, as +/// the downstream does at join. +#[derive(Debug, Default)] +struct Capture(Mutex>>); + +impl ServerCertVerifier for Capture { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + _: &[CertificateDer<'_>], + _: &ServerName<'_>, + _: &[u8], + _: UnixTime, + ) -> Result { + *self.0.lock().unwrap() = Some(end_entity.to_vec()); + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _: &[u8], + _: &CertificateDer<'_>, + _: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &link::tls::provider().signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + link::tls::provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +struct Downstream { + connector: TlsConnector, + capture: Arc, + addr: std::net::SocketAddr, +} + +impl Downstream { + fn new(ca: &link::tls::tests::TestCa, addr: std::net::SocketAddr) -> Self { + let capture = Arc::new(Capture::default()); + let config = rustls::ClientConfig::builder_with_provider(link::tls::provider()) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .dangerous() + .with_custom_certificate_verifier(capture.clone()) + .with_client_auth_cert( + vec![ + CertificateDer::from(ca.node_cert_der.clone()), + CertificateDer::from(ca.ca_der.clone()), + ], + PrivateKeyDer::try_from(ca.node_key_der.clone()).unwrap(), + ) + .unwrap(); + Downstream { + connector: TlsConnector::from(Arc::new(config)), + capture, + addr, + } + } + + async fn tls(&self) -> std::io::Result> { + let tcp = TcpStream::connect(self.addr).await?; + self.connector + .connect(ServerName::try_from("relay.invalid").unwrap(), tcp) + .await + } + + async fn ws(&self) -> Result { + let tls = self.tls().await.map_err(|e| e.to_string())?; + let request = tokio_tungstenite::tungstenite::http::Request::builder() + .uri("wss://relay.invalid/jmap/ws") + .header("Host", "relay.invalid") + .header("Connection", "Upgrade") + .header("Upgrade", "websocket") + .header("Sec-WebSocket-Version", "13") + .header( + "Sec-WebSocket-Key", + tokio_tungstenite::tungstenite::handshake::client::generate_key(), + ) + .header("Sec-WebSocket-Protocol", "jmap") + .body(()) + .unwrap(); + let (ws, _) = tokio_tungstenite::client_async(request, tls) + .await + .map_err(|e| e.to_string())?; + Ok(Ws { + ws, + changes: vec![], + next: 0, + }) + } + + async fn http(&self, method: &str, path: &str, body: Vec) -> (u16, Vec) { + let tls = self.tls().await.unwrap(); + let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(tls)) + .await + .unwrap(); + tokio::spawn(conn); + let req = hyper::Request::builder() + .method(method) + .uri(path) + .header("Host", "relay.invalid") + .body(Full::new(Bytes::from(body))) + .unwrap(); + let resp = sender.send_request(req).await.unwrap(); + let status = resp.status().as_u16(); + let body = resp + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(); + (status, body) + } +} + +struct Ws { + ws: WebSocketStream>, + /// StateChanges seen, as `type:state`. + changes: Vec, + next: u64, +} + +impl Ws { + async fn frame(&mut self) -> Value { + let msg = tokio::time::timeout(Duration::from_secs(10), self.ws.next()) + .await + .expect("a frame within 10 s") + .expect("open") + .expect("ok"); + let Message::Text(text) = msg else { + panic!("not text: {msg:?}") + }; + serde_json::from_str(&text).unwrap() + } + + fn note(&mut self, frame: &Value) { + if frame["@type"] == "StateChange" { + for (t, s) in frame["changed"]["relay"].as_object().unwrap() { + self.changes.push(format!("{t}:{}", s.as_str().unwrap())); + } + } + } + + async fn send(&mut self, value: Value) { + self.ws + .send(Message::Text(value.to_string().into())) + .await + .unwrap(); + } + + /// One method call; returns `(name, args)` of the response. + async fn call(&mut self, method: &str, mut args: Value) -> (String, Value) { + args["accountId"] = json!("relay"); + self.next += 1; + let id = format!("r{}", self.next); + self.send(json!({ + "@type": "Request", + "id": id, + "using": ["urn:ietf:params:jmap:core", "urn:inbuxa:jmap:relay"], + "methodCalls": [[method, args, "c0"]], + })) + .await; + loop { + let frame = self.frame().await; + self.note(&frame); + if frame["@type"] == "Response" && frame["requestId"] == id { + let call = &frame["methodResponses"][0]; + return (call[0].as_str().unwrap().to_string(), call[1].clone()); + } + assert_ne!(frame["@type"], "RequestError", "{frame}"); + } + } + + async fn ok(&mut self, method: &str, args: Value) -> Value { + let (name, args) = self.call(method, args).await; + assert_eq!(name, method, "{args}"); + args + } + + /// Waits for a StateChange naming `type_name`. + async fn wait_change(&mut self, type_name: &str) { + let prefix = format!("{type_name}:"); + let seen = self.changes.len(); + if self.changes[..seen].iter().any(|c| c.starts_with(&prefix)) { + self.changes.clear(); + return; + } + loop { + let frame = self.frame().await; + self.note(&frame); + if self.changes.iter().any(|c| c.starts_with(&prefix)) { + self.changes.clear(); + return; + } + } + } +} + +struct Smtp { + reader: BufReader, + writer: tokio::net::tcp::OwnedWriteHalf, +} + +impl Smtp { + async fn connect(port: u16) -> Smtp { + let tcp = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + let (r, w) = tcp.into_split(); + let mut s = Smtp { + reader: BufReader::new(r), + writer: w, + }; + assert!(s.reply().await.starts_with("220 relay.test")); + s + } + + /// The whole reply, lines joined by `\n`. + async fn reply(&mut self) -> String { + let mut out = String::new(); + loop { + let mut line = String::new(); + let n = tokio::time::timeout(Duration::from_secs(10), self.reader.read_line(&mut line)) + .await + .expect("a reply") + .unwrap(); + assert!(n > 0, "closed"); + let line = line.trim_end().to_string(); + let more = line.as_bytes().get(3) == Some(&b'-'); + out.push_str(&line); + if !more { + return out; + } + out.push('\n'); + } + } + + async fn cmd(&mut self, line: &str) -> String { + self.writer + .write_all(format!("{line}\r\n").as_bytes()) + .await + .unwrap(); + self.reply().await + } + + async fn send_message(&mut self, rcpt: &str, body: &str) -> String { + assert!( + self.cmd("MAIL FROM:") + .await + .starts_with("250") + ); + assert!( + self.cmd(&format!("RCPT TO:<{rcpt}>")) + .await + .starts_with("250") + ); + assert!(self.cmd("DATA").await.starts_with("354")); + self.writer.write_all(body.as_bytes()).await.unwrap(); + self.cmd(".").await + } +} + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn relay_end_to_end() { + let dir = std::env::temp_dir().join(format!("inbuxa-relay-e2e-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let paths = Paths::new(&dir); + + // inbuxa-admin makes a token; the relay host joins with it. + let ca = test_ca(true); + let ca_pin = cert_pin(&ca.ca_der).unwrap(); + let token = JoinToken::issue("r1", ca_pin, time::now()); + crate::join(&paths, &token.encode()).unwrap(); + assert!(crate::join(&paths, &token.encode()).is_err(), "joined once"); + + let identity = Identity::load(&paths).unwrap().unwrap(); + let app = Arc::new(App::open(Paths::new(&dir), identity).unwrap()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let link_addr = listener.local_addr().unwrap(); + tokio::spawn(link::serve(app.clone(), listener)); + tokio::spawn(smtp::serve(app.clone())); + + // RL-T8: a client from another CA gets no further than TLS. + let stranger = Downstream::new(&test_ca(true), link_addr); + assert!(stranger.ws().await.is_err()); + + let down = Downstream::new(&ca, link_addr); + let mut ws = down.ws().await.unwrap(); + + // Before the join, nothing but Relay/join. + let (name, args) = ws.call("Inbound/get", json!({})).await; + assert_eq!( + (name.as_str(), args["type"].as_str()), + ("error", Some("forbidden")) + ); + + // RL-T9 / RL-8: the join proof. + let nonce = [7u8; 32]; + let joined = ws + .ok("Relay/join", json!({ "nonce": b64::encode(&nonce) })) + .await; + let relay_cert = down.capture.0.lock().unwrap().clone().unwrap(); + let relay_pin = cert_pin(&relay_cert).unwrap(); + assert_eq!(joined["relayPin"], json!(b64::encode(&relay_pin))); + let proof = b64::decode(joined["proof"].as_str().unwrap()).unwrap(); + assert!(token::verify_join_proof( + &token.secret, + &nonce, + &relay_pin, + &proof + )); + let (name, _) = ws + .call("Relay/join", json!({ "nonce": b64::encode(&nonce) })) + .await; + assert_eq!(name, "error", "a second join fails"); + + // The first settings complete the join. + let (secret, public) = seal::generate_keypair(); + let recipient_key = [3u8; 32]; + let smtp_port = free_port(); + let set = ws + .ok( + "Relay/set", + json!({ "update": { "singleton": { + "hostname": "relay.test", + "smtpListen": [format!("127.0.0.1:{smtp_port}")], + "domains": ["example.com"], + "sealingKey": b64::encode(&public), + "recipientKey": b64::encode(&recipient_key), + "liveVerdictTimeout": 2, + }}}), + ) + .await; + assert_eq!(set["updated"], json!({ "singleton": null }), "{set}"); + let bad = ws + .ok( + "Relay/set", + json!({ "update": { "singleton": { "liveVerdictTimeout": 500 }}}), + ) + .await; + assert!(bad["notUpdated"]["singleton"].is_object()); + assert!( + Identity::load(&paths) + .unwrap() + .unwrap() + .join_secret + .is_none() + ); + + // Recipients: a domain rule, and the set by upload and replace. + ws.ok( + "RecipientDomain/set", + json!({ "create": { "d": { "domain": "example.com", "subaddressSeparator": "+" }}}), + ) + .await; + let file: Vec = ["john@example.com", "jane@example.com"] + .iter() + .flat_map(|a| recipient::hash(&recipient_key, a)) + .collect(); + let (status, body) = down.http("POST", "/jmap/upload/relay", file).await; + assert_eq!(status, 201); + let blob: Value = serde_json::from_slice(&body).unwrap(); + let replaced = ws + .ok("Recipient/replace", json!({ "blobId": blob["blobId"] })) + .await; + assert_eq!(replaced["count"], 2); + + // Port 25 opens once configured. + let mut opened = false; + for _ in 0..50 { + if TcpStream::connect(("127.0.0.1", smtp_port)).await.is_ok() { + opened = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!(opened, "port 25 opened"); + tokio::time::sleep(Duration::from_millis(400)).await; + + // RL-T1, RL-T2: the door, in held mode. + let mut s = Smtp::connect(smtp_port).await; + let ehlo = s.cmd("EHLO mx.example.org").await; + assert!(ehlo.contains("STARTTLS") && ehlo.contains("PIPELINING")); + assert!(!ehlo.contains("AUTH")); + assert!( + s.cmd("AUTH PLAIN AGJvYgBzZWNyZXQ=") + .await + .starts_with("502") + ); + assert!( + s.cmd("MAIL FROM:") + .await + .starts_with("250") + ); + assert!( + s.cmd("RCPT TO:") + .await + .starts_with("550 5.1.1") + ); + assert!( + s.cmd("RCPT TO:") + .await + .starts_with("550 5.7.1") + ); + assert!( + s.cmd("RCPT TO:") + .await + .starts_with("250") + ); + assert!(s.cmd("DATA").await.starts_with("354")); + let body = "Subject: test\r\n\r\nMARKER-e2e-0451\r\n..dot line\r\n"; + s.writer.write_all(body.as_bytes()).await.unwrap(); + let done = s.cmd(".").await; + assert!(done.starts_with("250 2.0.0 Accepted i"), "{done}"); + let id = done.rsplit(' ').next().unwrap().to_string(); + + // STARTTLS, with the self-signed certificate the relay uses until the + // downstream sends one. + let tcp = TcpStream::connect(("127.0.0.1", smtp_port)).await.unwrap(); + let mut plain = BufReader::new(tcp); + let mut line = String::new(); + plain.read_line(&mut line).await.unwrap(); + plain + .get_mut() + .write_all(b"EHLO mx.example.org\r\n") + .await + .unwrap(); + loop { + line.clear(); + plain.read_line(&mut line).await.unwrap(); + if line.as_bytes().get(3) == Some(&b' ') { + break; + } + } + plain.get_mut().write_all(b"STARTTLS\r\n").await.unwrap(); + line.clear(); + plain.read_line(&mut line).await.unwrap(); + assert!(line.starts_with("220"), "{line}"); + let config = rustls::ClientConfig::builder_with_provider(link::tls::provider()) + .with_safe_default_protocol_versions() + .unwrap() + .dangerous() + .with_custom_certificate_verifier(Arc::new(Capture::default())) + .with_no_client_auth(); + let tls = TlsConnector::from(Arc::new(config)) + .connect( + ServerName::try_from("relay.test").unwrap(), + plain.into_inner(), + ) + .await + .unwrap(); + let mut tls = BufReader::new(tls); + tls.get_mut() + .write_all(b"EHLO mx.example.org\r\n") + .await + .unwrap(); + let mut ehlo = String::new(); + loop { + line.clear(); + tls.read_line(&mut line).await.unwrap(); + ehlo.push_str(&line); + if line.as_bytes().get(3) == Some(&b' ') { + break; + } + } + assert!( + ehlo.starts_with("250-relay.test") && !ehlo.contains("STARTTLS"), + "{ehlo}" + ); + tls.get_mut().write_all(b"QUIT\r\n").await.unwrap(); + + // Handing it down: claim, download, open, destroy. + let claimed = ws.ok("Inbound/claim", json!({ "limit": 10 })).await; + assert_eq!(claimed["ids"], json!([id])); + let got = ws.ok("Inbound/get", json!({ "ids": [id] })).await; + let inbound = &got["list"][0]; + assert_eq!(inbound["state"], "claimed"); + assert_eq!( + inbound["envelope"]["rcptTo"][0]["email"], + "John+news@example.com" + ); + assert_eq!(inbound["session"]["remoteIp"], "127.0.0.1"); + assert_eq!(inbound["session"]["helo"], "mx.example.org"); + let on_disk = std::fs::read(app.paths.spool_in().join(format!("{id}.msg"))).unwrap(); + assert!( + !on_disk.windows(15).any(|w| w == b"MARKER-e2e-0451"), + "RL-T7" + ); + let (status, sealed) = down + .http("GET", &format!("/jmap/download/relay/{id}/msg"), vec![]) + .await; + assert_eq!(status, 200); + let opened = seal::open(&secret, id.as_bytes(), &sealed).unwrap(); + assert_eq!( + String::from_utf8(opened).unwrap(), + "Subject: test\r\n\r\nMARKER-e2e-0451\r\n.dot line\r\n" + ); + let destroyed = ws.ok("Inbound/set", json!({ "destroy": [id] })).await; + assert_eq!(destroyed["destroyed"], json!([id])); + assert!(!app.paths.spool_in().join(format!("{id}.msg")).exists()); + + // Live mode: push on, then the downstream refuses a message (RL-T3). + ws.send(json!({ "@type": "WebSocketPushEnable", "dataTypes": null })) + .await; + ws.wait_change("Inbound").await; + let sender = tokio::spawn(async move { + let mut s = Smtp::connect(smtp_port).await; + s.cmd("EHLO mx.example.org").await; + let first = s + .send_message("jane@example.com", "Subject: spam\r\n\r\nbuy\r\n") + .await; + let second = s + .send_message("jane@example.com", "Subject: slow\r\n\r\nwait\r\n") + .await; + (first, second) + }); + ws.wait_change("Inbound").await; + let pending = ws.ok("Inbound/query", json!({})).await; + let live_id = pending["ids"][0].as_str().unwrap().to_string(); + let got = ws.ok("Inbound/get", json!({ "ids": [live_id] })).await; + assert_eq!(got["list"][0]["state"], "pending"); + ws.ok("Inbound/claim", json!({})).await; + let verdict = ws + .ok( + "Inbound/set", + json!({ "update": { live_id.clone(): { "verdict": { + "type": "reject", "code": 550, "enhanced": "5.7.1", "text": "Spam" + }}}}), + ) + .await; + assert_eq!( + verdict["updated"], + json!({ live_id.clone(): null }), + "{verdict}" + ); + + // RL-T4: no verdict for the second, so it is held after the timeout. + let (first, second) = tokio::time::timeout(Duration::from_secs(15), sender) + .await + .unwrap() + .unwrap(); + assert_eq!(first, "550 5.7.1 Spam"); + assert!(second.starts_with("250 2.0.0 Accepted"), "{second}"); + let held_id = second.rsplit(' ').next().unwrap().to_string(); + assert!( + !app.spool.lock().unwrap().contains(&live_id), + "refused mail is gone" + ); + let got = ws.ok("Inbound/get", json!({ "ids": [held_id] })).await; + assert_eq!(got["list"][0]["state"], "held"); + + // Events went up the link. + let events = ws.ok("RelayEvent/get", json!({})).await; + let kinds: Vec<&str> = events["list"] + .as_array() + .unwrap() + .iter() + .map(|e| e["type"].as_str().unwrap()) + .collect(); + for kind in [ + "relay.joined", + "inbound.accepted", + "inbound.refused", + "smtp.session", + ] { + assert!(kinds.contains(&kind), "{kind} in {kinds:?}"); + } + + drop(ws); + std::fs::remove_dir_all(dir).unwrap(); +} -- 2.54.0