This commit is contained in:
qsc
2026-06-06 12:34:35 +08:00
parent 00dd907f65
commit 2d67146487
10 changed files with 1682 additions and 73 deletions
+7
View File
@@ -24,6 +24,13 @@ dist/
*.spec *.spec
*.manifest *.manifest
# Packaged binaries and release archives
*.exe
*.msi
*.7z
*.rar
*.zip
# Local runtime data # Local runtime data
network_profiles.json network_profiles.json
*.db *.db
+2
View File
@@ -6,11 +6,13 @@
- 网卡配置:读取网卡、IPv4/IPv6、网关、DNS、DHCP、链路速率、接口索引、DHCP 服务器与租约时间。 - 网卡配置:读取网卡、IPv4/IPv6、网关、DNS、DHCP、链路速率、接口索引、DHCP 服务器与租约时间。
- 配置模板:保存常用网卡配置、套用模板到表单、删除模板。 - 配置模板:保存常用网卡配置、套用模板到表单、删除模板。
- DNS 诊断:读取本机 DNS 并补充常用 DNS 服务器做对比,支持多域名、多记录类型解析、耗时/失败原因统计、诊断摘要复制与 CSV 导出。
- Ping 探测:支持持续/指定次数、间隔、超时、包大小、TTL、禁止分片、本地源 IP 下拉、CIDR/范围/列表批量探活、目标导入、CSV 导出、实时丢包率/平均延迟/抖动/质量统计。 - Ping 探测:支持持续/指定次数、间隔、超时、包大小、TTL、禁止分片、本地源 IP 下拉、CIDR/范围/列表批量探活、目标导入、CSV 导出、实时丢包率/平均延迟/抖动/质量统计。
- 端口扫描:支持单端口测试、端口列表/范围、常用端口预设、批量主机/CIDR/IP 段扫描、并发/超时控制、只显示开放端口、服务名识别、可选 Banner 探测、开放端口复制与 CSV 导出。 - 端口扫描:支持单端口测试、端口列表/范围、常用端口预设、批量主机/CIDR/IP 段扫描、并发/超时控制、只显示开放端口、服务名识别、可选 Banner 探测、开放端口复制与 CSV 导出。
- 路由追踪:支持 IPv4/IPv6、主机名解析开关、单次/指定次数/持续追踪、最大跳数、单跳超时、高延迟阈值、逐跳 RTT 表格、超时/高延迟/波动诊断、原始输出保留、诊断摘要复制与 CSV 导出。 - 路由追踪:支持 IPv4/IPv6、主机名解析开关、单次/指定次数/持续追踪、最大跳数、单跳超时、高延迟阈值、逐跳 RTT 表格、超时/高延迟/波动诊断、原始输出保留、诊断摘要复制与 CSV 导出。
- 环网检测:基于本机网卡统计、广播/组播流量、ARP/邻居表变化和网关 Ping 稳定性,判断疑似二层环路/广播风暴风险,支持单网卡/全部活动网卡、诊断摘要复制与 CSV 导出。 - 环网检测:基于本机网卡统计、广播/组播流量、ARP/邻居表变化和网关 Ping 稳定性,判断疑似二层环路/广播风暴风险,支持单网卡/全部活动网卡、诊断摘要复制与 CSV 导出。
- IP 冲突:支持本机 IP 冲突检测与安全限速网段扫描,结合地址状态、Tcpip 系统事件、ARP/邻居表和 IP/MAC 异常判断冲突风险,支持摘要复制与 CSV 导出。 - IP 冲突:支持本机 IP 冲突检测与安全限速网段扫描,结合地址状态、Tcpip 系统事件、ARP/邻居表和 IP/MAC 异常判断冲突风险,支持摘要复制与 CSV 导出。
- 设备发现:支持当前网卡安全限速局域网扫描,发现在线/ARP 可见设备,展示 IP、MAC、主机名、厂商、来源网卡、延迟与发现方式,支持设备清单复制与 CSV 导出。
## 运行 ## 运行
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

