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/models.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Typed data model for DNS diagnostics.
|
|
2
|
+
|
|
3
|
+
All models are frozen stdlib dataclasses (no Pydantic in core) with ``to_dict()`` for the
|
|
4
|
+
JSON envelope. See specs/001-dns-diagnostics/data-model.md.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RecordType(str, Enum):
|
|
16
|
+
"""DNS record types opskit can request."""
|
|
17
|
+
|
|
18
|
+
A = "A"
|
|
19
|
+
AAAA = "AAAA"
|
|
20
|
+
MX = "MX"
|
|
21
|
+
TXT = "TXT"
|
|
22
|
+
CNAME = "CNAME"
|
|
23
|
+
NS = "NS"
|
|
24
|
+
SOA = "SOA"
|
|
25
|
+
SRV = "SRV"
|
|
26
|
+
CAA = "CAA"
|
|
27
|
+
PTR = "PTR"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Transport(str, Enum):
|
|
31
|
+
"""Query transport: AUTO uses UDP then falls back to TCP on truncation."""
|
|
32
|
+
|
|
33
|
+
AUTO = "auto"
|
|
34
|
+
UDP = "udp"
|
|
35
|
+
TCP = "tcp"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Outcome(str, Enum):
|
|
39
|
+
"""The outcome class of a single query (mirrors the exit-code classes)."""
|
|
40
|
+
|
|
41
|
+
OK = "ok"
|
|
42
|
+
NXDOMAIN = "nxdomain"
|
|
43
|
+
SERVFAIL = "servfail"
|
|
44
|
+
REFUSED = "refused"
|
|
45
|
+
TIMEOUT = "timeout"
|
|
46
|
+
USAGE_ERROR = "usage_error"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class DnsRecord:
|
|
51
|
+
"""A single returned record: its type, rendered value, and TTL in seconds."""
|
|
52
|
+
|
|
53
|
+
type: RecordType
|
|
54
|
+
value: str
|
|
55
|
+
ttl: int
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, Any]:
|
|
58
|
+
"""Return a JSON-serializable mapping."""
|
|
59
|
+
return {"type": self.type.value, "value": self.value, "ttl": self.ttl}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Resolver:
|
|
64
|
+
"""A DNS server that answered a query (``"system"`` denotes the OS default)."""
|
|
65
|
+
|
|
66
|
+
address: str
|
|
67
|
+
label: str | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class DnsQuery:
|
|
72
|
+
"""What the user asked: target, record types, resolver(s), and query controls."""
|
|
73
|
+
|
|
74
|
+
target: str
|
|
75
|
+
record_types: tuple[RecordType, ...]
|
|
76
|
+
servers: tuple[str, ...] = ()
|
|
77
|
+
transport: Transport = Transport.AUTO
|
|
78
|
+
timeout_s: float = 5.0
|
|
79
|
+
retries: int = 2
|
|
80
|
+
port: int = 53
|
|
81
|
+
trace: bool = False
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict[str, Any]:
|
|
84
|
+
"""Return a JSON-serializable mapping of the request parameters."""
|
|
85
|
+
return {
|
|
86
|
+
"target": self.target,
|
|
87
|
+
"record_types": [t.value for t in self.record_types],
|
|
88
|
+
"servers": list(self.servers),
|
|
89
|
+
"transport": self.transport.value,
|
|
90
|
+
"timeout_s": self.timeout_s,
|
|
91
|
+
"retries": self.retries,
|
|
92
|
+
"port": self.port,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class LookupResult:
|
|
98
|
+
"""The successful outcome of a query against a single resolver.
|
|
99
|
+
|
|
100
|
+
Failures are raised as :class:`opskit.dns.errors.DnsError` subclasses, not represented
|
|
101
|
+
here; an empty ``records`` tuple means the name exists but has no record of that type.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
query: DnsQuery
|
|
105
|
+
resolver: Resolver
|
|
106
|
+
records: tuple[DnsRecord, ...] = ()
|
|
107
|
+
elapsed_ms: float = 0.0
|
|
108
|
+
outcome: Outcome = Outcome.OK
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def ok(self) -> bool:
|
|
112
|
+
"""True when the query succeeded."""
|
|
113
|
+
return self.outcome is Outcome.OK
|
|
114
|
+
|
|
115
|
+
def __bool__(self) -> bool:
|
|
116
|
+
"""A result is truthy when it succeeded."""
|
|
117
|
+
return self.ok
|
|
118
|
+
|
|
119
|
+
def __iter__(self) -> Iterator[DnsRecord]:
|
|
120
|
+
"""Iterating a result yields its records."""
|
|
121
|
+
return iter(self.records)
|
|
122
|
+
|
|
123
|
+
def to_dict(self) -> dict[str, Any]:
|
|
124
|
+
"""Return a JSON-serializable mapping of the result."""
|
|
125
|
+
return {
|
|
126
|
+
"outcome": self.outcome.value,
|
|
127
|
+
"resolver": self.resolver.address,
|
|
128
|
+
"records": [r.to_dict() for r in self.records],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class TraceStep:
|
|
134
|
+
"""One hop in an iterative resolution: which server was asked and what it returned."""
|
|
135
|
+
|
|
136
|
+
server: str
|
|
137
|
+
zone: str
|
|
138
|
+
response: Literal["referral", "answer", "error"]
|
|
139
|
+
referrals: tuple[str, ...] = ()
|
|
140
|
+
records: tuple[DnsRecord, ...] = ()
|
|
141
|
+
|
|
142
|
+
def to_dict(self) -> dict[str, Any]:
|
|
143
|
+
"""Return a JSON-serializable mapping of this trace step."""
|
|
144
|
+
return {
|
|
145
|
+
"server": self.server,
|
|
146
|
+
"zone": self.zone,
|
|
147
|
+
"response": self.response,
|
|
148
|
+
"referrals": list(self.referrals),
|
|
149
|
+
"records": [r.to_dict() for r in self.records],
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class ResolverAnswer:
|
|
155
|
+
"""What one resolver returned for a compared query (records, or a failure)."""
|
|
156
|
+
|
|
157
|
+
server: str
|
|
158
|
+
outcome: Outcome
|
|
159
|
+
records: tuple[DnsRecord, ...] = ()
|
|
160
|
+
error: str | None = None
|
|
161
|
+
elapsed_ms: float = 0.0
|
|
162
|
+
|
|
163
|
+
def signature(self) -> tuple[Outcome, frozenset[tuple[RecordType, str]]]:
|
|
164
|
+
"""Identity for agreement checks: outcome + record set, ignoring TTLs and order."""
|
|
165
|
+
return (self.outcome, frozenset((r.type, r.value) for r in self.records))
|
|
166
|
+
|
|
167
|
+
def to_dict(self) -> dict[str, Any]:
|
|
168
|
+
"""Return a JSON-serializable mapping of this resolver's answer."""
|
|
169
|
+
return {
|
|
170
|
+
"server": self.server,
|
|
171
|
+
"outcome": self.outcome.value,
|
|
172
|
+
"records": [r.to_dict() for r in self.records],
|
|
173
|
+
"error": self.error,
|
|
174
|
+
"elapsed_ms": round(self.elapsed_ms, 3),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True)
|
|
179
|
+
class ResolverComparison:
|
|
180
|
+
"""The same query asked of several resolvers, with agreement analysis.
|
|
181
|
+
|
|
182
|
+
``consistent`` is True only when every resolver returned the same outcome and record set.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
target: str
|
|
186
|
+
record_types: tuple[RecordType, ...]
|
|
187
|
+
answers: tuple[ResolverAnswer, ...]
|
|
188
|
+
consistent: bool
|
|
189
|
+
|
|
190
|
+
def to_dict(self) -> dict[str, Any]:
|
|
191
|
+
"""Return a JSON-serializable mapping of the comparison."""
|
|
192
|
+
return {
|
|
193
|
+
"target": self.target,
|
|
194
|
+
"record_types": [t.value for t in self.record_types],
|
|
195
|
+
"consistent": self.consistent,
|
|
196
|
+
"answers": [a.to_dict() for a in self.answers],
|
|
197
|
+
}
|
opskit/dns/output.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Rendering of DNS results to human-readable (rich) tables.
|
|
2
|
+
|
|
3
|
+
Category-owned so :mod:`opskit.core` stays free of DNS models. Resolver-derived and
|
|
4
|
+
user-supplied strings are escaped as rich markup before printing to avoid markup injection.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.markup import escape
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from opskit.dns.models import DnsRecord, Outcome, ResolverComparison, TraceStep
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render_records(records: Sequence[DnsRecord], *, console: Console) -> None:
|
|
20
|
+
"""Print records as a table (or a plain notice when there are none)."""
|
|
21
|
+
if not records:
|
|
22
|
+
console.print("No records found.")
|
|
23
|
+
return
|
|
24
|
+
table = Table(show_header=True, header_style="bold")
|
|
25
|
+
table.add_column("TYPE")
|
|
26
|
+
table.add_column("VALUE")
|
|
27
|
+
table.add_column("TTL", justify="right")
|
|
28
|
+
for record in records:
|
|
29
|
+
table.add_row(record.type.value, escape(record.value), str(record.ttl))
|
|
30
|
+
console.print(table)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render_comparison(comparison: ResolverComparison, *, console: Console) -> None:
|
|
34
|
+
"""Print a per-resolver comparison table, highlighting resolvers that disagree."""
|
|
35
|
+
status = "consistent" if comparison.consistent else "DIFFERS"
|
|
36
|
+
types = "/".join(t.value for t in comparison.record_types)
|
|
37
|
+
table = Table(
|
|
38
|
+
title=f"{escape(comparison.target)} [{types}] — {status}",
|
|
39
|
+
show_header=True,
|
|
40
|
+
header_style="bold",
|
|
41
|
+
)
|
|
42
|
+
table.add_column("RESOLVER")
|
|
43
|
+
table.add_column("OUTCOME")
|
|
44
|
+
table.add_column("RECORDS / ERROR")
|
|
45
|
+
signatures = [a.signature() for a in comparison.answers]
|
|
46
|
+
majority = Counter(signatures).most_common(1)[0][0] if signatures else None
|
|
47
|
+
for answer, signature in zip(comparison.answers, signatures):
|
|
48
|
+
lines = [
|
|
49
|
+
f"{r.type.value} {escape(r.value)} (ttl {r.ttl})" for r in answer.records
|
|
50
|
+
]
|
|
51
|
+
cell = "\n".join(lines) if lines else escape(answer.error or "—")
|
|
52
|
+
differs = not comparison.consistent and signature != majority
|
|
53
|
+
server = escape(answer.server)
|
|
54
|
+
resolver = f"[yellow]{server} ⚠ differs[/yellow]" if differs else server
|
|
55
|
+
outcome = (
|
|
56
|
+
answer.outcome.value
|
|
57
|
+
if answer.outcome is Outcome.OK
|
|
58
|
+
else f"[red]{answer.outcome.value}[/red]"
|
|
59
|
+
)
|
|
60
|
+
table.add_row(resolver, outcome, cell)
|
|
61
|
+
console.print(table)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def render_trace(steps: Sequence[TraceStep], *, console: Console) -> None:
|
|
65
|
+
"""Print an iterative resolution trace (root -> authoritative), one row per hop."""
|
|
66
|
+
table = Table(show_header=True, header_style="bold")
|
|
67
|
+
table.add_column("#", justify="right")
|
|
68
|
+
table.add_column("SERVER")
|
|
69
|
+
table.add_column("ZONE")
|
|
70
|
+
table.add_column("RESULT")
|
|
71
|
+
for index, step in enumerate(steps, start=1):
|
|
72
|
+
if step.response == "answer":
|
|
73
|
+
records = "\n".join(
|
|
74
|
+
f"{r.type.value} {escape(r.value)}" for r in step.records
|
|
75
|
+
)
|
|
76
|
+
detail = records or "(answer)"
|
|
77
|
+
elif step.response == "referral":
|
|
78
|
+
referrals = ", ".join(escape(r) for r in step.referrals)
|
|
79
|
+
detail = "-> " + referrals if referrals else "-> (referral)"
|
|
80
|
+
else:
|
|
81
|
+
detail = "(no response)"
|
|
82
|
+
table.add_row(str(index), escape(step.server), escape(step.zone), detail)
|
|
83
|
+
console.print(table)
|
opskit/dns/resolver.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Resolver abstraction over dnspython, injectable so tests can supply a fake.
|
|
2
|
+
|
|
3
|
+
The concrete :class:`DnspythonResolver` sends a query to one server, applies UDP→TCP fallback
|
|
4
|
+
on truncation, and normalizes rcodes/OS errors into the DNS exception hierarchy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable, Iterable
|
|
10
|
+
from typing import Protocol, cast
|
|
11
|
+
|
|
12
|
+
import dns.exception
|
|
13
|
+
import dns.flags
|
|
14
|
+
import dns.message
|
|
15
|
+
import dns.query
|
|
16
|
+
import dns.rcode
|
|
17
|
+
import dns.rdata
|
|
18
|
+
import dns.rdatatype
|
|
19
|
+
import dns.resolver
|
|
20
|
+
import dns.rrset
|
|
21
|
+
|
|
22
|
+
from opskit.dns.errors import DnsError, DnsRefused, DnsTimeout, NxDomain, ServerFailure
|
|
23
|
+
from opskit.dns.models import DnsRecord, RecordType, TraceStep, Transport
|
|
24
|
+
|
|
25
|
+
# Root server IPs (a/b/c-root) used as the entry point for iterative --trace.
|
|
26
|
+
_ROOT_SERVERS = ("198.41.0.4", "199.9.14.201", "192.33.4.12")
|
|
27
|
+
_MAX_TRACE_DEPTH = 15
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Resolver(Protocol):
|
|
31
|
+
"""Answers a single ``(name, type)`` query against one server."""
|
|
32
|
+
|
|
33
|
+
def query(
|
|
34
|
+
self,
|
|
35
|
+
name: str,
|
|
36
|
+
rtype: RecordType,
|
|
37
|
+
*,
|
|
38
|
+
server: str,
|
|
39
|
+
transport: Transport,
|
|
40
|
+
timeout: float,
|
|
41
|
+
retries: int,
|
|
42
|
+
port: int,
|
|
43
|
+
) -> tuple[DnsRecord, ...]:
|
|
44
|
+
"""Return records (possibly empty) or raise a :class:`DnsError` subclass."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def system_nameserver() -> str:
|
|
49
|
+
"""Return the first system-configured resolver address (cross-platform via dnspython)."""
|
|
50
|
+
try:
|
|
51
|
+
servers = dns.resolver.get_default_resolver().nameservers
|
|
52
|
+
except Exception as exc: # pragma: no cover - platform dependent
|
|
53
|
+
raise DnsError("could not determine the system resolver") from exc
|
|
54
|
+
if not servers: # pragma: no cover - unusual
|
|
55
|
+
raise DnsError("no system resolver configured", hint="pass --server explicitly")
|
|
56
|
+
return str(servers[0])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DnspythonResolver:
|
|
60
|
+
"""Concrete resolver using dnspython's low-level query API."""
|
|
61
|
+
|
|
62
|
+
def query(
|
|
63
|
+
self,
|
|
64
|
+
name: str,
|
|
65
|
+
rtype: RecordType,
|
|
66
|
+
*,
|
|
67
|
+
server: str,
|
|
68
|
+
transport: Transport,
|
|
69
|
+
timeout: float,
|
|
70
|
+
retries: int,
|
|
71
|
+
port: int,
|
|
72
|
+
) -> tuple[DnsRecord, ...]:
|
|
73
|
+
"""Send one query, applying rcode→exception mapping and TCP fallback."""
|
|
74
|
+
request = dns.message.make_query(name, rtype.value)
|
|
75
|
+
response = self._send(request, server, transport, timeout, retries, port)
|
|
76
|
+
rcode = response.rcode()
|
|
77
|
+
if rcode == dns.rcode.NXDOMAIN:
|
|
78
|
+
raise NxDomain(
|
|
79
|
+
f"{name} does not exist (NXDOMAIN)", hint="check the name for typos"
|
|
80
|
+
)
|
|
81
|
+
if rcode == dns.rcode.SERVFAIL:
|
|
82
|
+
raise ServerFailure(f"{server} returned SERVFAIL for {name}")
|
|
83
|
+
if rcode == dns.rcode.REFUSED:
|
|
84
|
+
raise DnsRefused(
|
|
85
|
+
f"{server} refused the query for {name}",
|
|
86
|
+
hint="the resolver may not serve this client; try a different --server",
|
|
87
|
+
)
|
|
88
|
+
if rcode != dns.rcode.NOERROR:
|
|
89
|
+
raise ServerFailure(
|
|
90
|
+
f"{server} returned {dns.rcode.to_text(rcode)} for {name}"
|
|
91
|
+
)
|
|
92
|
+
return self._extract(response)
|
|
93
|
+
|
|
94
|
+
def _send(
|
|
95
|
+
self,
|
|
96
|
+
request: dns.message.Message,
|
|
97
|
+
server: str,
|
|
98
|
+
transport: Transport,
|
|
99
|
+
timeout: float,
|
|
100
|
+
retries: int,
|
|
101
|
+
port: int,
|
|
102
|
+
) -> dns.message.Message:
|
|
103
|
+
last_exc: BaseException | None = None
|
|
104
|
+
for _ in range(retries + 1):
|
|
105
|
+
try:
|
|
106
|
+
if transport is Transport.TCP:
|
|
107
|
+
return dns.query.tcp(request, server, timeout=timeout, port=port)
|
|
108
|
+
response = dns.query.udp(request, server, timeout=timeout, port=port)
|
|
109
|
+
if transport is Transport.AUTO and (response.flags & dns.flags.TC):
|
|
110
|
+
return dns.query.tcp(request, server, timeout=timeout, port=port)
|
|
111
|
+
return response
|
|
112
|
+
except (dns.exception.Timeout, OSError) as exc:
|
|
113
|
+
# Timeouts are retried; raw socket errors (refused/unreachable) also land here.
|
|
114
|
+
last_exc = exc
|
|
115
|
+
if last_exc is None or isinstance(last_exc, dns.exception.Timeout):
|
|
116
|
+
raise DnsTimeout(
|
|
117
|
+
f"no response from {server} within {timeout}s",
|
|
118
|
+
hint="the resolver may be filtered; try --transport tcp or a different --server",
|
|
119
|
+
) from last_exc
|
|
120
|
+
raise ServerFailure(
|
|
121
|
+
f"cannot reach {server} on port {port}: {last_exc}",
|
|
122
|
+
hint="check the --server address and --port, or that the resolver is reachable",
|
|
123
|
+
) from last_exc
|
|
124
|
+
|
|
125
|
+
def _extract(self, response: dns.message.Message) -> tuple[DnsRecord, ...]:
|
|
126
|
+
return _records_from(response.answer)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _records_from(rrsets: list[dns.rrset.RRset]) -> tuple[DnsRecord, ...]:
|
|
130
|
+
"""Extract our supported records from a list of rrsets (skips unsupported types)."""
|
|
131
|
+
records: list[DnsRecord] = []
|
|
132
|
+
for rrset in rrsets:
|
|
133
|
+
type_text = dns.rdatatype.to_text(rrset.rdtype)
|
|
134
|
+
try:
|
|
135
|
+
rtype = RecordType(type_text)
|
|
136
|
+
except ValueError:
|
|
137
|
+
continue # skip records outside our supported set (e.g. RRSIG)
|
|
138
|
+
ttl = int(rrset.ttl)
|
|
139
|
+
for item in cast("Iterable[dns.rdata.Rdata]", rrset):
|
|
140
|
+
records.append(DnsRecord(type=rtype, value=str(item.to_text()), ttl=ttl))
|
|
141
|
+
return tuple(records)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _referral(response: dns.message.Message) -> tuple[list[str], list[str], str]:
|
|
145
|
+
"""Parse a delegation response into (NS names, glue IPs, delegated zone)."""
|
|
146
|
+
ns_names: list[str] = []
|
|
147
|
+
zone = ""
|
|
148
|
+
for rrset in response.authority:
|
|
149
|
+
if rrset.rdtype == dns.rdatatype.NS:
|
|
150
|
+
zone = str(rrset.name)
|
|
151
|
+
for item in cast("Iterable[dns.rdata.Rdata]", rrset):
|
|
152
|
+
ns_names.append(str(item.to_text()))
|
|
153
|
+
glue: list[str] = []
|
|
154
|
+
for rrset in response.additional:
|
|
155
|
+
if rrset.rdtype in (dns.rdatatype.A, dns.rdatatype.AAAA):
|
|
156
|
+
for item in cast("Iterable[dns.rdata.Rdata]", rrset):
|
|
157
|
+
glue.append(str(item.to_text()))
|
|
158
|
+
return ns_names, glue, zone
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _resolve_glue(ns_name: str, timeout: float) -> str | None:
|
|
162
|
+
"""Resolve an NS hostname to an address (used when a referral lacks glue).
|
|
163
|
+
|
|
164
|
+
Tries A then AAAA so IPv6-only or dual-stack NS hosts are not missed.
|
|
165
|
+
"""
|
|
166
|
+
for family in ("A", "AAAA"):
|
|
167
|
+
try:
|
|
168
|
+
answer = dns.resolver.resolve(ns_name, family, lifetime=timeout)
|
|
169
|
+
except dns.exception.DNSException:
|
|
170
|
+
continue
|
|
171
|
+
for item in cast("Iterable[dns.rdata.Rdata]", answer):
|
|
172
|
+
return str(item.to_text())
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _next_hop_servers(
|
|
177
|
+
glue: list[str], ns_names: list[str], timeout: float
|
|
178
|
+
) -> list[str]:
|
|
179
|
+
"""Servers to query for the next trace hop: prefer glue, else resolve an NS name."""
|
|
180
|
+
if glue:
|
|
181
|
+
return glue
|
|
182
|
+
if ns_names:
|
|
183
|
+
resolved = _resolve_glue(ns_names[0], timeout)
|
|
184
|
+
return [resolved] if resolved else []
|
|
185
|
+
return []
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
QueryFn = Callable[[str, dns.message.Message], dns.message.Message]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def trace_resolution(
|
|
192
|
+
name: str,
|
|
193
|
+
rtype: RecordType,
|
|
194
|
+
*,
|
|
195
|
+
timeout: float = 5.0,
|
|
196
|
+
port: int = 53,
|
|
197
|
+
query_fn: QueryFn | None = None,
|
|
198
|
+
) -> tuple[TraceStep, ...]:
|
|
199
|
+
"""Iteratively resolve ``name`` from the root, recording each delegation hop.
|
|
200
|
+
|
|
201
|
+
``query_fn`` (server, request) -> response is injectable for tests; by default it sends a
|
|
202
|
+
non-recursive UDP query to each server in turn.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def default_send(server: str, request: dns.message.Message) -> dns.message.Message:
|
|
206
|
+
# Fall back to TCP when the UDP answer is truncated so large referrals aren't lost.
|
|
207
|
+
response, _ = dns.query.udp_with_fallback(
|
|
208
|
+
request, server, timeout=timeout, port=port
|
|
209
|
+
)
|
|
210
|
+
return response
|
|
211
|
+
|
|
212
|
+
send = query_fn or default_send
|
|
213
|
+
qname = name if name.endswith(".") else name + "."
|
|
214
|
+
servers: list[str] = list(_ROOT_SERVERS)
|
|
215
|
+
zone = "."
|
|
216
|
+
steps: list[TraceStep] = []
|
|
217
|
+
for _ in range(_MAX_TRACE_DEPTH):
|
|
218
|
+
if not servers:
|
|
219
|
+
break
|
|
220
|
+
server = servers[0]
|
|
221
|
+
request = dns.message.make_query(qname, rtype.value)
|
|
222
|
+
request.flags &= ~dns.flags.RD
|
|
223
|
+
try:
|
|
224
|
+
response = send(server, request)
|
|
225
|
+
except (dns.exception.Timeout, OSError):
|
|
226
|
+
# A timeout or raw socket error (refused/unreachable) ends the trace at this hop
|
|
227
|
+
# rather than escaping as an unhandled exception.
|
|
228
|
+
steps.append(TraceStep(server=server, zone=zone, response="error"))
|
|
229
|
+
break
|
|
230
|
+
if response.answer:
|
|
231
|
+
steps.append(
|
|
232
|
+
TraceStep(
|
|
233
|
+
server=server,
|
|
234
|
+
zone=zone,
|
|
235
|
+
response="answer",
|
|
236
|
+
records=_records_from(response.answer),
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
break
|
|
240
|
+
ns_names, glue, delegated = _referral(response)
|
|
241
|
+
steps.append(
|
|
242
|
+
TraceStep(
|
|
243
|
+
server=server, zone=zone, response="referral", referrals=tuple(ns_names)
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
if delegated:
|
|
247
|
+
zone = delegated
|
|
248
|
+
servers = _next_hop_servers(glue, ns_names, timeout)
|
|
249
|
+
return tuple(steps)
|
opskit/net/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Network primitives — resolve/connect shared by socket-using categories.
|
|
2
|
+
|
|
3
|
+
Library-only for now (no CLI); the future net category registers commands on top of these.
|
|
4
|
+
Public surface is SemVer-governed. Failures raise; nothing here prints or exits the process.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from opskit.net.errors import ConnectRefused, ConnectTimeout, NetError, ResolutionError
|
|
10
|
+
from opskit.net.tcp import AddressCandidate, TcpConnection, connect, resolve
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AddressCandidate",
|
|
14
|
+
"ConnectRefused",
|
|
15
|
+
"ConnectTimeout",
|
|
16
|
+
"NetError",
|
|
17
|
+
"ResolutionError",
|
|
18
|
+
"TcpConnection",
|
|
19
|
+
"connect",
|
|
20
|
+
"resolve",
|
|
21
|
+
]
|
opskit/net/errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Network-layer exception hierarchy (subclasses of :class:`opskit.core.errors.OpskitError`).
|
|
2
|
+
|
|
3
|
+
These cover the resolve/connect layers shared by every category that opens a socket
|
|
4
|
+
(tls today; the future net category). Each type owns its exit code (constitution Art. VII).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from opskit.core.errors import OpskitError
|
|
10
|
+
from opskit.core.exit_codes import ExitCode
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NetError(OpskitError):
|
|
14
|
+
"""Base class for name-resolution and connection failures."""
|
|
15
|
+
|
|
16
|
+
code = "net_error"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ResolutionError(NetError):
|
|
20
|
+
"""The host name could not be resolved to any address."""
|
|
21
|
+
|
|
22
|
+
code = "resolve_failed"
|
|
23
|
+
exit_code = ExitCode.NXDOMAIN # same outcome class as DNS "name does not exist"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConnectRefused(NetError):
|
|
27
|
+
"""The target actively refused the connection (or was unreachable)."""
|
|
28
|
+
|
|
29
|
+
code = "connect_refused"
|
|
30
|
+
exit_code = ExitCode.CONNECT_FAILED
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ConnectTimeout(NetError):
|
|
34
|
+
"""No connection could be established before the timeout (possibly filtered)."""
|
|
35
|
+
|
|
36
|
+
code = "connect_timeout"
|
|
37
|
+
exit_code = ExitCode.TIMEOUT
|