This commit is contained in:
qsc
2026-06-06 02:37:14 +08:00
parent 129dc41208
commit 00dd907f65
10 changed files with 2328 additions and 61 deletions
+696
View File
@@ -0,0 +1,696 @@
import concurrent.futures
import csv
import ipaddress
import json
import re
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 = "全部活动网卡"
MODE_BOTH = "两者都做"
MODE_LOCAL = "本机 IP 检测"
MODE_SUBNET = "网段扫描"
@dataclass
class IpConflictOptions:
mode: str = MODE_BOTH
scan_range: str = ""
workers: int = 64
timeout_ms: int = 500
max_hosts: int = 254
neighbor_samples: int = 3
@dataclass
class IpConflictEvidence:
address_state: str = ""
local_mac: str = ""
observed_macs: list[str] = field(default_factory=list)
conflict_macs: list[str] = field(default_factory=list)
tcpip_event_count: int = 0
gateway_mac_changes: int = 0
ip_mac_changes: int = 0
shared_mac_ips: int = 0
scanned_hosts: int = 0
notes: str = ""
def to_dict(self) -> dict:
data = asdict(self)
data["observed_macs"] = ", ".join(self.observed_macs)
data["conflict_macs"] = ", ".join(self.conflict_macs)
return data
@dataclass
class IpConflictResult:
adapter: str
ip: str
mac: str
conflict_macs: str
evidence_type: str
risk_level: str
verdict: str
evidence: IpConflictEvidence
checked_at: str = field(default_factory=lambda: time.strftime("%Y-%m-%d %H:%M:%S"))
def to_dict(self) -> dict:
data = {
"adapter": self.adapter,
"ip": self.ip,
"mac": self.mac,
"conflict_macs": self.conflict_macs,
"evidence_type": self.evidence_type,
"risk_level": self.risk_level,
"verdict": self.verdict,
"checked_at": self.checked_at,
}
data.update(self.evidence.to_dict())
return data
class IpConflictDetector:
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_detection(self, adapter_name: str = ALL_ADAPTERS, options: Optional[dict] = None) -> None:
if self.is_running():
raise RuntimeError("IP 冲突检测正在运行,请先停止当前任务")
conflict_options = self.normalize_options(options)
adapters = self._select_adapters(adapter_name)
if not adapters:
raise ValueError("没有找到可检测的活动网卡")
self.stop_event.clear()
self.last_results = []
self.last_summary = ""
self.output(f"开始 IP 冲突检测: {adapter_name or ALL_ADAPTERS},模式: {conflict_options.mode}\n", "muted")
self.output("说明: 检测结果基于本机可见 ARP/邻居表、地址状态和系统事件,不替代交换机侧排查。\n\n", "muted")
self.status(self._status("运行中", len(adapters), "", 0, 0, 0, 0))
self.worker = threading.Thread(target=self._run_detection, args=(adapters, conflict_options), daemon=True)
self.worker.start()
def _run_detection(self, adapters: list[dict], options: IpConflictOptions) -> None:
started = time.perf_counter()
completed = 0
max_risk = "正常"
try:
address_states = self.get_address_states()
events = self.get_tcpip_conflict_events()
neighbor_samples = [self.get_neighbors()]
if options.mode in (MODE_BOTH, MODE_SUBNET):
targets = self.build_scan_targets(adapters, options)
self.output(f"准备刷新 ARP: {len(targets)} 个地址,并发 {options.workers},超时 {options.timeout_ms}ms\n", "muted")
self.status(self._status("扫描中", len(adapters), "", 0, len(targets), 0, time.perf_counter() - started))
self.refresh_arp_targets(targets, options)
neighbor_samples.append(self.get_neighbors())
for _index in range(max(options.neighbor_samples - len(neighbor_samples), 0)):
if self.stop_event.is_set():
break
self.stop_event.wait(0.3)
neighbor_samples.append(self.get_neighbors())
rows = []
for adapter in adapters:
if self.stop_event.is_set():
break
completed += 1
self.status(self._status("分析中", len(adapters), adapter["name"], 0, 0, completed, time.perf_counter() - started))
results = self.evaluate_adapter(adapter, address_states, events, neighbor_samples, options)
if not results:
results = [normal_result(adapter, "未发现本机 IP 冲突证据")]
for item in results:
row = item.to_dict()
rows.append(row)
self.last_results.append(row)
max_risk = worse_risk(max_risk, item.risk_level)
self.result(row)
self.last_summary = build_summary(rows, stopped=self.stop_event.is_set())
self.output("\nIP 冲突检测已停止\n" if self.stop_event.is_set() else "\nIP 冲突检测完成\n", "warning" if self.stop_event.is_set() else "success")
self.output(self.last_summary + "\n", "success" if max_risk == "正常" else "warning")
self.status(self._status("已停止" if self.stop_event.is_set() else "已完成", len(adapters), "", risk_rank(max_risk), 0, completed, time.perf_counter() - started))
except Exception as exc:
self.output(f"\nIP 冲突检测失败: {exc}\n", "error")
self.status(self._status("失败", len(adapters), "", 0, 0, completed, time.perf_counter() - started))
finally:
self.done()
def evaluate_adapter(
self,
adapter: dict,
address_states: dict,
events: list[dict],
neighbor_samples: list[list[dict]],
options: IpConflictOptions,
) -> list[IpConflictResult]:
local_mac = normalize_mac(adapter.get("mac", ""))
local_ip = adapter.get("ipv4", "")
gateway = adapter.get("gateway", "")
interface_index = str(adapter.get("interface_index", ""))
local_neighbors = filter_neighbors(neighbor_samples, interface_index, local_ip)
ip_to_macs, mac_to_ips = build_neighbor_maps(local_neighbors)
results = []
state = address_states.get(local_ip, "")
event_hits = event_count_for_ip(events, local_ip)
observed_macs = sorted(ip_to_macs.get(local_ip, set()))
conflict_macs = sorted([mac for mac in observed_macs if local_mac and mac != local_mac and not is_special_mac(mac)])
if state in {"Duplicate", "Tentative", "DadFailed"} or event_hits or conflict_macs:
evidence = IpConflictEvidence(
address_state=state,
local_mac=local_mac,
observed_macs=observed_macs,
conflict_macs=conflict_macs,
tcpip_event_count=event_hits,
scanned_hosts=options.max_hosts,
notes="本机 IP 冲突证据",
)
level = "冲突风险"
reasons = []
if state:
reasons.append(f"地址状态 {state}")
if event_hits:
reasons.append(f"系统 Tcpip 冲突事件 {event_hits}")
if conflict_macs:
reasons.append("本机 IP 被其它 MAC 响应")
results.append(
IpConflictResult(
adapter=adapter["name"],
ip=local_ip,
mac=local_mac,
conflict_macs=", ".join(conflict_macs),
evidence_type="本机 IP",
risk_level=level,
verdict="疑似本机 IP 冲突,请检查是否有其它设备使用相同地址。证据: " + "".join(reasons),
evidence=evidence,
)
)
multi_mac_ips = sorted(ip for ip, macs in ip_to_macs.items() if len([mac for mac in macs if not is_special_mac(mac)]) > 1)
for ip in multi_mac_ips:
if ip in {local_ip, gateway}:
continue
macs = sorted(ip_to_macs[ip])
evidence = IpConflictEvidence(
local_mac=local_mac,
observed_macs=macs,
conflict_macs=macs,
ip_mac_changes=1,
scanned_hosts=options.max_hosts,
notes="同一 IP 出现多个 MAC",
)
results.append(
IpConflictResult(
adapter=adapter["name"],
ip=ip,
mac="",
conflict_macs=", ".join(macs),
evidence_type="网段 IP",
risk_level="冲突风险",
verdict="同一 IP 在邻居表中出现多个 MAC,疑似 IP 地址冲突。",
evidence=evidence,
)
)
if gateway:
gateway_macs = sorted(ip_to_macs.get(gateway, set()))
if len([mac for mac in gateway_macs if not is_special_mac(mac)]) > 1:
evidence = IpConflictEvidence(
local_mac=local_mac,
observed_macs=gateway_macs,
conflict_macs=gateway_macs,
gateway_mac_changes=max(len(gateway_macs) - 1, 0),
scanned_hosts=options.max_hosts,
notes="网关 MAC 发生变化",
)
results.append(
IpConflictResult(
adapter=adapter["name"],
ip=gateway,
mac="",
conflict_macs=", ".join(gateway_macs),
evidence_type="网关",
risk_level="可疑",
verdict="网关 IP 对应多个 MAC,可能是网关冗余、ARP 欺骗或地址冲突,建议复核网关设备。",
evidence=evidence,
)
)
proxy_rows = []
for mac, ips in mac_to_ips.items():
clean_ips = [ip for ip in ips if not is_multicast_or_broadcast_ip(ip)]
if len(clean_ips) >= 8 and mac != local_mac:
proxy_rows.append((mac, sorted(clean_ips)))
for mac, ips in proxy_rows[:3]:
evidence = IpConflictEvidence(
local_mac=local_mac,
observed_macs=[mac],
shared_mac_ips=len(ips),
scanned_hosts=options.max_hosts,
notes="同一 MAC 对应大量 IP,可能是 Proxy ARP 或网关代理,不直接判定为 IP 冲突",
)
results.append(
IpConflictResult(
adapter=adapter["name"],
ip=", ".join(ips[:5]) + ("..." if len(ips) > 5 else ""),
mac=mac,
conflict_macs="",
evidence_type="Proxy ARP 提示",
risk_level="可疑",
verdict="同一 MAC 对应大量 IP,更像 Proxy ARP/网关代理,需结合网络拓扑判断。",
evidence=evidence,
)
)
return results
def build_scan_targets(self, adapters: list[dict], options: IpConflictOptions) -> 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 refresh_arp_targets(self, targets: list[str], options: IpConflictOptions) -> None:
done = 0
def task(ip: str) -> None:
if self.stop_event.is_set():
return
run_hidden(["ping", ip, "-n", "1", "-w", str(options.timeout_ms)], timeout=max(2, options.timeout_ms / 1000 + 2))
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):
done += 1
if self.stop_event.is_set():
for item in futures:
item.cancel()
break
try:
future.result()
except Exception:
pass
if done % 20 == 0 or done == len(targets):
self.status(self._status("扫描中", 0, futures[future], risk_rank("正常"), len(targets), done, 0))
def get_address_states(self) -> dict[str, str]:
script = r"""
$ErrorActionPreference = "SilentlyContinue"
Get-NetIPAddress -AddressFamily IPv4 | Select-Object IPAddress,InterfaceIndex,AddressState,PrefixLength | 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 {item.get("IPAddress", ""): str(item.get("AddressState", "")) for item in data if item.get("IPAddress")}
except Exception as exc:
self.output(f"读取本机地址状态失败: {exc}\n", "warning")
return {}
def get_tcpip_conflict_events(self) -> list[dict]:
script = r"""
$ErrorActionPreference = "SilentlyContinue"
Get-WinEvent -FilterHashtable @{LogName='System'; ProviderName='Tcpip'; StartTime=(Get-Date).AddDays(-7)} -MaxEvents 80 |
Select-Object TimeCreated,Id,ProviderName,Message | ConvertTo-Json -Depth 4 -Compress
"""
try:
result = run_hidden(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], timeout=12)
if result.returncode != 0 or not result.stdout.strip():
return []
data = extract_json(result.stdout)
if isinstance(data, dict):
data = [data]
events = []
for item in data:
message = str(item.get("Message", ""))
if re.search(r"conflict|duplicate|冲突|重复", message, re.IGNORECASE):
events.append(
{
"time": str(item.get("TimeCreated", "")),
"id": str(item.get("Id", "")),
"message": message,
}
)
return events
except Exception as exc:
self.output(f"读取 Tcpip 系统事件失败: {exc}\n", "warning")
return []
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 stop_detection(self) -> None:
if not self.is_running():
raise RuntimeError("当前没有正在运行的 IP 冲突检测")
self.stop_event.set()
self.output("\n正在停止 IP 冲突检测...\n", "warning")
def export_results(self, path: str) -> None:
if not self.last_results:
raise RuntimeError("还没有可导出的 IP 冲突检测结果")
fields = [
"adapter",
"ip",
"mac",
"conflict_macs",
"evidence_type",
"risk_level",
"verdict",
"address_state",
"local_mac",
"observed_macs",
"tcpip_event_count",
"gateway_mac_changes",
"ip_mac_changes",
"shared_mac_ips",
"scanned_hosts",
"notes",
"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
def normalize_options(self, options: Optional[dict]) -> IpConflictOptions:
options = options or {}
mode = options.get("mode", MODE_BOTH)
if mode not in {MODE_BOTH, MODE_LOCAL, MODE_SUBNET}:
mode = MODE_BOTH
return IpConflictOptions(
mode=mode,
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, "最大扫描地址数"),
neighbor_samples=clamp_int(options.get("neighbor_samples", 3), 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, total: int, current: str, max_risk_rank: int, total_targets: int, completed: int, elapsed: float) -> dict:
return {
"state": state,
"total_adapters": total,
"current": current,
"max_risk": risk_from_rank(max_risk_rank),
"total_targets": total_targets,
"completed": completed,
"elapsed": elapsed,
}
def is_running(self) -> bool:
return bool(self.worker and self.worker.is_alive())
def normal_result(adapter: dict, verdict: str) -> IpConflictResult:
evidence = IpConflictEvidence(address_state="", local_mac=normalize_mac(adapter.get("mac", "")), notes="未发现冲突证据")
return IpConflictResult(
adapter=adapter.get("name", ""),
ip=adapter.get("ipv4", ""),
mac=normalize_mac(adapter.get("mac", "")),
conflict_macs="",
evidence_type="本机 IP",
risk_level="正常",
verdict=verdict,
evidence=evidence,
)
def filter_neighbors(samples: list[list[dict]], interface_index: str, interface_ip: str) -> list[dict]:
rows = []
for sample in samples:
for item in sample:
if item.get("ifIndex"):
if str(item.get("ifIndex")) == str(interface_index):
rows.append(item)
elif item.get("interface_ip") == interface_ip:
rows.append(item)
return rows
def build_neighbor_maps(neighbors: list[dict]) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
ip_to_macs: dict[str, set[str]] = {}
mac_to_ips: dict[str, set[str]] = {}
for item in neighbors:
ip = item.get("ip", "")
mac = normalize_mac(item.get("mac", ""))
if not ip or not mac or is_special_mac(mac):
continue
ip_to_macs.setdefault(ip, set()).add(mac)
mac_to_ips.setdefault(mac, set()).add(ip)
return ip_to_macs, mac_to_ips
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
network = ipaddress.ip_network(f"{ip}/{prefix}", strict=False)
return str(network)
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 event_count_for_ip(events: list[dict], ip: str) -> int:
return len([event for event in events if ip and ip in event.get("message", "")])
def build_summary(rows: list[dict], stopped: bool = False) -> str:
if stopped:
return "检测已停止,当前结果仅代表已完成部分。"
if not rows:
return "没有生成 IP 冲突检测结果。"
ordered = sorted(rows, key=lambda row: risk_rank(row.get("risk_level", "正常")), reverse=True)
highest = ordered[0]
conflict = [row for row in ordered if row.get("risk_level") == "冲突风险"]
suspicious = [row for row in ordered if row.get("risk_level") == "可疑"]
lines = [
"==== IP 冲突诊断摘要 ====",
f"最高风险: {highest.get('adapter', '')} {highest.get('ip', '')} {highest.get('risk_level', '')}",
"结论基于本机 Windows 可见证据,建议结合交换机 MAC 地址表和现场设备确认。",
]
if conflict:
lines.append(f"发现 {len(conflict)} 条冲突风险。")
for row in conflict[:5]:
lines.append(f"{row['adapter']} {row['ip']}: {row['verdict']}")
elif suspicious:
lines.append(f"发现 {len(suspicious)} 条可疑信号,建议复测或结合交换机侧信息确认。")
for row in suspicious[:5]:
lines.append(f"{row['adapter']} {row['ip']}: {row['verdict']}")
else:
lines.append("整体正常,未发现本机 IP 被其它 MAC 占用、同 IP 多 MAC 或 Tcpip 冲突事件。")
return "\n".join(lines)
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 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 输出")
return json.loads(output[json_start:])
def is_special_mac(mac: str) -> bool:
if not mac or mac == "00-00-00-00-00-00":
return True
return mac.startswith("FF-FF-FF") or mac.startswith("01-00-5E")
def is_multicast_or_broadcast_ip(ip: str) -> bool:
try:
parsed = ipaddress.ip_address(ip)
return parsed.is_multicast or str(parsed).endswith(".255")
except ValueError:
return False
def worse_risk(left: str, right: str) -> str:
return left if risk_rank(left) >= risk_rank(right) else right
def risk_rank(level: str) -> int:
return {"正常": 0, "可疑": 1, "冲突风险": 2}.get(level, 0)
def risk_from_rank(value: int) -> str:
if value >= 2:
return "冲突风险"
if value == 1:
return "可疑"
return "正常"
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