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 +12 -0
- dnslens/__main__.py +5 -0
- dnslens/api.py +436 -0
- dnslens/audit.py +195 -0
- dnslens/cli.py +432 -0
- dnslens/core.py +279 -0
- dnslens/dnssec.py +89 -0
- dnslens/output.py +73 -0
- dnslens/py.typed +0 -0
- dnslens/server.py +728 -0
- dnslens/tools.py +243 -0
- dnslens/trace.py +162 -0
- dnslens-1.0.2.dist-info/METADATA +983 -0
- dnslens-1.0.2.dist-info/RECORD +18 -0
- dnslens-1.0.2.dist-info/WHEEL +5 -0
- dnslens-1.0.2.dist-info/entry_points.txt +2 -0
- dnslens-1.0.2.dist-info/licenses/LICENSE +21 -0
- dnslens-1.0.2.dist-info/top_level.txt +1 -0
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
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
|