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 bstr::BString;
15use chrono::{DateTime, Utc};
16use dns_resolver::Resolver;
17use mailparsing::AuthenticationResult;
18use serde::{Deserialize, Serialize};
19use std::collections::{BTreeMap, HashMap};
20use std::fs::File;
21use std::io::{BufRead, BufReader, Write};
22use std::net::IpAddr;
23use std::str::FromStr;
24use std::time::SystemTime;
25use uuid::Uuid;
26
27mod types;
28
29#[cfg(test)]
30mod tests;
31
32const DMARC_REPORT_LOG_FILEPATH: &str = "/var/log/kumomta/dmarc.log";
33
34pub struct DmarcPassContext {
35 pub from_domain: String,
37
38 pub mail_from_domain: Option<String>,
42
43 pub recipient_domain_list: Vec<String>,
45
46 pub received_from: String,
48
49 pub dkim_results: Vec<AuthenticationResult>,
51
52 pub spf_result: AuthenticationResult,
54
55 pub reporting_info: Option<ReportingInfo>,
57}
58
59impl DmarcPassContext {
60 pub async fn check(self, resolver: &dyn Resolver) -> DispositionWithContext {
61 let Self {
62 from_domain,
63 mail_from_domain,
64 recipient_domain_list: recipient_list,
65 received_from,
66 dkim_results,
67 spf_result,
68 reporting_info,
69 } = self;
70
71 let mut dmarc_context = DmarcContext::new(
72 &from_domain,
73 mail_from_domain.as_deref(),
74 &recipient_list[..],
75 received_from.as_str(),
76 &dkim_results[..],
77 &spf_result,
78 reporting_info.as_ref(),
79 );
80
81 dmarc_context.check(resolver).await
82 }
83}
84
85#[derive(Clone, Copy)]
86pub(crate) enum SenderDomainAlignment {
87 Exact,
89
90 OrganizationalDomain,
93}
94
95pub(crate) enum DmarcRecordResolution {
96 TempError,
98
99 PermError,
101
102 Records(Vec<Record>),
104}
105
106impl From<DmarcRecordResolution> for Disposition {
107 fn from(value: DmarcRecordResolution) -> Self {
108 match value {
109 DmarcRecordResolution::TempError => Disposition::TempError,
110 DmarcRecordResolution::PermError => Disposition::PermError,
111 DmarcRecordResolution::Records(_) => {
112 panic!("records must be parsed before being used in disposition")
113 }
114 }
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(deny_unknown_fields)]
120pub struct ReportingInfo {
121 org_name: String,
122 email: String,
123 extra_contact_info: Option<String>,
124}
125
126#[derive(Serialize, Deserialize, Clone)]
128pub(crate) struct ErrorRecord {
129 pub(crate) version: String,
130 pub(crate) org_name: String,
131 pub(crate) email: String,
132 pub(crate) extra_contact_info: Option<String>,
133 pub(crate) when: DateTime<Utc>,
134 pub(crate) error: String,
135 pub(crate) domain: String,
136 pub(crate) align_dkim: Option<Mode>,
137 pub(crate) align_spf: Option<Mode>,
138 pub(crate) policy: Policy,
139 pub(crate) subdomain_policy: Policy,
140 pub(crate) rate: u8,
141 pub(crate) report_failure: ReportFailure,
142 pub(crate) source_ip: IpAddr,
143 pub(crate) policy_evaluated: PolicyEvaluated,
144 pub(crate) identifier: Identifier,
145 pub(crate) auth_results: AuthResults,
146}
147
148struct DmarcContext<'a> {
149 pub(crate) from_domain: &'a str,
150 pub(crate) mail_from_domain: Option<&'a str>,
151 pub(crate) recipient_list: &'a [String],
152 pub(crate) received_from: &'a str,
153 pub(crate) now: SystemTime,
154 pub(crate) dkim_results: &'a [AuthenticationResult],
155 pub(crate) spf_result: &'a AuthenticationResult,
156 pub(crate) dkim_aligned: DmarcResult,
157 pub(crate) spf_aligned: DmarcResult,
158 pub(crate) reporting_info: Option<&'a ReportingInfo>,
159}
160
161impl<'a> DmarcContext<'a> {
162 fn new(
168 from_domain: &'a str,
169 mail_from_domain: Option<&'a str>,
170 recipient_list: &'a [String],
171 received_from: &'a str,
172 dkim_results: &'a [AuthenticationResult],
173 spf_result: &'a AuthenticationResult,
174 reporting_info: Option<&'a ReportingInfo>,
175 ) -> DmarcContext<'a> {
176 Self {
177 from_domain,
178 mail_from_domain,
179 recipient_list,
180 received_from,
181 now: SystemTime::now(),
182 dkim_results,
183 spf_result,
184 dkim_aligned: DmarcResult::Pass,
185 spf_aligned: DmarcResult::Pass,
186 reporting_info,
187 }
188 }
189
190 pub async fn report_error(
191 &self,
192 record: &Record,
193 dmarc_domain: &str,
194 sender_domain_alignment: SenderDomainAlignment,
195 error: &str,
196 ) -> std::io::Result<()> {
197 let source_ip = self
198 .received_from
199 .parse()
200 .map_err(|x: std::net::AddrParseError| {
201 std::io::Error::new(std::io::ErrorKind::AddrNotAvailable, x.to_string())
202 })?;
203
204 if let Some(reporting_info) = self.reporting_info {
205 let error_record = ErrorRecord {
206 version: "1.0".to_string(),
207 org_name: reporting_info.org_name.to_string(),
208 email: reporting_info.email.to_string(),
209 extra_contact_info: reporting_info.extra_contact_info.to_owned(),
210 when: Utc::now(),
211 error: error.to_string(),
212 domain: dmarc_domain.to_string(),
213 align_dkim: Some(record.align_dkim),
214 align_spf: Some(record.align_spf),
215 policy: record.policy,
216 subdomain_policy: record.subdomain_policy.unwrap_or(record.policy),
217 rate: record.rate,
218 report_failure: record.report_failure,
219 source_ip,
220 policy_evaluated: PolicyEvaluated {
221 disposition: record.policy_result(sender_domain_alignment),
222 dkim: self.dkim_aligned,
223 spf: self.spf_aligned,
224 reason: vec![],
225 },
226 identifier: Identifier {
227 envelope_to: self.recipient_list.into(),
228 envelope_from: if let Some(mail_from_domain) = self.mail_from_domain {
229 vec![mail_from_domain.into()]
230 } else {
231 vec![]
232 },
233 header_from: self.from_domain.into(),
234 },
235 auth_results: AuthResults {
236 dkim: self.dkim_results.iter().map(|x| x.clone().into()).collect(),
237 spf: vec![self.spf_result.clone().into()],
238 },
239 };
240
241 let result = serde_json::to_string(&error_record)?;
242
243 let mut f = File::options()
244 .append(true)
245 .open(DMARC_REPORT_LOG_FILEPATH)?;
246
247 writeln!(f, "{result}")?;
248 }
249
250 Ok(())
251 }
252
253 pub async fn aggregate(&self) -> anyhow::Result<()> {
254 let mut input_records = vec![];
255 let file = File::open(DMARC_REPORT_LOG_FILEPATH)?;
256 let lines = BufReader::new(file).lines();
257
258 for line in lines.map_while(Result::ok) {
259 let result: anyhow::Result<ErrorRecord> = serde_json::from_str::<ErrorRecord>(&line)
260 .map_err(|error| {
261 anyhow::Error::new(error).context(format!(
262 "Failed to decode a line from the DMARC report file \
263 {DMARC_REPORT_LOG_FILEPATH}. \
264 The line was: {line}. \
265 Is the file corrupt?"
266 ))
267 });
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;
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 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 }
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 if let Some(record) = records.into_iter().next() {
407 let mut result = record
408 .evaluate(self, &dmarc_domain, SenderDomainAlignment::Exact)
409 .await;
410 result.props.extend(policy_tags(&record));
411 return result;
412 }
413 }
414 x => {
415 let normalized_from = psl_utils::normalize_domain(self.from_domain);
416 if let Some(organizational_domain) = psl_utils::domain_str(&normalized_from) {
417 if organizational_domain != normalized_from {
418 let address = format!("_dmarc.{}", organizational_domain);
419 match fetch_dmarc_records(&address, resolver).await {
420 DmarcRecordResolution::TempError => {
421 return DispositionWithContext {
422 result: Disposition::TempError,
423 context: format!(
424 "DNS records could not be resolved for {}",
425 address
426 ),
427 props: BTreeMap::new(),
428 }
429 }
430 DmarcRecordResolution::PermError => {
431 return DispositionWithContext {
432 result: Disposition::PermError,
433 context: format!("no DMARC records found for {}", address),
434 props: BTreeMap::new(),
435 }
436 }
437 DmarcRecordResolution::Records(records) => {
438 if let Some(record) = records.into_iter().next() {
439 let mut result = record
440 .evaluate(
441 self,
442 &address,
443 SenderDomainAlignment::OrganizationalDomain,
444 )
445 .await;
446 result.props.extend(policy_tags(&record));
447 return result;
448 }
449 }
450 }
451 } else {
452 return DispositionWithContext {
453 result: x.into(),
454 context: format!("no DMARC records found for {}", &self.from_domain),
455 props: BTreeMap::new(),
456 };
457 }
458 }
459 }
460 }
461
462 DispositionWithContext {
463 result: Disposition::None,
464 context: format!("no DMARC records found for {}", &self.from_domain),
465 props: BTreeMap::new(),
466 }
467 }
468}
469
470fn policy_tags(record: &Record) -> BTreeMap<String, BString> {
471 let mut props = BTreeMap::new();
472
473 for (tag, value) in record.tags() {
474 props.insert(format!("policy.{tag}"), value.as_str().into());
475 }
476
477 props
478}
479
480fn read_lines<P>(filename: P) -> std::io::Result<std::io::Lines<std::io::BufReader<File>>>
483where
484 P: AsRef<std::path::Path>,
485{
486 let file = File::open(filename)?;
487 Ok(std::io::BufReader::new(file).lines())
488}
489
490pub(crate) async fn fetch_dmarc_records(
491 address: &str,
492 resolver: &dyn Resolver,
493) -> DmarcRecordResolution {
494 let initial_txt = match resolver.resolve_txt(address).await {
495 Ok(answer) => {
496 if answer.records.is_empty() || answer.nxdomain {
497 return DmarcRecordResolution::PermError;
498 } else {
499 answer.as_txt()
500 }
501 }
502 Err(_) => {
503 return DmarcRecordResolution::TempError;
504 }
505 };
506
507 let mut records = vec![];
508
509 for txt in initial_txt {
512 if txt.starts_with("v=DMARC1;") {
513 if let Ok(record) = Record::from_str(&txt) {
514 records.push(record);
515 }
516 }
517 }
518
519 if records.is_empty() {
520 return DmarcRecordResolution::PermError;
521 }
522
523 DmarcRecordResolution::Records(records)
524}