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 pub from_domain: String,
36
37 pub mail_from_domain: Option<String>,
41
42 pub recipient_domain_list: Vec<String>,
44
45 pub received_from: String,
47
48 pub dkim_results: Vec<AuthenticationResult>,
50
51 pub spf_result: AuthenticationResult,
53
54 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 Exact,
88
89 OrganizationalDomain,
92}
93
94pub(crate) enum DmarcRecordResolution {
95 TempError,
97
98 PermError,
100
101 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#[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 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 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 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
461fn 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 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}