From 43518d51cccf85d24457dbbbd8ddb4ee6ee8b82b Mon Sep 17 00:00:00 2001 From: DanailDR Date: Tue, 29 Sep 2026 11:25:33 +0300 Subject: [PATCH] Add network diagnostic reports --- AGENTS.md | 13 +- README.md | 6 +- System/network-diagnostic.md | 74 ++++ System/network-diagnostic.sh | 666 +++++++++++++++++++++++++++++++++++ 4 files changed, 756 insertions(+), 3 deletions(-) create mode 100644 System/network-diagnostic.md create mode 100755 System/network-diagnostic.sh diff --git a/AGENTS.md b/AGENTS.md index 91a458f..fa262a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,11 +37,20 @@ relative link към `.md` файла на скрипта. Пълното описание живее само в скрипт-специфичния `.md`, README не го дублира. -5. **Скриптът минава `shellcheck` без warnings** (виж `.shellcheckrc` в +5. **В началото се проверяват всички задължителни команди и програми.** + След `common::show_help_if_requested "$@"` и преди основната работа + извикай `common::require_cmd` за всяка външна команда, без която скриптът + не може да изпълни предназначението си. При липсваща команда helper-ът + извежда разбираемо съобщение и прекратява изпълнението. Незадължителните + команди се проверяват условно и трябва да имат fallback или описано + ограничено поведение; липсата им не бива да спира скрипта без причина. + Изброй задължителните и незадължителните зависимости в съответния `.md`. + +6. **Скриптът минава `shellcheck` без warnings** (виж `.shellcheckrc` в корена). Ако warning-ът е false positive, добави `# shellcheck disable=SCxxxx` с коментар защо точно на реда, не глобално в файла. -6. **`set -euo pipefail`** в началото на всеки скрипт, веднага след shebang +7. **`set -euo pipefail`** в началото на всеки скрипт, веднага след shebang реда и коментарния блок. Без изключения — ако скрипт умишлено трябва да продължи след грешка в конкретна команда, обгради само тази команда с `|| true` или явна проверка, не маха global `set -e`. diff --git a/README.md b/README.md index b783274..4ada230 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ strixscripts/ │ ├── repair-hdd.sh презапис на диск и SMART extended self-test │ └── erase-storage.sh презапис на дял или Secure Erase/Sanitize на диск ├── System/ -│ └── system-report.sh моментен системен отчет с TXT/JSON/HTML export +│ ├── network-diagnostic.sh подробна мрежова диагностика, топ 10 Wi-Fi мрежи и export +│ └── system-report.sh моментен системен отчет с TXT/JSON/HTML export └── <категория>/ функционална група скриптове (предстои) ``` @@ -53,6 +54,9 @@ strixscripts/ ### System/ +- [`network-diagnostic.sh`](./System/network-diagnostic.md) — показва + физическите интерфейси, топ десетте Wi-Fi мрежи и виртуалните интерфейси + в този ред; поддържа TXT, JSON и HTML отчет. - [`system-report.sh`](./System/system-report.md) — събира моментно състояние на системата, като групира Ethernet, Wi-Fi и виртуалните интерфейси, и предлага отчет във формат TXT, JSON или HTML. diff --git a/System/network-diagnostic.md b/System/network-diagnostic.md new file mode 100644 index 0000000..0d55306 --- /dev/null +++ b/System/network-diagnostic.md @@ -0,0 +1,74 @@ +# network-diagnostic.sh + +Показва подробна информация за мрежовите интерфейси и хардуерните карти, +след което сканира околните Wi-Fi мрежи и извежда до десетте най-силни. + +## Какво прави + +За всеки интерфейс показва типа и състоянието, MAC и IP адресите, MTU, +активната NetworkManager връзка, скоростта и наличните link modes. За +физическите PCI/USB устройства показва модел, bus и драйвер; `lspci` добавя +PCI описанието и kernel module информацията. Ако е наличен `ethtool`, се +показват Ethernet скоростта и състоянието на link-а; използва се и скоростта, +публикувана от драйвера в sysfs. Виртуалните интерфейси не се представят като +физически Ethernet връзки и не получават подвеждаща скорост от veth/bridge. + +Изходът е подреден като физически Ethernet, физически Wi-Fi, веднага след +тях топ 10 Wi-Fi scan, и накрая виртуалните интерфейси. + +Wi-Fi scan използва NetworkManager и заявява нов scan. Резултатите се +сортират по сила на сигнала и се показват най-много десет различни SSID; +ако едно SSID се вижда през няколко access point-а, показва се най-силният. +В списъка има BSSID, канал, честота, сигнал и тип защита. Свързаната точка +за достъп се отбелязва като `CONNECTED` и се оцветява в зелено в терминала; +в HTML целият ред е оцветен. При няколко access point-а със същото SSID +свързаният BSSID се запазва пред по-силен несвързан. При липса на +NetworkManager се използва `iw` като fallback, ако е инсталиран и има права +за scan. + +Скриптът е само за преглед и не променя мрежови настройки. След показването +на TXT отчета интерактивно предлага запис в `~/report.txt`. Може да се зададе +друг път и формат; поддържат се TXT, JSON и HTML. + +## Изисквания + +- Linux, Bash и Python 3 +- `ip` е задължителен за инвентара на интерфейсите +- `nmcli` от NetworkManager се препоръчва за Wi-Fi scan +- `lspci` и `ethtool` са незадължителни и добавят хардуерни/link детайли +- `iw` е незадължителен fallback за Wi-Fi scan; някои драйвери изискват root + права за сканиране с `iw` +- За `nmcli` обикновено не са нужни root права + +## Употреба + +```bash +./System/network-diagnostic.sh +./System/network-diagnostic.sh --help +./System/network-diagnostic.sh --output "$HOME/network-report.json" --format json +./System/network-diagnostic.sh --output /tmp/network-report.html --force +./System/network-diagnostic.sh --format json +``` + +| Опция | Описание | +|-------|----------| +| `-o`, `--output PATH` | Записва директно във файл; без тази опция предлага интерактивен запис | +| `-f`, `--format FORMAT` | Формат `txt`, `json` или `html`; без `--output` извежда формата към stdout в non-interactive режим | +| `--force` | Позволява презапис на съществуващ файл без потвърждение | +| `-h`, `--help` | Показва помощта и приключва | + +Без зададен `--format` форматът се разпознава от разширението на `--output`; +ако няма поддържано разширение, се използва TXT. При интерактивен запис +началният път е `~/report.`, като форматът по подразбиране е TXT. +В non-interactive режим без `--output` отчетът се извежда към stdout; зададеният +`--format` определя сериализацията. + +## Ограничения + +Скриптът показва до десет уникални SSID, а не десет отделни BSSID; при +еднакво име се избира access point-ът с най-силен сигнал. Скритите мрежи +нямат SSID и се третират отделно по BSSID. Детайлите за PCI устройства +зависят от `lspci`, за USB устройствата от sysfs, а Ethernet link modes +зависят от `ethtool` и драйвера. Wi-Fi scan може да не се изпълни, ако +NetworkManager не управлява устройството или правата на текущия потребител +не позволяват сканиране. \ No newline at end of file diff --git a/System/network-diagnostic.sh b/System/network-diagnostic.sh new file mode 100755 index 0000000..4e83d95 --- /dev/null +++ b/System/network-diagnostic.sh @@ -0,0 +1,666 @@ +#!/usr/bin/env bash +# +# network-diagnostic.sh — inspect network interfaces and nearby Wi-Fi networks. +# Full documentation: see network-diagnostic.md in this same folder. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "${SCRIPT_DIR}/../lib/common.sh" + +SCRIPT_HELP=$(cat <<'HELP' +Usage: ./System/network-diagnostic.sh [OPTIONS] + +Lists physical Ethernet interfaces, physical Wi-Fi interfaces, the ten strongest +nearby Wi-Fi networks, and then virtual interfaces. Reports can be displayed or +saved as TXT, JSON, or HTML. NetworkManager is used for scanning when available. +Without --output, offers to save an interactive report to ~/report.txt. + +Options: + -o, --output PATH Save directly to PATH; otherwise ask interactively + -f, --format FORMAT Report format: txt, json, or html + --force Overwrite an existing output file without prompting + -h, --help Show this help message and exit + +Examples: + ./System/network-diagnostic.sh + sudo ./System/network-diagnostic.sh + ./System/network-diagnostic.sh --output "$HOME/network-report.json" --format json + ./System/network-diagnostic.sh --output /tmp/network-report.html --force +HELP +) + +common::show_help_if_requested "$@" + +OUTPUT_PATH="" +OUTPUT_FORMAT="" +FORCE=0 +while [[ $# -gt 0 ]]; do + case "$1" in + -o|--output) + [[ $# -ge 2 && -n "$2" ]] || common::die "$1 requires a path" + OUTPUT_PATH="$2" + shift 2 + ;; + -f|--format) + [[ $# -ge 2 ]] || common::die "$1 requires txt, json, or html" + OUTPUT_FORMAT="${2,,}" + case "$OUTPUT_FORMAT" in + txt|json|html) ;; + *) common::die "unsupported report format: $2 (use txt, json, or html)" ;; + esac + shift 2 + ;; + --force) + FORCE=1 + shift + ;; + *) + common::die "unknown argument: $1" + ;; + esac +done + +common::require_cmd python3 +common::require_cmd ip + +python3 -c "$(cat <<'PYCODE' +import datetime +import html +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys +import tempfile + + +def run(command, timeout=10): + if shutil.which(command[0]) is None: + return None + try: + result = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + errors="replace", + timeout=timeout, + ) + except (OSError, subprocess.TimeoutExpired): + return None + return result.stdout.strip() if result.returncode == 0 else None + + +def read_text(path): + try: + return pathlib.Path(path).read_text(encoding="utf-8").strip() + except (OSError, UnicodeError): + return None + + +def split_nmcli_fields(line): + fields = [] + value = [] + escaped = False + for character in line: + if escaped: + value.append(character) + escaped = False + elif character == "\\": + escaped = True + elif character == ":": + fields.append("".join(value)) + value = [] + else: + value.append(character) + fields.append("".join(value)) + return fields + + +def parse_nmcli_properties(output): + properties = {} + if not output: + return properties + for line in output.splitlines(): + fields = split_nmcli_fields(line) + if len(fields) >= 2: + properties[fields[0]] = ":".join(fields[1:]) + return properties + + +def parse_link_modes(output): + if not output: + return [] + modes = [] + in_modes = False + for line in output.splitlines(): + if line.startswith("Supported link modes:"): + in_modes = True + first = line.split(":", 1)[1].strip() + if first: + modes.extend(first.split()) + elif in_modes and line[:1].isspace(): + modes.extend(line.split()) + elif in_modes: + break + return modes + + +def pci_details(device_path): + if not device_path: + return {} + bdf = pathlib.Path(device_path).name + if not re.fullmatch(r"[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-7]", bdf): + return {} + output = run(["lspci", "-D", "-nnk", "-s", bdf]) + if not output: + return {"bus_id": bdf} + lines = output.splitlines() + return { + "bus_id": bdf, + "description": lines[0].split(": ", 1)[1] if ": " in lines[0] else lines[0], + "driver": next((line.split(":", 1)[1].strip() for line in lines if "Kernel driver in use:" in line), None), + "modules": next((line.split(":", 1)[1].strip() for line in lines if "Kernel modules:" in line), None), + } + + +def hardware_details(interface): + base = pathlib.Path("/sys/class/net") / interface + device = base / "device" + if not device.exists(): + return {"kind": "virtual", "description": None, "driver": None, "bus": None, "vendor": None, "product": None} + + real_device = pathlib.Path(device.resolve()) + driver = (real_device / "driver").resolve().name if (real_device / "driver").exists() else None + usb_device = next( + (path for path in (real_device, *real_device.parents) if (path / "idVendor").exists()), + None, + ) + usb_vendor = read_text(usb_device / "idVendor") if usb_device else None + usb_product = read_text(usb_device / "idProduct") if usb_device else None + manufacturer = read_text(usb_device / "manufacturer") if usb_device else None + product_name = read_text(usb_device / "product") if usb_device else None + pci = pci_details(real_device) + + if pci: + description = pci.get("description") + return { + "kind": "physical PCI device", + "description": description, + "driver": pci.get("driver") or driver, + "bus": f"PCI {pci.get('bus_id')}", + "vendor": None, + "product": None, + "modules": pci.get("modules"), + } + + description_parts = [part for part in (manufacturer, product_name) if part] + return { + "kind": "physical USB/device" if description_parts or usb_vendor else "physical device", + "description": " ".join(description_parts) or None, + "driver": driver, + "bus": "USB" if usb_vendor else real_device.parent.name, + "vendor": usb_vendor, + "product": usb_product, + "modules": None, + } + + +def interface_details(link): + name = link.get("ifname", "unknown") + base = pathlib.Path("/sys/class/net") / name + addresses = [] + for address in link.get("addr_info", []): + local = address.get("local") + if local: + addresses.append(f"{local}/{address.get('prefixlen', '')}".rstrip("/")) + + kind = ((link.get("linkinfo") or {}).get("info_kind") or "").lower() + wireless = (base / "wireless").exists() or name.startswith(("wl", "wlan")) + hardware = hardware_details(name) + physical_ethernet = not wireless and hardware["kind"].startswith("physical") + nmcli = parse_nmcli_properties(run([ + "nmcli", "--terse", "--escape", "yes", "--fields", + "GENERAL.TYPE,GENERAL.STATE,GENERAL.CONNECTION,GENERAL.HWADDR,GENERAL.MTU,GENERAL.DRIVER,GENERAL.VENDOR,GENERAL.PRODUCT", + "device", "show", name, + ])) + + speed = None + if physical_ethernet: + raw_speed = read_text(base / "speed") + try: + value = int(raw_speed) if raw_speed is not None else 0 + speed = value if value > 0 else None + except ValueError: + pass + + ethtool = run(["ethtool", name]) if physical_ethernet else None + if ethtool: + match = re.search(r"^\s*Speed:\s*(\d+)\s*Mb/s", ethtool, re.MULTILINE) + if match: + speed = int(match.group(1)) + ethtool_driver = parse_nmcli_properties(run(["ethtool", "-i", name])) if physical_ethernet and shutil.which("ethtool") else {} + if ethtool_driver.get("driver"): + hardware["driver"] = ethtool_driver["driver"] + + flags = set(link.get("flags") or []) + operstate = (link.get("operstate") or read_text(base / "operstate") or "unknown").lower() + state = "UP" if "UP" in flags and operstate == "up" else operstate.upper() + if nmcli.get("GENERAL.STATE"): + state = nmcli["GENERAL.STATE"] + + if wireless: + interface_type = "Wi-Fi" + elif hardware["kind"].startswith("physical"): + interface_type = "Ethernet / physical" + else: + interface_type = kind or "virtual" + group = "physical_wifi" if wireless and hardware["kind"].startswith("physical") else ( + "physical_ethernet" if not wireless and hardware["kind"].startswith("physical") else "virtual" + ) + link_match = re.search(r"^\s*Link detected:\s*(yes|no)", ethtool or "", re.MULTILINE | re.IGNORECASE) + + return { + "name": name, + "type": interface_type, + "group": group, + "state": state, + "kind": kind or None, + "mac": link.get("address") or nmcli.get("GENERAL.HWADDR"), + "mtu": link.get("mtu") or nmcli.get("GENERAL.MTU"), + "addresses": addresses, + "connection": nmcli.get("GENERAL.CONNECTION"), + "speed_mbps": speed, + "link_detected": link_match.group(1).lower() if link_match else None, + "link_modes": parse_link_modes(ethtool or ""), + "hardware": hardware, + "wifi": wireless, + } + + +def wifi_scan_nmcli(): + output = run([ + "nmcli", "--terse", "--escape", "yes", "--fields", + "IN-USE,SSID,BSSID,CHAN,FREQ,SIGNAL,BARS,SECURITY", + "device", "wifi", "list", "--rescan", "yes", + ], timeout=45) + if output is None: + return None + networks = [] + for line in output.splitlines(): + fields = split_nmcli_fields(line) + if len(fields) < 8: + continue + in_use, ssid, bssid, channel, frequency, signal, bars, security = fields[:8] + frequency_match = re.search(r"\d+", frequency) + try: + signal_percent = int(signal) + except ValueError: + signal_percent = 0 + try: + channel_number = int(channel) + except ValueError: + channel_number = None + networks.append({ + "ssid": ssid or "(hidden network)", + "bssid": bssid or "Unavailable", + "channel": channel_number, + "frequency_mhz": int(frequency_match.group()) if frequency_match else None, + "signal_percent": signal_percent, + "signal_bars": bars, + "security": security or "Open", + "connected": in_use == "*", + }) + return networks + + +def wifi_scan_iw(interface_names): + if shutil.which("iw") is None or not interface_names: + return None + connected_bssids = set() + for interface in interface_names: + link = run(["iw", "dev", interface, "link"]) + match = re.search(r"^Connected to\s+([0-9a-fA-F:]+)", link or "", re.MULTILINE) + if match: + connected_bssids.add(match.group(1).lower()) + output = run(["iw", "dev", interface_names[0], "scan"], timeout=45) + if output is None: + return None + networks = [] + for block in re.split(r"(?=^BSS )", output, flags=re.MULTILINE): + bssid_match = re.search(r"^BSS\s+([0-9a-fA-F:]+)", block, re.MULTILINE) + ssid_match = re.search(r"^\s*SSID:\s*(.*)$", block, re.MULTILINE) + frequency_match = re.search(r"^\s*freq:\s*(\d+)", block, re.MULTILINE) + signal_match = re.search(r"^\s*signal:\s*(-?\d+(?:\.\d+)?)\s*dBm", block, re.MULTILINE) + if not bssid_match: + continue + signal_dbm = float(signal_match.group(1)) if signal_match else -100.0 + channel = None + if frequency_match: + frequency = int(frequency_match.group(1)) + channel = (frequency - 2407) // 5 if 2412 <= frequency <= 2472 else (frequency - 5000) // 5 if frequency >= 5000 else None + security = "WPA" if "WPA:" in block or "RSN:" in block else "Open" + networks.append({ + "ssid": (ssid_match.group(1).strip() if ssid_match else "") or "(hidden network)", + "bssid": bssid_match.group(1), + "channel": channel, + "frequency_mhz": int(frequency_match.group(1)) if frequency_match else None, + "signal_percent": None, + "signal_dbm": signal_dbm, + "signal_bars": None, + "security": security, + "connected": bssid_match.group(1).lower() in connected_bssids, + }) + return networks + + +def strongest_unique_networks(networks): + strongest = {} + for network in networks: + key = network["ssid"] if network["ssid"] != "(hidden network)" else network["bssid"] + previous = strongest.get(key) + strength = network.get("signal_percent") + if strength is None: + strength = network.get("signal_dbm", -100) + previous_strength = -101 + if previous: + previous_strength = previous.get("signal_percent") + if previous_strength is None: + previous_strength = previous.get("signal_dbm", -100) + if ( + previous is None + or (network.get("connected") and not previous.get("connected")) + or (network.get("connected") == previous.get("connected") and strength > previous_strength) + ): + strongest[key] = network + return sorted( + strongest.values(), + key=lambda item: ( + -(item.get("signal_percent") if item.get("signal_percent") is not None else item.get("signal_dbm", -100)), + item["ssid"].casefold(), + ), + )[:10] + + +def collect_report(): + output = run(["ip", "-j", "-details", "address", "show"]) + if output is None: + raise RuntimeError("could not read network interfaces using ip") + links = json.loads(output) + interfaces = [interface_details(link) for link in links] + wifi_names = [item["name"] for item in interfaces if item["wifi"]] + ethernet = sorted((item for item in interfaces if item["group"] == "physical_ethernet"), key=lambda item: item["name"]) + wifi_interfaces = sorted((item for item in interfaces if item["group"] == "physical_wifi"), key=lambda item: item["name"]) + virtual = sorted((item for item in interfaces if item["group"] == "virtual"), key=lambda item: item["name"]) + + scan_note = None + networks = [] + scan_source = None + if not wifi_names: + scan_note = "No Wi-Fi interfaces found; scan skipped." + else: + networks = wifi_scan_nmcli() + scan_source = "NetworkManager" + if networks is None: + networks = wifi_scan_iw(wifi_names) + scan_source = "iw" + if networks is None: + scan_note = "Wi-Fi scan failed. NetworkManager is unavailable and/or scanning requires elevated privileges." + networks = [] + else: + networks = strongest_unique_networks(networks) + if not networks: + scan_note = "No Wi-Fi networks found." + + return { + "generated_at": datetime.datetime.now().astimezone().isoformat(timespec="seconds"), + "physical_ethernet": ethernet, + "physical_wifi": wifi_interfaces, + "wifi_networks": networks, + "scan_source": scan_source, + "scan_note": scan_note, + "virtual_interfaces": virtual, + } + + +def interface_lines(interface, indent=" "): + lines = [f"{indent}{interface['name']} [{interface['type']}] {interface['state']}"] + hardware = interface["hardware"] + if hardware.get("description"): + lines.append(f"{indent} Network card: {hardware['description']}") + if hardware.get("bus"): + lines.append(f"{indent} Bus: {hardware['bus']}") + if hardware.get("driver"): + lines.append(f"{indent} Driver: {hardware['driver']}") + if hardware.get("modules"): + lines.append(f"{indent} Kernel modules: {hardware['modules']}") + if hardware.get("vendor") or hardware.get("product"): + lines.append(f"{indent} USB vendor/product: {hardware.get('vendor') or 'Unknown'}:{hardware.get('product') or 'Unknown'}") + if interface["kind"]: + lines.append(f"{indent} Virtual/link kind: {interface['kind']}") + if interface["mac"]: + lines.append(f"{indent} MAC: {interface['mac']}") + if interface["mtu"]: + lines.append(f"{indent} MTU: {interface['mtu']}") + if interface["connection"]: + lines.append(f"{indent} Active connection: {interface['connection']}") + if interface["addresses"]: + lines.append(f"{indent} IP addresses: {', '.join(interface['addresses'])}") + if interface["speed_mbps"]: + lines.append(f"{indent} Link speed: {interface['speed_mbps']} Mbps") + elif interface["group"] == "physical_ethernet": + lines.append(f"{indent} Link speed: unavailable (install/use ethtool or check driver support)") + if interface["link_detected"]: + lines.append(f"{indent} Cable/link detected: {interface['link_detected']}") + if interface["link_modes"]: + lines.append(f"{indent} Supported link modes: {', '.join(interface['link_modes'])}") + return lines + + +def render_text(report, color=False): + lines = [ + "NETWORK DIAGNOSTIC REPORT", + "=========================", + f"Generated: {report['generated_at']}", + "", + "PHYSICAL INTERFACES", + "--------------------", + "Ethernet", + ] + if report["physical_ethernet"]: + for interface in report["physical_ethernet"]: + lines.extend(interface_lines(interface)) + lines.append("") + else: + lines.append(" No physical Ethernet interfaces found.\n") + + lines.append("Wi-Fi") + if report["physical_wifi"]: + for interface in report["physical_wifi"]: + lines.extend(interface_lines(interface)) + lines.append("") + else: + lines.append(" No physical Wi-Fi interfaces found.\n") + + lines.extend(["TOP 10 WI-FI NETWORKS IN RANGE", "------------------------------"]) + if report["scan_note"]: + lines.append(f" {report['scan_note']}") + elif report["wifi_networks"]: + lines.append(f" Sorted by strongest signal; unique SSIDs, strongest access point shown ({report['scan_source']}).") + lines.append(f" {'#':>2} {'SIGNAL':>7} {'CH':>4} {'FREQUENCY':>10} SSID / SECURITY / BSSID") + for index, network in enumerate(report["wifi_networks"], start=1): + if network.get("signal_percent") is not None: + signal = f"{network['signal_percent']}% {network.get('signal_bars') or ''}".strip() + else: + signal = f"{network.get('signal_dbm', 'N/A')} dBm" + channel = str(network["channel"]) if network.get("channel") is not None else "N/A" + frequency = f"{network['frequency_mhz']} MHz" if network.get("frequency_mhz") else "N/A" + connected = " [CONNECTED]" if network.get("connected") else "" + row = f" {index:>2} {signal:>7} {channel:>4} {frequency:>10} {network['ssid']} / {network['security']} / {network['bssid']}{connected}" + lines.append(f"\033[32m{row}\033[0m" if color and network.get("connected") else row) + else: + lines.append(" No Wi-Fi networks found.") + + lines.extend(["", "VIRTUAL INTERFACES", "------------------"]) + if report["virtual_interfaces"]: + for interface in report["virtual_interfaces"]: + lines.extend(interface_lines(interface)) + lines.append("") + else: + lines.append(" No virtual interfaces found.") + return "\n".join(lines).rstrip() + "\n" + + +def render_html(report): + def esc(value): + return html.escape("Unavailable" if value is None or value == "" else str(value)) + + def interface_card(interface): + hardware = interface["hardware"] + details = [ + ("Hardware", hardware.get("description")), + ("Bus", hardware.get("bus")), + ("Driver", hardware.get("driver")), + ("Kernel modules", hardware.get("modules")), + ("MAC", interface.get("mac")), + ("MTU", interface.get("mtu")), + ("Connection", interface.get("connection")), + ("IP addresses", ", ".join(interface["addresses"]) if interface["addresses"] else None), + ("Link speed", f"{interface['speed_mbps']} Mbps" if interface["speed_mbps"] else None), + ("Link detected", interface.get("link_detected")), + ("Link modes", ", ".join(interface["link_modes"]) if interface["link_modes"] else None), + ("Kind", interface.get("kind")), + ] + items = "".join(f"
{esc(label)}
{esc(value)}
" for label, value in details if value) + return f"

{esc(interface['name'])}{esc(interface['state'])}

{esc(interface['type'])}

{items}
" + + def interface_section(title, interfaces): + body = "".join(interface_card(interface) for interface in interfaces) + if not body: + body = "

No interfaces found.

" + return f"

{html.escape(title)}

{body}
" + + network_rows = [] + for index, network in enumerate(report["wifi_networks"], start=1): + row_style = ' class="connected" style="background-color:#def2e4;color:#155b32;font-weight:650"' if network.get("connected") else "" + connected_label = " · CONNECTED" if network.get("connected") else "" + signal = esc(network["signal_percent"]) if network.get("signal_percent") is not None else esc(network.get("signal_dbm")) + " dBm" + network_rows.append( + f"{index}{esc(network['ssid'])}{connected_label}{signal}" + f"{esc(network.get('channel'))}{esc(network.get('frequency_mhz'))} MHz{esc(network['security'])}{esc(network['bssid'])}" + ) + network_rows = "".join(network_rows) + if network_rows: + wifi_body = "
" + network_rows + "
#SSIDSignalChannelFrequencySecurityBSSID
" + else: + wifi_body = f"

{esc(report['scan_note'] or 'No Wi-Fi networks found.')}

" + return ( + "" + "Network Diagnostic Report" + "
" + f"

Network Diagnostic Report

Generated {esc(report['generated_at'])}

" + + interface_section("Physical Ethernet interfaces", report["physical_ethernet"]) + + interface_section("Physical Wi-Fi interfaces", report["physical_wifi"]) + + f"

Top 10 Wi-Fi networks in range

Sorted by strongest signal · {esc(report['scan_source'] or 'no scan source')}

{wifi_body}
" + + interface_section("Virtual interfaces", report["virtual_interfaces"]) + + "
\n" + ) + + +def ask(prompt, default): + try: + answer = input(f"{prompt} [{default}]: ").strip() + except EOFError: + return default + return answer or default + + +def ask_yes_no(prompt, default): + marker = "Y/n" if default else "y/N" + try: + answer = input(f"{prompt} [{marker}]: ").strip().lower() + except EOFError: + return default + if not answer: + return default + return answer in ("y", "yes") + + +def infer_format(path, requested): + if requested: + return requested + suffix = pathlib.Path(path).suffix.lower().lstrip(".") + return suffix if suffix in ("txt", "json", "html") else "txt" + + +def render_report(report, format_name): + if format_name == "json": + return json.dumps(report, ensure_ascii=False, indent=2) + "\n" + if format_name == "html": + return render_html(report) + return render_text(report) + + +def save_report(path_text, format_name, force, interactive, report): + path = pathlib.Path(path_text).expanduser() + if not path.parent.exists() or not path.parent.is_dir(): + raise OSError(f"output directory does not exist: {path.parent}") + if path.exists() and not force: + if not interactive or not ask_yes_no(f"{path} already exists. Overwrite it?", False): + return False + rendered = render_report(report, format_name) + temporary = None + try: + with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as output: + temporary = pathlib.Path(output.name) + output.write(rendered) + os.replace(temporary, path) + finally: + if temporary is not None and temporary.exists(): + temporary.unlink() + print(f"Report saved: {path}", file=sys.stderr) + return True + + +def main(): + output_path, requested_format, force_value = sys.argv[1:4] + force = force_value == "1" + interactive = sys.stdin.isatty() and sys.stdout.isatty() + report = collect_report() + text_report = render_text(report) + + if output_path: + format_name = infer_format(output_path, requested_format) + if not save_report(output_path, format_name, force, interactive, report): + print("Report not written; existing file was not overwritten.", file=sys.stderr) + return 1 if not interactive else 0 + return 0 + if not interactive: + print(render_report(report, requested_format or "txt"), end="") + return 0 + + print(render_text(report, color=sys.stdout.isatty() and "NO_COLOR" not in os.environ), end="") + if not ask_yes_no("Save this report to a file?", True): + return 0 + format_name = requested_format or ask("Report format (txt/json/html)", "txt").lower() + if format_name not in ("txt", "json", "html"): + print("Unsupported format; using txt.", file=sys.stderr) + format_name = "txt" + default_path = str(pathlib.Path.home() / f"report.{format_name}") + path = ask("Output path", default_path) + if not save_report(path, format_name, force, interactive, report): + print("Report not written; existing file was not overwritten.", file=sys.stderr) + return 0 + + +try: + raise SystemExit(main()) +except (OSError, RuntimeError, json.JSONDecodeError) as error: + print(f"Error: {error}", file=sys.stderr) + raise SystemExit(1) +PYCODE +)" "$OUTPUT_PATH" "$OUTPUT_FORMAT" "$FORCE" \ No newline at end of file