Files
inbuxa-server/crates/common/src/config/smtp/session.rs
T
jcoffey-dev 999ae12cc7
ci / fork-checks (pull_request) Successful in 44s
ci / build (pull_request) Successful in 3m22s
Settings reload: no DNS at build time, don't refuse over old failures
A 3-node rehearsal found every settings reload refused, cluster-wide,
because one node couldn't resolve the Pyzor server:

- PyzorConfig::parse resolved the host while building the settings and
  made a failed lookup a build error. It now keeps the host and port and
  resolves when a message is checked (an IP address is used as is, a
  name is reused for five minutes, the lookup counts against the Pyzor
  timeout). A failure there is a Pyzor error for that message.
- A milter's hostname was resolved the same way, with a blocking
  to_socket_addrs in async code. An IP address is kept; a name is now
  resolved on each connection.

Other build-time I/O is already non-fatal: directories that can't
connect become unavailable with a warning (DIR-21), and the AI model
locality check only warns.

reload_registry swapped the core only when the whole build was free of
errors, while boot runs with whatever built. One failing object thus
refused every later reload, and the running settings went stale. Now a
reload is refused only for errors in objects that built when the
running settings were built (at boot or by the last applied reload):
applying it would lose those. Objects that already failed then are
missing from the running settings anyway, as at boot, so their errors
are logged and returned as known_errors but don't hold the reload back.
Refusing on new errors keeps a bad edit from taking a working object
out of service; the admin gets the error instead.

