From 421fa98292f4d84b7d2489f6c9b6ced6692c7088 Mon Sep 17 00:00:00 2001 From: MinerOps Admin Date: Wed, 22 Jul 2026 21:12:50 +0000 Subject: [PATCH] Add standalone Linux miner scanner --- OPERATIONS.md | 22 +++ scripts/linux-miner-scanner.py | 331 +++++++++++++++++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100755 scripts/linux-miner-scanner.py diff --git a/OPERATIONS.md b/OPERATIONS.md index 57c7b54..458bea7 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -113,3 +113,25 @@ Then rebuild backend if backend files were involved. - Check Git status before and after every patch. - Commit and push after every stable working change. - 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. diff --git a/scripts/linux-miner-scanner.py b/scripts/linux-miner-scanner.py new file mode 100755 index 0000000..90b2128 --- /dev/null +++ b/scripts/linux-miner-scanner.py @@ -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(" 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()