netool 1.0.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.
netool/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from netool.cli import main
2
+
3
+ __version__ = "1.0.0"
netool/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from netool.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
netool/cli.py ADDED
@@ -0,0 +1,1011 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import argparse
4
+ import concurrent.futures
5
+ try:
6
+ import fcntl
7
+ except ImportError:
8
+ fcntl = None
9
+ import ipaddress
10
+ import json
11
+ import os
12
+ import re
13
+ import shutil
14
+ import socket
15
+ import struct
16
+ import subprocess
17
+ import sys
18
+ import time
19
+ import urllib.error
20
+ import urllib.request
21
+
22
+ VERSION = "1.0.0"
23
+ ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
24
+ COLOR = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
25
+
26
+ TYPE_NAMES = {1: "A", 2: "NS", 5: "CNAME", 6: "SOA", 12: "PTR", 15: "MX", 16: "TXT", 28: "AAAA"}
27
+ QTYPES = {"A": 1, "NS": 2, "CNAME": 5, "SOA": 6, "PTR": 12, "MX": 15, "TXT": 16, "AAAA": 28}
28
+
29
+
30
+ def set_color(on):
31
+ global COLOR
32
+ COLOR = on
33
+
34
+
35
+ def green(s):
36
+ return f"\x1b[32m{s}\x1b[0m" if COLOR else str(s)
37
+
38
+
39
+ def red(s):
40
+ return f"\x1b[31m{s}\x1b[0m" if COLOR else str(s)
41
+
42
+
43
+ def yellow(s):
44
+ return f"\x1b[33m{s}\x1b[0m" if COLOR else str(s)
45
+
46
+
47
+ def cyan(s):
48
+ return f"\x1b[36m{s}\x1b[0m" if COLOR else str(s)
49
+
50
+
51
+ def dim(s):
52
+ return f"\x1b[2m{s}\x1b[0m" if COLOR else str(s)
53
+
54
+
55
+ def bold(s):
56
+ return f"\x1b[1m{s}\x1b[0m" if COLOR else str(s)
57
+
58
+
59
+ def die(msg, code=1):
60
+ print(red(f"error: {msg}"), file=sys.stderr)
61
+ sys.exit(code)
62
+
63
+
64
+ def width(s):
65
+ return len(ANSI_RE.sub("", str(s)))
66
+
67
+
68
+ def show(rows, columns, json_mode, title=None):
69
+ if json_mode:
70
+ print(json.dumps({"name": title, "items": rows} if title else rows, indent=2))
71
+ return
72
+ if title:
73
+ print(bold(title))
74
+ widths = {c: len(c) for c in columns}
75
+ for r in rows:
76
+ for c in columns:
77
+ widths[c] = max(widths[c], width(r.get(c, "")))
78
+ print(" ".join(c.ljust(widths[c]) for c in columns))
79
+ if rows:
80
+ print(dim(" ".join("-" * widths[c] for c in columns)))
81
+ for r in rows:
82
+ print(" ".join(str(r.get(c, "")).ljust(widths[c]) for c in columns))
83
+ if not rows:
84
+ print(dim("(no results)"))
85
+
86
+
87
+ def resolve_host(name):
88
+ try:
89
+ return socket.gethostbyname(name)
90
+ except socket.gaierror:
91
+ die(f"cannot resolve: {name}")
92
+
93
+
94
+ def icmp_checksum(data):
95
+ if len(data) & 1:
96
+ data += b"\x00"
97
+ s = 0
98
+ for i in range(0, len(data), 2):
99
+ s += (data[i] << 8) | data[i + 1]
100
+ s = (s & 0xFFFF) + (s >> 16)
101
+ s += s >> 16
102
+ return (~s) & 0xFFFF
103
+
104
+
105
+ def icmp_socket():
106
+ return socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP)
107
+
108
+
109
+ def icmp_probe(ip, ident, count, timeout, interval, on_result=None):
110
+ sock = icmp_socket()
111
+ sock.settimeout(timeout)
112
+ rtts = []
113
+ ok = 0
114
+ try:
115
+ for seq in range(count):
116
+ payload = struct.pack("!d", time.time()) + b"\x00" * 48
117
+ pkt = struct.pack("!BBHHH", 8, 0, 0, ident, seq) + payload
118
+ pkt = struct.pack("!BBHHH", 8, 0, icmp_checksum(pkt), ident, seq) + payload
119
+ t0 = time.time()
120
+ sock.sendto(pkt, (ip, 0))
121
+ try:
122
+ data, addr = sock.recvfrom(1024)
123
+ rtt = (time.time() - t0) * 1000
124
+ rtype, _rc, _cs, rid, _rseq = struct.unpack_from("!BBHHH", data, 20)
125
+ if rid == ident and rtype == 0:
126
+ if on_result:
127
+ on_result(seq, rtt, addr[0], None)
128
+ rtts.append(rtt)
129
+ ok += 1
130
+ elif rtype == 11:
131
+ if on_result:
132
+ on_result(seq, None, addr[0], "TTL expired")
133
+ except socket.timeout:
134
+ if on_result:
135
+ on_result(seq, None, None, "timeout")
136
+ if seq < count - 1 and interval:
137
+ time.sleep(interval)
138
+ finally:
139
+ sock.close()
140
+ return rtts, ok
141
+
142
+
143
+ def ping_tcp(ip, args):
144
+ port = args.port or 80
145
+ rtts = []
146
+ sent = 0
147
+ ok = 0
148
+ print(bold(f"PING {ip} via TCP port {port}"))
149
+ for i in range(args.count):
150
+ t0 = time.time()
151
+ try:
152
+ with socket.create_connection((ip, port), timeout=args.timeout):
153
+ rtt = (time.time() - t0) * 1000
154
+ ok += 1
155
+ rtts.append(rtt)
156
+ print(f"seq={i} {rtt:6.1f} ms {green('reachable')}")
157
+ except (ConnectionRefusedError, ConnectionResetError):
158
+ rtt = (time.time() - t0) * 1000
159
+ ok += 1
160
+ rtts.append(rtt)
161
+ print(f"seq={i} {rtt:6.1f} ms {cyan('reachable (port refused)')}")
162
+ except OSError as e:
163
+ print(f"seq={i} {red('unreachable')} ({e})")
164
+ sent += 1
165
+ if i < args.count - 1:
166
+ time.sleep(args.interval)
167
+ print_ping_summary(ip, rtts, sent, ok)
168
+
169
+
170
+ def print_ping_summary(ip, rtts, sent, ok):
171
+ loss = (1 - ok / sent) * 100 if sent else 0
172
+ print(f"--- {ip} ping statistics ---")
173
+ if rtts:
174
+ print(f"{sent} transmitted, {ok} received, {loss:.0f}% packet loss, "
175
+ f"min/avg/max = {min(rtts):.1f}/{sum(rtts) / len(rtts):.1f}/{max(rtts):.1f} ms")
176
+ else:
177
+ print(f"{sent} transmitted, {ok} received, {loss:.0f}% packet loss")
178
+
179
+
180
+ def cmd_ping(args):
181
+ ip = resolve_host(args.host)
182
+ if args.tcp:
183
+ ping_tcp(ip, args)
184
+ return
185
+ try:
186
+ icmp_socket()
187
+ except PermissionError:
188
+ print(yellow("raw ICMP requires root (sudo); falling back to TCP ping"))
189
+ ping_tcp(ip, args)
190
+ return
191
+
192
+ ident = os.getpid() & 0xFFFF
193
+ print(bold(f"PING {ip} via ICMP"))
194
+
195
+ def on_result(seq, rtt, addr, note):
196
+ if rtt is not None:
197
+ print(f"seq={seq} {rtt:6.1f} ms from {addr}")
198
+ elif note:
199
+ print(f"seq={seq} {yellow(note)} from {addr}" if addr else f"seq={seq} {yellow(note)}")
200
+
201
+ rtts, ok = icmp_probe(ip, ident, args.count, args.timeout, args.interval, on_result)
202
+ print_ping_summary(ip, rtts, args.count, ok)
203
+
204
+
205
+ def cmd_trace(args):
206
+ ip = resolve_host(args.host)
207
+ try:
208
+ icmp_socket().close()
209
+ except PermissionError:
210
+ for exe in ("traceroute", "tracepath"):
211
+ path = shutil.which(exe)
212
+ if path:
213
+ print(dim(f"using system {exe} (raw ICMP needs root)"))
214
+ cmd = [path, ip]
215
+ if "traceroute" in exe:
216
+ cmd += ["-m", str(args.hops), "-w", str(args.timeout)]
217
+ subprocess.call(cmd)
218
+ return
219
+ die("traceroute requires raw ICMP (run with sudo) or the 'traceroute' binary")
220
+
221
+ recv = icmp_socket()
222
+ recv.settimeout(args.timeout)
223
+ rows = []
224
+ try:
225
+ for ttl in range(1, args.hops + 1):
226
+ port = min(33434 + ttl - 1, 33534)
227
+ snd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
228
+ snd.setsockopt(socket.IPPROTO_IP, socket.IP_TTL, ttl)
229
+ snd.settimeout(args.timeout)
230
+ t0 = time.time()
231
+ snd.sendto(b"", (ip, port))
232
+ reached = False
233
+ try:
234
+ data, addr = recv.recvfrom(1024)
235
+ rtt = (time.time() - t0) * 1000
236
+ rtype = data[20]
237
+ code = data[21]
238
+ hop = addr[0] if addr else None
239
+ if rtype == 11:
240
+ status = "in-transit"
241
+ elif rtype == 3 and code == 3:
242
+ status = "destination"
243
+ reached = True
244
+ else:
245
+ status = f"icmp {rtype}/{code}"
246
+ rows.append({"ttl": ttl, "hop": hop, "rtt": round(rtt, 1), "status": status})
247
+ except socket.timeout:
248
+ rows.append({"ttl": ttl, "hop": "*", "rtt": "", "status": "timeout"})
249
+ snd.close()
250
+ if reached:
251
+ break
252
+ finally:
253
+ recv.close()
254
+ show(rows, ["ttl", "hop", "rtt", "status"], args.json, title=f"Traceroute to {ip}")
255
+
256
+
257
+ def parse_ports(spec):
258
+ ports = set()
259
+ for tok in spec.split(","):
260
+ tok = tok.strip()
261
+ if not tok:
262
+ continue
263
+ if "-" in tok:
264
+ try:
265
+ lo, hi = map(int, tok.split("-", 1))
266
+ except ValueError:
267
+ die(f"invalid port range: {tok}")
268
+ if lo < 1 or hi > 65535 or lo > hi:
269
+ die(f"invalid port range: {tok}")
270
+ ports.update(range(lo, hi + 1))
271
+ else:
272
+ try:
273
+ p = int(tok)
274
+ except ValueError:
275
+ die(f"invalid port: {tok}")
276
+ if not 1 <= p <= 65535:
277
+ die(f"port out of range: {p}")
278
+ ports.add(p)
279
+ if not ports:
280
+ die("no valid ports given")
281
+ return sorted(ports)
282
+
283
+
284
+ def is_ip(s):
285
+ try:
286
+ ipaddress.ip_address(s)
287
+ return True
288
+ except ValueError:
289
+ return False
290
+
291
+
292
+ def expand_ips(lo, hi):
293
+ if hi - lo + 1 > 4096:
294
+ die("target range too large (max 4096 addresses)")
295
+ return [str(ipaddress.ip_address(x)) for x in range(lo, hi + 1)]
296
+
297
+
298
+ def parse_targets(spec):
299
+ if "/" in spec:
300
+ net = ipaddress.ip_network(spec, strict=False)
301
+ if net.prefixlen == 32:
302
+ return [str(net.network_address)]
303
+ return [str(h) for h in net.hosts()]
304
+ if "-" in spec:
305
+ left, right = spec.split("-", 1)
306
+ start = left if is_ip(left) else socket.gethostbyname(left)
307
+ if "." in right:
308
+ end = right
309
+ else:
310
+ end = ".".join(start.split(".")[:-1] + [right])
311
+ if not is_ip(start) or not is_ip(end):
312
+ die(f"invalid target range: {spec}")
313
+ return expand_ips(int(ipaddress.ip_address(start)), int(ipaddress.ip_address(end)))
314
+ if is_ip(spec):
315
+ return [spec]
316
+ return [socket.gethostbyname(spec)]
317
+
318
+
319
+ def cmd_scan(args):
320
+ targets = parse_targets(args.target)
321
+ ports = parse_ports(args.ports)
322
+ rows = []
323
+
324
+ def probe(ip, port):
325
+ t0 = time.time()
326
+ try:
327
+ with socket.create_connection((ip, port), timeout=args.timeout):
328
+ rtt = round((time.time() - t0) * 1000, 1)
329
+ return {"ip": ip, "port": port, "status": "open", "rtt_ms": rtt}
330
+ except socket.timeout:
331
+ return {"ip": ip, "port": port, "status": "filtered", "rtt_ms": ""}
332
+ except OSError:
333
+ return {"ip": ip, "port": port, "status": "closed", "rtt_ms": ""}
334
+
335
+ jobs = [(ip, p) for ip in targets for p in ports]
336
+ print(dim(f"scanning {len(targets)} host(s) x {len(ports)} port(s) with {args.threads} threads"))
337
+ with concurrent.futures.ThreadPoolExecutor(max_workers=args.threads) as ex:
338
+ futs = {ex.submit(probe, ip, p): (ip, p) for ip, p in jobs}
339
+ for f in concurrent.futures.as_completed(futs):
340
+ r = f.result()
341
+ if r["status"] == "open" or args.all:
342
+ rows.append(r)
343
+ rows.sort(key=lambda r: (r["ip"], r["port"]))
344
+ for r in rows:
345
+ r["port"] = str(r["port"])
346
+ if args.service:
347
+ try:
348
+ r["service"] = socket.getservbyport(int(r["port"]))
349
+ except OSError:
350
+ r["service"] = "?"
351
+ if r["status"] == "open":
352
+ r["status"] = green("open")
353
+ elif r["status"] == "filtered":
354
+ r["status"] = yellow("filtered")
355
+ else:
356
+ r["status"] = dim(r["status"])
357
+ cols = ["ip", "port", "service", "status", "rtt_ms"] if args.service else ["ip", "port", "status", "rtt_ms"]
358
+ show(rows, cols, args.json, title=f"Port scan ({args.target})")
359
+
360
+
361
+ def system_resolver():
362
+ try:
363
+ with open("/etc/resolv.conf") as f:
364
+ for line in f:
365
+ parts = line.split()
366
+ if len(parts) >= 2 and parts[0] == "nameserver":
367
+ return parts[1]
368
+ except OSError:
369
+ pass
370
+ return "8.8.8.8"
371
+
372
+
373
+ def encode_name(name):
374
+ out = b""
375
+ for part in name.strip(".").split("."):
376
+ b = part.encode("utf-8")
377
+ out += bytes([len(b)]) + b
378
+ return out + b"\x00"
379
+
380
+
381
+ def parse_name(msg, off):
382
+ labels = []
383
+ while True:
384
+ l = msg[off]
385
+ if l == 0:
386
+ off += 1
387
+ break
388
+ if l & 0xC0 == 0xC0:
389
+ ptr = struct.unpack_from("!H", msg, off)[0] & 0x3FFF
390
+ name, _ = parse_name(msg, ptr)
391
+ labels.append(name)
392
+ off += 2
393
+ break
394
+ off += 1
395
+ labels.append(msg[off:off + l].decode("utf-8", "replace"))
396
+ off += l
397
+ return ".".join(labels), off
398
+
399
+
400
+ def dns_exchange(server, query, timeout):
401
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
402
+ s.settimeout(timeout)
403
+ try:
404
+ s.sendto(query, (server, 53))
405
+ data, _ = s.recvfrom(65535)
406
+ return data
407
+ finally:
408
+ s.close()
409
+
410
+
411
+ def parse_dns(msg):
412
+ _rid, _flags, qd, an, _ns, _ar = struct.unpack_from("!HHHHHH", msg, 0)
413
+ off = 12
414
+ for _ in range(qd):
415
+ _qname, off = parse_name(msg, off)
416
+ off += 4
417
+ rows = []
418
+ for _ in range(an):
419
+ rname, off = parse_name(msg, off)
420
+ rtype, _rclass, ttl, rdlen = struct.unpack_from("!HHIH", msg, off)
421
+ off += 10
422
+ if rtype in (2, 5, 12):
423
+ value, off = parse_name(msg, off)
424
+ elif rtype == 15:
425
+ pref = struct.unpack_from("!H", msg, off)[0]
426
+ exchange, off = parse_name(msg, off + 2)
427
+ value = f"{pref} {exchange}" if exchange else str(pref)
428
+ elif rtype == 16:
429
+ parts = []
430
+ o = off
431
+ while o < off + rdlen:
432
+ ln = msg[o]
433
+ parts.append(msg[o + 1:o + 1 + ln].decode("utf-8", "replace"))
434
+ o += 1 + ln
435
+ value = "".join(parts)
436
+ off += rdlen
437
+ elif rtype == 6:
438
+ mname, off = parse_name(msg, off)
439
+ rnamex, off = parse_name(msg, off)
440
+ serial, refresh, retry, expire, minimum = struct.unpack_from("!IIIII", msg, off)
441
+ off += 20
442
+ value = f"{mname} SOA {rnamex} serial={serial} refresh={refresh} retry={retry} expire={expire} minimum={minimum}"
443
+ elif rtype == 1:
444
+ value = socket.inet_ntoa(msg[off:off + rdlen])
445
+ off += rdlen
446
+ elif rtype == 28:
447
+ value = socket.inet_ntop(socket.AF_INET6, msg[off:off + rdlen])
448
+ off += rdlen
449
+ else:
450
+ value = msg[off:off + rdlen].hex()
451
+ off += rdlen
452
+ rows.append({"name": rname or "root", "type": TYPE_NAMES.get(rtype, str(rtype)), "ttl": ttl, "value": value})
453
+ return rows
454
+
455
+
456
+ def cmd_dns(args):
457
+ qtype = QTYPES.get(args.type.upper())
458
+ if qtype is None:
459
+ die(f"unsupported type: {args.type}")
460
+ server = args.server or system_resolver()
461
+ qid = os.getpid() & 0xFFFF
462
+ header = struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 0)
463
+ query = header + encode_name(args.qname) + struct.pack("!HH", qtype, 1)
464
+ try:
465
+ reply = dns_exchange(server, query, args.timeout)
466
+ except (socket.timeout, OSError) as e:
467
+ die(f"DNS query failed against {server}: {e}")
468
+ rows = parse_dns(reply)
469
+ if not rows:
470
+ print(yellow(f"no {args.type.upper()} records for {args.qname}"))
471
+ return
472
+ show(rows, ["name", "type", "ttl", "value"], args.json, title=f"DNS lookup ({args.qname}) via {server}")
473
+
474
+
475
+ def whois_query(server, query, timeout):
476
+ with socket.create_connection((server, 43), timeout=timeout) as s:
477
+ s.sendall((query + "\r\n").encode())
478
+ chunks = []
479
+ while True:
480
+ d = s.recv(4096)
481
+ if not d:
482
+ break
483
+ chunks.append(d)
484
+ return b"".join(chunks).decode("utf-8", "replace")
485
+
486
+
487
+ def cmd_whois(args):
488
+ query = args.query
489
+ server = "whois.iana.org"
490
+ seen = set()
491
+ try:
492
+ data = whois_query(server, query, args.timeout)
493
+ except OSError as e:
494
+ die(f"whois query failed: {e}")
495
+ for _ in range(5):
496
+ m = re.search(r"(?im)^refer:\s*(\S+)", data)
497
+ if not m:
498
+ break
499
+ nxt = m.group(1)
500
+ if nxt in seen or nxt == server:
501
+ break
502
+ seen.add(nxt)
503
+ server = nxt
504
+ try:
505
+ data = whois_query(server, query, args.timeout)
506
+ except OSError as e:
507
+ print(yellow(f"referral to {server} failed: {e}"))
508
+ break
509
+ if args.json:
510
+ print(json.dumps({"query": query, "server": server, "result": data}, indent=2))
511
+ else:
512
+ print(data)
513
+
514
+
515
+ def iface_ipv4(name):
516
+ try:
517
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
518
+ if fcntl is None:
519
+ # fallback for systems without fcntl (Windows)
520
+ try:
521
+ s.connect((name, 80))
522
+ addr = s.getsockname()[0]
523
+ s.close()
524
+ return addr
525
+ except OSError:
526
+ s.close()
527
+ return ""
528
+ res = fcntl.ioctl(s.fileno(), 0x8915, struct.pack("256s", name[:15].encode()))
529
+ s.close()
530
+ return socket.inet_ntoa(res[20:24])
531
+ except OSError:
532
+ return ""
533
+
534
+
535
+ def read_sys(name, key):
536
+ try:
537
+ with open(f"/sys/class/net/{name}/{key}") as f:
538
+ return f.read().strip()
539
+ except OSError:
540
+ return ""
541
+
542
+
543
+ def default_routes():
544
+ out = []
545
+ try:
546
+ with open("/proc/net/route") as f:
547
+ for line in f:
548
+ p = line.split()
549
+ if len(p) < 11 or p[0] == "Iface":
550
+ continue
551
+ dst = socket.inet_ntoa(struct.pack("<I", int(p[1], 16)))
552
+ gw = socket.inet_ntoa(struct.pack("<I", int(p[2], 16)))
553
+ if gw == "0.0.0.0":
554
+ continue
555
+ out.append({"iface": p[0], "destination": dst, "gateway": gw})
556
+ except OSError:
557
+ pass
558
+ return out
559
+
560
+
561
+ def cmd_netinfo(args):
562
+ try:
563
+ hostname = socket.gethostname()
564
+ except OSError:
565
+ hostname = "?"
566
+ try:
567
+ names = sorted(n for n in os.listdir("/sys/class/net") if n != "lo")
568
+ except FileNotFoundError:
569
+ die("netinfo requires Linux with /sys/class/net")
570
+ interfaces = []
571
+ for name in names:
572
+ mac = read_sys(name, "address")
573
+ mtu = read_sys(name, "mtu")
574
+ state = read_sys(name, "operstate") or (read_sys(name, "carrier") and "up" or "down")
575
+ interfaces.append({"name": name, "ipv4": iface_ipv4(name), "mac": mac, "mtu": mtu, "state": state})
576
+ routes = default_routes()
577
+
578
+ if args.json:
579
+ print(json.dumps({
580
+ "hostname": hostname,
581
+ "primary_resolver": system_resolver(),
582
+ "interfaces": interfaces,
583
+ "routes": routes,
584
+ }, indent=2))
585
+ return
586
+ print(bold(f"Host: {hostname}"))
587
+ print(f"DNS resolver: {system_resolver()}")
588
+ show(interfaces, ["name", "ipv4", "mac", "mtu", "state"], False, title="Interfaces")
589
+ show(routes, ["iface", "destination", "gateway"], False, title="Routes")
590
+
591
+
592
+ def cmd_arp(args):
593
+ rows = []
594
+ try:
595
+ with open("/proc/net/arp") as f:
596
+ for i, line in enumerate(f):
597
+ if i == 0:
598
+ continue
599
+ p = line.split()
600
+ if len(p) < 6:
601
+ continue
602
+ ip, hwtype, flags, hwaddr, _mask, iface = p[:6]
603
+ flags = int(flags, 16)
604
+ if args.state == "reachable" and not (flags & 0x2):
605
+ continue
606
+ if args.state == "incomplete" and (flags & 0x2):
607
+ continue
608
+ if hwaddr == "00:00:00:00:00:00":
609
+ state = "incomplete"
610
+ elif flags & 0x2:
611
+ state = "reachable"
612
+ elif flags & 0x4:
613
+ state = "permanent"
614
+ else:
615
+ state = "present"
616
+ rows.append({"ip": ip, "mac": hwaddr, "iface": iface, "state": state})
617
+ except OSError as e:
618
+ die(f"cannot read ARP table: {e}")
619
+ for r in rows:
620
+ if r["state"] == "reachable":
621
+ r["state"] = green(r["state"])
622
+ elif r["state"] == "incomplete":
623
+ r["state"] = yellow(r["state"])
624
+ show(rows, ["ip", "mac", "iface", "state"], args.json, title="ARP table")
625
+
626
+
627
+ def cmd_subnet(args):
628
+ try:
629
+ if len(args.address) == 1 and "/" in args.address[0]:
630
+ net = ipaddress.ip_network(args.address[0], strict=False)
631
+ elif len(args.address) == 2:
632
+ addr = ipaddress.ip_address(args.address[0])
633
+ mask = ipaddress.ip_network(f"{addr}/{args.address[1]}", strict=False)
634
+ net = mask
635
+ else:
636
+ die("usage: subnet <CIDR> or subnet <address> <netmask>")
637
+ except ValueError as e:
638
+ die(f"invalid subnet: {e}")
639
+ hosts = list(net.hosts())
640
+ rows = [
641
+ {"key": "network", "value": str(net.network_address)},
642
+ {"key": "netmask", "value": str(net.netmask)},
643
+ {"key": "wildcard", "value": str(net.hostmask)},
644
+ {"key": "CIDR", "value": f"/{net.prefixlen}"},
645
+ {"key": "broadcast", "value": str(net.broadcast_address)},
646
+ {"key": "hosts", "value": str(len(hosts))},
647
+ {"key": "first host", "value": str(hosts[0]) if hosts else str(net.network_address)},
648
+ {"key": "last host", "value": str(hosts[-1]) if hosts else str(net.broadcast_address)},
649
+ ]
650
+ show(rows, ["key", "value"], args.json, title=f"Subnet: {net}")
651
+
652
+
653
+ def http_get(url, timeout):
654
+ req = urllib.request.Request(url, headers={"User-Agent": f"netool/{VERSION}"})
655
+ with urllib.request.urlopen(req, timeout=timeout) as r:
656
+ return r.read()
657
+
658
+
659
+ def cmd_publicip(args):
660
+ ip = None
661
+ for u in ("https://api.ipify.org", "https://ifconfig.me/ip", "https://ipinfo.io/ip"):
662
+ try:
663
+ raw = http_get(u, args.timeout)
664
+ candidate = raw.decode("utf-8", "replace").strip()
665
+ if is_ip(candidate):
666
+ ip = candidate
667
+ break
668
+ except OSError:
669
+ continue
670
+ if not ip:
671
+ die("could not determine public IP address (offline or blocked?)")
672
+ rows = [{"key": "public IP", "value": ip}]
673
+ try:
674
+ geo = json.loads(http_get("http://ip-api.com/json/" + ip, args.timeout))
675
+ for k in ("country", "regionName", "city", "isp", "org", "as", "timezone"):
676
+ v = geo.get(k)
677
+ if v:
678
+ rows.append({"key": k, "value": str(v)})
679
+ if geo.get("lat") is not None and geo.get("lon") is not None:
680
+ rows.append({"key": "location", "value": f"{geo['lat']},{geo['lon']}"})
681
+ except (OSError, ValueError):
682
+ pass
683
+ show(rows, ["key", "value"], args.json, title="Public IP")
684
+
685
+
686
+ class RedirectRecorder(urllib.request.HTTPRedirectHandler):
687
+ def __init__(self):
688
+ self.chain = []
689
+
690
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
691
+ self.chain.append((code, newurl))
692
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
693
+
694
+
695
+ def cmd_http(args):
696
+ recorder = RedirectRecorder()
697
+ opener = urllib.request.build_opener(recorder)
698
+ req = urllib.request.Request(args.url, method=args.method, headers={"User-Agent": f"netool/{VERSION}"})
699
+ t0 = time.time()
700
+ rows = []
701
+ try:
702
+ with opener.open(req, timeout=args.timeout) as r:
703
+ rows.append({"key": "url", "value": r.geturl()})
704
+ rows.append({"key": "status", "value": green(str(r.status))})
705
+ rows.append({"key": "reason", "value": str(r.reason)})
706
+ rows.append({"key": "total time", "value": f"{round((time.time() - t0) * 1000)} ms"})
707
+ for h in ("content-length", "content-type", "server", "date", "location"):
708
+ if r.headers.get(h):
709
+ rows.append({"key": h, "value": r.headers.get(h)})
710
+ except urllib.error.HTTPError as e:
711
+ rows.append({"key": "url", "value": args.url})
712
+ rows.append({"key": "status", "value": red(str(e.code))})
713
+ rows.append({"key": "reason", "value": str(e.reason)})
714
+ rows.append({"key": "total time", "value": f"{round((time.time() - t0) * 1000)} ms"})
715
+ for h in ("content-type", "server", "date"):
716
+ if e.headers.get(h):
717
+ rows.append({"key": h, "value": e.headers.get(h)})
718
+ except urllib.error.URLError as e:
719
+ rows = [{"key": "url", "value": args.url}, {"key": "error", "value": red(str(e.reason))}]
720
+ for code, newurl in recorder.chain:
721
+ rows.append({"key": f"redirect {code}", "value": newurl})
722
+ show(rows, ["key", "value"], args.json, title="HTTP check")
723
+
724
+
725
+ def probe_icmp_once(ip, timeout):
726
+ sock = icmp_socket()
727
+ sock.settimeout(timeout)
728
+ try:
729
+ ident = os.getpid() & 0xFFFF
730
+ payload = struct.pack("!d", time.time()) + b"\x00" * 24
731
+ pkt = struct.pack("!BBHHH", 8, 0, 0, ident, 0) + payload
732
+ pkt = struct.pack("!BBHHH", 8, 0, icmp_checksum(pkt), ident, 0) + payload
733
+ t0 = time.time()
734
+ sock.sendto(pkt, (ip, 0))
735
+ data, _addr = sock.recvfrom(1024)
736
+ rtype, _rc, _cs, rid, _rseq = struct.unpack_from("!BBHHH", data, 20)
737
+ if rid == ident and rtype == 0:
738
+ return (time.time() - t0) * 1000
739
+ return None
740
+ except socket.timeout:
741
+ return None
742
+ finally:
743
+ sock.close()
744
+
745
+
746
+ def probe_host(ip, args, use_icmp):
747
+ if use_icmp:
748
+ try:
749
+ return probe_icmp_once(ip, args.timeout)
750
+ except PermissionError:
751
+ return None
752
+ t0 = time.time()
753
+ try:
754
+ with socket.create_connection((ip, args.port or 80), timeout=args.timeout):
755
+ return (time.time() - t0) * 1000
756
+ except OSError:
757
+ return None
758
+
759
+
760
+ def probe_http_once(url, timeout):
761
+ t0 = time.time()
762
+ try:
763
+ with urllib.request.urlopen(url, timeout=timeout) as r:
764
+ return (time.time() - t0) * 1000, r.status
765
+ except urllib.error.HTTPError as e:
766
+ return (time.time() - t0) * 1000, e.code
767
+ except OSError:
768
+ return None, None
769
+
770
+
771
+ def cmd_monitor(args):
772
+ if args.mode == "http":
773
+ url = args.url
774
+ print(bold(f"Monitoring {url} every {args.interval}s (Ctrl-C to stop)"))
775
+ up = down = 0
776
+ rtts = []
777
+ n = 0
778
+ try:
779
+ while True:
780
+ n += 1
781
+ ts = time.strftime("%H:%M:%S")
782
+ rtt, code = probe_http_once(url, args.timeout)
783
+ if rtt is None:
784
+ down += 1
785
+ print(f"{ts} {url} {red('DOWN')}", flush=True)
786
+ else:
787
+ up += 1
788
+ rtts.append(rtt)
789
+ print(f"{ts} {url} {green('UP')} {rtt:.0f} ms (HTTP {code})", flush=True)
790
+ if args.count and n >= args.count:
791
+ break
792
+ time.sleep(args.interval)
793
+ except KeyboardInterrupt:
794
+ pass
795
+ print_monitor_summary(up, down, rtts)
796
+ return
797
+
798
+ ip = resolve_host(args.host)
799
+ use_icmp = args.mode != "tcp"
800
+ if use_icmp:
801
+ try:
802
+ icmp_socket().close()
803
+ except PermissionError:
804
+ print(yellow("raw ICMP needs root; using TCP connect ping"))
805
+ use_icmp = False
806
+ kind = "ICMP" if use_icmp else f"TCP:{args.port or 80}"
807
+ print(bold(f"Monitoring {ip} ({kind}) every {args.interval}s (Ctrl-C to stop)"))
808
+ up = down = 0
809
+ rtts = []
810
+ n = 0
811
+ try:
812
+ while True:
813
+ n += 1
814
+ ts = time.strftime("%H:%M:%S")
815
+ rtt = probe_host(ip, args, use_icmp)
816
+ if rtt is None:
817
+ down += 1
818
+ print(f"{ts} {ip} {red('DOWN')}", flush=True)
819
+ else:
820
+ up += 1
821
+ rtts.append(rtt)
822
+ print(f"{ts} {ip} {green('UP')} {rtt:.1f} ms", flush=True)
823
+ if args.count and n >= args.count:
824
+ break
825
+ time.sleep(args.interval)
826
+ except KeyboardInterrupt:
827
+ pass
828
+ print_monitor_summary(up, down, rtts)
829
+
830
+
831
+ def print_monitor_summary(up, down, rtts):
832
+ total = up + down
833
+ loss = (down / total * 100) if total else 0
834
+ line = f"summary: {up} up, {down} down, loss {loss:.0f}%"
835
+ if rtts:
836
+ line += f", min/avg/max = {min(rtts):.1f}/{sum(rtts) / len(rtts):.1f}/{max(rtts):.1f} ms"
837
+ print(dim(line))
838
+
839
+
840
+ def cmd_mtr(args):
841
+ ip = resolve_host(args.host)
842
+ path = shutil.which("mtr")
843
+ if path:
844
+ print(dim("using system mtr"))
845
+ subprocess.call([path, "--report", "--report-cycles", str(args.count), "-m", str(args.hops), ip])
846
+ return
847
+ try:
848
+ icmp_socket().close()
849
+ except PermissionError:
850
+ die("mtr requires raw ICMP (run with sudo) or the 'mtr' binary")
851
+
852
+ recv = icmp_socket()
853
+ recv.settimeout(args.timeout)
854
+ hops = []
855
+ try:
856
+ for ttl in range(1, args.hops + 1):
857
+ port = min(33434 + ttl - 1, 33534)
858
+ snd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
859
+ snd.setsockopt(socket.IPPROTO_IP, socket.IP_TTL, ttl)
860
+ snd.settimeout(args.timeout)
861
+ snd.sendto(b"", (ip, port))
862
+ reached = False
863
+ try:
864
+ data, addr = recv.recvfrom(1024)
865
+ rtype = data[20]
866
+ code = data[21]
867
+ hops.append((ttl, addr[0] if addr else None))
868
+ if rtype == 3 and code == 3:
869
+ reached = True
870
+ except socket.timeout:
871
+ hops.append((ttl, None))
872
+ snd.close()
873
+ if reached:
874
+ break
875
+ finally:
876
+ recv.close()
877
+
878
+ rows = []
879
+ for ttl, hop in hops:
880
+ if hop is None:
881
+ rows.append({"ttl": ttl, "hop": "*", "loss": "100%", "min": "", "avg": "", "max": ""})
882
+ continue
883
+ ident = (os.getpid() + ttl) & 0xFFFF
884
+ rtts, ok = icmp_probe(hop, ident, args.count, args.timeout, 0)
885
+ loss = (1 - ok / args.count) * 100 if args.count else 0
886
+ row = {"ttl": ttl, "hop": hop, "loss": f"{loss:.0f}%"}
887
+ if rtts:
888
+ row.update({"min": f"{min(rtts):.1f}", "avg": f"{sum(rtts) / len(rtts):.1f}", "max": f"{max(rtts):.1f}"})
889
+ else:
890
+ row.update({"min": "", "avg": "", "max": ""})
891
+ rows.append(row)
892
+ show(rows, ["ttl", "hop", "loss", "min", "avg", "max"], args.json, title=f"mtr {ip}")
893
+
894
+
895
+ def build_parser():
896
+ p = argparse.ArgumentParser(
897
+ prog="netool",
898
+ description="All-around networking toolkit: ping, traceroute, port scan, DNS, whois, and more.",
899
+ epilog="Examples:\n"
900
+ " netool ping example.com\n"
901
+ " netool scan 192.168.1.1-20 --ports 22,80,443 -s\n"
902
+ " netool dns example.com --type MX\n"
903
+ " netool subnet 192.168.1.0/24\n"
904
+ " netool monitor 8.8.8.8 --count 20 --interval 2",
905
+ formatter_class=argparse.RawDescriptionHelpFormatter,
906
+ )
907
+ p.add_argument("--version", action="version", version=f"netool {VERSION}")
908
+ common = argparse.ArgumentParser(add_help=False)
909
+ common.add_argument("--json", action="store_true", help="emit JSON output")
910
+ common.add_argument("--timeout", type=float, default=3.0, metavar="S", help="socket timeout in seconds (default 3)")
911
+ common.add_argument("--no-color", action="store_true", help="disable colored output (also honors NO_COLOR env)")
912
+
913
+ sub = p.add_subparsers(dest="command", required=True, metavar="CMD")
914
+
915
+ sp = sub.add_parser("ping", parents=[common], help="ping a host (ICMP if root, else TCP)",
916
+ description="Ping a host with ICMP echo requests (requires root) or TCP connect as fallback.")
917
+ sp.add_argument("host")
918
+ sp.add_argument("-c", "--count", type=int, default=4, help="number of probes (default 4)")
919
+ sp.add_argument("-i", "--interval", type=float, default=1.0, help="seconds between probes (default 1)")
920
+ sp.add_argument("--port", type=int, help="TCP port for fallback ping (default 80)")
921
+ sp.add_argument("--tcp", action="store_true", help="force TCP connect ping")
922
+ sp.set_defaults(func=cmd_ping)
923
+
924
+ sp = sub.add_parser("trace", parents=[common], help="traceroute to a host (ICMP/UDP)",
925
+ description="Trace the network path to a host using raw ICMP/UDP probes (requires root).")
926
+ sp.add_argument("host")
927
+ sp.add_argument("-m", "--hops", type=int, default=30, help="max hops (default 30)")
928
+ sp.set_defaults(func=cmd_trace)
929
+
930
+ sp = sub.add_parser("scan", parents=[common], help="TCP port scanner",
931
+ description="Scan hosts and ports for open TCP services (concurrent connect scans).")
932
+ sp.add_argument("target", help="IP, hostname, CIDR, or range like 192.168.1.1-20")
933
+ sp.add_argument("--ports", default="22,80,443,3389,8000,8080,8888",
934
+ help="ports or ranges, e.g. 1-1000,22,443 (default common set)")
935
+ sp.add_argument("-T", "--threads", type=int, default=150, help="concurrent threads (default 150)")
936
+ sp.add_argument("-a", "--all", action="store_true", help="also show closed/filtered ports")
937
+ sp.add_argument("-s", "--service", action="store_true", help="guess service name per port")
938
+ sp.set_defaults(func=cmd_scan)
939
+
940
+ sp = sub.add_parser("dns", parents=[common], help="DNS lookup",
941
+ description="Query DNS records (A, AAAA, MX, NS, TXT, CNAME, PTR, SOA) over UDP.")
942
+ sp.add_argument("qname", help="domain or hostname to resolve")
943
+ sp.add_argument("-t", "--type", default="A", help="record type (default A)")
944
+ sp.add_argument("--server", help="DNS server to query (default: system resolver)")
945
+ sp.set_defaults(func=cmd_dns)
946
+
947
+ sp = sub.add_parser("whois", parents=[common], help="WHOIS lookup",
948
+ description="WHOIS a domain or IP address via IANA referral querying.")
949
+ sp.add_argument("query", help="domain or IP to look up")
950
+ sp.set_defaults(func=cmd_whois)
951
+
952
+ sp = sub.add_parser("netinfo", parents=[common], help="local network interface info",
953
+ description="Show local interfaces, IPs, MACs, MTU, routes, and DNS resolver.")
954
+ sp.set_defaults(func=cmd_netinfo)
955
+
956
+ sp = sub.add_parser("arp", parents=[common], help="show ARP/neighbor table",
957
+ description="Display the kernel ARP/neighbor table (local network devices seen so far).")
958
+ sp.add_argument("--state", choices=["reachable", "incomplete"], help="filter by state")
959
+ sp.set_defaults(func=cmd_arp)
960
+
961
+ sp = sub.add_parser("subnet", parents=[common], help="subnet calculator",
962
+ description="Calculate network/broadcast/host ranges for a CIDR or address+mask.")
963
+ sp.add_argument("address", nargs="+", help="CIDR (e.g. 192.168.1.0/24) OR address + netmask")
964
+ sp.set_defaults(func=cmd_subnet)
965
+
966
+ sp = sub.add_parser("publicip", parents=[common], help="show public IP and geolocation",
967
+ description="Determine your public IP and physical/ASN details.")
968
+ sp.set_defaults(func=cmd_publicip)
969
+
970
+ sp = sub.add_parser("http", parents=[common], help="HTTP(S) endpoint check",
971
+ description="Check an HTTP/S endpoint: status, latency, headers, redirect chain.")
972
+ sp.add_argument("url", help="full URL, e.g. https://example.com")
973
+ sp.add_argument("-X", "--method", choices=["GET", "HEAD"], default="GET", help="HTTP method (default GET)")
974
+ sp.set_defaults(func=cmd_http)
975
+
976
+ sp = sub.add_parser("monitor", parents=[common], help="continuous reachability monitor",
977
+ description="Continuously probe a host or URL and report up/down transitions with a summary.")
978
+ sp.add_argument("host", metavar="HOST_OR_URL", help="hostname/IP or URL for --mode http")
979
+ sp.add_argument("-i", "--interval", type=float, default=2.0, help="seconds between probes (default 2)")
980
+ sp.add_argument("-c", "--count", type=int, help="number of probes (default: run until Ctrl-C)")
981
+ sp.add_argument("-m", "--mode", choices=["ping", "tcp", "http"], default="ping",
982
+ help="probe method (default ping)")
983
+ sp.add_argument("--port", type=int, help="TCP port for tcp mode (default 80)")
984
+ sp.add_argument("--url", help="URL to probe when using --mode http")
985
+ sp.set_defaults(func=cmd_monitor)
986
+
987
+ sp = sub.add_parser("mtr", parents=[common], help="combined trace + per-hop stats",
988
+ description="Traceroute with per-hop ping statistics (loss + min/avg/max latency). "
989
+ "Requires root or the mtr binary.")
990
+ sp.add_argument("host")
991
+ sp.add_argument("-m", "--hops", type=int, default=16, help="max hops (default 16)")
992
+ sp.add_argument("-c", "--count", type=int, default=3, help="probes per hop (default 3)")
993
+ sp.set_defaults(func=cmd_mtr)
994
+
995
+ return p
996
+
997
+
998
+ def main(argv=None):
999
+ args = build_parser().parse_args(argv)
1000
+ if getattr(args, "no_color", False):
1001
+ set_color(False)
1002
+ socket.setdefaulttimeout(args.timeout)
1003
+ args.func(args)
1004
+
1005
+
1006
+ if __name__ == "__main__":
1007
+ try:
1008
+ main()
1009
+ except KeyboardInterrupt:
1010
+ print(dim("\ninterrupted"), file=sys.stderr)
1011
+ sys.exit(130)
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.1
2
+ Name: netool
3
+ Version: 1.0.0
4
+ Summary: All-around networking toolkit: ping, traceroute, port scan, DNS, whois, and more
5
+ Home-page: https://github.com/anomalyco/netool
6
+ License: MIT
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+
10
+ # netool
11
+
12
+ All-around networking toolkit: ping, traceroute, port scan, DNS, whois,
13
+ subnet calculator, HTTP check, monitoring, and MTR.
14
+
15
+ ```bash
16
+ pip install netool
17
+ sudo netool ping example.com
18
+ ```
@@ -0,0 +1,9 @@
1
+ netool-1.0.0.dist-info/METADATA,sha256=GFexZJnbgkDS5p8JVUi20TqmPlsPTeTZfP9fuffn7YE,459
2
+ netool-1.0.0.dist-info/WHEEL,sha256=f2-saOBXThY-fMTWOm_Iz3HbdXDqKhZBDfXo8NY8k6Q,86
3
+ netool-1.0.0.dist-info/entry_points.txt,sha256=8ofKUMyMRkThBOMaakcMC5ikUcL3N7dskFQZzxDk3AA,43
4
+ netool-1.0.0.dist-info/top_level.txt,sha256=ePy4A6vnE4Z6Jj-Z-LYaaJF_cp6n_PJk_GiIgxYOnuU,7
5
+ netool-1.0.0.dist-info/licenses/LICENSE,sha256=Vb5Hvm6QLOQ607bb-jg4-lq5FUNWFXP46zoCeG2rE2s,1061
6
+ netool/__init__.py,sha256=NKWmdvVx3OPW9RHC0rCjcpArEUilDS5V6xs5NgAs3LQ,50
7
+ netool/cli.py,sha256=2bnCMOhJLFIdQZsCm9bp7i9thbwQk1kg4ir0l-5v5Z4,36697
8
+ netool/__main__.py,sha256=PLZ7nH2oNsnx9OsKoQmpzImbxfROX4SNCxzNoRID0I0,66
9
+ netool-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: handclean 1.0.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ netool = netool.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gavin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ netool