kumo_api_types/
egress_path.rs

1use cidr_map::CidrSet;
2use data_loader::KeySource;
3use dns_resolver::{IpLookupStrategy, MailExchanger};
4#[cfg(feature = "lua")]
5use mlua::prelude::*;
6use openssl::ssl::SslOptions;
7use ordermap::OrderMap;
8use rfc5321::SmtpClientTimeouts;
9use rustls::crypto::aws_lc_rs::ALL_CIPHER_SUITES;
10use rustls::SupportedCipherSuite;
11use serde::{Deserialize, Deserializer, Serialize};
12use std::collections::BTreeMap;
13use std::fmt::Write;
14use std::time::Duration;
15use throttle::{LimitSpec, ThrottleSpec};
16use utoipa::ToSchema;
17
18#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Copy)]
19pub enum Tls {
20    /// Use it if available. If the peer has invalid or self-signed certificates, then
21    /// delivery will fail. Will NOT fallback to not using TLS if the peer advertises
22    /// STARTTLS.
23    Opportunistic,
24    /// Use it if available, and allow self-signed or otherwise invalid server certs.
25    /// Not recommended for sending to the public internet; this is for local/lab
26    /// testing scenarios only.
27    OpportunisticInsecure,
28    /// TLS with valid certs is required.
29    Required,
30    /// Required, and allow self-signed or otherwise invalid server certs.
31    /// Not recommended for sending to the public internet; this is for local/lab
32    /// testing scenarios only.
33    RequiredInsecure,
34    /// Do not try to use TLS
35    Disabled,
36}
37
38impl Tls {
39    pub fn allow_insecure(&self) -> bool {
40        match self {
41            Self::OpportunisticInsecure | Self::RequiredInsecure => true,
42            _ => false,
43        }
44    }
45
46    pub fn is_opportunistic(&self) -> bool {
47        match self {
48            Self::OpportunisticInsecure | Self::Opportunistic => true,
49            _ => false,
50        }
51    }
52}
53
54impl Default for Tls {
55    fn default() -> Self {
56        Self::Opportunistic
57    }
58}
59
60pub fn parse_openssl_options(option_list: &str) -> anyhow::Result<SslOptions> {
61    let mut result = SslOptions::empty();
62
63    for option in option_list.split('|') {
64        match SslOptions::from_name(option) {
65            Some(opt) => {
66                result.insert(opt);
67            }
68            None => {
69                let mut allowed: Vec<_> = SslOptions::all()
70                    .iter_names()
71                    .map(|(name, _)| format!("`{name}`"))
72                    .collect();
73                allowed.sort();
74                let allowed = allowed.join(", ");
75                anyhow::bail!(
76                    "`{option}` is not a valid SslOption name. \
77                    Possible values are {allowed} joined together by the pipe `|` character."
78                );
79            }
80        }
81    }
82
83    Ok(result)
84}
85
86fn deserialize_ssl_options<'de, D>(deserializer: D) -> Result<Option<SslOptions>, D::Error>
87where
88    D: Deserializer<'de>,
89{
90    use serde::de::Error;
91    let maybe_options = Option::<String>::deserialize(deserializer)?;
92
93    match maybe_options {
94        None => Ok(None),
95        Some(option_list) => match parse_openssl_options(&option_list) {
96            Ok(options) => Ok(Some(options)),
97            Err(err) => Err(D::Error::custom(format!("{err:#}"))),
98        },
99    }
100}
101
102fn deserialize_supported_ciphersuite<'de, D>(
103    deserializer: D,
104) -> Result<Vec<SupportedCipherSuite>, D::Error>
105where
106    D: Deserializer<'de>,
107{
108    use serde::de::Error;
109    let suites = Vec::<String>::deserialize(deserializer)?;
110    let mut result = vec![];
111
112    for s in suites {
113        match find_rustls_cipher_suite(&s) {
114            Some(s) => {
115                result.push(s);
116            }
117            None => {
118                return Err(D::Error::custom(format!(
119                    "`{s}` is not a valid rustls cipher suite"
120                )));
121            }
122        }
123    }
124
125    Ok(result)
126}
127
128pub fn find_rustls_cipher_suite(name: &str) -> Option<SupportedCipherSuite> {
129    for suite in ALL_CIPHER_SUITES {
130        let sname = format!("{:?}", suite.suite());
131        if sname.eq_ignore_ascii_case(name) {
132            return Some(*suite);
133        }
134    }
135    None
136}
137
138#[derive(Deserialize, Serialize, Debug, Clone, Default, Copy, PartialEq, Eq)]
139#[cfg_attr(feature = "lua", derive(FromLua))]
140pub enum WakeupStrategy {
141    #[default]
142    Aggressive,
143    Relaxed,
144}
145
146#[derive(Deserialize, Serialize, Debug, Clone, Default, Copy, PartialEq, Eq)]
147#[cfg_attr(feature = "lua", derive(FromLua))]
148pub enum MemoryReductionPolicy {
149    #[default]
150    ShrinkDataAndMeta,
151    ShrinkData,
152    NoShrink,
153}
154
155#[derive(Deserialize, Serialize, Debug, Clone, Default, Copy, PartialEq, Eq)]
156#[cfg_attr(feature = "lua", derive(FromLua))]
157pub enum ConfigRefreshStrategy {
158    #[default]
159    Ttl,
160    Epoch,
161}
162
163#[derive(Deserialize, Serialize, Debug, Clone, Default, Copy, PartialEq, Eq)]
164#[cfg_attr(feature = "lua", derive(FromLua))]
165pub enum ReconnectStrategy {
166    /// Close out the current connection session, allowing the maintainer
167    /// to decide about opening a new session and starting with a fresh
168    /// connection plan
169    TerminateSession,
170    /// Try to reconnect to the same host that we were using and where
171    /// we experienced the error
172    ReconnectSameHost,
173    /// Advance to the next host in the connection, if any. If none remain,
174    /// this is equivalent to TerminateSession
175    #[default]
176    ConnectNextHost,
177}
178
179#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
180#[cfg_attr(feature = "lua", derive(FromLua))]
181#[serde(deny_unknown_fields)]
182pub struct EgressPathConfig {
183    #[serde(default = "EgressPathConfig::default_connection_limit")]
184    pub connection_limit: LimitSpec,
185
186    #[serde(default)]
187    pub additional_connection_limits: OrderMap<String, LimitSpec>,
188
189    #[serde(default)]
190    pub enable_tls: Tls,
191
192    #[serde(default = "EgressPathConfig::default_enable_mta_sts")]
193    pub enable_mta_sts: bool,
194
195    #[serde(default = "EgressPathConfig::default_enable_dane")]
196    pub enable_dane: bool,
197
198    #[serde(default = "EgressPathConfig::default_enable_pipelining")]
199    pub enable_pipelining: bool,
200
201    #[serde(default = "EgressPathConfig::default_enable_rset")]
202    pub enable_rset: bool,
203
204    #[serde(default)]
205    pub tls_prefer_openssl: bool,
206
207    #[serde(default)]
208    pub tls_certificate: Option<KeySource>,
209
210    #[serde(default)]
211    pub tls_private_key: Option<KeySource>,
212
213    #[serde(default)]
214    pub openssl_cipher_list: Option<String>,
215    #[serde(default)]
216    pub openssl_cipher_suites: Option<String>,
217    #[serde(
218        default,
219        deserialize_with = "deserialize_ssl_options",
220        skip_serializing // FIXME
221    )]
222    pub openssl_options: Option<SslOptions>,
223
224    #[serde(
225        default,
226        deserialize_with = "deserialize_supported_ciphersuite",
227        skip_serializing // FIXME
228    )]
229    pub rustls_cipher_suites: Vec<SupportedCipherSuite>,
230
231    #[serde(flatten)]
232    pub client_timeouts: SmtpClientTimeouts,
233
234    /// How long to wait for an established session to gracefully
235    /// close when the system is shutting down. After this period
236    /// has elapsed, sessions will be aborted.
237    #[serde(default, with = "duration_serde")]
238    pub system_shutdown_timeout: Option<Duration>,
239
240    #[serde(default = "EgressPathConfig::default_max_ready")]
241    pub max_ready: usize,
242
243    #[serde(default = "EgressPathConfig::default_consecutive_connection_failures_before_delay")]
244    pub consecutive_connection_failures_before_delay: usize,
245
246    #[serde(default = "EgressPathConfig::default_smtp_port")]
247    pub smtp_port: u16,
248
249    #[serde(default)]
250    pub smtp_auth_plain_username: Option<String>,
251
252    #[serde(default)]
253    pub smtp_auth_plain_password: Option<KeySource>,
254
255    #[serde(default)]
256    pub allow_smtp_auth_plain_without_tls: bool,
257
258    /// {{since('dev')}}
259    /// When false (the default), SMTP AUTH PLAIN will not be attempted over a
260    /// TLS session whose peer certificate was not validated (for example, an
261    /// `OpportunisticInsecure`/`RequiredInsecure` session, or a DANE host whose
262    /// TLSA records are unusable). Set to true to restore the previous behavior
263    /// of sending credentials over any encrypted session.
264    #[serde(default)]
265    pub allow_smtp_auth_plain_without_valid_certificate: bool,
266
267    #[serde(default)]
268    pub max_message_rate: Option<ThrottleSpec>,
269
270    #[serde(default)]
271    pub additional_message_rate_throttles: OrderMap<String, ThrottleSpec>,
272
273    #[serde(default)]
274    pub source_selection_rate: Option<ThrottleSpec>,
275
276    #[serde(default)]
277    pub additional_source_selection_rates: OrderMap<String, ThrottleSpec>,
278
279    #[serde(default)]
280    pub max_connection_rate: Option<ThrottleSpec>,
281
282    #[serde(default = "EgressPathConfig::default_max_deliveries_per_connection")]
283    pub max_deliveries_per_connection: usize,
284
285    #[serde(default = "EgressPathConfig::default_max_recipients_per_batch")]
286    pub max_recipients_per_batch: usize,
287
288    #[serde(default = "CidrSet::default_prohibited_hosts")]
289    pub prohibited_hosts: CidrSet,
290
291    #[serde(default)]
292    pub skip_hosts: CidrSet,
293
294    #[serde(default)]
295    pub ip_lookup_strategy: IpLookupStrategy,
296
297    #[serde(default)]
298    pub ehlo_domain: Option<String>,
299
300    // TODO: decide if we want to keep this and then document
301    #[serde(default)]
302    pub aggressive_connection_opening: bool,
303
304    /// How long to wait between calls to get_egress_path_config for
305    /// any given ready queue. Making this longer uses fewer
306    /// resources (in aggregate) but means that it will take longer
307    /// to detect and adjust to changes in the queue configuration.
308    #[serde(
309        default = "EgressPathConfig::default_refresh_interval",
310        with = "duration_serde"
311    )]
312    pub refresh_interval: Duration,
313    #[serde(default)]
314    pub refresh_strategy: ConfigRefreshStrategy,
315
316    #[serde(default)]
317    pub dispatcher_wakeup_strategy: WakeupStrategy,
318    #[serde(default)]
319    pub maintainer_wakeup_strategy: WakeupStrategy,
320
321    /// Specify an explicit provider name that should apply to this
322    /// path. The provider name will be used when computing metrics
323    /// rollups by provider. If omitted, then
324    #[serde(default)]
325    pub provider_name: Option<String>,
326
327    /// If set, a process-local cache will be used to remember if
328    /// a site has broken TLS for the duration specified.  Once
329    /// encountered, we will pretend that EHLO didn't advertise STARTTLS
330    /// on subsequent connection attempts.
331    #[serde(default, with = "duration_serde")]
332    pub remember_broken_tls: Option<Duration>,
333
334    /// If true, when a TLS handshake fails and TLS is set to
335    /// opportunistic, we will re-connect to that host with
336    /// TLS disabled.
337    #[serde(default)]
338    pub opportunistic_tls_reconnect_on_failed_handshake: bool,
339
340    /// If true, rather than ESMTP, use the LMTP protocol
341    #[serde(default)]
342    pub use_lmtp: bool,
343
344    /// How to behave if we experience either a 421 response, an IO Error,
345    /// or a timeout while talking to the peer.
346    #[serde(default)]
347    pub reconnect_strategy: ReconnectStrategy,
348
349    /// Which thread pool to use for processing the ready queue
350    #[serde(default)]
351    pub readyq_pool_name: Option<String>,
352
353    /// What to do to newly inserted messages when memory is low
354    #[serde(default)]
355    pub low_memory_reduction_policy: MemoryReductionPolicy,
356
357    /// What to do to newly inserted messages when memory is over the soft limit
358    #[serde(default)]
359    pub no_memory_reduction_policy: MemoryReductionPolicy,
360
361    /// If we experience a transport error during SMTP, should we retry the
362    /// current message on the next host in the connection plan, or
363    /// immediately consider it a transient failure for that message?
364    #[serde(default)]
365    pub try_next_host_on_transport_error: bool,
366
367    /// If true, don't check for 8bit compatibility issues during
368    /// sending, instead, leave it to the remote host to raise
369    /// an error.
370    #[serde(default)]
371    pub ignore_8bit_checks: bool,
372
373    /// When set, dispatcher tasks for this egress path that fail to
374    /// make any forward progress for this duration are aborted by the
375    /// maintainer. When omitted the effective value is derived at
376    /// runtime from the protocol:
377    ///   * SMTP / Xfer: max(2 * longest of mail_from, rcpt_to, data,
378    ///     data_dot timeouts, 60s)
379    ///   * Lua / HttpInjectionGenerator / DeferredSmtpInjection: 600s
380    /// Users with a large `max_batch_latency` should set this
381    /// explicitly so the watchdog does not flag batch accumulation.
382    #[serde(default, with = "duration_serde")]
383    pub dispatcher_progress_watchdog_timeout: Option<Duration>,
384}
385
386#[cfg(feature = "lua")]
387impl LuaUserData for EgressPathConfig {
388    fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
389        config::impl_pairs_and_index(methods);
390    }
391}
392
393impl Default for EgressPathConfig {
394    fn default() -> Self {
395        Self {
396            connection_limit: Self::default_connection_limit(),
397            tls_prefer_openssl: false,
398            enable_tls: Tls::default(),
399            enable_mta_sts: Self::default_enable_mta_sts(),
400            enable_dane: Self::default_enable_dane(),
401            enable_rset: Self::default_enable_rset(),
402            enable_pipelining: Self::default_enable_pipelining(),
403            max_ready: Self::default_max_ready(),
404            consecutive_connection_failures_before_delay:
405                Self::default_consecutive_connection_failures_before_delay(),
406            smtp_port: Self::default_smtp_port(),
407            max_message_rate: None,
408            max_connection_rate: None,
409            max_deliveries_per_connection: Self::default_max_deliveries_per_connection(),
410            max_recipients_per_batch: Self::default_max_recipients_per_batch(),
411            client_timeouts: SmtpClientTimeouts::default(),
412            system_shutdown_timeout: None,
413            prohibited_hosts: CidrSet::default_prohibited_hosts(),
414            skip_hosts: CidrSet::default(),
415            ehlo_domain: None,
416            allow_smtp_auth_plain_without_tls: false,
417            allow_smtp_auth_plain_without_valid_certificate: false,
418            smtp_auth_plain_username: None,
419            smtp_auth_plain_password: None,
420            aggressive_connection_opening: false,
421            rustls_cipher_suites: vec![],
422            tls_certificate: None,
423            tls_private_key: None,
424            openssl_cipher_list: None,
425            openssl_cipher_suites: None,
426            openssl_options: None,
427            refresh_interval: Self::default_refresh_interval(),
428            refresh_strategy: ConfigRefreshStrategy::default(),
429            additional_message_rate_throttles: OrderMap::default(),
430            additional_connection_limits: OrderMap::default(),
431            source_selection_rate: None,
432            additional_source_selection_rates: OrderMap::default(),
433            provider_name: None,
434            remember_broken_tls: None,
435            opportunistic_tls_reconnect_on_failed_handshake: false,
436            use_lmtp: false,
437            reconnect_strategy: ReconnectStrategy::default(),
438            readyq_pool_name: None,
439            low_memory_reduction_policy: MemoryReductionPolicy::default(),
440            no_memory_reduction_policy: MemoryReductionPolicy::default(),
441            maintainer_wakeup_strategy: WakeupStrategy::default(),
442            dispatcher_wakeup_strategy: WakeupStrategy::default(),
443            try_next_host_on_transport_error: false,
444            ignore_8bit_checks: false,
445            ip_lookup_strategy: IpLookupStrategy::default(),
446            dispatcher_progress_watchdog_timeout: None,
447        }
448    }
449}
450
451impl EgressPathConfig {
452    fn default_connection_limit() -> LimitSpec {
453        LimitSpec::new(32)
454    }
455
456    fn default_enable_mta_sts() -> bool {
457        true
458    }
459
460    fn default_enable_pipelining() -> bool {
461        true
462    }
463
464    fn default_enable_rset() -> bool {
465        true
466    }
467
468    fn default_enable_dane() -> bool {
469        false
470    }
471
472    fn default_max_ready() -> usize {
473        1024
474    }
475
476    fn default_consecutive_connection_failures_before_delay() -> usize {
477        100
478    }
479
480    fn default_smtp_port() -> u16 {
481        25
482    }
483
484    fn default_max_deliveries_per_connection() -> usize {
485        1024
486    }
487
488    fn default_max_recipients_per_batch() -> usize {
489        100
490    }
491
492    fn default_refresh_interval() -> Duration {
493        Duration::from_secs(60)
494    }
495
496    /// Compute the steady-state ceilings implied by this
497    /// configuration. Per-axis, each ceiling carries a tag for which
498    /// configuration term produced it, so operators can see which
499    /// knob to turn.
500    ///
501    /// If `additional` is supplied, its ceilings are merged in via
502    /// `EffectiveConstraints::merge`. This is the entry point for
503    /// folding in constraints from other configuration layers, such
504    /// as the scheduled-queue rate from `QueueConfig`.
505    pub fn compute_constraints(
506        &self,
507        additional: Option<&EffectiveConstraints>,
508    ) -> EffectiveConstraints {
509        let mut constraints = self.compute_path_constraints();
510        if let Some(extra) = additional {
511            constraints.merge(extra);
512        }
513        constraints
514    }
515
516    fn compute_path_constraints(&self) -> EffectiveConstraints {
517        let max_concurrent_dispatchers = {
518            let mut best = EffectiveCeiling {
519                value: self.connection_limit.limit as f64,
520                source: CeilingSource::Primary,
521                display: self.connection_limit.limit.to_string(),
522            };
523            for (name, spec) in &self.additional_connection_limits {
524                let value = spec.limit as f64;
525                if value < best.value {
526                    best = EffectiveCeiling {
527                        value,
528                        source: CeilingSource::Additional { name: name.clone() },
529                        display: spec.limit.to_string(),
530                    };
531                }
532            }
533            best
534        };
535
536        // Compute the message-rate ceiling across the primary,
537        // additional throttles, and the synthetic K × C ceiling.
538        let mut msg_candidates: Vec<EffectiveCeiling> = vec![];
539        if let Some(spec) = &self.max_message_rate {
540            msg_candidates.push(throttle_ceiling(spec, CeilingSource::Primary));
541        }
542        for (name, spec) in &self.additional_message_rate_throttles {
543            msg_candidates.push(throttle_ceiling(
544                spec,
545                CeilingSource::Additional { name: name.clone() },
546            ));
547        }
548        if let Some(spec) = &self.max_connection_rate {
549            // K × C ceiling: every connection delivers at most
550            // max_deliveries_per_connection messages, so total msg
551            // rate cannot exceed (max_deliveries_per_connection) times
552            // the connection-establishment rate. The display shows
553            // both the factors and the computed total so operators
554            // don't have to do the arithmetic mentally for cases
555            // like 32 × 50/s.
556            let k = self.max_deliveries_per_connection as u64;
557            let total = ThrottleSpec {
558                limit: k.saturating_mul(spec.limit),
559                period: spec.period,
560                max_burst: None,
561                force_local: false,
562            };
563            msg_candidates.push(EffectiveCeiling {
564                value: throttle_rate_per_sec(spec) * k as f64,
565                source: CeilingSource::ReconnectCycling,
566                display: format!("{k} × {spec} = {total}"),
567            });
568        }
569        // total_cmp gives a total ordering even for NaN, so a malformed
570        // candidate cannot cause a panic here. NaN sorts greater than
571        // any finite value, so it naturally loses the min_by.
572        let max_message_rate = msg_candidates
573            .iter()
574            .min_by(|a, b| a.value.total_cmp(&b.value))
575            .cloned();
576
577        // If max_message_rate is explicitly configured but a
578        // different term (typically the synthetic reconnect-cycling
579        // ceiling) wins the minimum, record the declared rate so
580        // renderers can show an "effectively unreachable" annotation.
581        // Preserve the operator's original units via ThrottleSpec's
582        // Display impl.
583        let max_message_rate_declared = match (&max_message_rate, &self.max_message_rate) {
584            (Some(ceiling), Some(declared))
585                if !matches!(ceiling.source, CeilingSource::Primary) =>
586            {
587                Some(declared.to_string())
588            }
589            _ => None,
590        };
591
592        let max_connection_rate = self
593            .max_connection_rate
594            .as_ref()
595            .map(|spec| throttle_ceiling(spec, CeilingSource::Primary));
596
597        let max_source_selection_rate = {
598            let mut candidates: Vec<EffectiveCeiling> = vec![];
599            if let Some(spec) = &self.source_selection_rate {
600                candidates.push(throttle_ceiling(spec, CeilingSource::Primary));
601            }
602            for (name, spec) in &self.additional_source_selection_rates {
603                candidates.push(throttle_ceiling(
604                    spec,
605                    CeilingSource::Additional { name: name.clone() },
606                ));
607            }
608            candidates
609                .into_iter()
610                .min_by(|a, b| a.value.total_cmp(&b.value))
611        };
612
613        EffectiveConstraints {
614            max_concurrent_dispatchers,
615            max_message_rate,
616            max_message_rate_declared,
617            max_connection_rate,
618            max_source_selection_rate,
619        }
620    }
621}
622
623fn throttle_rate_per_sec(spec: &ThrottleSpec) -> f64 {
624    spec.limit as f64 / spec.period as f64
625}
626
627fn throttle_ceiling(spec: &ThrottleSpec, source: CeilingSource) -> EffectiveCeiling {
628    EffectiveCeiling {
629        value: throttle_rate_per_sec(spec),
630        source,
631        display: spec.to_string(),
632    }
633}
634
635/// Steady-state ceiling for a single throughput axis, with a tag
636/// for which configuration term produced it.
637///
638/// {{since('dev')}}
639#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
640pub struct EffectiveCeiling {
641    /// Canonical value. For rate axes: events per second; useful
642    /// for numeric comparison. For concurrency: a count.
643    pub value: f64,
644    pub source: CeilingSource,
645    /// Pre-formatted human display preserving the operator's
646    /// original configuration units. A rate configured as
647    /// `10000/hr` renders here as `10000/h` rather than `2.78/s`.
648    /// For concurrency, the integer count. For the synthetic
649    /// reconnect-cycling ceiling, the formula
650    /// `max_deliveries_per_connection × <connection_rate>`.
651    pub display: String,
652}
653
654/// Which configuration term produced an `EffectiveCeiling`.
655///
656/// {{since('dev')}}
657#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
658#[serde(tag = "kind", rename_all = "snake_case")]
659pub enum CeilingSource {
660    /// The primary configured term for this axis:
661    /// `connection_limit`, `max_message_rate`,
662    /// `max_connection_rate`, or `source_selection_rate`.
663    Primary,
664    /// A named entry from the corresponding `additional_*` map.
665    Additional { name: String },
666    /// Synthetic ceiling formed from
667    /// `max_deliveries_per_connection × max_connection_rate`.
668    /// Applies only to the message-rate axis: each connection
669    /// delivers at most `max_deliveries_per_connection` messages
670    /// before reconnecting, and new connections are throttled by
671    /// `max_connection_rate`, so the product is a hard ceiling on
672    /// system-wide message rate independent of `max_message_rate`.
673    ReconnectCycling,
674    /// A constraint contributed from a configuration layer outside
675    /// the egress path config. `name` is a free-form, human-readable
676    /// description of where the constraint came from (for example,
677    /// `"scheduled queue max_message_rate"`).
678    Other { name: String },
679}
680
681/// Summary of an MX resolution attempt for a destination.
682///
683/// {{since('dev')}}
684#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
685pub struct MxResolution {
686    /// Canonical site name; the value used as the queue grouping
687    /// key for this destination.
688    pub site_name: String,
689
690    /// MX hostnames grouped by preference. Lower preference is
691    /// preferred. Empty when DNS returned no MX records and the
692    /// resolver synthesised A/AAAA against `domain` directly.
693    pub by_preference: BTreeMap<u16, Vec<String>>,
694
695    /// True if real MX records were found in DNS. False when the
696    /// resolver synthesised an A/AAAA lookup against the domain.
697    pub is_mx: bool,
698
699    /// True if the destination was a domain literal like `[1.2.3.4]`.
700    pub is_domain_literal: bool,
701
702    /// True if the DNS result was DNSSEC validated.
703    pub is_secure: bool,
704}
705
706impl From<&MailExchanger> for MxResolution {
707    fn from(mx: &MailExchanger) -> Self {
708        Self {
709            site_name: mx.site_name.clone(),
710            by_preference: mx.by_pref.clone(),
711            is_mx: mx.is_mx,
712            is_domain_literal: mx.is_domain_literal,
713            is_secure: mx.is_secure,
714        }
715    }
716}
717
718impl MxResolution {
719    /// Render a one-line header summarising the MX result, plus an
720    /// indented preference→host listing beneath. Shared by
721    /// `kcli inspect-ready-q` and `kcli resolve-egress-path` so the
722    /// two surfaces produce identical output for the same input.
723    pub fn render(&self, out: &mut dyn Write) -> std::fmt::Result {
724        let mut flags: Vec<&str> = vec![];
725        if !self.is_mx {
726            flags.push("synthesised");
727        }
728        if self.is_domain_literal {
729            flags.push("domain literal");
730        }
731        if self.is_secure {
732            flags.push("dnssec verified");
733        }
734        write!(out, "mx (site: {}", self.site_name)?;
735        if !flags.is_empty() {
736            write!(out, ", {}", flags.join(", "))?;
737        }
738        writeln!(out, "):")?;
739        for (pref, hosts) in &self.by_preference {
740            for host in hosts {
741                writeln!(out, "  {pref:>4}  {host}")?;
742            }
743        }
744        Ok(())
745    }
746
747    pub fn to_human_string(&self) -> String {
748        let mut s = String::new();
749        let _ = self.render(&mut s);
750        s
751    }
752}
753
754/// Steady-state ceilings implied by an `EgressPathConfig`. Each
755/// ceiling carries a tag for which configuration term produced it.
756///
757/// {{since('dev')}}
758///
759/// These are per-queue ceilings; shared limits in `additional_*`
760/// maps are reported at their full value and may be tighter in
761/// practice when the bucket is contended.
762#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
763pub struct EffectiveConstraints {
764    pub max_concurrent_dispatchers: EffectiveCeiling,
765    /// None when neither `max_message_rate` nor any
766    /// `additional_message_rate_throttles` entry nor
767    /// `max_connection_rate` is configured.
768    pub max_message_rate: Option<EffectiveCeiling>,
769    /// Pre-formatted display of the declared `max_message_rate` when
770    /// a different term (typically `ReconnectCycling`) wins the
771    /// minimum. Lets renderers show a "declared but unreachable"
772    /// annotation. Uses the operator's original units.
773    pub max_message_rate_declared: Option<String>,
774    pub max_connection_rate: Option<EffectiveCeiling>,
775    pub max_source_selection_rate: Option<EffectiveCeiling>,
776}
777
778impl EffectiveConstraints {
779    /// Render a human-readable multi-line block. The same formatting
780    /// is used by `kcli inspect-ready-q` and by the
781    /// `resolve-shaping-domain` script, so operators see the same
782    /// output regardless of where they retrieved the config.
783    pub fn render(&self, out: &mut dyn Write) -> std::fmt::Result {
784        let label_axis = |w: &mut dyn Write, label: &str, value: &str| -> std::fmt::Result {
785            writeln!(w, "  {label:<24}{value}")
786        };
787        let label_source =
788            |w: &mut dyn Write, src: &CeilingSource, primary: &str| -> std::fmt::Result {
789                let s = match src {
790                    CeilingSource::Primary => primary,
791                    CeilingSource::Additional { name } => name.as_str(),
792                    CeilingSource::ReconnectCycling => {
793                        "max_deliveries_per_connection × max_connection_rate"
794                    }
795                    CeilingSource::Other { name } => name.as_str(),
796                };
797                writeln!(w, "    source: {s}")
798            };
799
800        writeln!(out, "ceilings:")?;
801
802        label_axis(
803            out,
804            "concurrent dispatchers:",
805            &self.max_concurrent_dispatchers.display,
806        )?;
807        label_source(
808            out,
809            &self.max_concurrent_dispatchers.source,
810            "connection_limit",
811        )?;
812
813        if let Some(c) = &self.max_message_rate {
814            label_axis(out, "message rate:", &c.display)?;
815            label_source(out, &c.source, "max_message_rate")?;
816            if let Some(declared) = &self.max_message_rate_declared {
817                writeln!(
818                    out,
819                    "    declared: max_message_rate = {declared} ← effectively unreachable"
820                )?;
821            }
822        }
823
824        if let Some(c) = &self.max_connection_rate {
825            label_axis(out, "connection rate:", &c.display)?;
826            label_source(out, &c.source, "max_connection_rate")?;
827        }
828
829        if let Some(c) = &self.max_source_selection_rate {
830            label_axis(out, "source selection rate:", &c.display)?;
831            label_source(out, &c.source, "source_selection_rate")?;
832        }
833
834        Ok(())
835    }
836
837    pub fn to_human_string(&self) -> String {
838        let mut s = String::new();
839        // std::fmt::Write into a String cannot fail in practice;
840        // swallow the error to avoid a panic site here.
841        let _ = self.render(&mut s);
842        s
843    }
844
845    /// Fold another `EffectiveConstraints` into this one by taking
846    /// the per-axis minimum. The losing ceiling is shadowed; the
847    /// winner's `source` tag is preserved. The
848    /// `max_message_rate_declared` annotation is re-evaluated so a
849    /// rate that is now shadowed by an external source gets the
850    /// "declared but unreachable" treatment.
851    pub fn merge(&mut self, other: &EffectiveConstraints) {
852        // Remember the path's declared rate before merging; if a
853        // tighter external source wins, we need to surface the
854        // declared value as the unreachable annotation.
855        let path_declared = self
856            .max_message_rate
857            .as_ref()
858            .filter(|c| matches!(c.source, CeilingSource::Primary))
859            .map(|c| c.display.clone())
860            .or_else(|| self.max_message_rate_declared.clone());
861
862        merge_axis(
863            &mut self.max_concurrent_dispatchers,
864            &other.max_concurrent_dispatchers,
865        );
866        merge_optional(&mut self.max_message_rate, &other.max_message_rate);
867        merge_optional(&mut self.max_connection_rate, &other.max_connection_rate);
868        merge_optional(
869            &mut self.max_source_selection_rate,
870            &other.max_source_selection_rate,
871        );
872
873        if let Some(merged) = &self.max_message_rate {
874            if !matches!(merged.source, CeilingSource::Primary) {
875                if let Some(declared) = path_declared {
876                    self.max_message_rate_declared = Some(declared);
877                }
878            }
879        }
880    }
881}
882
883fn merge_axis(a: &mut EffectiveCeiling, b: &EffectiveCeiling) {
884    if b.value.total_cmp(&a.value).is_lt() {
885        *a = b.clone();
886    }
887}
888
889fn merge_optional(a: &mut Option<EffectiveCeiling>, b: &Option<EffectiveCeiling>) {
890    match (a.as_mut(), b) {
891        (Some(av), Some(bv)) if bv.value.total_cmp(&av.value).is_lt() => *av = bv.clone(),
892        (None, Some(bv)) => *a = Some(bv.clone()),
893        _ => {}
894    }
895}
896
897#[cfg(test)]
898mod constraints_tests {
899    use super::*;
900
901    fn cfg() -> EgressPathConfig {
902        EgressPathConfig::default()
903    }
904
905    /// Derive the canonical numeric value from the same display
906    /// string the production code emits. A bare integer is a
907    /// concurrency count; a `<limit>/<period>` form is a rate (in
908    /// events per second after normalization); a `K × <rate> = <rate>`
909    /// form is the reconnect-cycling formula and uses the total on
910    /// the right side. Keeps tests as declarative `display`
911    /// strings.
912    fn value_from_display(display: &str) -> f64 {
913        if let Some((_, total)) = display.split_once(" = ") {
914            let spec = ThrottleSpec::try_from(total).unwrap();
915            return spec.limit as f64 / spec.period as f64;
916        }
917        if let Ok(spec) = ThrottleSpec::try_from(display) {
918            return spec.limit as f64 / spec.period as f64;
919        }
920        display.parse::<u64>().unwrap() as f64
921    }
922
923    fn primary(display: &str) -> EffectiveCeiling {
924        EffectiveCeiling {
925            value: value_from_display(display),
926            source: CeilingSource::Primary,
927            display: display.to_string(),
928        }
929    }
930
931    fn additional(name: &str, display: &str) -> EffectiveCeiling {
932        EffectiveCeiling {
933            value: value_from_display(display),
934            source: CeilingSource::Additional {
935                name: name.to_string(),
936            },
937            display: display.to_string(),
938        }
939    }
940
941    fn other(name: &str, display: &str) -> EffectiveCeiling {
942        EffectiveCeiling {
943            value: value_from_display(display),
944            source: CeilingSource::Other {
945                name: name.to_string(),
946            },
947            display: display.to_string(),
948        }
949    }
950
951    fn reconnect_cycling(display: &str) -> EffectiveCeiling {
952        EffectiveCeiling {
953            value: value_from_display(display),
954            source: CeilingSource::ReconnectCycling,
955            display: display.to_string(),
956        }
957    }
958
959    fn throttle(s: &str) -> ThrottleSpec {
960        ThrottleSpec::try_from(s).unwrap()
961    }
962
963    #[test]
964    fn rust_compact_round_trips() {
965        // Calling the compact TOML renderer directly on a typed
966        // EgressPathConfig preserves the array vs. map shape of
967        // each field, so the rendered text deserializes back into
968        // an equivalent EgressPathConfig.
969        let original = EgressPathConfig::default();
970        let s = mod_serde::toml_encode_pretty_compact(&original).unwrap();
971        let parsed: EgressPathConfig = toml::from_str(&s).unwrap();
972        assert_eq!(parsed, original);
973    }
974
975    #[test]
976    fn defaults_only_concurrency() {
977        assert_eq!(
978            cfg().compute_constraints(None),
979            EffectiveConstraints {
980                max_concurrent_dispatchers: primary("32"),
981                max_message_rate: None,
982                max_message_rate_declared: None,
983                max_connection_rate: None,
984                max_source_selection_rate: None,
985            }
986        );
987    }
988
989    #[test]
990    fn additional_connection_limit_wins() {
991        let mut p = cfg();
992        p.additional_connection_limits
993            .insert("provider_shared".to_string(), LimitSpec::new(10));
994        assert_eq!(
995            p.compute_constraints(None),
996            EffectiveConstraints {
997                max_concurrent_dispatchers: additional("provider_shared", "10"),
998                max_message_rate: None,
999                max_message_rate_declared: None,
1000                max_connection_rate: None,
1001                max_source_selection_rate: None,
1002            }
1003        );
1004    }
1005
1006    #[test]
1007    fn primary_connection_limit_wins_when_smaller() {
1008        let mut p = cfg();
1009        p.connection_limit = LimitSpec::new(4);
1010        p.additional_connection_limits
1011            .insert("large_pool".to_string(), LimitSpec::new(100));
1012        assert_eq!(
1013            p.compute_constraints(None),
1014            EffectiveConstraints {
1015                max_concurrent_dispatchers: primary("4"),
1016                max_message_rate: None,
1017                max_message_rate_declared: None,
1018                max_connection_rate: None,
1019                max_source_selection_rate: None,
1020            }
1021        );
1022    }
1023
1024    #[test]
1025    fn primary_message_rate_only() {
1026        let mut p = cfg();
1027        p.max_message_rate = Some(throttle("1000/s"));
1028        assert_eq!(
1029            p.compute_constraints(None),
1030            EffectiveConstraints {
1031                max_concurrent_dispatchers: primary("32"),
1032                max_message_rate: Some(primary("1000/s")),
1033                max_message_rate_declared: None,
1034                max_connection_rate: None,
1035                max_source_selection_rate: None,
1036            }
1037        );
1038    }
1039
1040    #[test]
1041    fn additional_message_rate_wins() {
1042        let mut p = cfg();
1043        p.max_message_rate = Some(throttle("1000/s"));
1044        p.additional_message_rate_throttles
1045            .insert("provider_cap".to_string(), throttle("250/s"));
1046        assert_eq!(
1047            p.compute_constraints(None),
1048            EffectiveConstraints {
1049                max_concurrent_dispatchers: primary("32"),
1050                max_message_rate: Some(additional("provider_cap", "250/s")),
1051                max_message_rate_declared: Some("1000/s".to_string()),
1052                max_connection_rate: None,
1053                max_source_selection_rate: None,
1054            }
1055        );
1056    }
1057
1058    #[test]
1059    fn additional_message_rate_throttles_mixed_periods() {
1060        // Three additional throttles with mixed periods: the smallest
1061        // canonical rate (10/hr ≈ 0.0028 msg/s) must win the min,
1062        // even though numerically 5/s has the smallest *literal*
1063        // limit. This exercises the period-normalized comparison.
1064        let mut p = cfg();
1065        p.additional_message_rate_throttles
1066            .insert("per_hour".to_string(), throttle("10/hr"));
1067        p.additional_message_rate_throttles
1068            .insert("per_minute".to_string(), throttle("8/min"));
1069        p.additional_message_rate_throttles
1070            .insert("per_second".to_string(), throttle("5/s"));
1071        assert_eq!(
1072            p.compute_constraints(None),
1073            EffectiveConstraints {
1074                max_concurrent_dispatchers: primary("32"),
1075                max_message_rate: Some(additional("per_hour", "10/h")),
1076                max_message_rate_declared: None,
1077                max_connection_rate: None,
1078                max_source_selection_rate: None,
1079            }
1080        );
1081    }
1082
1083    #[test]
1084    fn hourly_rate_preserves_units() {
1085        // Operator-configured "10000/hr" should round-trip as
1086        // "10000/h" via ThrottleSpec::Display, not collapse to a
1087        // per-second decimal like "2.78/s". Canonical value is
1088        // still events per second for numeric comparison.
1089        let mut p = cfg();
1090        p.max_message_rate = Some(throttle("10000/hr"));
1091        assert_eq!(
1092            p.compute_constraints(None),
1093            EffectiveConstraints {
1094                max_concurrent_dispatchers: primary("32"),
1095                max_message_rate: Some(primary("10000/h")),
1096                max_message_rate_declared: None,
1097                max_connection_rate: None,
1098                max_source_selection_rate: None,
1099            }
1100        );
1101    }
1102
1103    #[test]
1104    fn reconnect_cycling_wins() {
1105        // K = 10, C = 10/s => 100 msg/s ceiling, smaller than the
1106        // 1000/s max_message_rate.
1107        let mut p = cfg();
1108        p.max_message_rate = Some(throttle("1000/s"));
1109        p.max_deliveries_per_connection = 10;
1110        p.max_connection_rate = Some(throttle("10/s"));
1111        assert_eq!(
1112            p.compute_constraints(None),
1113            EffectiveConstraints {
1114                max_concurrent_dispatchers: primary("32"),
1115                max_message_rate: Some(reconnect_cycling("10 × 10/s = 100/s")),
1116                max_message_rate_declared: Some("1000/s".to_string()),
1117                max_connection_rate: Some(primary("10/s")),
1118                max_source_selection_rate: None,
1119            }
1120        );
1121    }
1122
1123    #[test]
1124    fn reconnect_cycling_does_not_bind_with_large_k() {
1125        // K = 1024 (default), C = 10/s => 10240 msg/s synthetic,
1126        // not binding when max_message_rate is 1000/s. Primary wins
1127        // and no declared-but-unreachable annotation is needed.
1128        let mut p = cfg();
1129        p.max_message_rate = Some(throttle("1000/s"));
1130        p.max_connection_rate = Some(throttle("10/s"));
1131        assert_eq!(
1132            p.compute_constraints(None),
1133            EffectiveConstraints {
1134                max_concurrent_dispatchers: primary("32"),
1135                max_message_rate: Some(primary("1000/s")),
1136                max_message_rate_declared: None,
1137                max_connection_rate: Some(primary("10/s")),
1138                max_source_selection_rate: None,
1139            }
1140        );
1141    }
1142
1143    #[test]
1144    fn reconnect_cycling_alone_sets_message_rate() {
1145        // No explicit max_message_rate, but K × C is still a
1146        // computable ceiling and should be reported. Nothing was
1147        // declared, so no annotation.
1148        let mut p = cfg();
1149        p.max_deliveries_per_connection = 5;
1150        p.max_connection_rate = Some(throttle("2/s"));
1151        assert_eq!(
1152            p.compute_constraints(None),
1153            EffectiveConstraints {
1154                max_concurrent_dispatchers: primary("32"),
1155                max_message_rate: Some(reconnect_cycling("5 × 2/s = 10/s")),
1156                max_message_rate_declared: None,
1157                max_connection_rate: Some(primary("2/s")),
1158                max_source_selection_rate: None,
1159            }
1160        );
1161    }
1162
1163    #[test]
1164    fn merge_external_message_rate_wins() {
1165        // Path config declares max_message_rate = 1000/s, but an
1166        // external source (e.g. a scheduled queue) declares 100/s.
1167        // The merged result picks the external term and surfaces the
1168        // path's declared rate as "effectively unreachable".
1169        let mut p = cfg();
1170        p.max_message_rate = Some(throttle("1000/s"));
1171        let external = EffectiveConstraints {
1172            max_concurrent_dispatchers: EffectiveCeiling {
1173                value: f64::INFINITY,
1174                source: CeilingSource::Other {
1175                    name: "queue config".to_string(),
1176                },
1177                display: "∞".to_string(),
1178            },
1179            max_message_rate: Some(other("scheduled queue max_message_rate", "100/s")),
1180            max_message_rate_declared: None,
1181            max_connection_rate: None,
1182            max_source_selection_rate: None,
1183        };
1184        assert_eq!(
1185            p.compute_constraints(Some(&external)),
1186            EffectiveConstraints {
1187                max_concurrent_dispatchers: primary("32"),
1188                max_message_rate: Some(other("scheduled queue max_message_rate", "100/s")),
1189                max_message_rate_declared: Some("1000/s".to_string()),
1190                max_connection_rate: None,
1191                max_source_selection_rate: None,
1192            }
1193        );
1194    }
1195
1196    #[test]
1197    fn merge_external_does_not_bind() {
1198        // Path config declares max_message_rate = 100/s; external
1199        // declares 1000/s. Path's primary wins; no annotation.
1200        let mut p = cfg();
1201        p.max_message_rate = Some(throttle("100/s"));
1202        let external = EffectiveConstraints {
1203            max_concurrent_dispatchers: EffectiveCeiling {
1204                value: f64::INFINITY,
1205                source: CeilingSource::Other {
1206                    name: "queue config".to_string(),
1207                },
1208                display: "∞".to_string(),
1209            },
1210            max_message_rate: Some(other("scheduled queue max_message_rate", "1000/s")),
1211            max_message_rate_declared: None,
1212            max_connection_rate: None,
1213            max_source_selection_rate: None,
1214        };
1215        assert_eq!(
1216            p.compute_constraints(Some(&external)),
1217            EffectiveConstraints {
1218                max_concurrent_dispatchers: primary("32"),
1219                max_message_rate: Some(primary("100/s")),
1220                max_message_rate_declared: None,
1221                max_connection_rate: None,
1222                max_source_selection_rate: None,
1223            }
1224        );
1225    }
1226
1227    #[test]
1228    fn render_external_wins_with_annotation() {
1229        let mut p = cfg();
1230        p.max_message_rate = Some(throttle("1000/s"));
1231        let external = EffectiveConstraints {
1232            max_concurrent_dispatchers: EffectiveCeiling {
1233                value: f64::INFINITY,
1234                source: CeilingSource::Other {
1235                    name: "queue config".to_string(),
1236                },
1237                display: "∞".to_string(),
1238            },
1239            max_message_rate: Some(other("scheduled queue max_message_rate", "100/s")),
1240            max_message_rate_declared: None,
1241            max_connection_rate: None,
1242            max_source_selection_rate: None,
1243        };
1244        let c = p.compute_constraints(Some(&external));
1245        k9::snapshot!(
1246            c.to_human_string(),
1247            "
1248ceilings:
1249  concurrent dispatchers: 32
1250    source: connection_limit
1251  message rate:           100/s
1252    source: scheduled queue max_message_rate
1253    declared: max_message_rate = 1000/s ← effectively unreachable
1254
1255"
1256        );
1257    }
1258
1259    #[test]
1260    fn source_selection_rate() {
1261        let mut p = cfg();
1262        p.source_selection_rate = Some(throttle("5/s"));
1263        assert_eq!(
1264            p.compute_constraints(None),
1265            EffectiveConstraints {
1266                max_concurrent_dispatchers: primary("32"),
1267                max_message_rate: None,
1268                max_message_rate_declared: None,
1269                max_connection_rate: None,
1270                max_source_selection_rate: Some(primary("5/s")),
1271            }
1272        );
1273    }
1274
1275    #[test]
1276    fn render_defaults() {
1277        let c = cfg().compute_constraints(None);
1278        k9::snapshot!(
1279            c.to_human_string(),
1280            "
1281ceilings:
1282  concurrent dispatchers: 32
1283    source: connection_limit
1284
1285"
1286        );
1287    }
1288
1289    #[test]
1290    fn render_primary_message_rate_no_annotation() {
1291        // max_message_rate is the binding term; no declared-but-
1292        // unreachable annotation should appear.
1293        let mut p = cfg();
1294        p.max_message_rate = Some(throttle("1000/s"));
1295        let c = p.compute_constraints(None);
1296        k9::snapshot!(
1297            c.to_human_string(),
1298            "
1299ceilings:
1300  concurrent dispatchers: 32
1301    source: connection_limit
1302  message rate:           1000/s
1303    source: max_message_rate
1304
1305"
1306        );
1307    }
1308
1309    #[test]
1310    fn render_reconnect_cycling_with_annotation() {
1311        let mut p = cfg();
1312        p.max_message_rate = Some(throttle("1000/s"));
1313        p.max_deliveries_per_connection = 10;
1314        p.max_connection_rate = Some(throttle("10/s"));
1315        let c = p.compute_constraints(None);
1316        k9::snapshot!(
1317            c.to_human_string(),
1318            "
1319ceilings:
1320  concurrent dispatchers: 32
1321    source: connection_limit
1322  message rate:           10 × 10/s = 100/s
1323    source: max_deliveries_per_connection × max_connection_rate
1324    declared: max_message_rate = 1000/s ← effectively unreachable
1325  connection rate:        10/s
1326    source: max_connection_rate
1327
1328"
1329        );
1330    }
1331
1332    #[test]
1333    fn render_additional_throttle_winning() {
1334        let mut p = cfg();
1335        p.max_message_rate = Some(throttle("1000/s"));
1336        p.additional_message_rate_throttles
1337            .insert("provider_cap".to_string(), throttle("250/s"));
1338        let c = p.compute_constraints(None);
1339        k9::snapshot!(
1340            c.to_human_string(),
1341            "
1342ceilings:
1343  concurrent dispatchers: 32
1344    source: connection_limit
1345  message rate:           250/s
1346    source: provider_cap
1347    declared: max_message_rate = 1000/s ← effectively unreachable
1348
1349"
1350        );
1351    }
1352}