#!/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"