inbuxa-relay: the relay binary, inbound in held and live mode #245

Merged
jcoffey-dev merged 1 commits from relay/binary into main 2026-10-30 03:47:45 +00:00
15 changed files with 5238 additions and 1 deletions

No files matched your search

Generated
+23
View File
@@ -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"
+1
View File
@@ -33,6 +33,7 @@ members = [
"crates/migration",
"crates/features",
"crates/relay-proto",
"crates/relay",
"tests",
]
+1 -1
View File
@@ -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};
+37
View File
@@ -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"] }
+302
View File
@@ -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");
}
}
+227
View File
@@ -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();
}
}
+831
View File
@@ -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(&current).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"), &current.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(&current).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(),
),
]
}
+563
View File
@@ -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(|_| ())
}
+227
View File
@@ -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());
}
}
+357
View File
@@ -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(())
}
+205
View File
@@ -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();
}
}
+852
View File
@@ -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)),
]),
);
}
}
+725
View File
@@ -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/"));
}
}
+247
View File
@@ -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();
}
}
+640
View File
@@ -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();
}