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 +3 -0
- netool/__main__.py +4 -0
- netool/cli.py +1011 -0
- netool-1.0.0.dist-info/METADATA +18 -0
- netool-1.0.0.dist-info/RECORD +9 -0
- netool-1.0.0.dist-info/WHEEL +4 -0
- netool-1.0.0.dist-info/entry_points.txt +2 -0
- netool-1.0.0.dist-info/licenses/LICENSE +21 -0
- netool-1.0.0.dist-info/top_level.txt +1 -0
netool/__init__.py
ADDED
netool/__main__.py
ADDED
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,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
|