1use crate::record::Record;
2use crate::spec::MacroSpec;
3use dns_resolver::{DnsError, Resolver};
4use hickory_resolver::proto::rr::RecordType;
5use hickory_resolver::Name;
6use instant_xml::{FromXml, ToXml};
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use std::net::IpAddr;
10use std::sync::atomic::{AtomicUsize, Ordering};
11use std::sync::Arc;
12use std::time::SystemTime;
13
14pub mod record;
15mod spec;
16use record::Qualifier;
17#[cfg(test)]
18mod tests;
19
20#[derive(Debug, Clone, Copy, Eq, FromXml, PartialEq, ToXml, Serialize, Deserialize)]
21#[xml(scalar, rename_all = "lowercase")]
22#[serde(rename_all = "lowercase")]
23pub enum SpfDisposition {
24 None,
29
30 Neutral,
33
34 Pass,
37
38 Fail,
41
42 SoftFail,
46
47 TempError,
51
52 PermError,
56}
57
58impl SpfDisposition {
59 pub fn as_str(&self) -> &'static str {
60 match self {
61 Self::None => "none",
62 Self::Neutral => "neutral",
63 Self::Pass => "pass",
64 Self::Fail => "fail",
65 Self::SoftFail => "softfail",
66 Self::TempError => "temperror",
67 Self::PermError => "permerror",
68 }
69 }
70}
71
72impl From<String> for SpfDisposition {
73 fn from(value: String) -> Self {
74 match value.to_lowercase().as_str() {
75 "none" => Self::None,
76 "neutral" => Self::Neutral,
77 "pass" => Self::Pass,
78 "fail" => Self::Fail,
79 "softfail" => Self::SoftFail,
80 "temperror" => Self::TempError,
81 "permerror" => Self::PermError,
82 _ => Self::None,
83 }
84 }
85}
86
87impl From<Qualifier> for SpfDisposition {
88 fn from(qualifier: Qualifier) -> Self {
89 match qualifier {
90 Qualifier::Pass => Self::Pass,
91 Qualifier::Fail => Self::Fail,
92 Qualifier::SoftFail => Self::SoftFail,
93 Qualifier::Neutral => Self::Neutral,
94 }
95 }
96}
97
98impl fmt::Display for SpfDisposition {
99 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100 write!(f, "{}", self.as_str())
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct SpfResult {
106 pub disposition: SpfDisposition,
107 pub context: String,
108}
109
110impl SpfResult {
111 fn fail(context: String) -> Self {
112 Self {
113 disposition: SpfDisposition::Fail,
114 context,
115 }
116 }
117}
118
119pub struct CheckHostParams {
120 pub domain: String,
124
125 pub sender: Option<String>,
127
128 pub client_ip: IpAddr,
130
131 pub ehlo_domain: Option<String>,
134
135 pub relaying_host_name: Option<String>,
137}
138
139impl CheckHostParams {
140 pub async fn check(self, resolver: &dyn Resolver) -> SpfResult {
141 let Self {
142 mut domain,
143 sender,
144 client_ip,
145 ehlo_domain,
146 relaying_host_name,
147 } = self;
148
149 if domain.is_empty() {
151 if let Some(ehlo) = &ehlo_domain {
152 domain = ehlo.clone();
153 }
154 }
155
156 let sender = match sender {
157 Some(sender) => sender,
158 None => format!("postmaster@{domain}"),
159 };
160
161 match SpfContext::new(&sender, &domain, client_ip) {
162 Ok(cx) => {
163 cx.with_ehlo_domain(ehlo_domain.as_deref())
164 .with_relaying_host_name(relaying_host_name.as_deref())
165 .check(resolver, true)
166 .await
167 }
168 Err(result) => result,
169 }
170 }
171}
172
173struct SpfContext<'a> {
174 pub(crate) sender: &'a str,
175 pub(crate) local_part: &'a str,
176 pub(crate) sender_domain: &'a str,
177 pub(crate) domain: &'a str,
178 pub(crate) client_ip: IpAddr,
179 pub(crate) now: SystemTime,
180 pub(crate) ehlo_domain: Option<&'a str>,
181 pub(crate) relaying_host_name: &'a str,
182 lookups_remaining: Arc<AtomicUsize>,
183}
184
185impl<'a> SpfContext<'a> {
186 fn new(sender: &'a str, domain: &'a str, client_ip: IpAddr) -> Result<Self, SpfResult> {
193 let (local_part, sender_domain) = match sender.rsplit_once('@') {
194 Some((local_part, sender_domain)) => (local_part, sender_domain),
195 None => ("postmaster", sender),
196 };
197
198 Ok(Self {
199 sender,
200 local_part,
201 sender_domain,
202 domain,
203 client_ip,
204 now: SystemTime::now(),
205 ehlo_domain: None,
206 relaying_host_name: "localhost",
207 lookups_remaining: Arc::new(AtomicUsize::new(10)),
208 })
209 }
210
211 pub fn with_ehlo_domain(&self, ehlo_domain: Option<&'a str>) -> Self {
212 Self {
213 ehlo_domain,
214 lookups_remaining: self.lookups_remaining.clone(),
215 ..*self
216 }
217 }
218
219 pub fn with_relaying_host_name(&self, relaying_host_name: Option<&'a str>) -> Self {
220 Self {
221 relaying_host_name: relaying_host_name.unwrap_or(self.relaying_host_name),
222 lookups_remaining: self.lookups_remaining.clone(),
223 ..*self
224 }
225 }
226
227 pub(crate) fn with_domain(&self, domain: &'a str) -> Self {
228 Self {
229 domain,
230 lookups_remaining: self.lookups_remaining.clone(),
231 ..*self
232 }
233 }
234
235 pub(crate) fn check_lookup_limit(&self) -> Result<(), SpfResult> {
236 let remain = self.lookups_remaining.load(Ordering::Relaxed);
237 if remain > 0 {
238 self.lookups_remaining.store(remain - 1, Ordering::Relaxed);
239 return Ok(());
240 }
241
242 Err(SpfResult {
243 disposition: SpfDisposition::PermError,
244 context: "DNS lookup limits exceeded".to_string(),
245 })
246 }
247
248 pub async fn check(&self, resolver: &dyn Resolver, initial: bool) -> SpfResult {
249 if !initial {
250 if let Err(err) = self.check_lookup_limit() {
251 return err;
252 }
253 }
254
255 let name = match Name::from_str_relaxed(self.domain) {
256 Ok(mut name) => {
257 name.set_fqdn(true);
258 name
259 }
260 Err(_) => {
261 let context = format!("invalid domain name: {}", self.domain);
264 return if initial {
265 SpfResult {
266 disposition: SpfDisposition::None,
267 context,
268 }
269 } else {
270 SpfResult {
271 disposition: SpfDisposition::TempError,
272 context,
273 }
274 };
275 }
276 };
277
278 let initial_txt = match resolver.resolve(name, RecordType::TXT).await {
279 Ok(answer) => {
280 if answer.records.is_empty() || answer.nxdomain {
281 return SpfResult {
282 disposition: SpfDisposition::None,
283 context: if answer.records.is_empty() {
284 format!("no SPF records found for {}", &self.domain)
285 } else {
286 format!("domain {} not found", &self.domain)
287 },
288 };
289 } else {
290 answer.as_txt()
291 }
292 }
293 Err(err) => {
294 return SpfResult {
295 disposition: match err {
296 DnsError::InvalidName(_) => SpfDisposition::PermError,
297 DnsError::ResolveFailed(_) => SpfDisposition::TempError,
298 },
299 context: format!("{err}"),
300 };
301 }
302 };
303
304 for txt in initial_txt {
307 if txt.starts_with("v=spf1 ") {
312 match Record::parse(&txt) {
313 Ok(record) => return record.evaluate(self, resolver).await,
314 Err(err) => {
315 return SpfResult {
316 disposition: SpfDisposition::PermError,
317 context: format!("failed to parse spf record: {err}"),
318 };
319 }
320 }
321 }
322 }
323 SpfResult {
324 disposition: SpfDisposition::None,
325 context: format!("no SPF records found for {}", &self.domain),
326 }
327 }
328
329 pub(crate) async fn domain(
330 &self,
331 spec: Option<&MacroSpec>,
332 resolver: &dyn Resolver,
333 ) -> Result<String, SpfResult> {
334 let Some(spec) = spec else {
335 return Ok(self.domain.to_owned());
336 };
337
338 spec.expand(self, resolver).await.map_err(|err| SpfResult {
339 disposition: SpfDisposition::TempError,
340 context: format!("error evaluating domain spec: {err}"),
341 })
342 }
343
344 pub(crate) async fn validated_domain(
345 &self,
346 spec: Option<&MacroSpec>,
347 resolver: &dyn Resolver,
348 ) -> Result<Option<String>, SpfResult> {
349 self.check_lookup_limit()?;
351
352 let domain = self.domain(spec, resolver).await?;
353
354 let domain = match Name::from_str_relaxed(&domain) {
355 Ok(domain) => domain,
356 Err(err) => {
357 return Err(SpfResult {
358 disposition: SpfDisposition::PermError,
359 context: format!("error parsing domain name: {err}"),
360 })
361 }
362 };
363
364 let ptrs = match resolver.resolve_ptr(self.client_ip).await {
365 Ok(ptrs) => ptrs,
366 Err(err) => {
367 return Err(SpfResult {
368 disposition: SpfDisposition::TempError,
369 context: format!("error looking up PTR for {}: {err}", self.client_ip),
370 })
371 }
372 };
373
374 for (idx, ptr) in ptrs.iter().filter(|ptr| domain.zone_of(ptr)).enumerate() {
375 if idx >= 10 {
376 return Err(SpfResult {
378 disposition: SpfDisposition::PermError,
379 context: format!("too many PTR records for {}", self.client_ip),
380 });
381 }
382 match resolver.resolve_ip(&fully_qualify(&ptr.to_string())).await {
383 Ok(ips) => {
384 if ips.iter().any(|&ip| ip == self.client_ip) {
385 let mut ptr = ptr.clone();
386 ptr.set_fqdn(false);
388 return Ok(Some(ptr.to_string()));
389 }
390 }
391 Err(err) => {
392 return Err(SpfResult {
393 disposition: SpfDisposition::TempError,
394 context: format!("error looking up IP for {ptr}: {err}"),
395 })
396 }
397 }
398 }
399
400 Ok(None)
401 }
402}
403
404pub(crate) fn fully_qualify(domain_name: &str) -> String {
405 match dns_resolver::fully_qualify(domain_name) {
406 Ok(name) => name.to_string(),
407 Err(_) => domain_name.to_string(),
408 }
409}