dnslens 1.0.2__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.
dnslens/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """dnslens - cross-platform DNS toolkit for developers and DevOps."""
2
+ try:
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("dnslens")
7
+ except PackageNotFoundError: # running from a source checkout
8
+ __version__ = "0.0.0+local"
9
+ except ImportError: # pragma: no cover
10
+ __version__ = "0.0.0+local"
11
+
12
+ __all__ = ["__version__"]
dnslens/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
dnslens/api.py ADDED
@@ -0,0 +1,436 @@
1
+ """HTTP API (JSON + Prometheus text) exposing the dnslens tools. Standard library only."""
2
+ from __future__ import annotations
3
+
4
+ import hmac
5
+ import json
6
+ import logging
7
+ import re
8
+ import socket
9
+ import threading
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
14
+ from urllib.parse import parse_qs, urlparse
15
+
16
+ import dns.exception
17
+ import dns.rdatatype
18
+ import dns.reversename
19
+
20
+ from . import __version__
21
+ from .audit import audit
22
+ from .core import PUBLIC_RESOLVERS, SYSTEM, QueryOpts, Target, parse_target, query_target
23
+ from .dnssec import check_dnssec
24
+ from .tools import DEFAULT_BENCH_DOMAINS, bench, propagate
25
+ from .trace import trace
26
+
27
+ log = logging.getLogger("dnslens.api")
28
+
29
+ _NAME_RE = re.compile(r"^[\w.*\-]{1,253}$")
30
+ _TRUE = {"1", "true", "yes", "on", ""}
31
+
32
+
33
+ class ApiError(Exception):
34
+ def __init__(self, status: int, message: str) -> None:
35
+ super().__init__(message)
36
+ self.status = status
37
+ self.message = message
38
+
39
+
40
+ @dataclass
41
+ class Raw:
42
+ body: str
43
+ content_type: str = "text/plain; version=0.0.4; charset=utf-8"
44
+
45
+
46
+ @dataclass
47
+ class ApiConfig:
48
+ token: Optional[str] = None
49
+ resolvers: Dict[str, Target] = field(default_factory=dict) # extra named resolvers (-r)
50
+ allow_custom: bool = False
51
+ default_timeout: float = 3.0
52
+ max_concurrent: int = 16
53
+ max_bench: int = 50
54
+
55
+ def registry(self) -> Dict[str, Target]:
56
+ reg = {t.name: t for t in PUBLIC_RESOLVERS}
57
+ reg["system"] = SYSTEM
58
+ reg.update(self.resolvers)
59
+ return reg
60
+
61
+ def default_targets(self, public: bool) -> List[Target]:
62
+ if self.resolvers:
63
+ return list(self.resolvers.values())
64
+ return list(PUBLIC_RESOLVERS) if public else [SYSTEM]
65
+
66
+
67
+ class Params:
68
+ def __init__(self, qs: Dict[str, List[str]]) -> None:
69
+ self.qs = qs
70
+
71
+ def text(self, key: str, required: bool = False, default: Optional[str] = None) -> Optional[str]:
72
+ v = self.qs.get(key)
73
+ if not v or v[0] == "":
74
+ if required:
75
+ raise ApiError(400, f"missing required parameter '{key}'")
76
+ return default
77
+ return v[0]
78
+
79
+ def many(self, key: str, limit: int = 20) -> List[str]:
80
+ out = [x for v in self.qs.get(key, []) for x in v.split(",") if x] if key != "expect" else [
81
+ x for x in self.qs.get(key, []) if x
82
+ ]
83
+ if len(out) > limit:
84
+ raise ApiError(400, f"too many '{key}' values (max {limit})")
85
+ return out
86
+
87
+ def flag(self, key: str) -> bool:
88
+ v = self.qs.get(key)
89
+ return bool(v) and v is not None and v[-1].lower() in _TRUE
90
+
91
+ def num(self, key: str, default: float, lo: float, hi: float, cast: Callable[[Any], Any] = float) -> Any:
92
+ v = self.text(key)
93
+ if v is None:
94
+ return default
95
+ try:
96
+ n = cast(v)
97
+ except ValueError:
98
+ raise ApiError(400, f"parameter '{key}' must be a number") from None
99
+ if not lo <= n <= hi:
100
+ raise ApiError(400, f"parameter '{key}' must be between {lo} and {hi}")
101
+ return n
102
+
103
+ def choice(self, key: str, options: Sequence[str], default: Optional[str] = None) -> str:
104
+ v = self.text(key, required=default is None, default=default)
105
+ assert v is not None
106
+ if v not in options:
107
+ raise ApiError(400, f"parameter '{key}' must be one of: {', '.join(options)}")
108
+ return v
109
+
110
+ def name(self, key: str = "name") -> str:
111
+ v = self.text(key, required=True)
112
+ assert v is not None
113
+ if not _NAME_RE.match(v):
114
+ raise ApiError(400, f"invalid {key}")
115
+ return v
116
+
117
+ def rtype(self, default: str = "A") -> str:
118
+ v = (self.text("type", default=default) or default).upper()
119
+ try:
120
+ dns.rdatatype.from_text(v)
121
+ except Exception:
122
+ raise ApiError(400, f"unknown record type '{v}'") from None
123
+ return v
124
+
125
+
126
+ def _opts(cfg: ApiConfig, p: Params, **kw: Any) -> QueryOpts:
127
+ return QueryOpts(
128
+ timeout=p.num("timeout", cfg.default_timeout, 0.2, 10.0),
129
+ retries=p.num("retries", 1, 0, 2, int),
130
+ transport=p.choice("transport", ("udp", "tcp", "tls"), "udp"),
131
+ **kw,
132
+ )
133
+
134
+
135
+ def _targets(cfg: ApiConfig, p: Params, public_default: bool) -> List[Target]:
136
+ names = p.many("resolver")
137
+ if not names:
138
+ return cfg.default_targets(public_default)
139
+ reg = cfg.registry()
140
+ out: List[Target] = []
141
+ for n in names:
142
+ if n == "public":
143
+ out.extend(PUBLIC_RESOLVERS)
144
+ elif n in reg:
145
+ out.append(reg[n])
146
+ elif cfg.allow_custom:
147
+ out.append(parse_target(n))
148
+ else:
149
+ raise ApiError(400, f"unknown resolver '{n}'; known: {', '.join(sorted(reg))}, public")
150
+ return out
151
+
152
+
153
+ def _server(cfg: ApiConfig, p: Params) -> Optional[str]:
154
+ return _targets(cfg, p, False)[0].address
155
+
156
+
157
+ # ------------------------------------------------------------------ endpoints ---
158
+
159
+
160
+ def ep_health(cfg: ApiConfig, p: Params) -> Any:
161
+ return {"status": "ok", "version": __version__}
162
+
163
+
164
+ def ep_query(cfg: ApiConfig, p: Params) -> Any:
165
+ name = p.name()
166
+ types = [t.upper() for t in (p.text("type", default="A") or "A").split(",") if t]
167
+ if not 1 <= len(types) <= 5:
168
+ raise ApiError(400, "1 to 5 record types allowed")
169
+ for ty in types:
170
+ Params({"type": [ty]}).rtype()
171
+ opts = _opts(cfg, p, dnssec=p.flag("dnssec"))
172
+ rd = not p.flag("norecurse")
173
+ out = []
174
+ for tgt in _targets(cfg, p, False):
175
+ for ty in types:
176
+ out.append(dict(query_target(tgt, name, ty, opts, rd).to_dict(), resolver=tgt.name))
177
+ return out
178
+
179
+
180
+ def ep_reverse(cfg: ApiConfig, p: Params) -> Any:
181
+ ip = p.text("ip", required=True)
182
+ try:
183
+ qname = dns.reversename.from_address(ip or "").to_text()
184
+ except (dns.exception.DNSException, ValueError):
185
+ raise ApiError(400, "invalid IP address") from None
186
+ opts = _opts(cfg, p)
187
+ return [dict(query_target(t, qname, "PTR", opts).to_dict(), resolver=t.name)
188
+ for t in _targets(cfg, p, False)]
189
+
190
+
191
+ def ep_propagate(cfg: ApiConfig, p: Params) -> Any:
192
+ return propagate(_targets(cfg, p, True), p.name(), p.rtype(), _opts(cfg, p),
193
+ p.many("expect") or None, p.flag("strict")).to_dict()
194
+
195
+
196
+ def ep_trace(cfg: ApiConfig, p: Params) -> Any:
197
+ return trace(p.name(), p.rtype(), _opts(cfg, p)).to_dict()
198
+
199
+
200
+ def ep_audit(cfg: ApiConfig, p: Params) -> Any:
201
+ f = audit(p.name("domain"), _server(cfg, p), _opts(cfg, p), p.many("dkim_selector", 10))
202
+ return {"ok": not any(x.level == "FAIL" for x in f), "findings": [x.to_dict() for x in f]}
203
+
204
+
205
+ def ep_dnssec(cfg: ApiConfig, p: Params) -> Any:
206
+ f = check_dnssec(p.name("domain"), _server(cfg, p), _opts(cfg, p))
207
+ return {"ok": not any(x.level == "FAIL" for x in f), "findings": [x.to_dict() for x in f]}
208
+
209
+
210
+ def ep_bench(cfg: ApiConfig, p: Params) -> Any:
211
+ count = p.num("count", 5, 1, cfg.max_bench, int)
212
+ doms = p.many("domain", 10)
213
+ for d in doms:
214
+ if not _NAME_RE.match(d):
215
+ raise ApiError(400, "invalid domain")
216
+ res = bench(_targets(cfg, p, True), doms or DEFAULT_BENCH_DOMAINS, count, _opts(cfg, p), p.flag("uncached"))
217
+ return [r.to_dict() for r in res]
218
+
219
+
220
+ def _prom(metrics: Sequence[Tuple[str, str, float]]) -> str:
221
+ lines = []
222
+ for name, help_, value in metrics:
223
+ lines += [f"# HELP {name} {help_}", f"# TYPE {name} gauge", f"{name} {value:g}"]
224
+ return "\n".join(lines) + "\n"
225
+
226
+
227
+ def ep_probe(cfg: ApiConfig, p: Params) -> Any:
228
+ """Blackbox-exporter style probe for Prometheus: /probe?kind=propagate&name=...&expect=..."""
229
+ kind = p.choice("kind", ("query", "propagate", "audit", "dnssec"))
230
+ t0 = time.perf_counter()
231
+ m: List[Tuple[str, str, float]] = []
232
+ if kind == "query":
233
+ name, rtype = p.name(), p.rtype()
234
+ expect = p.many("expect")
235
+ a = query_target(_targets(cfg, p, False)[0], name, rtype, _opts(cfg, p))
236
+ ok = a.ok and bool(a.values())
237
+ if expect:
238
+ from .tools import normalise_expected
239
+ ok = ok and a.values() == normalise_expected(rtype, expect)
240
+ m += [("dnslens_probe_success", "1 if the probe passed", float(ok)),
241
+ ("dnslens_probe_answer_records", "number of records of the queried type", float(len(a.values()))),
242
+ ("dnslens_probe_dns_seconds", "time of the DNS query", a.elapsed_ms / 1000)]
243
+ elif kind == "propagate":
244
+ rep = propagate(_targets(cfg, p, True), p.name(), p.rtype(), _opts(cfg, p),
245
+ p.many("expect") or None, p.flag("strict"))
246
+ match = (max((len(v) for v in rep.groups.values()), default=0) if rep.expected is None else
247
+ sum(len(v) for g, v in rep.groups.items() if g == rep.expected))
248
+ m += [("dnslens_probe_success", "1 if the probe passed", float(rep.ok)),
249
+ ("dnslens_probe_resolvers_total", "resolvers queried", float(len(rep.results))),
250
+ ("dnslens_probe_resolvers_responding", "resolvers that answered", float(rep.responders)),
251
+ ("dnslens_probe_resolvers_matching", "resolvers returning the expected (or majority) answer", float(match)),
252
+ ("dnslens_probe_distinct_answers", "number of distinct answer sets", float(len(rep.groups)))]
253
+ else:
254
+ dom = p.name("domain")
255
+ f = (audit(dom, _server(cfg, p), _opts(cfg, p), p.many("dkim_selector", 10)) if kind == "audit"
256
+ else check_dnssec(dom, _server(cfg, p), _opts(cfg, p)))
257
+ fails, warns = sum(x.level == "FAIL" for x in f), sum(x.level == "WARN" for x in f)
258
+ m += [("dnslens_probe_success", "1 if the probe passed", float(fails == 0)),
259
+ ("dnslens_probe_failures", "findings with level FAIL", float(fails)),
260
+ ("dnslens_probe_warnings", "findings with level WARN", float(warns))]
261
+ m.append(("dnslens_probe_duration_seconds", "total probe duration", time.perf_counter() - t0))
262
+ return Raw(_prom(m))
263
+
264
+
265
+ ROUTES: Dict[str, Callable[[ApiConfig, Params], Any]] = {
266
+ "/healthz": ep_health, "/query": ep_query, "/reverse": ep_reverse, "/propagate": ep_propagate,
267
+ "/trace": ep_trace, "/audit": ep_audit, "/dnssec": ep_dnssec, "/bench": ep_bench, "/probe": ep_probe,
268
+ }
269
+
270
+
271
+ # -------------------------------------------------------------------- server ---
272
+
273
+
274
+ class Metrics:
275
+ def __init__(self) -> None:
276
+ self._lock = threading.Lock()
277
+ self.requests: Dict[Tuple[str, int], int] = {}
278
+ self.dur_sum: Dict[str, float] = {}
279
+ self.dur_count: Dict[str, int] = {}
280
+ self.inflight = 0
281
+ self.start = time.time()
282
+
283
+ def observe(self, path: str, status: int, seconds: float) -> None:
284
+ with self._lock:
285
+ self.requests[(path, status)] = self.requests.get((path, status), 0) + 1
286
+ self.dur_sum[path] = self.dur_sum.get(path, 0.0) + seconds
287
+ self.dur_count[path] = self.dur_count.get(path, 0) + 1
288
+
289
+ def render(self) -> str:
290
+ with self._lock:
291
+ out = ["# HELP dnslens_api_requests_total HTTP requests", "# TYPE dnslens_api_requests_total counter"]
292
+ for (path, status), n in sorted(self.requests.items()):
293
+ out.append(f'dnslens_api_requests_total{{path="{path}",code="{status}"}} {n}')
294
+ out += ["# HELP dnslens_api_request_duration_seconds request duration",
295
+ "# TYPE dnslens_api_request_duration_seconds summary"]
296
+ for path in sorted(self.dur_sum):
297
+ out.append(f'dnslens_api_request_duration_seconds_sum{{path="{path}"}} {self.dur_sum[path]:.6f}')
298
+ out.append(f'dnslens_api_request_duration_seconds_count{{path="{path}"}} {self.dur_count[path]}')
299
+ out += ["# HELP dnslens_api_inflight_requests requests being processed",
300
+ "# TYPE dnslens_api_inflight_requests gauge", f"dnslens_api_inflight_requests {self.inflight}",
301
+ "# HELP dnslens_api_uptime_seconds process uptime",
302
+ "# TYPE dnslens_api_uptime_seconds gauge", f"dnslens_api_uptime_seconds {time.time() - self.start:.0f}",
303
+ "# HELP dnslens_build_info version", "# TYPE dnslens_build_info gauge",
304
+ f'dnslens_build_info{{version="{__version__}"}} 1']
305
+ return "\n".join(out) + "\n"
306
+
307
+
308
+ class _HTTPServer(ThreadingHTTPServer):
309
+ daemon_threads = True
310
+ allow_reuse_address = True
311
+
312
+
313
+ class _HTTPServer6(_HTTPServer):
314
+ address_family = socket.AF_INET6
315
+
316
+
317
+ def _make_handler(cfg: ApiConfig, metrics: Metrics, sem: threading.BoundedSemaphore) -> Any:
318
+ class Handler(BaseHTTPRequestHandler):
319
+ server_version = f"dnslens/{__version__}"
320
+ protocol_version = "HTTP/1.1"
321
+
322
+ def log_message(self, fmt: str, *args: Any) -> None: # route to logging
323
+ log.info("%s %s", self.address_string(), fmt % args)
324
+
325
+ def _send(self, status: int, body: bytes, ctype: str, head: bool) -> None:
326
+ self.send_response(status)
327
+ self.send_header("Content-Type", ctype)
328
+ self.send_header("Content-Length", str(len(body)))
329
+ self.send_header("Cache-Control", "no-store")
330
+ self.send_header("X-Content-Type-Options", "nosniff")
331
+ self.end_headers()
332
+ if not head:
333
+ self.wfile.write(body)
334
+
335
+ def _authorized(self) -> bool:
336
+ if not cfg.token:
337
+ return True
338
+ got = self.headers.get("X-API-Key", "")
339
+ auth = self.headers.get("Authorization", "")
340
+ if auth.lower().startswith("bearer "):
341
+ got = auth[7:].strip()
342
+ return hmac.compare_digest(got.encode(), cfg.token.encode())
343
+
344
+ def _handle(self, head: bool) -> None:
345
+ t0 = time.perf_counter()
346
+ parsed = urlparse(self.path)
347
+ path = parsed.path.rstrip("/") or "/"
348
+ status, label = 500, path if (path in ROUTES or path in ("/", "/metrics")) else "other"
349
+ counted = False
350
+ try:
351
+ if path == "/healthz":
352
+ result: Any = ep_health(cfg, Params({}))
353
+ elif not self._authorized():
354
+ raise ApiError(401, "missing or invalid token")
355
+ elif path == "/":
356
+ result = {"name": "dnslens", "version": __version__,
357
+ "endpoints": sorted(list(ROUTES) + ["/metrics"])}
358
+ elif path == "/metrics":
359
+ result = Raw(metrics.render())
360
+ elif path in ROUTES:
361
+ if not sem.acquire(blocking=False):
362
+ raise ApiError(503, "server busy, retry later")
363
+ counted = True
364
+ with metrics._lock:
365
+ metrics.inflight += 1
366
+ try:
367
+ result = ROUTES[path](cfg, Params(parse_qs(parsed.query, keep_blank_values=True)))
368
+ finally:
369
+ with metrics._lock:
370
+ metrics.inflight -= 1
371
+ sem.release()
372
+ else:
373
+ raise ApiError(404, "not found")
374
+ status = 200
375
+ if isinstance(result, Raw):
376
+ body, ctype = result.body.encode(), result.content_type
377
+ else:
378
+ body, ctype = json.dumps(result, indent=2, default=str).encode(), "application/json"
379
+ except ApiError as e:
380
+ status, body, ctype = e.status, json.dumps({"error": e.message}).encode(), "application/json"
381
+ except Exception:
382
+ log.exception("unhandled error on %s", path)
383
+ status, body, ctype = 500, b'{"error": "internal error"}', "application/json"
384
+ self._send(status, body, ctype, head)
385
+ if counted or path in ROUTES or path in ("/", "/metrics"):
386
+ metrics.observe(label, status, time.perf_counter() - t0)
387
+
388
+ def do_GET(self) -> None:
389
+ self._handle(False)
390
+
391
+ def do_HEAD(self) -> None:
392
+ self._handle(True)
393
+
394
+ def _nope(self) -> None:
395
+ body = b'{"error": "method not allowed"}'
396
+ self.send_response(405)
397
+ self.send_header("Allow", "GET, HEAD")
398
+ self.send_header("Content-Type", "application/json")
399
+ self.send_header("Content-Length", str(len(body)))
400
+ self.end_headers()
401
+ self.wfile.write(body)
402
+
403
+ do_POST = do_PUT = do_DELETE = do_PATCH = _nope # type: ignore[assignment]
404
+
405
+ return Handler
406
+
407
+
408
+ class ApiServer:
409
+ def __init__(self, cfg: ApiConfig, host: str = "127.0.0.1", port: int = 8053) -> None:
410
+ self.cfg = cfg
411
+ self.metrics = Metrics()
412
+ self._sem = threading.BoundedSemaphore(cfg.max_concurrent)
413
+ cls = _HTTPServer6 if ":" in host else _HTTPServer
414
+ self.httpd = cls((host, port), _make_handler(cfg, self.metrics, self._sem))
415
+ self.host, self.port = host, self.httpd.server_address[1]
416
+ self._thread: Optional[threading.Thread] = None
417
+
418
+ def start(self) -> ApiServer:
419
+ self._thread = threading.Thread(target=self.httpd.serve_forever, kwargs={"poll_interval": 0.2}, daemon=True)
420
+ self._thread.start()
421
+ return self
422
+
423
+ def serve_forever(self) -> None:
424
+ self.httpd.serve_forever(poll_interval=0.2)
425
+
426
+ def stop(self) -> None:
427
+ self.httpd.shutdown()
428
+ self.httpd.server_close()
429
+ if self._thread:
430
+ self._thread.join(1)
431
+
432
+ def __enter__(self) -> ApiServer:
433
+ return self.start()
434
+
435
+ def __exit__(self, *exc: object) -> None:
436
+ self.stop()
dnslens/audit.py ADDED
@@ -0,0 +1,195 @@
1
+ """Domain health audit: delegation, SOA sync, apex CNAME, mail auth, CAA, DNSSEC."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import Callable, List, Optional, Sequence, Set, Tuple
6
+
7
+ from .core import (
8
+ FAIL,
9
+ INFO,
10
+ OK,
11
+ WARN,
12
+ Answer,
13
+ Finding,
14
+ QueryOpts,
15
+ query,
16
+ txt_join,
17
+ )
18
+ from .dnssec import check_dnssec
19
+
20
+ QFn = Callable[[str, str], Answer]
21
+
22
+
23
+ def _txt(ans: Answer) -> List[str]:
24
+ return [txt_join(r.data) for r in ans.records if r.type == "TXT"]
25
+
26
+
27
+ def spf_record(domain: str, q: QFn) -> Optional[str]:
28
+ recs = [t for t in _txt(q(domain, "TXT")) if t.lower().startswith("v=spf1")]
29
+ return recs[0] if recs else None
30
+
31
+
32
+ _DIRECT = re.compile(r"^(a|mx|ptr)(:|/|$)")
33
+
34
+
35
+ def spf_lookups(domain: str, q: QFn, depth: int = 0, seen: Optional[Set[str]] = None) -> int:
36
+ """Count DNS-lookup-causing terms (RFC 7208 limit: 10), following include/redirect."""
37
+ seen = seen if seen is not None else set()
38
+ if depth > 10 or domain.lower() in seen:
39
+ return 0
40
+ seen.add(domain.lower())
41
+ rec = spf_record(domain, q)
42
+ if not rec:
43
+ return 0
44
+ n = 0
45
+ for part in rec.split()[1:]:
46
+ low = part.lower().lstrip("+-~?")
47
+ if low.startswith(("include:", "redirect=")):
48
+ n += 1 + spf_lookups(part.split(":", 1)[-1].split("=", 1)[-1], q, depth + 1, seen)
49
+ elif low.startswith("exists:") or _DIRECT.match(low):
50
+ n += 1
51
+ return n
52
+
53
+
54
+ def _soa_serial(ans: Answer) -> Optional[int]:
55
+ rd = ans.rdatas("SOA")
56
+ return int(rd[0].serial) if rd else None
57
+
58
+
59
+ def _check_delegation(domain: str, q: QFn, opts: QueryOpts) -> List[Finding]:
60
+ f: List[Finding] = []
61
+ ns_ans = q(domain, "NS")
62
+ if ns_ans.error:
63
+ return [Finding(FAIL, "ns", f"NS lookup failed: {ns_ans.error}")]
64
+ if ns_ans.rcode == "NXDOMAIN":
65
+ return [Finding(FAIL, "ns", "domain does not exist (NXDOMAIN)")]
66
+ names = sorted({r.data for r in ns_ans.records if r.type == "NS"})
67
+ if not names:
68
+ return [Finding(WARN, "ns", "no NS records at this name (not a zone apex?)")]
69
+ if len(names) < 2:
70
+ f.append(Finding(WARN, "ns", f"only {len(names)} nameserver: no redundancy"))
71
+ else:
72
+ f.append(Finding(OK, "ns", f"{len(names)} nameservers: {', '.join(n.rstrip('.') for n in names)}"))
73
+
74
+ addrs: List[Tuple[str, str]] = []
75
+ for n in names:
76
+ found = False
77
+ for rt in ("A", "AAAA"):
78
+ for r in q(n, rt).records:
79
+ if r.type == rt:
80
+ addrs.append((n.rstrip("."), r.data))
81
+ found = True
82
+ if not found:
83
+ f.append(Finding(FAIL, "ns", f"{n.rstrip('.')} has no A/AAAA address"))
84
+
85
+ serials = {}
86
+ for n, ip in addrs:
87
+ a = query(domain, "SOA", ip, opts, rd=False)
88
+ v6 = ":" in ip
89
+ if a.error:
90
+ lvl = WARN if v6 else FAIL
91
+ note = " (may be missing local IPv6 connectivity)" if v6 else ""
92
+ f.append(Finding(lvl, "ns", f"{n} [{ip}] unreachable: {a.error}{note}"))
93
+ continue
94
+ if "AA" not in a.flags.split():
95
+ f.append(Finding(FAIL, "ns", f"{n} [{ip}] answered non-authoritatively (lame delegation)"))
96
+ continue
97
+ serial = _soa_serial(a)
98
+ if serial is None:
99
+ f.append(Finding(FAIL, "ns", f"{n} [{ip}] returned no SOA ({a.rcode})"))
100
+ continue
101
+ serials[f"{n} [{ip}]"] = serial
102
+ if serials:
103
+ if len(set(serials.values())) == 1:
104
+ f.append(Finding(OK, "soa", f"serial {next(iter(serials.values()))} consistent on all {len(serials)} reachable servers"))
105
+ else:
106
+ detail = ", ".join(f"{k}={v}" for k, v in serials.items())
107
+ f.append(Finding(WARN, "soa", f"serial mismatch between nameservers: {detail}"))
108
+ return f
109
+
110
+
111
+ def _check_mail(domain: str, q: QFn, dkim: Sequence[str]) -> List[Finding]:
112
+ f: List[Finding] = []
113
+ mx = [r.data for r in q(domain, "MX").records if r.type == "MX"]
114
+ has_mail = bool(mx) and mx != ["0 ."]
115
+ if has_mail:
116
+ f.append(Finding(OK, "mx", f"{len(mx)} MX record(s)"))
117
+ else:
118
+ f.append(Finding(INFO, "mx", "no MX (domain does not receive mail)"))
119
+ miss = WARN if has_mail else INFO
120
+
121
+ spf = spf_record(domain, q)
122
+ if spf is None:
123
+ f.append(Finding(miss, "spf", "no SPF record"))
124
+ else:
125
+ n_spf = len([t for t in _txt(q(domain, "TXT")) if t.lower().startswith("v=spf1")])
126
+ if n_spf > 1:
127
+ f.append(Finding(FAIL, "spf", "multiple SPF records (permerror)"))
128
+ terms = [p.lower() for p in spf.split()[1:]]
129
+ if "+all" in terms or "all" in terms:
130
+ f.append(Finding(FAIL, "spf", "SPF ends in +all: anyone may send as this domain"))
131
+ elif "?all" in terms:
132
+ f.append(Finding(WARN, "spf", "SPF ends in ?all (neutral)"))
133
+ elif "~all" in terms or "-all" in terms or any(t.startswith("redirect=") for t in terms):
134
+ f.append(Finding(OK, "spf", "SPF present with a restrictive policy"))
135
+ else:
136
+ f.append(Finding(WARN, "spf", "SPF has no terminating 'all' or redirect"))
137
+ n = spf_lookups(domain, q)
138
+ if n > 10:
139
+ f.append(Finding(FAIL, "spf", f"{n} DNS lookups exceeds the limit of 10 (permerror)"))
140
+ else:
141
+ f.append(Finding(OK, "spf", f"{n}/10 DNS lookups"))
142
+
143
+ dm = [t for t in _txt(q(f"_dmarc.{domain}", "TXT")) if t.upper().startswith("V=DMARC1")]
144
+ if not dm:
145
+ f.append(Finding(miss, "dmarc", "no DMARC record"))
146
+ else:
147
+ tags = {}
148
+ for part in dm[0].split(";"):
149
+ if "=" in part:
150
+ k, v = part.split("=", 1)
151
+ tags[k.strip().lower()] = v.strip().lower()
152
+ pol = tags.get("p", "")
153
+ if pol in ("quarantine", "reject"):
154
+ f.append(Finding(OK, "dmarc", f"policy p={pol}"))
155
+ else:
156
+ f.append(Finding(WARN, "dmarc", f"policy p={pol or '?'} (monitoring only)"))
157
+ if "rua" not in tags:
158
+ f.append(Finding(INFO, "dmarc", "no rua= reporting address"))
159
+
160
+ for sel in dkim:
161
+ recs = _txt(q(f"{sel}._domainkey.{domain}", "TXT"))
162
+ if any("p=" in t for t in recs):
163
+ f.append(Finding(OK, "dkim", f"selector '{sel}' has a key"))
164
+ else:
165
+ f.append(Finding(FAIL, "dkim", f"selector '{sel}' not found"))
166
+ return f
167
+
168
+
169
+ def audit(
170
+ domain: str,
171
+ server: Optional[str] = None,
172
+ opts: QueryOpts = QueryOpts(),
173
+ dkim_selectors: Sequence[str] = (),
174
+ ) -> List[Finding]:
175
+ def q(name: str, rtype: str) -> Answer:
176
+ return query(name, rtype, server, opts)
177
+
178
+ findings = _check_delegation(domain, q, opts)
179
+ if any(x.level == FAIL and "NXDOMAIN" in x.message for x in findings):
180
+ return findings
181
+
182
+ cname = q(domain, "CNAME")
183
+ if any(r.type == "CNAME" and r.name.rstrip(".").lower() == domain.rstrip(".").lower() for r in cname.records):
184
+ findings.append(Finding(FAIL, "apex", "CNAME at zone apex conflicts with SOA/NS records"))
185
+
186
+ findings += _check_mail(domain, q, dkim_selectors)
187
+
188
+ caa = q(domain, "CAA")
189
+ if any(r.type == "CAA" for r in caa.records):
190
+ findings.append(Finding(OK, "caa", "CAA records present"))
191
+ else:
192
+ findings.append(Finding(INFO, "caa", "no CAA records (any CA may issue certificates)"))
193
+
194
+ findings += check_dnssec(domain, server, opts)
195
+ return findings