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 collector.push(
751 options.dns_fail,
752 format!(
753 "error resolving MX for {domain}: {err:#}. \
754 Ignoring the shaping config for that domain."
755 ),
756 );
757 continue;
758 }
759 None => {
760 collector.push(
761 options.dns_fail,
762 format!(
763 "We didn't try to resolve the MX for {domain} for some reason!?. \
764 Ignoring the shaping config for that domain."
765 ),
766 );
767 continue;
768 }
769 };
770
771 if mx.site_name.is_empty() {
772 collector.push(
773 options.null_mx,
774 format!(
775 "domain {domain} has a NULL MX and cannot be used with mx_rollup=true. \
776 Ignoring the shaping config for that domain."),
777 );
778 continue;
779 }
780
781 site_aliases
782 .entry(mx.site_name.to_string())
783 .or_default()
784 .push(domain.to_string());
785
786 match by_site.get_mut(&mx.site_name) {
787 Some(existing) => {
788 existing.merge_from(partial);
789 }
790 None => {
791 by_site.insert(mx.site_name.clone(), partial);
792 }
793 }
794 } else {
795 match by_domain.get_mut(&domain) {
796 Some(existing) => {
797 existing.merge_from(partial);
798 }
799 None => {
800 by_domain.insert(domain, partial);
801 }
802 }
803 }
804 }
805
806 for (provider, mut prov) in item.provider {
807 prov.provider_name = provider.to_string();
808 match by_provider.get_mut(&provider) {
809 Some(existing) => {
810 existing.merge_from(prov);
811 }
812 None => {
813 by_provider.insert(provider.to_string(), prov);
814 }
815 }
816 }
817 }
818
819 for (site, partial) in &by_site {
820 partial
821 .clone()
822 .finish()
823 .with_context(|| format!("site: {site}"))?;
824 }
825
826 for (domain, partial) in &by_domain {
827 partial
828 .clone()
829 .finish()
830 .with_context(|| format!("domain: {domain}"))?;
831 }
832
833 for (provider, prov) in &by_provider {
834 prov.finish_params()
835 .with_context(|| format!("provider: {provider}"))?;
836 }
837
838 if options.aliased_site != CheckLevel::Ignore {
839 for (site, aliases) in site_aliases {
840 if aliases.len() > 1 {
841 collector.push(
842 options.aliased_site,
843 format!(
844 "multiple domain blocks alias to the same site: {site}: {}",
845 aliases.join(", ")
846 ),
847 );
848 }
849 }
850 }
851
852 if options.provider_overlap != CheckLevel::Ignore {
853 for domain in mx.keys() {
854 let mut matching_providers = vec![];
855 for (prov_name, prov) in &by_provider {
856 if prov.domain_matches(domain).await {
857 matching_providers.push(prov_name.to_string());
858 }
859 }
860 if !matching_providers.is_empty() {
861 collector.push(
862 options.provider_overlap,
863 format!(
864 "domain {domain} is also matched by provider(s): {}",
865 matching_providers.join(", ")
866 ),
867 );
868 }
869 }
870 }
871
872 let mut ctx = Sha256::new();
873 ctx.update("by_site");
874 for (site, entry) in &by_site {
875 ctx.update(site);
876 entry.hash_into(&mut ctx);
877 }
878 ctx.update("by_domain");
879 for (domain, entry) in &by_domain {
880 ctx.update(domain);
881 entry.hash_into(&mut ctx);
882 }
883 ctx.update("by_provider");
884 for (provider, prov) in &by_provider {
885 ctx.update(provider);
886 prov.hash_into(&mut ctx);
887 }
888 ctx.update("warnings");
889 for warn in &collector.warnings {
890 ctx.update(warn);
891 }
892 ctx.update("errors");
893 for err in &collector.errors {
894 ctx.update(err);
895 }
896 let hash = ctx.finalize();
897 let hash = data_encoding::HEXLOWER.encode(&hash);
898
899 Ok(Self {
900 inner: Arc::new(ShapingInner {
901 by_site,
902 by_domain,
903 by_provider,
904 warnings: collector.warnings,
905 errors: collector.errors,
906 hash,
907 }),
908 })
909 }
910
911 async fn get_egress_path_config(
912 &self,
913 domain: &str,
914 egress_source: &str,
915 site_name: &str,
916 ) -> PartialEntry {
917 self.inner
918 .get_egress_path_config(domain, egress_source, site_name)
919 .await
920 }
921
922 pub async fn get_egress_path_config_value(
923 &self,
924 domain: &str,
925 egress_source: &str,
926 site_name: &str,
927 ) -> anyhow::Result<serde_json::Value> {
928 let partial = self
929 .get_egress_path_config(domain, egress_source, site_name)
930 .await;
931 Ok(serde_json::to_value(&partial)?)
932 }
933
934 pub fn get_errors(&self) -> &[String] {
935 &self.inner.errors
936 }
937
938 pub fn get_warnings(&self) -> &[String] {
939 &self.inner.warnings
940 }
941
942 pub async fn match_rules(&self, record: &JsonLogRecord) -> anyhow::Result<Vec<Rule>> {
943 self.inner.match_rules(record).await
944 }
945
946 pub fn get_referenced_sources(&self) -> BTreeMap<String, Vec<String>> {
947 let mut result = BTreeMap::new();
948
949 for (site_name, site) in &self.inner.by_site {
950 for source_name in site.sources.keys() {
951 result
952 .entry(source_name.to_string())
953 .or_insert(vec![])
954 .push(format!("site:{site_name}"));
955 }
956 }
957 for (domain_name, domain) in &self.inner.by_domain {
958 for source_name in domain.sources.keys() {
959 result
960 .entry(source_name.to_string())
961 .or_insert(vec![])
962 .push(format!("domain:{domain_name}"));
963 }
964 }
965
966 result
967 }
968
969 pub fn hash(&self) -> String {
970 self.inner.hash.clone()
971 }
972}
973
974#[cfg(feature = "lua")]
975impl LuaUserData for Shaping {
976 fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
977 mod_memoize::Memoized::impl_memoize(methods);
978 methods.add_async_method(
979 "get_egress_path_config",
980 |lua, this, (domain, egress_source, site_name): (String, String, String)| async move {
981 let params = this
982 .get_egress_path_config(&domain, &egress_source, &site_name)
983 .await;
984 lua.to_value_with(¶ms.params, serialize_options())
985 },
986 );
987
988 methods.add_method("get_errors", move |_lua, this, ()| {
989 let errors: Vec<String> = this.get_errors().iter().map(|s| s.to_string()).collect();
990 Ok(errors)
991 });
992
993 methods.add_method("get_warnings", move |_lua, this, ()| {
994 let warnings: Vec<String> = this.get_warnings().iter().map(|s| s.to_string()).collect();
995 Ok(warnings)
996 });
997
998 methods.add_method("get_referenced_sources", move |_lua, this, ()| {
999 Ok(this.get_referenced_sources())
1000 });
1001
1002 methods.add_async_method("match_rules", |lua, this, record: mlua::Value| async move {
1003 let record: JsonLogRecord = lua.from_value(record)?;
1004 let rules = this.match_rules(&record).await.map_err(any_err)?;
1005 let mut result = vec![];
1006 for rule in rules {
1007 result.push(lua.to_value(&rule)?);
1008 }
1009 Ok(result)
1010 });
1011
1012 methods.add_method("hash", move |_, this, ()| Ok(this.hash()));
1013 }
1014}
1015
1016#[derive(Default, Debug)]
1017pub struct MergedEntry {
1018 pub params: EgressPathConfig,
1019 pub sources: OrderMap<String, EgressPathConfig>,
1020 pub automation: Vec<Rule>,
1021}
1022
1023#[cfg(feature = "lua")]
1024#[derive(Deserialize, Serialize, Debug, Clone, Default)]
1025struct ShapingFile {
1026 pub default: Option<PartialEntry>,
1027 #[serde(flatten, default)]
1028 pub domains: OrderMap<String, PartialEntry>,
1029 #[serde(default)]
1030 pub provider: OrderMap<String, ProviderEntry>,
1031}
1032
1033#[cfg(feature = "lua")]
1034#[derive(Deserialize, Serialize, Debug, Clone, Default)]
1035struct PartialEntry {
1036 #[serde(skip)]
1037 pub domain_name: Option<String>,
1038
1039 #[serde(flatten)]
1040 pub params: toml::Table,
1041
1042 #[serde(default = "default_true")]
1043 pub mx_rollup: bool,
1044
1045 #[cfg(test)]
1049 #[serde(default)]
1050 pub _treat_domain_name_as_site_name: bool,
1051
1052 #[serde(default)]
1053 pub replace_base: bool,
1054
1055 #[serde(default)]
1056 pub automation: Vec<Rule>,
1057
1058 #[serde(default)]
1059 pub sources: OrderMap<String, toml::Table>,
1060}
1061
1062#[cfg(feature = "lua")]
1063#[derive(Deserialize, Serialize, Debug, Clone, Default)]
1064pub struct ProviderEntry {
1065 #[serde(skip, default)]
1066 pub provider_name: String,
1067
1068 #[serde(default)]
1069 pub provider_connection_limit: Option<LimitSpec>,
1070
1071 #[serde(default)]
1072 pub provider_max_message_rate: Option<ThrottleSpec>,
1073
1074 #[serde(default, rename = "match")]
1075 pub matches: Vec<ProviderMatch>,
1076
1077 #[serde(default)]
1078 pub replace_base: bool,
1079
1080 #[serde(flatten)]
1081 pub params: toml::Table,
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")]
1091fn suffix_matches(candidate: &str, suffix: &str) -> bool {
1092 let candidate = candidate.strip_suffix(".").unwrap_or(candidate);
1095 candidate.ends_with(suffix)
1099}
1100
1101#[cfg(feature = "lua")]
1102#[cfg(test)]
1103#[test]
1104fn test_suffix_matches() {
1105 assert!(suffix_matches("a", "a"));
1106 assert!(suffix_matches("foo.com", "foo.com"));
1107 assert!(!suffix_matches("foo.com", ".foo.com"));
1108 assert!(!suffix_matches("foo.com", "longer.com"));
1109 assert!(!suffix_matches("réputation.net", ".mx.microsoft"));
1110
1111 assert!(!suffix_matches("foo.Com", ".com"));
1115 assert!(!suffix_matches("foo.Cam", ".com"));
1116}
1117
1118#[cfg(feature = "lua")]
1119fn host_matches(candidate: &str, name: &str) -> bool {
1120 let candidate = candidate.strip_suffix(".").unwrap_or(candidate);
1123 candidate == name
1124}
1125
1126#[cfg(feature = "lua")]
1127#[cfg(test)]
1128#[test]
1129fn test_host_matches() {
1130 assert!(host_matches("foo.com", "foo.com"));
1131 assert!(host_matches("foo.com.", "foo.com"));
1132 assert!(!host_matches("foo.com", "notfoo.com"));
1133}
1134
1135#[cfg(feature = "lua")]
1136impl ProviderEntry {
1137 async fn domain_matches(&self, domain: &str) -> bool {
1138 let mut need_mx = false;
1141
1142 tracing::trace!(
1143 "ProviderEntry::domain_matches({domain}) vs {:?}",
1144 self.matches
1145 );
1146
1147 for rule in &self.matches {
1148 match rule {
1149 ProviderMatch::DomainSuffix(suffix) => {
1150 if suffix_matches(domain, suffix) {
1151 tracing::trace!("{domain} suffix matches {suffix}");
1152 return true;
1153 }
1154 }
1155 ProviderMatch::HostName(_) | ProviderMatch::MXSuffix(_) => {
1156 need_mx = true;
1157 }
1158 }
1159 }
1160
1161 if !need_mx {
1162 return false;
1163 }
1164
1165 match MailExchanger::resolve(domain).await {
1167 Err(err) => {
1168 tracing::trace!(
1174 "Error resolving MX for {domain}: {err:#}. \
1175 Provider {} match rules will be ignored",
1176 self.provider_name
1177 );
1178 false
1179 }
1180 Ok(mx) => {
1181 tracing::trace!("Consider MXSuffix rules");
1182 for host in &mx.hosts {
1183 let mut matched = false;
1184
1185 for rule in &self.matches {
1186 match rule {
1187 ProviderMatch::MXSuffix(suffix) => {
1188 tracing::trace!("suffix={suffix} vs host {host}");
1193 if suffix_matches(host, suffix) {
1194 matched = true;
1195 break;
1196 }
1197 }
1198 ProviderMatch::HostName(name) => {
1199 if host_matches(host, name) {
1200 matched = true;
1201 break;
1202 }
1203 }
1204 ProviderMatch::DomainSuffix(_) => {}
1205 }
1206 }
1207
1208 if !matched {
1209 tracing::trace!("host didn't match any of these rules");
1210 return false;
1211 }
1212 }
1213
1214 true
1215 }
1216 }
1217 }
1218
1219 fn merge_from(&mut self, mut other: Self) {
1220 if other.replace_base {
1221 self.provider_connection_limit = other.provider_connection_limit;
1222 self.matches = other.matches;
1223 self.params = other.params;
1224 self.sources = other.sources;
1225 self.automation = other.automation;
1226 } else {
1227 if other.provider_connection_limit.is_some() {
1228 self.provider_connection_limit = other.provider_connection_limit;
1229 }
1230
1231 toml_table_merge_from(&mut self.params, &other.params);
1232
1233 for (source, tbl) in other.sources {
1234 match self.sources.get_mut(&source) {
1235 Some(existing) => {
1236 toml_table_merge_from(existing, &tbl);
1237 }
1238 None => {
1239 self.sources.insert(source, tbl);
1240 }
1241 }
1242 }
1243
1244 self.matches.append(&mut other.matches);
1245 self.automation.append(&mut other.automation);
1246 }
1247 }
1248
1249 fn apply_provider_params_to(&self, source: &str, target: &mut toml::Table) {
1250 let mut implied = toml::Table::new();
1251 implied.insert(
1252 "provider_name".to_string(),
1253 toml::Value::String(self.provider_name.to_string()),
1254 );
1255
1256 if let Some(limit) = &self.provider_connection_limit {
1257 let mut limits = toml::Table::new();
1258 limits.insert(
1259 format!("shaping-provider-{}-{source}-limit", self.provider_name),
1260 toml::Value::String(limit.to_string()),
1261 );
1262 implied.insert(
1263 "additional_connection_limits".to_string(),
1264 toml::Value::Table(limits),
1265 );
1266 }
1267 if let Some(rate) = &self.provider_max_message_rate {
1268 let rate = rate.as_string();
1269 let mut limits = toml::Table::new();
1270 limits.insert(
1271 format!("shaping-provider-{}-{source}-rate", self.provider_name),
1272 rate.into(),
1273 );
1274 implied.insert(
1275 "additional_message_rate_throttles".to_string(),
1276 toml::Value::Table(limits),
1277 );
1278 }
1279
1280 if let Some(rate) = target.remove("provider_source_selection_rate") {
1281 let mut limits = toml::Table::new();
1282 limits.insert(
1283 format!(
1284 "shaping-provider-{}-{source}-selection-rate",
1285 self.provider_name
1286 ),
1287 rate,
1288 );
1289 implied.insert(
1290 "additional_source_selection_rates".to_string(),
1291 toml::Value::Table(limits),
1292 );
1293 }
1294
1295 toml_table_merge_from(target, &implied);
1296 }
1297
1298 fn finish_params(&self) -> anyhow::Result<MergedEntry> {
1299 let provider_name = &self.provider_name;
1300
1301 let params = EgressPathConfig::deserialize(self.params.clone()).with_context(|| {
1302 format!(
1303 "interpreting provider '{provider_name}' params {:#?} as EgressPathConfig",
1304 self.params
1305 )
1306 })?;
1307 let mut sources = OrderMap::new();
1308
1309 for (source, params) in &self.sources {
1310 let mut params = params.clone();
1311 params.remove("provider_source_selection_rate");
1317 sources.insert(
1318 source.clone(),
1319 EgressPathConfig::deserialize(params.clone()).with_context(|| {
1320 format!("interpreting provider '{provider_name}' source '{source}' {params:#} as EgressPathConfig")
1321 })?,
1322 );
1323 }
1324
1325 Ok(MergedEntry {
1326 params,
1327 sources,
1328 automation: self.automation.clone(),
1329 })
1330 }
1331
1332 fn hash_into(&self, ctx: &mut Sha256) {
1333 ctx.update(&self.provider_name);
1334 ctx.update(serde_json::to_string(self).unwrap_or_else(|_| String::new()));
1335 }
1336}
1337
1338#[cfg(feature = "lua")]
1339#[derive(Deserialize, Serialize, Debug, Clone)]
1340pub enum ProviderMatch {
1341 MXSuffix(String),
1342 DomainSuffix(String),
1343 HostName(String),
1344}
1345
1346#[cfg(feature = "lua")]
1347fn toml_table_merge_from(tbl: &mut toml::Table, source: &toml::Table) {
1348 fn is_mergeable(name: &str) -> bool {
1351 match name {
1352 "additional_connection_limits"
1353 | "additional_message_rate_throttles"
1354 | "additional_source_selection_rates" => true,
1355 _ => false,
1356 }
1357 }
1358
1359 for (k, v) in source {
1360 match (tbl.get_mut(k), v.as_table()) {
1361 (Some(toml::Value::Table(existing)), Some(v)) if is_mergeable(k) => {
1363 for (inner_k, inner_v) in v {
1364 existing.insert(inner_k.clone(), inner_v.clone());
1365 }
1366 }
1367 _ => {
1368 tbl.insert(k.clone(), v.clone());
1369 }
1370 }
1371 }
1372}
1373
1374#[cfg(feature = "lua")]
1375impl PartialEntry {
1376 fn merge_from(&mut self, mut other: Self) {
1377 if other.replace_base {
1378 self.params = other.params;
1379 self.automation = other.automation;
1380 self.sources = other.sources;
1381 } else {
1382 toml_table_merge_from(&mut self.params, &other.params);
1383
1384 for (source, tbl) in other.sources {
1385 match self.sources.get_mut(&source) {
1386 Some(existing) => {
1387 toml_table_merge_from(existing, &tbl);
1388 }
1389 None => {
1390 self.sources.insert(source, tbl);
1391 }
1392 }
1393 }
1394
1395 self.automation.append(&mut other.automation);
1396 }
1397 }
1398
1399 fn finish(self) -> anyhow::Result<MergedEntry> {
1400 let domain = self.domain_name.unwrap_or_default();
1401
1402 let params = EgressPathConfig::deserialize(self.params.clone()).with_context(|| {
1403 format!(
1404 "interpreting domain '{domain}' params {:#?} as EgressPathConfig",
1405 self.params
1406 )
1407 })?;
1408 let mut sources = OrderMap::new();
1409
1410 for (source, params) in self.sources {
1411 sources.insert(
1412 source.clone(),
1413 EgressPathConfig::deserialize(params.clone()).with_context(|| {
1414 format!("interpreting domain '{domain}' source '{source}' {params:#} as EgressPathConfig")
1415 })?,
1416 );
1417 }
1418
1419 Ok(MergedEntry {
1420 params,
1421 sources,
1422 automation: self.automation,
1423 })
1424 }
1425
1426 fn hash_into(&self, ctx: &mut Sha256) {
1427 if let Some(name) = self.domain_name.as_ref() {
1428 ctx.update(name)
1429 }
1430 ctx.update(serde_json::to_string(self).unwrap_or_else(|_| String::new()));
1431 }
1432}
1433
1434fn one_or_many<'de, T, D>(deserializer: D, expecting: &str) -> Result<Vec<T>, D::Error>
1435where
1436 T: Deserialize<'de>,
1437 D: Deserializer<'de>,
1438{
1439 let result: Result<Vec<T>, _> =
1440 OneOrMany::<serde_with::Same, PreferOne>::deserialize_as(deserializer);
1441 match result {
1442 Ok(r) => Ok(r),
1443 Err(err) => Err(serde::de::Error::custom(format!(
1444 "{expecting}.\nThe underlying error message is:\n{err:#}"
1445 ))),
1446 }
1447}
1448
1449fn one_or_many_action<'de, D>(deserializer: D) -> Result<Vec<Action>, D::Error>
1450where
1451 D: Deserializer<'de>,
1452{
1453 one_or_many(
1454 deserializer,
1455 "\"action\" field expected either a single Action or an array of Actions",
1456 )
1457}
1458
1459fn regex_string_or_array<'de, D>(deserializer: D) -> Result<Vec<Regex>, D::Error>
1460where
1461 D: Deserializer<'de>,
1462{
1463 string_or_array(
1464 deserializer,
1465 "regex string or array of regex strings for field regex",
1466 )
1467}
1468
1469fn string_or_array<'de, T, D>(deserializer: D, expecting: &'static str) -> Result<Vec<T>, D::Error>
1470where
1471 T: Deserialize<'de> + TryFrom<String>,
1472 <T as TryFrom<String>>::Error: std::fmt::Debug,
1473 D: Deserializer<'de>,
1474{
1475 struct StringOrArray<T>(PhantomData<fn() -> T>, &'static str);
1481
1482 impl<'de, T> Visitor<'de> for StringOrArray<T>
1483 where
1484 T: Deserialize<'de> + TryFrom<String>,
1485 <T as TryFrom<String>>::Error: std::fmt::Debug,
1486 {
1487 type Value = Vec<T>;
1488
1489 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
1490 formatter.write_str(self.1)
1491 }
1492
1493 fn visit_str<E>(self, value: &str) -> Result<Vec<T>, E>
1494 where
1495 E: serde::de::Error,
1496 {
1497 Ok(vec![
1498 T::try_from(value.to_string()).map_err(|e| E::custom(format!("{e:?}")))?
1499 ])
1500 }
1501
1502 fn visit_seq<S>(self, seq: S) -> Result<Vec<T>, S::Error>
1503 where
1504 S: SeqAccess<'de>,
1505 {
1506 Deserialize::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))
1507 }
1508 }
1509
1510 deserializer.deserialize_any(StringOrArray(PhantomData, expecting))
1511}
1512
1513#[cfg(feature = "lua")]
1514fn default_true() -> bool {
1515 true
1516}
1517
1518#[cfg(feature = "lua")]
1519pub fn register(lua: &mlua::Lua) -> anyhow::Result<()> {
1520 let shaping_mod = config::get_or_create_sub_module(lua, "shaping")?;
1521
1522 shaping_mod.set(
1523 "load",
1524 lua.create_async_function(
1525 move |lua, (paths, options): (Vec<String>, Option<mlua::Value>)| async move {
1526 let options = match options {
1527 Some(v) => lua.from_value(v)?,
1528 None => Default::default(),
1529 };
1530 let shaping = Shaping::merge_files(&paths, &options)
1531 .await
1532 .map_err(any_err)?;
1533 Ok(shaping)
1534 },
1535 )?,
1536 )?;
1537
1538 Ok(())
1539}
1540
1541#[cfg(test)]
1542mod test {
1543 use super::*;
1544 use kumo_log_types::RecordType;
1545 use rfc5321::Response;
1546 use std::io::Write;
1547 use tempfile::NamedTempFile;
1548 use uuid::Uuid;
1549
1550 async fn make_shaping_configs(inputs: &[&str]) -> Shaping {
1551 let mut files = vec![];
1552 let mut file_names = vec![];
1553
1554 for (i, content) in inputs.iter().enumerate() {
1555 let mut shaping_file = NamedTempFile::with_prefix(format!("file{i}")).unwrap();
1556 shaping_file.write_all(content.as_bytes()).unwrap();
1557 file_names.push(shaping_file.path().to_str().unwrap().to_string());
1558 files.push(shaping_file);
1559 }
1560
1561 Shaping::merge_files(&file_names, &ShapingMergeOptions::default())
1562 .await
1563 .unwrap()
1564 }
1565
1566 #[tokio::test]
1567 async fn test_merge_additional() {
1568 let shaping = make_shaping_configs(&[
1569 r#"
1570["example.com"]
1571mx_rollup = false
1572additional_connection_limits = {"first"=10}
1573 "#,
1574 r#"
1575["example.com"]
1576mx_rollup = false
1577additional_connection_limits = {"second"=32}
1578additional_message_rate_throttles = {"second"="100/hr"}
1579 "#,
1580 ])
1581 .await;
1582
1583 let resolved = shaping
1584 .get_egress_path_config("example.com", "invalid.source", "invalid.site")
1585 .await
1586 .finish()
1587 .unwrap();
1588
1589 k9::snapshot!(
1590 resolved.params.additional_connection_limits,
1591 r#"
1592{
1593 "first": 10,
1594 "second": 32,
1595}
1596"#
1597 );
1598 k9::snapshot!(
1599 resolved.params.additional_message_rate_throttles,
1600 r#"
1601{
1602 "second": 100/h,
1603}
1604"#
1605 );
1606 }
1607
1608 #[tokio::test]
1609 async fn test_provider_multi_hostname() {
1610 let shaping = make_shaping_configs(&[r#"
1611[provider."yahoo"]
1612match=[{HostName="mta5.am0.yahoodns.net"},{HostName="mta6.am0.yahoodns.net"},{HostName="mta7.am0.yahoodns.net"}]
1613enable_tls = "Required"
1614 "#])
1615 .await;
1616
1617 let resolved = shaping
1618 .get_egress_path_config("yahoo.com", "invalid.source", "invalid.site")
1619 .await
1620 .finish()
1621 .unwrap();
1622
1623 k9::assert_equal!(
1624 resolved.params.enable_tls,
1625 crate::egress_path::Tls::Required
1626 );
1627 k9::assert_equal!(resolved.params.provider_name.unwrap(), "yahoo");
1628 }
1629
1630 #[tokio::test]
1631 async fn test_provider_multi_suffix() {
1632 let shaping = make_shaping_configs(&[r#"
1633[provider."yahoo"]
1634match=[{MXSuffix="mta5.am0.yahoodns.net"},{MXSuffix="mta6.am0.yahoodns.net"},{MXSuffix="mta7.am0.yahoodns.net"}]
1635enable_tls = "Required"
1636 "#])
1637 .await;
1638
1639 let resolved = shaping
1640 .get_egress_path_config("yahoo.com", "invalid.source", "invalid.site")
1641 .await
1642 .finish()
1643 .unwrap();
1644
1645 k9::assert_equal!(
1646 resolved.params.enable_tls,
1647 crate::egress_path::Tls::Required
1648 );
1649 k9::assert_equal!(resolved.params.provider_name.unwrap(), "yahoo");
1650 }
1651
1652 #[tokio::test]
1653 async fn test_provider() {
1654 let shaping = make_shaping_configs(&[r#"
1655[provider."Office 365"]
1656match=[{MXSuffix=".olc.protection.outlook.com"},{DomainSuffix=".outlook.com"}]
1657enable_tls = "Required"
1658provider_connection_limit = 10
1659provider_max_message_rate = "120/s"
1660
1661[provider."Office 365".sources."new-source"]
1662provider_source_selection_rate = "500/d,max_burst=1"
1663 "#])
1664 .await;
1665
1666 let resolved = shaping
1667 .get_egress_path_config("outlook.com", "invalid.source", "invalid.site")
1668 .await
1669 .finish()
1670 .unwrap();
1671
1672 k9::assert_equal!(
1673 resolved.params.enable_tls,
1674 crate::egress_path::Tls::Required
1675 );
1676 k9::assert_equal!(resolved.params.provider_name.unwrap(), "Office 365");
1677
1678 k9::snapshot!(
1679 resolved.params.additional_connection_limits,
1680 r#"
1681{
1682 "shaping-provider-Office 365-invalid.source-limit": 10,
1683}
1684"#
1685 );
1686 k9::snapshot!(
1687 resolved.params.additional_message_rate_throttles,
1688 r#"
1689{
1690 "shaping-provider-Office 365-invalid.source-rate": 120/s,
1691}
1692"#
1693 );
1694 assert!(resolved.params.source_selection_rate.is_none());
1695 assert!(resolved.params.additional_source_selection_rates.is_empty());
1696
1697 let resolved = shaping
1698 .get_egress_path_config("outlook.com", "new-source", "invalid.site")
1699 .await
1700 .finish()
1701 .unwrap();
1702 assert!(resolved.params.source_selection_rate.is_none());
1703 k9::snapshot!(
1704 resolved.params.additional_source_selection_rates,
1705 r#"
1706{
1707 "shaping-provider-Office 365-new-source-selection-rate": 500/d,max_burst=1,
1708}
1709"#
1710 );
1711 }
1712
1713 #[tokio::test]
1714 async fn test_rule_matching() {
1715 let shaping = make_shaping_configs(&[r#"
1716[["default".automation]]
1717regex="default"
1718action = {SetConfig={name="connection_limit", value=1}}
1719duration = "1hr"
1720
1721["fake.site"]
1722_treat_domain_name_as_site_name = true
1723
1724[["fake.site".automation]]
1725regex="fake_rollup"
1726action = {SetConfig={name="connection_limit", value=2}}
1727duration = "1hr"
1728
1729["woot.provider"]
1730mx_rollup = false
1731
1732[["woot.provider".automation]]
1733regex="woot_domain"
1734action = {SetConfig={name="connection_limit", value=2}}
1735duration = "1hr"
1736
1737[provider."provider"]
1738match=[{DomainSuffix=".provider"}]
1739
1740[[provider."provider".automation]]
1741regex="provider"
1742action = {SetConfig={name="connection_limit", value=3}}
1743duration = "1hr"
1744match_internal = true
1745
1746"#])
1747 .await;
1748
1749 eprintln!("{:?}", shaping.inner.warnings);
1750
1751 fn make_record(content: &str, recipient: &str, site: &str) -> JsonLogRecord {
1752 JsonLogRecord {
1753 kind: RecordType::TransientFailure,
1754 id: String::new(),
1755 sender: String::new(),
1756 recipient: vec![recipient.to_string()],
1757 queue: String::new(),
1758 site: site.to_string(),
1759 size: 0,
1760 response: Response {
1761 code: 400,
1762 command: None,
1763 enhanced_code: None,
1764 content: content.to_string(),
1765 },
1766 peer_address: None,
1767 timestamp: Default::default(),
1768 created: Default::default(),
1769 num_attempts: 1,
1770 bounce_classification: Default::default(),
1771 egress_pool: None,
1772 egress_source: None,
1773 source_address: None,
1774 feedback_report: None,
1775 meta: Default::default(),
1776 headers: Default::default(),
1777 delivery_protocol: None,
1778 reception_protocol: None,
1779 nodeid: Uuid::default(),
1780 tls_cipher: None,
1781 tls_protocol_version: None,
1782 tls_peer_subject_name: None,
1783 provider_name: None,
1784 session_id: None,
1785 }
1786 }
1787
1788 let matches = shaping
1789 .match_rules(&make_record("default", "user@example.com", "dummy_site"))
1790 .await
1791 .unwrap();
1792 k9::assert_equal!(
1793 matches[0].regex[0].to_string(),
1794 "default",
1795 "matches against default automation rule"
1796 );
1797
1798 let matches = shaping
1799 .match_rules(&make_record(
1800 "KumoMTA internal: default",
1801 "user@example.com",
1802 "dummy_site",
1803 ))
1804 .await
1805 .unwrap();
1806 assert!(matches.is_empty(), "internal bounce should not match");
1807
1808 let matches = shaping
1809 .match_rules(&make_record(
1810 "woot_domain",
1811 "user@woot.provider",
1812 "dummy_site",
1813 ))
1814 .await
1815 .unwrap();
1816 k9::assert_equal!(
1817 matches[0].regex[0].to_string(),
1818 "woot_domain",
1819 "matches against domain rule with mx_rollup=false"
1820 );
1821
1822 let matches = shaping
1823 .match_rules(&make_record("fake_rollup", "user@fake.rollup", "fake.site"))
1824 .await
1825 .unwrap();
1826 k9::assert_equal!(
1827 matches[0].regex[0].to_string(),
1828 "fake_rollup",
1829 "matches against domain rule with mx_rollup=true"
1830 );
1831
1832 let matches = shaping
1833 .match_rules(&make_record("provider", "user@woot.provider", "dummy_site"))
1834 .await
1835 .unwrap();
1836 k9::assert_equal!(
1837 matches[0].regex[0].to_string(),
1838 "provider",
1839 "matches against provider rule"
1840 );
1841
1842 let matches = shaping
1843 .match_rules(&make_record(
1844 "KumoMTA internal: provider",
1845 "user@woot.provider",
1846 "dummy_site",
1847 ))
1848 .await
1849 .unwrap();
1850 k9::assert_equal!(
1851 matches[0].regex[0].to_string(),
1852 "provider",
1853 "internal response matches against provider rule"
1854 );
1855 }
1856
1857 #[tokio::test]
1858 async fn test_defaults() {
1859 let shaping = make_shaping_configs(&[
1860 r#"
1861["default"]
1862connection_limit = 10
1863max_connection_rate = "100/min"
1864max_deliveries_per_connection = 100
1865max_message_rate = "100/s"
1866idle_timeout = "60s"
1867data_timeout = "30s"
1868data_dot_timeout = "60s"
1869enable_tls = "Opportunistic"
1870consecutive_connection_failures_before_delay = 100
1871
1872[["default".automation]]
1873regex=[
1874 '/Messages from \d+\.\d+\.\d+\.\d+ temporarily deferred/',
1875 '/All messages from \d+\.\d+\.\d+\.\d+ will be permanently deferred/',
1876 '/has been temporarily rate limited due to IP reputation/',
1877 '/Unfortunately, messages from \d+\.\d+\.\d+\.\d+ weren.t sent/',
1878 '/Server busy\. Please try again later from/'
1879]
1880action = [
1881 {SetConfig={name="max_message_rate", value="1/minute"}},
1882 {SetConfig={name="connection_limit", value=1}}
1883]
1884duration = "90m"
1885
1886[["default".automation]]
1887regex="KumoMTA internal: failed to connect to any candidate hosts: All failures are related to OpportunisticInsecure STARTTLS. Consider setting enable_tls=Disabled for this site"
1888action = {SetConfig={name="enable_tls", value="Disabled"}}
1889duration = "30 days"
1890
1891["gmail.com"]
1892max_deliveries_per_connection = 50
1893connection_limit = 5
1894enable_tls = "Required"
1895consecutive_connection_failures_before_delay = 5
1896
1897["yahoo.com"]
1898max_deliveries_per_connection = 20
1899
1900[["yahoo.com".automation]]
1901regex = "\\[TS04\\]"
1902action = "Suspend"
1903duration = "2 hours"
1904
1905["comcast.net"]
1906connection_limit = 25
1907max_deliveries_per_connection = 250
1908enable_tls = "Required"
1909idle_timeout = "30s"
1910consecutive_connection_failures_before_delay = 24
1911
1912["mail.com"]
1913max_deliveries_per_connection = 100
1914
1915["orange.fr"]
1916connection_limit = 3
1917
1918["smtp.mailgun.com"]
1919connection_limit = 7000
1920max_deliveries_per_connection = 3
1921
1922["example.com"]
1923mx_rollup = false
1924max_deliveries_per_connection = 100
1925connection_limit = 3
1926
1927["example.com".sources."my source name"]
1928connection_limit = 5
1929 "#,
1930 ])
1931 .await;
1932
1933 let default = shaping
1934 .get_egress_path_config("invalid.domain", "invalid.source", "invalid.site")
1935 .await
1936 .finish()
1937 .unwrap();
1938 k9::snapshot!(
1939 default,
1940 r#"
1941MergedEntry {
1942 params: EgressPathConfig {
1943 connection_limit: 10,
1944 additional_connection_limits: {},
1945 enable_tls: Opportunistic,
1946 enable_mta_sts: true,
1947 enable_dane: false,
1948 enable_pipelining: true,
1949 enable_rset: true,
1950 tls_prefer_openssl: false,
1951 tls_certificate: None,
1952 tls_private_key: None,
1953 openssl_cipher_list: None,
1954 openssl_cipher_suites: None,
1955 openssl_options: None,
1956 rustls_cipher_suites: [],
1957 client_timeouts: SmtpClientTimeouts {
1958 connect_timeout: 60s,
1959 banner_timeout: 60s,
1960 ehlo_timeout: 300s,
1961 mail_from_timeout: 300s,
1962 rcpt_to_timeout: 300s,
1963 data_timeout: 30s,
1964 data_dot_timeout: 60s,
1965 rset_timeout: 5s,
1966 idle_timeout: 60s,
1967 starttls_timeout: 5s,
1968 auth_timeout: 60s,
1969 },
1970 system_shutdown_timeout: None,
1971 max_ready: 1024,
1972 consecutive_connection_failures_before_delay: 100,
1973 smtp_port: 25,
1974 smtp_auth_plain_username: None,
1975 smtp_auth_plain_password: None,
1976 allow_smtp_auth_plain_without_tls: false,
1977 allow_smtp_auth_plain_without_valid_certificate: false,
1978 max_message_rate: Some(
1979 100/s,
1980 ),
1981 additional_message_rate_throttles: {},
1982 source_selection_rate: None,
1983 additional_source_selection_rates: {},
1984 max_connection_rate: Some(
1985 100/m,
1986 ),
1987 max_deliveries_per_connection: 100,
1988 max_recipients_per_batch: 100,
1989 prohibited_hosts: {
1990 "0.0.0.0",
1991 "127.0.0.0/8",
1992 "::/127",
1993 },
1994 skip_hosts: {},
1995 ip_lookup_strategy: Ipv4AndIpv6,
1996 ehlo_domain: None,
1997 aggressive_connection_opening: false,
1998 refresh_interval: 60s,
1999 refresh_strategy: Ttl,
2000 dispatcher_wakeup_strategy: Aggressive,
2001 maintainer_wakeup_strategy: Aggressive,
2002 provider_name: None,
2003 remember_broken_tls: None,
2004 opportunistic_tls_reconnect_on_failed_handshake: false,
2005 use_lmtp: false,
2006 reconnect_strategy: ConnectNextHost,
2007 readyq_pool_name: None,
2008 low_memory_reduction_policy: ShrinkDataAndMeta,
2009 no_memory_reduction_policy: ShrinkDataAndMeta,
2010 try_next_host_on_transport_error: false,
2011 ignore_8bit_checks: false,
2012 dispatcher_progress_watchdog_timeout: None,
2013 },
2014 sources: {},
2015 automation: [
2016 Rule {
2017 regex: [
2018 Regex(
2019 /Messages from \d+\.\d+\.\d+\.\d+ temporarily deferred/,
2020 ),
2021 Regex(
2022 /All messages from \d+\.\d+\.\d+\.\d+ will be permanently deferred/,
2023 ),
2024 Regex(
2025 /has been temporarily rate limited due to IP reputation/,
2026 ),
2027 Regex(
2028 /Unfortunately, messages from \d+\.\d+\.\d+\.\d+ weren.t sent/,
2029 ),
2030 Regex(
2031 /Server busy\. Please try again later from/,
2032 ),
2033 ],
2034 action: [
2035 SetConfig(
2036 EgressPathConfigValue {
2037 name: "max_message_rate",
2038 value: HashableTomlValue {
2039 value: String(
2040 "1/minute",
2041 ),
2042 },
2043 },
2044 ),
2045 SetConfig(
2046 EgressPathConfigValue {
2047 name: "connection_limit",
2048 value: HashableTomlValue {
2049 value: Integer(
2050 1,
2051 ),
2052 },
2053 },
2054 ),
2055 ],
2056 trigger: Immediate,
2057 duration: 5400s,
2058 was_rollup: false,
2059 match_internal: false,
2060 },
2061 Rule {
2062 regex: [
2063 Regex(
2064 KumoMTA internal: failed to connect to any candidate hosts: All failures are related to OpportunisticInsecure STARTTLS. Consider setting enable_tls=Disabled for this site,
2065 ),
2066 ],
2067 action: [
2068 SetConfig(
2069 EgressPathConfigValue {
2070 name: "enable_tls",
2071 value: HashableTomlValue {
2072 value: String(
2073 "Disabled",
2074 ),
2075 },
2076 },
2077 ),
2078 ],
2079 trigger: Immediate,
2080 duration: 2592000s,
2081 was_rollup: false,
2082 match_internal: false,
2083 },
2084 ],
2085}
2086"#
2087 );
2088
2089 let example_com = shaping
2090 .get_egress_path_config("example.com", "invalid.source", "invalid.site")
2091 .await
2092 .finish()
2093 .unwrap();
2094 k9::snapshot!(
2095 example_com,
2096 r#"
2097MergedEntry {
2098 params: EgressPathConfig {
2099 connection_limit: 3,
2100 additional_connection_limits: {},
2101 enable_tls: Opportunistic,
2102 enable_mta_sts: true,
2103 enable_dane: false,
2104 enable_pipelining: true,
2105 enable_rset: true,
2106 tls_prefer_openssl: false,
2107 tls_certificate: None,
2108 tls_private_key: None,
2109 openssl_cipher_list: None,
2110 openssl_cipher_suites: None,
2111 openssl_options: None,
2112 rustls_cipher_suites: [],
2113 client_timeouts: SmtpClientTimeouts {
2114 connect_timeout: 60s,
2115 banner_timeout: 60s,
2116 ehlo_timeout: 300s,
2117 mail_from_timeout: 300s,
2118 rcpt_to_timeout: 300s,
2119 data_timeout: 30s,
2120 data_dot_timeout: 60s,
2121 rset_timeout: 5s,
2122 idle_timeout: 60s,
2123 starttls_timeout: 5s,
2124 auth_timeout: 60s,
2125 },
2126 system_shutdown_timeout: None,
2127 max_ready: 1024,
2128 consecutive_connection_failures_before_delay: 100,
2129 smtp_port: 25,
2130 smtp_auth_plain_username: None,
2131 smtp_auth_plain_password: None,
2132 allow_smtp_auth_plain_without_tls: false,
2133 allow_smtp_auth_plain_without_valid_certificate: false,
2134 max_message_rate: Some(
2135 100/s,
2136 ),
2137 additional_message_rate_throttles: {},
2138 source_selection_rate: None,
2139 additional_source_selection_rates: {},
2140 max_connection_rate: Some(
2141 100/m,
2142 ),
2143 max_deliveries_per_connection: 100,
2144 max_recipients_per_batch: 100,
2145 prohibited_hosts: {
2146 "0.0.0.0",
2147 "127.0.0.0/8",
2148 "::/127",
2149 },
2150 skip_hosts: {},
2151 ip_lookup_strategy: Ipv4AndIpv6,
2152 ehlo_domain: None,
2153 aggressive_connection_opening: false,
2154 refresh_interval: 60s,
2155 refresh_strategy: Ttl,
2156 dispatcher_wakeup_strategy: Aggressive,
2157 maintainer_wakeup_strategy: Aggressive,
2158 provider_name: None,
2159 remember_broken_tls: None,
2160 opportunistic_tls_reconnect_on_failed_handshake: false,
2161 use_lmtp: false,
2162 reconnect_strategy: ConnectNextHost,
2163 readyq_pool_name: None,
2164 low_memory_reduction_policy: ShrinkDataAndMeta,
2165 no_memory_reduction_policy: ShrinkDataAndMeta,
2166 try_next_host_on_transport_error: false,
2167 ignore_8bit_checks: false,
2168 dispatcher_progress_watchdog_timeout: None,
2169 },
2170 sources: {
2171 "my source name": EgressPathConfig {
2172 connection_limit: 5,
2173 additional_connection_limits: {},
2174 enable_tls: Opportunistic,
2175 enable_mta_sts: true,
2176 enable_dane: false,
2177 enable_pipelining: true,
2178 enable_rset: true,
2179 tls_prefer_openssl: false,
2180 tls_certificate: None,
2181 tls_private_key: None,
2182 openssl_cipher_list: None,
2183 openssl_cipher_suites: None,
2184 openssl_options: None,
2185 rustls_cipher_suites: [],
2186 client_timeouts: SmtpClientTimeouts {
2187 connect_timeout: 60s,
2188 banner_timeout: 60s,
2189 ehlo_timeout: 300s,
2190 mail_from_timeout: 300s,
2191 rcpt_to_timeout: 300s,
2192 data_timeout: 300s,
2193 data_dot_timeout: 300s,
2194 rset_timeout: 5s,
2195 idle_timeout: 5s,
2196 starttls_timeout: 5s,
2197 auth_timeout: 60s,
2198 },
2199 system_shutdown_timeout: None,
2200 max_ready: 1024,
2201 consecutive_connection_failures_before_delay: 100,
2202 smtp_port: 25,
2203 smtp_auth_plain_username: None,
2204 smtp_auth_plain_password: None,
2205 allow_smtp_auth_plain_without_tls: false,
2206 allow_smtp_auth_plain_without_valid_certificate: false,
2207 max_message_rate: None,
2208 additional_message_rate_throttles: {},
2209 source_selection_rate: None,
2210 additional_source_selection_rates: {},
2211 max_connection_rate: None,
2212 max_deliveries_per_connection: 1024,
2213 max_recipients_per_batch: 100,
2214 prohibited_hosts: {
2215 "0.0.0.0",
2216 "127.0.0.0/8",
2217 "::/127",
2218 },
2219 skip_hosts: {},
2220 ip_lookup_strategy: Ipv4AndIpv6,
2221 ehlo_domain: None,
2222 aggressive_connection_opening: false,
2223 refresh_interval: 60s,
2224 refresh_strategy: Ttl,
2225 dispatcher_wakeup_strategy: Aggressive,
2226 maintainer_wakeup_strategy: Aggressive,
2227 provider_name: None,
2228 remember_broken_tls: None,
2229 opportunistic_tls_reconnect_on_failed_handshake: false,
2230 use_lmtp: false,
2231 reconnect_strategy: ConnectNextHost,
2232 readyq_pool_name: None,
2233 low_memory_reduction_policy: ShrinkDataAndMeta,
2234 no_memory_reduction_policy: ShrinkDataAndMeta,
2235 try_next_host_on_transport_error: false,
2236 ignore_8bit_checks: false,
2237 dispatcher_progress_watchdog_timeout: None,
2238 },
2239 },
2240 automation: [
2241 Rule {
2242 regex: [
2243 Regex(
2244 /Messages from \d+\.\d+\.\d+\.\d+ temporarily deferred/,
2245 ),
2246 Regex(
2247 /All messages from \d+\.\d+\.\d+\.\d+ will be permanently deferred/,
2248 ),
2249 Regex(
2250 /has been temporarily rate limited due to IP reputation/,
2251 ),
2252 Regex(
2253 /Unfortunately, messages from \d+\.\d+\.\d+\.\d+ weren.t sent/,
2254 ),
2255 Regex(
2256 /Server busy\. Please try again later from/,
2257 ),
2258 ],
2259 action: [
2260 SetConfig(
2261 EgressPathConfigValue {
2262 name: "max_message_rate",
2263 value: HashableTomlValue {
2264 value: String(
2265 "1/minute",
2266 ),
2267 },
2268 },
2269 ),
2270 SetConfig(
2271 EgressPathConfigValue {
2272 name: "connection_limit",
2273 value: HashableTomlValue {
2274 value: Integer(
2275 1,
2276 ),
2277 },
2278 },
2279 ),
2280 ],
2281 trigger: Immediate,
2282 duration: 5400s,
2283 was_rollup: false,
2284 match_internal: false,
2285 },
2286 Rule {
2287 regex: [
2288 Regex(
2289 KumoMTA internal: failed to connect to any candidate hosts: All failures are related to OpportunisticInsecure STARTTLS. Consider setting enable_tls=Disabled for this site,
2290 ),
2291 ],
2292 action: [
2293 SetConfig(
2294 EgressPathConfigValue {
2295 name: "enable_tls",
2296 value: HashableTomlValue {
2297 value: String(
2298 "Disabled",
2299 ),
2300 },
2301 },
2302 ),
2303 ],
2304 trigger: Immediate,
2305 duration: 2592000s,
2306 was_rollup: false,
2307 match_internal: false,
2308 },
2309 ],
2310}
2311"#
2312 );
2313
2314 let yahoo_com = shaping
2317 .get_egress_path_config(
2318 "yahoo.com",
2319 "invalid.source",
2320 "(mta5|mta6|mta7).am0.yahoodns.net",
2321 )
2322 .await
2323 .finish()
2324 .unwrap();
2325 k9::snapshot!(
2326 yahoo_com,
2327 r#"
2328MergedEntry {
2329 params: EgressPathConfig {
2330 connection_limit: 10,
2331 additional_connection_limits: {},
2332 enable_tls: Opportunistic,
2333 enable_mta_sts: true,
2334 enable_dane: false,
2335 enable_pipelining: true,
2336 enable_rset: true,
2337 tls_prefer_openssl: false,
2338 tls_certificate: None,
2339 tls_private_key: None,
2340 openssl_cipher_list: None,
2341 openssl_cipher_suites: None,
2342 openssl_options: None,
2343 rustls_cipher_suites: [],
2344 client_timeouts: SmtpClientTimeouts {
2345 connect_timeout: 60s,
2346 banner_timeout: 60s,
2347 ehlo_timeout: 300s,
2348 mail_from_timeout: 300s,
2349 rcpt_to_timeout: 300s,
2350 data_timeout: 30s,
2351 data_dot_timeout: 60s,
2352 rset_timeout: 5s,
2353 idle_timeout: 60s,
2354 starttls_timeout: 5s,
2355 auth_timeout: 60s,
2356 },
2357 system_shutdown_timeout: None,
2358 max_ready: 1024,
2359 consecutive_connection_failures_before_delay: 100,
2360 smtp_port: 25,
2361 smtp_auth_plain_username: None,
2362 smtp_auth_plain_password: None,
2363 allow_smtp_auth_plain_without_tls: false,
2364 allow_smtp_auth_plain_without_valid_certificate: false,
2365 max_message_rate: Some(
2366 100/s,
2367 ),
2368 additional_message_rate_throttles: {},
2369 source_selection_rate: None,
2370 additional_source_selection_rates: {},
2371 max_connection_rate: Some(
2372 100/m,
2373 ),
2374 max_deliveries_per_connection: 20,
2375 max_recipients_per_batch: 100,
2376 prohibited_hosts: {
2377 "0.0.0.0",
2378 "127.0.0.0/8",
2379 "::/127",
2380 },
2381 skip_hosts: {},
2382 ip_lookup_strategy: Ipv4AndIpv6,
2383 ehlo_domain: None,
2384 aggressive_connection_opening: false,
2385 refresh_interval: 60s,
2386 refresh_strategy: Ttl,
2387 dispatcher_wakeup_strategy: Aggressive,
2388 maintainer_wakeup_strategy: Aggressive,
2389 provider_name: None,
2390 remember_broken_tls: None,
2391 opportunistic_tls_reconnect_on_failed_handshake: false,
2392 use_lmtp: false,
2393 reconnect_strategy: ConnectNextHost,
2394 readyq_pool_name: None,
2395 low_memory_reduction_policy: ShrinkDataAndMeta,
2396 no_memory_reduction_policy: ShrinkDataAndMeta,
2397 try_next_host_on_transport_error: false,
2398 ignore_8bit_checks: false,
2399 dispatcher_progress_watchdog_timeout: None,
2400 },
2401 sources: {},
2402 automation: [
2403 Rule {
2404 regex: [
2405 Regex(
2406 /Messages from \d+\.\d+\.\d+\.\d+ temporarily deferred/,
2407 ),
2408 Regex(
2409 /All messages from \d+\.\d+\.\d+\.\d+ will be permanently deferred/,
2410 ),
2411 Regex(
2412 /has been temporarily rate limited due to IP reputation/,
2413 ),
2414 Regex(
2415 /Unfortunately, messages from \d+\.\d+\.\d+\.\d+ weren.t sent/,
2416 ),
2417 Regex(
2418 /Server busy\. Please try again later from/,
2419 ),
2420 ],
2421 action: [
2422 SetConfig(
2423 EgressPathConfigValue {
2424 name: "max_message_rate",
2425 value: HashableTomlValue {
2426 value: String(
2427 "1/minute",
2428 ),
2429 },
2430 },
2431 ),
2432 SetConfig(
2433 EgressPathConfigValue {
2434 name: "connection_limit",
2435 value: HashableTomlValue {
2436 value: Integer(
2437 1,
2438 ),
2439 },
2440 },
2441 ),
2442 ],
2443 trigger: Immediate,
2444 duration: 5400s,
2445 was_rollup: false,
2446 match_internal: false,
2447 },
2448 Rule {
2449 regex: [
2450 Regex(
2451 KumoMTA internal: failed to connect to any candidate hosts: All failures are related to OpportunisticInsecure STARTTLS. Consider setting enable_tls=Disabled for this site,
2452 ),
2453 ],
2454 action: [
2455 SetConfig(
2456 EgressPathConfigValue {
2457 name: "enable_tls",
2458 value: HashableTomlValue {
2459 value: String(
2460 "Disabled",
2461 ),
2462 },
2463 },
2464 ),
2465 ],
2466 trigger: Immediate,
2467 duration: 2592000s,
2468 was_rollup: false,
2469 match_internal: false,
2470 },
2471 Rule {
2472 regex: [
2473 Regex(
2474 \[TS04\],
2475 ),
2476 ],
2477 action: [
2478 Suspend,
2479 ],
2480 trigger: Immediate,
2481 duration: 7200s,
2482 was_rollup: false,
2483 match_internal: false,
2484 },
2485 ],
2486}
2487"#
2488 );
2489 }
2490
2491 #[tokio::test]
2492 async fn test_load_default_shaping_toml() {
2493 Shaping::merge_files(
2494 &["../../assets/policy-extras/shaping.toml".into()],
2495 &ShapingMergeOptions::default(),
2496 )
2497 .await
2498 .unwrap();
2499 }
2500}