Add standalone Linux miner scanner
This commit is contained in:
1 parent
d1a703da7c
commit
421fa98292
2 files changed
+353
No files matched your search
@@ -113,3 +113,25 @@ Then rebuild backend if backend files were involved.
|
|||||||
- Check Git status before and after every patch.
|
- Check Git status before and after every patch.
|
||||||
- Commit and push after every stable working change.
|
- Commit and push after every stable working change.
|
||||||
- Use /system-health after major changes.
|
- Use /system-health after major changes.
|
||||||
|
|
||||||
|
## Linux read-only miner scanner
|
||||||
|
|
||||||
|
A standalone Linux scanner is included at:
|
||||||
|
|
||||||
|
scripts/linux-miner-scanner.py
|
||||||
|
|
||||||
|
Run it from a Linux machine that can reach the miner networks. CT 306 may not have routes to the miner VLANs, so run this on the correct network.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
python3 scripts/linux-miner-scanner.py --cidr 10.30.0.0/22 --cidr 10.80.0.0/22 --csv miner-scan.csv --json miner-scan.json
|
||||||
|
|
||||||
|
Single miner test:
|
||||||
|
|
||||||
|
python3 scripts/linux-miner-scanner.py --ip 10.30.0.138
|
||||||
|
|
||||||
|
Port check only:
|
||||||
|
|
||||||
|
python3 scripts/linux-miner-scanner.py --cidr 10.30.0.0/22 --no-query
|
||||||
|
|
||||||
|
This scanner is read-only. It checks legacy port 4028 and API3 port 4433.
|
||||||
Executable
+331
@@ -0,0 +1,331 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import concurrent.futures
|
||||||
|
import csv
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def recv_until_timeout(sock, max_bytes=1048576):
|
||||||
|
chunks = []
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
while total < max_bytes:
|
||||||
|
try:
|
||||||
|
chunk = sock.recv(65536)
|
||||||
|
except socket.timeout:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
|
||||||
|
chunks.append(chunk)
|
||||||
|
total += len(chunk)
|
||||||
|
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def recv_exact(sock, size):
|
||||||
|
data = b""
|
||||||
|
|
||||||
|
while len(data) < size:
|
||||||
|
chunk = sock.recv(size - len(data))
|
||||||
|
|
||||||
|
if not chunk:
|
||||||
|
raise ConnectionError("connection closed")
|
||||||
|
|
||||||
|
data += chunk
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def tcp_open(ip, port, timeout):
|
||||||
|
try:
|
||||||
|
with socket.create_connection((str(ip), port), timeout=timeout):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_command(ip, command, timeout):
|
||||||
|
payload = json.dumps({"command": command}, separators=(",", ":")).encode("ascii")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with socket.create_connection((str(ip), 4028), timeout=timeout) as sock:
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
sock.sendall(payload)
|
||||||
|
|
||||||
|
try:
|
||||||
|
sock.shutdown(socket.SHUT_WR)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raw = recv_until_timeout(sock)
|
||||||
|
except Exception as exc:
|
||||||
|
return None, str(exc)
|
||||||
|
|
||||||
|
text = raw.replace(b"\x00", b"").decode("utf-8", errors="replace").strip()
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
return None, "empty response"
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(text), None
|
||||||
|
except Exception:
|
||||||
|
return {"raw": text[:2000]}, None
|
||||||
|
|
||||||
|
|
||||||
|
def api3_get(ip, cmd, timeout):
|
||||||
|
request = json.dumps({"cmd": cmd}, separators=(",", ":")).encode("ascii")
|
||||||
|
frame = struct.pack("<I", len(request)) + request
|
||||||
|
|
||||||
|
try:
|
||||||
|
with socket.create_connection((str(ip), 4433), timeout=timeout) as sock:
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
sock.sendall(frame)
|
||||||
|
|
||||||
|
header = recv_exact(sock, 4)
|
||||||
|
length = struct.unpack("<I", header)[0]
|
||||||
|
|
||||||
|
if length < 1 or length > 10485760:
|
||||||
|
return None, "bad api3 frame length"
|
||||||
|
|
||||||
|
raw = recv_exact(sock, length)
|
||||||
|
except Exception as exc:
|
||||||
|
return None, str(exc)
|
||||||
|
|
||||||
|
text = raw.decode("utf-8", errors="replace").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(text), None
|
||||||
|
except Exception:
|
||||||
|
return {"raw": text[:2000]}, None
|
||||||
|
|
||||||
|
|
||||||
|
def walk_values(obj):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for key, value in obj.items():
|
||||||
|
yield key, value
|
||||||
|
|
||||||
|
yield from walk_values(value)
|
||||||
|
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for item in obj:
|
||||||
|
yield from walk_values(item)
|
||||||
|
|
||||||
|
|
||||||
|
def first_value(obj, names):
|
||||||
|
wanted = {name.lower() for name in names}
|
||||||
|
|
||||||
|
for key, value in walk_values(obj):
|
||||||
|
if str(key).lower() in wanted and value not in (None, ""):
|
||||||
|
return value
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def clean(value):
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
text = str(value).replace("\n", " ").replace("\r", " ").strip()
|
||||||
|
|
||||||
|
if len(text) > 60:
|
||||||
|
return text[:57] + "..."
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def scan_one(ip, timeout, query):
|
||||||
|
result = {
|
||||||
|
"ip": str(ip),
|
||||||
|
"legacy_4028": False,
|
||||||
|
"api3_4433": False,
|
||||||
|
"mac": "",
|
||||||
|
"model": "",
|
||||||
|
"miner_sn": "",
|
||||||
|
"power_sn": "",
|
||||||
|
"hashrate": "",
|
||||||
|
"legacy_error": "",
|
||||||
|
"api3_error": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
result["legacy_4028"] = tcp_open(ip, 4028, timeout)
|
||||||
|
result["api3_4433"] = tcp_open(ip, 4433, timeout)
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return result
|
||||||
|
|
||||||
|
legacy_data = None
|
||||||
|
api3_data = None
|
||||||
|
|
||||||
|
if result["legacy_4028"]:
|
||||||
|
legacy_data, result["legacy_error"] = legacy_command(ip, "summary", timeout)
|
||||||
|
|
||||||
|
if result["api3_4433"]:
|
||||||
|
api3_data, result["api3_error"] = api3_get(ip, "get.device.info", timeout)
|
||||||
|
|
||||||
|
combined = {
|
||||||
|
"legacy": legacy_data or {},
|
||||||
|
"api3": api3_data or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
result["mac"] = clean(first_value(combined, [
|
||||||
|
"mac", "mac_address", "net_mac", "ethaddr", "eth_mac"
|
||||||
|
]))
|
||||||
|
|
||||||
|
result["model"] = clean(first_value(combined, [
|
||||||
|
"miner_type", "miner type", "model", "type", "product_model"
|
||||||
|
]))
|
||||||
|
|
||||||
|
result["miner_sn"] = clean(first_value(combined, [
|
||||||
|
"miner_sn", "miner serial", "miner_sn_code", "serial", "sn"
|
||||||
|
]))
|
||||||
|
|
||||||
|
result["power_sn"] = clean(first_value(combined, [
|
||||||
|
"power_sn", "psu_sn", "power serial", "psu_serial"
|
||||||
|
]))
|
||||||
|
|
||||||
|
result["hashrate"] = clean(first_value(combined, [
|
||||||
|
"hashrate", "hash_rate", "mhs 5s", "mhs_5s", "ths 5s", "ths_5s", "total_hashrate"
|
||||||
|
]))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_targets(cidrs, ips, limit):
|
||||||
|
targets = []
|
||||||
|
|
||||||
|
for cidr in cidrs:
|
||||||
|
network = ipaddress.ip_network(cidr, strict=False)
|
||||||
|
|
||||||
|
for ip in network.hosts():
|
||||||
|
targets.append(str(ip))
|
||||||
|
|
||||||
|
for ip in ips:
|
||||||
|
targets.append(str(ipaddress.ip_address(ip)))
|
||||||
|
|
||||||
|
deduped = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for ip in targets:
|
||||||
|
if ip not in seen:
|
||||||
|
seen.add(ip)
|
||||||
|
deduped.append(ip)
|
||||||
|
|
||||||
|
if limit and limit > 0:
|
||||||
|
deduped = deduped[:limit]
|
||||||
|
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path, rows):
|
||||||
|
with open(path, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(rows, handle, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(path, rows):
|
||||||
|
fieldnames = [
|
||||||
|
"ip",
|
||||||
|
"legacy_4028",
|
||||||
|
"api3_4433",
|
||||||
|
"mac",
|
||||||
|
"model",
|
||||||
|
"miner_sn",
|
||||||
|
"power_sn",
|
||||||
|
"hashrate",
|
||||||
|
"legacy_error",
|
||||||
|
"api3_error",
|
||||||
|
]
|
||||||
|
|
||||||
|
with open(path, "w", encoding="utf-8", newline="") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def print_table(rows, show_all):
|
||||||
|
visible = rows if show_all else [
|
||||||
|
row for row in rows if row["legacy_4028"] or row["api3_4433"]
|
||||||
|
]
|
||||||
|
|
||||||
|
print("Scanned hosts:", len(rows))
|
||||||
|
print("Hosts with miner ports open:", len(visible))
|
||||||
|
print()
|
||||||
|
|
||||||
|
if not visible:
|
||||||
|
return
|
||||||
|
|
||||||
|
columns = ["ip", "legacy_4028", "api3_4433", "mac", "model", "miner_sn", "power_sn", "hashrate"]
|
||||||
|
widths = {
|
||||||
|
"ip": 15,
|
||||||
|
"legacy_4028": 11,
|
||||||
|
"api3_4433": 10,
|
||||||
|
"mac": 17,
|
||||||
|
"model": 16,
|
||||||
|
"miner_sn": 20,
|
||||||
|
"power_sn": 20,
|
||||||
|
"hashrate": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
header = " ".join(col.ljust(widths[col]) for col in columns)
|
||||||
|
print(header)
|
||||||
|
print("-" * len(header))
|
||||||
|
|
||||||
|
for row in visible:
|
||||||
|
print(" ".join(clean(row.get(col, "")).ljust(widths[col])[:widths[col]] for col in columns))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Read-only Linux scanner for WhatsMiner ASICs.")
|
||||||
|
parser.add_argument("--cidr", action="append", default=[], help="CIDR range to scan. Can be used multiple times.")
|
||||||
|
parser.add_argument("--ip", action="append", default=[], help="Single IP to scan. Can be used multiple times.")
|
||||||
|
parser.add_argument("--timeout", type=float, default=0.8, help="TCP timeout in seconds. Default: 0.8")
|
||||||
|
parser.add_argument("--workers", type=int, default=256, help="Concurrent workers. Default: 256")
|
||||||
|
parser.add_argument("--limit", type=int, default=0, help="Limit number of targets for testing.")
|
||||||
|
parser.add_argument("--no-query", action="store_true", help="Only check open ports. Do not request miner info.")
|
||||||
|
parser.add_argument("--show-all", action="store_true", help="Show hosts even when no miner ports are open.")
|
||||||
|
parser.add_argument("--json", default="", help="Write JSON output to this file.")
|
||||||
|
parser.add_argument("--csv", default="", help="Write CSV output to this file.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.cidr and not args.ip:
|
||||||
|
parser.error("Use --cidr and/or --ip")
|
||||||
|
|
||||||
|
targets = build_targets(args.cidr, args.ip, args.limit)
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||||||
|
future_map = {
|
||||||
|
executor.submit(scan_one, ip, args.timeout, not args.no_query): ip
|
||||||
|
for ip in targets
|
||||||
|
}
|
||||||
|
|
||||||
|
for future in concurrent.futures.as_completed(future_map):
|
||||||
|
rows.append(future.result())
|
||||||
|
|
||||||
|
rows.sort(key=lambda row: tuple(int(part) for part in row["ip"].split(".")))
|
||||||
|
|
||||||
|
print_table(rows, args.show_all)
|
||||||
|
|
||||||
|
elapsed = time.time() - start
|
||||||
|
print()
|
||||||
|
print("Elapsed seconds:", round(elapsed, 2))
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
write_json(args.json, rows)
|
||||||
|
print("JSON written:", args.json)
|
||||||
|
|
||||||
|
if args.csv:
|
||||||
|
write_csv(args.csv, rows)
|
||||||
|
print("CSV written:", args.csv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in new issue
Block a user