+618
View File
@@ -0,0 +1,618 @@
import concurrent.futures
import csv
import ipaddress
import json
import re
import socket
import threading
import time
from dataclasses import asdict, dataclass, field
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
OutputCallback = Callable[[str, Optional[str]], None]
DoneCallback = Callable[[], None]
StatusCallback = Callable[[dict], None]
ResultCallback = Callable[[dict], None]
ALL_ADAPTERS = "全部活动网卡"
OUI_VENDOR_MAP = {
"00-05-69": "VMware",
"00-0C-29": "VMware",
"00-1C-14": "VMware",
"00-50-56": "VMware",
"00-15-5D": "Microsoft Hyper-V",
"00-03-FF": "Microsoft",
"00-1A-A0": "Dell",
"00-1B-21": "Intel",
"00-1E-67": "Intel",
"00-21-5C": "Intel",
"00-24-D7": "Intel",
"3C-A8-2A": "Intel",
"48-7D-2E": "TP-Link",
"50-C7-BF": "TP-Link",
"60-E3-27": "TP-Link",
"A0-F3-C1": "TP-Link",
"B0-A7-B9": "TP-Link",
"D8-07-B6": "TP-Link",
"00-1D-0F": "Cisco",
"00-22-BD": "Cisco",
"00-25-9C": "Cisco",
"A4-18-75": "Cisco",
"F4-4E-05": "Cisco",
"00-16-EA": "HPE",
"00-1F-29": "HPE",
"3C-A8-2A": "HPE/Intel",
"00-1E-C2": "Apple",
"00-25-00": "Apple",
"3C-15-C2": "Apple",
"7C-D1-C3": "Apple",
"A4-C3-F0": "Apple",
"F0-18-98": "Apple",
"18-C0-4D": "Realtek",
"52-54-00": "QEMU/KVM",
"08-00-27": "VirtualBox",
"BC-24-11": "Proxmox/QEMU",
}
@dataclass
class DeviceDiscoveryOptions:
scan_range: str = ""
workers: int = 64
timeout_ms: int = 500
max_hosts: int = 254
@dataclass
class DeviceInfo:
ip: str
mac: str
hostname: str
vendor: str
adapter: str
interface_index: str
latency_ms: float = 0.0
method: str = "ARP 可见"
note: str = ""
checked_at: str = field(default_factory=lambda: time.strftime("%Y-%m-%d %H:%M:%S"))
def to_dict(self) -> dict:
data = asdict(self)
data["latency_ms"] = round(self.latency_ms, 1)
return data
class DeviceDiscovery:
def __init__(
self,
output: OutputCallback,
done: Optional[DoneCallback] = None,
status: Optional[StatusCallback] = None,
result: Optional[ResultCallback] = None,
):
self.output = output
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.stop_event = threading.Event()
self.worker = None
self.last_results: list[dict] = []
self.last_summary = ""
def get_adapter_choices(self) -> list[str]:
adapters = self._active_adapters(self.network.get_network_info())
return [ALL_ADAPTERS] + [adapter["name"] for adapter in adapters]
def default_scan_range(self, adapter_name: str = ALL_ADAPTERS) -> str:
adapters = self._select_adapters(adapter_name)
if not adapters:
return ""
return adapter_to_safe_range(adapters[0])
def start_discovery(self, adapter_name: str = ALL_ADAPTERS, options: Optional[dict] = None) -> None:
if self.is_running():
raise RuntimeError("设备发现正在运行,请先停止当前任务")
discovery_options = self.normalize_options(options)
adapters = self._select_adapters(adapter_name)
if not adapters:
raise ValueError("没有找到可扫描的活动网卡")
targets = self.build_scan_targets(adapters, discovery_options)
if not targets:
raise ValueError("没有可扫描的目标地址")
self.stop_event.clear()
self.last_results = []
self.last_summary = ""
self.output(
f"开始局域网设备发现: {adapter_name or ALL_ADAPTERS}"
f"目标 {len(targets)} 个,并发 {discovery_options.workers},超时 {discovery_options.timeout_ms}ms\n",
"muted",
)
self.status(self._status("扫描中", adapter_name or ALL_ADAPTERS, describe_targets(targets), len(targets), 0, 0, 0))
self.worker = threading.Thread(target=self._run_discovery, args=(adapters, targets, discovery_options), daemon=True)
self.worker.start()
def _run_discovery(self, adapters: list[dict], targets: list[str], options: DeviceDiscoveryOptions) -> None:
started = time.perf_counter()
ping_results: dict[str, dict] = {}
try:
ping_results = self.scan_targets(targets, options, started)
neighbors = self.get_neighbors()
devices = self.build_devices(adapters, targets, ping_results, neighbors)
for index, device in enumerate(sorted(devices, key=lambda item: ip_sort_key(item.ip)), start=1):
if self.stop_event.is_set():
break
row = device.to_dict()
self.last_results.append(row)
self.result(row)
self.status(
self._status(
"汇总中",
device.adapter,
describe_targets(targets),
len(targets),
len(targets),
index,
time.perf_counter() - started,
)
)
self.last_summary = build_summary(self.last_results, stopped=self.stop_event.is_set())
if self.stop_event.is_set():
self.output("\n设备发现已停止\n", "warning")
else:
self.output("\n设备发现完成\n", "success")
self.output(self.last_summary + "\n", "success")
self.status(
self._status(
"已停止" if self.stop_event.is_set() else "已完成",
"",
describe_targets(targets),
len(targets),
len(targets),
len(self.last_results),
time.perf_counter() - started,
)
)
except Exception as exc:
self.output(f"\n设备发现失败: {exc}\n", "error")
self.status(self._status("失败", "", describe_targets(targets), len(targets), len(ping_results), 0, time.perf_counter() - started))
finally:
self.done()
def scan_targets(self, targets: list[str], options: DeviceDiscoveryOptions, started: float) -> dict[str, dict]:
results = {}
completed = 0
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:
futures = {executor.submit(task, ip): ip for ip in targets}
for future in concurrent.futures.as_completed(futures):
completed += 1
ip = futures[future]
if self.stop_event.is_set():
for item in futures:
item.cancel()
break
try:
result = future.result()
except Exception:
result = {"ip": ip, "ok": False, "rtt": 0.0}
results[ip] = result
if completed % 20 == 0 or completed == len(targets):
self.status(self._status("扫描中", ip, describe_targets(targets), len(targets), completed, 0, time.perf_counter() - started))
return results
def build_devices(
self,
adapters: list[dict],
targets: list[str],
ping_results: dict[str, dict],
neighbors: list[dict],
) -> list[DeviceInfo]:
adapter_by_index = {str(adapter.get("interface_index", "")): adapter for adapter in adapters}
adapter_by_ip = {adapter.get("ipv4", ""): adapter for adapter in adapters}
target_set = set(targets)
local_ips = {adapter.get("ipv4", "") for adapter in adapters}
gateways = {adapter.get("gateway", "") for adapter in adapters if adapter.get("gateway")}
devices: dict[str, DeviceInfo] = {}
for adapter in adapters:
local_ip = adapter.get("ipv4", "")
if not local_ip:
continue
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", "")),
latency_ms=ping_results.get(local_ip, {}).get("rtt", 0.0),
method="在线" if ping_results.get(local_ip, {}).get("ok") else "本机",
note="本机",
)
for item in neighbors:
ip = item.get("ip", "")
mac = normalize_mac(item.get("mac", ""))
if ip not in target_set and ip not in gateways and ip not in local_ips:
continue
if not is_valid_device_mac(mac) or is_multicast_or_broadcast_ip(ip):
continue
adapter = adapter_by_index.get(str(item.get("ifIndex", ""))) or adapter_by_ip.get(item.get("interface_ip", "")) or adapter_for_ip(ip, adapters)
if not adapter:
continue
ping = ping_results.get(ip, {})
method = "在线" if ping.get("ok") else "ARP 可见"
note = []
if ip in local_ips:
note.append("本机")
if ip in gateways:
note.append("网关")
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", "")),
latency_ms=ping.get("rtt", 0.0),
method=method,
note="; ".join(note),
)
for ip, ping in ping_results.items():
if not ping.get("ok") or ip in devices:
continue
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 "本机",
)
return list(devices.values())
def get_neighbors(self) -> list[dict]:
script = r"""
$ErrorActionPreference = "SilentlyContinue"
Get-NetNeighbor -AddressFamily IPv4 | Select-Object ifIndex,IPAddress,LinkLayerAddress,State | ConvertTo-Json -Depth 4 -Compress
"""
try:
result = run_hidden(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], timeout=10)
if result.returncode != 0:
raise RuntimeError(result.stdout.strip())
data = extract_json(result.stdout)
if isinstance(data, dict):
data = [data]
return [normalize_neighbor(item) for item in data]
except Exception as exc:
self.output(f"PowerShell 邻居表读取失败,尝试 arp -a: {exc}\n", "warning")
return self.get_neighbors_from_arp()
def get_neighbors_from_arp(self) -> list[dict]:
result = run_hidden(["arp", "-a"], timeout=10)
if result.returncode != 0:
return []
neighbors = []
current_interface = ""
for line in result.stdout.splitlines():
header = re.search(r"Interface:\s+([^\s]+)", line, re.IGNORECASE)
if header:
current_interface = header.group(1)
continue
match = re.match(r"\s*(\d+\.\d+\.\d+\.\d+)\s+([0-9a-fA-F-]+)\s+(\w+)", line)
if match:
neighbors.append(
{
"ifIndex": "",
"interface_ip": current_interface,
"ip": match.group(1),
"mac": normalize_mac(match.group(2)),
"state": match.group(3),
}
)
return neighbors
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)
targets = []
for adapter in adapters:
targets.extend(parse_target_range(adapter_to_safe_range(adapter), options.max_hosts))
return list(dict.fromkeys(targets))[: options.max_hosts]
def stop_discovery(self) -> None:
if not self.is_running():
raise RuntimeError("当前没有正在运行的设备发现")
self.stop_event.set()
self.output("\n正在停止设备发现...\n", "warning")
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"]
with open(path, "w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(file, fieldnames=fields)
writer.writeheader()
for row in self.last_results:
writer.writerow({field_name: row.get(field_name, "") for field_name in fields})
def copy_summary(self) -> str:
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', '')}"
for row in sorted(self.last_results, key=lambda item: ip_sort_key(item.get("ip", "")))
)
def normalize_options(self, options: Optional[dict]) -> DeviceDiscoveryOptions:
options = options or {}
return DeviceDiscoveryOptions(
scan_range=str(options.get("scan_range", "")).strip(),
workers=clamp_int(options.get("workers", 64), 1, 256, "并发数"),
timeout_ms=clamp_int(options.get("timeout_ms", 500), 100, 10000, "超时"),
max_hosts=clamp_int(options.get("max_hosts", 254), 1, 254, "最大扫描地址数"),
)
def _select_adapters(self, adapter_name: str) -> list[dict]:
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]
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:
continue
active.append(adapter)
return active
def _status(self, state: str, current: str, scan_range: str, total: int, scanned: int, found: int, elapsed: float) -> dict:
return {
"state": state,
"current": current,
"scan_range": scan_range,
"total": total,
"scanned": scanned,
"found": found,
"elapsed": elapsed,
}
def is_running(self) -> bool:
return bool(self.worker and self.worker.is_alive())
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))
output = result.stdout
if re.search(r"\bTTL=", output, re.IGNORECASE):
match = re.search(r"(?:time|时间)[=<]?\s*(\d+(?:\.\d+)?)\s*(?:ms|毫秒)", output, re.IGNORECASE)
return {"ip": ip, "ok": True, "rtt": float(match.group(1)) if match else 0.0}
except Exception:
pass
return {"ip": ip, "ok": False, "rtt": 0.0}
def parse_target_range(text: str, max_hosts: int = 254) -> list[str]:
raw = text.strip()
if not raw:
return []
targets = []
for part in re.split(r"[,;\s]+", raw):
item = part.strip()
if not item:
continue
if "/" in item:
network = ipaddress.ip_network(item, strict=False)
targets.extend(str(ip) for ip in network.hosts())
elif re.match(r"^\d{1,3}(?:\.\d{1,3}){3}-\d{1,3}$", item):
prefix, tail = item.rsplit(".", 1)
start_text, end_text = tail.split("-", 1)
start = int(start_text)
end = int(end_text)
if start > end:
raise ValueError("IP 范围起始值不能大于结束值")
targets.extend(str(ipaddress.ip_address(f"{prefix}.{value}")) for value in range(start, end + 1))
elif re.match(r"^\d{1,3}(?:\.\d{1,3}){3}-\d{1,3}(?:\.\d{1,3}){3}$", item):
start_text, end_text = item.split("-", 1)
start_ip = ipaddress.ip_address(start_text)
end_ip = ipaddress.ip_address(end_text)
if start_ip.version != end_ip.version:
raise ValueError("IP 范围两端必须是同一 IP 版本")
if int(start_ip) > int(end_ip):
raise ValueError("IP 范围起始值不能大于结束值")
targets.extend(str(ipaddress.ip_address(value)) for value in range(int(start_ip), int(end_ip) + 1))
else:
targets.append(str(ipaddress.ip_address(item)))
return list(dict.fromkeys(targets))[:max_hosts]
def adapter_to_safe_range(adapter: dict) -> str:
ip = adapter.get("ipv4", "")
if not ip:
return ""
prefix = adapter.get("prefix_length")
if prefix in ("", None):
prefix = netmask_to_prefix(adapter.get("netmask", "")) or 24
try:
prefix = int(prefix)
if prefix < 24:
prefix = 24
return str(ipaddress.ip_network(f"{ip}/{prefix}", strict=False))
except Exception:
parts = ip.split(".")
return ".".join(parts[:3]) + ".0/24" if len(parts) == 4 else ""
def netmask_to_prefix(netmask: str) -> Optional[int]:
if not netmask:
return None
try:
return ipaddress.IPv4Network(f"0.0.0.0/{netmask}").prefixlen
except Exception:
return None
def normalize_neighbor(item: dict) -> dict:
return {
"ifIndex": str(item.get("ifIndex", "")),
"interface_ip": "",
"ip": str(item.get("IPAddress", "")),
"mac": normalize_mac(item.get("LinkLayerAddress", "")),
"state": str(item.get("State", "")),
}
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:
return "未知"
return OUI_VENDOR_MAP.get(normalized[:8], "未知")
def build_summary(rows: list[dict], stopped: bool = False) -> str:
if stopped:
return "设备发现已停止,当前结果仅代表已完成扫描。"
if not rows:
return "未发现有效局域网设备。"
online = len([row for row in rows if row.get("method") == "在线"])
arp = len([row for row in rows if row.get("method") == "ARP 可见"])
gateway = len([row for row in rows if "网关" in row.get("note", "")])
local = len([row for row in rows if "本机" in row.get("note", "")])
vendors = sorted({row.get("vendor", "未知") for row in rows if row.get("vendor") and row.get("vendor") != "未知"})
lines = [
"==== 局域网设备发现摘要 ====",
f"发现设备: {len(rows)} 在线: {online} ARP 可见: {arp} 本机: {local} 网关: {gateway}",
]
if vendors:
lines.append("识别厂商: " + ", ".join(vendors[:8]))
else:
lines.append("未识别到已知厂商,可能需要后续扩展 OUI 数据。")
return "\n".join(lines)
def describe_targets(targets: list[str]) -> str:
if not targets:
return ""
if len(targets) == 1:
return targets[0]
return f"{targets[0]} - {targets[-1]}"
def adapter_for_ip(ip: str, adapters: list[dict]) -> Optional[dict]:
try:
address = ipaddress.ip_address(ip)
except ValueError:
return None
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:
network = ipaddress.ip_network(f"{local_ip}/{prefix}", strict=False)
if address in network:
return adapter
except Exception:
continue
return adapters[0] if adapters else None
def is_valid_device_mac(mac: str) -> bool:
normalized = normalize_mac(mac)
if not normalized or normalized == "00-00-00-00-00-00":
return False
if normalized.startswith("FF-FF-FF") or normalized.startswith("01-00-5E"):
return False
return bool(re.match(r"^[0-9A-F]{2}(?:-[0-9A-F]{2}){5}$", normalized))
def is_multicast_or_broadcast_ip(ip: str) -> bool:
try:
parsed = ipaddress.ip_address(ip)
return parsed.is_multicast or str(parsed).endswith(".255") or str(parsed) == "255.255.255.255"
except ValueError:
return True
def ip_sort_key(value: str) -> tuple:
try:
return (0, int(ipaddress.ip_address(value)))
except ValueError:
return (1, value)
def extract_json(text: str):
output = text.strip()
json_start = min([idx for idx in (output.find("["), output.find("{")) if idx >= 0], default=-1)
if json_start < 0:
raise RuntimeError("未获取到 JSON 输出")
data = json.loads(output[json_start:])
return [] if data is None else data
def clamp_int(value, min_value: int, max_value: int, label: str) -> int:
try:
number = int(str(value).strip())
except ValueError as exc:
raise ValueError(f"{label}必须是整数") from exc
if number < min_value or number > max_value:
raise ValueError(f"{label}必须在 {min_value}-{max_value} 之间")
return number
+584
View File
@@ -0,0 +1,584 @@
import concurrent.futures
import csv
import ipaddress
import json
import re
import subprocess
import threading
import time
from dataclasses import asdict, dataclass, field
from typing import Callable, Optional
from core.Function.common import run_hidden
from core.Function.network_fun import NetworkManager
OutputCallback = Callable[[str, Optional[str]], None]
DoneCallback = Callable[[], None]
StatusCallback = Callable[[dict], None]
ResultCallback = Callable[[dict], None]
ALL_ADAPTERS = "全部活动网卡"
DEFAULT_DOMAINS = "www.baidu.com,www.qq.com"
DEFAULT_RECORD_TYPES = "A,AAAA,CNAME,MX,TXT"
PUBLIC_DNS_SERVERS = ["223.5.5.5", "114.114.114.114", "8.8.8.8"]
SUPPORTED_RECORD_TYPES = {"A", "AAAA", "CNAME", "MX", "TXT", "NS"}
@dataclass
class DnsDiagOptions:
domains: list[str] = field(default_factory=lambda: parse_list(DEFAULT_DOMAINS))
record_types: list[str] = field(default_factory=lambda: parse_record_types(DEFAULT_RECORD_TYPES))
dns_servers: list[str] = field(default_factory=list)
timeout_ms: int = 2000
repeat_count: int = 1
@dataclass
class DnsQueryResult:
domain: str
record_type: str
dns_server: str
adapter: str
status: str
elapsed_ms: float
values: str = ""
error: str = ""
verdict: str = ""
attempt: int = 1
resolver: str = "Resolve-DnsName"
checked_at: str = field(default_factory=lambda: time.strftime("%Y-%m-%d %H:%M:%S"))
def to_dict(self) -> dict:
data = asdict(self)
data["elapsed_ms"] = round(self.elapsed_ms, 1)
return data
class DnsDiagnostic:
def __init__(
self,
output: OutputCallback,
done: Optional[DoneCallback] = None,
status: Optional[StatusCallback] = None,
result: Optional[ResultCallback] = None,
):
self.output = output
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.stop_event = threading.Event()
self.worker = None
self.last_results: list[dict] = []
self.last_summary = ""
self.local_dns_servers: set[str] = set()
def get_adapter_choices(self) -> list[str]:
adapters = self._active_adapters(self.network.get_network_info())
return [ALL_ADAPTERS] + [adapter["name"] for adapter in adapters]
def default_dns_servers(self, adapter_name: str = ALL_ADAPTERS) -> str:
adapters = self._select_adapters(adapter_name)
servers = collect_adapter_dns(adapters)
return ",".join(list(dict.fromkeys(servers + PUBLIC_DNS_SERVERS)))
def start_diagnosis(self, adapter_name: str = ALL_ADAPTERS, options: Optional[dict] = None) -> None:
if self.is_running():
raise RuntimeError("DNS 诊断正在运行,请先停止当前任务")
adapters = self._select_adapters(adapter_name)
if not adapters:
raise ValueError("没有找到可用于 DNS 诊断的活动网卡")
diag_options = self.normalize_options(options, adapters)
tasks = build_tasks(diag_options)
if not tasks:
raise ValueError("没有可执行的 DNS 查询任务")
self.stop_event.clear()
self.last_results = []
self.last_summary = ""
self.local_dns_servers = set(collect_adapter_dns(adapters))
current_dns = ",".join(sorted(self.local_dns_servers)) or "未读取到本机 DNS"
self.output(
f"开始 DNS 诊断: {adapter_name or ALL_ADAPTERS}"
f"域名 {len(diag_options.domains)} 个,类型 {len(diag_options.record_types)} 个,"
f"DNS {len(diag_options.dns_servers)} 个,重复 {diag_options.repeat_count}\n",
"muted",
)
self.status(self._status("查询中", current_dns, len(tasks), 0, 0, 0))
self.worker = threading.Thread(
target=self._run_diagnosis,
args=(adapter_name or ALL_ADAPTERS, current_dns, tasks, diag_options),
daemon=True,
)
self.worker.start()
def _run_diagnosis(self, adapter_name: str, current_dns: str, tasks: list[dict], options: DnsDiagOptions) -> None:
started = time.perf_counter()
completed = 0
abnormal = 0
try:
workers = min(8, max(1, len(tasks)))
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
futures = {
executor.submit(self.query_one, adapter_name, task, options.timeout_ms): task
for task in tasks
}
for future in concurrent.futures.as_completed(futures):
if self.stop_event.is_set():
for item in futures:
item.cancel()
break
completed += 1
try:
row = future.result().to_dict()
except Exception as exc:
task = futures[future]
row = DnsQueryResult(
domain=task["domain"],
record_type=task["record_type"],
dns_server=task["dns_server"],
adapter=adapter_name,
status="失败",
elapsed_ms=0,
error=str(exc),
verdict="查询失败,建议与其它 DNS 对比",
attempt=task["attempt"],
).to_dict()
self.last_results.append(row)
if row.get("status") != "正常":
abnormal += 1
self.result(row)
self.status(self._status("查询中", current_dns, len(tasks), completed, abnormal, time.perf_counter() - started))
self.last_summary = build_summary(self.last_results, self.local_dns_servers, stopped=self.stop_event.is_set())
if self.stop_event.is_set():
self.output("\nDNS 诊断已停止\n", "warning")
else:
self.output("\nDNS 诊断完成\n", "success")
self.output(self.last_summary + "\n", "success")
self.status(
self._status(
"已停止" if self.stop_event.is_set() else "已完成",
current_dns,
len(tasks),
completed,
abnormal,
time.perf_counter() - started,
)
)
except Exception as exc:
self.output(f"\nDNS 诊断失败: {exc}\n", "error")
self.status(self._status("失败", current_dns, len(tasks), completed, abnormal, time.perf_counter() - started))
finally:
self.done()
def query_one(self, adapter_name: str, task: dict, timeout_ms: int) -> DnsQueryResult:
if self.stop_event.is_set():
return DnsQueryResult(
domain=task["domain"],
record_type=task["record_type"],
dns_server=task["dns_server"],
adapter=adapter_name,
status="失败",
elapsed_ms=0,
error="任务已停止",
verdict="任务停止,结果不完整",
attempt=task["attempt"],
)
started = time.perf_counter()
domain = task["domain"]
record_type = task["record_type"]
dns_server = task["dns_server"]
try:
values = resolve_with_powershell(domain, record_type, dns_server, timeout_ms)
resolver = "Resolve-DnsName"
except Exception as ps_error:
try:
values = resolve_with_nslookup(domain, record_type, dns_server, timeout_ms)
resolver = "nslookup"
except Exception as ns_error:
elapsed = (time.perf_counter() - started) * 1000
error = classify_error(f"{ps_error}; {ns_error}")
return DnsQueryResult(
domain=domain,
record_type=record_type,
dns_server=dns_server,
adapter=adapter_name,
status="失败",
elapsed_ms=elapsed,
error=error,
verdict=verdict_for_error(error),
attempt=task["attempt"],
resolver="fallback",
)
elapsed = (time.perf_counter() - started) * 1000
if values:
return DnsQueryResult(
domain=domain,
record_type=record_type,
dns_server=dns_server,
adapter=adapter_name,
status="正常",
elapsed_ms=elapsed,
values="; ".join(values),
verdict="解析成功",
attempt=task["attempt"],
resolver=resolver,
)
return DnsQueryResult(
domain=domain,
record_type=record_type,
dns_server=dns_server,
adapter=adapter_name,
status="无记录",
elapsed_ms=elapsed,
verdict="未返回该类型记录,建议结合其它记录类型判断",
attempt=task["attempt"],
resolver=resolver,
)
def stop_diagnosis(self) -> None:
if not self.is_running():
raise RuntimeError("当前没有正在运行的 DNS 诊断")
self.stop_event.set()
self.output("\n正在停止 DNS 诊断...\n", "warning")
def export_results(self, path: str) -> None:
if not self.last_results:
raise RuntimeError("还没有可导出的 DNS 诊断结果")
fields = [
"domain",
"record_type",
"dns_server",
"adapter",
"status",
"elapsed_ms",
"values",
"error",
"verdict",
"attempt",
"resolver",
"checked_at",
]
with open(path, "w", newline="", encoding="utf-8-sig") as file:
writer = csv.DictWriter(file, fieldnames=fields)
writer.writeheader()
for row in self.last_results:
writer.writerow({field_name: row.get(field_name, "") for field_name in fields})
def copy_summary(self) -> str:
return self.last_summary or "\n".join(
f"{row.get('domain', '')}\t{row.get('record_type', '')}\t{row.get('dns_server', '')}\t"
f"{row.get('status', '')}\t{row.get('elapsed_ms', '')}ms\t{row.get('values', '') or row.get('error', '')}"
for row in self.last_results
)
def normalize_options(self, options: Optional[dict], adapters: list[dict]) -> DnsDiagOptions:
options = options or {}
domains = parse_list(str(options.get("domains", "")).strip() or DEFAULT_DOMAINS)
record_types = parse_record_types(str(options.get("record_types", "")).strip() or DEFAULT_RECORD_TYPES)
dns_servers = parse_dns_servers(str(options.get("dns_servers", "")).strip())
if not dns_servers:
dns_servers = collect_adapter_dns(adapters) + PUBLIC_DNS_SERVERS
dns_servers = list(dict.fromkeys(dns_servers))
return DnsDiagOptions(
domains=domains,
record_types=record_types,
dns_servers=dns_servers,
timeout_ms=clamp_int(options.get("timeout_ms", 2000), 300, 10000, "超时"),
repeat_count=clamp_int(options.get("repeat_count", 1), 1, 5, "重复次数"),
)
def _select_adapters(self, adapter_name: str) -> list[dict]:
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]
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:
continue
active.append(adapter)
return active
def _status(self, state: str, current_dns: str, total: int, completed: int, abnormal: int, elapsed: float) -> dict:
return {
"state": state,
"current_dns": current_dns,
"total": total,
"completed": completed,
"abnormal": abnormal,
"elapsed": elapsed,
}
def is_running(self) -> bool:
return bool(self.worker and self.worker.is_alive())
def build_tasks(options: DnsDiagOptions) -> list[dict]:
tasks = []
for attempt in range(1, options.repeat_count + 1):
for domain in options.domains:
for record_type in options.record_types:
for dns_server in options.dns_servers:
tasks.append(
{
"domain": domain,
"record_type": record_type,
"dns_server": dns_server,
"attempt": attempt,
}
)
return tasks
def resolve_with_powershell(domain: str, record_type: str, dns_server: str, timeout_ms: int) -> list[str]:
script = f"""
$ErrorActionPreference = "Stop"
$items = Resolve-DnsName -Name {ps_quote(domain)} -Type {ps_quote(record_type)} -Server {ps_quote(dns_server)} -DnsOnly -ErrorAction Stop
$items | Select-Object Name,Type,QueryType,IPAddress,NameHost,NameExchange,Preference,Strings,CharacterStrings | ConvertTo-Json -Depth 5 -Compress
"""
result = run_hidden(
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
timeout=max(3, timeout_ms / 1000 + 2),
)
if result.returncode != 0:
raise RuntimeError(result.stdout.strip() or "Resolve-DnsName 查询失败")
data = extract_json(result.stdout)
if isinstance(data, dict):
data = [data]
return normalize_record_values(data)
def resolve_with_nslookup(domain: str, record_type: str, dns_server: str, timeout_ms: int) -> list[str]:
seconds = max(1, int(round(timeout_ms / 1000)))
try:
result = run_hidden(["nslookup", f"-timeout={seconds}", f"-type={record_type}", domain, dns_server], timeout=seconds + 3)
except subprocess.TimeoutExpired as exc:
raise RuntimeError("nslookup 查询超时") from exc
output = result.stdout.strip()
if result.returncode != 0 and not output:
raise RuntimeError("nslookup 查询失败")
if is_nslookup_error(output):
raise RuntimeError(output)
return parse_nslookup_output(output, record_type)
def normalize_record_values(items) -> list[str]:
values = []
for item in items or []:
if not isinstance(item, dict):
continue
if item.get("IPAddress"):
values.append(str(item["IPAddress"]))
if item.get("NameHost"):
values.append(str(item["NameHost"]).rstrip("."))
if item.get("NameExchange"):
exchange = str(item["NameExchange"]).rstrip(".")
preference = item.get("Preference")
values.append(f"{preference} {exchange}" if preference not in ("", None) else exchange)
for key in ("Strings", "CharacterStrings"):
text = item.get(key)
if isinstance(text, list):
values.append(" ".join(str(part) for part in text))
elif text:
values.append(str(text))
return sorted(dict.fromkeys(value.strip() for value in values if value and value.strip()))
def parse_nslookup_output(output: str, record_type: str) -> list[str]:
values = []
answer_started = False
for raw_line in output.splitlines():
line = raw_line.strip()
lower = line.lower()
if not line:
continue
if lower.startswith("name:") or line.startswith("名称:") or "canonical name =" in lower or "mail exchanger =" in lower:
answer_started = True
if record_type in {"A", "AAAA"}:
match = re.search(r"(?:address|addresses|地址)\s*:\s*([0-9a-fA-F:.]+)", line, re.IGNORECASE)
if match and answer_started:
value = match.group(1)
if ":" in value or "." in value:
values.append(value)
elif record_type == "CNAME":
match = re.search(r"canonical name\s*=\s*(.+)$", line, re.IGNORECASE)
if match:
values.append(match.group(1).strip().rstrip("."))
elif record_type == "MX":
match = re.search(r"mail exchanger\s*=\s*(.+)$", line, re.IGNORECASE)
if match:
values.append(match.group(1).strip().rstrip("."))
elif record_type == "TXT":
match = re.search(r'text\s*=\s*"?(.*?)"?$', line, re.IGNORECASE)
if match:
values.append(match.group(1).strip())
elif record_type == "NS":
match = re.search(r"nameserver\s*=\s*(.+)$", line, re.IGNORECASE)
if match:
values.append(match.group(1).strip().rstrip("."))
if answer_started and record_type in {"A", "AAAA"}:
bare_ip = re.match(r"^([0-9a-fA-F:.]+)$", line)
if bare_ip:
values.append(bare_ip.group(1))
return sorted(dict.fromkeys(value for value in values if value))
def build_summary(rows: list[dict], local_dns_servers: set[str], stopped: bool = False) -> str:
if stopped:
return "DNS 诊断已停止,当前结果仅代表已完成查询。"
if not rows:
return "DNS 诊断未产生结果。"
total = len(rows)
ok_rows = [row for row in rows if row.get("status") == "正常"]
failed_rows = [row for row in rows if row.get("status") != "正常"]
avg_elapsed = sum(float(row.get("elapsed_ms") or 0) for row in ok_rows) / len(ok_rows) if ok_rows else 0
lines = [
"==== DNS 诊断摘要 ====",
f"查询总数: {total} 成功: {len(ok_rows)} 异常: {len(failed_rows)} 成功平均耗时: {avg_elapsed:.1f} ms",
]
for (domain, record_type), group in group_by_domain_type(rows).items():
local = [row for row in group if row.get("dns_server") in local_dns_servers]
public = [row for row in group if row.get("dns_server") not in local_dns_servers]
local_ok = any(row.get("status") == "正常" for row in local)
public_ok = any(row.get("status") == "正常" for row in public)
any_ok = any(row.get("status") == "正常" for row in group)
if local and not local_ok and public_ok:
lines.append(f"{domain} {record_type}: 本机 DNS 失败但对比 DNS 成功,本机 DNS 服务器疑似异常。")
elif not any_ok:
lines.append(f"{domain} {record_type}: 所有 DNS 均未成功,可能域名不存在、网络不可达或上游 DNS 异常。")
elif values_are_inconsistent(group):
lines.append(f"{domain} {record_type}: 不同 DNS 返回结果不一致,可能存在 CDN、缓存、污染或策略差异。")
if len(lines) == 2:
lines.append("未发现明显 DNS 异常;如故障偶发,建议增加重复次数复测。")
return "\n".join(lines)
def group_by_domain_type(rows: list[dict]) -> dict[tuple[str, str], list[dict]]:
grouped: dict[tuple[str, str], list[dict]] = {}
for row in rows:
grouped.setdefault((row.get("domain", ""), row.get("record_type", "")), []).append(row)
return grouped
def values_are_inconsistent(rows: list[dict]) -> bool:
value_sets = {
tuple(sorted(value.strip() for value in str(row.get("values", "")).split(";") if value.strip()))
for row in rows
if row.get("status") == "正常" and row.get("values")
}
return len(value_sets) > 1
def collect_adapter_dns(adapters: list[dict]) -> list[str]:
servers = []
for adapter in adapters:
for key in ("dns1", "dns2"):
value = str(adapter.get(key, "")).strip()
if value and is_ip_address(value):
servers.append(value)
return list(dict.fromkeys(servers))
def parse_list(text: str, max_items: int = 20) -> list[str]:
items = [item.strip() for item in re.split(r"[,;\s]+", text.strip()) if item.strip()]
return list(dict.fromkeys(items))[:max_items]
def parse_record_types(text: str) -> list[str]:
values = [item.upper() for item in parse_list(text, 10)]
invalid = [item for item in values if item not in SUPPORTED_RECORD_TYPES]
if invalid:
raise ValueError("不支持的 DNS 记录类型: " + ", ".join(invalid))
return values
def parse_dns_servers(text: str) -> list[str]:
servers = []
for item in parse_list(text, 12):
if not is_ip_address(item):
raise ValueError(f"DNS 服务器必须是 IP 地址: {item}")
servers.append(item)
return list(dict.fromkeys(servers))
def classify_error(text: str) -> str:
clean = compact_text(text)
lower = clean.lower()
if "timed out" in lower or "timeout" in lower or "超时" in clean:
return "查询超时"
if "non-existent" in lower or "nxdomain" in lower or "不存在" in clean:
return "域名或记录不存在"
if "refused" in lower or "拒绝" in clean:
return "DNS 服务器拒绝查询"
if "server failed" in lower or "servfail" in lower:
return "上游 DNS 返回失败"
return clean[:240] if clean else "解析失败"
def verdict_for_error(error: str) -> str:
if "超时" in error:
return "查询超时,可能 DNS 服务器不可达或网络阻塞"
if "不存在" in error:
return "未查询到该记录,建议与其它 DNS 或记录类型对比"
if "拒绝" in error:
return "DNS 服务器拒绝查询,可能存在策略限制"
return "解析失败,建议与其它 DNS 对比"
def is_nslookup_error(output: str) -> bool:
lower = output.lower()
return any(token in lower for token in ("timed out", "can't find", "non-existent", "servfail", "refused"))
def is_ip_address(value: str) -> bool:
try:
ipaddress.ip_address(value)
return True
except ValueError:
return False
def extract_json(text: str):
output = text.strip()
if not output:
return []
json_start = min([idx for idx in (output.find("["), output.find("{")) if idx >= 0], default=-1)
if json_start < 0:
return []
data = json.loads(output[json_start:])
return [] if data is None else data
def ps_quote(value: str) -> str:
return "'" + str(value).replace("'", "''") + "'"
def compact_text(text: str) -> str:
return re.sub(r"\s+", " ", text or "").strip()
def clamp_int(value, min_value: int, max_value: int, label: str) -> int:
try:
number = int(str(value).strip())
except ValueError as exc:
raise ValueError(f"{label}必须是整数") from exc
if number < min_value or number > max_value:
raise ValueError(f"{label}必须在 {min_value}-{max_value} 之间")
return number
-71
View File
@@ -1,71 +0,0 @@
import tkinter as tk
from tkinter import ttk
class BasicUI:
"""旧版页面的兼容辅助类。
新界面主要使用 core.ui.components;保留这个类是为了兼容后续可能仍引用
add_input/add_combobox/add_button 的小工具页。
"""
def add_input(
self,
parent,
label_text,
row,
col=0,
inivar="",
label_width=8,
entry_width=20,
colspan=1,
sticky="w",
):
group_frame = ttk.Frame(parent)
group_frame.grid(row=row, column=col, columnspan=colspan, sticky=sticky, padx=5, pady=3)
label = ttk.Label(group_frame, text=f"{label_text}:", width=label_width, anchor="w")
label.grid(row=0, column=0, sticky="w", padx=(0, 5))
var = tk.StringVar(value=inivar)
entry = ttk.Entry(group_frame, textvariable=var, width=entry_width)
entry.grid(row=0, column=1, sticky="w")
return {"frame": group_frame, "label": label, "entry": entry, "var": var}
def add_combobox(
self,
parent,
label_text,
row,
col=0,
listbox=None,
inivar=0,
width=17,
colspan=1,
sticky="w",
label_width=8,
state="readonly",
):
values = listbox or []
frame = ttk.Frame(parent)
frame.grid(row=row, column=col, columnspan=colspan, sticky=sticky, padx=5, pady=3)
label = ttk.Label(frame, text=f"{label_text}:", width=label_width, anchor="w")
label.grid(row=0, column=0, sticky="w", padx=(0, 5))
var = tk.StringVar()
combobox = ttk.Combobox(frame, textvariable=var, values=values, width=width, state=state)
combobox.grid(row=0, column=1, sticky="w")
if values and inivar >= 0:
var.set(values[inivar])
return {"frame": frame, "label": label, "combobox": combobox, "var": var}
def add_button(self, parent, button_text, row, col=0, command=None, width=5, colspan=1, sticky="w"):
group_frame = ttk.Frame(parent)
group_frame.grid(row=row, column=col, columnspan=colspan, sticky=sticky, padx=5, pady=3)
btn = ttk.Button(group_frame, text=button_text, command=command, width=width)
btn.grid(row=0, column=0, sticky="w")
return {"frame": group_frame, "btn": btn}
+233
View File
@@ -0,0 +1,233 @@
import threading
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from core.Function.device_discovery_fun import ALL_ADAPTERS, DeviceDiscovery
from core.ui.components import Console, Page, action_bar, button, combo, field
class DeviceDiscoveryTab(Page):
def __init__(self, parent):
super().__init__(parent, "设备发现", "发现局域网在线与 ARP 可见设备,整理 IP、MAC、主机名、厂商与来源网卡。")
self.body.rowconfigure(3, weight=1)
status = self.section("实时状态", 0, columns=6)
self.state = field(status, "状态", 1, 0, "等待", 10)
self.current = field(status, "当前网卡", 1, 1, "-", 18)
self.scan_scope = field(status, "扫描范围", 1, 2, "-", 20)
self.progress = field(status, "进度", 1, 3, "0/0", 10)
self.found = field(status, "发现设备", 1, 4, "0", 8)
self.elapsed = field(status, "耗时", 1, 5, "0.0s", 10)
for item in (self.state, self.current, self.scan_scope, self.progress, self.found, self.elapsed):
item["entry"].configure(state="disabled")
params = self.section("发现参数", 1, columns=6)
self.adapter = combo(params, "检测网卡", 1, 0, [ALL_ADAPTERS], ALL_ADAPTERS, 22)
self.scan_range = field(params, "扫描范围", 1, 1, "", 34, colspan=2)
self.workers = field(params, "并发数", 1, 3, "64", 10)
self.timeout = field(params, "超时 ms", 1, 4, "500", 10)
self.max_hosts = field(params, "最大地址数", 1, 5, "254", 10)
actions = action_bar(params, 2, 6)
self.start_btn = button(actions, "开始发现", self.start_discovery, "Primary.TButton")
self.stop_btn = button(actions, "停止", self.stop_discovery, "Danger.TButton")
self.refresh_btn = button(actions, "刷新网卡", self.load_adapters, "Secondary.TButton")
self.auto_range_btn = button(actions, "自动范围", self.fill_default_range, "Secondary.TButton")
self.copy_btn = button(actions, "复制清单", self.copy_inventory, "Secondary.TButton")
self.export_btn = button(actions, "导出 CSV", self.export_results, "Secondary.TButton")
results = self.section("发现结果", 2, columns=1)
results.rowconfigure(1, weight=1)
results.columnconfigure(0, weight=1)
self.results_tree = ttk.Treeview(
results,
columns=("ip", "mac", "hostname", "vendor", "adapter", "latency", "method", "note"),
show="headings",
height=10,
)
headings = {
"ip": "IP",
"mac": "MAC",
"hostname": "主机名",
"vendor": "厂商",
"adapter": "来源网卡",
"latency": "延迟",
"method": "发现方式",
"note": "备注",
}
widths = {
"ip": 130,
"mac": 150,
"hostname": 180,
"vendor": 130,
"adapter": 150,
"latency": 85,
"method": 90,
"note": 130,
}
for column, title in headings.items():
self.results_tree.heading(column, text=title)
self.results_tree.column(column, width=widths[column], anchor="w")
self.results_tree.tag_configure("在线", foreground="#15803d")
self.results_tree.tag_configure("ARP 可见", foreground="#b7791f")
self.results_tree.tag_configure("本机", foreground="#246bfe")
self.results_tree.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18))
result_scroll = ttk.Scrollbar(results, orient="vertical", command=self.results_tree.yview)
result_scroll.grid(row=1, column=1, sticky="ns", pady=(0, 18))
self.results_tree.configure(yscrollcommand=result_scroll.set)
output = self.section("发现控制台", 3, columns=1)
output.rowconfigure(1, weight=1)
output.columnconfigure(0, weight=1)
self.console = Console(output, height=12)
self.console.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18))
self.discovery = DeviceDiscovery(self.write, self.on_task_done, self.update_status, self.add_result)
self.after(350, self.load_adapters)
def write(self, text, tag=None):
self.console.write(text, tag)
def clear(self):
self.console.clear()
for item in self.results_tree.get_children():
self.results_tree.delete(item)
self.update_status(
{
"state": "等待",
"current": "-",
"scan_range": "-",
"total": 0,
"scanned": 0,
"found": 0,
"elapsed": 0,
}
)
def load_adapters(self):
def worker():
try:
values = self.discovery.get_adapter_choices()
default_range = self.discovery.default_scan_range(ALL_ADAPTERS)
except Exception as exc:
values = [ALL_ADAPTERS]
default_range = ""
self.write(f"读取网卡失败: {exc}\n", "warning")
self.after(0, lambda: self.apply_adapters(values, default_range))
threading.Thread(target=worker, daemon=True).start()
def apply_adapters(self, values, default_range):
values = values or [ALL_ADAPTERS]
values = list(dict.fromkeys(values))
self.adapter["combobox"]["values"] = values
if self.adapter["var"].get() not in values:
self.adapter["var"].set(values[1] if len(values) > 1 else values[0])
if not self.scan_range["var"].get() and default_range:
self.scan_range["var"].set(default_range)
def fill_default_range(self):
try:
value = self.discovery.default_scan_range(self.adapter["var"].get())
if not value:
messagebox.showinfo("提示", "未能根据当前网卡生成扫描范围")
return
self.scan_range["var"].set(value)
self.write(f"已生成安全扫描范围: {value}\n", "success")
except Exception as exc:
messagebox.showwarning("生成失败", str(exc))
def start_discovery(self):
try:
self.clear()
self.start_btn.configure(state="disabled")
self.discovery.start_discovery(self.adapter["var"].get(), self.options())
except Exception as exc:
self.start_btn.configure(state="normal")
messagebox.showwarning("无法开始设备发现", str(exc))
def stop_discovery(self):
try:
self.discovery.stop_discovery()
except Exception as exc:
messagebox.showinfo("提示", str(exc))
def options(self):
return {
"scan_range": self.scan_range["var"].get(),
"workers": self.workers["var"].get(),
"timeout_ms": self.timeout["var"].get(),
"max_hosts": self.max_hosts["var"].get(),
}
def export_results(self):
path = filedialog.asksaveasfilename(
title="导出设备发现结果",
defaultextension=".csv",
filetypes=[("CSV 文件", "*.csv")],
)
if not path:
return
try:
self.discovery.export_results(path)
self.write(f"已导出结果: {path}\n", "success")
except Exception as exc:
messagebox.showwarning("导出失败", str(exc))
def copy_inventory(self):
text = self.discovery.copy_summary()
if not text:
messagebox.showinfo("提示", "还没有可复制的设备清单")
return
self.clipboard_clear()
self.clipboard_append(text)
self.write("已复制设备清单到剪贴板\n", "success")
def update_status(self, stats):
def apply():
total = stats.get("total", 0)
scanned = stats.get("scanned", 0)
values = [
(self.state, stats.get("state", "等待")),
(self.current, stats.get("current") or "-"),
(self.scan_scope, stats.get("scan_range") or "-"),
(self.progress, f"{scanned}/{total}" if total else "0/0"),
(self.found, str(stats.get("found", 0))),
(self.elapsed, f"{stats.get('elapsed', 0):.1f}s"),
]
for item, value in values:
item["entry"].configure(state="normal")
item["var"].set(value)
item["entry"].configure(state="disabled")
self.after(0, apply)
def add_result(self, row):
def apply():
method = row.get("method", "")
note = row.get("note", "")
tag = "本机" if "本机" in note else method
latency = row.get("latency_ms", 0)
try:
latency_text = f"{float(latency):.1f} ms" if float(latency) > 0 else ""
except (TypeError, ValueError):
latency_text = ""
self.results_tree.insert(
"",
"end",
values=(
row.get("ip", ""),
row.get("mac", ""),
row.get("hostname", ""),
row.get("vendor", ""),
row.get("adapter", ""),
latency_text,
method,
note,
),
tags=(tag,),
)
self.after(0, apply)
def on_task_done(self):
self.after(0, lambda: self.start_btn.configure(state="normal"))
+229
View File
@@ -0,0 +1,229 @@
import threading
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from core.Function.dns_diag_fun import ALL_ADAPTERS, DEFAULT_DOMAINS, DEFAULT_RECORD_TYPES, DnsDiagnostic
from core.ui.components import Console, Page, action_bar, button, combo, field
class DnsTab(Page):
def __init__(self, parent):
super().__init__(parent, "DNS 诊断", "对比本机 DNS 与常用 DNS 的解析结果、耗时和失败原因。")
self.body.rowconfigure(3, weight=1)
status = self.section("实时状态", 0, columns=6)
self.state = field(status, "状态", 1, 0, "等待", 10)
self.current_dns = field(status, "当前 DNS", 1, 1, "-", 24, colspan=2)
self.target_total = field(status, "目标数", 1, 3, "0", 8)
self.progress = field(status, "进度", 1, 4, "0/0", 10)
self.abnormal = field(status, "异常数", 1, 5, "0", 8)
self.elapsed = field(status, "耗时", 2, 0, "0.0s", 10)
for item in (self.state, self.current_dns, self.target_total, self.progress, self.abnormal, self.elapsed):
item["entry"].configure(state="disabled")
params = self.section("诊断参数", 1, columns=6)
self.adapter = combo(params, "检测网卡", 1, 0, [ALL_ADAPTERS], ALL_ADAPTERS, 22)
self.domains = field(params, "目标域名", 1, 1, DEFAULT_DOMAINS, 34, colspan=2)
self.record_types = field(params, "记录类型", 1, 3, DEFAULT_RECORD_TYPES, 18)
self.timeout = field(params, "超时 ms", 1, 4, "2000", 10)
self.repeat_count = field(params, "重复次数", 1, 5, "1", 10)
self.dns_servers = field(params, "DNS 服务器", 2, 0, "", 60, colspan=6)
actions = action_bar(params, 3, 6)
self.start_btn = button(actions, "开始诊断", self.start_diagnosis, "Primary.TButton")
self.stop_btn = button(actions, "停止", self.stop_diagnosis, "Danger.TButton")
self.refresh_btn = button(actions, "刷新网卡", self.load_adapters, "Secondary.TButton")
self.auto_dns_btn = button(actions, "自动 DNS", self.fill_default_dns, "Secondary.TButton")
self.copy_btn = button(actions, "复制摘要", self.copy_summary, "Secondary.TButton")
self.export_btn = button(actions, "导出 CSV", self.export_results, "Secondary.TButton")
results = self.section("诊断结果", 2, columns=1)
results.rowconfigure(1, weight=1)
results.columnconfigure(0, weight=1)
self.results_tree = ttk.Treeview(
results,
columns=("domain", "type", "server", "status", "elapsed", "values", "verdict"),
show="headings",
height=10,
)
headings = {
"domain": "域名",
"type": "类型",
"server": "DNS 服务器",
"status": "状态",
"elapsed": "耗时",
"values": "解析结果",
"verdict": "错误 / 判断",
}
widths = {
"domain": 160,
"type": 70,
"server": 130,
"status": 80,
"elapsed": 85,
"values": 300,
"verdict": 360,
}
for column, title in headings.items():
self.results_tree.heading(column, text=title)
self.results_tree.column(column, width=widths[column], anchor="w")
self.results_tree.tag_configure("正常", foreground="#15803d")
self.results_tree.tag_configure("无记录", foreground="#b7791f")
self.results_tree.tag_configure("失败", foreground="#dc2626")
self.results_tree.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18))
result_scroll = ttk.Scrollbar(results, orient="vertical", command=self.results_tree.yview)
result_scroll.grid(row=1, column=1, sticky="ns", pady=(0, 18))
self.results_tree.configure(yscrollcommand=result_scroll.set)
output = self.section("诊断控制台", 3, columns=1)
output.rowconfigure(1, weight=1)
output.columnconfigure(0, weight=1)
self.console = Console(output, height=12)
self.console.grid(row=1, column=0, sticky="nsew", padx=18, pady=(0, 18))
self.diagnostic = DnsDiagnostic(self.write, self.on_task_done, self.update_status, self.add_result)
self.after(350, self.load_adapters)
def write(self, text, tag=None):
self.console.write(text, tag)
def clear(self):
self.console.clear()
for item in self.results_tree.get_children():
self.results_tree.delete(item)
self.update_status(
{
"state": "等待",
"current_dns": "-",
"total": 0,
"completed": 0,
"abnormal": 0,
"elapsed": 0,
}
)
def load_adapters(self):
def worker():
try:
values = self.diagnostic.get_adapter_choices()
default_dns = self.diagnostic.default_dns_servers(values[1] if len(values) > 1 else ALL_ADAPTERS)
except Exception as exc:
values = [ALL_ADAPTERS]
default_dns = ""
self.write(f"读取网卡 DNS 失败: {exc}\n", "warning")
self.after(0, lambda: self.apply_adapters(values, default_dns))
threading.Thread(target=worker, daemon=True).start()
def apply_adapters(self, values, default_dns):
values = values or [ALL_ADAPTERS]
values = list(dict.fromkeys(values))
self.adapter["combobox"]["values"] = values
if self.adapter["var"].get() not in values:
self.adapter["var"].set(values[1] if len(values) > 1 else values[0])
if not self.dns_servers["var"].get() and default_dns:
self.dns_servers["var"].set(default_dns)
def fill_default_dns(self):
try:
value = self.diagnostic.default_dns_servers(self.adapter["var"].get())
if not value:
messagebox.showinfo("提示", "未读取到本机 DNS,已保留常用 DNS 对比服务器")
value = "223.5.5.5,114.114.114.114,8.8.8.8"
self.dns_servers["var"].set(value)
self.write(f"已生成 DNS 对比列表: {value}\n", "success")
except Exception as exc:
messagebox.showwarning("生成失败", str(exc))
def start_diagnosis(self):
try:
self.clear()
self.start_btn.configure(state="disabled")
self.diagnostic.start_diagnosis(self.adapter["var"].get(), self.options())
except Exception as exc:
self.start_btn.configure(state="normal")
messagebox.showwarning("无法开始 DNS 诊断", str(exc))
def stop_diagnosis(self):
try:
self.diagnostic.stop_diagnosis()
except Exception as exc:
messagebox.showinfo("提示", str(exc))
def options(self):
return {
"domains": self.domains["var"].get(),
"record_types": self.record_types["var"].get(),
"dns_servers": self.dns_servers["var"].get(),
"timeout_ms": self.timeout["var"].get(),
"repeat_count": self.repeat_count["var"].get(),
}
def export_results(self):
path = filedialog.asksaveasfilename(
title="导出 DNS 诊断结果",
defaultextension=".csv",
filetypes=[("CSV 文件", "*.csv")],
)
if not path:
return
try:
self.diagnostic.export_results(path)
self.write(f"已导出结果: {path}\n", "success")
except Exception as exc:
messagebox.showwarning("导出失败", str(exc))
def copy_summary(self):
text = self.diagnostic.copy_summary()
if not text:
messagebox.showinfo("提示", "还没有可复制的 DNS 诊断摘要")
return
self.clipboard_clear()
self.clipboard_append(text)
self.write("已复制 DNS 诊断摘要到剪贴板\n", "success")
def update_status(self, stats):
def apply():
total = stats.get("total", 0)
completed = stats.get("completed", 0)
values = [
(self.state, stats.get("state", "等待")),
(self.current_dns, stats.get("current_dns") or "-"),
(self.target_total, str(total)),
(self.progress, f"{completed}/{total}" if total else "0/0"),
(self.abnormal, str(stats.get("abnormal", 0))),
(self.elapsed, f"{stats.get('elapsed', 0):.1f}s"),
]
for item, value in values:
item["entry"].configure(state="normal")
item["var"].set(value)
item["entry"].configure(state="disabled")
self.after(0, apply)
def add_result(self, row):
def apply():
elapsed = row.get("elapsed_ms", 0)
try:
elapsed_text = f"{float(elapsed):.1f} ms" if float(elapsed) > 0 else ""
except (TypeError, ValueError):
elapsed_text = ""
detail = row.get("error") or row.get("verdict", "")
self.results_tree.insert(
"",
"end",
values=(
row.get("domain", ""),
row.get("record_type", ""),
row.get("dns_server", ""),
row.get("status", ""),
elapsed_text,
row.get("values", ""),
detail,
),
tags=(row.get("status", ""),),
)
self.after(0, apply)
def on_task_done(self):
self.after(0, lambda: self.start_btn.configure(state="normal"))
+6
View File
@@ -1,6 +1,8 @@
import tkinter as tk import tkinter as tk
from tkinter import ttk from tkinter import ttk
from core.ui.tab_device_discovery import DeviceDiscoveryTab
from core.ui.tab_dns import DnsTab
from core.ui.tab_ip_conflict import IpConflictTab from core.ui.tab_ip_conflict import IpConflictTab
from core.ui.tab_loop import LoopTab from core.ui.tab_loop import LoopTab
from core.ui.tab_network import NetworkTab from core.ui.tab_network import NetworkTab
@@ -61,6 +63,8 @@ class MainUI:
items = [ items = [
("network", "", "网卡配置", "IP / DNS / DHCP"), ("network", "", "网卡配置", "IP / DNS / DHCP"),
("dns", "", "DNS 诊断", "解析与服务器对比"),
("devices", "", "设备发现", "局域网资产"),
("ping", "", "Ping 探测", "单点与批量探活"), ("ping", "", "Ping 探测", "单点与批量探活"),
("ports", "", "端口扫描", "TCP 连通性检测"), ("ports", "", "端口扫描", "TCP 连通性检测"),
("trace", "", "路由追踪", "跳点路径分析"), ("trace", "", "路由追踪", "跳点路径分析"),
@@ -104,11 +108,13 @@ class MainUI:
def _build_pages(self) -> None: def _build_pages(self) -> None:
self.pages = { self.pages = {
"network": NetworkTab(self.content), "network": NetworkTab(self.content),
"dns": DnsTab(self.content),
"ping": PingTab(self.content), "ping": PingTab(self.content),
"ports": TelnetTab(self.content), "ports": TelnetTab(self.content),
"trace": TracertTab(self.content), "trace": TracertTab(self.content),
"loop": LoopTab(self.content), "loop": LoopTab(self.content),
"ip_conflict": IpConflictTab(self.content), "ip_conflict": IpConflictTab(self.content),
"devices": DeviceDiscoveryTab(self.content),
} }
for page in self.pages.values(): for page in self.pages.values():
page.grid(row=0, column=0, sticky="nsew") page.grid(row=0, column=0, sticky="nsew")
+3 -2
View File
@@ -3,6 +3,7 @@ setlocal
set "PYTHON_EXE=C:\Users\Administrator\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" set "PYTHON_EXE=C:\Users\Administrator\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe"
set "PYINSTALLER_PATH=%CD%\.packaging\pyinstaller" set "PYINSTALLER_PATH=%CD%\.packaging\pyinstaller"
set "APP_ICON=%CD%\assets\app.ico"
if exist "%PYTHON_EXE%" ( if exist "%PYTHON_EXE%" (
if not exist "%PYINSTALLER_PATH%\PyInstaller\__main__.py" ( if not exist "%PYINSTALLER_PATH%\PyInstaller\__main__.py" (
@@ -10,9 +11,9 @@ if exist "%PYTHON_EXE%" (
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
) )
set "PYTHONPATH=%PYINSTALLER_PATH%" set "PYTHONPATH=%PYINSTALLER_PATH%"
"%PYTHON_EXE%" -m PyInstaller -F -w main.py -n NetworkTool "%PYTHON_EXE%" -m PyInstaller -F -w main.py -n NetworkTool --icon "%APP_ICON%"
) else ( ) else (
pyinstaller -F -w main.py -n NetworkTool pyinstaller -F -w main.py -n NetworkTool --icon "%APP_ICON%"
) )
endlocal endlocal