kumo_api_types/
egress_path.rs

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