fix(device-test): contain tool processes and add reviewed preview

This commit is contained in:
chenmingxuan committed 2026-10-30 12:43:36 +08:00
1 parent c98d7527a0
commit 7d31e09cb7
59 files changed
+23722

No files matched your search

@@ -0,0 +1,537 @@
#!/usr/bin/env python3
"""通过多个 Windows IPv4 接口执行 V2 EPBP 设备发现。"""
from __future__ import annotations
import argparse
import errno
import ipaddress
import json
import secrets
import select
import socket
import struct
import subprocess
import sys
import time
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
DEVICE_PORT = 8941
CLIENT_PORT = 8951
DISCOVERY_COMMAND = 60000
MAX_FRAME_SIZE = 1024 * 1024
class DiscoveryError(RuntimeError):
"""表示参数、网卡枚举或协议校验失败。"""
@dataclass(frozen=True)
class Interface:
"""保存一个 IPv4 接口及其筛选信息。"""
alias: str
description: str
address: ipaddress.IPv4Address
prefix_length: int
hardware: bool
@property
def network(self) -> ipaddress.IPv4Network:
"""返回接口对应的 IPv4 网络。"""
return ipaddress.ip_network(
"%s/%d" % (self.address, self.prefix_length), strict=False
)
def configure_output() -> None:
"""输出被重定向时统一使用 UTF-8。"""
for stream in (sys.stdout, sys.stderr):
if not stream.isatty() and hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="backslashreplace")
def emit(event: str, **fields: Any) -> None:
"""输出单行 JSON,便于终端查看和日志保存。"""
record: Dict[str, Any] = {
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
"event": event,
}
record.update(fields)
print(json.dumps(record, ensure_ascii=False, separators=(",", ":")), flush=True)
def parse_bool(value: Any) -> bool:
"""兼容 PowerShell JSON 中的布尔值和字符串。"""
if isinstance(value, bool):
return value
return str(value).strip().lower() == "true"
def powershell_interfaces() -> List[Interface]:
"""通过 Windows PowerShell 枚举已启用且具有 IPv4 的接口。"""
command = r"""
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
$OutputEncoding = [Console]::OutputEncoding
$items = Get-NetIPConfiguration | Where-Object {
$_.NetAdapter.Status -eq 'Up' -and $null -ne $_.IPv4Address
} | ForEach-Object {
$cfg = $_
foreach ($ipv4 in @($cfg.IPv4Address)) {
[PSCustomObject]@{
Alias = [string]$cfg.InterfaceAlias
Description = [string]$cfg.InterfaceDescription
Address = [string]$ipv4.IPAddress
PrefixLength = [int]$ipv4.PrefixLength
Hardware = [bool]$cfg.NetAdapter.HardwareInterface
}
}
}
@($items) | ConvertTo-Json -Compress
"""
try:
result = subprocess.run(
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
timeout=20,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise DiscoveryError("无法调用 PowerShell 枚举网卡:%s" % exc) from exc
if result.returncode != 0:
raise DiscoveryError("PowerShell 网卡枚举失败:%s" % result.stderr.strip())
try:
decoded = json.loads(result.stdout or "[]")
except json.JSONDecodeError as exc:
raise DiscoveryError("PowerShell 网卡结果不是有效 JSON:%s" % exc) from exc
if isinstance(decoded, dict):
decoded = [decoded]
interfaces: List[Interface] = []
for item in decoded:
if not isinstance(item, dict):
continue
try:
address = ipaddress.IPv4Address(str(item.get("Address", "")))
prefix_length = int(item.get("PrefixLength"))
ipaddress.ip_network("%s/%d" % (address, prefix_length), strict=False)
except (ipaddress.AddressValueError, ValueError, TypeError):
continue
interfaces.append(
Interface(
alias=str(item.get("Alias") or "未知接口"),
description=str(item.get("Description") or ""),
address=address,
prefix_length=prefix_length,
hardware=parse_bool(item.get("Hardware")),
)
)
return interfaces
def explicit_interfaces(values: Sequence[str]) -> List[Interface]:
"""解析重复传入的 IP/前缀接口参数。"""
interfaces: List[Interface] = []
for index, value in enumerate(values, 1):
try:
parsed = ipaddress.ip_interface(value)
except ValueError as exc:
raise DiscoveryError("无效的 --interface %r:%s" % (value, exc)) from exc
if not isinstance(parsed, ipaddress.IPv4Interface):
raise DiscoveryError("--interface 只支持 IPv4:%s" % value)
interfaces.append(
Interface(
alias="手工接口%d" % index,
description="由 --interface 指定",
address=parsed.ip,
prefix_length=parsed.network.prefixlen,
hardware=True,
)
)
return interfaces
def exclusion_reason(interface: Interface, include_virtual: bool) -> Optional[str]:
"""返回接口的排除原因;返回空值表示可用于搜索。"""
if interface.address.is_loopback:
return "回环地址"
if interface.address.is_link_local:
return "APIPA/链路本地地址"
if interface.address.is_unspecified or interface.address.is_multicast:
return "不是可用单播地址"
if not interface.hardware and not include_virtual:
return "虚拟或 VPN 接口(使用 --include-virtual 可加入)"
return None
def make_frame(session: int) -> bytes:
"""生成 V2 发现请求帧。"""
payload = json.dumps(
{"cmd": DISCOVERY_COMMAND, "session": session}, separators=(",", ":")
).encode("utf-8")
return b"EPBP" + struct.pack("!I", len(payload)) + payload
def parse_frame(packet: bytes, expected_session: int) -> Dict[str, Any]:
"""严格解析并校验一个完整 EPBP 数据报。"""
if len(packet) < 8 or packet[:4] != b"EPBP":
raise DiscoveryError("响应同步头不是 EPBP")
payload_size = struct.unpack("!I", packet[4:8])[0]
if payload_size <= 0 or payload_size > MAX_FRAME_SIZE:
raise DiscoveryError("响应长度不合理:%d" % payload_size)
if len(packet) != 8 + payload_size:
raise DiscoveryError(
"响应数据报长度不匹配:声明 %d,实际 %d" % (payload_size, len(packet) - 8)
)
try:
response = json.loads(packet[8:].decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise DiscoveryError("响应不是有效 UTF-8 JSON:%s" % exc) from exc
if not isinstance(response, dict):
raise DiscoveryError("响应 JSON 顶层不是对象")
if response.get("cmd") != DISCOVERY_COMMAND:
raise DiscoveryError("响应 cmd 不匹配:%r" % response.get("cmd"))
if response.get("session") != expected_session:
raise DiscoveryError("响应 session 不匹配:%r" % response.get("session"))
return response
def response_data(response: Dict[str, Any]) -> Dict[str, Any]:
"""兼容身份位于顶层或 data 对象的设备响应。"""
data = response.get("data")
return data if isinstance(data, dict) else response
def identity(response: Dict[str, Any]) -> Tuple[Optional[str], List[str], Optional[str]]:
"""提取完整 SN、型号集合和 MAC。"""
data = response_data(response)
sn = data.get("sn") if isinstance(data.get("sn"), str) else None
mac = data.get("mac") if isinstance(data.get("mac"), str) else None
models: List[str] = []
for section_name in ("inner", "outer", "hardware"):
section = data.get(section_name)
if isinstance(section, dict):
model = section.get("type")
if isinstance(model, str) and model and model not in models:
models.append(model)
return sn, models, mac
def port_owner(port: int) -> Optional[Dict[str, Any]]:
"""在 Windows 上查询 UDP 端口占用者,不结束任何进程。"""
if sys.platform != "win32":
return None
command = (
"$e=Get-NetUDPEndpoint -LocalPort %d -ErrorAction SilentlyContinue | "
"Select-Object -First 1; if($null -ne $e){$p=Get-Process -Id $e.OwningProcess "
"-ErrorAction SilentlyContinue;[PSCustomObject]@{PID=$e.OwningProcess;"
"Process=$p.ProcessName;Address=$e.LocalAddress}|ConvertTo-Json -Compress}" % port
)
try:
result = subprocess.run(
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
encoding="utf-8",
errors="replace",
timeout=10,
)
return json.loads(result.stdout) if result.stdout.strip() else None
except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError):
return None
def open_sockets(
interfaces: Iterable[Interface], client_port: int
) -> List[Tuple[Interface, socket.socket]]:
"""为每个 IPv4 接口创建独立的广播收发套接字。"""
opened: List[Tuple[Interface, socket.socket]] = []
for interface in interfaces:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
try:
sock.bind((str(interface.address), client_port))
except OSError as exc:
sock.close()
owner = port_owner(client_port) if exc.errno in (errno.EACCES, errno.EADDRINUSE, 10013, 10048) else None
emit(
"bind_error",
interface=interface.alias,
address=str(interface.address),
port=client_port,
error=str(exc),
owner=owner,
action="请释放端口;本脚本不会结束占用进程",
)
continue
sock.setblocking(False)
opened.append((interface, sock))
emit(
"socket_ready",
interface=interface.alias,
address=str(interface.address),
port=client_port,
)
return opened
def discover(
opened: Sequence[Tuple[Interface, socket.socket]],
device_port: int,
timeout: float,
session: int,
) -> List[Dict[str, Any]]:
"""向每个接口发送两类广播并收集合法响应。"""
frame = make_frame(session)
socket_map = {sock: interface for interface, sock in opened}
for interface, sock in opened:
destinations = ["255.255.255.255", str(interface.network.broadcast_address)]
for destination in dict.fromkeys(destinations):
try:
sock.sendto(frame, (destination, device_port))
emit(
"broadcast_sent",
interface=interface.alias,
source=str(interface.address),
destination=destination,
device_port=device_port,
session=session,
)
except OSError as exc:
emit(
"send_error",
interface=interface.alias,
destination=destination,
error=str(exc),
)
deadline = time.monotonic() + timeout
found: List[Dict[str, Any]] = []
while socket_map:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
readable, _, _ = select.select(list(socket_map), [], [], remaining)
if not readable:
break
for sock in readable:
interface = socket_map[sock]
try:
packet, peer = sock.recvfrom(MAX_FRAME_SIZE + 9)
response = parse_frame(packet, session)
except (OSError, DiscoveryError) as exc:
emit(
"invalid_response",
interface=interface.alias,
error=str(exc),
)
continue
sn, models, mac = identity(response)
candidate = {
"source_ip": peer[0],
"source_port": peer[1],
"received_on": str(interface.address),
"interface": interface.alias,
"sn": sn,
"models": models,
"mac": mac,
"response": response,
}
found.append(candidate)
emit("candidate", **candidate)
return found
def deduplicate(candidates: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""只合并 SN、源地址、MAC 和型号完全一致的重复响应。"""
unique: Dict[Tuple[str, ...], Dict[str, Any]] = {}
for candidate in candidates:
sn = candidate.get("sn")
key = (
"identity",
str(sn or ""),
str(candidate.get("source_ip") or ""),
str(candidate.get("mac") or ""),
json.dumps(candidate.get("models", []), ensure_ascii=False, separators=(",", ":")),
)
unique.setdefault(key, candidate)
return list(unique.values())
def find_identity_conflicts(candidates: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""找出同一完整 SN 对应多个网络或型号身份的冲突。"""
grouped: Dict[str, List[Dict[str, Any]]] = {}
for candidate in candidates:
sn = candidate.get("sn")
if isinstance(sn, str) and sn:
grouped.setdefault(sn, []).append(candidate)
return [
{"sn": sn, "candidates": items}
for sn, items in grouped.items()
if len(items) > 1
]
def select_expected(
candidates: Sequence[Dict[str, Any]], expected_sn: Optional[str], expected_model: Optional[str]
) -> List[Dict[str, Any]]:
"""按完整 SN 和型号筛选候选。"""
selected = list(candidates)
if expected_sn:
selected = [item for item in selected if item.get("sn") == expected_sn]
if expected_model:
selected = [item for item in selected if expected_model in item.get("models", [])]
return selected
def build_parser() -> argparse.ArgumentParser:
"""创建命令行参数解析器。"""
parser = argparse.ArgumentParser(
description="从每个活动 IPv4 接口的本地 8951 端口执行 V2 EPBP 广播搜索。"
)
parser.add_argument(
"--interface",
action="append",
default=[],
metavar="IP/PREFIX",
help="手工指定接口,可重复;给出后不自动枚举,格式为本机IPv4/前缀长度",
)
parser.add_argument(
"--include-virtual",
action="store_true",
help="自动枚举时同时使用活动的虚拟、VPN 等非物理接口",
)
parser.add_argument("--expected-sn", help="期望的完整设备 SN,按完全相等匹配")
parser.add_argument("--expected-model", help="期望型号,匹配 inner/outer/hardware.type")
parser.add_argument("--timeout", type=float, default=3.0, help="接收等待秒数")
parser.add_argument("--client-port", type=int, default=CLIENT_PORT)
parser.add_argument("--device-port", type=int, default=DEVICE_PORT)
return parser
def validate_options(args: argparse.Namespace) -> None:
"""拒绝无效端口和等待时间。"""
if args.timeout <= 0 or args.timeout > 300:
raise DiscoveryError("--timeout 必须在 0 到 300 秒之间")
for name in ("client_port", "device_port"):
value = getattr(args, name)
if value <= 0 or value > 65535:
raise DiscoveryError("--%s 必须在 1 到 65535 之间" % name.replace("_", "-"))
def main(argv: Optional[List[str]] = None) -> int:
"""执行网卡筛选、广播搜索和唯一身份匹配。"""
opened: List[Tuple[Interface, socket.socket]] = []
try:
args = build_parser().parse_args(argv)
validate_options(args)
if args.client_port != CLIENT_PORT:
emit(
"diagnostic_port",
client_port=args.client_port,
warning="设备可能只回复到 8951;非标准源端口无响应不能证明设备离线",
)
interfaces = (
explicit_interfaces(args.interface) if args.interface else powershell_interfaces()
)
selected_interfaces: List[Interface] = []
for interface in interfaces:
reason = exclusion_reason(interface, args.include_virtual)
emit(
"interface",
alias=interface.alias,
description=interface.description,
address=str(interface.address),
prefix_length=interface.prefix_length,
directed_broadcast=str(interface.network.broadcast_address),
hardware=interface.hardware,
selected=reason is None,
exclusion_reason=reason,
)
if reason is None:
selected_interfaces.append(interface)
if not selected_interfaces:
raise DiscoveryError("没有可用于广播的 IPv4 接口")
opened = open_sockets(selected_interfaces, args.client_port)
if not opened:
raise DiscoveryError(
"所有接口都无法绑定本地端口 %d;请检查端口占用" % args.client_port
)
session = secrets.randbelow(0xFFFFFFFF) + 1
candidates = deduplicate(
discover(opened, args.device_port, args.timeout, session)
)
conflicts = find_identity_conflicts(candidates)
if conflicts:
emit(
"identity_conflict",
message="同一完整 SN 对应不同 IP、MAC 或型号,禁止自动选择设备",
conflicts=conflicts,
)
return 2
matches = select_expected(candidates, args.expected_sn, args.expected_model)
identity_verified = bool(args.expected_sn and len(matches) == 1)
emit(
"summary",
mode="identity_check" if args.expected_sn else "list_only",
identity_verified=identity_verified,
candidate_count=len(candidates),
candidates=candidates,
expected_sn=args.expected_sn,
expected_model=args.expected_model,
match_count=len(matches),
matches=matches,
)
if not candidates:
emit(
"not_found",
message=(
"未收到合法响应;若使用的不是客户端端口 8951,此结果不能证明设备离线"
),
)
return 2
if args.expected_sn:
if len(matches) != 1:
emit("identity_error", message="期望身份没有唯一匹配,禁止自动选择设备")
return 2
return 0
except (OSError, ValueError, DiscoveryError) as exc:
emit("error", type=type(exc).__name__, message=str(exc))
return 1
finally:
for _, sock in opened:
sock.close()
if __name__ == "__main__":
configure_output()
raise SystemExit(main())