ReloadSettings now says "Settings were not reloaded." and names the
object and its error ("Tracer with id ...: Only one console tracer is
allowed"), with a count of any further errors. A refused reload after a
directory change logs its errors too.

system::reload::reload_tests (new): with Pyzor enabled on an
unresolvable host, ReloadSettings succeeds (on main it fails with
"Invalid address: failed to lookup address information"); an IP host
needs no lookup; a new build error refuses the reload, names the object
and leaves the running settings unchanged; the same error, once known
from the running settings' build, no longer blocks; once fixed, a new
error there blocks again. smtp::inbound::milter's session test now
names its milter "localhost", so the connect-time lookup is exercised.
2026-09-24 12:11:41 -07:00

567 lines
20 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use self::resolver::Policy;
use super::*;
use crate::expr::{
Variable,
if_block::{BootstrapExprExt, IfBlock},
};
use ahash::AHashSet;
use hyper::HeaderMap;
use registry::schema::{
enums::{self, ExpressionConstant, MtaStage},
prelude::ObjectType,
structs::{
MtaExtensions, MtaHook, MtaInboundSession, MtaMilter, MtaStageAuth, MtaStageConnect,
MtaStageData, MtaStageEhlo, MtaStageMail, MtaStageRcpt,
},
};
use smtp_proto::*;
use std::{
net::{IpAddr, SocketAddr},
str::FromStr,
time::Duration,
};
#[derive(Clone)]
pub struct SessionConfig {
pub timeout: IfBlock,
pub duration: IfBlock,
pub transfer_limit: IfBlock,
pub connect: Connect,
pub ehlo: Ehlo,
pub auth: Auth,
pub mail: Mail,
pub rcpt: Rcpt,
pub data: Data,
pub extensions: Extensions,
pub mta_sts_policy: Option<Policy>,
pub milters: Vec<Milter>,
pub hooks: Vec<MTAHook>,
}
#[derive(Clone)]
pub struct Connect {
pub hostname: IfBlock,
pub script: IfBlock,
pub greeting: IfBlock,
}
#[derive(Clone)]
pub struct Ehlo {
pub script: IfBlock,
pub require: IfBlock,
pub reject_non_fqdn: IfBlock,
}
#[derive(Clone)]
pub struct Extensions {
pub pipelining: IfBlock,
pub chunking: IfBlock,
pub requiretls: IfBlock,
pub dsn: IfBlock,
pub vrfy: IfBlock,
pub expn: IfBlock,
pub no_soliciting: IfBlock,
pub future_release: IfBlock,
pub deliver_by: IfBlock,
pub mt_priority: IfBlock,
}
#[derive(Clone)]
pub struct Auth {
pub mechanisms: IfBlock,
pub require: IfBlock,
pub must_match_sender: IfBlock,
pub errors_max: IfBlock,
pub errors_wait: IfBlock,
}
#[derive(Clone)]
pub struct Mail {
pub script: IfBlock,
pub rewrite: IfBlock,
pub is_allowed: IfBlock,
}
#[derive(Clone)]
pub struct Rcpt {
pub script: IfBlock,
pub relay: IfBlock,
pub rewrite: IfBlock,
pub errors_max: IfBlock,
pub errors_wait: IfBlock,
pub max_recipients: IfBlock,
}
#[derive(Debug, Default, Clone)]
pub enum AddressMapping {
Enable,
Custom(IfBlock),
#[default]
Disable,
}
#[derive(Clone)]
pub struct Data {
pub script: IfBlock,
pub spam_filter: IfBlock,
pub max_messages: IfBlock,
pub max_message_size: IfBlock,
pub max_received_headers: IfBlock,
pub add_received: IfBlock,
pub add_received_spf: IfBlock,
pub add_return_path: IfBlock,
pub add_auth_results: IfBlock,
pub add_message_id: IfBlock,
pub add_date: IfBlock,
pub add_delivered_to: bool,
}
#[derive(Clone)]
pub struct Milter {
pub enable: IfBlock,
pub id: ObjectId,
pub addrs: Vec<SocketAddr>,
pub hostname: String,
pub port: u16,
pub timeout_connect: Duration,
pub timeout_command: Duration,
pub timeout_data: Duration,
pub tls: bool,
pub tls_allow_invalid_certs: bool,
pub tempfail_on_error: bool,
pub max_frame_len: usize,
pub protocol_version: MilterVersion,
pub flags_actions: Option<u32>,
pub flags_protocol: Option<u32>,
pub run_on_stage: AHashSet<Stage>,
}
#[derive(Clone, Copy)]
pub enum MilterVersion {
V2,
V6,
}
#[derive(Clone)]
pub struct MTAHook {
pub enable: IfBlock,
pub id: ObjectId,
pub url: String,
pub timeout: Duration,
pub headers: HeaderMap,
pub tls_allow_invalid_certs: bool,
pub tempfail_on_error: bool,
pub run_on_stage: AHashSet<Stage>,
pub max_response_size: usize,
pub client: reqwest::Client,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum Stage {
Connect,
Ehlo,
Auth,
Mail,
Rcpt,
Data,
}
impl SessionConfig {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let session = bp.setting_infallible::<MtaInboundSession>().await;
let connect = bp.setting_infallible::<MtaStageConnect>().await;
let auth = bp.setting_infallible::<MtaStageAuth>().await;
let ehlo = bp.setting_infallible::<MtaStageEhlo>().await;
let mail = bp.setting_infallible::<MtaStageMail>().await;
let rcpt = bp.setting_infallible::<MtaStageRcpt>().await;
let data = bp.setting_infallible::<MtaStageData>().await;
let ext = bp.setting_infallible::<MtaExtensions>().await;
let mut hooks = Vec::new();
for hook in bp.list_infallible::<MtaHook>().await {
let id = hook.id;
let hook = hook.object;
let enable = bp.compile_expr(id, &hook.ctx_enable());
let headers = match hook
.http_auth
.build_headers(hook.http_headers, "application/json".into())
.await
{
Ok(headers) => headers,
Err(err) => {
bp.build_error(id, format!("Unable to build HTTP headers: {}", err));
continue;
}
};
hooks.push(MTAHook {
enable,
id,
url: hook.url,
timeout: hook.timeout.into_inner(),
headers,
tls_allow_invalid_certs: hook.allow_invalid_certs,
tempfail_on_error: hook.temp_fail_on_error,
run_on_stage: hook.stages.into_iter().map(Stage::from).collect(),
max_response_size: hook.max_response_size as usize,
client: utils::http::http_client_builder(hook.allow_invalid_certs)
.build()
.unwrap_or_default(),
});
}
SessionConfig {
timeout: bp.compile_expr(
ObjectType::MtaInboundSession.singleton(),
&session.ctx_timeout(),
),
duration: bp.compile_expr(
ObjectType::MtaInboundSession.singleton(),
&session.ctx_max_duration(),
),
transfer_limit: bp.compile_expr(
ObjectType::MtaInboundSession.singleton(),
&session.ctx_transfer_limit(),
),
connect: Connect {
hostname: bp.compile_expr(
ObjectType::MtaStageConnect.singleton(),
&connect.ctx_hostname(),
),
script: bp.compile_expr(
ObjectType::MtaStageConnect.singleton(),
&connect.ctx_script(),
),
greeting: bp.compile_expr(
ObjectType::MtaStageConnect.singleton(),
&connect.ctx_smtp_greeting(),
),
},
ehlo: Ehlo {
script: bp.compile_expr(ObjectType::MtaStageEhlo.singleton(), &ehlo.ctx_script()),
require: bp.compile_expr(ObjectType::MtaStageEhlo.singleton(), &ehlo.ctx_require()),
reject_non_fqdn: bp.compile_expr(
ObjectType::MtaStageEhlo.singleton(),
&ehlo.ctx_reject_non_fqdn(),
),
},
auth: Auth {
mechanisms: bp.compile_expr(
ObjectType::MtaStageAuth.singleton(),
&auth.ctx_sasl_mechanisms(),
),
require: bp.compile_expr(ObjectType::MtaStageAuth.singleton(), &auth.ctx_require()),
must_match_sender: bp.compile_expr(
ObjectType::MtaStageAuth.singleton(),
&auth.ctx_must_match_sender(),
),
errors_max: bp.compile_expr(
ObjectType::MtaStageAuth.singleton(),
&auth.ctx_max_failures(),
),
errors_wait: bp.compile_expr(
ObjectType::MtaStageAuth.singleton(),
&auth.ctx_wait_on_fail(),
),
},
mail: Mail {
script: bp.compile_expr(ObjectType::MtaStageMail.singleton(), &mail.ctx_script()),
rewrite: bp.compile_expr(ObjectType::MtaStageMail.singleton(), &mail.ctx_rewrite()),
is_allowed: bp.compile_expr(
ObjectType::MtaStageMail.singleton(),
&mail.ctx_is_sender_allowed(),
),
},
rcpt: Rcpt {
script: bp.compile_expr(ObjectType::MtaStageRcpt.singleton(), &rcpt.ctx_script()),
relay: bp.compile_expr(
ObjectType::MtaStageRcpt.singleton(),
&rcpt.ctx_allow_relaying(),
),
rewrite: bp.compile_expr(ObjectType::MtaStageRcpt.singleton(), &rcpt.ctx_rewrite()),
errors_max: bp.compile_expr(
ObjectType::MtaStageRcpt.singleton(),
&rcpt.ctx_max_failures(),
),
errors_wait: bp.compile_expr(
ObjectType::MtaStageRcpt.singleton(),
&rcpt.ctx_wait_on_fail(),
),
max_recipients: bp.compile_expr(
ObjectType::MtaStageRcpt.singleton(),
&rcpt.ctx_max_recipients(),
),
},
data: Data {
script: bp.compile_expr(ObjectType::MtaStageData.singleton(), &data.ctx_script()),
spam_filter: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_enable_spam_filter(),
),
max_messages: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_max_messages(),
),
max_message_size: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_max_message_size(),
),
max_received_headers: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_max_received_headers(),
),
add_received: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_add_received_header(),
),
add_received_spf: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_add_received_spf_header(),
),
add_return_path: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_add_return_path_header(),
),
add_auth_results: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_add_auth_results_header(),
),
add_message_id: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_add_message_id_header(),
),
add_date: bp.compile_expr(
ObjectType::MtaStageData.singleton(),
&data.ctx_add_date_header(),
),
add_delivered_to: data.add_delivered_to_header,
},
extensions: Extensions {
pipelining: bp
.compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_pipelining()),
chunking: bp
.compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_chunking()),
requiretls: bp.compile_expr(
ObjectType::MtaExtensions.singleton(),
&ext.ctx_require_tls(),
),
dsn: bp.compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_dsn()),
vrfy: bp.compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_vrfy()),
expn: bp.compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_expn()),
no_soliciting: bp.compile_expr(
ObjectType::MtaExtensions.singleton(),
&ext.ctx_no_soliciting(),
),
future_release: bp.compile_expr(
ObjectType::MtaExtensions.singleton(),
&ext.ctx_future_release(),
),
deliver_by: bp
.compile_expr(ObjectType::MtaExtensions.singleton(), &ext.ctx_deliver_by()),
mt_priority: bp.compile_expr(
ObjectType::MtaExtensions.singleton(),
&ext.ctx_mt_priority(),
),
},
mta_sts_policy: Policy::try_parse(bp).await,
milters: bp
.list_infallible::<MtaMilter>()
.await
.into_iter()
.filter_map(|milter| {
let id = milter.id;
let milter = milter.object;
Some(Milter {
enable: bp.compile_expr(id, &milter.ctx_enable()),
id,
// inbuxa: upstream resolved the hostname here (a
// blocking lookup) and made a failure a build error,
// which refused the whole settings reload. An IP
// address is kept as is; a name is resolved on each
// connection (MilterClient::connect).
addrs: milter
.hostname
.parse::<IpAddr>()
.map(|ip| vec![SocketAddr::new(ip, milter.port as u16)])
.unwrap_or_default(),
hostname: milter.hostname,
port: milter.port as u16,
timeout_connect: milter.timeout_connect.into_inner(),
timeout_command: milter.timeout_command.into_inner(),
timeout_data: milter.timeout_data.into_inner(),
tls: milter.use_tls,
tls_allow_invalid_certs: milter.allow_invalid_certs,
tempfail_on_error: milter.temp_fail_on_error,
max_frame_len: milter.max_response_size as usize,
protocol_version: match milter.protocol_version {
enums::MilterVersion::V2 => MilterVersion::V2,
enums::MilterVersion::V6 => MilterVersion::V6,
},
flags_actions: milter.flags_action.map(|v| v as u32),
flags_protocol: milter.flags_protocol.map(|v| v as u32),
run_on_stage: milter.stages.into_iter().map(Stage::from).collect(),
})
})
.collect(),
hooks,
}
}
}
#[derive(Default)]
pub struct Mechanism(u64);
impl FromStr for Mechanism {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Ok(Mechanism(match value.to_ascii_uppercase().as_str() {
"LOGIN" => AUTH_LOGIN,
"PLAIN" => AUTH_PLAIN,
"XOAUTH2" => AUTH_XOAUTH2,
"OAUTHBEARER" => AUTH_OAUTHBEARER,
/*"SCRAM-SHA-256-PLUS" => AUTH_SCRAM_SHA_256_PLUS,
"SCRAM-SHA-256" => AUTH_SCRAM_SHA_256,
"SCRAM-SHA-1-PLUS" => AUTH_SCRAM_SHA_1_PLUS,
"SCRAM-SHA-1" => AUTH_SCRAM_SHA_1,
"XOAUTH" => AUTH_XOAUTH,
"9798-M-DSA-SHA1" => AUTH_9798_M_DSA_SHA1,
"9798-M-ECDSA-SHA1" => AUTH_9798_M_ECDSA_SHA1,
"9798-M-RSA-SHA1-ENC" => AUTH_9798_M_RSA_SHA1_ENC,
"9798-U-DSA-SHA1" => AUTH_9798_U_DSA_SHA1,
"9798-U-ECDSA-SHA1" => AUTH_9798_U_ECDSA_SHA1,
"9798-U-RSA-SHA1-ENC" => AUTH_9798_U_RSA_SHA1_ENC,
"EAP-AES128" => AUTH_EAP_AES128,
"EAP-AES128-PLUS" => AUTH_EAP_AES128_PLUS,
"ECDH-X25519-CHALLENGE" => AUTH_ECDH_X25519_CHALLENGE,
"ECDSA-NIST256P-CHALLENGE" => AUTH_ECDSA_NIST256P_CHALLENGE,
"EXTERNAL" => AUTH_EXTERNAL,
"GS2-KRB5" => AUTH_GS2_KRB5,
"GS2-KRB5-PLUS" => AUTH_GS2_KRB5_PLUS,
"GSS-SPNEGO" => AUTH_GSS_SPNEGO,
"GSSAPI" => AUTH_GSSAPI,
"KERBEROS_V4" => AUTH_KERBEROS_V4,
"KERBEROS_V5" => AUTH_KERBEROS_V5,
"NMAS-SAMBA-AUTH" => AUTH_NMAS_SAMBA_AUTH,
"NMAS_AUTHEN" => AUTH_NMAS_AUTHEN,
"NMAS_LOGIN" => AUTH_NMAS_LOGIN,
"NTLM" => AUTH_NTLM,
"OAUTH10A" => AUTH_OAUTH10A,
"OPENID20" => AUTH_OPENID20,
"OTP" => AUTH_OTP,
"SAML20" => AUTH_SAML20,
"SECURID" => AUTH_SECURID,
"SKEY" => AUTH_SKEY,
"SPNEGO" => AUTH_SPNEGO,
"SPNEGO-PLUS" => AUTH_SPNEGO_PLUS,
"SXOVER-PLUS" => AUTH_SXOVER_PLUS,
"CRAM-MD5" => AUTH_CRAM_MD5,
"DIGEST-MD5" => AUTH_DIGEST_MD5,
"ANONYMOUS" => AUTH_ANONYMOUS,*/
_ => return Err(format!("Unsupported mechanism {:?}.", value)),
}))
}
}
impl<'x> TryFrom<Variable<'x>> for Mechanism {
type Error = ();
fn try_from(value: Variable<'x>) -> Result<Self, Self::Error> {
match value {
Variable::Constant(value) => Mechanism::try_from(value),
Variable::Array(items) => {
let mut mechanism = 0;
for item in items {
match item {
Variable::Constant(value) => mechanism |= Mechanism::try_from(value)?.0,
_ => return Err(()),
}
}
Ok(Mechanism(mechanism))
}
_ => Err(()),
}
}
}
impl TryFrom<ExpressionConstant> for Mechanism {
type Error = ();
fn try_from(value: ExpressionConstant) -> Result<Self, Self::Error> {
match value {
ExpressionConstant::Login => Ok(Mechanism(AUTH_LOGIN)),
ExpressionConstant::Plain => Ok(Mechanism(AUTH_PLAIN)),
ExpressionConstant::Xoauth2 => Ok(Mechanism(AUTH_XOAUTH2)),
ExpressionConstant::Oauthbearer => Ok(Mechanism(AUTH_OAUTHBEARER)),
_ => Err(()),
}
}
}
impl From<Mechanism> for u64 {
fn from(value: Mechanism) -> Self {
value.0
}
}
impl From<u64> for Mechanism {
fn from(value: u64) -> Self {
Mechanism(value)
}
}
impl<'x> TryFrom<Variable<'x>> for MtPriority {
type Error = ();
fn try_from(value: Variable<'x>) -> Result<Self, Self::Error> {
match value {
Variable::Constant(value) => match value {
ExpressionConstant::Mixer => Ok(MtPriority::Mixer),
ExpressionConstant::Stanag4406 => Ok(MtPriority::Stanag4406),
ExpressionConstant::Nsep => Ok(MtPriority::Nsep),
_ => Err(()),
},
Variable::String(value) => {
let value = value.as_str();
if value.eq_ignore_ascii_case("MIXER") {
Ok(MtPriority::Mixer)
} else if value.eq_ignore_ascii_case("STANAG4406") {
Ok(MtPriority::Stanag4406)
} else if value.eq_ignore_ascii_case("NSEP") {
Ok(MtPriority::Nsep)
} else {
Err(())
}
}
_ => Err(()),
}
}
}
impl From<MtaStage> for Stage {
fn from(value: MtaStage) -> Self {
match value {
MtaStage::Connect => Stage::Connect,
MtaStage::Ehlo => Stage::Ehlo,
MtaStage::Auth => Stage::Auth,
MtaStage::Mail => Stage::Mail,
MtaStage::Rcpt => Stage::Rcpt,
MtaStage::Data => Stage::Data,
}
}
}