Files
2026-06-09 14:01:51 +08:00

224 lines
7.5 KiB
Python

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)