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    /// Users with a large `max_batch_latency` should set this
382    /// explicitly so the watchdog does not flag batch accumulation.
383    #[serde(default, with = "duration_serde")]
384    pub dispatcher_progress_watchdog_timeout: Option<Duration>,
385}
386
387#[cfg(feature = "lua")]
388impl LuaUserData for EgressPathConfig {
389    fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
390        config::impl_pairs_and_index(methods);
391    }
392}
393
394impl Default for EgressPathConfig {
395    fn default() -> Self {
396        Self {
397            connection_limit: Self::default_connection_limit(),
398            tls_prefer_openssl: false,
399            enable_tls: Tls::default(),
400            enable_mta_sts: Self::default_enable_mta_sts(),
401            enable_dane: Self::default_enable_dane(),
402            enable_rset: Self::default_enable_rset(),
403            enable_pipelining: Self::default_enable_pipelining(),
404            max_ready: Self::default_max_ready(),
405            consecutive_connection_failures_before_delay:
406                Self::default_consecutive_connection_failures_before_delay(),
407            smtp_port: Self::default_smtp_port(),
408            max_message_rate: None,
409            max_connection_rate: None,
410            max_deliveries_per_connection: Self::default_max_deliveries_per_connection(),
411            max_recipients_per_batch: Self::default_max_recipients_per_batch(),
412            client_timeouts: SmtpClientTimeouts::default(),
413            system_shutdown_timeout: None,
414            prohibited_hosts: CidrSet::default_prohibited_hosts(),
415            skip_hosts: CidrSet::default(),
416            ehlo_domain: None,
417            allow_smtp_auth_plain_without_tls: false,
418            allow_smtp_auth_plain_without_valid_certificate: false,
419            smtp_auth_plain_username: None,
420            smtp_auth_plain_password: None,
421            aggressive_connection_opening: false,
422            rustls_cipher_suites: vec![],
423            tls_certificate: None,
424            tls_private_key: None,
425            openssl_cipher_list: None,
426            openssl_cipher_suites: None,
427            openssl_options: None,
428            refresh_interval: Self::default_refresh_interval(),
429            refresh_strategy: ConfigRefreshStrategy::default(),
430            additional_message_rate_throttles: OrderMap::default(),
431            additional_connection_limits: OrderMap::default(),
432            source_selection_rate: None,
433            additional_source_selection_rates: OrderMap::default(),
434            provider_name: None,
435            remember_broken_tls: None,
436            opportunistic_tls_reconnect_on_failed_handshake: false,
437            use_lmtp: false,
438            reconnect_strategy: ReconnectStrategy::default(),
439            readyq_pool_name: None,
440            low_memory_reduction_policy: MemoryReductionPolicy::default(),
441            no_memory_reduction_policy: MemoryReductionPolicy::default(),
442            maintainer_wakeup_strategy: WakeupStrategy::default(),
443            dispatcher_wakeup_strategy: WakeupStrategy::default(),
444            try_next_host_on_transport_error: false,
445            ignore_8bit_checks: false,
446            ip_lookup_strategy: IpLookupStrategy::default(),
447            dispatcher_progress_watchdog_timeout: None,
448        }
449    }
450}
451
452impl EgressPathConfig {
453    fn default_connection_limit() -> LimitSpec {
454        LimitSpec::new(32)
455    }
456
457    fn default_enable_mta_sts() -> bool {
458        true
459    }
460
461    fn default_enable_pipelining() -> bool {
462        true
463    }
464
465    fn default_enable_rset() -> bool {
466        true
467    }
468
469    fn default_enable_dane() -> bool {
470        false
471    }
472
473    fn default_max_ready() -> usize {
474        1024
475    }
476
477    fn default_consecutive_connection_failures_before_delay() -> usize {
478        100
479    }
480
481    fn default_smtp_port() -> u16 {
482        25
483    }
484
485    fn default_max_deliveries_per_connection() -> usize {
486        1024
487    }
488
489    fn default_max_recipients_per_batch() -> usize {
490        100
491    }
492
493    fn default_refresh_interval() -> Duration {
494        Duration::from_secs(60)
495    }
496
497    /// Compute the steady-state ceilings implied by this
498    /// configuration. Per-axis, each ceiling carries a tag for which
499    /// configuration term produced it, so operators can see which
500    /// knob to turn.
501    ///
502    /// If `additional` is supplied, its ceilings are merged in via
503    /// `EffectiveConstraints::merge`. This is the entry point for
504    /// folding in constraints from other configuration layers, such
505    /// as the scheduled-queue rate from `QueueConfig`.
506    pub fn compute_constraints(
507        &self,
508        additional: Option<&EffectiveConstraints>,
509    ) -> EffectiveConstraints {
510        let mut constraints = self.compute_path_constraints();
511        if let Some(extra) = additional {
512            constraints.merge(extra);
513        }
514        constraints
515    }
516
517    fn compute_path_constraints(&self) -> EffectiveConstraints {
518        let max_concurrent_dispatchers = {
519            let mut best = EffectiveCeiling {
520                value: self.connection_limit.limit as f64,
521                source: CeilingSource::Primary,
522                display: self.connection_limit.limit.to_string(),
523            };
524            for (name, spec) in &self.additional_connection_limits {
525                let value = spec.limit as f64;
526                if value < best.value {
527                    best = EffectiveCeiling {
528                        value,
529                        source: CeilingSource::Additional { name: name.clone() },
530                        display: spec.limit.to_string(),
531                    };
532                }
533            }
534            best
535        };
536
537        // Compute the message-rate ceiling across the primary,
538        // additional throttles, and the synthetic K × C ceiling.
539        let mut msg_candidates: Vec<EffectiveCeiling> = vec![];
540        if let Some(spec) = &self.max_message_rate {
541            msg_candidates.push(throttle_ceiling(spec, CeilingSource::Primary));
542        }
543        for (name, spec) in &self.additional_message_rate_throttles {
544            msg_candidates.push(throttle_ceiling(
545                spec,
546                CeilingSource::Additional { name: name.clone() },
547            ));
548        }
549        if let Some(spec) = &self.max_connection_rate {
550            // K × C ceiling: every connection delivers at most
551            // max_deliveries_per_connection messages, so total msg
552            // rate cannot exceed (max_deliveries_per_connection) times
553            // the connection-establishment rate. The display shows
554            // both the factors and the computed total so operators
555            // don't have to do the arithmetic mentally for cases
556            // like 32 × 50/s.
557            let k = self.max_deliveries_per_connection as u64;
558            let total = ThrottleSpec {
559                limit: k.saturating_mul(spec.limit),
560                period: spec.period,
561                max_burst: None,
562                force_local: false,
563            };
564            msg_candidates.push(EffectiveCeiling {
565                value: throttle_rate_per_sec(spec) * k as f64,
566                source: CeilingSource::ReconnectCycling,
567                display: format!("{k} × {spec} = {total}"),
568            });
569        }
570        // total_cmp gives a total ordering even for NaN, so a malformed
571        // candidate cannot cause a panic here. NaN sorts greater than
572        // any finite value, so it naturally loses the min_by.
573        let max_message_rate = msg_candidates
574            .iter()
575            .min_by(|a, b| a.value.total_cmp(&b.value))
576            .cloned();
577
578        // If max_message_rate is explicitly configured but a
579        // different term (typically the synthetic reconnect-cycling
580        // ceiling) wins the minimum, record the declared rate so
581        // renderers can show an "effectively unreachable" annotation.
582        // Preserve the operator's original units via ThrottleSpec's
583        // Display impl.
584        let max_message_rate_declared = match (&max_message_rate, &self.max_message_rate) {
585            (Some(ceiling), Some(declared))
586                if !matches!(ceiling.source, CeilingSource::Primary) =>
587            {
588                Some(declared.to_string())
589            }
590            _ => None,
591        };
592
593        let max_connection_rate = self
594            .max_connection_rate
595            .as_ref()
596            .map(|spec| throttle_ceiling(spec, CeilingSource::Primary));
597
598        let max_source_selection_rate = {
599            let mut candidates: Vec<EffectiveCeiling> = vec![];
600            if let Some(spec) = &self.source_selection_rate {
601                candidates.push(throttle_ceiling(spec, CeilingSource::Primary));
602            }
603            for (name, spec) in &self.additional_source_selection_rates {
604                candidates.push(throttle_ceiling(
605                    spec,
606                    CeilingSource::Additional { name: name.clone() },
607                ));
608            }
609            candidates
610                .into_iter()
611                .min_by(|a, b| a.value.total_cmp(&b.value))
612        };
613
614        EffectiveConstraints {
615            max_concurrent_dispatchers,
616            max_message_rate,
617            max_message_rate_declared,
618            max_connection_rate,
619            max_source_selection_rate,
620        }
621    }
622}
623
624fn throttle_rate_per_sec(spec: &ThrottleSpec) -> f64 {
625    spec.limit as f64 / spec.period as f64
626}
627
628fn throttle_ceiling(spec: &ThrottleSpec, source: CeilingSource) -> EffectiveCeiling {
629    EffectiveCeiling {
630        value: throttle_rate_per_sec(spec),
631        source,
632        display: spec.to_string(),
633    }
634}
635
636/// Steady-state ceiling for a single throughput axis, with a tag
637/// for which configuration term produced it.
638///
639/// {{since('dev')}}
640#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
641pub struct EffectiveCeiling {
642    /// Canonical value. For rate axes: events per second; useful
643    /// for numeric comparison. For concurrency: a count.
644    pub value: f64,
645    pub source: CeilingSource,
646    /// Pre-formatted human display preserving the operator's
647    /// original configuration units. A rate configured as
648    /// `10000/hr` renders here as `10000/h` rather than `2.78/s`.
649    /// For concurrency, the integer count. For the synthetic
650    /// reconnect-cycling ceiling, the formula
651    /// `max_deliveries_per_connection × <connection_rate>`.
652    pub display: String,
653}
654
655/// Which configuration term produced an `EffectiveCeiling`.
656///
657/// {{since('dev')}}
658#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
659#[serde(tag = "kind", rename_all = "snake_case")]
660pub enum CeilingSource {
661    /// The primary configured term for this axis:
662    /// `connection_limit`, `max_message_rate`,
663    /// `max_connection_rate`, or `source_selection_rate`.
664    Primary,
665    /// A named entry from the corresponding `additional_*` map.
666    Additional { name: String },
667    /// Synthetic ceiling formed from
668    /// `max_deliveries_per_connection × max_connection_rate`.
669    /// Applies only to the message-rate axis: each connection
670    /// delivers at most `max_deliveries_per_connection` messages
671    /// before reconnecting, and new connections are throttled by
672    /// `max_connection_rate`, so the product is a hard ceiling on
673    /// system-wide message rate independent of `max_message_rate`.
674    ReconnectCycling,
675    /// A constraint contributed from a configuration layer outside
676    /// the egress path config. `name` is a free-form, human-readable
677    /// description of where the constraint came from (for example,
678    /// `"scheduled queue max_message_rate"`).
679    Other { name: String },
680}
681
682/// Summary of an MX resolution attempt for a destination.
683///
684/// {{since('dev')}}
685#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
686pub struct MxResolution {
687    /// Canonical site name; the value used as the queue grouping
688    /// key for this destination.
689    pub site_name: String,
690
691    /// MX hostnames grouped by preference. Lower preference is
692    /// preferred. Empty when DNS returned no MX records and the
693    /// resolver synthesised A/AAAA against `domain` directly.
694    pub by_preference: BTreeMap<u16, Vec<String>>,
695
696    /// True if real MX records were found in DNS. False when the
697    /// resolver synthesised an A/AAAA lookup against the domain.
698    pub is_mx: bool,
699
700    /// True if the destination was a domain literal like `[1.2.3.4]`.
701    pub is_domain_literal: bool,
702
703    /// True if the DNS result was DNSSEC validated.
704    pub is_secure: bool,
705}
706
707impl From<&MailExchanger> for MxResolution {
708    fn from(mx: &MailExchanger) -> Self {
709        Self {
710            site_name: mx.site_name.clone(),
711            by_preference: mx.by_pref.clone(),
712            is_mx: mx.is_mx,
713            is_domain_literal: mx.is_domain_literal,
714            is_secure: mx.is_secure,
715        }
716    }
717}
718
719impl MxResolution {
720    /// Render a one-line header summarising the MX result, plus an
721    /// indented preference→host listing beneath. Shared by
722    /// `kcli inspect-ready-q` and `kcli resolve-egress-path` so the
723    /// two surfaces produce identical output for the same input.
724    pub fn render(&self, out: &mut dyn Write) -> std::fmt::Result {
725        let mut flags: Vec<&str> = vec![];
726        if !self.is_mx {
727            flags.push("synthesised");
728        }
729        if self.is_domain_literal {
730            flags.push("domain literal");
731        }
732        if self.is_secure {
733            flags.push("dnssec verified");
734        }
735        write!(out, "mx (site: {}", self.site_name)?;
736        if !flags.is_empty() {
737            write!(out, ", {}", flags.join(", "))?;
738        }
739        writeln!(out, "):")?;
740        for (pref, hosts) in &self.by_preference {
741            for host in hosts {
742                writeln!(out, "  {pref:>4}  {host}")?;
743            }
744        }
745        Ok(())
746    }
747
748    pub fn to_human_string(&self) -> String {
749        let mut s = String::new();
750        let _ = self.render(&mut s);
751        s
752    }
753}
754
755/// Steady-state ceilings implied by an `EgressPathConfig`. Each
756/// ceiling carries a tag for which configuration term produced it.
757///
758/// {{since('dev')}}
759///
760/// These are per-queue ceilings; shared limits in `additional_*`
761/// maps are reported at their full value and may be tighter in
762/// practice when the bucket is contended.
763#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, ToSchema)]
764pub struct EffectiveConstraints {
765    pub max_concurrent_dispatchers: EffectiveCeiling,
766    /// None when neither `max_message_rate` nor any
767    /// `additional_message_rate_throttles` entry nor
768    /// `max_connection_rate` is configured.
769    pub max_message_rate: Option<EffectiveCeiling>,
770    /// Pre-formatted display of the declared `max_message_rate` when
771    /// a different term (typically `ReconnectCycling`) wins the
772    /// minimum. Lets renderers show a "declared but unreachable"
773    /// annotation. Uses the operator's original units.
774    pub max_message_rate_declared: Option<String>,
775    pub max_connection_rate: Option<EffectiveCeiling>,
776    pub max_source_selection_rate: Option<EffectiveCeiling>,
777}
778
779impl EffectiveConstraints {
780    /// Render a human-readable multi-line block. The same formatting
781    /// is used by `kcli inspect-ready-q` and by the
782    /// `resolve-shaping-domain` script, so operators see the same
783    /// output regardless of where they retrieved the config.
784    pub fn render(&self, out: &mut dyn Write) -> std::fmt::Result {
785        let label_axis = |w: &mut dyn Write, label: &str, value: &str| -> std::fmt::Result {
786            writeln!(w, "  {label:<24}{value}")
787        };
788        let label_source =
789            |w: &mut dyn Write, src: &CeilingSource, primary: &str| -> std::fmt::Result {
790                let s = match src {
791                    CeilingSource::Primary => primary,
792                    CeilingSource::Additional { name } => name.as_str(),
793                    CeilingSource::ReconnectCycling => {
794                        "max_deliveries_per_connection × max_connection_rate"
795                    }
796                    CeilingSource::Other { name } => name.as_str(),
797                };
798                writeln!(w, "    source: {s}")
799            };
800
801        writeln!(out, "ceilings:")?;
802
803        label_axis(
804            out,
805            "concurrent dispatchers:",
806            &self.max_concurrent_dispatchers.display,
807        )?;
808        label_source(
809            out,
810            &self.max_concurrent_dispatchers.source,
811            "connection_limit",
812        )?;
813
814        if let Some(c) = &self.max_message_rate {
815            label_axis(out, "message rate:", &c.display)?;
816            label_source(out, &c.source, "max_message_rate")?;
817            if let Some(declared) = &self.max_message_rate_declared {
818                writeln!(
819                    out,
820                    "    declared: max_message_rate = {declared} ← effectively unreachable"
821                )?;
822            }
823        }
824
825        if let Some(c) = &self.max_connection_rate {
826            label_axis(out, "connection rate:", &c.display)?;
827            label_source(out, &c.source, "max_connection_rate")?;
828        }
829
830        if let Some(c) = &self.max_source_selection_rate {
831            label_axis(out, "source selection rate:", &c.display)?;
832            label_source(out, &c.source, "source_selection_rate")?;
833        }
834
835        Ok(())
836    }
837
838    pub fn to_human_string(&self) -> String {
839        let mut s = String::new();
840        // std::fmt::Write into a String cannot fail in practice;
841        // swallow the error to avoid a panic site here.
842        let _ = self.render(&mut s);
843        s
844    }
845
846    /// Fold another `EffectiveConstraints` into this one by taking
847    /// the per-axis minimum. The losing ceiling is shadowed; the
848    /// winner's `source` tag is preserved. The
849    /// `max_message_rate_declared` annotation is re-evaluated so a
850    /// rate that is now shadowed by an external source gets the
851    /// "declared but unreachable" treatment.
852    pub fn merge(&mut self, other: &EffectiveConstraints) {
853        // Remember the path's declared rate before merging; if a
854        // tighter external source wins, we need to surface the
855        // declared value as the unreachable annotation.
856        let path_declared = self
857            .max_message_rate
858            .as_ref()
859            .filter(|c| matches!(c.source, CeilingSource::Primary))
860            .map(|c| c.display.clone())
861            .or_else(|| self.max_message_rate_declared.clone());
862
863        merge_axis(
864            &mut self.max_concurrent_dispatchers,
865            &other.max_concurrent_dispatchers,
866        );
867        merge_optional(&mut self.max_message_rate, &other.max_message_rate);
868        merge_optional(&mut self.max_connection_rate, &other.max_connection_rate);
869        merge_optional(
870            &mut self.max_source_selection_rate,
871            &other.max_source_selection_rate,
872        );
873
874        if let Some(merged) = &self.max_message_rate {
875            if !matches!(merged.source, CeilingSource::Primary) {
876                if let Some(declared) = path_declared {
877                    self.max_message_rate_declared = Some(declared);
878                }
879            }
880        }
881    }
882}
883
884fn merge_axis(a: &mut EffectiveCeiling, b: &EffectiveCeiling) {
885    if b.value.total_cmp(&a.value).is_lt() {
886        *a = b.clone();
887    }
888}
889
890fn merge_optional(a: &mut Option<EffectiveCeiling>, b: &Option<EffectiveCeiling>) {
891    match (a.as_mut(), b) {
892        (Some(av), Some(bv)) if bv.value.total_cmp(&av.value).is_lt() => *av = bv.clone(),
893        (None, Some(bv)) => *a = Some(bv.clone()),
894        _ => {}
895    }
896}
897
898#[cfg(test)]
899mod constraints_tests {
900    use super::*;
901
902    fn cfg() -> EgressPathConfig {
903        EgressPathConfig::default()
904    }
905
906    /// Derive the canonical numeric value from the same display
907    /// string the production code emits. A bare integer is a
908    /// concurrency count; a `<limit>/<period>` form is a rate (in
909    /// events per second after normalization); a `K × <rate> = <rate>`
910    /// form is the reconnect-cycling formula and uses the total on
911    /// the right side. Keeps tests as declarative `display`
912    /// strings.
913    fn value_from_display(display: &str) -> f64 {
914        if let Some((_, total)) = display.split_once(" = ") {
915            let spec = ThrottleSpec::try_from(total).unwrap();
916            return spec.limit as f64 / spec.period as f64;
917        }
918        if let Ok(spec) = ThrottleSpec::try_from(display) {
919            return spec.limit as f64 / spec.period as f64;
920        }
921        display.parse::<u64>().unwrap() as f64
922    }
923
924    fn primary(display: &str) -> EffectiveCeiling {
925        EffectiveCeiling {
926            value: value_from_display(display),
927            source: CeilingSource::Primary,
928            display: display.to_string(),
929        }
930    }
931
932    fn additional(name: &str, display: &str) -> EffectiveCeiling {
933        EffectiveCeiling {
934            value: value_from_display(display),
935            source: CeilingSource::Additional {
936                name: name.to_string(),
937            },
938            display: display.to_string(),
939        }
940    }
941
942    fn other(name: &str, display: &str) -> EffectiveCeiling {
943        EffectiveCeiling {
944            value: value_from_display(display),
945            source: CeilingSource::Other {
946                name: name.to_string(),
947            },
948            display: display.to_string(),
949        }
950    }
951
952    fn reconnect_cycling(display: &str) -> EffectiveCeiling {
953        EffectiveCeiling {
954            value: value_from_display(display),
955            source: CeilingSource::ReconnectCycling,
956            display: display.to_string(),
957        }
958    }
959
960    fn throttle(s: &str) -> ThrottleSpec {
961        ThrottleSpec::try_from(s).unwrap()
962    }
963
964    #[test]
965    fn rust_compact_round_trips() {
966        // Calling the compact TOML renderer directly on a typed
967        // EgressPathConfig preserves the array vs. map shape of
968        // each field, so the rendered text deserializes back into
969        // an equivalent EgressPathConfig.
970        let original = EgressPathConfig::default();
971        let s = mod_serde::toml_encode_pretty_compact(&original).unwrap();
972        let parsed: EgressPathConfig = toml::from_str(&s).unwrap();
973        assert_eq!(parsed, original);
974    }
975
976    #[test]
977    fn defaults_only_concurrency() {
978        assert_eq!(
979            cfg().compute_constraints(None),
980            EffectiveConstraints {
981                max_concurrent_dispatchers: primary("32"),
982                max_message_rate: None,
983                max_message_rate_declared: None,
984                max_connection_rate: None,
985                max_source_selection_rate: None,
986            }
987        );
988    }
989
990    #[test]
991    fn additional_connection_limit_wins() {
992        let mut p = cfg();
993        p.additional_connection_limits
994            .insert("provider_shared".to_string(), LimitSpec::new(10));
995        assert_eq!(
996            p.compute_constraints(None),
997            EffectiveConstraints {
998                max_concurrent_dispatchers: additional("provider_shared", "10"),
999                max_message_rate: None,
1000                max_message_rate_declared: None,
1001                max_connection_rate: None,
1002                max_source_selection_rate: None,
1003            }
1004        );
1005    }
1006
1007    #[test]
1008    fn primary_connection_limit_wins_when_smaller() {
1009        let mut p = cfg();
1010        p.connection_limit = LimitSpec::new(4);
1011        p.additional_connection_limits
1012            .insert("large_pool".to_string(), LimitSpec::new(100));
1013        assert_eq!(
1014            p.compute_constraints(None),
1015            EffectiveConstraints {
1016                max_concurrent_dispatchers: primary("4"),
1017                max_message_rate: None,
1018                max_message_rate_declared: None,
1019                max_connection_rate: None,
1020                max_source_selection_rate: None,
1021            }
1022        );
1023    }
1024
1025    #[test]
1026    fn primary_message_rate_only() {
1027        let mut p = cfg();
1028        p.max_message_rate = Some(throttle("1000/s"));
1029        assert_eq!(
1030            p.compute_constraints(None),
1031            EffectiveConstraints {
1032                max_concurrent_dispatchers: primary("32"),
1033                max_message_rate: Some(primary("1000/s")),
1034                max_message_rate_declared: None,
1035                max_connection_rate: None,
1036                max_source_selection_rate: None,
1037            }
1038        );
1039    }
1040
1041    #[test]
1042    fn additional_message_rate_wins() {
1043        let mut p = cfg();
1044        p.max_message_rate = Some(throttle("1000/s"));
1045        p.additional_message_rate_throttles
1046            .insert("provider_cap".to_string(), throttle("250/s"));
1047        assert_eq!(
1048            p.compute_constraints(None),
1049            EffectiveConstraints {
1050                max_concurrent_dispatchers: primary("32"),
1051                max_message_rate: Some(additional("provider_cap", "250/s")),
1052                max_message_rate_declared: Some("1000/s".to_string()),
1053                max_connection_rate: None,
1054                max_source_selection_rate: None,
1055            }
1056        );
1057    }
1058
1059    #[test]
1060    fn additional_message_rate_throttles_mixed_periods() {
1061        // Three additional throttles with mixed periods: the smallest
1062        // canonical rate (10/hr ≈ 0.0028 msg/s) must win the min,
1063        // even though numerically 5/s has the smallest *literal*
1064        // limit. This exercises the period-normalized comparison.
1065        let mut p = cfg();
1066        p.additional_message_rate_throttles
1067            .insert("per_hour".to_string(), throttle("10/hr"));
1068        p.additional_message_rate_throttles
1069            .insert("per_minute".to_string(), throttle("8/min"));
1070        p.additional_message_rate_throttles
1071            .insert("per_second".to_string(), throttle("5/s"));
1072        assert_eq!(
1073            p.compute_constraints(None),
1074            EffectiveConstraints {
1075                max_concurrent_dispatchers: primary("32"),
1076                max_message_rate: Some(additional("per_hour", "10/h")),
1077                max_message_rate_declared: None,
1078                max_connection_rate: None,
1079                max_source_selection_rate: None,
1080            }
1081        );
1082    }
1083
1084    #[test]
1085    fn hourly_rate_preserves_units() {
1086        // Operator-configured "10000/hr" should round-trip as
1087        // "10000/h" via ThrottleSpec::Display, not collapse to a
1088        // per-second decimal like "2.78/s". Canonical value is
1089        // still events per second for numeric comparison.
1090        let mut p = cfg();
1091        p.max_message_rate = Some(throttle("10000/hr"));
1092        assert_eq!(
1093            p.compute_constraints(None),
1094            EffectiveConstraints {
1095                max_concurrent_dispatchers: primary("32"),
1096                max_message_rate: Some(primary("10000/h")),
1097                max_message_rate_declared: None,
1098                max_connection_rate: None,
1099                max_source_selection_rate: None,
1100            }
1101        );
1102    }
1103
1104    #[test]
1105    fn reconnect_cycling_wins() {
1106        // K = 10, C = 10/s => 100 msg/s ceiling, smaller than the
1107        // 1000/s max_message_rate.
1108        let mut p = cfg();
1109        p.max_message_rate = Some(throttle("1000/s"));
1110        p.max_deliveries_per_connection = 10;
1111        p.max_connection_rate = Some(throttle("10/s"));
1112        assert_eq!(
1113            p.compute_constraints(None),
1114            EffectiveConstraints {
1115                max_concurrent_dispatchers: primary("32"),
1116                max_message_rate: Some(reconnect_cycling("10 × 10/s = 100/s")),
1117                max_message_rate_declared: Some("1000/s".to_string()),
1118                max_connection_rate: Some(primary("10/s")),
1119                max_source_selection_rate: None,
1120            }
1121        );
1122    }
1123
1124    #[test]
1125    fn reconnect_cycling_does_not_bind_with_large_k() {
1126        // K = 1024 (default), C = 10/s => 10240 msg/s synthetic,
1127        // not binding when max_message_rate is 1000/s. Primary wins
1128        // and no declared-but-unreachable annotation is needed.
1129        let mut p = cfg();
1130        p.max_message_rate = Some(throttle("1000/s"));
1131        p.max_connection_rate = Some(throttle("10/s"));
1132        assert_eq!(
1133            p.compute_constraints(None),
1134            EffectiveConstraints {
1135                max_concurrent_dispatchers: primary("32"),
1136                max_message_rate: Some(primary("1000/s")),
1137                max_message_rate_declared: None,
1138                max_connection_rate: Some(primary("10/s")),
1139                max_source_selection_rate: None,
1140            }
1141        );
1142    }
1143
1144    #[test]
1145    fn reconnect_cycling_alone_sets_message_rate() {
1146        // No explicit max_message_rate, but K × C is still a
1147        // computable ceiling and should be reported. Nothing was
1148        // declared, so no annotation.
1149        let mut p = cfg();
1150        p.max_deliveries_per_connection = 5;
1151        p.max_connection_rate = Some(throttle("2/s"));
1152        assert_eq!(
1153            p.compute_constraints(None),
1154            EffectiveConstraints {
1155                max_concurrent_dispatchers: primary("32"),
1156                max_message_rate: Some(reconnect_cycling("5 × 2/s = 10/s")),
1157                max_message_rate_declared: None,
1158                max_connection_rate: Some(primary("2/s")),
1159                max_source_selection_rate: None,
1160            }
1161        );
1162    }
1163
1164    #[test]
1165    fn merge_external_message_rate_wins() {
1166        // Path config declares max_message_rate = 1000/s, but an
1167        // external source (e.g. a scheduled queue) declares 100/s.
1168        // The merged result picks the external term and surfaces the
1169        // path's declared rate as "effectively unreachable".
1170        let mut p = cfg();
1171        p.max_message_rate = Some(throttle("1000/s"));
1172        let external = EffectiveConstraints {
1173            max_concurrent_dispatchers: EffectiveCeiling {
1174                value: f64::INFINITY,
1175                source: CeilingSource::Other {
1176                    name: "queue config".to_string(),
1177                },
1178                display: "∞".to_string(),
1179            },
1180            max_message_rate: Some(other("scheduled queue max_message_rate", "100/s")),
1181            max_message_rate_declared: None,
1182            max_connection_rate: None,
1183            max_source_selection_rate: None,
1184        };
1185        assert_eq!(
1186            p.compute_constraints(Some(&external)),
1187            EffectiveConstraints {
1188                max_concurrent_dispatchers: primary("32"),
1189                max_message_rate: Some(other("scheduled queue max_message_rate", "100/s")),
1190                max_message_rate_declared: Some("1000/s".to_string()),
1191                max_connection_rate: None,
1192                max_source_selection_rate: None,
1193            }
1194        );
1195    }
1196
1197    #[test]
1198    fn merge_external_does_not_bind() {
1199        // Path config declares max_message_rate = 100/s; external
1200        // declares 1000/s. Path's primary wins; no annotation.
1201        let mut p = cfg();
1202        p.max_message_rate = Some(throttle("100/s"));
1203        let external = EffectiveConstraints {
1204            max_concurrent_dispatchers: EffectiveCeiling {
1205                value: f64::INFINITY,
1206                source: CeilingSource::Other {
1207                    name: "queue config".to_string(),
1208                },
1209                display: "∞".to_string(),
1210            },
1211            max_message_rate: Some(other("scheduled queue max_message_rate", "1000/s")),
1212            max_message_rate_declared: None,
1213            max_connection_rate: None,
1214            max_source_selection_rate: None,
1215        };
1216        assert_eq!(
1217            p.compute_constraints(Some(&external)),
1218            EffectiveConstraints {
1219                max_concurrent_dispatchers: primary("32"),
1220                max_message_rate: Some(primary("100/s")),
1221                max_message_rate_declared: None,
1222                max_connection_rate: None,
1223                max_source_selection_rate: None,
1224            }
1225        );
1226    }
1227
1228    #[test]
1229    fn render_external_wins_with_annotation() {
1230        let mut p = cfg();
1231        p.max_message_rate = Some(throttle("1000/s"));
1232        let external = EffectiveConstraints {
1233            max_concurrent_dispatchers: EffectiveCeiling {
1234                value: f64::INFINITY,
1235                source: CeilingSource::Other {
1236                    name: "queue config".to_string(),
1237                },
1238                display: "∞".to_string(),
1239            },
1240            max_message_rate: Some(other("scheduled queue max_message_rate", "100/s")),
1241            max_message_rate_declared: None,
1242            max_connection_rate: None,
1243            max_source_selection_rate: None,
1244        };
1245        let c = p.compute_constraints(Some(&external));
1246        k9::snapshot!(
1247            c.to_human_string(),
1248            "
1249ceilings:
1250  concurrent dispatchers: 32
1251    source: connection_limit
1252  message rate:           100/s
1253    source: scheduled queue max_message_rate
1254    declared: max_message_rate = 1000/s ← effectively unreachable
1255
1256"
1257        );
1258    }
1259
1260    #[test]
1261    fn source_selection_rate() {
1262        let mut p = cfg();
1263        p.source_selection_rate = Some(throttle("5/s"));
1264        assert_eq!(
1265            p.compute_constraints(None),
1266            EffectiveConstraints {
1267                max_concurrent_dispatchers: primary("32"),
1268                max_message_rate: None,
1269                max_message_rate_declared: None,
1270                max_connection_rate: None,
1271                max_source_selection_rate: Some(primary("5/s")),
1272            }
1273        );
1274    }
1275
1276    #[test]
1277    fn render_defaults() {
1278        let c = cfg().compute_constraints(None);
1279        k9::snapshot!(
1280            c.to_human_string(),
1281            "
1282ceilings:
1283  concurrent dispatchers: 32
1284    source: connection_limit
1285
1286"
1287        );
1288    }
1289
1290    #[test]
1291    fn render_primary_message_rate_no_annotation() {
1292        // max_message_rate is the binding term; no declared-but-
1293        // unreachable annotation should appear.
1294        let mut p = cfg();
1295        p.max_message_rate = Some(throttle("1000/s"));
1296        let c = p.compute_constraints(None);
1297        k9::snapshot!(
1298            c.to_human_string(),
1299            "
1300ceilings:
1301  concurrent dispatchers: 32
1302    source: connection_limit
1303  message rate:           1000/s
1304    source: max_message_rate
1305
1306"
1307        );
1308    }
1309
1310    #[test]
1311    fn render_reconnect_cycling_with_annotation() {
1312        let mut p = cfg();
1313        p.max_message_rate = Some(throttle("1000/s"));
1314        p.max_deliveries_per_connection = 10;
1315        p.max_connection_rate = Some(throttle("10/s"));
1316        let c = p.compute_constraints(None);
1317        k9::snapshot!(
1318            c.to_human_string(),
1319            "
1320ceilings:
1321  concurrent dispatchers: 32
1322    source: connection_limit
1323  message rate:           10 × 10/s = 100/s
1324    source: max_deliveries_per_connection × max_connection_rate
1325    declared: max_message_rate = 1000/s ← effectively unreachable
1326  connection rate:        10/s
1327    source: max_connection_rate
1328
1329"
1330        );
1331    }
1332
1333    #[test]
1334    fn render_additional_throttle_winning() {
1335        let mut p = cfg();
1336        p.max_message_rate = Some(throttle("1000/s"));
1337        p.additional_message_rate_throttles
1338            .insert("provider_cap".to_string(), throttle("250/s"));
1339        let c = p.compute_constraints(None);
1340        k9::snapshot!(
1341            c.to_human_string(),
1342            "
1343ceilings:
1344  concurrent dispatchers: 32
1345    source: connection_limit
1346  message rate:           250/s
1347    source: provider_cap
1348    declared: max_message_rate = 1000/s ← effectively unreachable
1349
1350"
1351        );
1352    }
1353}