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/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