538 lines
19 KiB
Python
538 lines
19 KiB
Python
#!/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())
|