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::{Serialize, Serializer};
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)]
21#[xml(scalar, rename_all = "lowercase")]
22pub enum SpfDisposition {
23 None,
28
29 Neutral,
32
33 Pass,
36
37 Fail,
40
41 SoftFail,
45
46 TempError,
50
51 PermError,
55}
56
57impl SpfDisposition {
58 pub fn as_str(&self) -> &'static str {
59 match self {
60 Self::None => "none",
61 Self::Neutral => "neutral",
62 Self::Pass => "pass",
63 Self::Fail => "fail",
64 Self::SoftFail => "softfail",
65 Self::TempError => "temperror",
66 Self::PermError => "permerror",
67 }
68 }
69}
70
71impl Serialize for SpfDisposition {
72 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
73 serializer.serialize_str(self.as_str())
74 }
75}
76
77impl From<Qualifier> for SpfDisposition {
78 fn from(qualifier: Qualifier) -> Self {
79 match qualifier {
80 Qualifier::Pass => Self::Pass,
81 Qualifier::Fail => Self::Fail,
82 Qualifier::SoftFail => Self::SoftFail,
83 Qualifier::Neutral => Self::Neutral,
84 }
85 }
86}
87
88impl fmt::Display for SpfDisposition {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 write!(f, "{}", self.as_str())
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct SpfResult {
96 pub disposition: SpfDisposition,
97 pub context: String,
98}
99
100impl SpfResult {
101 fn fail(context: String) -> Self {
102 Self {
103 disposition: SpfDisposition::Fail,
104 context,
105 }
106 }
107}
108
109pub struct CheckHostParams {
110 pub domain: String,
114
115 pub sender: Option<String>,
117
118 pub client_ip: IpAddr,
120
121 pub ehlo_domain: Option<String>,
124
125 pub relaying_host_name: Option<String>,
127}
128
129impl CheckHostParams {
130 pub async fn check(self, resolver: &dyn Resolver) -> SpfResult {
131 let Self {
132 domain,
133 sender,
134 client_ip,
135 ehlo_domain,
136 relaying_host_name,
137 } = self;
138
139 let sender = match sender {
140 Some(sender) => sender,
141 None => format!("postmaster@{domain}"),
142 };
143
144 match SpfContext::new(&sender, &domain, client_ip) {
145 Ok(cx) => {
146 cx.with_ehlo_domain(ehlo_domain.as_deref())
147 .with_relaying_host_name(relaying_host_name.as_deref())
148 .check(resolver, true)
149 .await
150 }
151 Err(result) => result,
152 }
153 }
154}
155
156struct SpfContext<'a> {
157 pub(crate) sender: &'a str,
158 pub(crate) local_part: &'a str,
159 pub(crate) sender_domain: &'a str,
160 pub(crate) domain: &'a str,
161 pub(crate) client_ip: IpAddr,
162 pub(crate) now: SystemTime,
163 pub(crate) ehlo_domain: Option<&'a str>,
164 pub(crate) relaying_host_name: &'a str,
165 lookups_remaining: Arc<AtomicUsize>,
166}
167
168impl<'a> SpfContext<'a> {
169 fn new(sender: &'a str, domain: &'a str, client_ip: IpAddr) -> Result<Self, SpfResult> {
176 let Some((local_part, sender_domain)) = sender.split_once('@') else {
177 return Err(SpfResult {
178 disposition: SpfDisposition::PermError,
179 context:
180 "input sender parameter '{sender}' is missing @ sign to delimit local part and domain".to_owned(),
181 });
182 };
183
184 Ok(Self {
185 sender,
186 local_part,
187 sender_domain,
188 domain,
189 client_ip,
190 now: SystemTime::now(),
191 ehlo_domain: None,
192 relaying_host_name: "localhost",
193 lookups_remaining: Arc::new(AtomicUsize::new(10)),
194 })
195 }
196
197 pub fn with_ehlo_domain(&self, ehlo_domain: Option<&'a str>) -> Self {
198 Self {
199 ehlo_domain,
200 lookups_remaining: self.lookups_remaining.clone(),
201 ..*self
202 }
203 }
204
205 pub fn with_relaying_host_name(&self, relaying_host_name: Option<&'a str>) -> Self {
206 Self {
207 relaying_host_name: relaying_host_name.unwrap_or(self.relaying_host_name),
208 lookups_remaining: self.lookups_remaining.clone(),
209 ..*self
210 }
211 }
212
213 pub(crate) fn with_domain(&self, domain: &'a str) -> Self {
214 Self {
215 domain,
216 lookups_remaining: self.lookups_remaining.clone(),
217 ..*self
218 }
219 }
220
221 pub(crate) fn check_lookup_limit(&self) -> Result<(), SpfResult> {
222 let remain = self.lookups_remaining.load(Ordering::Relaxed);
223 if remain > 0 {
224 self.lookups_remaining.store(remain - 1, Ordering::Relaxed);
225 return Ok(());
226 }
227
228 Err(SpfResult {
229 disposition: SpfDisposition::PermError,
230 context: "DNS lookup limits exceeded".to_string(),
231 })
232 }
233
234 pub async fn check(&self, resolver: &dyn Resolver, initial: bool) -> SpfResult {
235 if !initial {
236 if let Err(err) = self.check_lookup_limit() {
237 return err;
238 }
239 }
240
241 let name = match Name::from_str_relaxed(self.domain) {
242 Ok(name) => name,
243 Err(_) => {
244 let context = format!("invalid domain name: {}", self.domain);
247 return if initial {
248 SpfResult {
249 disposition: SpfDisposition::None,
250 context,
251 }
252 } else {
253 SpfResult {
254 disposition: SpfDisposition::TempError,
255 context,
256 }
257 };
258 }
259 };
260
261 let initial_txt = match resolver.resolve(name, RecordType::TXT).await {
262 Ok(answer) => {
263 if answer.records.is_empty() || answer.nxdomain {
264 return SpfResult {
265 disposition: SpfDisposition::None,
266 context: if answer.records.is_empty() {
267 format!("no SPF records found for {}", &self.domain)
268 } else {
269 format!("domain {} not found", &self.domain)
270 },
271 };
272 } else {
273 answer.as_txt()
274 }
275 }
276 Err(err) => {
277 return SpfResult {
278 disposition: match err {
279 DnsError::InvalidName(_) => SpfDisposition::PermError,
280 DnsError::ResolveFailed(_) => SpfDisposition::TempError,
281 },
282 context: format!("{err}"),
283 };
284 }
285 };
286
287 for txt in initial_txt {
290 if txt.starts_with("v=spf1 ") {
295 match Record::parse(&txt) {
296 Ok(record) => return record.evaluate(self, resolver).await,
297 Err(err) => {
298 return SpfResult {
299 disposition: SpfDisposition::PermError,
300 context: format!("failed to parse spf record: {err}"),
301 };
302 }
303 }
304 }
305 }
306 SpfResult {
307 disposition: SpfDisposition::None,
308 context: format!("no SPF records found for {}", &self.domain),
309 }
310 }
311
312 pub(crate) async fn domain(
313 &self,
314 spec: Option<&MacroSpec>,
315 resolver: &dyn Resolver,
316 ) -> Result<String, SpfResult> {
317 let Some(spec) = spec else {
318 return Ok(self.domain.to_owned());
319 };
320
321 spec.expand(self, resolver).await.map_err(|err| SpfResult {
322 disposition: SpfDisposition::TempError,
323 context: format!("error evaluating domain spec: {err}"),
324 })
325 }
326
327 pub(crate) async fn validated_domain(
328 &self,
329 spec: Option<&MacroSpec>,
330 resolver: &dyn Resolver,
331 ) -> Result<Option<String>, SpfResult> {
332 self.check_lookup_limit()?;
334
335 let domain = self.domain(spec, resolver).await?;
336
337 let domain = match Name::from_str_relaxed(&domain) {
338 Ok(domain) => domain,
339 Err(err) => {
340 return Err(SpfResult {
341 disposition: SpfDisposition::PermError,
342 context: format!("error parsing domain name: {err}"),
343 })
344 }
345 };
346
347 let ptrs = match resolver.resolve_ptr(self.client_ip).await {
348 Ok(ptrs) => ptrs,
349 Err(err) => {
350 return Err(SpfResult {
351 disposition: SpfDisposition::TempError,
352 context: format!("error looking up PTR for {}: {err}", self.client_ip),
353 })
354 }
355 };
356
357 for (idx, ptr) in ptrs.iter().filter(|ptr| domain.zone_of(ptr)).enumerate() {
358 if idx >= 10 {
359 return Err(SpfResult {
361 disposition: SpfDisposition::PermError,
362 context: format!("too many PTR records for {}", self.client_ip),
363 });
364 }
365 match resolver.resolve_ip(&ptr.to_string()).await {
366 Ok(ips) => {
367 if ips.iter().any(|&ip| ip == self.client_ip) {
368 let mut ptr = ptr.clone();
369 ptr.set_fqdn(false);
371 return Ok(Some(ptr.to_string()));
372 }
373 }
374 Err(err) => {
375 return Err(SpfResult {
376 disposition: SpfDisposition::TempError,
377 context: format!("error looking up IP for {ptr}: {err}"),
378 })
379 }
380 }
381 }
382
383 Ok(None)
384 }
385}