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 ADDED
@@ -0,0 +1,3 @@
1
+ """ipsak - Fast IP, CIDR, and domain information query tool."""
2
+
3
+ __version__ = "0.1.0"
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)