1use crate::egress_path::EgressPathConfig;
2#[cfg(feature = "lua")]
3use anyhow::Context;
4#[cfg(feature = "lua")]
5use config::any_err;
6#[cfg(feature = "lua")]
7use config::serialize_options;
8#[cfg(feature = "lua")]
9use dns_resolver::fully_qualify;
10#[cfg(feature = "lua")]
11use kumo_log_types::JsonLogRecord;
12#[cfg(feature = "lua")]
13use mailexchanger::MailExchanger;
14#[cfg(feature = "lua")]
15use mlua::prelude::LuaUserData;
16#[cfg(feature = "lua")]
17use mlua::{LuaSerdeExt, UserDataMethods};
18use ordermap::OrderMap;
19use serde::de::{SeqAccess, Visitor};
20use serde::{Deserialize, Deserializer, Serialize};
21use serde_with::formats::PreferOne;
22use serde_with::{serde_as, DeserializeAs, OneOrMany};
23#[cfg(feature = "lua")]
24use sha2::{Digest, Sha256};
25#[cfg(feature = "lua")]
26use std::collections::BTreeMap;
27use std::hash::{Hash, Hasher};
28use std::marker::PhantomData;
29#[cfg(feature = "lua")]
30use std::sync::Arc;
31use std::time::Duration;
32#[cfg(feature = "lua")]
33use throttle::LimitSpec;
34use throttle::ThrottleSpec;
35
36#[derive(Deserialize, Serialize, Debug, Clone)]
37#[serde(try_from = "String", into = "String")]
38pub struct Regex(fancy_regex::Regex);
39
40impl TryFrom<String> for Regex {
41 type Error = fancy_regex::Error;
42
43 fn try_from(s: String) -> fancy_regex::Result<Self> {
44 Ok(Self(fancy_regex::Regex::new(&s)?))
45 }
46}
47
48impl From<Regex> for String {
49 fn from(r: Regex) -> String {
50 r.0.as_str().to_string()
51 }
52}
53
54impl std::ops::Deref for Regex {
55 type Target = fancy_regex::Regex;
56 fn deref(&self) -> &Self::Target {
57 &self.0
58 }
59}
60
61impl std::hash::Hash for Regex {
62 fn hash<H: std::hash::Hasher>(&self, hasher: &mut H) {
63 self.0.as_str().hash(hasher)
64 }
65}
66
67#[derive(Deserialize, Serialize, Debug, Clone)]
73#[serde(from = "toml::Value", into = "toml::Value")]
74pub struct HashableTomlValue {
75 value: toml::Value,
76}
77
78impl From<toml::Value> for HashableTomlValue {
79 fn from(value: toml::Value) -> Self {
80 Self { value }
81 }
82}
83
84impl From<HashableTomlValue> for toml::Value {
85 fn from(value: HashableTomlValue) -> toml::Value {
86 value.value
87 }
88}
89
90impl std::ops::Deref for HashableTomlValue {
91 type Target = toml::Value;
92 fn deref(&self) -> &toml::Value {
93 &self.value
94 }
95}
96
97fn hash_toml<H>(value: &toml::Value, h: &mut H)
98where
99 H: Hasher,
100{
101 match value {
102 toml::Value::Boolean(v) => v.hash(h),
103 toml::Value::Datetime(v) => {
104 if let Some(d) = &v.date {
105 d.year.hash(h);
106 d.month.hash(h);
107 d.day.hash(h);
108 }
109 if let Some(t) = &v.time {
110 t.hour.hash(h);
111 t.minute.hash(h);
112 t.second.hash(h);
113 t.nanosecond.hash(h);
114 }
115 if let Some(toml::value::Offset::Custom { minutes }) = &v.offset {
116 minutes.hash(h);
117 }
118 }
119 toml::Value::String(v) => v.hash(h),
120 toml::Value::Integer(v) => v.hash(h),
121 toml::Value::Float(v) => v.to_ne_bytes().hash(h),
122 toml::Value::Array(a) => {
123 for v in a.iter() {
124 hash_toml(v, h);
125 }
126 }
127 toml::Value::Table(m) => {
128 for (k, v) in m.iter() {
129 k.hash(h);
130 hash_toml(v, h);
131 }
132 }
133 }
134}
135
136impl Hash for HashableTomlValue {
137 fn hash<H>(&self, h: &mut H)
138 where
139 H: Hasher,
140 {
141 hash_toml(&self.value, h);
142 }
143}
144
145#[derive(Deserialize, Serialize, Debug, Clone, Hash)]
148#[serde(
149 try_from = "EgressPathConfigValueUnchecked",
150 into = "EgressPathConfigValueUnchecked"
151)]
152pub struct EgressPathConfigValue {
153 pub name: String,
154 pub value: HashableTomlValue,
155}
156
157#[derive(Deserialize, Serialize, Debug, Clone)]
163pub struct EgressPathConfigValueUnchecked {
164 pub name: String,
165 pub value: toml::Value,
166}
167
168impl TryFrom<EgressPathConfigValueUnchecked> for EgressPathConfigValue {
169 type Error = anyhow::Error;
170 fn try_from(config: EgressPathConfigValueUnchecked) -> anyhow::Result<EgressPathConfigValue> {
171 let mut map = toml::map::Map::new();
172 map.insert(config.name.clone(), config.value.clone());
173 let table = toml::Value::Table(map);
174
175 EgressPathConfig::deserialize(table)?;
178
179 Ok(EgressPathConfigValue {
181 name: config.name,
182 value: HashableTomlValue {
183 value: config.value,
184 },
185 })
186 }
187}
188
189impl From<EgressPathConfigValue> for EgressPathConfigValueUnchecked {
190 fn from(config: EgressPathConfigValue) -> EgressPathConfigValueUnchecked {
191 EgressPathConfigValueUnchecked {
192 name: config.name,
193 value: config.value.value,
194 }
195 }
196}
197
198#[derive(Deserialize, Serialize, Debug, Clone, Hash)]
199pub enum Action {
200 Suspend,
201 SetConfig(EgressPathConfigValue),
202 SuspendTenant,
203 SuspendCampaign,
204 SetDomainConfig(EgressPathConfigValue),
205 Bounce,
206 BounceTenant,
207 BounceCampaign,
208}
209
210#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash, Default)]
211pub enum Trigger {
212 #[default]
214 Immediate,
215 Threshold(ThrottleSpec),
218}
219
220#[serde_as]
221#[derive(Deserialize, Serialize, Debug, Hash, Clone)]
222pub struct Rule {
223 #[serde(deserialize_with = "regex_string_or_array")]
224 pub regex: Vec<Regex>,
225
226 #[serde(deserialize_with = "one_or_many_action")]
227 pub action: Vec<Action>,
228
229 #[serde(default)]
230 pub trigger: Trigger,
231
232 #[serde(with = "duration_serde")]
233 pub duration: Duration,
234
235 #[serde(skip)]
236 pub was_rollup: bool,
237
238 #[serde(default)]
242 pub match_internal: bool,
243}
244
245impl Rule {
246 pub fn matches(&self, is_internal: bool, response: &str) -> bool {
247 if is_internal && !self.match_internal {
248 return false;
249 }
250 self.regex
251 .iter()
252 .any(|r| r.is_match(response).unwrap_or(false))
253 }
254
255 pub fn clone_and_set_rollup(&self) -> Self {
256 let mut result = self.clone();
257 result.was_rollup = true;
258 result
259 }
260}
261
262#[cfg(feature = "lua")]
263#[derive(Debug, Default)]
264struct ShapingInner {
265 by_site: OrderMap<String, PartialEntry>,
266 by_domain: OrderMap<String, PartialEntry>,
267 by_provider: OrderMap<String, ProviderEntry>,
268 warnings: Vec<String>,
269 errors: Vec<String>,
270 hash: String,
271}
272
273#[cfg(feature = "lua")]
274impl ShapingInner {
275 pub async fn get_egress_path_config(
276 &self,
277 domain: &str,
278 egress_source: &str,
279 site_name: &str,
280 ) -> PartialEntry {
281 let mut params = PartialEntry::default();
282
283 if let Some(default) = self.by_domain.get("default") {
285 params.merge_from(default.clone());
286 }
287
288 let is_domain_name = dns_resolver::Name::from_str_relaxed(domain).is_ok();
293 if is_domain_name {
294 let mut prov_with_sources = vec![];
295
296 for prov in self.by_provider.values() {
297 if prov.domain_matches(domain).await {
298 toml_table_merge_from(&mut params.params, &prov.params);
299 prov.apply_provider_params_to(egress_source, &mut params.params);
300
301 if !prov.sources.is_empty() {
302 prov_with_sources.push(prov);
306 }
307 }
308 }
309
310 for prov in prov_with_sources {
312 if let Some(source) = prov.sources.get(egress_source) {
313 toml_table_merge_from(&mut params.params, source);
314 prov.apply_provider_params_to(egress_source, &mut params.params);
315 }
316 }
317 }
318
319 if let Some(by_site) = self.by_site.get(site_name) {
321 params.merge_from(by_site.clone());
322 }
323
324 if let Some(by_domain) = self.by_domain.get(domain) {
326 params.merge_from(by_domain.clone());
327 }
328
329 if let Some(by_site) = self.by_site.get(site_name) {
331 if let Some(source) = by_site.sources.get(egress_source) {
332 toml_table_merge_from(&mut params.params, source);
333 }
334 }
335
336 if let Some(by_domain) = self.by_domain.get(domain) {
338 if let Some(source) = by_domain.sources.get(egress_source) {
339 toml_table_merge_from(&mut params.params, source);
340 }
341 }
342
343 params
344 }
345
346 pub async fn match_rules(&self, record: &JsonLogRecord) -> anyhow::Result<Vec<Rule>> {
347 use rfc5321::parser::ForwardPath;
348 let recipient = ForwardPath::try_from(
350 record
351 .recipient
352 .first()
353 .ok_or_else(|| anyhow::anyhow!("no recipients!?"))?
354 .as_str(),
355 )
356 .map_err(|err| anyhow::anyhow!("parsing record.recipient: {err}"))?;
357
358 let recipient = match recipient {
359 ForwardPath::Postmaster => {
360 return Ok(vec![]);
363 }
364 ForwardPath::Path(path) => path.mailbox,
365 };
366 let domain = recipient.domain.to_string();
367
368 let source = record.egress_source.as_deref().unwrap_or("unspecified");
370 let site_name = record
383 .site
384 .trim_start_matches(&format!("{source}->"))
385 .trim_end_matches("@smtp_client")
386 .to_string();
387
388 Ok(self.match_rules_impl(record, &domain, &site_name).await)
389 }
390
391 pub async fn match_rules_impl(
392 &self,
393 record: &JsonLogRecord,
394 domain: &str,
395 site_name: &str,
396 ) -> Vec<Rule> {
397 let mut result = vec![];
398 let response = record.response.to_single_line();
399 tracing::trace!("Consider rules for {response}");
400
401 let is_internal = record.response.content.starts_with("KumoMTA internal: ");
402
403 if let Some(default) = self.by_domain.get("default") {
404 for rule in &default.automation {
405 tracing::trace!("Consider \"default\" rule {rule:?} for {response}");
406 if rule.matches(is_internal, &response) {
407 result.push(rule.clone_and_set_rollup());
414 }
415 }
416 }
417
418 for prov in self.by_provider.values() {
419 if prov.domain_matches(domain).await {
420 for rule in &prov.automation {
421 tracing::trace!(
422 "Consider provider \"{}\" rule {rule:?} for {response}",
423 prov.provider_name
424 );
425 if rule.matches(is_internal, &response) {
426 result.push(rule.clone());
427 }
428 }
429 }
430 }
431
432 if let Some(by_site) = self.by_site.get(site_name) {
434 for rule in &by_site.automation {
435 tracing::trace!("Consider \"{site_name}\" rule {rule:?} for {response}");
436 if rule.matches(is_internal, &response) {
437 result.push(rule.clone_and_set_rollup());
438 }
439 }
440 }
441
442 if let Some(by_domain) = self.by_domain.get(domain) {
444 for rule in &by_domain.automation {
445 tracing::trace!("Consider \"{domain}\" rule {rule:?} for {response}");
446 if rule.matches(is_internal, &response) {
447 result.push(rule.clone());
448 }
449 }
450 }
451
452 result
453 }
454}
455
456#[cfg(feature = "lua")]
457#[derive(Debug, Default, Clone, mlua::FromLua)]
458pub struct Shaping {
459 inner: Arc<ShapingInner>,
460}
461
462#[cfg(feature = "lua")]
463fn from_json<'a, T: Deserialize<'a>>(json: &'a str) -> anyhow::Result<T> {
464 let d = &mut serde_json::Deserializer::from_str(json);
465 Ok(serde_path_to_error::deserialize(d)?)
466}
467
468#[cfg(feature = "lua")]
469fn from_toml<'a, T: Deserialize<'a>>(toml: &'a str) -> anyhow::Result<T> {
470 let d = toml::Deserializer::parse(toml)?;
471 Ok(serde_path_to_error::deserialize(d)?)
472}
473
474#[cfg(feature = "lua")]
475#[derive(Default, Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
476pub enum CheckLevel {
477 #[default]
478 Ignore,
479 Warn,
480 Error,
481}
482#[cfg(feature = "lua")]
483impl std::str::FromStr for CheckLevel {
484 type Err = String;
485
486 fn from_str(s: &str) -> Result<Self, String> {
487 if s.eq_ignore_ascii_case("ignore") {
488 Ok(Self::Ignore)
489 } else if s.eq_ignore_ascii_case("warn") {
490 Ok(Self::Warn)
491 } else if s.eq_ignore_ascii_case("error") {
492 Ok(Self::Error)
493 } else {
494 Err(format!(
495 "Expected one of `Ignore`, `Warn` or `Error`, got `{s}`"
496 ))
497 }
498 }
499}
500
501#[cfg(feature = "lua")]
502#[derive(Default)]
503struct Collector {
504 warnings: Vec<String>,
505 errors: Vec<String>,
506}
507
508#[cfg(feature = "lua")]
509impl Collector {
510 fn push<S: Into<String>>(&mut self, level: CheckLevel, msg: S) {
511 match level {
512 CheckLevel::Ignore => {}
513 CheckLevel::Warn => self.warnings.push(msg.into()),
514 CheckLevel::Error => self.errors.push(msg.into()),
515 }
516 }
517}
518
519#[cfg(feature = "lua")]
520#[derive(Debug, Clone, Deserialize)]
521#[serde_as]
522#[serde(deny_unknown_fields)]
523pub struct ShapingMergeOptions {
524 #[serde(default)]
525 pub provider_overlap: CheckLevel,
526 #[serde(default)]
527 pub dns_fail: CheckLevel,
528 #[serde(default)]
529 pub null_mx: CheckLevel,
530 #[serde(default)]
531 pub aliased_site: CheckLevel,
532 #[serde(default)]
533 pub skip_remote: bool,
534 #[serde(default)]
535 pub remote_load: CheckLevel,
536 #[serde(default)]
537 pub local_load: CheckLevel,
538 #[serde(default, with = "duration_serde")]
539 pub http_timeout: Option<Duration>,
540}
541
542#[cfg(feature = "lua")]
543impl Default for ShapingMergeOptions {
544 fn default() -> Self {
545 Self {
546 provider_overlap: CheckLevel::Ignore,
547 dns_fail: CheckLevel::Ignore,
548 null_mx: CheckLevel::Ignore,
549 aliased_site: CheckLevel::Ignore,
550 skip_remote: false,
551 remote_load: CheckLevel::Ignore,
552 local_load: CheckLevel::Error,
553 http_timeout: None,
554 }
555 }
556}
557
558#[cfg(feature = "lua")]
559impl Shaping {
560 async fn load_from_file(
561 path: &str,
562 options: &ShapingMergeOptions,
563 collector: &mut Collector,
564 ) -> anyhow::Result<ShapingFile> {
565 let (data, level): (String, CheckLevel) =
566 if path.starts_with("http://") || path.starts_with("https://") {
567 if options.skip_remote {
568 collector.push(
569 CheckLevel::Warn,
570 format!("Ignoring {path} because skip_remote is set to true"),
571 );
572 return Ok(ShapingFile::default());
573 }
574
575 async fn http_get(url: &str, timeout: Duration) -> anyhow::Result<String> {
581 tokio::time::timeout(timeout, async {
582 let response = reqwest::Client::builder()
583 .timeout(timeout)
584 .connect_timeout(timeout)
585 .read_timeout(timeout)
586 .build()?
587 .get(url)
588 .send()
589 .await
590 .with_context(|| format!("making HTTP request to {url}"))?;
591 let status = response.status();
592 if !status.is_success() {
593 let err_text = match response.text().await {
594 Ok(text) => text,
595 Err(err) => {
596 format!("Additional error {err:#} while reading response body")
597 }
598 };
599 anyhow::bail!(
600 "HTTP request to {url} failed: status {status} {reason} {err_text}",
601 status = status.as_u16(),
602 reason = status.canonical_reason().unwrap_or("")
603 );
604 }
605 response
606 .text()
607 .await
608 .with_context(|| format!("reading text from {url}"))
609 })
610 .await
611 .with_context(|| format!("timeout making HTTP request to {url}"))?
612 }
613
614 let timeout = options.http_timeout.unwrap_or(Duration::from_secs(5));
615
616 match http_get(path, timeout).await {
617 Ok(s) => (s, options.remote_load),
618 Err(err) => {
619 tracing::error!("{err:#}. Ignoring this shaping source for now");
620 collector.push(
621 options.remote_load,
622 format!("remote shaping source {path} error: {err:#}"),
623 );
624 return Ok(ShapingFile::default());
625 }
626 }
627 } else {
628 match std::fs::read_to_string(path)
629 .with_context(|| format!("loading data from file {path}"))
630 {
631 Err(err) => {
632 collector.push(
633 options.local_load,
634 format!("local shaping source {path} error: {err:#}"),
635 );
636 return Ok(ShapingFile::default());
637 }
638 Ok(s) => (s, options.local_load),
639 }
640 };
641
642 if path.ends_with(".toml") {
643 from_toml(&data).with_context(|| format!("parsing toml from file {path}"))
644 } else if path.ends_with(".json") {
645 from_json(&data).with_context(|| format!("parsing json from file {path}"))
646 } else {
647 let mut errors = vec![];
649 match from_toml(&data) {
650 Ok(s) => return Ok(s),
651 Err(err) => errors.push(format!("as toml: {err:#}")),
652 }
653 match from_json(&data) {
654 Ok(s) => return Ok(s),
655 Err(err) => errors.push(format!("as json: {err:#}")),
656 }
657
658 collector.push(level, format!("parsing {path}: {}", errors.join(", ")));
659 Ok(ShapingFile::default())
660 }
661 }
662
663 pub async fn merge_files(
664 files: &[String],
665 options: &ShapingMergeOptions,
666 ) -> anyhow::Result<Self> {
667 use futures_util::stream::FuturesUnordered;
668 use futures_util::StreamExt;
669
670 let mut collector = Collector::default();
671 let mut loaded = vec![];
672 for p in files {
673 loaded.push(Self::load_from_file(p, options, &mut collector).await?);
674 }
675
676 let mut by_site: OrderMap<String, PartialEntry> = OrderMap::new();
677 let mut by_domain: OrderMap<String, PartialEntry> = OrderMap::new();
678 let mut by_provider: OrderMap<String, ProviderEntry> = OrderMap::new();
679 let mut site_aliases: OrderMap<String, Vec<String>> = OrderMap::new();
680
681 let mut mx = std::collections::HashMap::new();
685 let mut lookups = FuturesUnordered::new();
686 for item in &loaded {
687 for (domain, partial) in &item.domains {
688 if partial.mx_rollup {
689 let domain = domain.to_string();
690 lookups.push(tokio::spawn(async move {
691 let mx_result = MailExchanger::resolve(&domain).await;
692 (domain, mx_result)
693 }));
694 }
695 }
696 }
697
698 while let Some(Ok((domain, result))) = lookups.next().await {
699 mx.insert(domain, result);
700 }
701
702 for mut item in loaded {
703 if let Some(mut partial) = item.default.take() {
704 let domain = "default";
705 partial.domain_name.replace(domain.to_string());
706 match by_domain.get_mut(domain) {
707 Some(existing) => {
708 existing.merge_from(partial);
709 }
710 None => {
711 by_domain.insert(domain.to_string(), partial);
712 }
713 }
714 }
715
716 for (domain, mut partial) in item.domains {
717 partial.domain_name.replace(domain.clone());
718
719 if let Ok(name) = fully_qualify(&domain) {
720 if name.num_labels() == 1 {
721 collector.push(
722 CheckLevel::Warn,
723 format!(
724 "Entry for domain '{domain}' consists of a \
725 single DNS label. Domain names in TOML sections \
726 need to be quoted like '[\"{domain}.com\"]` otherwise \
727 the '.' will create a nested table rather than being \
728 added to the domain name."
729 ),
730 );
731 }
732 }
733
734 #[cfg(test)]
735 if partial._treat_domain_name_as_site_name {
736 match by_site.get_mut(&domain) {
737 Some(existing) => {
738 existing.merge_from(partial);
739 }
740 None => {
741 by_site.insert(domain.to_string(), partial);
742 }
743 }
744 continue;
745 }
746 if partial.mx_rollup {
747 let mx = match mx.get(&domain) {
748 Some(Ok(mx)) => mx,
749 Some(Err(err)) => {
750 tracing::debug!(
751 target: "shaping_load",
752 %domain,
753 "dropping domain from shaping: MX resolve failed: {err:#}"
754 );
755 collector.push(
756 options.dns_fail,
757 format!(
758 "error resolving MX for {domain}: {err:#}. \
759 Ignoring the shaping config for that domain."
760 ),
761 );
762 continue;
763 }
764 None => {
765 tracing::debug!(
766 target: "shaping_load",
767 %domain,
768 "dropping domain from shaping: MX was not resolved"
769 );
770 collector.push(
771 options.dns_fail,
772 format!(
773 "We didn't try to resolve the MX for {domain} for some reason!?. \
774 Ignoring the shaping config for that domain."
775 ),
776 );
777 continue;
778 }
779 };
780
781 if mx.site_name.is_empty() {
782 collector.push(
783 options.null_mx,
784 format!(
785 "domain {domain} has a NULL MX and cannot be used with mx_rollup=true. \
786 Ignoring the shaping config for that domain."),
787 );
788 continue;
789 }
790
791 tracing::trace!(
792 target: "shaping_load",
793 %domain,
794 site_name = %mx.site_name,
795 "domain resolved to site_name"
796 );
797
798 site_aliases
799 .entry(mx.site_name.to_string())
800 .or_default()
801 .push(domain.to_string());
802
803 match by_site.get_mut(&mx.site_name) {
804 Some(existing) => {
805 existing.merge_from(partial);
806 }
807 None => {
808 by_site.insert(mx.site_name.clone(), partial);
809 }
810 }
811 } else {
812 match by_domain.get_mut(&domain) {
813 Some(existing) => {
814 existing.merge_from(partial);
815 }
816 None => {
817 by_domain.insert(domain, partial);
818 }
819 }
820 }
821 }
822
823 for (provider, mut prov) in item.provider {
824 prov.provider_name = provider.to_string();
825 match by_provider.get_mut(&provider) {
826 Some(existing) => {
827 existing.merge_from(prov);
828 }
829 None => {
830 by_provider.insert(provider.to_string(), prov);
831 }
832 }
833 }
834 }
835
836 for (site, partial) in &by_site {
837 partial
838 .clone()
839 .finish()
840 .with_context(|| format!("site: {site}"))?;
841 }
842
843 for (domain, partial) in &by_domain {
844 partial
845 .clone()
846 .finish()
847 .with_context(|| format!("domain: {domain}"))?;
848 }
849
850 for (provider, prov) in &by_provider {
851 prov.finish_params()
852 .with_context(|| format!("provider: {provider}"))?;
853 }
854
855 if options.aliased_site != CheckLevel::Ignore {
856 for (site, aliases) in site_aliases {
857 if aliases.len() > 1 {
858 collector.push(
859 options.aliased_site,
860 format!(
861 "multiple domain blocks alias to the same site: {site}: {}",
862 aliases.join(", ")
863 ),
864 );
865 }
866 }
867 }
868
869 if options.provider_overlap != CheckLevel::Ignore {
870 for domain in mx.keys() {
871 let mut matching_providers = vec![];
872 for (prov_name, prov) in &by_provider {
873 if prov.domain_matches(domain).await {
874 matching_providers.push(prov_name.to_string());
875 }
876 }
877 if !matching_providers.is_empty() {
878 collector.push(
879 options.provider_overlap,
880 format!(
881 "domain {domain} is also matched by provider(s): {}",
882 matching_providers.join(", ")
883 ),
884 );
885 }
886 }
887 }
888
889 let mut ctx = Sha256::new();
890 ctx.update("by_site");
891 for (site, entry) in &by_site {
892 ctx.update(site);
893 entry.hash_into(&mut ctx);
894 }
895 ctx.update("by_domain");
896 for (domain, entry) in &by_domain {
897 ctx.update(domain);
898 entry.hash_into(&mut ctx);
899 }
900 ctx.update("by_provider");
901 for (provider, prov) in &by_provider {
902 ctx.update(provider);
903 prov.hash_into(&mut ctx);
904 }
905 ctx.update("warnings");
906 for warn in &collector.warnings {
907 ctx.update(warn);
908 }
909 ctx.update("errors");
910 for err in &collector.errors {
911 ctx.update(err);
912 }
913 let hash = ctx.finalize();
914 let hash = data_encoding::HEXLOWER.encode(&hash);
915
916 tracing::debug!(
917 target: "shaping_load",
918 %hash,
919 sites = by_site.len(),
920 domains = by_domain.len(),
921 providers = by_provider.len(),
922 warnings = collector.warnings.len(),
923 errors = collector.errors.len(),
924 "merged shaping config"
925 );
926
927 Ok(Self {
928 inner: Arc::new(ShapingInner {
929 by_site,
930 by_domain,
931 by_provider,
932 warnings: collector.warnings,
933 errors: collector.errors,
934 hash,
935 }),
936 })
937 }
938
939 async fn get_egress_path_config(
940 &self,
941 domain: &str,
942 egress_source: &str,
943 site_name: &str,
944 ) -> PartialEntry {
945 self.inner
946 .get_egress_path_config(domain, egress_source, site_name)
947 .await
948 }
949
950 pub async fn get_egress_path_config_value(
951 &self,
952 domain: &str,
953 egress_source: &str,
954 site_name: &str,
955 ) -> anyhow::Result<serde_json::Value> {
956 let partial = self
957 .get_egress_path_config(domain, egress_source, site_name)
958 .await;
959 Ok(serde_json::to_value(&partial)?)
960 }
961
962 pub fn get_errors(&self) -> &[String] {
963 &self.inner.errors
964 }
965
966 pub fn get_warnings(&self) -> &[String] {
967 &self.inner.warnings
968 }
969
970 pub async fn match_rules(&self, record: &JsonLogRecord) -> anyhow::Result<Vec<Rule>> {
971 self.inner.match_rules(record).await
972 }
973
974 pub fn get_referenced_sources(&self) -> BTreeMap<String, Vec<String>> {
975 let mut result = BTreeMap::new();
976
977 for (site_name, site) in &self.inner.by_site {
978 for source_name in site.sources.keys() {
979 result
980 .entry(source_name.to_string())
981 .or_insert(vec![])
982 .push(format!("site:{site_name}"));
983 }
984 }
985 for (domain_name, domain) in &self.inner.by_domain {
986 for source_name in domain.sources.keys() {
987 result
988 .entry(source_name.to_string())
989 .or_insert(vec![])
990 .push(format!("domain:{domain_name}"));
991 }
992 }
993
994 result
995 }
996
997 pub fn hash(&self) -> String {
998 self.inner.hash.clone()
999 }
1000}
1001
1002#[cfg(feature = "lua")]
1003impl LuaUserData for Shaping {
1004 fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
1005 mod_memoize::Memoized::impl_memoize(methods);
1006 methods.add_async_method(
1007 "get_egress_path_config",
1008 |lua, this, (domain, egress_source, site_name): (String, String, String)| async move {
1009 let params = this
1010 .get_egress_path_config(&domain, &egress_source, &site_name)
1011 .await;
1012 lua.to_value_with(¶ms.params, serialize_options())
1013 },
1014 );
1015
1016 methods.add_method("get_errors", move |_lua, this, ()| {
1017 let errors: Vec<String> = this.get_errors().iter().map(|s| s.to_string()).collect();
1018 Ok(errors)
1019 });
1020
1021 methods.add_method("get_warnings", move |_lua, this, ()| {
1022 let warnings: Vec<String> = this.get_warnings().iter().map(|s| s.to_string()).collect();
1023 Ok(warnings)
1024 });
1025
1026 methods.add_method("get_referenced_sources", move |_lua, this, ()| {
1027 Ok(this.get_referenced_sources())
1028 });
1029
1030 methods.add_async_method("match_rules", |lua, this, record: mlua::Value| async move {
1031 let record: JsonLogRecord = lua.from_value(record)?;
1032 let rules = this.match_rules(&record).await.map_err(any_err)?;
1033 let mut result = vec![];
1034 for rule in rules {
1035 result.push(lua.to_value(&rule)?);
1036 }
1037 Ok(result)
1038 });
1039
1040 methods.add_method("hash", move |_, this, ()| Ok(this.hash()));
1041 }
1042}
1043
1044#[derive(Default, Debug)]
1045pub struct MergedEntry {
1046 pub params: EgressPathConfig,
1047 pub sources: OrderMap<String, EgressPathConfig>,
1048 pub automation: Vec<Rule>,
1049}
1050
1051#[cfg(feature = "lua")]
1052#[derive(Deserialize, Serialize, Debug, Clone, Default)]
1053struct ShapingFile {
1054 pub default: Option<PartialEntry>,
1055 #[serde(flatten, default)]
1056 pub domains: OrderMap<String, PartialEntry>,
1057 #[serde(default)]
1058 pub provider: OrderMap<String, ProviderEntry>,
1059}
1060
1061#[cfg(feature = "lua")]
1062#[derive(Deserialize, Serialize, Debug, Clone, Default)]
1063struct PartialEntry {
1064 #[serde(skip)]
1065 pub domain_name: Option<String>,
1066
1067 #[serde(flatten)]
1068 pub params: toml::Table,
1069
1070 #[serde(default = "default_true")]
1071 pub mx_rollup: bool,
1072
1073 #[cfg(test)]
1077 #[serde(default)]
1078 pub _treat_domain_name_as_site_name: bool,
1079
1080 #[serde(default)]
1081 pub replace_base: bool,
1082
1083 #[serde(default)]
1084 pub automation: Vec<Rule>,
1085
1086 #[serde(default)]
1087 pub sources: OrderMap<String, toml::Table>,
1088}
1089
1090#[cfg(feature = "lua")]
1091#[derive(Deserialize, Serialize, Debug, Clone, Default)]
1092pub struct ProviderEntry {
1093 #[serde(skip, default)]
1094 pub provider_name: String,
1095
1096 #[serde(default)]
1097 pub provider_connection_limit: Option<LimitSpec>,
1098
1099 #[serde(default)]
1100 pub provider_max_message_rate: Option<ThrottleSpec>,
1101
1102 #[serde(default, rename = "match")]
1103 pub matches: Vec<ProviderMatch>,
1104
1105 #[serde(default)]
1106 pub replace_base: bool,
1107
1108 #[serde(flatten)]
1109 pub params: toml::Table,
1110
1111 #[serde(default)]
1112 pub automation: Vec<Rule>,
1113
1114 #[serde(default)]
1115 pub sources: OrderMap<String, toml::Table>,
1116}
1117
1118#[cfg(feature = "lua")]
1119fn suffix_matches(candidate: &str, suffix: &str) -> bool {
1120 let candidate = candidate.strip_suffix(".").unwrap_or(candidate);
1123 candidate.ends_with(suffix)
1127}
1128
1129#[cfg(feature = "lua")]
1130#[cfg(test)]
1131#[test]
1132fn test_suffix_matches() {
1133 assert!(suffix_matches("a", "a"));
1134 assert!(suffix_matches("foo.com", "foo.com"));
1135 assert!(!suffix_matches("foo.com", ".foo.com"));
1136 assert!(!suffix_matches("foo.com", "longer.com"));
1137 assert!(!suffix_matches("réputation.net", ".mx.microsoft"));
1138
1139 assert!(!suffix_matches("foo.Com", ".com"));
1143 assert!(!suffix_matches("foo.Cam", ".com"));
1144}
1145
1146#[cfg(feature = "lua")]
1147fn host_matches(candidate: &str, name: &str) -> bool {
1148 let candidate = candidate.strip_suffix(".").unwrap_or(candidate);
1151 candidate == name
1152}
1153
1154#[cfg(feature = "lua")]
1155#[cfg(test)]
1156#[test]
1157fn test_host_matches() {
1158 assert!(host_matches("foo.com", "foo.com"));
1159 assert!(host_matches("foo.com.", "foo.com"));
1160 assert!(!host_matches("foo.com", "notfoo.com"));
1161}
1162
1163#[cfg(feature = "lua")]
1164impl ProviderEntry {
1165 async fn domain_matches(&self, domain: &str) -> bool {
1166 let mut need_mx = false;
1169
1170 tracing::trace!(
1171 "ProviderEntry::domain_matches({domain}) vs {:?}",
1172 self.matches
1173 );
1174
1175 for rule in &self.matches {
1176 match rule {
1177 ProviderMatch::DomainSuffix(suffix) => {
1178 if suffix_matches(domain, suffix) {
1179 tracing::trace!("{domain} suffix matches {suffix}");
1180 return true;
1181 }
1182 }
1183 ProviderMatch::HostName(_) | ProviderMatch::MXSuffix(_) => {
1184 need_mx = true;
1185 }
1186 }
1187 }
1188
1189 if !need_mx {
1190 return false;
1191 }
1192
1193 match MailExchanger::resolve(domain).await {
1195 Err(err) => {
1196 tracing::trace!(
1202 "Error resolving MX for {domain}: {err:#}. \
1203 Provider {} match rules will be ignored",
1204 self.provider_name
1205 );
1206 false
1207 }
1208 Ok(mx) => {
1209 tracing::trace!("Consider MXSuffix rules");
1210 for host in &mx.hosts {
1211 let mut matched = false;
1212
1213 for rule in &self.matches {
1214 match rule {
1215 ProviderMatch::MXSuffix(suffix) => {
1216 tracing::trace!("suffix={suffix} vs host {host}");
1221 if suffix_matches(host, suffix) {
1222 matched = true;
1223 break;
1224 }
1225 }
1226 ProviderMatch::HostName(name) => {
1227 if host_matches(host, name) {
1228 matched = true;
1229 break;
1230 }
1231 }
1232 ProviderMatch::DomainSuffix(_) => {}
1233 }
1234 }
1235
1236 if !matched {
1237 tracing::trace!("host didn't match any of these rules");
1238 return false;
1239 }
1240 }
1241
1242 true
1243 }
1244 }
1245 }
1246
1247 fn merge_from(&mut self, mut other: Self) {
1248 if other.replace_base {
1249 self.provider_connection_limit = other.provider_connection_limit;
1250 self.matches = other.matches;
1251 self.params = other.params;
1252 self.sources = other.sources;
1253 self.automation = other.automation;
1254 } else {
1255 if other.provider_connection_limit.is_some() {
1256 self.provider_connection_limit = other.provider_connection_limit;
1257 }
1258
1259 toml_table_merge_from(&mut self.params, &other.params);
1260
1261 for (source, tbl) in other.sources {
1262 match self.sources.get_mut(&source) {
1263 Some(existing) => {
1264 toml_table_merge_from(existing, &tbl);
1265 }
1266 None => {
1267 self.sources.insert(source, tbl);
1268 }
1269 }
1270 }
1271
1272 self.matches.append(&mut other.matches);
1273 self.automation.append(&mut other.automation);
1274 }
1275 }
1276
1277 fn apply_provider_params_to(&self, source: &str, target: &mut toml::Table) {
1278 let mut implied = toml::Table::new();
1279 implied.insert(
1280 "provider_name".to_string(),
1281 toml::Value::String(self.provider_name.to_string()),
1282 );
1283
1284 if let Some(limit) = &self.provider_connection_limit {
1285 let mut limits = toml::Table::new();
1286 limits.insert(
1287 format!("shaping-provider-{}-{source}-limit", self.provider_name),
1288 toml::Value::String(limit.to_string()),
1289 );
1290 implied.insert(
1291 "additional_connection_limits".to_string(),
1292 toml::Value::Table(limits),
1293 );
1294 }
1295 if let Some(rate) = &self.provider_max_message_rate {
1296 let rate = rate.as_string();
1297 let mut limits = toml::Table::new();
1298 limits.insert(
1299 format!("shaping-provider-{}-{source}-rate", self.provider_name),
1300 rate.into(),
1301 );
1302 implied.insert(
1303 "additional_message_rate_throttles".to_string(),
1304 toml::Value::Table(limits),
1305 );
1306 }
1307
1308 if let Some(rate) = target.remove("provider_source_selection_rate") {
1309 let mut limits = toml::Table::new();
1310 limits.insert(
1311 format!(
1312 "shaping-provider-{}-{source}-selection-rate",
1313 self.provider_name
1314 ),
1315 rate,
1316 );
1317 implied.insert(
1318 "additional_source_selection_rates".to_string(),
1319 toml::Value::Table(limits),
1320 );
1321 }
1322
1323 toml_table_merge_from(target, &implied);
1324 }
1325
1326 fn finish_params(&self) -> anyhow::Result<MergedEntry> {
1327 let provider_name = &self.provider_name;
1328
1329 let params = EgressPathConfig::deserialize(self.params.clone()).with_context(|| {
1330 format!(
1331 "interpreting provider '{provider_name}' params {:#?} as EgressPathConfig",
1332 self.params
1333 )
1334 })?;
1335 let mut sources = OrderMap::new();
1336
1337 for (source, params) in &self.sources {
1338 let mut params = params.clone();
1339 params.remove("provider_source_selection_rate");
1345 sources.insert(
1346 source.clone(),
1347 EgressPathConfig::deserialize(params.clone()).with_context(|| {
1348 format!("interpreting provider '{provider_name}' source '{source}' {params:#} as EgressPathConfig")
1349 })?,
1350 );
1351 }
1352
1353 Ok(MergedEntry {
1354 params,
1355 sources,
1356 automation: self.automation.clone(),
1357 })
1358 }
1359
1360 fn hash_into(&self, ctx: &mut Sha256) {
1361 ctx.update(&self.provider_name);
1362 ctx.update(serde_json::to_string(self).unwrap_or_else(|_| String::new()));
1363 }
1364}
1365
1366#[cfg(feature = "lua")]
1367#[derive(Deserialize, Serialize, Debug, Clone)]
1368pub enum ProviderMatch {
1369 MXSuffix(String),
1370 DomainSuffix(String),
1371 HostName(String),
1372}
1373
1374#[cfg(feature = "lua")]
1375fn toml_table_merge_from(tbl: &mut toml::Table, source: &toml::Table) {
1376 fn is_mergeable(name: &str) -> bool {
1379 match name {
1380 "additional_connection_limits"
1381 | "additional_message_rate_throttles"
1382 | "additional_source_selection_rates" => true,
1383 _ => false,
1384 }
1385 }
1386
1387 for (k, v) in source {
1388 match (tbl.get_mut(k), v.as_table()) {
1389 (Some(toml::Value::Table(existing)), Some(v)) if is_mergeable(k) => {
1391 for (inner_k, inner_v) in v {
1392 existing.insert(inner_k.clone(), inner_v.clone());
1393 }
1394 }
1395 _ => {
1396 tbl.insert(k.clone(), v.clone());
1397 }
1398 }
1399 }
1400}
1401
1402#[cfg(feature = "lua")]
1403impl PartialEntry {
1404 fn merge_from(&mut self, mut other: Self) {
1405 if other.replace_base {
1406 self.params = other.params;
1407 self.automation = other.automation;
1408 self.sources = other.sources;
1409 } else {
1410 toml_table_merge_from(&mut self.params, &other.params);
1411
1412 for (source, tbl) in other.sources {
1413 match self.sources.get_mut(&source) {
1414 Some(existing) => {
1415 toml_table_merge_from(existing, &tbl);
1416 }
1417 None => {
1418 self.sources.insert(source, tbl);
1419 }
1420 }
1421 }
1422
1423 self.automation.append(&mut other.automation);
1424 }
1425 }
1426
1427 fn finish(self) -> anyhow::Result<MergedEntry> {
1428 let domain = self.domain_name.unwrap_or_default();
1429
1430 let params = EgressPathConfig::deserialize(self.params.clone()).with_context(|| {
1431 format!(
1432 "interpreting domain '{domain}' params {:#?} as EgressPathConfig",
1433 self.params
1434 )
1435 })?;
1436 let mut sources = OrderMap::new();
1437
1438 for (source, params) in self.sources {
1439 sources.insert(
1440 source.clone(),
1441 EgressPathConfig::deserialize(params.clone()).with_context(|| {
1442 format!("interpreting domain '{domain}' source '{source}' {params:#} as EgressPathConfig")
1443 })?,
1444 );
1445 }
1446
1447 Ok(MergedEntry {
1448 params,
1449 sources,
1450 automation: self.automation,
1451 })
1452 }
1453
1454 fn hash_into(&self, ctx: &mut Sha256) {
1455 if let Some(name) = self.domain_name.as_ref() {
1456 ctx.update(name)
1457 }
1458 ctx.update(serde_json::to_string(self).unwrap_or_else(|_| String::new()));
1459 }
1460}
1461
1462fn one_or_many<'de, T, D>(deserializer: D, expecting: &str) -> Result<Vec<T>, D::Error>
1463where
1464 T: Deserialize<'de>,
1465 D: Deserializer<'de>,
1466{
1467 let result: Result<Vec<T>, _> =
1468 OneOrMany::<serde_with::Same, PreferOne>::deserialize_as(deserializer);
1469 match result {
1470 Ok(r) => Ok(r),
1471 Err(err) => Err(serde::de::Error::custom(format!(
1472 "{expecting}.\nThe underlying error message is:\n{err:#}"
1473 ))),
1474 }
1475}
1476
1477fn one_or_many_action<'de, D>(deserializer: D) -> Result<Vec<Action>, D::Error>
1478where
1479 D: Deserializer<'de>,
1480{
1481 one_or_many(
1482 deserializer,
1483 "\"action\" field expected either a single Action or an array of Actions",
1484 )
1485}
1486
1487fn regex_string_or_array<'de, D>(deserializer: D) -> Result<Vec<Regex>, D::Error>
1488where
1489 D: Deserializer<'de>,
1490{
1491 string_or_array(
1492 deserializer,
1493 "regex string or array of regex strings for field regex",
1494 )
1495}
1496
1497fn string_or_array<'de, T, D>(deserializer: D, expecting: &'static str) -> Result<Vec<T>, D::Error>
1498where
1499 T: Deserialize<'de> + TryFrom<String>,
1500 <T as TryFrom<String>>::Error: std::fmt::Debug,
1501 D: Deserializer<'de>,
1502{
1503 struct StringOrArray<T>(PhantomData<fn() -> T>, &'static str);
1509
1510 impl<'de, T> Visitor<'de> for StringOrArray<T>
1511 where
1512 T: Deserialize<'de> + TryFrom<String>,
1513 <T as TryFrom<String>>::Error: std::fmt::Debug,
1514 {
1515 type Value = Vec<T>;
1516
1517 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
1518 formatter.write_str(self.1)
1519 }
1520
1521 fn visit_str<E>(self, value: &str) -> Result<Vec<T>, E>
1522 where
1523 E: serde::de::Error,
1524 {
1525 Ok(vec![
1526 T::try_from(value.to_string()).map_err(|e| E::custom(format!("{e:?}")))?
1527 ])
1528 }
1529
1530 fn visit_seq<S>(self, seq: S) -> Result<Vec<T>, S::Error>
1531 where
1532 S: SeqAccess<'de>,
1533 {
1534 Deserialize::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))
1535 }
1536 }
1537
1538 deserializer.deserialize_any(StringOrArray(PhantomData, expecting))
1539}
1540
1541#[cfg(feature = "lua")]
1542fn default_true() -> bool {
1543 true
1544}
1545
1546#[cfg(feature = "lua")]
1547pub fn register(lua: &mlua::Lua) -> anyhow::Result<()> {
1548 let shaping_mod = config::get_or_create_sub_module(lua, "shaping")?;
1549
1550 shaping_mod.set(
1551 "load",
1552 lua.create_async_function(
1553 move |lua, (paths, options): (Vec<String>, Option<mlua::Value>)| async move {
1554 let options = match options {
1555 Some(v) => lua.from_value(v)?,
1556 None => Default::default(),
1557 };
1558 let shaping = Shaping::merge_files(&paths, &options)
1559 .await
1560 .map_err(any_err)?;
1561 Ok(shaping)
1562 },
1563 )?,
1564 )?;
1565
1566 Ok(())
1567}
1568
1569#[cfg(test)]
1570mod test {
1571 use super::*;
1572 use kumo_log_types::RecordType;
1573 use rfc5321::Response;
1574 use std::io::Write;
1575 use tempfile::NamedTempFile;
1576 use uuid::Uuid;
1577
1578 async fn make_shaping_configs(inputs: &[&str]) -> Shaping {
1579 let mut files = vec![];
1580 let mut file_names = vec![];
1581
1582 for (i, content) in inputs.iter().enumerate() {
1583 let mut shaping_file = NamedTempFile::with_prefix(format!("file{i}")).unwrap();
1584 shaping_file.write_all(content.as_bytes()).unwrap();
1585 file_names.push(shaping_file.path().to_str().unwrap().to_string());
1586 files.push(shaping_file);
1587 }
1588
1589 Shaping::merge_files(&file_names, &ShapingMergeOptions::default())
1590 .await
1591 .unwrap()
1592 }
1593
1594 #[tokio::test]
1595 async fn test_merge_additional() {
1596 let shaping = make_shaping_configs(&[
1597 r#"
1598["example.com"]
1599mx_rollup = false
1600additional_connection_limits = {"first"=10}
1601 "#,
1602 r#"
1603["example.com"]
1604mx_rollup = false
1605additional_connection_limits = {"second"=32}
1606additional_message_rate_throttles = {"second"="100/hr"}
1607 "#,
1608 ])
1609 .await;
1610
1611 let resolved = shaping
1612 .get_egress_path_config("example.com", "invalid.source", "invalid.site")
1613 .await
1614 .finish()
1615 .unwrap();
1616
1617 k9::snapshot!(
1618 resolved.params.additional_connection_limits,
1619 r#"
1620{
1621 "first": 10,
1622 "second": 32,
1623}
1624"#
1625 );
1626 k9::snapshot!(
1627 resolved.params.additional_message_rate_throttles,
1628 r#"
1629{
1630 "second": 100/h,
1631}
1632"#
1633 );
1634 }
1635
1636 #[tokio::test]
1637 async fn test_provider_multi_hostname() {
1638 let shaping = make_shaping_configs(&[r#"
1639[provider."yahoo"]
1640match=[{HostName="mta5.am0.yahoodns.net"},{HostName="mta6.am0.yahoodns.net"},{HostName="mta7.am0.yahoodns.net"}]
1641enable_tls = "Required"
1642 "#])
1643 .await;
1644
1645 let resolved = shaping
1646 .get_egress_path_config("yahoo.com", "invalid.source", "invalid.site")
1647 .await
1648 .finish()
1649 .unwrap();
1650
1651 k9::assert_equal!(
1652 resolved.params.enable_tls,
1653 crate::egress_path::Tls::Required
1654 );
1655 k9::assert_equal!(resolved.params.provider_name.unwrap(), "yahoo");
1656 }
1657
1658 #[tokio::test]
1659 async fn test_provider_multi_suffix() {
1660 let shaping = make_shaping_configs(&[r#"
1661[provider."yahoo"]
1662match=[{MXSuffix="mta5.am0.yahoodns.net"},{MXSuffix="mta6.am0.yahoodns.net"},{MXSuffix="mta7.am0.yahoodns.net"}]
1663enable_tls = "Required"
1664 "#])
1665 .await;
1666
1667 let resolved = shaping
1668 .get_egress_path_config("yahoo.com", "invalid.source", "invalid.site")
1669 .await
1670 .finish()
1671 .unwrap();
1672
1673 k9::assert_equal!(
1674 resolved.params.enable_tls,
1675 crate::egress_path::Tls::Required
1676 );
1677 k9::assert_equal!(resolved.params.provider_name.unwrap(), "yahoo");
1678 }
1679
1680 #[tokio::test]
1681 async fn test_provider() {
1682 let shaping = make_shaping_configs(&[r#"
1683[provider."Office 365"]
1684match=[{MXSuffix=".olc.protection.outlook.com"},{DomainSuffix=".outlook.com"}]
1685enable_tls = "Required"
1686provider_connection_limit = 10
1687provider_max_message_rate = "120/s"
1688
1689[provider."Office 365".sources."new-source"]
1690provider_source_selection_rate = "500/d,max_burst=1"
1691 "#])
1692 .await;
1693
1694 let resolved = shaping
1695 .get_egress_path_config("outlook.com", "invalid.source", "invalid.site")
1696 .await
1697 .finish()
1698 .unwrap();
1699
1700 k9::assert_equal!(
1701 resolved.params.enable_tls,
1702 crate::egress_path::Tls::Required
1703 );
1704 k9::assert_equal!(resolved.params.provider_name.unwrap(), "Office 365");
1705
1706 k9::snapshot!(
1707 resolved.params.additional_connection_limits,
1708 r#"
1709{
1710 "shaping-provider-Office 365-invalid.source-limit": 10,
1711}
1712"#
1713 );
1714 k9::snapshot!(
1715 resolved.params.additional_message_rate_throttles,
1716 r#"
1717{
1718 "shaping-provider-Office 365-invalid.source-rate": 120/s,
1719}
1720"#
1721 );
1722 assert!(resolved.params.source_selection_rate.is_none());
1723 assert!(resolved.params.additional_source_selection_rates.is_empty());
1724
1725 let resolved = shaping
1726 .get_egress_path_config("outlook.com", "new-source", "invalid.site")
1727 .await
1728 .finish()
1729 .unwrap();
1730 assert!(resolved.params.source_selection_rate.is_none());
1731 k9::snapshot!(
1732 resolved.params.additional_source_selection_rates,
1733 r#"
1734{
1735 "shaping-provider-Office 365-new-source-selection-rate": 500/d,max_burst=1,
1736}
1737"#
1738 );
1739 }
1740
1741 #[tokio::test]
1742 async fn test_rule_matching() {
1743 let shaping = make_shaping_configs(&[r#"
1744[["default".automation]]
1745regex="default"
1746action = {SetConfig={name="connection_limit", value=1}}
1747duration = "1hr"
1748
1749["fake.site"]
1750_treat_domain_name_as_site_name = true
1751
1752[["fake.site".automation]]
1753regex="fake_rollup"
1754action = {SetConfig={name="connection_limit", value=2}}
1755duration = "1hr"
1756
1757["woot.provider"]
1758mx_rollup = false
1759
1760[["woot.provider".automation]]
1761regex="woot_domain"
1762action = {SetConfig={name="connection_limit", value=2}}
1763duration = "1hr"
1764
1765[provider."provider"]
1766match=[{DomainSuffix=".provider"}]
1767
1768[[provider."provider".automation]]
1769regex="provider"
1770action = {SetConfig={name="connection_limit", value=3}}
1771duration = "1hr"
1772match_internal = true
1773
1774"#])
1775 .await;
1776
1777 eprintln!("{:?}", shaping.inner.warnings);
1778
1779 fn make_record(content: &str, recipient: &str, site: &str) -> JsonLogRecord {
1780 JsonLogRecord {
1781 kind: RecordType::TransientFailure,
1782 id: String::new(),
1783 sender: String::new(),
1784 recipient: vec![recipient.to_string()],
1785 queue: String::new(),
1786 site: site.to_string(),
1787 size: 0,
1788 response: Response {
1789 code: 400,
1790 command: None,
1791 enhanced_code: None,
1792 content: content.to_string(),
1793 },
1794 peer_address: None,
1795 timestamp: Default::default(),
1796 created: Default::default(),
1797 num_attempts: 1,
1798 bounce_classification: Default::default(),
1799 egress_pool: None,
1800 egress_source: None,
1801 source_address: None,
1802 feedback_report: None,
1803 meta: Default::default(),
1804 headers: Default::default(),
1805 delivery_protocol: None,
1806 reception_protocol: None,
1807 nodeid: Uuid::default(),
1808 tls_cipher: None,
1809 tls_protocol_version: None,
1810 tls_peer_subject_name: None,
1811 provider_name: None,
1812 session_id: None,
1813 }
1814 }
1815
1816 let matches = shaping
1817 .match_rules(&make_record("default", "user@example.com", "dummy_site"))
1818 .await
1819 .unwrap();
1820 k9::assert_equal!(
1821 matches[0].regex[0].to_string(),
1822 "default",
1823 "matches against default automation rule"
1824 );
1825
1826 let matches = shaping
1827 .match_rules(&make_record(
1828 "KumoMTA internal: default",
1829 "user@example.com",
1830 "dummy_site",
1831 ))
1832 .await
1833 .unwrap();
1834 assert!(matches.is_empty(), "internal bounce should not match");
1835
1836 let matches = shaping
1837 .match_rules(&make_record(
1838 "woot_domain",
1839 "user@woot.provider",
1840 "dummy_site",
1841 ))
1842 .await
1843 .unwrap();
1844 k9::assert_equal!(
1845 matches[0].regex[0].to_string(),
1846 "woot_domain",
1847 "matches against domain rule with mx_rollup=false"
1848 );
1849
1850 let matches = shaping
1851 .match_rules(&make_record("fake_rollup", "user@fake.rollup", "fake.site"))
1852 .await
1853 .unwrap();
1854 k9::assert_equal!(
1855 matches[0].regex[0].to_string(),
1856 "fake_rollup",
1857 "matches against domain rule with mx_rollup=true"
1858 );
1859
1860 let matches = shaping
1861 .match_rules(&make_record("provider", "user@woot.provider", "dummy_site"))
1862 .await
1863 .unwrap();
1864 k9::assert_equal!(
1865 matches[0].regex[0].to_string(),
1866 "provider",
1867 "matches against provider rule"
1868 );
1869
1870 let matches = shaping
1871 .match_rules(&make_record(
1872 "KumoMTA internal: provider",
1873 "user@woot.provider",
1874 "dummy_site",
1875 ))
1876 .await
1877 .unwrap();
1878 k9::assert_equal!(
1879 matches[0].regex[0].to_string(),
1880 "provider",
1881 "internal response matches against provider rule"
1882 );
1883 }
1884
1885 #[tokio::test]
1886 async fn test_defaults() {
1887 let shaping = make_shaping_configs(&[
1888 r#"
1889["default"]
1890connection_limit = 10
1891max_connection_rate = "100/min"
1892max_deliveries_per_connection = 100
1893max_message_rate = "100/s"
1894idle_timeout = "60s"
1895data_timeout = "30s"
1896data_dot_timeout = "60s"
1897enable_tls = "Opportunistic"
1898consecutive_connection_failures_before_delay = 100
1899
1900[["default".automation]]
1901regex=[
1902 '/Messages from \d+\.\d+\.\d+\.\d+ temporarily deferred/',
1903 '/All messages from \d+\.\d+\.\d+\.\d+ will be permanently deferred/',
1904 '/has been temporarily rate limited due to IP reputation/',
1905 '/Unfortunately, messages from \d+\.\d+\.\d+\.\d+ weren.t sent/',
1906 '/Server busy\. Please try again later from/'
1907]
1908action = [
1909 {SetConfig={name="max_message_rate", value="1/minute"}},
1910 {SetConfig={name="connection_limit", value=1}}
1911]
1912duration = "90m"
1913
1914[["default".automation]]
1915regex="KumoMTA internal: failed to connect to any candidate hosts: All failures are related to OpportunisticInsecure STARTTLS. Consider setting enable_tls=Disabled for this site"
1916action = {SetConfig={name="enable_tls", value="Disabled"}}
1917duration = "30 days"
1918
1919["gmail.com"]
1920max_deliveries_per_connection = 50
1921connection_limit = 5
1922enable_tls = "Required"
1923consecutive_connection_failures_before_delay = 5
1924
1925["yahoo.com"]
1926max_deliveries_per_connection = 20
1927
1928[["yahoo.com".automation]]
1929regex = "\\[TS04\\]"
1930action = "Suspend"
1931duration = "2 hours"
1932
1933["comcast.net"]
1934connection_limit = 25
1935max_deliveries_per_connection = 250
1936enable_tls = "Required"
1937idle_timeout = "30s"
1938consecutive_connection_failures_before_delay = 24
1939
1940["mail.com"]
1941max_deliveries_per_connection = 100
1942
1943["orange.fr"]
1944connection_limit = 3
1945
1946["smtp.mailgun.com"]
1947connection_limit = 7000
1948max_deliveries_per_connection = 3
1949
1950["example.com"]
1951mx_rollup = false
1952max_deliveries_per_connection = 100
1953connection_limit = 3
1954
1955["example.com".sources."my source name"]
1956connection_limit = 5
1957 "#,
1958 ])
1959 .await;
1960
1961 let default = shaping
1962 .get_egress_path_config("invalid.domain", "invalid.source", "invalid.site")
1963 .await
1964 .finish()
1965 .unwrap();
1966 k9::snapshot!(
1967 default,
1968 r#"
1969MergedEntry {
1970 params: EgressPathConfig {
1971 connection_limit: 10,
1972 additional_connection_limits: {},
1973 enable_tls: Opportunistic,
1974 enable_mta_sts: true,
1975 enable_dane: false,
1976 enable_pipelining: true,
1977 enable_rset: true,
1978 tls_prefer_openssl: false,
1979 tls_certificate: None,
1980 tls_private_key: None,
1981 openssl_cipher_list: None,
1982 openssl_cipher_suites: None,
1983 openssl_options: None,
1984 rustls_cipher_suites: [],
1985 client_timeouts: SmtpClientTimeouts {
1986 connect_timeout: 60s,
1987 banner_timeout: 60s,
1988 ehlo_timeout: 300s,
1989 mail_from_timeout: 300s,
1990 rcpt_to_timeout: 300s,
1991 data_timeout: 30s,
1992 data_dot_timeout: 60s,
1993 rset_timeout: 5s,
1994 idle_timeout: 60s,
1995 starttls_timeout: 5s,
1996 auth_timeout: 60s,
1997 },
1998 system_shutdown_timeout: None,
1999 max_ready: 1024,
2000 consecutive_connection_failures_before_delay: 100,
2001 smtp_port: 25,
2002 smtp_auth_plain_username: None,
2003 smtp_auth_plain_password: None,
2004 allow_smtp_auth_plain_without_tls: false,
2005 allow_smtp_auth_plain_without_valid_certificate: false,
2006 max_message_rate: Some(
2007 100/s,
2008 ),
2009 additional_message_rate_throttles: {},
2010 source_selection_rate: None,
2011 additional_source_selection_rates: {},
2012 max_connection_rate: Some(
2013 100/m,
2014 ),
2015 max_deliveries_per_connection: 100,
2016 max_recipients_per_batch: 100,
2017 prohibited_hosts: {
2018 "0.0.0.0",
2019 "127.0.0.0/8",
2020 "::/127",
2021 },
2022 skip_hosts: {},
2023 ip_lookup_strategy: Ipv4AndIpv6,
2024 ehlo_domain: None,
2025 aggressive_connection_opening: false,
2026 refresh_interval: 60s,
2027 refresh_strategy: Ttl,
2028 dispatcher_wakeup_strategy: Aggressive,
2029 maintainer_wakeup_strategy: Aggressive,
2030 provider_name: None,
2031 remember_broken_tls: None,
2032 opportunistic_tls_reconnect_on_failed_handshake: false,
2033 use_lmtp: false,
2034 reconnect_strategy: ConnectNextHost,
2035 readyq_pool_name: None,
2036 low_memory_reduction_policy: ShrinkDataAndMeta,
2037 no_memory_reduction_policy: ShrinkDataAndMeta,
2038 try_next_host_on_transport_error: false,
2039 ignore_8bit_checks: false,
2040 dispatcher_progress_watchdog_timeout: None,
2041 },
2042 sources: {},
2043 automation: [
2044 Rule {
2045 regex: [
2046 Regex(
2047 /Messages from \d+\.\d+\.\d+\.\d+ temporarily deferred/,
2048 ),
2049 Regex(
2050 /All messages from \d+\.\d+\.\d+\.\d+ will be permanently deferred/,
2051 ),
2052 Regex(
2053 /has been temporarily rate limited due to IP reputation/,
2054 ),
2055 Regex(
2056 /Unfortunately, messages from \d+\.\d+\.\d+\.\d+ weren.t sent/,
2057 ),
2058 Regex(
2059 /Server busy\. Please try again later from/,
2060 ),
2061 ],
2062 action: [
2063 SetConfig(
2064 EgressPathConfigValue {
2065 name: "max_message_rate",
2066 value: HashableTomlValue {
2067 value: String(
2068 "1/minute",
2069 ),
2070 },
2071 },
2072 ),
2073 SetConfig(
2074 EgressPathConfigValue {
2075 name: "connection_limit",
2076 value: HashableTomlValue {
2077 value: Integer(
2078 1,
2079 ),
2080 },
2081 },
2082 ),
2083 ],
2084 trigger: Immediate,
2085 duration: 5400s,
2086 was_rollup: false,
2087 match_internal: false,
2088 },
2089 Rule {
2090 regex: [
2091 Regex(
2092 KumoMTA internal: failed to connect to any candidate hosts: All failures are related to OpportunisticInsecure STARTTLS. Consider setting enable_tls=Disabled for this site,
2093 ),
2094 ],
2095 action: [
2096 SetConfig(
2097 EgressPathConfigValue {
2098 name: "enable_tls",
2099 value: HashableTomlValue {
2100 value: String(
2101 "Disabled",
2102 ),
2103 },
2104 },
2105 ),
2106 ],
2107 trigger: Immediate,
2108 duration: 2592000s,
2109 was_rollup: false,
2110 match_internal: false,
2111 },
2112 ],
2113}
2114"#
2115 );
2116
2117 let example_com = shaping
2118 .get_egress_path_config("example.com", "invalid.source", "invalid.site")
2119 .await
2120 .finish()
2121 .unwrap();
2122 k9::snapshot!(
2123 example_com,
2124 r#"
2125MergedEntry {
2126 params: EgressPathConfig {
2127 connection_limit: 3,
2128 additional_connection_limits: {},
2129 enable_tls: Opportunistic,
2130 enable_mta_sts: true,
2131 enable_dane: false,
2132 enable_pipelining: true,
2133 enable_rset: true,
2134 tls_prefer_openssl: false,
2135 tls_certificate: None,
2136 tls_private_key: None,
2137 openssl_cipher_list: None,
2138 openssl_cipher_suites: None,
2139 openssl_options: None,
2140 rustls_cipher_suites: [],
2141 client_timeouts: SmtpClientTimeouts {
2142 connect_timeout: 60s,
2143 banner_timeout: 60s,
2144 ehlo_timeout: 300s,
2145 mail_from_timeout: 300s,
2146 rcpt_to_timeout: 300s,
2147 data_timeout: 30s,
2148 data_dot_timeout: 60s,
2149 rset_timeout: 5s,
2150 idle_timeout: 60s,
2151 starttls_timeout: 5s,
2152 auth_timeout: 60s,
2153 },
2154 system_shutdown_timeout: None,
2155 max_ready: 1024,
2156 consecutive_connection_failures_before_delay: 100,
2157 smtp_port: 25,
2158 smtp_auth_plain_username: None,
2159 smtp_auth_plain_password: None,
2160 allow_smtp_auth_plain_without_tls: false,
2161 allow_smtp_auth_plain_without_valid_certificate: false,
2162 max_message_rate: Some(
2163 100/s,
2164 ),
2165 additional_message_rate_throttles: {},
2166 source_selection_rate: None,
2167 additional_source_selection_rates: {},
2168 max_connection_rate: Some(
2169 100/m,
2170 ),
2171 max_deliveries_per_connection: 100,
2172 max_recipients_per_batch: 100,
2173 prohibited_hosts: {
2174 "0.0.0.0",
2175 "127.0.0.0/8",
2176 "::/127",
2177 },
2178 skip_hosts: {},
2179 ip_lookup_strategy: Ipv4AndIpv6,
2180 ehlo_domain: None,
2181 aggressive_connection_opening: false,
2182 refresh_interval: 60s,
2183 refresh_strategy: Ttl,
2184 dispatcher_wakeup_strategy: Aggressive,
2185 maintainer_wakeup_strategy: Aggressive,
2186 provider_name: None,
2187 remember_broken_tls: None,
2188 opportunistic_tls_reconnect_on_failed_handshake: false,
2189 use_lmtp: false,
2190 reconnect_strategy: ConnectNextHost,
2191 readyq_pool_name: None,
2192 low_memory_reduction_policy: ShrinkDataAndMeta,
2193 no_memory_reduction_policy: ShrinkDataAndMeta,
2194 try_next_host_on_transport_error: false,
2195 ignore_8bit_checks: false,
2196 dispatcher_progress_watchdog_timeout: None,
2197 },
2198 sources: {
2199 "my source name": EgressPathConfig {
2200 connection_limit: 5,
2201 additional_connection_limits: {},
2202 enable_tls: Opportunistic,
2203 enable_mta_sts: true,
2204 enable_dane: false,
2205 enable_pipelining: true,
2206 enable_rset: true,
2207 tls_prefer_openssl: false,
2208 tls_certificate: None,
2209 tls_private_key: None,
2210 openssl_cipher_list: None,
2211 openssl_cipher_suites: None,
2212 openssl_options: None,
2213 rustls_cipher_suites: [],
2214 client_timeouts: SmtpClientTimeouts {
2215 connect_timeout: 60s,
2216 banner_timeout: 60s,
2217 ehlo_timeout: 300s,
2218 mail_from_timeout: 300s,
2219 rcpt_to_timeout: 300s,
2220 data_timeout: 300s,
2221 data_dot_timeout: 300s,
2222 rset_timeout: 5s,
2223 idle_timeout: 5s,
2224 starttls_timeout: 5s,
2225 auth_timeout: 60s,
2226 },
2227 system_shutdown_timeout: None,
2228 max_ready: 1024,
2229 consecutive_connection_failures_before_delay: 100,
2230 smtp_port: 25,
2231 smtp_auth_plain_username: None,
2232 smtp_auth_plain_password: None,
2233 allow_smtp_auth_plain_without_tls: false,
2234 allow_smtp_auth_plain_without_valid_certificate: false,
2235 max_message_rate: None,
2236 additional_message_rate_throttles: {},
2237 source_selection_rate: None,
2238 additional_source_selection_rates: {},
2239 max_connection_rate: None,
2240 max_deliveries_per_connection: 1024,
2241 max_recipients_per_batch: 100,
2242 prohibited_hosts: {
2243 "0.0.0.0",
2244 "127.0.0.0/8",
2245 "::/127",
2246 },
2247 skip_hosts: {},
2248 ip_lookup_strategy: Ipv4AndIpv6,
2249 ehlo_domain: None,
2250 aggressive_connection_opening: false,
2251 refresh_interval: 60s,
2252 refresh_strategy: Ttl,
2253 dispatcher_wakeup_strategy: Aggressive,
2254 maintainer_wakeup_strategy: Aggressive,
2255 provider_name: None,
2256 remember_broken_tls: None,
2257 opportunistic_tls_reconnect_on_failed_handshake: false,
2258 use_lmtp: false,
2259 reconnect_strategy: ConnectNextHost,
2260 readyq_pool_name: None,
2261 low_memory_reduction_policy: ShrinkDataAndMeta,
2262 no_memory_reduction_policy: ShrinkDataAndMeta,
2263 try_next_host_on_transport_error: false,
2264 ignore_8bit_checks: false,
2265 dispatcher_progress_watchdog_timeout: None,
2266 },
2267 },
2268 automation: [
2269 Rule {
2270 regex: [
2271 Regex(
2272 /Messages from \d+\.\d+\.\d+\.\d+ temporarily deferred/,
2273 ),
2274 Regex(
2275 /All messages from \d+\.\d+\.\d+\.\d+ will be permanently deferred/,
2276 ),
2277 Regex(
2278 /has been temporarily rate limited due to IP reputation/,
2279 ),
2280 Regex(
2281 /Unfortunately, messages from \d+\.\d+\.\d+\.\d+ weren.t sent/,
2282 ),
2283 Regex(
2284 /Server busy\. Please try again later from/,
2285 ),
2286 ],
2287 action: [
2288 SetConfig(
2289 EgressPathConfigValue {
2290 name: "max_message_rate",
2291 value: HashableTomlValue {
2292 value: String(
2293 "1/minute",
2294 ),
2295 },
2296 },
2297 ),
2298 SetConfig(
2299 EgressPathConfigValue {
2300 name: "connection_limit",
2301 value: HashableTomlValue {
2302 value: Integer(
2303 1,
2304 ),
2305 },
2306 },
2307 ),
2308 ],
2309 trigger: Immediate,
2310 duration: 5400s,
2311 was_rollup: false,
2312 match_internal: false,
2313 },
2314 Rule {
2315 regex: [
2316 Regex(
2317 KumoMTA internal: failed to connect to any candidate hosts: All failures are related to OpportunisticInsecure STARTTLS. Consider setting enable_tls=Disabled for this site,
2318 ),
2319 ],
2320 action: [
2321 SetConfig(
2322 EgressPathConfigValue {
2323 name: "enable_tls",
2324 value: HashableTomlValue {
2325 value: String(
2326 "Disabled",
2327 ),
2328 },
2329 },
2330 ),
2331 ],
2332 trigger: Immediate,
2333 duration: 2592000s,
2334 was_rollup: false,
2335 match_internal: false,
2336 },
2337 ],
2338}
2339"#
2340 );
2341
2342 let yahoo_com = shaping
2345 .get_egress_path_config(
2346 "yahoo.com",
2347 "invalid.source",
2348 "(mta5|mta6|mta7).am0.yahoodns.net",
2349 )
2350 .await
2351 .finish()
2352 .unwrap();
2353 k9::snapshot!(
2354 yahoo_com,
2355 r#"
2356MergedEntry {
2357 params: EgressPathConfig {
2358 connection_limit: 10,
2359 additional_connection_limits: {},
2360 enable_tls: Opportunistic,
2361 enable_mta_sts: true,
2362 enable_dane: false,
2363 enable_pipelining: true,
2364 enable_rset: true,
2365 tls_prefer_openssl: false,
2366 tls_certificate: None,
2367 tls_private_key: None,
2368 openssl_cipher_list: None,
2369 openssl_cipher_suites: None,
2370 openssl_options: None,
2371 rustls_cipher_suites: [],
2372 client_timeouts: SmtpClientTimeouts {
2373 connect_timeout: 60s,
2374 banner_timeout: 60s,
2375 ehlo_timeout: 300s,
2376 mail_from_timeout: 300s,
2377 rcpt_to_timeout: 300s,
2378 data_timeout: 30s,
2379 data_dot_timeout: 60s,
2380 rset_timeout: 5s,
2381 idle_timeout: 60s,
2382 starttls_timeout: 5s,
2383 auth_timeout: 60s,
2384 },
2385 system_shutdown_timeout: None,
2386 max_ready: 1024,
2387 consecutive_connection_failures_before_delay: 100,
2388 smtp_port: 25,
2389 smtp_auth_plain_username: None,
2390 smtp_auth_plain_password: None,
2391 allow_smtp_auth_plain_without_tls: false,
2392 allow_smtp_auth_plain_without_valid_certificate: false,
2393 max_message_rate: Some(
2394 100/s,
2395 ),
2396 additional_message_rate_throttles: {},
2397 source_selection_rate: None,
2398 additional_source_selection_rates: {},
2399 max_connection_rate: Some(
2400 100/m,
2401 ),
2402 max_deliveries_per_connection: 20,
2403 max_recipients_per_batch: 100,
2404 prohibited_hosts: {
2405 "0.0.0.0",
2406 "127.0.0.0/8",
2407 "::/127",
2408 },
2409 skip_hosts: {},
2410 ip_lookup_strategy: Ipv4AndIpv6,
2411 ehlo_domain: None,
2412 aggressive_connection_opening: false,
2413 refresh_interval: 60s,
2414 refresh_strategy: Ttl,
2415 dispatcher_wakeup_strategy: Aggressive,
2416 maintainer_wakeup_strategy: Aggressive,
2417 provider_name: None,
2418 remember_broken_tls: None,
2419 opportunistic_tls_reconnect_on_failed_handshake: false,
2420 use_lmtp: false,
2421 reconnect_strategy: ConnectNextHost,
2422 readyq_pool_name: None,
2423 low_memory_reduction_policy: ShrinkDataAndMeta,
2424 no_memory_reduction_policy: ShrinkDataAndMeta,
2425 try_next_host_on_transport_error: false,
2426 ignore_8bit_checks: false,
2427 dispatcher_progress_watchdog_timeout: None,
2428 },
2429 sources: {},
2430 automation: [
2431 Rule {
2432 regex: [
2433 Regex(
2434 /Messages from \d+\.\d+\.\d+\.\d+ temporarily deferred/,
2435 ),
2436 Regex(
2437 /All messages from \d+\.\d+\.\d+\.\d+ will be permanently deferred/,
2438 ),
2439 Regex(
2440 /has been temporarily rate limited due to IP reputation/,
2441 ),
2442 Regex(
2443 /Unfortunately, messages from \d+\.\d+\.\d+\.\d+ weren.t sent/,
2444 ),
2445 Regex(
2446 /Server busy\. Please try again later from/,
2447 ),
2448 ],
2449 action: [
2450 SetConfig(
2451 EgressPathConfigValue {
2452 name: "max_message_rate",
2453 value: HashableTomlValue {
2454 value: String(
2455 "1/minute",
2456 ),
2457 },
2458 },
2459 ),
2460 SetConfig(
2461 EgressPathConfigValue {
2462 name: "connection_limit",
2463 value: HashableTomlValue {
2464 value: Integer(
2465 1,
2466 ),
2467 },
2468 },
2469 ),
2470 ],
2471 trigger: Immediate,
2472 duration: 5400s,
2473 was_rollup: false,
2474 match_internal: false,
2475 },
2476 Rule {
2477 regex: [
2478 Regex(
2479 KumoMTA internal: failed to connect to any candidate hosts: All failures are related to OpportunisticInsecure STARTTLS. Consider setting enable_tls=Disabled for this site,
2480 ),
2481 ],
2482 action: [
2483 SetConfig(
2484 EgressPathConfigValue {
2485 name: "enable_tls",
2486 value: HashableTomlValue {
2487 value: String(
2488 "Disabled",
2489 ),
2490 },
2491 },
2492 ),
2493 ],
2494 trigger: Immediate,
2495 duration: 2592000s,
2496 was_rollup: false,
2497 match_internal: false,
2498 },
2499 Rule {
2500 regex: [
2501 Regex(
2502 \[TS04\],
2503 ),
2504 ],
2505 action: [
2506 Suspend,
2507 ],
2508 trigger: Immediate,
2509 duration: 7200s,
2510 was_rollup: false,
2511 match_internal: false,
2512 },
2513 ],
2514}
2515"#
2516 );
2517 }
2518
2519 #[tokio::test]
2520 async fn test_load_default_shaping_toml() {
2521 Shaping::merge_files(
2522 &["../../assets/policy-extras/shaping.toml".into()],
2523 &ShapingMergeOptions::default(),
2524 )
2525 .await
2526 .unwrap();
2527 }
2528}