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