This commit is contained in:
2026-06-09 14:01:51 +08:00
parent 3acb26d9e2
commit 448f2f18cc
16 changed files with 699 additions and 289 deletions
+7 -4
View File
@@ -12,6 +12,7 @@ from typing import Callable, Optional
from core.Function.common import run_hidden
from core.Function.loop_fun import normalize_mac
from core.Function.network_fun import NetworkManager
from core.Function.oui_lookup import OuiVendorLookup
OutputCallback = Callable[[str, Optional[str]], None]
@@ -63,6 +64,9 @@ OUI_VENDOR_MAP = {
}
OUI_LOOKUP = OuiVendorLookup(OUI_VENDOR_MAP)
@dataclass
class DeviceDiscoveryOptions:
scan_range: str = ""
@@ -106,6 +110,8 @@ class DeviceDiscovery:
self.worker = None
self.last_results: list[dict] = []
self.last_summary = ""
OUI_LOOKUP.load(self.output)
OUI_LOOKUP.update_if_stale_async(self.output)
def get_adapter_choices(self) -> list[str]:
adapters = self._active_adapters(self.network.get_network_info())
@@ -543,10 +549,7 @@ def normalize_neighbor(item: dict) -> dict:
def vendor_name(mac: str) -> str:
normalized = normalize_mac(mac)
if len(normalized) < 8:
return "未知"
return OUI_VENDOR_MAP.get(normalized[:8], "未知")
return OUI_LOOKUP.lookup(mac)
def build_summary(rows: list[dict], stopped: bool = False) -> str:
+5 -5
View File
@@ -76,13 +76,13 @@ class DnsDiagnostic:
self.local_dns_servers: set[str] = set()
def get_adapter_choices(self) -> list[str]:
adapters = self.network.get_network_info()
adapters = self._active_adapters(self.network.get_network_info())
return [ALL_ADAPTERS] + [adapter["name"] for adapter in adapters]
def get_adapter_choices_and_default_dns(self) -> tuple[list[str], str]:
adapters = self.network.get_network_info()
adapters = self._active_adapters(self.network.get_network_info())
choices = [ALL_ADAPTERS] + [adapter["name"] for adapter in adapters]
servers = collect_adapter_dns(self._active_adapters(adapters))
servers = collect_adapter_dns(adapters)
default_dns = ",".join(list(dict.fromkeys(servers + PUBLIC_DNS_SERVERS)))
return choices, default_dns
@@ -324,9 +324,9 @@ class DnsDiagnostic:
return [adapter for adapter in adapters if adapter.get("name") == adapter_name]
def _select_dns_source_adapters(self, adapter_name: str) -> list[dict]:
adapters = self.network.get_network_info()
adapters = self._active_adapters(self.network.get_network_info())
if not adapter_name or adapter_name == ALL_ADAPTERS:
return self._active_adapters(adapters)
return adapters
return [adapter for adapter in adapters if adapter.get("name") == adapter_name]
def _select_repair_adapters(self, adapter_name: str) -> list[dict]:
+223
View File
@@ -0,0 +1,223 @@
import csv
import json
import os
import re
import sys
import tempfile
import threading
import time
import urllib.request
from pathlib import Path
from typing import Callable, Optional
OutputCallback = Callable[[str, Optional[str]], None]
IEEE_OUI_SOURCES = [
("MA-L", "https://standards-oui.ieee.org/oui/oui.csv"),
("MA-M", "https://standards-oui.ieee.org/oui28/mam.csv"),
("MA-S", "https://standards-oui.ieee.org/oui36/oui36.csv"),
]
UPDATE_INTERVAL_SECONDS = 7 * 24 * 60 * 60
UNKNOWN_VENDOR = "未知"
LOCAL_ADMIN_VENDOR = "随机/本地管理地址"
class OuiVendorLookup:
def __init__(self, fallback_map: Optional[dict[str, str]] = None):
self.fallback_map = {normalize_prefix(key): value for key, value in (fallback_map or {}).items()}
self.vendors: dict[str, str] = {}
self.source = "未加载"
self.updated_at = 0.0
self._loaded = False
self._update_started = False
self._lock = threading.RLock()
def load(self, output: Optional[OutputCallback] = None) -> None:
with self._lock:
if self._loaded:
return
loaded = self._load_json_file(cache_file())
if loaded:
self.vendors, self.updated_at = loaded
self.source = "本地缓存"
self._loaded = True
write(output, f"已加载 OUI 厂商缓存:{len(self.vendors)}\n", "muted")
return
loaded = self._load_json_file(seed_file())
if loaded:
self.vendors, self.updated_at = loaded
self.source = "离线内置"
self._loaded = True
write(output, f"已加载离线 OUI 厂商库:{len(self.vendors)}\n", "muted")
return
self.vendors = dict(self.fallback_map)
self.updated_at = 0.0
self.source = "内置兜底"
self._loaded = True
write(output, f"已加载内置 OUI 兜底表:{len(self.vendors)}\n", "muted")
def lookup(self, mac: str) -> str:
self.load()
normalized = normalize_mac_hex(mac)
if len(normalized) < 6:
return UNKNOWN_VENDOR
with self._lock:
for prefix_length in (9, 7, 6):
if len(normalized) >= prefix_length:
vendor = self.vendors.get(normalized[:prefix_length])
if vendor:
return vendor
fallback = self.fallback_map.get(normalized[:prefix_length])
if fallback:
return fallback
if is_locally_administered(normalized):
return LOCAL_ADMIN_VENDOR
return UNKNOWN_VENDOR
def update_if_stale_async(self, output: Optional[OutputCallback] = None) -> None:
self.load(output)
with self._lock:
if self._update_started or not self.is_stale():
return
self._update_started = True
thread = threading.Thread(target=self._update_worker, args=(output,), daemon=True)
thread.start()
def update_now(self, output: Optional[OutputCallback] = None) -> bool:
self.load(output)
vendors = download_ieee_oui()
if not vendors:
raise RuntimeError("未下载到有效 OUI 数据")
payload = {"updated_at": time.time(), "vendors": vendors}
save_json_atomic(cache_file(), payload)
with self._lock:
self.vendors = vendors
self.updated_at = float(payload["updated_at"])
self.source = "联网更新"
write(output, f"OUI 厂商库已更新:{len(vendors)}\n", "success")
return True
def is_stale(self) -> bool:
if not self.updated_at:
return True
return time.time() - self.updated_at > UPDATE_INTERVAL_SECONDS
def _update_worker(self, output: Optional[OutputCallback]) -> None:
try:
self.update_now(output)
except Exception as exc:
write(output, f"OUI 厂商库更新失败,继续使用离线库:{exc}\n", "warning")
def _load_json_file(self, path: Path) -> Optional[tuple[dict[str, str], float]]:
try:
if not path.exists():
return None
with path.open("r", encoding="utf-8") as file:
payload = json.load(file)
raw_vendors = payload.get("vendors", {})
vendors = {
normalize_prefix(prefix): compact_vendor_name(vendor)
for prefix, vendor in raw_vendors.items()
if normalize_prefix(prefix) and compact_vendor_name(vendor)
}
if not vendors:
return None
return vendors, float(payload.get("updated_at") or 0)
except Exception:
return None
def download_ieee_oui() -> dict[str, str]:
vendors: dict[str, str] = {}
for _registry, url in IEEE_OUI_SOURCES:
text = download_text(url)
vendors.update(parse_ieee_csv(text))
return vendors
def download_text(url: str) -> str:
request = urllib.request.Request(
url,
headers={
"User-Agent": "NetPilot/1.0 (+https://standards.ieee.org/develop/regauth/)",
"Accept": "text/csv,text/plain,*/*",
},
)
with urllib.request.urlopen(request, timeout=25) as response:
return response.read().decode("utf-8-sig", errors="replace")
def parse_ieee_csv(text: str) -> dict[str, str]:
rows = csv.DictReader(text.splitlines())
vendors: dict[str, str] = {}
for row in rows:
prefix = normalize_prefix(row.get("Assignment", ""))
vendor = compact_vendor_name(row.get("Organization Name", ""))
if prefix and vendor:
vendors[prefix] = vendor
return vendors
def normalize_prefix(value: str) -> str:
cleaned = re.sub(r"[^0-9A-Fa-f]", "", str(value or "")).upper()
if len(cleaned) in (6, 7, 9):
return cleaned
return ""
def normalize_mac_hex(value: str) -> str:
return re.sub(r"[^0-9A-Fa-f]", "", str(value or "")).upper()
def compact_vendor_name(value: str) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()
def is_locally_administered(mac_hex: str) -> bool:
try:
return bool(int(mac_hex[:2], 16) & 0x02)
except Exception:
return False
def cache_file() -> Path:
base = os.environ.get("LOCALAPPDATA") or os.path.join(Path.home(), "AppData", "Local")
return Path(base) / "NetPilot" / "oui_vendors.json"
def seed_file() -> Path:
candidates = []
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
candidates.append(Path(sys._MEIPASS) / "assets" / "oui_vendors_seed.json")
candidates.append(Path(__file__).resolve().parents[2] / "assets" / "oui_vendors_seed.json")
if getattr(sys, "frozen", False):
candidates.append(Path(sys.executable).resolve().parent / "assets" / "oui_vendors_seed.json")
for path in candidates:
if path.exists():
return path
return candidates[0]
def save_json_atomic(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix=path.name, suffix=".tmp", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as file:
json.dump(payload, file, ensure_ascii=False, separators=(",", ":"))
os.replace(temp_name, path)
finally:
if os.path.exists(temp_name):
os.remove(temp_name)
def write(output: Optional[OutputCallback], text: str, tag: Optional[str] = None) -> None:
if output:
output(text, tag)