kumo_dmarc/
lib.rs

1#![allow(dead_code)]
2
3use crate::types::date_range::DateRange;
4use crate::types::feedback::Feedback;
5use crate::types::identifier::Identifier;
6use crate::types::mode::Mode;
7use crate::types::policy::Policy;
8use crate::types::policy_published::PolicyPublished;
9use crate::types::record::Record;
10use crate::types::report_failure::ReportFailure;
11use crate::types::report_metadata::ReportMetadata;
12use crate::types::results::{AuthResults, DmarcResult, PolicyEvaluated, Results, Row};
13pub use crate::types::results::{Disposition, DispositionWithContext};
14use chrono::{DateTime, Utc};
15use dns_resolver::Resolver;
16use mailparsing::AuthenticationResult;
17use serde::{Deserialize, Serialize};
18use std::collections::{BTreeMap, HashMap};
19use std::fs::File;
20use std::io::{BufRead, BufReader, Write};
21use std::net::IpAddr;
22use std::str::FromStr;
23use std::time::SystemTime;
24use uuid::Uuid;
25
26mod types;
27
28#[cfg(test)]
29mod tests;
30
31const DMARC_REPORT_LOG_FILEPATH: &'static str = "/var/log/kumomta/dmarc.log";
32
33pub struct DmarcPassContext {
34    /// Domain of the sender in the "From:"
35    pub from_domain: String,
36
37    /// Domain that provides the sought-after authorization information.
38    ///
39    /// The "MAIL FROM" email address if available.
40    pub mail_from_domain: Option<String>,
41
42    /// The envelope to
43    pub recipient_domain_list: Vec<String>,
44
45    /// The source IP address
46    pub received_from: String,
47
48    /// The results of the DKIM part of the checks
49    pub dkim_results: Vec<AuthenticationResult>,
50
51    /// The results of the SPF part of the checks
52    pub spf_result: AuthenticationResult,
53
54    /// The additional information needed to perform reporting
55    pub reporting_info: Option<ReportingInfo>,
56}
57
58impl DmarcPassContext {
59    pub async fn check(self, resolver: &dyn Resolver) -> DispositionWithContext {
60        let Self {
61            from_domain,
62            mail_from_domain,
63            recipient_domain_list: recipient_list,
64            received_from,
65            dkim_results,
66            spf_result,
67            reporting_info,
68        } = self;
69
70        let mut dmarc_context = DmarcContext::new(
71            &from_domain,
72            mail_from_domain.as_ref().map(|x| x.as_str()),
73            &recipient_list[..],
74            received_from.as_str(),
75            &dkim_results[..],
76            &spf_result,
77            reporting_info.as_ref(),
78        );
79
80        dmarc_context.check(resolver).await
81    }
82}
83
84#[derive(Clone, Copy)]
85pub(crate) enum SenderDomainAlignment {
86    /// Sender domain is an exact match to the dmarc record
87    Exact,
88
89    /// Sender domain has no exact matching dmarc record
90    /// but its organizational domain does
91    OrganizationalDomain,
92}
93
94pub(crate) enum DmarcRecordResolution {
95    /// DNS could not be resolved at this time
96    TempError,
97
98    /// DNS was resolved, but no DMARC record was found
99    PermError,
100
101    /// DNS was resolved, and DMARC record was found
102    Records(Vec<Record>),
103}
104
105impl From<DmarcRecordResolution> for Disposition {
106    fn from(value: DmarcRecordResolution) -> Self {
107        match value {
108            DmarcRecordResolution::TempError => Disposition::TempError,
109            DmarcRecordResolution::PermError => Disposition::PermError,
110            DmarcRecordResolution::Records(_) => {
111                panic!("records must be parsed before being used in disposition")
112            }
113        }
114    }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct ReportingInfo {
120    org_name: String,
121    email: String,
122    extra_contact_info: Option<String>,
123}
124
125/// The individual error records that are then aggregated for output in the report
126#[derive(Serialize, Deserialize, Clone)]
127pub(crate) struct ErrorRecord {
128    pub(crate) version: String,
129    pub(crate) org_name: String,
130    pub(crate) email: String,
131    pub(crate) extra_contact_info: Option<String>,
132    pub(crate) when: DateTime<Utc>,
133    pub(crate) error: String,
134    pub(crate) domain: String,
135    pub(crate) align_dkim: Option<Mode>,
136    pub(crate) align_spf: Option<Mode>,
137    pub(crate) policy: Policy,
138    pub(crate) subdomain_policy: Policy,
139    pub(crate) rate: u8,
140    pub(crate) report_failure: ReportFailure,
141    pub(crate) source_ip: IpAddr,
142    pub(crate) policy_evaluated: PolicyEvaluated,
143    pub(crate) identifier: Identifier,
144    pub(crate) auth_results: AuthResults,
145}
146
147struct DmarcContext<'a> {
148    pub(crate) from_domain: &'a str,
149    pub(crate) mail_from_domain: Option<&'a str>,
150    pub(crate) recipient_list: &'a [String],
151    pub(crate) received_from: &'a str,
152    pub(crate) now: SystemTime,
153    pub(crate) dkim_results: &'a [AuthenticationResult],
154    pub(crate) spf_result: &'a AuthenticationResult,
155    pub(crate) dkim_aligned: DmarcResult,
156    pub(crate) spf_aligned: DmarcResult,
157    pub(crate) reporting_info: Option<&'a ReportingInfo>,
158}
159
160impl<'a> DmarcContext<'a> {
161    /// Create a new evaluation context.
162    ///
163    /// - `from_domain` is the domain of the "From:" header
164    /// - `mail_from_domain` is the domain portion of the "MAIL FROM" identity
165    /// - `client_ip` is the IP address of the SMTP client that is emitting the mail
166    fn new(
167        from_domain: &'a str,
168        mail_from_domain: Option<&'a str>,
169        recipient_list: &'a [String],
170        received_from: &'a str,
171        dkim_results: &'a [AuthenticationResult],
172        spf_result: &'a AuthenticationResult,
173        reporting_info: Option<&'a ReportingInfo>,
174    ) -> DmarcContext<'a> {
175        Self {
176            from_domain,
177            mail_from_domain,
178            recipient_list,
179            received_from,
180            now: SystemTime::now(),
181            dkim_results,
182            spf_result,
183            dkim_aligned: DmarcResult::Pass,
184            spf_aligned: DmarcResult::Pass,
185            reporting_info,
186        }
187    }
188
189    pub async fn report_error(
190        &self,
191        record: &Record,
192        dmarc_domain: &str,
193        sender_domain_alignment: SenderDomainAlignment,
194        error: &str,
195    ) -> std::io::Result<()> {
196        let source_ip = self
197            .received_from
198            .parse()
199            .map_err(|x: std::net::AddrParseError| {
200                std::io::Error::new(std::io::ErrorKind::AddrNotAvailable, x.to_string())
201            })?;
202
203        if let Some(reporting_info) = self.reporting_info {
204            let error_record = ErrorRecord {
205                version: "1.0".to_string(),
206                org_name: reporting_info.org_name.to_string(),
207                email: reporting_info.email.to_string(),
208                extra_contact_info: reporting_info.extra_contact_info.to_owned(),
209                when: Utc::now(),
210                error: error.to_string(),
211                domain: dmarc_domain.to_string(),
212                align_dkim: Some(record.align_dkim),
213                align_spf: Some(record.align_spf),
214                policy: record.policy,
215                subdomain_policy: record.subdomain_policy.unwrap_or(record.policy),
216                rate: record.rate,
217                report_failure: record.report_failure,
218                source_ip,
219                policy_evaluated: PolicyEvaluated {
220                    disposition: record.policy_result(sender_domain_alignment),
221                    dkim: self.dkim_aligned,
222                    spf: self.spf_aligned,
223                    reason: vec![],
224                },
225                identifier: Identifier {
226                    envelope_to: self.recipient_list.into(),
227                    envelope_from: if let Some(mail_from_domain) = self.mail_from_domain {
228                        vec![mail_from_domain.into()]
229                    } else {
230                        vec![]
231                    },
232                    header_from: self.from_domain.into(),
233                },
234                auth_results: AuthResults {
235                    dkim: self.dkim_results.iter().map(|x| x.clone().into()).collect(),
236                    spf: vec![self.spf_result.clone().into()],
237                },
238            };
239
240            let result = serde_json::to_string(&error_record)?;
241
242            let mut f = File::options()
243                .append(true)
244                .open(DMARC_REPORT_LOG_FILEPATH)?;
245
246            writeln!(f, "{result}")?;
247        }
248
249        Ok(())
250    }
251
252    pub async fn aggregate(&self) -> anyhow::Result<()> {
253        let mut input_records = vec![];
254        let file = File::open(DMARC_REPORT_LOG_FILEPATH)?;
255        let lines = BufReader::new(file).lines();
256
257        for line in lines.map_while(Result::ok) {
258            let result: anyhow::Result<ErrorRecord> = serde_json::from_str::<ErrorRecord>(&line)
259                .map_err(|error| {
260                    anyhow::Error::new(error).context(format!(
261                        "Failed to decode a line from the DMARC report file \
262           {DMARC_REPORT_LOG_FILEPATH}. \
263           The line was: {line}. \
264           Is the file corrupt?"
265                    ))
266                })
267                .into();
268
269            input_records.push(result?);
270        }
271
272        let mut errors_grouped_by_email: HashMap<String, BTreeMap<IpAddr, Vec<ErrorRecord>>> =
273            HashMap::new();
274
275        for record in input_records {
276            let entry = errors_grouped_by_email.entry(record.email.clone());
277            let record_source_ip = record.source_ip.clone();
278
279            entry
280                .and_modify(|entry| {
281                    entry
282                        .entry(record.source_ip)
283                        .and_modify(|x| x.push(record.clone()))
284                        .or_insert_with(|| vec![record.clone()]);
285                })
286                .or_insert({
287                    let mut new_group = BTreeMap::new();
288
289                    new_group.insert(record_source_ip, vec![record]);
290
291                    new_group
292                });
293        }
294
295        for (email, errors_grouped_by_ip) in errors_grouped_by_email.iter_mut() {
296            let mut errors = vec![];
297            let mut record = vec![];
298
299            //we know this is safe to do because for this list to be present, we will have found it earlier
300            let (_, first_records) = errors_grouped_by_ip
301                .iter()
302                .next()
303                .expect("guaranteed to not be empty by the logic above");
304
305            let first_record = &first_records[0];
306
307            let version = first_record.version.clone();
308            let org_name = first_record.org_name.clone();
309            let email = email.clone();
310            let extra_contact_info = first_record.extra_contact_info.clone();
311
312            let mut date_range = DateRange::new(first_record.when, first_record.when);
313
314            let report_id = Uuid::new_v4().to_string();
315
316            let domain = first_record.domain.clone();
317            let align_dkim = first_record.align_dkim;
318            let align_spf = first_record.align_spf;
319            let policy = first_record.policy;
320            let subdomain_policy = first_record.subdomain_policy;
321            let rate = first_record.rate;
322            let report_failure = first_record.report_failure;
323
324            for (ip, error_group_for_ip) in errors_grouped_by_ip.iter_mut() {
325                let row = Row {
326                    source_ip: *ip,
327                    count: error_group_for_ip.len() as u64,
328                    policy_evaluated: error_group_for_ip[0].policy_evaluated.clone(),
329                };
330
331                let mut results = Results {
332                    row,
333                    identifiers: Identifier {
334                        envelope_to: vec![],
335                        envelope_from: vec![],
336                        header_from: String::new(),
337                    },
338                    auth_results: AuthResults {
339                        dkim: vec![],
340                        spf: vec![],
341                    },
342                };
343
344                for group_error in error_group_for_ip.iter() {
345                    errors.push(group_error.error.clone());
346
347                    date_range.begin = std::cmp::min(date_range.begin, group_error.when);
348                    date_range.end = std::cmp::max(date_range.end, group_error.when);
349
350                    results
351                        .identifiers
352                        .envelope_from
353                        .extend_from_slice(&group_error.identifier.envelope_from);
354                    results
355                        .identifiers
356                        .envelope_to
357                        .extend_from_slice(&group_error.identifier.envelope_to);
358
359                    results
360                        .auth_results
361                        .dkim
362                        .extend_from_slice(&group_error.auth_results.dkim);
363                    results
364                        .auth_results
365                        .spf
366                        .extend_from_slice(&group_error.auth_results.spf);
367                }
368
369                record.push(results);
370            }
371
372            let _feedback = Feedback {
373                version,
374                metadata: ReportMetadata {
375                    org_name,
376                    email,
377                    extra_contact_info,
378                    report_id,
379                    date_range,
380                    error: errors,
381                },
382                policy: PolicyPublished::new(
383                    domain,
384                    align_dkim,
385                    align_spf,
386                    policy,
387                    subdomain_policy,
388                    rate,
389                    report_failure,
390                ),
391                record,
392            };
393
394            // if let Ok(result) = instant_xml::to_string(&feedback) {
395            //     println!("log: {}", result);
396            // }
397        }
398
399        Ok(())
400    }
401
402    pub async fn check(&mut self, resolver: &dyn Resolver) -> DispositionWithContext {
403        let dmarc_domain = format!("_dmarc.{}", self.from_domain);
404        match fetch_dmarc_records(&dmarc_domain, resolver).await {
405            DmarcRecordResolution::Records(records) => {
406                for record in records {
407                    return record
408                        .evaluate(self, &dmarc_domain, SenderDomainAlignment::Exact)
409                        .await;
410                }
411            }
412            x => {
413                if let Some(organizational_domain) = psl::domain_str(self.from_domain) {
414                    if organizational_domain != self.from_domain {
415                        let address = format!("_dmarc.{}", organizational_domain);
416                        match fetch_dmarc_records(&address, resolver).await {
417                            DmarcRecordResolution::TempError => {
418                                return DispositionWithContext {
419                                    result: Disposition::TempError,
420                                    context: format!(
421                                        "DNS records could not be resolved for {}",
422                                        address
423                                    ),
424                                }
425                            }
426                            DmarcRecordResolution::PermError => {
427                                return DispositionWithContext {
428                                    result: Disposition::PermError,
429                                    context: format!("no DMARC records found for {}", address),
430                                }
431                            }
432                            DmarcRecordResolution::Records(records) => {
433                                for record in records {
434                                    return record
435                                        .evaluate(
436                                            self,
437                                            &address,
438                                            SenderDomainAlignment::OrganizationalDomain,
439                                        )
440                                        .await;
441                                }
442                            }
443                        }
444                    } else {
445                        return DispositionWithContext {
446                            result: x.into(),
447                            context: format!("no DMARC records found for {}", &self.from_domain),
448                        };
449                    }
450                }
451            }
452        }
453
454        DispositionWithContext {
455            result: Disposition::None,
456            context: format!("no DMARC records found for {}", &self.from_domain),
457        }
458    }
459}
460
461// The output is wrapped in a Result to allow matching on errors.
462// Returns an Iterator to the Reader of the lines of the file.
463fn read_lines<P>(filename: P) -> std::io::Result<std::io::Lines<std::io::BufReader<File>>>
464where
465    P: AsRef<std::path::Path>,
466{
467    let file = File::open(filename)?;
468    Ok(std::io::BufReader::new(file).lines())
469}
470
471pub(crate) async fn fetch_dmarc_records(
472    address: &str,
473    resolver: &dyn Resolver,
474) -> DmarcRecordResolution {
475    let initial_txt = match resolver.resolve_txt(address).await {
476        Ok(answer) => {
477            if answer.records.is_empty() || answer.nxdomain {
478                return DmarcRecordResolution::PermError;
479            } else {
480                answer.as_txt()
481            }
482        }
483        Err(_) => {
484            return DmarcRecordResolution::TempError;
485        }
486    };
487
488    let mut records = vec![];
489
490    // TXT records can contain all sorts of stuff, let's walk through
491    // the set that we retrieved and take the first one that parses
492    for txt in initial_txt {
493        if txt.starts_with("v=DMARC1;") {
494            if let Ok(record) = Record::from_str(&txt) {
495                records.push(record);
496            }
497        }
498    }
499
500    if records.is_empty() {
501        return DmarcRecordResolution::PermError;
502    }
503
504    DmarcRecordResolution::Records(records)
505}