ipsak 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.
- ipsak/__init__.py +3 -0
- ipsak/__main__.py +15 -0
- ipsak/cli.py +343 -0
- ipsak/display.py +601 -0
- ipsak/lookups/__init__.py +131 -0
- ipsak/lookups/asn.py +63 -0
- ipsak/lookups/bogon.py +62 -0
- ipsak/lookups/dns.py +49 -0
- ipsak/lookups/geo.py +33 -0
- ipsak/lookups/myip.py +115 -0
- ipsak/lookups/reputation.py +57 -0
- ipsak/lookups/rpki.py +40 -0
- ipsak/lookups/subnet.py +38 -0
- ipsak/lookups/trace.py +327 -0
- ipsak/lookups/trace_engine.py +299 -0
- ipsak/lookups/whois.py +85 -0
- ipsak/models.py +153 -0
- ipsak/resolve.py +72 -0
- ipsak-0.1.0.dist-info/METADATA +190 -0
- ipsak-0.1.0.dist-info/RECORD +23 -0
- ipsak-0.1.0.dist-info/WHEEL +4 -0
- ipsak-0.1.0.dist-info/entry_points.txt +2 -0
- ipsak-0.1.0.dist-info/licenses/LICENSE +116 -0
ipsak/__init__.py
ADDED
ipsak/__main__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = [
|
|
4
|
+
# "dnspython>=2.6",
|
|
5
|
+
# "ipwhois>=1.3",
|
|
6
|
+
# "httpx>=0.27",
|
|
7
|
+
# "typer>=0.12",
|
|
8
|
+
# "rich>=13.0",
|
|
9
|
+
# ]
|
|
10
|
+
# ///
|
|
11
|
+
"""Allow running as `python -m ipsak` or `uv run src/ipsak/__main__.py`."""
|
|
12
|
+
|
|
13
|
+
from ipsak.cli import app
|
|
14
|
+
|
|
15
|
+
app()
|
ipsak/cli.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""CLI entry point for ipsak."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from typer.core import TyperGroup
|
|
10
|
+
|
|
11
|
+
from ipsak import __version__
|
|
12
|
+
from ipsak.display import (
|
|
13
|
+
print_calc,
|
|
14
|
+
print_dns,
|
|
15
|
+
print_info,
|
|
16
|
+
print_json,
|
|
17
|
+
print_myip,
|
|
18
|
+
print_trace,
|
|
19
|
+
print_whois,
|
|
20
|
+
)
|
|
21
|
+
from ipsak.lookups import run_info_lookups
|
|
22
|
+
from ipsak.lookups.dns import lookup_dns_records, lookup_ptr
|
|
23
|
+
from ipsak.lookups.subnet import calculate_subnet
|
|
24
|
+
from ipsak.lookups.trace import run_traceroute
|
|
25
|
+
from ipsak.lookups.whois import lookup_whois
|
|
26
|
+
from ipsak.models import DNSResults, QueryResult
|
|
27
|
+
from ipsak.resolve import detect_target
|
|
28
|
+
|
|
29
|
+
console = Console(stderr=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# AIDEV-NOTE: Custom Click group that routes unknown first arguments to the "info"
|
|
33
|
+
# subcommand, enabling `ipsak 8.8.8.8` as shorthand for `ipsak info 8.8.8.8`.
|
|
34
|
+
# Known subcommands (dns, whois, calc, trace) are routed normally.
|
|
35
|
+
class DefaultInfoGroup(TyperGroup):
|
|
36
|
+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
37
|
+
# If first arg isn't a known command or a flag, treat it as `info <target>`
|
|
38
|
+
if args and args[0] not in self.commands and not args[0].startswith("-"):
|
|
39
|
+
args = ["info"] + args
|
|
40
|
+
return super().parse_args(ctx, args)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
app = typer.Typer(
|
|
44
|
+
name="ipsak",
|
|
45
|
+
help="The IP Swiss Army Knife — fast IP, CIDR, and domain information queries for network operations.",
|
|
46
|
+
cls=DefaultInfoGroup,
|
|
47
|
+
add_completion=False,
|
|
48
|
+
invoke_without_command=True,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.callback(invoke_without_command=True)
|
|
53
|
+
def main(
|
|
54
|
+
ctx: typer.Context,
|
|
55
|
+
version: Annotated[bool, typer.Option("--version", "-V", help="Show version")] = False,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""The IP Swiss Army Knife — fast IP, CIDR, and domain information queries."""
|
|
58
|
+
if version:
|
|
59
|
+
typer.echo(f"ipsak {__version__}")
|
|
60
|
+
raise typer.Exit()
|
|
61
|
+
if ctx.invoked_subcommand is None:
|
|
62
|
+
typer.echo(ctx.get_help())
|
|
63
|
+
raise typer.Exit()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command()
|
|
67
|
+
def info(
|
|
68
|
+
target: Annotated[str, typer.Argument(help="IP address, CIDR, or domain to query")],
|
|
69
|
+
json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False,
|
|
70
|
+
trace: Annotated[bool, typer.Option("--trace", "-t", help="Include traceroute")] = False,
|
|
71
|
+
timeout: Annotated[
|
|
72
|
+
float, typer.Option("--timeout", "-T", help="Lookup timeout in seconds")
|
|
73
|
+
] = 10.0,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Show comprehensive information about a target."""
|
|
76
|
+
_run_info(target, json_output=json_output, do_trace=trace, timeout=timeout)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command()
|
|
80
|
+
def dns(
|
|
81
|
+
target: Annotated[str, typer.Argument(help="Domain or IP to query DNS for")],
|
|
82
|
+
json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False,
|
|
83
|
+
timeout: Annotated[
|
|
84
|
+
float, typer.Option("--timeout", "-T", help="Lookup timeout in seconds")
|
|
85
|
+
] = 10.0,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Look up DNS records for a domain or reverse DNS for an IP."""
|
|
88
|
+
target_type, normalized = detect_target(target)
|
|
89
|
+
|
|
90
|
+
async def _run() -> QueryResult:
|
|
91
|
+
result = QueryResult(target=normalized, target_type=target_type, ip=None)
|
|
92
|
+
result.dns = DNSResults()
|
|
93
|
+
|
|
94
|
+
if target_type in ("ipv4", "ipv6"):
|
|
95
|
+
result.ip = normalized
|
|
96
|
+
try:
|
|
97
|
+
result.dns.ptr = await lookup_ptr(normalized, timeout=timeout)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
result.errors["ptr"] = str(e)
|
|
100
|
+
elif target_type == "domain":
|
|
101
|
+
try:
|
|
102
|
+
records = await lookup_dns_records(normalized, timeout=timeout)
|
|
103
|
+
for k, v in records.items():
|
|
104
|
+
if k == "soa":
|
|
105
|
+
result.dns.soa = v # type: ignore[assignment]
|
|
106
|
+
elif hasattr(result.dns, k):
|
|
107
|
+
setattr(result.dns, k, v)
|
|
108
|
+
if result.dns.a:
|
|
109
|
+
result.ip = result.dns.a[0]
|
|
110
|
+
except Exception as e:
|
|
111
|
+
result.errors["dns"] = str(e)
|
|
112
|
+
else:
|
|
113
|
+
_error_exit(f"Cannot look up DNS for: {target}")
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
result = asyncio.run(_run())
|
|
117
|
+
if json_output:
|
|
118
|
+
print_json(result)
|
|
119
|
+
else:
|
|
120
|
+
print_dns(result)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command()
|
|
124
|
+
def whois(
|
|
125
|
+
target: Annotated[str, typer.Argument(help="IP address to query WHOIS for")],
|
|
126
|
+
json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False,
|
|
127
|
+
timeout: Annotated[
|
|
128
|
+
float, typer.Option("--timeout", "-T", help="Lookup timeout in seconds")
|
|
129
|
+
] = 10.0,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Look up WHOIS/RDAP information for an IP."""
|
|
132
|
+
target_type, normalized = detect_target(target)
|
|
133
|
+
|
|
134
|
+
if target_type not in ("ipv4", "ipv6"):
|
|
135
|
+
if target_type == "domain":
|
|
136
|
+
import dns.resolver
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
answers = dns.resolver.resolve(normalized, "A")
|
|
140
|
+
normalized = str(answers[0])
|
|
141
|
+
target_type = "ipv4"
|
|
142
|
+
except Exception:
|
|
143
|
+
_error_exit(f"Cannot resolve domain: {target}")
|
|
144
|
+
else:
|
|
145
|
+
_error_exit(f"Cannot look up WHOIS for: {target}")
|
|
146
|
+
|
|
147
|
+
async def _run() -> QueryResult:
|
|
148
|
+
result = QueryResult(target=target, target_type=target_type, ip=normalized)
|
|
149
|
+
try:
|
|
150
|
+
result.whois = await lookup_whois(normalized, timeout=timeout)
|
|
151
|
+
except Exception as e:
|
|
152
|
+
result.errors["whois"] = str(e)
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
result = asyncio.run(_run())
|
|
156
|
+
if json_output:
|
|
157
|
+
print_json(result)
|
|
158
|
+
else:
|
|
159
|
+
print_whois(result)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command()
|
|
163
|
+
def calc(
|
|
164
|
+
cidr: Annotated[str, typer.Argument(help="CIDR notation network (e.g. 10.0.0.0/24)")],
|
|
165
|
+
json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""IP subnet calculator."""
|
|
168
|
+
target_type, normalized = detect_target(cidr)
|
|
169
|
+
if not target_type.startswith("cidr"):
|
|
170
|
+
_error_exit(f"Not a valid CIDR: {cidr}")
|
|
171
|
+
|
|
172
|
+
subnet = calculate_subnet(normalized)
|
|
173
|
+
|
|
174
|
+
if json_output:
|
|
175
|
+
import json
|
|
176
|
+
from dataclasses import asdict
|
|
177
|
+
|
|
178
|
+
print(json.dumps(asdict(subnet), indent=2, default=str))
|
|
179
|
+
else:
|
|
180
|
+
print_calc(subnet)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command(name="trace")
|
|
184
|
+
def trace_cmd(
|
|
185
|
+
target: Annotated[str, typer.Argument(help="Target to traceroute to")],
|
|
186
|
+
json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False,
|
|
187
|
+
timeout: Annotated[
|
|
188
|
+
float, typer.Option("--timeout", "-T", help="Traceroute timeout in seconds")
|
|
189
|
+
] = 10.0,
|
|
190
|
+
probes: Annotated[int, typer.Option("--probes", "-q", help="Probes per hop (1-10)")] = 5,
|
|
191
|
+
asn: Annotated[bool, typer.Option("--asn", "-a", help="Show ASN for each hop")] = False,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Run traceroute to a target."""
|
|
194
|
+
import time as _time
|
|
195
|
+
|
|
196
|
+
target_type, normalized = detect_target(target)
|
|
197
|
+
|
|
198
|
+
trace_target = normalized
|
|
199
|
+
if target_type == "domain":
|
|
200
|
+
import dns.resolver
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
answers = dns.resolver.resolve(normalized, "A")
|
|
204
|
+
trace_target = str(answers[0])
|
|
205
|
+
except Exception:
|
|
206
|
+
trace_target = normalized
|
|
207
|
+
|
|
208
|
+
probes = max(1, min(10, probes))
|
|
209
|
+
|
|
210
|
+
async def _run() -> QueryResult:
|
|
211
|
+
result = QueryResult(target=target, target_type=target_type, ip=trace_target)
|
|
212
|
+
try:
|
|
213
|
+
result.trace = await run_traceroute(
|
|
214
|
+
trace_target, timeout=timeout, count=probes, with_asn=asn
|
|
215
|
+
)
|
|
216
|
+
except Exception as e:
|
|
217
|
+
result.errors["trace"] = str(e)
|
|
218
|
+
return result
|
|
219
|
+
|
|
220
|
+
t0 = _time.monotonic()
|
|
221
|
+
result = asyncio.run(_run())
|
|
222
|
+
elapsed = _time.monotonic() - t0
|
|
223
|
+
|
|
224
|
+
if json_output:
|
|
225
|
+
print_json(result)
|
|
226
|
+
else:
|
|
227
|
+
print_trace(result, elapsed=elapsed)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@app.command()
|
|
231
|
+
def myip(
|
|
232
|
+
json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False,
|
|
233
|
+
timeout: Annotated[
|
|
234
|
+
float, typer.Option("--timeout", "-T", help="Lookup timeout in seconds")
|
|
235
|
+
] = 10.0,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Show public and local IP addresses for this system."""
|
|
238
|
+
from ipsak.lookups.myip import (
|
|
239
|
+
discover_local_interfaces,
|
|
240
|
+
discover_public_ip,
|
|
241
|
+
get_hostname,
|
|
242
|
+
MyIPResult,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
async def _run() -> tuple[MyIPResult, QueryResult | None]:
|
|
246
|
+
import httpx
|
|
247
|
+
|
|
248
|
+
myip_result = MyIPResult(
|
|
249
|
+
local_interfaces=discover_local_interfaces(),
|
|
250
|
+
hostname=get_hostname(),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout)) as client:
|
|
254
|
+
try:
|
|
255
|
+
ip, source = await discover_public_ip(client)
|
|
256
|
+
myip_result.public_ip = ip
|
|
257
|
+
myip_result.public_source = source
|
|
258
|
+
except Exception as e:
|
|
259
|
+
myip_result.public_ip = None
|
|
260
|
+
if not json_output:
|
|
261
|
+
console.print(f"[yellow]Warning:[/] {e}")
|
|
262
|
+
return (myip_result, None)
|
|
263
|
+
|
|
264
|
+
# Run full info lookup on the public IP
|
|
265
|
+
public_info = await run_info_lookups(
|
|
266
|
+
target=myip_result.public_ip,
|
|
267
|
+
target_type="ipv4",
|
|
268
|
+
ip=myip_result.public_ip,
|
|
269
|
+
timeout=timeout,
|
|
270
|
+
)
|
|
271
|
+
return (myip_result, public_info)
|
|
272
|
+
|
|
273
|
+
myip_result, public_info = asyncio.run(_run())
|
|
274
|
+
|
|
275
|
+
if json_output:
|
|
276
|
+
import json
|
|
277
|
+
from dataclasses import asdict
|
|
278
|
+
|
|
279
|
+
out = asdict(myip_result)
|
|
280
|
+
if public_info:
|
|
281
|
+
out["public_info"] = public_info.to_dict()
|
|
282
|
+
console.print_json(json.dumps(out, indent=2, default=str))
|
|
283
|
+
else:
|
|
284
|
+
print_myip(myip_result, public_info)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _run_info(
|
|
288
|
+
target: str,
|
|
289
|
+
*,
|
|
290
|
+
json_output: bool = False,
|
|
291
|
+
do_trace: bool = False,
|
|
292
|
+
timeout: float = 10.0,
|
|
293
|
+
) -> None:
|
|
294
|
+
"""Run the info command (shared by callback and info subcommand)."""
|
|
295
|
+
target_type, normalized = detect_target(target)
|
|
296
|
+
|
|
297
|
+
if target_type == "unknown":
|
|
298
|
+
_error_exit(f"Cannot determine type of: {target}")
|
|
299
|
+
|
|
300
|
+
async def _run() -> QueryResult:
|
|
301
|
+
ip: str | None = None
|
|
302
|
+
|
|
303
|
+
if target_type in ("ipv4", "ipv6"):
|
|
304
|
+
ip = normalized
|
|
305
|
+
elif target_type == "domain":
|
|
306
|
+
import dns.asyncresolver
|
|
307
|
+
|
|
308
|
+
resolver = dns.asyncresolver.Resolver()
|
|
309
|
+
resolver.lifetime = timeout
|
|
310
|
+
try:
|
|
311
|
+
answers = await resolver.resolve(normalized, "A")
|
|
312
|
+
ip = str(answers[0])
|
|
313
|
+
except Exception:
|
|
314
|
+
try:
|
|
315
|
+
answers = await resolver.resolve(normalized, "AAAA")
|
|
316
|
+
ip = str(answers[0])
|
|
317
|
+
except Exception:
|
|
318
|
+
pass
|
|
319
|
+
elif target_type.startswith("cidr"):
|
|
320
|
+
import ipaddress
|
|
321
|
+
|
|
322
|
+
net = ipaddress.ip_network(normalized, strict=False)
|
|
323
|
+
ip = str(net.network_address)
|
|
324
|
+
|
|
325
|
+
return await run_info_lookups(
|
|
326
|
+
target=normalized,
|
|
327
|
+
target_type=target_type,
|
|
328
|
+
ip=ip,
|
|
329
|
+
do_trace=do_trace,
|
|
330
|
+
timeout=timeout,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
result = asyncio.run(_run())
|
|
334
|
+
|
|
335
|
+
if json_output:
|
|
336
|
+
print_json(result)
|
|
337
|
+
else:
|
|
338
|
+
print_info(result)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _error_exit(msg: str) -> None:
|
|
342
|
+
console.print(f"[red bold]Error:[/] {msg}")
|
|
343
|
+
raise typer.Exit(1)
|