spftrace 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spftrace/__init__.py +199 -0
- spftrace/cli.py +202 -0
- spftrace/errors.py +28 -0
- spftrace/evaluator.py +554 -0
- spftrace/macros.py +223 -0
- spftrace/parser.py +231 -0
- spftrace/py.typed +0 -0
- spftrace/resolver.py +155 -0
- spftrace/trace.py +96 -0
- spftrace-0.1.0.dist-info/METADATA +208 -0
- spftrace-0.1.0.dist-info/RECORD +14 -0
- spftrace-0.1.0.dist-info/WHEEL +4 -0
- spftrace-0.1.0.dist-info/entry_points.txt +2 -0
- spftrace-0.1.0.dist-info/licenses/LICENSE +21 -0
spftrace/__init__.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""spftrace: an RFC 7208 SPF evaluator that shows its working.
|
|
2
|
+
|
|
3
|
+
The trace is the product, not a side effect. Every DNS query, macro expansion,
|
|
4
|
+
mechanism evaluation and limit decision is recorded and returned.
|
|
5
|
+
|
|
6
|
+
Quick use:
|
|
7
|
+
|
|
8
|
+
import spftrace
|
|
9
|
+
r = spftrace.check("203.0.113.1", "user@example.com")
|
|
10
|
+
print(r.verdict) # pass | fail | softfail | neutral | none |
|
|
11
|
+
# permerror | temperror
|
|
12
|
+
print(r.to_dict()) # stable JSON-safe structure
|
|
13
|
+
|
|
14
|
+
Inside an async application (FastAPI and friends), await the async form:
|
|
15
|
+
|
|
16
|
+
r = await spftrace.acheck("203.0.113.1", "user@example.com")
|
|
17
|
+
|
|
18
|
+
For full control over DNS, build the resolver and evaluator yourself:
|
|
19
|
+
|
|
20
|
+
from spftrace import Evaluator, Limits, LiveResolver
|
|
21
|
+
resolver = LiveResolver(["192.0.2.53"], timeout=3.0, max_queries=75)
|
|
22
|
+
result = await Evaluator(resolver, Limits()).evaluate(ip, sender, helo)
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
from typing import Iterable
|
|
28
|
+
|
|
29
|
+
from .errors import (
|
|
30
|
+
SpfError,
|
|
31
|
+
SpfNoneError,
|
|
32
|
+
SpfPermError,
|
|
33
|
+
SpfTempError,
|
|
34
|
+
SpfUsageError,
|
|
35
|
+
)
|
|
36
|
+
from .evaluator import (
|
|
37
|
+
DEFAULT_TIME_LIMIT,
|
|
38
|
+
MAX_DNS_TERMS,
|
|
39
|
+
MAX_MX_RECORDS,
|
|
40
|
+
MAX_PTR_NAMES,
|
|
41
|
+
MAX_VOID_LOOKUPS,
|
|
42
|
+
Evaluator,
|
|
43
|
+
Limits,
|
|
44
|
+
)
|
|
45
|
+
from .parser import Term, parse
|
|
46
|
+
from .resolver import BaseResolver, DnsError, LiveResolver, ZoneResolver
|
|
47
|
+
from .trace import DnsQueryRecord, Event, Result, Trace
|
|
48
|
+
|
|
49
|
+
__version__ = "0.1.0"
|
|
50
|
+
|
|
51
|
+
#: Used only when no resolver and no nameservers are supplied. Callers that care
|
|
52
|
+
#: which resolver answers should pass their own; this library never reads the
|
|
53
|
+
#: system resolver configuration or any environment variable.
|
|
54
|
+
DEFAULT_NAMESERVERS: tuple[str, ...] = ("8.8.8.8",)
|
|
55
|
+
|
|
56
|
+
#: Hard cap on real DNS queries per evaluation. This is not the RFC's 10-term
|
|
57
|
+
#: limit: that counts terms, not lookups, and ten `mx` terms with ten MX records
|
|
58
|
+
#: each is 10 terms but 111 queries. This cap stops a hostile zone from turning
|
|
59
|
+
#: one check into unbounded traffic.
|
|
60
|
+
DEFAULT_MAX_QUERIES = 75
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"__version__",
|
|
64
|
+
"acheck",
|
|
65
|
+
"check",
|
|
66
|
+
"BaseResolver",
|
|
67
|
+
"DEFAULT_MAX_QUERIES",
|
|
68
|
+
"DEFAULT_NAMESERVERS",
|
|
69
|
+
"DEFAULT_TIME_LIMIT",
|
|
70
|
+
"DnsError",
|
|
71
|
+
"DnsQueryRecord",
|
|
72
|
+
"Evaluator",
|
|
73
|
+
"Event",
|
|
74
|
+
"Limits",
|
|
75
|
+
"LiveResolver",
|
|
76
|
+
"MAX_DNS_TERMS",
|
|
77
|
+
"MAX_MX_RECORDS",
|
|
78
|
+
"MAX_PTR_NAMES",
|
|
79
|
+
"MAX_VOID_LOOKUPS",
|
|
80
|
+
"Result",
|
|
81
|
+
"SpfError",
|
|
82
|
+
"SpfNoneError",
|
|
83
|
+
"SpfPermError",
|
|
84
|
+
"SpfTempError",
|
|
85
|
+
"SpfUsageError",
|
|
86
|
+
"Term",
|
|
87
|
+
"Trace",
|
|
88
|
+
"ZoneResolver",
|
|
89
|
+
"parse",
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def acheck(
|
|
94
|
+
ip: str,
|
|
95
|
+
sender: str,
|
|
96
|
+
helo: str = "",
|
|
97
|
+
*,
|
|
98
|
+
policy: str | None = None,
|
|
99
|
+
resolver: BaseResolver | None = None,
|
|
100
|
+
nameservers: Iterable[str] | None = None,
|
|
101
|
+
timeout: float = 5.0,
|
|
102
|
+
max_queries: int | None = DEFAULT_MAX_QUERIES,
|
|
103
|
+
time_limit: float = DEFAULT_TIME_LIMIT,
|
|
104
|
+
receiver: str = "spftrace",
|
|
105
|
+
audit: bool = False,
|
|
106
|
+
) -> Result:
|
|
107
|
+
"""Evaluate SPF for `ip` sending as `sender`, returning a traced Result.
|
|
108
|
+
|
|
109
|
+
RFC outcomes are never exceptions. A malformed record, a blown lookup limit
|
|
110
|
+
or an exhausted query budget all come back as a `permerror` verdict with the
|
|
111
|
+
reason in the trace, so a caller never has to wrap this in try/except just to
|
|
112
|
+
survive a hostile zone. Exceptions are reserved for caller mistakes.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
ip: the connecting IP. IPv4-mapped IPv6 is normalised to IPv4.
|
|
116
|
+
sender: MAIL FROM address. A bare domain is treated as postmaster@domain.
|
|
117
|
+
helo: HELO/EHLO name. Defaults to the sender domain.
|
|
118
|
+
policy: evaluate this record instead of looking one up in DNS. Useful for
|
|
119
|
+
testing a record you have not published yet.
|
|
120
|
+
resolver: a ready-made resolver. Mutually exclusive with `nameservers`.
|
|
121
|
+
Supply your own to add caching, or a ZoneResolver to test offline.
|
|
122
|
+
nameservers: resolver addresses to query. Defaults to DEFAULT_NAMESERVERS.
|
|
123
|
+
timeout: per-query DNS timeout in seconds.
|
|
124
|
+
max_queries: hard cap on real DNS queries, or None for no cap.
|
|
125
|
+
time_limit: overall deadline in seconds, checked between terms.
|
|
126
|
+
receiver: value of the %{r} macro.
|
|
127
|
+
audit: keep counting past the 10-term limit to report what a record
|
|
128
|
+
really needs. The verdict is still forced to permerror, so this
|
|
129
|
+
changes visibility and never the answer.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
SpfUsageError: both `resolver` and `nameservers` were supplied.
|
|
133
|
+
"""
|
|
134
|
+
if resolver is not None and nameservers is not None:
|
|
135
|
+
raise SpfUsageError(
|
|
136
|
+
"pass either resolver or nameservers, not both: a supplied resolver "
|
|
137
|
+
"already carries its own nameservers, timeout and query budget"
|
|
138
|
+
)
|
|
139
|
+
if resolver is None:
|
|
140
|
+
resolver = LiveResolver(
|
|
141
|
+
nameservers if nameservers is not None else DEFAULT_NAMESERVERS,
|
|
142
|
+
timeout=timeout,
|
|
143
|
+
max_queries=max_queries,
|
|
144
|
+
)
|
|
145
|
+
evaluator = Evaluator(
|
|
146
|
+
resolver,
|
|
147
|
+
Limits(time_limit=time_limit, audit=audit),
|
|
148
|
+
receiver=receiver,
|
|
149
|
+
policy_override=policy,
|
|
150
|
+
)
|
|
151
|
+
return await evaluator.evaluate(ip, sender, helo)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def check(
|
|
155
|
+
ip: str,
|
|
156
|
+
sender: str,
|
|
157
|
+
helo: str = "",
|
|
158
|
+
*,
|
|
159
|
+
policy: str | None = None,
|
|
160
|
+
resolver: BaseResolver | None = None,
|
|
161
|
+
nameservers: Iterable[str] | None = None,
|
|
162
|
+
timeout: float = 5.0,
|
|
163
|
+
max_queries: int | None = DEFAULT_MAX_QUERIES,
|
|
164
|
+
time_limit: float = DEFAULT_TIME_LIMIT,
|
|
165
|
+
receiver: str = "spftrace",
|
|
166
|
+
audit: bool = False,
|
|
167
|
+
) -> Result:
|
|
168
|
+
"""Blocking form of `acheck`, for scripts and sync code.
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
SpfUsageError: called from inside a running event loop. The evaluator is
|
|
172
|
+
async underneath; from async code, await `acheck` instead. Without
|
|
173
|
+
this check asyncio raises a confusing "cannot be called from a
|
|
174
|
+
running event loop" from several frames down.
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
asyncio.get_running_loop()
|
|
178
|
+
except RuntimeError:
|
|
179
|
+
pass
|
|
180
|
+
else:
|
|
181
|
+
raise SpfUsageError(
|
|
182
|
+
"spftrace.check() cannot be called from a running event loop. "
|
|
183
|
+
"Use 'await spftrace.acheck(...)' instead."
|
|
184
|
+
)
|
|
185
|
+
return asyncio.run(
|
|
186
|
+
acheck(
|
|
187
|
+
ip,
|
|
188
|
+
sender,
|
|
189
|
+
helo,
|
|
190
|
+
policy=policy,
|
|
191
|
+
resolver=resolver,
|
|
192
|
+
nameservers=nameservers,
|
|
193
|
+
timeout=timeout,
|
|
194
|
+
max_queries=max_queries,
|
|
195
|
+
time_limit=time_limit,
|
|
196
|
+
receiver=receiver,
|
|
197
|
+
audit=audit,
|
|
198
|
+
)
|
|
199
|
+
)
|
spftrace/cli.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Command line front end. Renders the trace in the ORF-style step format."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from . import DEFAULT_MAX_QUERIES, DEFAULT_NAMESERVERS, __version__
|
|
11
|
+
from .evaluator import DEFAULT_TIME_LIMIT, Evaluator, Limits
|
|
12
|
+
from .resolver import LiveResolver
|
|
13
|
+
from .trace import Result
|
|
14
|
+
|
|
15
|
+
VERDICT = {
|
|
16
|
+
"pass": "The policy designates the argument IP as a permitted sender.",
|
|
17
|
+
"fail": "The policy does NOT designate the argument IP as a permitted sender.",
|
|
18
|
+
"softfail": "The policy does NOT designate the argument IP as permitted sender, "
|
|
19
|
+
"but it's not quite confident about it.",
|
|
20
|
+
"neutral": "The policy makes no assertion about the argument IP.",
|
|
21
|
+
"none": "No SPF policy was found for the domain.",
|
|
22
|
+
"permerror": "The policy could not be evaluated: permanent error.",
|
|
23
|
+
"temperror": "The policy could not be evaluated: transient error, retry later.",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render_text(result: Result, dns_server: str = "") -> str:
|
|
28
|
+
lines: list[str] = []
|
|
29
|
+
if dns_server:
|
|
30
|
+
lines.append("PARAMETERS")
|
|
31
|
+
lines.append(f"DNS server: {dns_server}")
|
|
32
|
+
lines.append(f"Evaluation time limit: {int(DEFAULT_TIME_LIMIT)} seconds "
|
|
33
|
+
"(RFC 7208 Section 4.6.4)")
|
|
34
|
+
lines.append("Maximum number of void DNS lookups: 2 "
|
|
35
|
+
"(RFC 7208 Section 4.6.4)")
|
|
36
|
+
lines.append("Standards compliance: RFC 7208 (April 2014)")
|
|
37
|
+
lines.append("")
|
|
38
|
+
|
|
39
|
+
for ev in result.trace.to_list():
|
|
40
|
+
indent = " " * ev["depth"]
|
|
41
|
+
stamp = f"+{ev['at_ms']:.0f} ms"
|
|
42
|
+
kind = ev["kind"]
|
|
43
|
+
head = f"{stamp:>10} {indent}"
|
|
44
|
+
if kind == "check_start":
|
|
45
|
+
lines.append(f"{head}SPF check starting.")
|
|
46
|
+
lines.append(f"{head} IP: {ev['ip']}")
|
|
47
|
+
lines.append(f"{head} Sender: {ev['sender']}")
|
|
48
|
+
lines.append(f"{head} Domain: {ev['domain']}")
|
|
49
|
+
lines.append(f"{head} EHLO/HELO domain: {ev['helo']}")
|
|
50
|
+
elif kind == "txt_lookup":
|
|
51
|
+
lines.append(f"{head}Retrieving DNS TXT record for \"{ev['domain']}\".")
|
|
52
|
+
if ev["records"]:
|
|
53
|
+
for i, rec in enumerate(ev["records"], 1):
|
|
54
|
+
lines.append(f"{head} Line #{i}: \"{rec}\"")
|
|
55
|
+
else:
|
|
56
|
+
lines.append(f"{head} No TXT records ({ev['rcode']}).")
|
|
57
|
+
elif kind == "policy_override":
|
|
58
|
+
lines.append(f"{head}Policy supplied by user for \"{ev['domain']}\" "
|
|
59
|
+
"(no DNS lookup, no DNS term consumed).")
|
|
60
|
+
lines.append(f"{head} Policy: \"{ev['policy']}\"")
|
|
61
|
+
elif kind == "policy_parsed":
|
|
62
|
+
lines.append(f"{head}The policy passed syntax validation.")
|
|
63
|
+
lines.append(f"{head}Evaluating SPF mechanisms.")
|
|
64
|
+
elif kind == "parse_error":
|
|
65
|
+
lines.append(f"{head}Syntax error: {ev['message']}")
|
|
66
|
+
elif kind == "mech_start":
|
|
67
|
+
lines.append(f"{head}Evaluating mechanism \"{ev['mechanism']}\".")
|
|
68
|
+
lines.append(f"{head} Qualifier: \"{ev['qualifier']}\"")
|
|
69
|
+
if ev.get("arg"):
|
|
70
|
+
lines.append(f"{head} Argument: \"{ev['arg']}\"")
|
|
71
|
+
if ev.get("cidr4") is not None:
|
|
72
|
+
lines.append(f"{head} CIDR length (IPv4): {ev['cidr4']}")
|
|
73
|
+
if ev.get("cidr6") is not None:
|
|
74
|
+
lines.append(f"{head} CIDR length (IPv6): {ev['cidr6']}")
|
|
75
|
+
lines.append(
|
|
76
|
+
f"{head} DNS limits status: DNS terms {ev['dns_terms_used']} of 10 "
|
|
77
|
+
f"allowed. Void lookups {ev['void_used']} of 2 allowed."
|
|
78
|
+
)
|
|
79
|
+
elif kind == "macro_expand" and ev.get("before"):
|
|
80
|
+
lines.append(
|
|
81
|
+
f"{head}Domain argument after macro expansion: \"{ev['after']}\"."
|
|
82
|
+
)
|
|
83
|
+
elif kind == "limit_exceeded":
|
|
84
|
+
lines.append(
|
|
85
|
+
f"{head}DNS lookup limit exceeded at \"{ev['term']}\" "
|
|
86
|
+
f"({ev['used']} of {ev['allowed']}). {ev['note']}"
|
|
87
|
+
)
|
|
88
|
+
elif kind == "void_lookup":
|
|
89
|
+
lines.append(
|
|
90
|
+
f"{head}A void DNS lookup was encountered "
|
|
91
|
+
f"({ev['used']} of {ev['allowed']} allowed) at \"{ev['term']}\"."
|
|
92
|
+
)
|
|
93
|
+
elif kind == "family_skip":
|
|
94
|
+
lines.append(
|
|
95
|
+
f"{head}The \"{ev['mechanism']}\" mechanism was {ev['note']}."
|
|
96
|
+
)
|
|
97
|
+
elif kind == "exists_miss":
|
|
98
|
+
lines.append(
|
|
99
|
+
f"{head}\"{ev['target']}\" returned {ev['rcode']}; {ev['note']}."
|
|
100
|
+
)
|
|
101
|
+
elif kind == "audit_override":
|
|
102
|
+
lines.append(
|
|
103
|
+
f"{head}Audit mode: evaluation reached \"{ev['observed']}\" but the "
|
|
104
|
+
f"record needs {ev['dns_terms']} lookups of {ev['allowed']} allowed. "
|
|
105
|
+
f"Returning \"permerror\"."
|
|
106
|
+
)
|
|
107
|
+
elif kind == "recurse_in":
|
|
108
|
+
lines.append(f"{head}Entering recursive evaluation ({ev['via']}).")
|
|
109
|
+
elif kind == "recurse_out":
|
|
110
|
+
lines.append(
|
|
111
|
+
f"{head}Returned from recursive evaluation with \"{ev['result']}\"."
|
|
112
|
+
)
|
|
113
|
+
elif kind == "mech_match":
|
|
114
|
+
lines.append(
|
|
115
|
+
f"{head}The mechanism matched with the \"{ev['result']}\" qualifier."
|
|
116
|
+
)
|
|
117
|
+
elif kind == "mech_nomatch":
|
|
118
|
+
lines.append(f"{head}The mechanism did not match.")
|
|
119
|
+
elif kind == "target_invalid":
|
|
120
|
+
lines.append(f"{head}Target \"{ev['target']}\" is not a valid domain; "
|
|
121
|
+
"mechanism cannot match.")
|
|
122
|
+
elif kind == "domain_invalid":
|
|
123
|
+
lines.append(f"{head}Malformed domain \"{ev['domain']}\".")
|
|
124
|
+
elif kind == "ptr_validated":
|
|
125
|
+
lines.append(f"{head}Forward-confirmed PTR name \"{ev['name']}\".")
|
|
126
|
+
elif kind == "mx_match":
|
|
127
|
+
lines.append(f"{head}Matched via MX host \"{ev['exchange']}\".")
|
|
128
|
+
elif kind == "eval_done":
|
|
129
|
+
lines.append(
|
|
130
|
+
f"{head}Policy evaluation for \"{ev['domain']}\" finished with "
|
|
131
|
+
f"SPF \"{ev['result']}\"."
|
|
132
|
+
)
|
|
133
|
+
elif kind == "exp":
|
|
134
|
+
lines.append(f"{head}Explanation from \"{ev['domain']}\": {ev['text']}")
|
|
135
|
+
elif kind in ("exit", "error", "txt_error"):
|
|
136
|
+
lines.append(f"{head}{ev.get('reason') or ev.get('message')}")
|
|
137
|
+
elif kind == "result":
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
lines.append("")
|
|
141
|
+
lines.append("TEST SUMMARY")
|
|
142
|
+
lines.append(
|
|
143
|
+
f"The evaluation completed in {result.elapsed_ms:.0f} ms. "
|
|
144
|
+
f"DNS terms used: {result.dns_terms_used} of 10. "
|
|
145
|
+
f"Void lookups: {result.void_lookups_used} of 2."
|
|
146
|
+
)
|
|
147
|
+
lines.append(f"Result: SPF {result.result}")
|
|
148
|
+
lines.append(VERDICT.get(result.result, ""))
|
|
149
|
+
if result.explanation:
|
|
150
|
+
lines.append(f"Explanation: {result.explanation}")
|
|
151
|
+
for warning in result.warnings:
|
|
152
|
+
lines.append(f"Warning: {warning}")
|
|
153
|
+
return "\n".join(lines)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def run(args: argparse.Namespace) -> int:
|
|
157
|
+
resolver = LiveResolver(
|
|
158
|
+
[args.dns], timeout=args.timeout, max_queries=args.max_queries
|
|
159
|
+
)
|
|
160
|
+
ev = Evaluator(
|
|
161
|
+
resolver,
|
|
162
|
+
Limits(time_limit=args.time_limit, audit=args.audit),
|
|
163
|
+
receiver=args.receiver,
|
|
164
|
+
policy_override=args.policy,
|
|
165
|
+
)
|
|
166
|
+
result = await ev.evaluate(args.ip, args.sender, args.helo or "")
|
|
167
|
+
if args.json:
|
|
168
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
169
|
+
else:
|
|
170
|
+
print(render_text(result, dns_server=args.dns))
|
|
171
|
+
return 0 if result.result in ("pass", "neutral", "softfail", "fail", "none") else 1
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main() -> int:
|
|
175
|
+
p = argparse.ArgumentParser(prog="spftrace", description="SPF evaluator with trace")
|
|
176
|
+
p.add_argument("ip", help="connecting IP address")
|
|
177
|
+
p.add_argument("sender", help="MAIL FROM address (or domain)")
|
|
178
|
+
p.add_argument("--helo", help="HELO/EHLO domain")
|
|
179
|
+
p.add_argument("--policy", help="override the sender domain's SPF record")
|
|
180
|
+
p.add_argument(
|
|
181
|
+
"--dns",
|
|
182
|
+
default=os.environ.get("SPFTRACE_DNS", DEFAULT_NAMESERVERS[0]),
|
|
183
|
+
help="resolver IP (or set SPFTRACE_DNS)",
|
|
184
|
+
)
|
|
185
|
+
p.add_argument(
|
|
186
|
+
"--audit",
|
|
187
|
+
action="store_true",
|
|
188
|
+
help="keep counting lookups past the 10 limit; result stays permerror",
|
|
189
|
+
)
|
|
190
|
+
p.add_argument(
|
|
191
|
+
"--max-queries", type=int, default=DEFAULT_MAX_QUERIES, dest="max_queries"
|
|
192
|
+
)
|
|
193
|
+
p.add_argument("--version", action="version", version=f"spftrace {__version__}")
|
|
194
|
+
p.add_argument("--receiver", default="spftrace", help="value for the %%{r} macro")
|
|
195
|
+
p.add_argument("--timeout", type=float, default=5.0, help="per-query timeout")
|
|
196
|
+
p.add_argument("--time-limit", type=float, default=DEFAULT_TIME_LIMIT)
|
|
197
|
+
p.add_argument("--json", action="store_true", help="emit the raw trace as JSON")
|
|
198
|
+
return asyncio.run(run(p.parse_args()))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
sys.exit(main())
|
spftrace/errors.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""SPF evaluation errors, mapped to RFC 7208 result codes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SpfError(Exception):
|
|
6
|
+
result = "permerror"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SpfPermError(SpfError):
|
|
10
|
+
result = "permerror"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SpfTempError(SpfError):
|
|
14
|
+
result = "temperror"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SpfNoneError(SpfError):
|
|
18
|
+
"""Not an error as such: check_host() exits with 'none'."""
|
|
19
|
+
|
|
20
|
+
result = "none"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SpfUsageError(RuntimeError):
|
|
24
|
+
"""Caller error, not an RFC outcome.
|
|
25
|
+
|
|
26
|
+
Raised for mistakes in how spftrace is called, never for anything a remote
|
|
27
|
+
domain's DNS can cause. RFC results always come back as a verdict on Result.
|
|
28
|
+
"""
|