server: TLS report support

This commit is contained in:
Bill Thiede 2025-08-12 11:01:02 -07:00
parent 8218fca2ef
commit 8f53678e53
2 changed files with 302 additions and 20 deletions

View File

@ -1,7 +1,7 @@
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
fs::File, fs::File,
io::Cursor, io::{Cursor, Read},
}; };
use askama::Template; use askama::Template;
@ -33,6 +33,7 @@ const MESSAGE_RFC822: &'static str = "message/rfc822";
const MULTIPART_ALTERNATIVE: &'static str = "multipart/alternative"; const MULTIPART_ALTERNATIVE: &'static str = "multipart/alternative";
const MULTIPART_MIXED: &'static str = "multipart/mixed"; const MULTIPART_MIXED: &'static str = "multipart/mixed";
const MULTIPART_RELATED: &'static str = "multipart/related"; const MULTIPART_RELATED: &'static str = "multipart/related";
const MULTIPART_REPORT: &'static str = "multipart/report";
const TEXT_HTML: &'static str = "text/html"; const TEXT_HTML: &'static str = "text/html";
const TEXT_PLAIN: &'static str = "text/plain"; const TEXT_PLAIN: &'static str = "text/plain";
@ -453,6 +454,7 @@ fn extract_body(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, Ser
MULTIPART_MIXED => extract_mixed(m, part_addr), MULTIPART_MIXED => extract_mixed(m, part_addr),
MULTIPART_ALTERNATIVE => extract_alternative(m, part_addr), MULTIPART_ALTERNATIVE => extract_alternative(m, part_addr),
MULTIPART_RELATED => extract_related(m, part_addr), MULTIPART_RELATED => extract_related(m, part_addr),
MULTIPART_REPORT => extract_report(m, part_addr),
APPLICATION_ZIP => extract_zip(m), APPLICATION_ZIP => extract_zip(m),
_ => extract_unhandled(m), _ => extract_unhandled(m),
}; };
@ -528,6 +530,83 @@ fn extract_gzip(m: &ParsedMail) -> Result<Body, ServerError> {
extract_unhandled(m) extract_unhandled(m)
} }
fn extract_report(m: &ParsedMail, _part_addr: &mut Vec<String>) -> Result<Body, ServerError> {
let mut html_part = None;
let mut tlsrpt_part = None;
for sp in &m.subparts {
match sp.ctype.mimetype.as_str() {
TEXT_HTML => html_part = Some(sp.get_body()?),
"application/tlsrpt+gzip" => tlsrpt_part = Some(sp.get_body_raw()?),
_ => {} // Ignore other parts for now
}
}
let tlsrpt_summary_html = if let Some(gz_bytes) = tlsrpt_part {
let mut decoder = flate2::read::GzDecoder::new(&gz_bytes[..]);
let mut buffer = Vec::new();
if decoder.read_to_end(&mut buffer).is_ok() {
if let Ok(json_str) = String::from_utf8(buffer) {
match serde_json::from_str::<TlsRpt>(&json_str) {
Ok(tlsrpt) => {
let formatted_tlsrpt = FormattedTlsRpt {
organization_name: tlsrpt.organization_name,
date_range: FormattedTlsRptDateRange {
start_datetime: tlsrpt.date_range.start_datetime,
end_datetime: tlsrpt.date_range.end_datetime,
},
contact_info: tlsrpt.contact_info.unwrap_or_else(|| "".to_string()),
report_id: tlsrpt.report_id,
policies: tlsrpt.policies.into_iter().map(|policy| {
FormattedTlsRptPolicy {
policy: FormattedTlsRptPolicyDetails {
policy_type: policy.policy.policy_type,
policy_string: policy.policy.policy_string,
policy_domain: policy.policy.policy_domain,
mx_host: policy.policy.mx_host.unwrap_or_else(|| Vec::new()),
},
summary: policy.summary,
failure_details: policy.failure_details.unwrap_or_else(|| Vec::new()).into_iter().map(|detail| {
FormattedTlsRptFailureDetails {
result_type: detail.result_type,
sending_mta_ip: detail.sending_mta_ip.unwrap_or_else(|| "".to_string()),
receiving_ip: detail.receiving_ip.unwrap_or_else(|| "".to_string()),
receiving_mx_hostname: detail.receiving_mx_hostname.unwrap_or_else(|| "".to_string()),
failed_session_count: detail.failed_session_count,
additional_info: detail.additional_info.unwrap_or_else(|| "".to_string()),
failure_reason_code: detail.failure_reason_code.unwrap_or_else(|| "".to_string()),
}
}).collect(),
}
}).collect(),
};
let template = TlsReportTemplate { report: &formatted_tlsrpt };
template.render().unwrap_or_else(|e| format!("<div class=\"tlsrpt-error\">Failed to render TLS report template: {}</div>", e))
}
Err(e) => format!(
"<div class=\"tlsrpt-error\">Failed to parse TLS report JSON: {}</div>",
e
),
}
} else {
format!("<div class=\"tlsrpt-error\">Failed to convert decompressed data to UTF-8.</div>")
}
} else {
format!("<div class=\"tlsrpt-error\">Failed to decompressed data.</div>")
}
} else {
"".to_string()
};
let final_html = if let Some(html) = html_part {
format!("{}<hr>{} ", html, tlsrpt_summary_html)
} else {
tlsrpt_summary_html
};
Ok(Body::html(final_html))
}
fn extract_unhandled(m: &ParsedMail) -> Result<Body, ServerError> { fn extract_unhandled(m: &ParsedMail) -> Result<Body, ServerError> {
let msg = format!( let msg = format!(
"Unhandled body content type:\n{}\n{}", "Unhandled body content type:\n{}\n{}",
@ -1287,6 +1366,123 @@ pub struct Reason {
pub reason_type: Option<String>, pub reason_type: Option<String>,
pub comment: Option<String>, pub comment: Option<String>,
} }
#[derive(Debug, serde::Deserialize)]
pub struct TlsRpt {
#[serde(rename = "organization-name")]
pub organization_name: String,
#[serde(rename = "date-range")]
pub date_range: TlsRptDateRange,
#[serde(rename = "contact-info")]
pub contact_info: Option<String>,
#[serde(rename = "report-id")]
pub report_id: String,
pub policies: Vec<TlsRptPolicy>,
}
#[derive(Debug, serde::Deserialize)]
pub struct TlsRptDateRange {
#[serde(rename = "start-datetime")]
pub start_datetime: String,
#[serde(rename = "end-datetime")]
pub end_datetime: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct TlsRptPolicy {
pub policy: TlsRptPolicyDetails,
pub summary: TlsRptSummary,
#[serde(rename = "failure-details")]
pub failure_details: Option<Vec<TlsRptFailureDetails>>,
}
#[derive(Debug, serde::Deserialize)]
pub struct TlsRptPolicyDetails {
#[serde(rename = "policy-type")]
pub policy_type: String,
#[serde(rename = "policy-string")]
pub policy_string: Vec<String>,
#[serde(rename = "policy-domain")]
pub policy_domain: String,
#[serde(rename = "mx-host")]
pub mx_host: Option<Vec<TlsRptMxHost>>,
}
#[derive(Debug, serde::Deserialize)]
pub struct TlsRptSummary {
#[serde(rename = "total-successful-session-count")]
pub total_successful_session_count: u64,
#[serde(rename = "total-failure-session-count")]
pub total_failure_session_count: u64,
}
#[derive(Debug, serde::Deserialize)]
pub struct TlsRptMxHost {
pub hostname: String,
#[serde(rename = "failure-count")]
pub failure_count: u64,
#[serde(rename = "result-type")]
pub result_type: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct TlsRptFailureDetails {
#[serde(rename = "result-type")]
pub result_type: String,
#[serde(rename = "sending-mta-ip")]
pub sending_mta_ip: Option<String>,
#[serde(rename = "receiving-ip")]
pub receiving_ip: Option<String>,
#[serde(rename = "receiving-mx-hostname")]
pub receiving_mx_hostname: Option<String>,
#[serde(rename = "failed-session-count")]
pub failed_session_count: u64,
#[serde(rename = "additional-info")]
pub additional_info: Option<String>,
#[serde(rename = "failure-reason-code")]
pub failure_reason_code: Option<String>,
}
#[derive(Debug)]
pub struct FormattedTlsRpt {
pub organization_name: String,
pub date_range: FormattedTlsRptDateRange,
pub contact_info: String,
pub report_id: String,
pub policies: Vec<FormattedTlsRptPolicy>,
}
#[derive(Debug)]
pub struct FormattedTlsRptDateRange {
pub start_datetime: String,
pub end_datetime: String,
}
#[derive(Debug)]
pub struct FormattedTlsRptPolicy {
pub policy: FormattedTlsRptPolicyDetails,
pub summary: TlsRptSummary,
pub failure_details: Vec<FormattedTlsRptFailureDetails>,
}
#[derive(Debug)]
pub struct FormattedTlsRptPolicyDetails {
pub policy_type: String,
pub policy_string: Vec<String>,
pub policy_domain: String,
pub mx_host: Vec<TlsRptMxHost>,
}
#[derive(Debug)]
pub struct FormattedTlsRptFailureDetails {
pub result_type: String,
pub sending_mta_ip: String,
pub receiving_ip: String,
pub receiving_mx_hostname: String,
pub failed_session_count: u64,
pub additional_info: String,
pub failure_reason_code: String,
}
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
pub struct Identifiers { pub struct Identifiers {
pub header_from: Option<String>, pub header_from: Option<String>,
@ -1315,6 +1511,12 @@ pub struct DmarcReportTemplate<'a> {
pub report: &'a FormattedFeedback, pub report: &'a FormattedFeedback,
} }
#[derive(Template)]
#[template(path = "tls_report.html")]
pub struct TlsReportTemplate<'a> {
pub report: &'a FormattedTlsRpt,
}
// Add this helper function to parse the DMARC XML and summarize it. // Add this helper function to parse the DMARC XML and summarize it.
pub fn parse_dmarc_report(xml: &str) -> Result<String, ServerError> { pub fn parse_dmarc_report(xml: &str) -> Result<String, ServerError> {
let feedback: Feedback = xml_from_str(xml) let feedback: Feedback = xml_from_str(xml)
@ -1379,25 +1581,62 @@ pub fn parse_dmarc_report(xml: &str) -> Result<String, ServerError> {
}); });
FormattedRecord { FormattedRecord {
source_ip: rec.row.as_ref().and_then(|r| r.source_ip.clone()).unwrap_or_else(|| "".to_string()), source_ip: rec
count: rec.row.as_ref().and_then(|r| r.count.map(|c| c.to_string())).unwrap_or_else(|| "".to_string()), .row
header_from: rec.identifiers.as_ref().and_then(|i| i.header_from.clone()).unwrap_or_else(|| "".to_string()), .as_ref()
disposition: rec.row.as_ref().and_then(|r| r.policy_evaluated.as_ref()).and_then(|p| p.disposition.clone()).unwrap_or_else(|| "".to_string()), .and_then(|r| r.source_ip.clone())
dkim: rec.row.as_ref().and_then(|r| r.policy_evaluated.as_ref()).and_then(|p| p.dkim.clone()).unwrap_or_else(|| "".to_string()), .unwrap_or_else(|| "".to_string()),
spf: rec.row.as_ref().and_then(|r| r.policy_evaluated.as_ref()).and_then(|p| p.spf.clone()).unwrap_or_else(|| "".to_string()), count: rec
reason: rec.row.as_ref().and_then(|r| r.policy_evaluated.as_ref()).and_then(|p| p.reason.clone()).unwrap_or_else(|| Vec::new()).into_iter().map(|r| { .row
let mut s = String::new(); .as_ref()
if let Some(reason_type) = r.reason_type { .and_then(|r| r.count.map(|c| c.to_string()))
s.push_str(&format!("Type: {}", reason_type)); .unwrap_or_else(|| "".to_string()),
} header_from: rec
if let Some(comment) = r.comment { .identifiers
if !s.is_empty() { s.push_str(", "); } .as_ref()
s.push_str(&format!("Comment: {}", comment)); .and_then(|i| i.header_from.clone())
} .unwrap_or_else(|| "".to_string()),
s disposition: rec
}).collect(), .row
auth_results, .as_ref()
} .and_then(|r| r.policy_evaluated.as_ref())
.and_then(|p| p.disposition.clone())
.unwrap_or_else(|| "".to_string()),
dkim: rec
.row
.as_ref()
.and_then(|r| r.policy_evaluated.as_ref())
.and_then(|p| p.dkim.clone())
.unwrap_or_else(|| "".to_string()),
spf: rec
.row
.as_ref()
.and_then(|r| r.policy_evaluated.as_ref())
.and_then(|p| p.spf.clone())
.unwrap_or_else(|| "".to_string()),
reason: rec
.row
.as_ref()
.and_then(|r| r.policy_evaluated.as_ref())
.and_then(|p| p.reason.clone())
.unwrap_or_else(|| Vec::new())
.into_iter()
.map(|r| {
let mut s = String::new();
if let Some(reason_type) = r.reason_type {
s.push_str(&format!("Type: {}", reason_type));
}
if let Some(comment) = r.comment {
if !s.is_empty() {
s.push_str(", ");
}
s.push_str(&format!("Comment: {}", comment));
}
s
})
.collect(),
auth_results,
}
}) })
.collect() .collect()
}); });

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head>
<title>TLS Report</title>
</head>
<body>
<h3>TLS Report Summary:</h3>
<p>Organization: {{ report.organization_name }}</p>
<p>Date Range: {{ report.date_range.start_datetime }} to {{ report.date_range.end_datetime }}</p>
<p>Contact: {{ report.contact_info }}</p>
<p>Report ID: {{ report.report_id }}</p>
<h4>Policies:</h4>
{% for policy in report.policies %}
<h5>Policy Domain: {{ policy.policy.policy_domain }}</h5>
<ul>
<li>Policy Type: {{ policy.policy.policy_type }}</li>
<li>Policy String: {{ policy.policy.policy_string | join(", ") }}</li>
<li>Successful Sessions: {{ policy.summary.total_successful_session_count }}</li>
<li>Failed Sessions: {{ policy.summary.total_failure_session_count }}</li>
</ul>
<ul>
{% for mx_host in policy.policy.mx_host %}
<li>Hostname: {{ mx_host.hostname }}, Failures: {{ mx_host.failure_count }}, Result: {{ mx_host.result_type }}</li>
{% endfor %}
</ul>
<ul>
{% for detail in policy.failure_details %}
<li>Result: {{ detail.result_type }}, Sending IP: {{ detail.sending_mta_ip }}, Failed Sessions: {{ detail.failed_session_count }}
{% if detail.failure_reason_code != "" %}
(Reason: {{ detail.failure_reason_code }})
{% endif %}
</li>
(Receiving IP: {{ detail.receiving_ip }})
(Receiving MX: {{ detail.receiving_mx_hostname }})
(Additional Info: {{ detail.additional_info }})
{% endfor %}
</ul>
{% endfor %}
</body>
</html>