Add network diagnostic reports
This commit is contained in:
1 parent
a23fc775e3
commit
43518d51cc
4 files changed
+756
-3
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.<format>`, като форматът по подразбиране е TXT.
|
||||
В non-interactive режим без `--output` отчетът се извежда към stdout; зададеният
|
||||
`--format` определя сериализацията.
|
||||
|
||||
## Ограничения
|
||||
|
||||
Скриптът показва до десет уникални SSID, а не десет отделни BSSID; при
|
||||
еднакво име се избира access point-ът с най-силен сигнал. Скритите мрежи
|
||||
нямат SSID и се третират отделно по BSSID. Детайлите за PCI устройства
|
||||
зависят от `lspci`, за USB устройствата от sysfs, а Ethernet link modes
|
||||
зависят от `ethtool` и драйвера. Wi-Fi scan може да не се изпълни, ако
|
||||
NetworkManager не управлява устройството или правата на текущия потребител
|
||||
не позволяват сканиране.
|
||||
Executable
+666
@@ -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"<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"
|
||||
Reference in new issue
Block a user