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/__init__.py +16 -0
- opskit/__main__.py +8 -0
- opskit/cli.py +49 -0
- opskit/core/__init__.py +8 -0
- opskit/core/cliutils.py +171 -0
- opskit/core/errors.py +37 -0
- opskit/core/exit_codes.py +39 -0
- opskit/core/output.py +19 -0
- opskit/core/result.py +41 -0
- opskit/dns/README.md +205 -0
- opskit/dns/__init__.py +61 -0
- opskit/dns/api.py +415 -0
- opskit/dns/cli.py +627 -0
- opskit/dns/errors.py +47 -0
- opskit/dns/models.py +197 -0
- opskit/dns/output.py +83 -0
- opskit/dns/resolver.py +249 -0
- opskit/net/__init__.py +21 -0
- opskit/net/errors.py +37 -0
- opskit/net/tcp.py +164 -0
- opskit/py.typed +0 -0
- opskit/tls/README.md +150 -0
- opskit/tls/__init__.py +39 -0
- opskit/tls/api.py +151 -0
- opskit/tls/cli.py +267 -0
- opskit/tls/errors.py +65 -0
- opskit/tls/handshake.py +243 -0
- opskit/tls/inspect.py +273 -0
- opskit/tls/models.py +240 -0
- opskit/tls/output.py +85 -0
- opskit-0.1.0.dist-info/METADATA +171 -0
- opskit-0.1.0.dist-info/RECORD +35 -0
- opskit-0.1.0.dist-info/WHEEL +4 -0
- opskit-0.1.0.dist-info/entry_points.txt +2 -0
- opskit-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|