inbuxa-relay: the relay binary, inbound in held and live mode #245
No files matched your search
Generated
+23
@@ -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"
|
||||
|
||||
@@ -33,6 +33,7 @@ members = [
|
||||
"crates/migration",
|
||||
"crates/features",
|
||||
"crates/relay-proto",
|
||||
"crates/relay",
|
||||
"tests",
|
||||
]
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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<Identity>,
|
||||
/// The settings the downstream last set, if any.
|
||||
pub settings: watch::Sender<Option<Arc<RelaySettings>>>,
|
||||
settings_state: AtomicU64,
|
||||
pub recipients: RwLock<Recipients>,
|
||||
pub spool: Mutex<Spool>,
|
||||
pub events: Mutex<Events>,
|
||||
pub push: broadcast::Sender<Changed>,
|
||||
/// 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<Option<String>>,
|
||||
pub limiter: Mutex<Limiter>,
|
||||
pub self_upgrade: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn open(paths: Paths, identity: Identity) -> std::io::Result<App> {
|
||||
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<Arc<RelaySettings>> {
|
||||
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<String, serde_json::Value>) {
|
||||
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<String>) {
|
||||
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<IpAddr, IpCount>,
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
@@ -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<Events> {
|
||||
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::<RelayEvent>(&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, Value>) -> 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<RelayEvent>, Vec<String>) {
|
||||
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<String>, Vec<String>)> {
|
||||
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<String> {
|
||||
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<const N: usize>(pairs: [(&str, Value); N]) -> Map<String, Value> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<String>);
|
||||
type MethodResult = Result<Value, Failure>;
|
||||
|
||||
fn err(kind: MethodError, text: impl Into<String>) -> Failure {
|
||||
(kind, Some(text.into()))
|
||||
}
|
||||
|
||||
pub fn handle(app: &App, caller: &Caller, request: Request) -> Result<Response, RequestError> {
|
||||
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<String, Value>, key: &str) -> Result<Option<Vec<String>>, 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::<Option<Vec<_>>>()
|
||||
.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<String, Value>,
|
||||
key: &str,
|
||||
) -> Result<Option<&'a Map<String, Value>>, 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<String>) -> Value {
|
||||
json!({ "type": kind, "description": description.into() })
|
||||
}
|
||||
|
||||
// ---- Relay ----------------------------------------------------------------
|
||||
|
||||
fn relay_join(app: &App, args: &Map<String, Value>) -> 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<String, Value>) -> 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<String> = 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<String, Value>) -> 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<Value> = 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<String, Value>) -> 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::<RecipientDomain>(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::<RecipientDomain>(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<String, Failure> {
|
||||
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<RecipientHash> {
|
||||
b64::decode(text).and_then(|b| b.try_into().ok())
|
||||
}
|
||||
|
||||
fn recipient_set(app: &App, args: &Map<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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::<Verdict>(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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value> = 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(),
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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/<blobId>`.
|
||||
|
||||
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<Bytes, std::io::Error>;
|
||||
|
||||
fn full(bytes: impl Into<Bytes>) -> Body {
|
||||
Full::new(bytes.into())
|
||||
.map_err(|never| match never {})
|
||||
.boxed_unsync()
|
||||
}
|
||||
|
||||
fn json_response(status: StatusCode, value: &impl serde::Serialize) -> Response<Body> {
|
||||
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<Body> {
|
||||
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<IpAddr, (u64, u32)>,
|
||||
}
|
||||
|
||||
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<App>, 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<App>, conn: u64, req: Request<Incoming>) -> Response<Body> {
|
||||
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<Incoming>) -> 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<Incoming>) -> Response<Body> {
|
||||
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<App>, conn: u64, req: Request<Incoming>) -> Response<Body> {
|
||||
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::<serde_json::Value>(&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<App>, req: Request<Incoming>) -> Response<Body> {
|
||||
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<App>, rest: &str) -> Response<Body> {
|
||||
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<App>, conn: u64, mut req: Request<Incoming>) -> Response<Body> {
|
||||
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<Option<Vec<String>>>,
|
||||
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<S>(app: Arc<App>, conn: u64, ws: WebSocketStream<S>)
|
||||
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::<ClientFrame>(&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<S>(sink: &mut S, frame: &ServerFrame) -> Result<(), ()>
|
||||
where
|
||||
S: futures_util::Sink<Message> + Unpin,
|
||||
{
|
||||
let text = serde_json::to_string(frame).expect("frame serializes");
|
||||
sink.send(Message::Text(text.into())).await.map_err(|_| ())
|
||||
}
|
||||
@@ -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<CryptoProvider> {
|
||||
Arc::new(rustls::crypto::aws_lc_rs::default_provider())
|
||||
}
|
||||
|
||||
/// The pin of a certificate's key.
|
||||
pub fn cert_pin(cert: &[u8]) -> Option<Pin> {
|
||||
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<rustls::ServerConfig, String> {
|
||||
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<ClientCertVerified, Error> {
|
||||
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<HandshakeSignatureValid, Error> {
|
||||
// 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<HandshakeSignatureValid, Error> {
|
||||
rustls::crypto::verify_tls13_signature(message, cert, dss, &self.algorithms)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
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<u8>,
|
||||
pub node_cert_der: Vec<u8>,
|
||||
pub node_key_der: Vec<u8>,
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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 <token> 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 <dir> state directory (default /var/lib/inbuxa-relay)
|
||||
--link-listen <addr> 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<String>,
|
||||
state: String,
|
||||
link_listen: SocketAddr,
|
||||
discard_held: bool,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
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 <token>`".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<App>) {
|
||||
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<inbuxa_relay_proto::types::RelayStatus> =
|
||||
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(())
|
||||
}
|
||||
@@ -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<String, RecipientDomain>,
|
||||
hashes: HashSet<RecipientHash>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Stored {
|
||||
state: u64,
|
||||
domains: Vec<RecipientDomain>,
|
||||
hashes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Recipients {
|
||||
pub fn load(paths: &Paths) -> io::Result<Recipients> {
|
||||
let Some(stored) = read_json::<Stored>(&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<String> = 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<Item = &RecipientDomain> {
|
||||
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<usize, &'static str> {
|
||||
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, "[email protected]"));
|
||||
file.extend_from_slice(&hash(&key, "[email protected]"));
|
||||
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, "[email protected]")],
|
||||
&[hash(&key, "[email protected]")],
|
||||
);
|
||||
r.save(&paths).unwrap();
|
||||
|
||||
let r = Recipients::load(&paths).unwrap();
|
||||
let all = |_: &str| true;
|
||||
assert_eq!(r.lookup("[email protected]", &key, all), Lookup::Accept);
|
||||
assert_eq!(r.lookup("[email protected]", &key, all), Lookup::UnknownUser);
|
||||
assert_eq!(r.lookup("[email protected]", &key, all), Lookup::Accept);
|
||||
// A domain with rules but no longer in the settings isn't served.
|
||||
assert_eq!(
|
||||
r.lookup("[email protected]", &key, |_| false),
|
||||
Lookup::NotServed
|
||||
);
|
||||
// A served domain without rules: exact addresses only.
|
||||
let mut r = r;
|
||||
r.apply(&[hash(&key, "[email protected]")], &[]);
|
||||
assert_eq!(r.lookup("[email protected]", &key, all), Lookup::Accept);
|
||||
assert_eq!(r.lookup("[email protected]", &key, all), Lookup::UnknownUser);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -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<rustls::ServerConfig, String> {
|
||||
let (chain, key) = match cert {
|
||||
Some(cert) => {
|
||||
let chain: Vec<CertificateDer<'static>> =
|
||||
CertificateDer::pem_slice_iter(cert.chain_pem.as_bytes())
|
||||
.collect::<Result<_, _>>()
|
||||
.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<Option<(String, Arc<rustls::ServerConfig>)>>,
|
||||
}
|
||||
|
||||
impl TlsCache {
|
||||
fn get(&self, settings: &RelaySettings) -> Option<Arc<rustls::ServerConfig>> {
|
||||
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<App>) {
|
||||
let tls = Arc::new(TlsCache::default());
|
||||
let mut listeners: HashMap<SocketAddr, JoinHandle<()>> = HashMap::new();
|
||||
let mut settings_rx = app.settings.subscribe();
|
||||
loop {
|
||||
let wanted: Vec<SocketAddr> = 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<App>, tls: Arc<TlsCache>, 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<App>,
|
||||
tls: Arc<TlsCache>,
|
||||
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<S> {
|
||||
Closed,
|
||||
StartTls(S),
|
||||
}
|
||||
|
||||
/// A command, owned, so it outlives the read buffer.
|
||||
enum Cmd {
|
||||
Ehlo(String),
|
||||
Helo(String),
|
||||
Mail(MailFrom<String>),
|
||||
Rcpt(RcptTo<String>),
|
||||
Data,
|
||||
Rset,
|
||||
Noop,
|
||||
Quit,
|
||||
Help,
|
||||
Vrfy,
|
||||
StartTls,
|
||||
NotOffered,
|
||||
}
|
||||
|
||||
impl Cmd {
|
||||
fn from(req: Request<std::borrow::Cow<'_, str>>) -> 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<App>,
|
||||
settings: Arc<RelaySettings>,
|
||||
peer: SocketAddr,
|
||||
local: SocketAddr,
|
||||
helo: Option<String>,
|
||||
tls: Option<TlsInfo>,
|
||||
tls_failed: bool,
|
||||
mail_from: Option<Address>,
|
||||
rcpt_to: Vec<Address>,
|
||||
errors: u32,
|
||||
accepted: u32,
|
||||
refused_rcpts: u32,
|
||||
out: Vec<u8>,
|
||||
}
|
||||
|
||||
enum Next {
|
||||
Continue,
|
||||
Close,
|
||||
StartTls,
|
||||
Data(Box<Incoming>),
|
||||
}
|
||||
|
||||
struct DataState {
|
||||
rx: DataReceiver,
|
||||
incoming: Option<Box<Incoming>>,
|
||||
buf: Vec<u8>,
|
||||
too_big: bool,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn new(
|
||||
app: Arc<App>,
|
||||
settings: Arc<RelaySettings>,
|
||||
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<S: AsyncWrite + Unpin>(&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<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
&mut self,
|
||||
mut stream: S,
|
||||
_: Option<()>,
|
||||
) -> End<S> {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let mut receiver = RequestReceiver::default();
|
||||
let mut data: Option<DataState> = 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<String>) {
|
||||
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<String>) {
|
||||
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 <CRLF>.<CRLF>");
|
||||
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)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/`: `<id>.msg`, the sealed
|
||||
//! stream, written and fsynced first; then `<id>.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<DoorCheck>,
|
||||
}
|
||||
|
||||
struct Lease {
|
||||
conn: u64,
|
||||
until: u64,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
meta: Meta,
|
||||
file_len: u64,
|
||||
created: u64,
|
||||
modseq: u64,
|
||||
lease: Option<Lease>,
|
||||
/// Set while the SMTP session waits for a live verdict.
|
||||
waiter: Option<oneshot::Sender<Verdict>>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub updated: Vec<String>,
|
||||
pub destroyed: Vec<String>,
|
||||
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<String, Entry>,
|
||||
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<Spool> {
|
||||
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<Incoming> {
|
||||
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<oneshot::Receiver<Verdict>> {
|
||||
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<Inbound> {
|
||||
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<String> {
|
||||
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<Changes> {
|
||||
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<String> = 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<String> {
|
||||
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<String> = 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<String> = 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<Meta> {
|
||||
let old: Vec<String> = 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<u8>,
|
||||
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("[email protected]"),
|
||||
rcpt_to: vec![Address::new("[email protected]")],
|
||||
},
|
||||
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<oneshot::Receiver<Verdict>>) {
|
||||
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/"));
|
||||
}
|
||||
}
|
||||
@@ -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<PathBuf>) -> 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<T: for<'de> Deserialize<'de>>(path: &Path) -> io::Result<Option<T>> {
|
||||
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<T: Serialize>(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<Option<Identity>> {
|
||||
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<Option<RelaySettings>> {
|
||||
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<RunLock> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<Option<Vec<u8>>>);
|
||||
|
||||
impl ServerCertVerifier for Capture {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &CertificateDer<'_>,
|
||||
_: &[CertificateDer<'_>],
|
||||
_: &ServerName<'_>,
|
||||
_: &[u8],
|
||||
_: UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
*self.0.lock().unwrap() = Some(end_entity.to_vec());
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_: &[u8],
|
||||
_: &CertificateDer<'_>,
|
||||
_: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
message: &[u8],
|
||||
cert: &CertificateDer<'_>,
|
||||
dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
rustls::crypto::verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&link::tls::provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
|
||||
link::tls::provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
struct Downstream {
|
||||
connector: TlsConnector,
|
||||
capture: Arc<Capture>,
|
||||
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<TlsStream<TcpStream>> {
|
||||
let tcp = TcpStream::connect(self.addr).await?;
|
||||
self.connector
|
||||
.connect(ServerName::try_from("relay.invalid").unwrap(), tcp)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn ws(&self) -> Result<Ws, String> {
|
||||
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<u8>) -> (u16, Vec<u8>) {
|
||||
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<TlsStream<TcpStream>>,
|
||||
/// StateChanges seen, as `type:state`.
|
||||
changes: Vec<String>,
|
||||
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<tokio::net::tcp::OwnedReadHalf>,
|
||||
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:<[email protected]>")
|
||||
.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<u8> = ["[email protected]", "[email protected]"]
|
||||
.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:<[email protected]>")
|
||||
.await
|
||||
.starts_with("250")
|
||||
);
|
||||
assert!(
|
||||
s.cmd("RCPT TO:<[email protected]>")
|
||||
.await
|
||||
.starts_with("550 5.1.1")
|
||||
);
|
||||
assert!(
|
||||
s.cmd("RCPT TO:<[email protected]>")
|
||||
.await
|
||||
.starts_with("550 5.7.1")
|
||||
);
|
||||
assert!(
|
||||
s.cmd("RCPT TO:<[email protected]>")
|
||||
.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"],
|
||||
"[email protected]"
|
||||
);
|
||||
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("[email protected]", "Subject: spam\r\n\r\nbuy\r\n")
|
||||
.await;
|
||||
let second = s
|
||||
.send_message("[email protected]", "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();
|
||||
}
|
||||
Reference in new issue
Block a user