opskit 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.
opskit/dns/api.py ADDED
@@ -0,0 +1,415 @@
1
+ """Public DNS diagnostics API — the CLI is a thin client over this module.
2
+
3
+ Functions return typed results on success and raise :class:`opskit.dns.errors.DnsError`
4
+ subclasses (or :class:`opskit.core.errors.UsageError`) on failure. Nothing here prints or
5
+ calls ``sys.exit``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from collections.abc import Sequence
12
+
13
+ import dns.exception
14
+ import dns.reversename
15
+
16
+ from opskit.core.errors import UsageError
17
+ from opskit.dns.errors import DnsError, NxDomain
18
+ from opskit.dns.models import (
19
+ DnsQuery,
20
+ DnsRecord,
21
+ LookupResult,
22
+ Outcome,
23
+ RecordType,
24
+ Resolver,
25
+ ResolverAnswer,
26
+ ResolverComparison,
27
+ TraceStep,
28
+ Transport,
29
+ )
30
+ from opskit.dns.resolver import DnspythonResolver, system_nameserver, trace_resolution
31
+ from opskit.dns.resolver import Resolver as ResolverEngine
32
+
33
+ _MAX_PORT = 65535
34
+ _TARGET_REQUIRED = "a target name is required"
35
+
36
+ # The common forward record types queried by lookup_all() / `--all`. DNS `ANY` is deprecated
37
+ # (RFC 8482), so a one-stop lookup fans out across these individually.
38
+ ALL_RECORD_TYPES: tuple[RecordType, ...] = (
39
+ RecordType.A,
40
+ RecordType.AAAA,
41
+ RecordType.CNAME,
42
+ RecordType.MX,
43
+ RecordType.NS,
44
+ RecordType.SOA,
45
+ RecordType.TXT,
46
+ RecordType.SRV,
47
+ RecordType.CAA,
48
+ )
49
+
50
+
51
+ def _coerce_types(types: Sequence[RecordType | str]) -> tuple[RecordType, ...]:
52
+ coerced: list[RecordType] = []
53
+ for value in types:
54
+ if isinstance(value, RecordType):
55
+ coerced.append(value)
56
+ continue
57
+ try:
58
+ coerced.append(RecordType(str(value).upper()))
59
+ except ValueError as exc:
60
+ raise UsageError(f"unknown record type: {value}") from exc
61
+ if not coerced:
62
+ raise UsageError("at least one record type is required")
63
+ return tuple(coerced)
64
+
65
+
66
+ def _coerce_servers(server: str | Sequence[str] | None) -> tuple[str, ...]:
67
+ if server is None:
68
+ return ()
69
+ if isinstance(server, str):
70
+ return (server,)
71
+ return tuple(server)
72
+
73
+
74
+ def _coerce_transport(transport: Transport | str) -> Transport:
75
+ if isinstance(transport, Transport):
76
+ return transport
77
+ try:
78
+ return Transport(str(transport).lower())
79
+ except ValueError as exc:
80
+ raise UsageError(f"unknown transport: {transport}") from exc
81
+
82
+
83
+ def _validate(timeout: float, retries: int, port: int) -> None:
84
+ if timeout <= 0:
85
+ raise UsageError("timeout must be positive")
86
+ if retries < 0:
87
+ raise UsageError("retries must be >= 0")
88
+ if not 1 <= port <= _MAX_PORT:
89
+ raise UsageError(f"port must be between 1 and {_MAX_PORT}")
90
+
91
+
92
+ def _prepare(
93
+ target: str,
94
+ record_types: tuple[RecordType, ...],
95
+ *,
96
+ server: str | Sequence[str] | None,
97
+ transport: Transport | str,
98
+ timeout: float,
99
+ retries: int,
100
+ port: int,
101
+ resolver: ResolverEngine | None,
102
+ ) -> tuple[ResolverEngine, str, DnsQuery]:
103
+ """Coerce/validate controls and build the (engine, server address, query) shared by lookups."""
104
+ transport_enum = _coerce_transport(transport)
105
+ servers = _coerce_servers(server)
106
+ _validate(timeout, retries, port)
107
+ server_addr = servers[0] if servers else system_nameserver()
108
+ engine: ResolverEngine = resolver if resolver is not None else DnspythonResolver()
109
+ query = DnsQuery(
110
+ target=target,
111
+ record_types=record_types,
112
+ servers=servers,
113
+ transport=transport_enum,
114
+ timeout_s=timeout,
115
+ retries=retries,
116
+ port=port,
117
+ )
118
+ return engine, server_addr, query
119
+
120
+
121
+ def _query_types(
122
+ engine: ResolverEngine,
123
+ target: str,
124
+ record_types: Sequence[RecordType],
125
+ server_addr: str,
126
+ query: DnsQuery,
127
+ ) -> list[DnsRecord]:
128
+ """Query each record type, tolerating per-type failures.
129
+
130
+ Records from the types that answer are aggregated; a failure is surfaced only when nothing
131
+ resolves. NXDOMAIN is name-level and always propagates.
132
+ """
133
+ records: list[DnsRecord] = []
134
+ first_error: DnsError | None = None
135
+ for rtype in record_types:
136
+ try:
137
+ records.extend(
138
+ engine.query(
139
+ target,
140
+ rtype,
141
+ server=server_addr,
142
+ transport=query.transport,
143
+ timeout=query.timeout_s,
144
+ retries=query.retries,
145
+ port=query.port,
146
+ )
147
+ )
148
+ except NxDomain:
149
+ raise
150
+ except DnsError as exc:
151
+ if first_error is None:
152
+ first_error = exc
153
+ if not records and first_error is not None:
154
+ raise first_error
155
+ return records
156
+
157
+
158
+ def _finish(
159
+ query: DnsQuery, server_addr: str, records: Sequence[DnsRecord], start: float
160
+ ) -> LookupResult:
161
+ """Assemble a LookupResult from collected records and the elapsed time."""
162
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
163
+ return LookupResult(
164
+ query=query,
165
+ resolver=Resolver(address=server_addr),
166
+ records=tuple(records),
167
+ elapsed_ms=elapsed_ms,
168
+ )
169
+
170
+
171
+ def lookup(
172
+ target: str,
173
+ types: Sequence[RecordType | str] = ("A",),
174
+ *,
175
+ server: str | Sequence[str] | None = None,
176
+ transport: Transport | str = "auto",
177
+ timeout: float = 5.0,
178
+ retries: int = 2,
179
+ port: int = 53,
180
+ resolver: ResolverEngine | None = None,
181
+ ) -> LookupResult:
182
+ """Resolve ``target`` for one or more record types against a single resolver.
183
+
184
+ Args:
185
+ target: The hostname to resolve.
186
+ types: Record types to query (defaults to ``A``).
187
+ server: Explicit resolver(s); the first is used, else the system resolver.
188
+ transport: ``auto`` (UDP→TCP on truncation), ``udp``, or ``tcp``.
189
+ timeout: Per-attempt timeout in seconds.
190
+ retries: Number of retries on timeout.
191
+ port: Resolver port.
192
+ resolver: Optional resolver engine (injected in tests).
193
+
194
+ Returns:
195
+ A :class:`LookupResult`; an empty ``records`` tuple means the name exists but has no
196
+ record of the requested type.
197
+
198
+ Raises:
199
+ UsageError: For invalid input (before any network I/O).
200
+ DnsError: For resolution failures (NXDOMAIN, SERVFAIL, REFUSED, timeout, …).
201
+ """
202
+ if not target or not target.strip():
203
+ raise UsageError(_TARGET_REQUIRED)
204
+ record_types = _coerce_types(types)
205
+ engine, server_addr, query = _prepare(
206
+ target,
207
+ record_types,
208
+ server=server,
209
+ transport=transport,
210
+ timeout=timeout,
211
+ retries=retries,
212
+ port=port,
213
+ resolver=resolver,
214
+ )
215
+ start = time.perf_counter()
216
+ records = _query_types(engine, target, record_types, server_addr, query)
217
+ return _finish(query, server_addr, records, start)
218
+
219
+
220
+ def reverse(
221
+ ip: str,
222
+ *,
223
+ server: str | Sequence[str] | None = None,
224
+ transport: Transport | str = "auto",
225
+ timeout: float = 5.0,
226
+ retries: int = 2,
227
+ port: int = 53,
228
+ resolver: ResolverEngine | None = None,
229
+ ) -> LookupResult:
230
+ """Reverse (PTR) lookup for an IPv4 or IPv6 address.
231
+
232
+ Returns a :class:`LookupResult` whose records are the PTR hostname(s); an empty tuple means
233
+ the address has no PTR record.
234
+
235
+ Raises:
236
+ UsageError: For an invalid IP or bad controls (before any network I/O).
237
+ DnsError: For resolution failures.
238
+ """
239
+ if not ip or not ip.strip():
240
+ raise UsageError("an IP address is required")
241
+ try:
242
+ ptr_name = dns.reversename.from_address(ip.strip())
243
+ except (ValueError, dns.exception.SyntaxError) as exc:
244
+ raise UsageError(f"invalid IP address: {ip}") from exc
245
+ engine, server_addr, query = _prepare(
246
+ ip,
247
+ (RecordType.PTR,),
248
+ server=server,
249
+ transport=transport,
250
+ timeout=timeout,
251
+ retries=retries,
252
+ port=port,
253
+ resolver=resolver,
254
+ )
255
+ start = time.perf_counter()
256
+ records = engine.query(
257
+ str(ptr_name),
258
+ RecordType.PTR,
259
+ server=server_addr,
260
+ transport=query.transport,
261
+ timeout=query.timeout_s,
262
+ retries=query.retries,
263
+ port=query.port,
264
+ )
265
+ return _finish(query, server_addr, records, start)
266
+
267
+
268
+ def lookup_all(
269
+ target: str,
270
+ *,
271
+ server: str | Sequence[str] | None = None,
272
+ transport: Transport | str = "auto",
273
+ timeout: float = 5.0,
274
+ retries: int = 2,
275
+ port: int = 53,
276
+ resolver: ResolverEngine | None = None,
277
+ ) -> LookupResult:
278
+ """Query every common forward record type at once — a one-stop lookup.
279
+
280
+ Records from the types that answer are aggregated. Per-type failures are tolerated (some
281
+ resolvers refuse specific types), so nothing that exists is missed.
282
+
283
+ Raises:
284
+ UsageError: For invalid input (before any network I/O).
285
+ NxDomain: If the name does not exist.
286
+ DnsError: If every type fails and no records are collected (the first error).
287
+ """
288
+ if not target or not target.strip():
289
+ raise UsageError(_TARGET_REQUIRED)
290
+ engine, server_addr, query = _prepare(
291
+ target,
292
+ ALL_RECORD_TYPES,
293
+ server=server,
294
+ transport=transport,
295
+ timeout=timeout,
296
+ retries=retries,
297
+ port=port,
298
+ resolver=resolver,
299
+ )
300
+ start = time.perf_counter()
301
+ records = _query_types(engine, target, ALL_RECORD_TYPES, server_addr, query)
302
+ return _finish(query, server_addr, records, start)
303
+
304
+
305
+ _MIN_COMPARE_SERVERS = 2
306
+
307
+
308
+ def _outcome_for(error: DnsError) -> Outcome:
309
+ """Map a DNS error to its outcome class (defaults to SERVFAIL for unmapped errors)."""
310
+ try:
311
+ return Outcome(error.code)
312
+ except ValueError:
313
+ return Outcome.SERVFAIL
314
+
315
+
316
+ def _comparison_consistent(answers: Sequence[ResolverAnswer]) -> bool:
317
+ """True when every resolver returned the same outcome and record set (TTL ignored)."""
318
+ return len({answer.signature() for answer in answers}) == 1
319
+
320
+
321
+ def compare(
322
+ target: str,
323
+ servers: Sequence[str],
324
+ types: Sequence[RecordType | str] = ("A",),
325
+ *,
326
+ transport: Transport | str = "auto",
327
+ timeout: float = 5.0,
328
+ retries: int = 2,
329
+ port: int = 53,
330
+ resolver: ResolverEngine | None = None,
331
+ ) -> ResolverComparison:
332
+ """Query the same name across several resolvers and compare their answers.
333
+
334
+ Returns a :class:`ResolverComparison` with one :class:`ResolverAnswer` per resolver (records
335
+ or a failure), and a ``consistent`` flag that is True only when every resolver agrees. A
336
+ resolver that fails does not abort the comparison — its failure is recorded.
337
+
338
+ Raises:
339
+ UsageError: For invalid input or fewer than two resolvers.
340
+ """
341
+ server_list = list(_coerce_servers(servers))
342
+ if len(server_list) < _MIN_COMPARE_SERVERS:
343
+ raise UsageError("comparing resolvers needs at least two --server values")
344
+ record_types = _coerce_types(types)
345
+ answers: list[ResolverAnswer] = []
346
+ for srv in server_list:
347
+ try:
348
+ result = lookup(
349
+ target,
350
+ record_types,
351
+ server=srv,
352
+ transport=transport,
353
+ timeout=timeout,
354
+ retries=retries,
355
+ port=port,
356
+ resolver=resolver,
357
+ )
358
+ answers.append(
359
+ ResolverAnswer(
360
+ server=srv,
361
+ outcome=Outcome.OK,
362
+ records=result.records,
363
+ elapsed_ms=result.elapsed_ms,
364
+ )
365
+ )
366
+ except DnsError as exc:
367
+ answers.append(
368
+ ResolverAnswer(server=srv, outcome=_outcome_for(exc), error=exc.message)
369
+ )
370
+ return ResolverComparison(
371
+ target=target,
372
+ record_types=record_types,
373
+ answers=tuple(answers),
374
+ consistent=_comparison_consistent(answers),
375
+ )
376
+
377
+
378
+ def trace(
379
+ name: str,
380
+ rtype: RecordType | str = "A",
381
+ *,
382
+ timeout: float = 5.0,
383
+ port: int = 53,
384
+ ) -> tuple[TraceStep, ...]:
385
+ """Trace the iterative resolution path of ``name`` from the root down to the answer.
386
+
387
+ Raises:
388
+ UsageError: For invalid input.
389
+ """
390
+ if not name or not name.strip():
391
+ raise UsageError(_TARGET_REQUIRED)
392
+ record_type = _coerce_types([rtype])[0]
393
+ _validate(timeout, 0, port)
394
+ return trace_resolution(name, record_type, timeout=timeout, port=port)
395
+
396
+
397
+ def reverse_trace(
398
+ ip: str,
399
+ *,
400
+ timeout: float = 5.0,
401
+ port: int = 53,
402
+ ) -> tuple[TraceStep, ...]:
403
+ """Trace the iterative resolution path of an IP's PTR name.
404
+
405
+ Raises:
406
+ UsageError: For an invalid IP.
407
+ """
408
+ if not ip or not ip.strip():
409
+ raise UsageError("an IP address is required")
410
+ try:
411
+ ptr_name = dns.reversename.from_address(ip.strip())
412
+ except (ValueError, dns.exception.SyntaxError) as exc:
413
+ raise UsageError(f"invalid IP address: {ip}") from exc
414
+ _validate(timeout, 0, port)
415
+ return trace_resolution(str(ptr_name), RecordType.PTR, timeout=timeout, port=port)