This commit is contained in:
2026-06-08 13:39:59 +08:00
parent 2d67146487
commit e9ef41f184
17 changed files with 723 additions and 266 deletions
+61 -39
View File
@@ -2,8 +2,8 @@ import concurrent.futures
import csv
import ipaddress
import json
import unicodedata
import re
import socket
import threading
import time
from dataclasses import asdict, dataclass, field
@@ -75,7 +75,6 @@ class DeviceDiscoveryOptions:
class DeviceInfo:
ip: str
mac: str
hostname: str
vendor: str
adapter: str
interface_index: str
@@ -112,6 +111,12 @@ class DeviceDiscovery:
adapters = self._active_adapters(self.network.get_network_info())
return [ALL_ADAPTERS] + [adapter["name"] for adapter in adapters]
def get_adapter_choices_and_default_range(self) -> tuple[list[str], str, str]:
adapters = self._active_adapters(self.network.get_network_info())
choices = [ALL_ADAPTERS] + [adapter["name"] for adapter in adapters]
default_adapter = preferred_discovery_adapter(adapters)
return choices, adapter_to_safe_range(default_adapter) if default_adapter else "", default_adapter.get("name", "") if default_adapter else ""
def default_scan_range(self, adapter_name: str = ALL_ADAPTERS) -> str:
adapters = self._select_adapters(adapter_name)
if not adapters:
@@ -136,7 +141,7 @@ class DeviceDiscovery:
self.last_summary = ""
self.output(
f"开始局域网设备发现: {adapter_name or ALL_ADAPTERS}"
f"目标 {len(targets)} 个,并发 {discovery_options.workers},超时 {discovery_options.timeout_ms}ms\n",
f"目标 {len(targets)} 个,并发 {min(discovery_options.workers, 24, max(1, len(targets)))},超时 {discovery_options.timeout_ms}ms\n",
"muted",
)
self.status(self._status("扫描中", adapter_name or ALL_ADAPTERS, describe_targets(targets), len(targets), 0, 0, 0))
@@ -196,13 +201,14 @@ class DeviceDiscovery:
def scan_targets(self, targets: list[str], options: DeviceDiscoveryOptions, started: float) -> dict[str, dict]:
results = {}
completed = 0
workers = min(options.workers, 24, max(1, len(targets)))
def task(ip: str) -> dict:
if self.stop_event.is_set():
return {"ip": ip, "ok": False, "rtt": 0.0}
return ping_once(ip, options.timeout_ms)
with concurrent.futures.ThreadPoolExecutor(max_workers=options.workers) as executor:
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(task, ip): ip for ip in targets}
for future in concurrent.futures.as_completed(futures):
completed += 1
@@ -241,7 +247,6 @@ class DeviceDiscovery:
devices[local_ip] = DeviceInfo(
ip=local_ip,
mac=normalize_mac(adapter.get("mac", "")),
hostname=resolve_hostname(local_ip),
vendor=vendor_name(adapter.get("mac", "")),
adapter=adapter.get("name", ""),
interface_index=str(adapter.get("interface_index", "")),
@@ -270,7 +275,6 @@ class DeviceDiscovery:
devices[ip] = DeviceInfo(
ip=ip,
mac=mac,
hostname=resolve_hostname(ip),
vendor=vendor_name(mac),
adapter=adapter.get("name", ""),
interface_index=str(adapter.get("interface_index", "")),
@@ -285,20 +289,15 @@ class DeviceDiscovery:
adapter = adapter_for_ip(ip, adapters)
if not adapter:
continue
# Ping succeeded but no valid ARP MAC was visible; keep it out of
# the asset list unless it is a known local/gateway address.
if ip not in gateways and ip not in local_ips:
continue
devices[ip] = DeviceInfo(
ip=ip,
mac="",
hostname=resolve_hostname(ip),
vendor="未知",
adapter=adapter.get("name", ""),
interface_index=str(adapter.get("interface_index", "")),
latency_ms=ping.get("rtt", 0.0),
method="在线",
note="网关" if ip in gateways else "本机",
note="网关" if ip in gateways else "本机" if ip in local_ips else "未读取到 MAC",
)
return list(devices.values())
@@ -346,7 +345,7 @@ Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerA
def build_scan_targets(self, adapters: list[dict], options: DeviceDiscoveryOptions) -> list[str]:
if options.scan_range:
return parse_target_range(options.scan_range, options.max_hosts)
return filter_targets_for_adapters(parse_target_range(options.scan_range, options.max_hosts), adapters)
targets = []
for adapter in adapters:
targets.extend(parse_target_range(adapter_to_safe_range(adapter), options.max_hosts))
@@ -361,7 +360,7 @@ Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerA
def export_results(self, path: str) -> None:
if not self.last_results:
raise RuntimeError("还没有可导出的设备发现结果")
fields = ["ip", "mac", "hostname", "vendor", "adapter", "interface_index", "latency_ms", "method", "note", "checked_at"]
fields = ["ip", "mac", "vendor", "adapter", "interface_index", "latency_ms", "method", "note", "checked_at"]
with open(path, "w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(file, fieldnames=fields)
writer.writeheader()
@@ -372,7 +371,7 @@ Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerA
if not self.last_results:
return self.last_summary
return "\n".join(
f"{row.get('ip', '')}\t{row.get('mac', '')}\t{row.get('hostname', '')}\t{row.get('vendor', '')}\t{row.get('method', '')}"
f"{row.get('ip', '')}\t{row.get('mac', '')}\t{row.get('vendor', '')}\t{row.get('method', '')}"
for row in sorted(self.last_results, key=lambda item: ip_sort_key(item.get("ip", "")))
)
@@ -389,7 +388,8 @@ Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerA
adapters = self._active_adapters(self.network.get_network_info())
if not adapter_name or adapter_name == ALL_ADAPTERS:
return adapters
return [adapter for adapter in adapters if adapter.get("name") == adapter_name]
selected = normalize_adapter_name(adapter_name)
return [adapter for adapter in adapters if normalize_adapter_name(adapter.get("name", "")) == selected]
def _active_adapters(self, adapters: list[dict]) -> list[dict]:
active = []
@@ -397,7 +397,7 @@ Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerA
if not adapter.get("ipv4"):
continue
status = str(adapter.get("status", "")).lower()
if "disconnect" in status or "断开" in status:
if any(value in status for value in ("disconnect", "disabled", "not present", "断开", "禁用")):
continue
active.append(adapter)
return active
@@ -419,7 +419,7 @@ Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerA
def ping_once(ip: str, timeout_ms: int) -> dict:
try:
result = run_hidden(["ping", ip, "-n", "1", "-w", str(timeout_ms)], timeout=max(2, timeout_ms / 1000 + 2))
result = run_hidden(["ping", ip, "-n", "1", "-w", str(timeout_ms)], timeout=max(1.2, timeout_ms / 1000 + 0.8))
output = result.stdout
if re.search(r"\bTTL=", output, re.IGNORECASE):
match = re.search(r"(?:time|时间)[=<]?\s*(\d+(?:\.\d+)?)\s*(?:ms|毫秒)", output, re.IGNORECASE)
@@ -480,6 +480,49 @@ def adapter_to_safe_range(adapter: dict) -> str:
return ".".join(parts[:3]) + ".0/24" if len(parts) == 4 else ""
def preferred_discovery_adapter(adapters: list[dict]) -> Optional[dict]:
if not adapters:
return None
for adapter in adapters:
if adapter.get("gateway"):
return adapter
for adapter in adapters:
prefix = adapter.get("prefix_length")
try:
if prefix not in ("", None) and int(prefix) <= 24:
return adapter
except (TypeError, ValueError):
continue
return adapters[0]
def filter_targets_for_adapters(targets: list[str], adapters: list[dict]) -> list[str]:
return [target for target in targets if target_in_adapter_network(target, adapters)]
def target_in_adapter_network(target: str, adapters: list[dict]) -> bool:
try:
address = ipaddress.ip_address(target)
except ValueError:
return False
for adapter in adapters:
local_ip = adapter.get("ipv4", "")
if not local_ip:
continue
prefix = adapter.get("prefix_length") or netmask_to_prefix(adapter.get("netmask", "")) or 24
try:
if address in ipaddress.ip_network(f"{local_ip}/{prefix}", strict=False):
return True
except Exception:
continue
return False
def normalize_adapter_name(value: str) -> str:
normalized = unicodedata.normalize("NFKC", str(value or ""))
return re.sub(r"[\s\u200b-\u200d\ufeff]+", " ", normalized).strip().casefold()
def netmask_to_prefix(netmask: str) -> Optional[int]:
if not netmask:
return None
@@ -499,27 +542,6 @@ def normalize_neighbor(item: dict) -> dict:
}
def resolve_hostname(ip: str) -> str:
try:
old_timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(0.8)
try:
return socket.gethostbyaddr(ip)[0]
finally:
socket.setdefaulttimeout(old_timeout)
except Exception:
pass
try:
result = run_hidden(["nbtstat", "-A", ip], timeout=2)
for line in result.stdout.splitlines():
match = re.match(r"\s*([^\s<]+)\s+<00>\s+UNIQUE", line, re.IGNORECASE)
if match:
return match.group(1).strip()
except Exception:
pass
return ""
def vendor_name(mac: str) -> str:
normalized = normalize_mac(mac)
if len(normalized) < 8:
+37 -4
View File
@@ -68,7 +68,7 @@ class DnsDiagnostic:
self.done = done or (lambda: None)
self.status = status or (lambda _stats: None)
self.result = result or (lambda _row: None)
self.network = NetworkManager(lambda _text, _tag=None: None)
self.network = NetworkManager(output)
self.stop_event = threading.Event()
self.worker = None
self.last_results: list[dict] = []
@@ -76,14 +76,35 @@ class DnsDiagnostic:
self.local_dns_servers: set[str] = set()
def get_adapter_choices(self) -> list[str]:
adapters = self._active_adapters(self.network.get_network_info())
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()
choices = [ALL_ADAPTERS] + [adapter["name"] for adapter in adapters]
servers = collect_adapter_dns(self._active_adapters(adapters))
default_dns = ",".join(list(dict.fromkeys(servers + PUBLIC_DNS_SERVERS)))
return choices, default_dns
def default_dns_servers(self, adapter_name: str = ALL_ADAPTERS) -> str:
adapters = self._select_adapters(adapter_name)
adapters = self._select_dns_source_adapters(adapter_name)
servers = collect_adapter_dns(adapters)
return ",".join(list(dict.fromkeys(servers + PUBLIC_DNS_SERVERS)))
def repair_abnormal_dns(self, adapter_name: str = ALL_ADAPTERS) -> dict:
if self.is_running():
raise RuntimeError("DNS 诊断正在运行,请先停止当前任务")
adapters = self._select_repair_adapters(adapter_name)
if not adapters:
raise ValueError("没有找到可修复的活动网卡")
servers = PUBLIC_DNS_SERVERS[:2]
for adapter in adapters:
self.network.set_dns_servers(adapter["name"], servers)
self.network.flush_dns_cache()
return {"adapters": [adapter["name"] for adapter in adapters], "servers": servers}
def start_diagnosis(self, adapter_name: str = ALL_ADAPTERS, options: Optional[dict] = None) -> None:
if self.is_running():
raise RuntimeError("DNS 诊断正在运行,请先停止当前任务")
@@ -302,13 +323,25 @@ class DnsDiagnostic:
return adapters
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()
if not adapter_name or adapter_name == ALL_ADAPTERS:
return self._active_adapters(adapters)
return [adapter for adapter in adapters if adapter.get("name") == adapter_name]
def _select_repair_adapters(self, adapter_name: str) -> list[dict]:
adapters = self._select_adapters(adapter_name)
if adapter_name and adapter_name != ALL_ADAPTERS:
return adapters
return [adapter for adapter in adapters if adapter.get("gateway") or adapter.get("dns1") or adapter.get("dns2")]
def _active_adapters(self, adapters: list[dict]) -> list[dict]:
active = []
for adapter in adapters:
if not adapter.get("ipv4"):
continue
status = str(adapter.get("status", "")).lower()
if "disconnect" in status or "断开" in status:
if any(value in status for value in ("disconnect", "disabled", "not present", "断开", "禁用")):
continue
active.append(adapter)
return active
+1 -1
View File
@@ -487,7 +487,7 @@ Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerA
if not adapter.get("ipv4"):
continue
status = str(adapter.get("status", "")).lower()
if "disconnect" in status or "断开" in status:
if any(value in status for value in ("disconnect", "disabled", "not present", "断开", "禁用")):
continue
active.append(adapter)
return active
+1 -1
View File
@@ -353,7 +353,7 @@ Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerA
if not adapter.get("ipv4"):
continue
status = str(adapter.get("status", "")).lower()
if "disconnect" in status or "断开" in status:
if any(value in status for value in ("disconnect", "disabled", "not present", "断开", "禁用")):
continue
active.append(adapter)
return active
+92 -17
View File
@@ -24,33 +24,55 @@ class NetworkManager:
def get_network_info(self) -> list[dict]:
script = r"""
$ErrorActionPreference = "SilentlyContinue"
$items = Get-NetIPConfiguration | ForEach-Object {
$alias = $_.InterfaceAlias
$index = $_.InterfaceIndex
$adapter = Get-NetAdapter -InterfaceAlias $alias -ErrorAction SilentlyContinue
$ipif = Get-NetIPInterface -InterfaceAlias $alias -AddressFamily IPv4 -ErrorAction SilentlyContinue | Select-Object -First 1
$cim = Get-CimInstance Win32_NetworkAdapterConfiguration -Filter "IPEnabled=True" -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceIndex -eq $index } | Select-Object -First 1
$ipconfigs = @{}
Get-NetIPConfiguration | ForEach-Object { $ipconfigs[[string]$_.InterfaceIndex] = $_ }
$ipifs = @{}
Get-NetIPInterface -AddressFamily IPv4 | ForEach-Object {
$key = [string]$_.InterfaceIndex
if (-not $ipifs.ContainsKey($key)) { $ipifs[$key] = $_ }
}
$cims = @{}
Get-CimInstance Win32_NetworkAdapterConfiguration | ForEach-Object {
if ($_.InterfaceIndex -ne $null) { $cims[[string]$_.InterfaceIndex] = $_ }
}
$dnsMap = @{}
Get-DnsClientServerAddress -AddressFamily IPv4 | ForEach-Object {
$dnsMap[[string]$_.InterfaceIndex] = @($_.ServerAddresses)
}
$items = Get-NetAdapter | Sort-Object Name | ForEach-Object {
$adapter = $_
$alias = $adapter.Name
$index = $adapter.ifIndex
$key = [string]$index
$ipconfig = $ipconfigs[$key]
$ipif = $ipifs[$key]
$cim = $cims[$key]
$dns = @($dnsMap[$key])
[PSCustomObject]@{
name = $alias
description = $_.InterfaceDescription
mac = if ($adapter) { $adapter.MacAddress } else { "" }
status = if ($adapter) { [string]$adapter.Status } else { "" }
link_speed = if ($adapter) { [string]$adapter.LinkSpeed } else { "" }
description = $adapter.InterfaceDescription
mac = $adapter.MacAddress
status = [string]$adapter.Status
link_speed = [string]$adapter.LinkSpeed
interface_index = $index
ipv4 = @($_.IPv4Address | Select-Object -ExpandProperty IPAddress)[0]
ipv6 = @($_.IPv6Address | Select-Object -ExpandProperty IPAddress)
prefix_length = @($_.IPv4Address | Select-Object -ExpandProperty PrefixLength)[0]
gateway = @($_.IPv4DefaultGateway | Select-Object -ExpandProperty NextHop)[0]
dns = @($_.DNSServer.ServerAddresses)
ipv4 = @($ipconfig.IPv4Address | Select-Object -ExpandProperty IPAddress)[0]
ipv6 = @($ipconfig.IPv6Address | Select-Object -ExpandProperty IPAddress)
prefix_length = @($ipconfig.IPv4Address | Select-Object -ExpandProperty PrefixLength)[0]
gateway = @($ipconfig.IPv4DefaultGateway | Select-Object -ExpandProperty NextHop)[0]
dns = $dns
dhcp_server = if ($cim) { $cim.DHCPServer } else { "" }
dhcp_lease_obtained = if ($cim) { [string]$cim.DHCPLeaseObtained } else { "" }
dhcp_lease_expires = if ($cim) { [string]$cim.DHCPLeaseExpires } else { "" }
dhcp_enabled = if ($ipif) { [string]$ipif.Dhcp -eq "Enabled" } else { $false }
dhcp_enabled = if ($ipif) { [string]$ipif.Dhcp -eq "Enabled" } elseif ($cim) { [bool]$cim.DHCPEnabled } else { $false }
}
}
$items | ConvertTo-Json -Depth 5 -Compress
"""
result = run_hidden(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], timeout=15)
result = run_hidden(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], timeout=30)
if result.returncode != 0:
raise RuntimeError(result.stdout.strip() or "PowerShell 获取网卡信息失败")
@@ -60,6 +82,8 @@ $items | ConvertTo-Json -Depth 5 -Compress
return self._get_network_info_from_ipconfig()
data = json.loads(output[json_start:])
if data is None:
return []
if isinstance(data, dict):
data = [data]
@@ -263,6 +287,54 @@ $items | ConvertTo-Json -Depth 5 -Compress
self.output("静态 IPv4 配置已应用\n", "success")
def set_dns_servers(self, name: str, dns_servers: list[str]) -> None:
adapter_name = name.strip()
if not adapter_name:
raise ValueError("请选择网卡")
servers = [validate_ip(server) for server in dns_servers if str(server).strip()]
if not servers:
raise ValueError("请输入至少一个 DNS 服务器")
self._run_netsh(
[
"interface",
"ip",
"set",
"dnsservers",
f"name={adapter_name}",
"source=static",
f"address={servers[0]}",
"index=1",
]
)
for index, server in enumerate(servers[1:], start=2):
self._run_netsh(["interface", "ip", "add", "dnsservers", f"name={adapter_name}", f"address={server}", f"index={index}"])
self.output(f"已设置 DNS: {adapter_name} -> {', '.join(servers)}\n", "success")
def flush_dns_cache(self) -> None:
result = run_hidden(["ipconfig", "/flushdns"], timeout=10)
if result.returncode != 0:
raise RuntimeError(result.stdout.strip() or "刷新 DNS 缓存失败")
self.output("已刷新 DNS 缓存\n", "success")
def set_adapter_enabled(self, name: str, enabled: bool) -> None:
adapter_name = name.strip()
if not adapter_name:
raise ValueError("请选择网卡")
action = "启用" if enabled else "禁用"
self.output(f"准备{action}网卡: {adapter_name}\n", "warning")
command = "Enable-NetAdapter" if enabled else "Disable-NetAdapter"
script = f"""
$ErrorActionPreference = "Stop"
{command} -Name {self._ps_quote(adapter_name)} -Confirm:$false
"""
result = run_hidden(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], timeout=20)
if result.returncode != 0:
raise RuntimeError(result.stdout.strip() or f"{action}网卡失败,请确认已用管理员权限运行")
self.output(f"网卡已{action}: {adapter_name}\n", "success")
def load_profiles(self) -> dict:
if not os.path.exists(self.profiles_file):
return {}
@@ -301,3 +373,6 @@ $items | ConvertTo-Json -Depth 5 -Compress
if result.returncode != 0:
message = (result.stdout + result.stderr).strip()
raise RuntimeError(message or "netsh 命令执行失败,请确认已用管理员权限运行")
def _ps_quote(self, value: str) -> str:
return "'" + value.replace("'", "''") + "'"