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/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""opskit — cross-platform diagnostics for engineers, one toolkit for every OS.
|
|
2
|
+
|
|
3
|
+
This is the package root. Public, category-scoped APIs (e.g. ``opskit.dns``) are added
|
|
4
|
+
per feature; see the project constitution for the API-first design rules.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0" # x-release-please-version
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__"]
|
|
14
|
+
|
|
15
|
+
# Good-citizen library logging: silent unless the host app configures a handler.
|
|
16
|
+
logging.getLogger("opskit").addHandler(logging.NullHandler())
|
opskit/__main__.py
ADDED
opskit/cli.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Root command-line interface for opskit.
|
|
2
|
+
|
|
3
|
+
Per the constitution the CLI is a thin presentation layer: it parses arguments and
|
|
4
|
+
renders results, holding no business logic. Categories (e.g. ``dns``) register
|
|
5
|
+
themselves here as Typer sub-apps.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from opskit import __version__
|
|
13
|
+
from opskit.dns.cli import app as dns_app
|
|
14
|
+
from opskit.tls.cli import app as tls_app
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="opskit",
|
|
18
|
+
help="Cross-platform diagnostics for engineers — one toolkit, every OS.",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
add_completion=True,
|
|
21
|
+
)
|
|
22
|
+
app.add_typer(dns_app, name="dns")
|
|
23
|
+
app.add_typer(tls_app, name="tls")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _version_callback(value: bool) -> None:
|
|
27
|
+
"""Print the version and exit when ``--version`` was requested."""
|
|
28
|
+
if value:
|
|
29
|
+
typer.echo(f"opskit {__version__}")
|
|
30
|
+
raise typer.Exit
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.callback()
|
|
34
|
+
def root(
|
|
35
|
+
version: bool = typer.Option(
|
|
36
|
+
False,
|
|
37
|
+
"--version",
|
|
38
|
+
"-V",
|
|
39
|
+
help="Show the opskit version and exit.",
|
|
40
|
+
callback=_version_callback,
|
|
41
|
+
is_eager=True,
|
|
42
|
+
),
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Top-level options shared by every opskit command."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main() -> None: # pragma: no cover
|
|
48
|
+
"""Console-script entry point (``opskit``)."""
|
|
49
|
+
app()
|
opskit/core/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Shared cross-cutting concerns for opskit commands.
|
|
2
|
+
|
|
3
|
+
Holds the pieces every diagnostic category reuses: the exit-code map, the base exception
|
|
4
|
+
hierarchy, the versioned result envelope, and output rendering. Category packages (e.g.
|
|
5
|
+
``opskit.dns``) build on these; nothing here is category-specific.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
opskit/core/cliutils.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Category-agnostic CLI plumbing shared by every command group.
|
|
2
|
+
|
|
3
|
+
Batch-input reading, outcome collection with per-target failure tolerance, aggregate
|
|
4
|
+
exit-code derivation, JSON-envelope emission, and the ``--watch`` loop. Imports only
|
|
5
|
+
``opskit.core`` — no category models (constitution Art. VII).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, TypeVar
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from opskit.core.errors import OpskitError, UsageError
|
|
20
|
+
from opskit.core.exit_codes import ExitCode, exit_code_for
|
|
21
|
+
from opskit.core.output import make_console
|
|
22
|
+
from opskit.core.result import to_json
|
|
23
|
+
|
|
24
|
+
_T = TypeVar("_T")
|
|
25
|
+
|
|
26
|
+
# (exit code, change-detection signature) returned by a command's single execution.
|
|
27
|
+
ActionResult = tuple[ExitCode, str]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_input_file(path: Path) -> list[str]:
|
|
31
|
+
"""Read targets from a file, one per line, ignoring blanks and ``#`` comments."""
|
|
32
|
+
try:
|
|
33
|
+
raw = path.read_text(encoding="utf-8")
|
|
34
|
+
except OSError as exc:
|
|
35
|
+
raise UsageError(f"cannot read input file: {path}") from exc
|
|
36
|
+
targets: list[str] = []
|
|
37
|
+
for line in raw.splitlines():
|
|
38
|
+
stripped = line.strip()
|
|
39
|
+
if stripped and not stripped.startswith("#"):
|
|
40
|
+
targets.append(stripped)
|
|
41
|
+
return targets
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def collect_targets(positional: str | None, input_file: Path | None) -> list[str]:
|
|
45
|
+
"""Combine a positional target with any from ``--input-file`` (positional first)."""
|
|
46
|
+
targets: list[str] = []
|
|
47
|
+
if positional:
|
|
48
|
+
targets.append(positional)
|
|
49
|
+
if input_file is not None:
|
|
50
|
+
targets.extend(read_input_file(input_file))
|
|
51
|
+
if not targets:
|
|
52
|
+
raise UsageError("provide a target argument or --input-file")
|
|
53
|
+
return targets
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def collect_outcomes(
|
|
57
|
+
targets: Sequence[str], run_one: Callable[[str], _T]
|
|
58
|
+
) -> list[tuple[str, _T | None, OpskitError | None]]:
|
|
59
|
+
"""Run ``run_one`` for each target, capturing its result or the raised OpskitError."""
|
|
60
|
+
outcomes: list[tuple[str, _T | None, OpskitError | None]] = []
|
|
61
|
+
for target in targets:
|
|
62
|
+
try:
|
|
63
|
+
outcomes.append((target, run_one(target), None))
|
|
64
|
+
except OpskitError as exc:
|
|
65
|
+
outcomes.append((target, None, exc))
|
|
66
|
+
return outcomes
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def echo_failures(
|
|
70
|
+
outcomes: Sequence[tuple[str, object, OpskitError | None]],
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Print each failed target's error to stderr (human mode / no-success bail)."""
|
|
73
|
+
for target, _, error in outcomes:
|
|
74
|
+
if error is not None:
|
|
75
|
+
typer.echo(f"error: {target}: {error.message}", err=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def aggregate_exit(codes: Sequence[ExitCode]) -> ExitCode:
|
|
79
|
+
"""Batch rule (Art. IX): 0 if all succeed; the class code if uniform; else PARTIAL."""
|
|
80
|
+
if all(code is ExitCode.OK for code in codes):
|
|
81
|
+
return ExitCode.OK
|
|
82
|
+
distinct = {code for code in codes if code is not ExitCode.OK}
|
|
83
|
+
if len(distinct) == 1 and all(code is not ExitCode.OK for code in codes):
|
|
84
|
+
return next(iter(distinct))
|
|
85
|
+
return ExitCode.PARTIAL
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def aggregate_outcome_exit(
|
|
89
|
+
outcomes: Sequence[tuple[str, object, OpskitError | None]],
|
|
90
|
+
) -> ExitCode:
|
|
91
|
+
""":func:`aggregate_exit` over (target, result, error) outcome tuples."""
|
|
92
|
+
return aggregate_exit(
|
|
93
|
+
[
|
|
94
|
+
ExitCode.OK if error is None else exit_code_for(error)
|
|
95
|
+
for _, _, error in outcomes
|
|
96
|
+
]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def emit_envelopes(envelopes: Sequence[dict[str, Any]], *, jsonl: bool) -> None:
|
|
101
|
+
"""Emit JSON envelopes: NDJSON (one per line), a bare object, or an array."""
|
|
102
|
+
if jsonl:
|
|
103
|
+
for envelope in envelopes:
|
|
104
|
+
typer.echo(to_json(envelope, indent=None))
|
|
105
|
+
elif len(envelopes) == 1:
|
|
106
|
+
typer.echo(to_json(envelopes[0]))
|
|
107
|
+
else:
|
|
108
|
+
typer.echo(json.dumps(envelopes, indent=2))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_interval(text: str) -> float:
|
|
112
|
+
"""Parse a --watch interval like '5', '5s', '250ms', or '2m' into seconds."""
|
|
113
|
+
value = text.strip().lower()
|
|
114
|
+
try:
|
|
115
|
+
if value.endswith("ms"):
|
|
116
|
+
seconds = float(value[:-2]) / 1000.0
|
|
117
|
+
elif value.endswith("s"):
|
|
118
|
+
seconds = float(value[:-1])
|
|
119
|
+
elif value.endswith("m"):
|
|
120
|
+
seconds = float(value[:-1]) * 60.0
|
|
121
|
+
else:
|
|
122
|
+
seconds = float(value)
|
|
123
|
+
except ValueError as exc:
|
|
124
|
+
raise UsageError(f"invalid --watch interval: {text}") from exc
|
|
125
|
+
if seconds <= 0:
|
|
126
|
+
raise UsageError("--watch interval must be positive")
|
|
127
|
+
return seconds
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def watch(
|
|
131
|
+
action: Callable[[], ActionResult],
|
|
132
|
+
*,
|
|
133
|
+
interval: float,
|
|
134
|
+
no_color: bool,
|
|
135
|
+
) -> ExitCode:
|
|
136
|
+
"""Re-run ``action`` every ``interval`` seconds until interrupted, flagging changes."""
|
|
137
|
+
console = make_console(no_color=no_color)
|
|
138
|
+
previous: str | None = None
|
|
139
|
+
code = ExitCode.OK
|
|
140
|
+
try:
|
|
141
|
+
while True:
|
|
142
|
+
code, signature = action()
|
|
143
|
+
if previous is None:
|
|
144
|
+
status = "initial"
|
|
145
|
+
elif signature != previous:
|
|
146
|
+
status = "changed"
|
|
147
|
+
else:
|
|
148
|
+
status = "no change"
|
|
149
|
+
stamp = datetime.now().strftime("%H:%M:%S")
|
|
150
|
+
console.print(
|
|
151
|
+
f"[dim]-- {stamp} - {status} - next in {interval:g}s (Ctrl-C to stop) --[/dim]"
|
|
152
|
+
)
|
|
153
|
+
previous = signature
|
|
154
|
+
time.sleep(interval) # looked up at call time so tests can patch it
|
|
155
|
+
except KeyboardInterrupt:
|
|
156
|
+
return code
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def run_or_watch(
|
|
160
|
+
action: Callable[[], ActionResult], *, watch_spec: str | None, no_color: bool
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Run ``action`` once, or repeatedly under --watch; then exit with its code."""
|
|
163
|
+
if watch_spec is not None:
|
|
164
|
+
try:
|
|
165
|
+
interval = parse_interval(watch_spec)
|
|
166
|
+
except UsageError as error:
|
|
167
|
+
typer.echo(f"error: {error.message}", err=True)
|
|
168
|
+
raise typer.Exit(int(ExitCode.USAGE)) from error
|
|
169
|
+
raise typer.Exit(int(watch(action, interval=interval, no_color=no_color)))
|
|
170
|
+
code, _ = action()
|
|
171
|
+
raise typer.Exit(int(code))
|
opskit/core/errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Base exception hierarchy for opskit.
|
|
2
|
+
|
|
3
|
+
The library layer raises these; only the CLI catches them and maps them to exit codes
|
|
4
|
+
(see :mod:`opskit.core.exit_codes`). No raw third-party exception should reach the user.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from opskit.core.exit_codes import ExitCode
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpskitError(Exception):
|
|
13
|
+
"""Base class for all opskit errors.
|
|
14
|
+
|
|
15
|
+
Each subclass declares the process ``exit_code`` it maps to, so the CLI's exit-code
|
|
16
|
+
resolution stays category-agnostic (see :func:`opskit.core.exit_codes.exit_code_for`).
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
message: Human-readable summary of what went wrong.
|
|
20
|
+
hint: Optional actionable next step for the user (e.g. "try a different --server").
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
code: str = "error"
|
|
24
|
+
exit_code: ExitCode = ExitCode.ERROR # generic failure unless a subclass narrows it
|
|
25
|
+
|
|
26
|
+
def __init__(self, message: str, *, hint: str | None = None) -> None:
|
|
27
|
+
"""Initialize the error with a message and an optional remediation hint."""
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.message = message
|
|
30
|
+
self.hint = hint
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UsageError(OpskitError):
|
|
34
|
+
"""Invalid user input (bad name/IP, unknown option). Raised before any network I/O."""
|
|
35
|
+
|
|
36
|
+
code = "usage_error"
|
|
37
|
+
exit_code = ExitCode.USAGE
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Structured process exit codes and the exception-to-code mapping.
|
|
2
|
+
|
|
3
|
+
Exit codes are part of the public, SemVer-governed contract (see contracts/cli.md) so scripts
|
|
4
|
+
can branch on the outcome class without parsing text.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import IntEnum
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from opskit.core.errors import OpskitError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExitCode(IntEnum):
|
|
17
|
+
"""Documented exit codes returned by the CLI."""
|
|
18
|
+
|
|
19
|
+
OK = 0
|
|
20
|
+
ERROR = 1
|
|
21
|
+
USAGE = 2
|
|
22
|
+
NXDOMAIN = 3
|
|
23
|
+
SERVFAIL = 4
|
|
24
|
+
REFUSED = 5
|
|
25
|
+
TIMEOUT = 6
|
|
26
|
+
PARTIAL = 7
|
|
27
|
+
CONNECT_FAILED = 8
|
|
28
|
+
HANDSHAKE_FAILED = 9
|
|
29
|
+
CERT_INVALID = 10
|
|
30
|
+
CERT_EXPIRING = 11
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def exit_code_for(error: OpskitError) -> ExitCode:
|
|
34
|
+
"""Return the :class:`ExitCode` an error declares.
|
|
35
|
+
|
|
36
|
+
Each error type owns its ``exit_code`` (set on the class), so this mapping stays
|
|
37
|
+
category-agnostic — adding a new category (net/tls/ad) never touches ``core``.
|
|
38
|
+
"""
|
|
39
|
+
return error.exit_code
|
opskit/core/output.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shared, category-agnostic console setup.
|
|
2
|
+
|
|
3
|
+
``rich.Console`` auto-detects a TTY (plain when piped) and honors ``NO_COLOR``; ``no_color``
|
|
4
|
+
forces plain output regardless. Category-specific rendering lives with its category (e.g.
|
|
5
|
+
:mod:`opskit.dns.output`) so ``core`` stays free of category models.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make_console(*, no_color: bool = False) -> Console:
|
|
14
|
+
"""Return a console configured for the current output context.
|
|
15
|
+
|
|
16
|
+
``no_color`` forces plain output when set; when unset, ``Console`` is left to its default
|
|
17
|
+
(which honors the ``NO_COLOR`` environment variable and auto-detects a non-TTY).
|
|
18
|
+
"""
|
|
19
|
+
return Console(no_color=True if no_color else None, highlight=False)
|
opskit/core/result.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Versioned JSON output envelope shared by every command.
|
|
2
|
+
|
|
3
|
+
The envelope shape is the public ``--json`` contract (contracts/json-envelope.md); its schema
|
|
4
|
+
changes are governed by SemVer. The current ``schema_version`` is ``"1"``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from opskit.core.errors import OpskitError
|
|
13
|
+
|
|
14
|
+
SCHEMA_VERSION = "1"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_envelope(
|
|
18
|
+
*,
|
|
19
|
+
command: str,
|
|
20
|
+
query: dict[str, Any],
|
|
21
|
+
result: dict[str, Any] | None,
|
|
22
|
+
error: OpskitError | None,
|
|
23
|
+
elapsed_ms: float,
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
"""Assemble the versioned envelope for a single command invocation."""
|
|
26
|
+
error_obj: dict[str, Any] | None = None
|
|
27
|
+
if error is not None:
|
|
28
|
+
error_obj = {"code": error.code, "message": error.message, "hint": error.hint}
|
|
29
|
+
return {
|
|
30
|
+
"schema_version": SCHEMA_VERSION,
|
|
31
|
+
"command": command,
|
|
32
|
+
"query": query,
|
|
33
|
+
"result": result,
|
|
34
|
+
"error": error_obj,
|
|
35
|
+
"elapsed_ms": round(elapsed_ms, 3),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def to_json(envelope: dict[str, Any], *, indent: int | None = 2) -> str:
|
|
40
|
+
"""Serialize an envelope to a JSON string (single object)."""
|
|
41
|
+
return json.dumps(envelope, indent=indent)
|
opskit/dns/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# `opskit dns` — DNS diagnostics
|
|
2
|
+
|
|
3
|
+
Read-only DNS troubleshooting that behaves **identically on Windows, macOS, and Linux** — a single
|
|
4
|
+
replacement for `nslookup` / `dig` / `Resolve-DnsName`. Available both as CLI commands and as an
|
|
5
|
+
importable Python API.
|
|
6
|
+
|
|
7
|
+
> Part of [**opskit**](../../../README.md). See the root README for install and project-wide docs.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Contents
|
|
12
|
+
|
|
13
|
+
- [Commands](#commands)
|
|
14
|
+
- [`opskit dns lookup`](#opskit-dns-lookup)
|
|
15
|
+
- [`opskit dns reverse`](#opskit-dns-reverse)
|
|
16
|
+
- [Modes](#modes) — `--all`, `--diff`, `--trace`, `--watch`
|
|
17
|
+
- [Bulk lookups](#bulk-lookups) — `--input-file`
|
|
18
|
+
- [Output & exit codes](#output--exit-codes)
|
|
19
|
+
- [Use as a Python library](#use-as-a-python-library)
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
opskit dns lookup example.com # A records, pretty table
|
|
27
|
+
opskit dns lookup example.com -t MX -t TXT # specific record types
|
|
28
|
+
opskit dns lookup example.com --all # every common record type at once
|
|
29
|
+
opskit dns reverse 8.8.8.8 # PTR (IP → hostname)
|
|
30
|
+
opskit dns lookup example.com --json # machine-readable envelope
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Help is grouped into panels (**Query**, **Modes**, **Query controls**, **Output**) with worked
|
|
34
|
+
examples in the footer — think `man`, but organized and copy-pasteable:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
opskit dns --help # the DNS command group
|
|
38
|
+
opskit dns lookup --help # a specific command (grouped options + examples)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Commands
|
|
44
|
+
|
|
45
|
+
### `opskit dns lookup`
|
|
46
|
+
|
|
47
|
+
Forward DNS lookup for one or more hostnames.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
opskit dns lookup TARGET [OPTIONS]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
| Option | Description | Default |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `TARGET` | Hostname to resolve (optional if `--input-file` is used) | — |
|
|
56
|
+
| `-t, --type` | Record type(s): `A AAAA MX TXT CNAME NS SOA SRV CAA` (repeatable) | `A` |
|
|
57
|
+
| `--all` | Query **all** common record types at once | off |
|
|
58
|
+
| `--diff` | Query every `--server` and compare/diff answers | off |
|
|
59
|
+
| `--trace` | Show the iterative resolution path (root → authoritative) | off |
|
|
60
|
+
| `-s, --server` | Resolver(s) to query (repeatable); defaults to the system resolver | system |
|
|
61
|
+
| `--transport` | `udp` \| `tcp` \| `auto` (UDP then TCP on truncation) | `auto` |
|
|
62
|
+
| `--timeout` | Per-attempt timeout, seconds | `5.0` |
|
|
63
|
+
| `--retries` | Retry count on timeout | `2` |
|
|
64
|
+
| `--port` | Resolver port | `53` |
|
|
65
|
+
| `-i, --input-file` | File of targets, one per line (`#` comments allowed) | — |
|
|
66
|
+
| `--json` | Emit the versioned JSON envelope | off |
|
|
67
|
+
| `--jsonl` | Emit one JSON envelope per line (NDJSON) | off |
|
|
68
|
+
| `--watch` | Re-run every interval (e.g. `5s`, `2m`, `250ms`) until Ctrl-C | off |
|
|
69
|
+
| `--no-color` | Disable colored output | off |
|
|
70
|
+
|
|
71
|
+
### `opskit dns reverse`
|
|
72
|
+
|
|
73
|
+
Reverse (PTR) lookup for one or more IP addresses. Accepts IPv4 and IPv6.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
opskit dns reverse IP [OPTIONS]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Supports the same query controls, output, `--trace`, `--watch`, and `--input-file` options as
|
|
80
|
+
`lookup` (record-type options don't apply).
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Modes
|
|
85
|
+
|
|
86
|
+
### `--all` — one-stop lookup
|
|
87
|
+
|
|
88
|
+
DNS `ANY` queries are deprecated (RFC 8482), so `--all` fans out across every common record type
|
|
89
|
+
and aggregates the results. It's **resilient**: if a resolver refuses one type, that type is
|
|
90
|
+
skipped rather than failing the whole lookup — you still get everything that exists.
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
opskit dns lookup cloudflare.com --all
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `--diff` — multi-resolver comparison
|
|
97
|
+
|
|
98
|
+
Ask the same question of several resolvers and see **who returns what** — the differing resolver
|
|
99
|
+
is highlighted. Great for propagation lag, split-horizon/GeoDNS, or spotting a broken/poisoned
|
|
100
|
+
resolver.
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
opskit dns lookup example.com --diff -s 1.1.1.1 -s 8.8.8.8 -s 9.9.9.9
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Exit code is `0` when all resolvers agree and `7` when they differ (TTLs are ignored — only real
|
|
107
|
+
answer differences count), so you can alert on divergence in scripts.
|
|
108
|
+
|
|
109
|
+
### `--trace` — the resolution path
|
|
110
|
+
|
|
111
|
+
An iterative walk from the root servers, following each delegation down to the authoritative
|
|
112
|
+
answer — like `dig +trace`, one row per hop.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
opskit dns lookup www.wikipedia.org --trace
|
|
116
|
+
opskit dns reverse 8.8.8.8 --trace
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### `--watch` — live re-run
|
|
120
|
+
|
|
121
|
+
Re-run on an interval until Ctrl-C, flagging when the **answer changes** (TTL changes are
|
|
122
|
+
ignored). Ideal for watching propagation or failover.
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
opskit dns lookup example.com --watch 30s
|
|
126
|
+
opskit dns lookup example.com --all --watch 10s # modes compose
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Bulk lookups
|
|
132
|
+
|
|
133
|
+
Feed many targets from a file with `-i/--input-file` (one per line; blank lines and `#` comments
|
|
134
|
+
are skipped). Combine with a positional target if you like.
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
opskit dns lookup -i hosts.txt # per-target tables
|
|
138
|
+
opskit dns lookup -i hosts.txt --jsonl | jq . # NDJSON, one object per line
|
|
139
|
+
opskit dns reverse -i ips.txt --json # JSON array
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
For a batch, the exit code is `0` only if **every** target succeeds; otherwise `7` (partial).
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Output & exit codes
|
|
147
|
+
|
|
148
|
+
- **Human** (default): colorized tables, auto-plain when piped; honors `NO_COLOR` and `--no-color`.
|
|
149
|
+
- **`--json`**: a stable, versioned envelope (`schema_version`, `command`, `query`, `result`,
|
|
150
|
+
`error`, `elapsed_ms`); an array for batches.
|
|
151
|
+
- **`--jsonl`**: NDJSON — one envelope per line, ideal for `jq` / streaming.
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{
|
|
155
|
+
"schema_version": "1",
|
|
156
|
+
"command": "dns.lookup",
|
|
157
|
+
"query": { "target": "example.com", "record_types": ["A"], "servers": [], "transport": "auto" },
|
|
158
|
+
"result": { "outcome": "ok", "resolver": "1.1.1.1", "records": [ { "type": "A", "value": "93.184.216.34", "ttl": 300 } ] },
|
|
159
|
+
"error": null,
|
|
160
|
+
"elapsed_ms": 12.3
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Exit codes are documented and scriptable:
|
|
165
|
+
|
|
166
|
+
| Code | Meaning |
|
|
167
|
+
|---|---|
|
|
168
|
+
| `0` | success |
|
|
169
|
+
| `1` | generic error (uncategorized failure) |
|
|
170
|
+
| `2` | usage error (bad input; before any network) |
|
|
171
|
+
| `3` | NXDOMAIN (name does not exist) |
|
|
172
|
+
| `4` | SERVFAIL |
|
|
173
|
+
| `5` | REFUSED |
|
|
174
|
+
| `6` | TIMEOUT / no response |
|
|
175
|
+
| `7` | PARTIAL (batch had a failure, or resolvers disagreed) |
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Use as a Python library
|
|
180
|
+
|
|
181
|
+
The DNS API is **API-first** — every capability is importable, returns typed results, and raises
|
|
182
|
+
typed exceptions (it never prints or calls `sys.exit`). The package ships `py.typed`.
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from opskit.dns import lookup, reverse, lookup_all, compare, trace
|
|
186
|
+
from opskit.dns import NxDomain, DnsTimeout
|
|
187
|
+
|
|
188
|
+
result = lookup("example.com", ["A", "MX"], server="1.1.1.1", timeout=3)
|
|
189
|
+
if result.ok:
|
|
190
|
+
for record in result: # results are iterable
|
|
191
|
+
print(record.type.value, record.value, record.ttl)
|
|
192
|
+
|
|
193
|
+
everything = lookup_all("example.com") # all record types
|
|
194
|
+
ptr = reverse("8.8.8.8") # PTR
|
|
195
|
+
cmp = compare("example.com", ["1.1.1.1", "8.8.8.8"]) # multi-resolver
|
|
196
|
+
print("consistent:", cmp.consistent)
|
|
197
|
+
hops = trace("www.wikipedia.org") # resolution path
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
lookup("does-not-exist.invalid")
|
|
201
|
+
except NxDomain as exc:
|
|
202
|
+
print(exc.message, "—", exc.hint)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Results serialize cleanly: `result.to_dict()` / the envelope shape match the CLI's `--json`.
|
opskit/dns/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""DNS diagnostics — importable API and CLI sub-app.
|
|
2
|
+
|
|
3
|
+
Public surface (SemVer-governed): :func:`lookup`, the typed models, and the DNS exception
|
|
4
|
+
hierarchy. Failures raise; nothing here prints or exits the process.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from opskit.dns.api import (
|
|
10
|
+
compare,
|
|
11
|
+
lookup,
|
|
12
|
+
lookup_all,
|
|
13
|
+
reverse,
|
|
14
|
+
reverse_trace,
|
|
15
|
+
trace,
|
|
16
|
+
)
|
|
17
|
+
from opskit.dns.errors import (
|
|
18
|
+
DnsError,
|
|
19
|
+
DnsRefused,
|
|
20
|
+
DnssecError,
|
|
21
|
+
DnsTimeout,
|
|
22
|
+
NxDomain,
|
|
23
|
+
ServerFailure,
|
|
24
|
+
)
|
|
25
|
+
from opskit.dns.models import (
|
|
26
|
+
DnsQuery,
|
|
27
|
+
DnsRecord,
|
|
28
|
+
LookupResult,
|
|
29
|
+
Outcome,
|
|
30
|
+
RecordType,
|
|
31
|
+
Resolver,
|
|
32
|
+
ResolverAnswer,
|
|
33
|
+
ResolverComparison,
|
|
34
|
+
TraceStep,
|
|
35
|
+
Transport,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"DnsError",
|
|
40
|
+
"DnsQuery",
|
|
41
|
+
"DnsRecord",
|
|
42
|
+
"DnsRefused",
|
|
43
|
+
"DnsTimeout",
|
|
44
|
+
"DnssecError",
|
|
45
|
+
"LookupResult",
|
|
46
|
+
"NxDomain",
|
|
47
|
+
"Outcome",
|
|
48
|
+
"RecordType",
|
|
49
|
+
"Resolver",
|
|
50
|
+
"ResolverAnswer",
|
|
51
|
+
"ResolverComparison",
|
|
52
|
+
"ServerFailure",
|
|
53
|
+
"TraceStep",
|
|
54
|
+
"Transport",
|
|
55
|
+
"compare",
|
|
56
|
+
"lookup",
|
|
57
|
+
"lookup_all",
|
|
58
|
+
"reverse",
|
|
59
|
+
"reverse_trace",
|
|
60
|
+
"trace",
|
|
61
|
+
]
|