666 lines
27 KiB
Bash
Executable File
666 lines
27 KiB
Bash
Executable File
#!/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"<dt>{esc(label)}</dt><dd>{esc(value)}</dd>" for label, value in details if value)
|
|
return f"<article class=\"iface\"><h4>{esc(interface['name'])}<span>{esc(interface['state'])}</span></h4><p>{esc(interface['type'])}</p><dl>{items}</dl></article>"
|
|
|
|
def interface_section(title, interfaces):
|
|
body = "".join(interface_card(interface) for interface in interfaces)
|
|
if not body:
|
|
body = "<p class=\"muted\">No interfaces found.</p>"
|
|
return f"<section><h2>{html.escape(title)}</h2><div class=\"grid\">{body}</div></section>"
|
|
|
|
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"<tr{row_style}><td>{index}</td><td>{esc(network['ssid'])}{connected_label}</td><td>{signal}</td>"
|
|
f"<td>{esc(network.get('channel'))}</td><td>{esc(network.get('frequency_mhz'))} MHz</td><td>{esc(network['security'])}</td><td>{esc(network['bssid'])}</td></tr>"
|
|
)
|
|
network_rows = "".join(network_rows)
|
|
if network_rows:
|
|
wifi_body = "<div class=\"table-wrap\"><table><thead><tr><th>#</th><th>SSID</th><th>Signal</th><th>Channel</th><th>Frequency</th><th>Security</th><th>BSSID</th></tr></thead><tbody>" + network_rows + "</tbody></table></div>"
|
|
else:
|
|
wifi_body = f"<p class=\"muted\">{esc(report['scan_note'] or 'No Wi-Fi networks found.')}</p>"
|
|
return (
|
|
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">"
|
|
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Network Diagnostic Report</title>"
|
|
"<style>:root{--ink:#18262c;--muted:#5c7078;--line:#d7e1e3;--paper:#f2f6f5;--panel:#fff;--teal:#087e80;--blue:#3569a8}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui,-apple-system,\"Segoe UI\",sans-serif}main{max-width:1120px;margin:auto;padding:32px 22px 48px}header{border-bottom:2px solid var(--teal);padding-bottom:16px}h1{margin:0;font-size:28px}header p,.muted{color:var(--muted)}section{background:var(--panel);border:1px solid var(--line);padding:18px;margin-top:14px}h2{font-size:19px;margin:0 0 14px}h3{font-size:15px;color:var(--muted);margin:18px 0 9px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.iface{border:1px solid var(--line);padding:12px;min-width:0}.iface h4{margin:0;display:flex;justify-content:space-between;gap:12px}.iface h4 span,.iface p{color:var(--muted);font-size:13px}.iface p{margin:4px 0 10px}dl{display:grid;grid-template-columns:120px 1fr;gap:3px 10px;margin:0;font-size:13px}dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere}.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;text-align:left}th,td{border-bottom:1px solid var(--line);padding:9px 10px;white-space:nowrap}th{font-size:12px;color:var(--muted);text-transform:uppercase}@media(max-width:680px){main{padding:20px 12px}.grid{grid-template-columns:1fr}section{padding:14px}dl{grid-template-columns:100px 1fr}}</style></head><body><main>"
|
|
f"<header><h1>Network Diagnostic Report</h1><p>Generated {esc(report['generated_at'])}</p></header>"
|
|
+ interface_section("Physical Ethernet interfaces", report["physical_ethernet"])
|
|
+ interface_section("Physical Wi-Fi interfaces", report["physical_wifi"])
|
|
+ f"<section><h2>Top 10 Wi-Fi networks in range</h2><p class=\"muted\">Sorted by strongest signal · {esc(report['scan_source'] or 'no scan source')}</p>{wifi_body}</section>"
|
|
+ interface_section("Virtual interfaces", report["virtual_interfaces"])
|
|
+ "</main></body></html>\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" |