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(mut name) => {
243 name.set_fqdn(true);
244 name
245 }
246 Err(_) => {
247 let context = format!("invalid domain name: {}", self.domain);
250 return if initial {
251 SpfResult {
252 disposition: SpfDisposition::None,
253 context,
254 }
255 } else {
256 SpfResult {
257 disposition: SpfDisposition::TempError,
258 context,
259 }
260 };
261 }
262 };
263
264 let initial_txt = match resolver.resolve(name, RecordType::TXT).await {
265 Ok(answer) => {
266 if answer.records.is_empty() || answer.nxdomain {
267 return SpfResult {
268 disposition: SpfDisposition::None,
269 context: if answer.records.is_empty() {
270 format!("no SPF records found for {}", &self.domain)
271 } else {
272 format!("domain {} not found", &self.domain)
273 },
274 };
275 } else {
276 answer.as_txt()
277 }
278 }
279 Err(err) => {
280 return SpfResult {
281 disposition: match err {
282 DnsError::InvalidName(_) => SpfDisposition::PermError,
283 DnsError::ResolveFailed(_) => SpfDisposition::TempError,
284 },
285 context: format!("{err}"),
286 };
287 }
288 };
289
290 for txt in initial_txt {
293 if txt.starts_with("v=spf1 ") {
298 match Record::parse(&txt) {
299 Ok(record) => return record.evaluate(self, resolver).await,
300 Err(err) => {
301 return SpfResult {
302 disposition: SpfDisposition::PermError,
303 context: format!("failed to parse spf record: {err}"),
304 };
305 }
306 }
307 }
308 }
309 SpfResult {
310 disposition: SpfDisposition::None,
311 context: format!("no SPF records found for {}", &self.domain),
312 }
313 }
314
315 pub(crate) async fn domain(
316 &self,
317 spec: Option<&MacroSpec>,
318 resolver: &dyn Resolver,
319 ) -> Result<String, SpfResult> {
320 let Some(spec) = spec else {
321 return Ok(self.domain.to_owned());
322 };
323
324 spec.expand(self, resolver).await.map_err(|err| SpfResult {
325 disposition: SpfDisposition::TempError,
326 context: format!("error evaluating domain spec: {err}"),
327 })
328 }
329
330 pub(crate) async fn validated_domain(
331 &self,
332 spec: Option<&MacroSpec>,
333 resolver: &dyn Resolver,
334 ) -> Result<Option<String>, SpfResult> {
335 self.check_lookup_limit()?;
337
338 let domain = self.domain(spec, resolver).await?;
339
340 let domain = match Name::from_str_relaxed(&domain) {
341 Ok(domain) => domain,
342 Err(err) => {
343 return Err(SpfResult {
344 disposition: SpfDisposition::PermError,
345 context: format!("error parsing domain name: {err}"),
346 })
347 }
348 };
349
350 let ptrs = match resolver.resolve_ptr(self.client_ip).await {
351 Ok(ptrs) => ptrs,
352 Err(err) => {
353 return Err(SpfResult {
354 disposition: SpfDisposition::TempError,
355 context: format!("error looking up PTR for {}: {err}", self.client_ip),
356 })
357 }
358 };
359
360 for (idx, ptr) in ptrs.iter().filter(|ptr| domain.zone_of(ptr)).enumerate() {
361 if idx >= 10 {
362 return Err(SpfResult {
364 disposition: SpfDisposition::PermError,
365 context: format!("too many PTR records for {}", self.client_ip),
366 });
367 }
368 match resolver.resolve_ip(&fully_qualify(&ptr.to_string())).await {
369 Ok(ips) => {
370 if ips.iter().any(|&ip| ip == self.client_ip) {
371 let mut ptr = ptr.clone();
372 ptr.set_fqdn(false);
374 return Ok(Some(ptr.to_string()));
375 }
376 }
377 Err(err) => {
378 return Err(SpfResult {
379 disposition: SpfDisposition::TempError,
380 context: format!("error looking up IP for {ptr}: {err}"),
381 })
382 }
383 }
384 }
385
386 Ok(None)
387 }
388}
389
390pub(crate) fn fully_qualify(domain_name: &str) -> String {
391 match dns_resolver::fully_qualify(domain_name) {
392 Ok(name) => name.to_string(),
393 Err(_) => domain_name.to_string(),
394 }
395}