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/cli.py
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
"""Thin Typer sub-app for DNS diagnostics: parse args, delegate to the API, render.
|
|
2
|
+
|
|
3
|
+
Holds no business logic — it maps options onto :mod:`opskit.dns.api` and turns typed
|
|
4
|
+
results/exceptions into human or JSON output and structured exit codes. Supports bulk targets
|
|
5
|
+
via a positional argument and/or ``--input-file`` (one target per line, ``#`` comments allowed).
|
|
6
|
+
|
|
7
|
+
.. note::
|
|
8
|
+
This module intentionally does **not** use ``from __future__ import annotations``. Typer reads
|
|
9
|
+
the ``Annotated[...]`` metadata off the concrete annotation objects; deferring them to strings
|
|
10
|
+
(PEP 563) makes Typer silently drop the ``Argument``/``Option`` metadata on Python 3.9, turning
|
|
11
|
+
positional arguments into ``--options``. Keep annotations eager here.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from collections.abc import Callable, Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Annotated, Any, Optional
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
from rich.markup import escape
|
|
21
|
+
|
|
22
|
+
from opskit.core.cliutils import (
|
|
23
|
+
ActionResult,
|
|
24
|
+
aggregate_outcome_exit,
|
|
25
|
+
collect_outcomes,
|
|
26
|
+
collect_targets,
|
|
27
|
+
echo_failures,
|
|
28
|
+
emit_envelopes,
|
|
29
|
+
run_or_watch,
|
|
30
|
+
)
|
|
31
|
+
from opskit.core.errors import OpskitError, UsageError
|
|
32
|
+
from opskit.core.exit_codes import ExitCode
|
|
33
|
+
from opskit.core.output import make_console
|
|
34
|
+
from opskit.core.result import build_envelope
|
|
35
|
+
from opskit.dns import api
|
|
36
|
+
from opskit.dns.models import LookupResult, ResolverComparison, TraceStep
|
|
37
|
+
from opskit.dns.output import render_comparison, render_records, render_trace
|
|
38
|
+
|
|
39
|
+
app = typer.Typer(
|
|
40
|
+
name="dns", help="DNS diagnostics (lookup, reverse).", no_args_is_help=True
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_LOOKUP_EPILOG = """\
|
|
44
|
+
[bold]Examples[/bold]
|
|
45
|
+
|
|
46
|
+
opskit dns lookup example.com -t MX -t TXT
|
|
47
|
+
opskit dns lookup example.com --all
|
|
48
|
+
opskit dns lookup example.com --diff -s 1.1.1.1 -s 8.8.8.8
|
|
49
|
+
opskit dns lookup www.wikipedia.org --trace
|
|
50
|
+
opskit dns lookup -i hosts.txt --jsonl
|
|
51
|
+
opskit dns lookup example.com --watch 30s
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
_REVERSE_EPILOG = """\
|
|
55
|
+
[bold]Examples[/bold]
|
|
56
|
+
|
|
57
|
+
opskit dns reverse 8.8.8.8
|
|
58
|
+
opskit dns reverse 2001:4860:4860::8888 --json
|
|
59
|
+
opskit dns reverse 8.8.8.8 --trace
|
|
60
|
+
opskit dns reverse -i ips.txt
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
# (target, result-or-None, error-or-None) for one target in a batch.
|
|
64
|
+
_Outcome = tuple[str, Optional[LookupResult], Optional[OpskitError]]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _envelope(
|
|
68
|
+
command: str,
|
|
69
|
+
target: str,
|
|
70
|
+
result: Optional[LookupResult],
|
|
71
|
+
error: Optional[OpskitError],
|
|
72
|
+
error_query: Callable[[str], dict[str, Any]],
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
if result is not None:
|
|
75
|
+
return build_envelope(
|
|
76
|
+
command=command,
|
|
77
|
+
query=result.query.to_dict(),
|
|
78
|
+
result=result.to_dict(),
|
|
79
|
+
error=None,
|
|
80
|
+
elapsed_ms=result.elapsed_ms,
|
|
81
|
+
)
|
|
82
|
+
return build_envelope(
|
|
83
|
+
command=command,
|
|
84
|
+
query=error_query(target),
|
|
85
|
+
result=None,
|
|
86
|
+
error=error,
|
|
87
|
+
elapsed_ms=0.0,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _compare_envelope(
|
|
92
|
+
target: str,
|
|
93
|
+
comparison: Optional[ResolverComparison],
|
|
94
|
+
error: Optional[OpskitError],
|
|
95
|
+
types: Sequence[str],
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
"""Build the JSON envelope for one compared target (success or failure)."""
|
|
98
|
+
if comparison is not None:
|
|
99
|
+
return build_envelope(
|
|
100
|
+
command="dns.compare",
|
|
101
|
+
query={
|
|
102
|
+
"target": comparison.target,
|
|
103
|
+
"record_types": [t.value for t in comparison.record_types],
|
|
104
|
+
},
|
|
105
|
+
result=comparison.to_dict(),
|
|
106
|
+
error=None,
|
|
107
|
+
elapsed_ms=0.0,
|
|
108
|
+
)
|
|
109
|
+
return build_envelope(
|
|
110
|
+
command="dns.compare",
|
|
111
|
+
query={"target": target, "record_types": [t.upper() for t in types]},
|
|
112
|
+
result=None,
|
|
113
|
+
error=error,
|
|
114
|
+
elapsed_ms=0.0,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _trace_envelope(
|
|
119
|
+
command: str,
|
|
120
|
+
target: str,
|
|
121
|
+
steps: Optional[tuple[TraceStep, ...]],
|
|
122
|
+
error: Optional[OpskitError],
|
|
123
|
+
) -> dict[str, Any]:
|
|
124
|
+
"""Build the JSON envelope for one traced target (success or failure)."""
|
|
125
|
+
result = (
|
|
126
|
+
{"trace": [step.to_dict() for step in steps]} if steps is not None else None
|
|
127
|
+
)
|
|
128
|
+
return build_envelope(
|
|
129
|
+
command=command,
|
|
130
|
+
query={"target": target},
|
|
131
|
+
result=result,
|
|
132
|
+
error=error,
|
|
133
|
+
elapsed_ms=0.0,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _render_json(
|
|
138
|
+
command: str,
|
|
139
|
+
outcomes: Sequence[_Outcome],
|
|
140
|
+
error_query: Callable[[str], dict[str, Any]],
|
|
141
|
+
*,
|
|
142
|
+
jsonl: bool,
|
|
143
|
+
) -> None:
|
|
144
|
+
envelopes = [_envelope(command, t, r, e, error_query) for t, r, e in outcomes]
|
|
145
|
+
emit_envelopes(envelopes, jsonl=jsonl)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _render_human(outcomes: Sequence[_Outcome], *, batch: bool, no_color: bool) -> None:
|
|
149
|
+
console = make_console(no_color=no_color)
|
|
150
|
+
for target, result, error in outcomes:
|
|
151
|
+
if batch:
|
|
152
|
+
console.print(f"[bold];; {escape(target)}[/bold]")
|
|
153
|
+
if error is not None:
|
|
154
|
+
message = f"error: {error.message}"
|
|
155
|
+
if error.hint:
|
|
156
|
+
message += f"\nhint: {error.hint}"
|
|
157
|
+
typer.echo(message, err=True)
|
|
158
|
+
elif result is not None:
|
|
159
|
+
render_records(result.records, console=console)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _render_comparisons_human(
|
|
163
|
+
outcomes: Sequence[tuple[str, Optional[ResolverComparison], Optional[OpskitError]]],
|
|
164
|
+
*,
|
|
165
|
+
no_color: bool,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Render successful comparisons; echo failed targets to stderr."""
|
|
168
|
+
console = make_console(no_color=no_color)
|
|
169
|
+
for target, comparison, error in outcomes:
|
|
170
|
+
if comparison is not None:
|
|
171
|
+
render_comparison(comparison, console=console)
|
|
172
|
+
elif error is not None:
|
|
173
|
+
typer.echo(f"error: {target}: {error.message}", err=True)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _render_traces_human(
|
|
177
|
+
outcomes: Sequence[
|
|
178
|
+
tuple[str, Optional[tuple[TraceStep, ...]], Optional[OpskitError]]
|
|
179
|
+
],
|
|
180
|
+
*,
|
|
181
|
+
batch: bool,
|
|
182
|
+
no_color: bool,
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Render successful traces; echo failed targets to stderr."""
|
|
185
|
+
console = make_console(no_color=no_color)
|
|
186
|
+
for target, steps, error in outcomes:
|
|
187
|
+
if steps is not None:
|
|
188
|
+
if batch:
|
|
189
|
+
console.print(f"[bold];; trace {escape(target)}[/bold]")
|
|
190
|
+
render_trace(steps, console=console)
|
|
191
|
+
elif error is not None:
|
|
192
|
+
typer.echo(f"error: {target}: {error.message}", err=True)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _signature(outcomes: Sequence[_Outcome]) -> str:
|
|
196
|
+
"""A change-detection key over targets and their (type, value) records (TTL ignored)."""
|
|
197
|
+
parts: list[object] = []
|
|
198
|
+
for target, result, error in outcomes:
|
|
199
|
+
if result is not None:
|
|
200
|
+
recs = sorted([r.type.value, r.value] for r in result.records)
|
|
201
|
+
parts.append([target, "ok", recs])
|
|
202
|
+
else:
|
|
203
|
+
parts.append([target, error.code if error else "error"])
|
|
204
|
+
return json.dumps(parts, sort_keys=True)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _run(
|
|
208
|
+
command: str,
|
|
209
|
+
targets: Sequence[str],
|
|
210
|
+
run_one: Callable[[str], LookupResult],
|
|
211
|
+
error_query: Callable[[str], dict[str, Any]],
|
|
212
|
+
*,
|
|
213
|
+
as_json: bool,
|
|
214
|
+
jsonl: bool,
|
|
215
|
+
no_color: bool,
|
|
216
|
+
) -> tuple[ExitCode, str]:
|
|
217
|
+
"""Execute the query for each target and render; return (exit code, change signature)."""
|
|
218
|
+
outcomes = collect_outcomes(targets, run_one)
|
|
219
|
+
if as_json or jsonl:
|
|
220
|
+
_render_json(command, outcomes, error_query, jsonl=jsonl)
|
|
221
|
+
else:
|
|
222
|
+
_render_human(outcomes, batch=len(targets) > 1, no_color=no_color)
|
|
223
|
+
return aggregate_outcome_exit(outcomes), _signature(outcomes)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _run_compare(
|
|
227
|
+
targets: Sequence[str],
|
|
228
|
+
servers: Sequence[str],
|
|
229
|
+
types: Sequence[str],
|
|
230
|
+
*,
|
|
231
|
+
transport: str,
|
|
232
|
+
timeout: float,
|
|
233
|
+
retries: int,
|
|
234
|
+
port: int,
|
|
235
|
+
as_json: bool,
|
|
236
|
+
jsonl: bool,
|
|
237
|
+
no_color: bool,
|
|
238
|
+
) -> tuple[ExitCode, str]:
|
|
239
|
+
"""Compare each target across the given resolvers; render; return (code, signature).
|
|
240
|
+
|
|
241
|
+
Per-target failures don't abort the batch: successful comparisons are still rendered, and the
|
|
242
|
+
exit code degrades to PARTIAL when any target failed (USAGE only when none succeeded).
|
|
243
|
+
"""
|
|
244
|
+
outcomes = collect_outcomes(
|
|
245
|
+
targets,
|
|
246
|
+
lambda target: api.compare(
|
|
247
|
+
target,
|
|
248
|
+
list(servers),
|
|
249
|
+
types,
|
|
250
|
+
transport=transport,
|
|
251
|
+
timeout=timeout,
|
|
252
|
+
retries=retries,
|
|
253
|
+
port=port,
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
successes = [c for _, c, _ in outcomes if c is not None]
|
|
257
|
+
had_error = any(error is not None for _, _, error in outcomes)
|
|
258
|
+
if not successes:
|
|
259
|
+
echo_failures(outcomes)
|
|
260
|
+
return ExitCode.USAGE, ""
|
|
261
|
+
|
|
262
|
+
if as_json or jsonl:
|
|
263
|
+
emit_envelopes(
|
|
264
|
+
[_compare_envelope(t, c, e, types) for t, c, e in outcomes], jsonl=jsonl
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
_render_comparisons_human(outcomes, no_color=no_color)
|
|
268
|
+
signature = json.dumps(
|
|
269
|
+
[
|
|
270
|
+
[
|
|
271
|
+
c.target,
|
|
272
|
+
[
|
|
273
|
+
[
|
|
274
|
+
a.server,
|
|
275
|
+
a.outcome.value,
|
|
276
|
+
sorted([r.type.value, r.value] for r in a.records),
|
|
277
|
+
]
|
|
278
|
+
for a in c.answers
|
|
279
|
+
],
|
|
280
|
+
]
|
|
281
|
+
for c in successes
|
|
282
|
+
]
|
|
283
|
+
)
|
|
284
|
+
consistent = all(c.consistent for c in successes)
|
|
285
|
+
code = ExitCode.OK if consistent and not had_error else ExitCode.PARTIAL
|
|
286
|
+
return code, signature
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _run_trace(
|
|
290
|
+
targets: Sequence[str],
|
|
291
|
+
trace_fn: Callable[[str], tuple[TraceStep, ...]],
|
|
292
|
+
*,
|
|
293
|
+
command: str,
|
|
294
|
+
as_json: bool,
|
|
295
|
+
jsonl: bool,
|
|
296
|
+
no_color: bool,
|
|
297
|
+
) -> tuple[ExitCode, str]:
|
|
298
|
+
"""Trace each target's resolution path; render; return (code, signature).
|
|
299
|
+
|
|
300
|
+
Per-target failures don't abort the batch: successful traces are still rendered, and the exit
|
|
301
|
+
code degrades to PARTIAL when any target failed (USAGE only when none succeeded).
|
|
302
|
+
"""
|
|
303
|
+
outcomes = collect_outcomes(targets, trace_fn)
|
|
304
|
+
successes = [(t, s) for t, s, _ in outcomes if s is not None]
|
|
305
|
+
had_error = any(error is not None for _, _, error in outcomes)
|
|
306
|
+
if not successes:
|
|
307
|
+
echo_failures(outcomes)
|
|
308
|
+
return ExitCode.USAGE, ""
|
|
309
|
+
if as_json or jsonl:
|
|
310
|
+
emit_envelopes(
|
|
311
|
+
[_trace_envelope(command, t, s, e) for t, s, e in outcomes], jsonl=jsonl
|
|
312
|
+
)
|
|
313
|
+
else:
|
|
314
|
+
_render_traces_human(outcomes, batch=len(targets) > 1, no_color=no_color)
|
|
315
|
+
signature = json.dumps([[t, [s.response for s in steps]] for t, steps in successes])
|
|
316
|
+
resolved = all(steps and steps[-1].response == "answer" for _, steps in successes)
|
|
317
|
+
code = ExitCode.OK if resolved and not had_error else ExitCode.PARTIAL
|
|
318
|
+
return code, signature
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@app.command(epilog=_LOOKUP_EPILOG)
|
|
322
|
+
def lookup(
|
|
323
|
+
target: Annotated[
|
|
324
|
+
Optional[str], typer.Argument(help="Hostname to resolve (or use --input-file).")
|
|
325
|
+
] = None,
|
|
326
|
+
types: Annotated[
|
|
327
|
+
Optional[list[str]],
|
|
328
|
+
typer.Option(
|
|
329
|
+
"--type", "-t", help="Record type(s) to query.", rich_help_panel="Query"
|
|
330
|
+
),
|
|
331
|
+
] = None,
|
|
332
|
+
all_types: Annotated[
|
|
333
|
+
bool,
|
|
334
|
+
typer.Option(
|
|
335
|
+
"--all",
|
|
336
|
+
help="Query all common record types (A/AAAA/CNAME/MX/NS/SOA/TXT/SRV/CAA).",
|
|
337
|
+
rich_help_panel="Modes",
|
|
338
|
+
),
|
|
339
|
+
] = False,
|
|
340
|
+
diff: Annotated[
|
|
341
|
+
bool,
|
|
342
|
+
typer.Option(
|
|
343
|
+
"--diff",
|
|
344
|
+
help="Query every --server resolver and compare/diff their answers.",
|
|
345
|
+
rich_help_panel="Modes",
|
|
346
|
+
),
|
|
347
|
+
] = False,
|
|
348
|
+
server: Annotated[
|
|
349
|
+
Optional[list[str]],
|
|
350
|
+
typer.Option(
|
|
351
|
+
"--server",
|
|
352
|
+
"-s",
|
|
353
|
+
help="Resolver(s) to query.",
|
|
354
|
+
rich_help_panel="Query controls",
|
|
355
|
+
),
|
|
356
|
+
] = None,
|
|
357
|
+
transport: Annotated[
|
|
358
|
+
str,
|
|
359
|
+
typer.Option(
|
|
360
|
+
"--transport", help="udp | tcp | auto.", rich_help_panel="Query controls"
|
|
361
|
+
),
|
|
362
|
+
] = "auto",
|
|
363
|
+
timeout: Annotated[
|
|
364
|
+
float,
|
|
365
|
+
typer.Option(
|
|
366
|
+
"--timeout",
|
|
367
|
+
help="Per-attempt timeout (s).",
|
|
368
|
+
rich_help_panel="Query controls",
|
|
369
|
+
),
|
|
370
|
+
] = 5.0,
|
|
371
|
+
retries: Annotated[
|
|
372
|
+
int,
|
|
373
|
+
typer.Option(
|
|
374
|
+
"--retries", help="Retry count.", rich_help_panel="Query controls"
|
|
375
|
+
),
|
|
376
|
+
] = 2,
|
|
377
|
+
port: Annotated[
|
|
378
|
+
int,
|
|
379
|
+
typer.Option("--port", help="Resolver port.", rich_help_panel="Query controls"),
|
|
380
|
+
] = 53,
|
|
381
|
+
input_file: Annotated[
|
|
382
|
+
Optional[Path],
|
|
383
|
+
typer.Option(
|
|
384
|
+
"--input-file", "-i", help="File of targets, one per line (# comments ok)."
|
|
385
|
+
),
|
|
386
|
+
] = None,
|
|
387
|
+
as_json: Annotated[
|
|
388
|
+
bool,
|
|
389
|
+
typer.Option(
|
|
390
|
+
"--json", help="Emit the versioned JSON envelope.", rich_help_panel="Output"
|
|
391
|
+
),
|
|
392
|
+
] = False,
|
|
393
|
+
jsonl: Annotated[
|
|
394
|
+
bool,
|
|
395
|
+
typer.Option(
|
|
396
|
+
"--jsonl",
|
|
397
|
+
help="Emit one JSON envelope per line (NDJSON).",
|
|
398
|
+
rich_help_panel="Output",
|
|
399
|
+
),
|
|
400
|
+
] = False,
|
|
401
|
+
trace: Annotated[
|
|
402
|
+
bool,
|
|
403
|
+
typer.Option(
|
|
404
|
+
"--trace",
|
|
405
|
+
help="Show the iterative resolution path (root -> authoritative).",
|
|
406
|
+
rich_help_panel="Modes",
|
|
407
|
+
),
|
|
408
|
+
] = False,
|
|
409
|
+
watch: Annotated[
|
|
410
|
+
Optional[str],
|
|
411
|
+
typer.Option(
|
|
412
|
+
"--watch",
|
|
413
|
+
help="Re-run every interval (e.g. 5s, 2m) until Ctrl-C.",
|
|
414
|
+
rich_help_panel="Modes",
|
|
415
|
+
),
|
|
416
|
+
] = None,
|
|
417
|
+
no_color: Annotated[
|
|
418
|
+
bool,
|
|
419
|
+
typer.Option(
|
|
420
|
+
"--no-color", help="Disable colored output.", rich_help_panel="Output"
|
|
421
|
+
),
|
|
422
|
+
] = False,
|
|
423
|
+
) -> None:
|
|
424
|
+
"""Forward DNS lookup for one or more hostnames."""
|
|
425
|
+
requested = types if types else ["A"]
|
|
426
|
+
try:
|
|
427
|
+
targets = collect_targets(target, input_file)
|
|
428
|
+
except UsageError as error:
|
|
429
|
+
typer.echo(f"error: {error.message}", err=True)
|
|
430
|
+
raise typer.Exit(int(ExitCode.USAGE)) from error
|
|
431
|
+
|
|
432
|
+
if all_types and trace:
|
|
433
|
+
typer.echo(
|
|
434
|
+
"error: --all cannot be combined with --trace (a trace follows one record type)",
|
|
435
|
+
err=True,
|
|
436
|
+
)
|
|
437
|
+
raise typer.Exit(int(ExitCode.USAGE))
|
|
438
|
+
# --all fans out over every common type; --diff honors it, --trace is rejected above.
|
|
439
|
+
compare_types = [t.value for t in api.ALL_RECORD_TYPES] if all_types else requested
|
|
440
|
+
|
|
441
|
+
def run_one(name: str) -> LookupResult:
|
|
442
|
+
if all_types:
|
|
443
|
+
return api.lookup_all(
|
|
444
|
+
name,
|
|
445
|
+
server=server,
|
|
446
|
+
transport=transport,
|
|
447
|
+
timeout=timeout,
|
|
448
|
+
retries=retries,
|
|
449
|
+
port=port,
|
|
450
|
+
)
|
|
451
|
+
return api.lookup(
|
|
452
|
+
name,
|
|
453
|
+
requested,
|
|
454
|
+
server=server,
|
|
455
|
+
transport=transport,
|
|
456
|
+
timeout=timeout,
|
|
457
|
+
retries=retries,
|
|
458
|
+
port=port,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def error_query(name: str) -> dict[str, Any]:
|
|
462
|
+
if all_types:
|
|
463
|
+
return {
|
|
464
|
+
"target": name,
|
|
465
|
+
"record_types": [t.value for t in api.ALL_RECORD_TYPES],
|
|
466
|
+
}
|
|
467
|
+
return {"target": name, "record_types": [t.upper() for t in requested]}
|
|
468
|
+
|
|
469
|
+
def action() -> ActionResult:
|
|
470
|
+
if trace:
|
|
471
|
+
return _run_trace(
|
|
472
|
+
targets,
|
|
473
|
+
lambda name: api.trace(name, requested[0], timeout=timeout, port=port),
|
|
474
|
+
command="dns.trace",
|
|
475
|
+
as_json=as_json,
|
|
476
|
+
jsonl=jsonl,
|
|
477
|
+
no_color=no_color,
|
|
478
|
+
)
|
|
479
|
+
if diff:
|
|
480
|
+
return _run_compare(
|
|
481
|
+
targets,
|
|
482
|
+
server or [],
|
|
483
|
+
compare_types,
|
|
484
|
+
transport=transport,
|
|
485
|
+
timeout=timeout,
|
|
486
|
+
retries=retries,
|
|
487
|
+
port=port,
|
|
488
|
+
as_json=as_json,
|
|
489
|
+
jsonl=jsonl,
|
|
490
|
+
no_color=no_color,
|
|
491
|
+
)
|
|
492
|
+
return _run(
|
|
493
|
+
"dns.lookup",
|
|
494
|
+
targets,
|
|
495
|
+
run_one,
|
|
496
|
+
error_query,
|
|
497
|
+
as_json=as_json,
|
|
498
|
+
jsonl=jsonl,
|
|
499
|
+
no_color=no_color,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
run_or_watch(action, watch_spec=watch, no_color=no_color)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@app.command(epilog=_REVERSE_EPILOG)
|
|
506
|
+
def reverse(
|
|
507
|
+
target: Annotated[
|
|
508
|
+
Optional[str],
|
|
509
|
+
typer.Argument(help="IP address to reverse-resolve (or use --input-file)."),
|
|
510
|
+
] = None,
|
|
511
|
+
server: Annotated[
|
|
512
|
+
Optional[list[str]],
|
|
513
|
+
typer.Option(
|
|
514
|
+
"--server",
|
|
515
|
+
"-s",
|
|
516
|
+
help="Resolver(s) to query.",
|
|
517
|
+
rich_help_panel="Query controls",
|
|
518
|
+
),
|
|
519
|
+
] = None,
|
|
520
|
+
transport: Annotated[
|
|
521
|
+
str,
|
|
522
|
+
typer.Option(
|
|
523
|
+
"--transport", help="udp | tcp | auto.", rich_help_panel="Query controls"
|
|
524
|
+
),
|
|
525
|
+
] = "auto",
|
|
526
|
+
timeout: Annotated[
|
|
527
|
+
float,
|
|
528
|
+
typer.Option(
|
|
529
|
+
"--timeout",
|
|
530
|
+
help="Per-attempt timeout (s).",
|
|
531
|
+
rich_help_panel="Query controls",
|
|
532
|
+
),
|
|
533
|
+
] = 5.0,
|
|
534
|
+
retries: Annotated[
|
|
535
|
+
int,
|
|
536
|
+
typer.Option(
|
|
537
|
+
"--retries", help="Retry count.", rich_help_panel="Query controls"
|
|
538
|
+
),
|
|
539
|
+
] = 2,
|
|
540
|
+
port: Annotated[
|
|
541
|
+
int,
|
|
542
|
+
typer.Option("--port", help="Resolver port.", rich_help_panel="Query controls"),
|
|
543
|
+
] = 53,
|
|
544
|
+
input_file: Annotated[
|
|
545
|
+
Optional[Path],
|
|
546
|
+
typer.Option(
|
|
547
|
+
"--input-file", "-i", help="File of IPs, one per line (# comments ok)."
|
|
548
|
+
),
|
|
549
|
+
] = None,
|
|
550
|
+
as_json: Annotated[
|
|
551
|
+
bool,
|
|
552
|
+
typer.Option(
|
|
553
|
+
"--json", help="Emit the versioned JSON envelope.", rich_help_panel="Output"
|
|
554
|
+
),
|
|
555
|
+
] = False,
|
|
556
|
+
jsonl: Annotated[
|
|
557
|
+
bool,
|
|
558
|
+
typer.Option(
|
|
559
|
+
"--jsonl",
|
|
560
|
+
help="Emit one JSON envelope per line (NDJSON).",
|
|
561
|
+
rich_help_panel="Output",
|
|
562
|
+
),
|
|
563
|
+
] = False,
|
|
564
|
+
trace: Annotated[
|
|
565
|
+
bool,
|
|
566
|
+
typer.Option(
|
|
567
|
+
"--trace",
|
|
568
|
+
help="Show the iterative resolution path (root -> authoritative).",
|
|
569
|
+
rich_help_panel="Modes",
|
|
570
|
+
),
|
|
571
|
+
] = False,
|
|
572
|
+
watch: Annotated[
|
|
573
|
+
Optional[str],
|
|
574
|
+
typer.Option(
|
|
575
|
+
"--watch",
|
|
576
|
+
help="Re-run every interval (e.g. 5s, 2m) until Ctrl-C.",
|
|
577
|
+
rich_help_panel="Modes",
|
|
578
|
+
),
|
|
579
|
+
] = None,
|
|
580
|
+
no_color: Annotated[
|
|
581
|
+
bool,
|
|
582
|
+
typer.Option(
|
|
583
|
+
"--no-color", help="Disable colored output.", rich_help_panel="Output"
|
|
584
|
+
),
|
|
585
|
+
] = False,
|
|
586
|
+
) -> None:
|
|
587
|
+
"""Reverse (PTR) lookup for one or more IP addresses."""
|
|
588
|
+
try:
|
|
589
|
+
targets = collect_targets(target, input_file)
|
|
590
|
+
except UsageError as error:
|
|
591
|
+
typer.echo(f"error: {error.message}", err=True)
|
|
592
|
+
raise typer.Exit(int(ExitCode.USAGE)) from error
|
|
593
|
+
|
|
594
|
+
def run_one(ip: str) -> LookupResult:
|
|
595
|
+
return api.reverse(
|
|
596
|
+
ip,
|
|
597
|
+
server=server,
|
|
598
|
+
transport=transport,
|
|
599
|
+
timeout=timeout,
|
|
600
|
+
retries=retries,
|
|
601
|
+
port=port,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
def error_query(ip: str) -> dict[str, Any]:
|
|
605
|
+
return {"target": ip}
|
|
606
|
+
|
|
607
|
+
def action() -> ActionResult:
|
|
608
|
+
if trace:
|
|
609
|
+
return _run_trace(
|
|
610
|
+
targets,
|
|
611
|
+
lambda ip: api.reverse_trace(ip, timeout=timeout, port=port),
|
|
612
|
+
command="dns.trace",
|
|
613
|
+
as_json=as_json,
|
|
614
|
+
jsonl=jsonl,
|
|
615
|
+
no_color=no_color,
|
|
616
|
+
)
|
|
617
|
+
return _run(
|
|
618
|
+
"dns.reverse",
|
|
619
|
+
targets,
|
|
620
|
+
run_one,
|
|
621
|
+
error_query,
|
|
622
|
+
as_json=as_json,
|
|
623
|
+
jsonl=jsonl,
|
|
624
|
+
no_color=no_color,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
run_or_watch(action, watch_spec=watch, no_color=no_color)
|
opskit/dns/errors.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""DNS-specific exception hierarchy (subclasses of :class:`opskit.core.errors.OpskitError`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from opskit.core.errors import OpskitError
|
|
6
|
+
from opskit.core.exit_codes import ExitCode
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DnsError(OpskitError):
|
|
10
|
+
"""Base class for DNS resolution failures (SERVFAIL unless a subclass narrows it)."""
|
|
11
|
+
|
|
12
|
+
code = "dns_error"
|
|
13
|
+
exit_code = ExitCode.SERVFAIL
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NxDomain(DnsError):
|
|
17
|
+
"""The queried name does not exist (NXDOMAIN)."""
|
|
18
|
+
|
|
19
|
+
code = "nxdomain"
|
|
20
|
+
exit_code = ExitCode.NXDOMAIN
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ServerFailure(DnsError):
|
|
24
|
+
"""The resolver returned SERVFAIL."""
|
|
25
|
+
|
|
26
|
+
code = "servfail"
|
|
27
|
+
exit_code = ExitCode.SERVFAIL
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DnsRefused(DnsError):
|
|
31
|
+
"""The resolver refused the query (REFUSED)."""
|
|
32
|
+
|
|
33
|
+
code = "refused"
|
|
34
|
+
exit_code = ExitCode.REFUSED
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DnsTimeout(DnsError):
|
|
38
|
+
"""No response before the timeout elapsed (the resolver may be filtered)."""
|
|
39
|
+
|
|
40
|
+
code = "timeout"
|
|
41
|
+
exit_code = ExitCode.TIMEOUT
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DnssecError(DnsError):
|
|
45
|
+
"""DNSSEC validation failed for the response."""
|
|
46
|
+
|
|
47
|
+
code = "dnssec_error"
|