diff --git a/.gitignore b/.gitignore index 97e2a8a8be6..104587288c3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ coverage.xml __pycache__/ doc/scapy/_build doc/scapy/api -.idea \ No newline at end of file +.idea +gui/release \ No newline at end of file diff --git a/doc/_build/dummy/.doctrees/advanced_usage/asn1_snmp.doctree b/doc/_build/dummy/.doctrees/advanced_usage/asn1_snmp.doctree new file mode 100644 index 00000000000..5c6e4160ce0 Binary files /dev/null and b/doc/_build/dummy/.doctrees/advanced_usage/asn1_snmp.doctree differ diff --git a/doc/_build/dummy/.doctrees/advanced_usage/automaton.doctree b/doc/_build/dummy/.doctrees/advanced_usage/automaton.doctree new file mode 100644 index 00000000000..d56aac8e6be Binary files /dev/null and b/doc/_build/dummy/.doctrees/advanced_usage/automaton.doctree differ diff --git a/doc/_build/dummy/.doctrees/advanced_usage/cbor.doctree b/doc/_build/dummy/.doctrees/advanced_usage/cbor.doctree new file mode 100644 index 00000000000..290863d5064 Binary files /dev/null and b/doc/_build/dummy/.doctrees/advanced_usage/cbor.doctree differ diff --git a/doc/_build/dummy/.doctrees/advanced_usage/fwdmachine.doctree b/doc/_build/dummy/.doctrees/advanced_usage/fwdmachine.doctree new file mode 100644 index 00000000000..012054b7990 Binary files /dev/null and b/doc/_build/dummy/.doctrees/advanced_usage/fwdmachine.doctree differ diff --git a/doc/_build/dummy/.doctrees/advanced_usage/index.doctree b/doc/_build/dummy/.doctrees/advanced_usage/index.doctree new file mode 100644 index 00000000000..7096cd67562 Binary files /dev/null and b/doc/_build/dummy/.doctrees/advanced_usage/index.doctree differ diff --git a/doc/_build/dummy/.doctrees/advanced_usage/pipetools.doctree b/doc/_build/dummy/.doctrees/advanced_usage/pipetools.doctree new file mode 100644 index 00000000000..840c3f4dcfb Binary files /dev/null and b/doc/_build/dummy/.doctrees/advanced_usage/pipetools.doctree differ diff --git a/doc/_build/dummy/.doctrees/backmatter.doctree b/doc/_build/dummy/.doctrees/backmatter.doctree new file mode 100644 index 00000000000..734271b86f8 Binary files /dev/null and b/doc/_build/dummy/.doctrees/backmatter.doctree differ diff --git a/doc/_build/dummy/.doctrees/build_dissect.doctree b/doc/_build/dummy/.doctrees/build_dissect.doctree new file mode 100644 index 00000000000..285935ff552 Binary files /dev/null and b/doc/_build/dummy/.doctrees/build_dissect.doctree differ diff --git a/doc/_build/dummy/.doctrees/development.doctree b/doc/_build/dummy/.doctrees/development.doctree new file mode 100644 index 00000000000..b06ea657093 Binary files /dev/null and b/doc/_build/dummy/.doctrees/development.doctree differ diff --git a/doc/_build/dummy/.doctrees/environment.pickle b/doc/_build/dummy/.doctrees/environment.pickle new file mode 100644 index 00000000000..d0a1efea107 Binary files /dev/null and b/doc/_build/dummy/.doctrees/environment.pickle differ diff --git a/doc/_build/dummy/.doctrees/extending.doctree b/doc/_build/dummy/.doctrees/extending.doctree new file mode 100644 index 00000000000..56ac9204c67 Binary files /dev/null and b/doc/_build/dummy/.doctrees/extending.doctree differ diff --git a/doc/_build/dummy/.doctrees/functions.doctree b/doc/_build/dummy/.doctrees/functions.doctree new file mode 100644 index 00000000000..2e7500388a0 Binary files /dev/null and b/doc/_build/dummy/.doctrees/functions.doctree differ diff --git a/doc/_build/dummy/.doctrees/gui_application_design.doctree b/doc/_build/dummy/.doctrees/gui_application_design.doctree new file mode 100644 index 00000000000..764dd47a710 Binary files /dev/null and b/doc/_build/dummy/.doctrees/gui_application_design.doctree differ diff --git a/doc/_build/dummy/.doctrees/index.doctree b/doc/_build/dummy/.doctrees/index.doctree new file mode 100644 index 00000000000..e5a78c0516f Binary files /dev/null and b/doc/_build/dummy/.doctrees/index.doctree differ diff --git a/doc/_build/dummy/.doctrees/installation.doctree b/doc/_build/dummy/.doctrees/installation.doctree new file mode 100644 index 00000000000..235960cfadf Binary files /dev/null and b/doc/_build/dummy/.doctrees/installation.doctree differ diff --git a/doc/_build/dummy/.doctrees/introduction.doctree b/doc/_build/dummy/.doctrees/introduction.doctree new file mode 100644 index 00000000000..2a90c74cda8 Binary files /dev/null and b/doc/_build/dummy/.doctrees/introduction.doctree differ diff --git a/doc/_build/dummy/.doctrees/layers/automotive.doctree b/doc/_build/dummy/.doctrees/layers/automotive.doctree new file mode 100644 index 00000000000..1a20b2f3620 Binary files /dev/null and b/doc/_build/dummy/.doctrees/layers/automotive.doctree differ diff --git a/doc/_build/dummy/.doctrees/layers/bluetooth.doctree b/doc/_build/dummy/.doctrees/layers/bluetooth.doctree new file mode 100644 index 00000000000..f038646157c Binary files /dev/null and b/doc/_build/dummy/.doctrees/layers/bluetooth.doctree differ diff --git a/doc/_build/dummy/.doctrees/layers/dcerpc.doctree b/doc/_build/dummy/.doctrees/layers/dcerpc.doctree new file mode 100644 index 00000000000..dc3dd8f36aa Binary files /dev/null and b/doc/_build/dummy/.doctrees/layers/dcerpc.doctree differ diff --git a/doc/_build/dummy/.doctrees/layers/dcom.doctree b/doc/_build/dummy/.doctrees/layers/dcom.doctree new file mode 100644 index 00000000000..52d60044a02 Binary files /dev/null and b/doc/_build/dummy/.doctrees/layers/dcom.doctree differ diff --git a/doc/_build/dummy/.doctrees/layers/dotnet.doctree b/doc/_build/dummy/.doctrees/layers/dotnet.doctree new file mode 100644 index 00000000000..e707d31b5b5 Binary files /dev/null and b/doc/_build/dummy/.doctrees/layers/dotnet.doctree differ diff --git a/doc/_build/dummy/.doctrees/layers/gssapi.doctree b/doc/_build/dummy/.doctrees/layers/gssapi.doctree new file mode 100644 index 00000000000..a9e93ef4b59 Binary files /dev/null and b/doc/_build/dummy/.doctrees/layers/gssapi.doctree differ diff --git a/doc/_build/dummy/.doctrees/layers/http.doctree b/doc/_build/dummy/.doctrees/layers/http.doctree new file mode 100644 index 00000000000..95f2c69934a Binary files /dev/null and b/doc/_build/dummy/.doctrees/layers/http.doctree differ diff --git a/doc/_build/dummy/.doctrees/layers/index.doctree b/doc/_build/dummy/.doctrees/layers/index.doctree new file mode 100644 index 00000000000..81de135da60 Binary files /dev/null and b/doc/_build/dummy/.doctrees/layers/index.doctree differ diff --git a/doc/_build/dummy/.doctrees/layers/kerberos.doctree b/doc/_build/dummy/.doctrees/layers/kerberos.doctree new file mode 100644 index 00000000000..8c92f4b4447 Binary files /dev/null and b/doc/_build/dummy/.doctrees/layers/kerberos.doctree differ diff --git a/doc/_build/dummy/.doctrees/layers/ldap.doctree b/doc/_build/dummy/.doctrees/layers/ldap.doctree new file mode 100644 index 00000000000..d73db365122 Binary files /dev/null and b/doc/_build/dummy/.doctrees/layers/ldap.doctree differ diff --git a/doc/_build/dummy/.doctrees/layers/netflow.doctree b/doc/_build/dummy/.doctrees/layers/netflow.doctree new file mode 100644 index 00000000000..58ac206f468 Binary files /dev/null and b/doc/_build/dummy/.doctrees/layers/netflow.doctree differ diff --git a/doc/_build/dummy/.doctrees/layers/pnio.doctree b/doc/_build/dummy/.doctrees/layers/pnio.doctree new file mode 100644 index 00000000000..80c5b325aa7 Binary files /dev/null and b/doc/_build/dummy/.doctrees/layers/pnio.doctree differ diff --git a/doc/_build/dummy/.doctrees/layers/sctp.doctree b/doc/_build/dummy/.doctrees/layers/sctp.doctree new file mode 100644 index 00000000000..26bf7d9a724 Binary files /dev/null and b/doc/_build/dummy/.doctrees/layers/sctp.doctree differ diff --git a/doc/_build/dummy/.doctrees/layers/smb.doctree b/doc/_build/dummy/.doctrees/layers/smb.doctree new file mode 100644 index 00000000000..ce9806f24b9 Binary files /dev/null and b/doc/_build/dummy/.doctrees/layers/smb.doctree differ diff --git a/doc/_build/dummy/.doctrees/layers/tcp.doctree b/doc/_build/dummy/.doctrees/layers/tcp.doctree new file mode 100644 index 00000000000..6221151d72c Binary files /dev/null and b/doc/_build/dummy/.doctrees/layers/tcp.doctree differ diff --git a/doc/_build/dummy/.doctrees/layers/tuntap.doctree b/doc/_build/dummy/.doctrees/layers/tuntap.doctree new file mode 100644 index 00000000000..47af5d72a8d Binary files /dev/null and b/doc/_build/dummy/.doctrees/layers/tuntap.doctree differ diff --git a/doc/_build/dummy/.doctrees/routing.doctree b/doc/_build/dummy/.doctrees/routing.doctree new file mode 100644 index 00000000000..da20173f84a Binary files /dev/null and b/doc/_build/dummy/.doctrees/routing.doctree differ diff --git a/doc/_build/dummy/.doctrees/troubleshooting.doctree b/doc/_build/dummy/.doctrees/troubleshooting.doctree new file mode 100644 index 00000000000..fdff2cc78aa Binary files /dev/null and b/doc/_build/dummy/.doctrees/troubleshooting.doctree differ diff --git a/doc/_build/dummy/.doctrees/usage.doctree b/doc/_build/dummy/.doctrees/usage.doctree new file mode 100644 index 00000000000..f7f72ee95f0 Binary files /dev/null and b/doc/_build/dummy/.doctrees/usage.doctree differ diff --git a/doc/scapy/gui_application_design.rst b/doc/scapy/gui_application_design.rst new file mode 100644 index 00000000000..313b494f8c1 --- /dev/null +++ b/doc/scapy/gui_application_design.rst @@ -0,0 +1,693 @@ +*********************** +Scapy GUI 设计文档 +*********************** + +目标与范围 +========== + +本文档定义一个基于 Scapy library 的图形化桌面应用方案。首个可交付版本采用 +PySide6 / Qt,平台优先级为 Windows。GUI 不重新实现协议栈,而是把现有 Scapy +能力封装为可视化、可组合、可回放、可分析的工作流。 + +目标如下: + +* 让用户无需记忆 Scapy CLI 与 Python API,也能完成数据包构建、发送、离线分析。 +* 保持与 Scapy 原生对象模型一致,核心后端仍然使用 ``Packet``、``PacketList``、 + ``PcapReader``、``AnsweringMachine``、``Automaton`` 等能力。 +* 充分吸收 Scapy 已支持的协议层、扩展协议与自动化能力,而不是只做一个简化的协议浏览器。 +* 实时抓包由 Wireshark 等专业工具承担,GUI 负责消费 ``pcap`` / ``pcapng`` 结果并继续分析、回放与编辑。 +* 为后续扩展协议插件、自动化向导、数据分析视图预留架构空间。 + +首版不做的事情: + +* 不在 GUI 内重新实现一套独立的协议描述语言。 +* 不追求替代 Wireshark 的全部高级分析器功能。 +* 不在 GUI 内提供实时抓包页面;当前 Qt 路线在高吞吐抓包场景下容易卡死,交互稳定性与性能都不如 Wireshark。 +* 不在首版覆盖所有平台差异;先把 Windows + Npcap 路线设计清楚。 + + +Scapy 能力盘点 +=============== + +本节只记录与 GUI 设计直接相关的能力面。 + +核心能力 +-------- + +Scapy 的主定位已经很清楚:创建、发送、捕获、解析、操作网络数据包。其入口既可以是 +交互式 shell,也可以是 Python library。 + +与 GUI 最直接相关的后端能力包括: + +* 数据包对象模型:``scapy/packet.py`` + + * ``Packet`` 支持层叠组合、字段展示、摘要、探索、``ls()``、``explore()``。 + * GUI 可直接围绕 ``Packet`` 建立协议树、字段编辑器、十六进制预览与构建预览。 + +* 字段系统:``scapy/fields.py`` + + * 字段类型丰富,适合生成动态表单。 + * 可基于字段元数据构建通用的属性编辑面板,而不是为每个协议单独手工写表单。 + +* 发送与收发:``scapy/sendrecv.py`` + + * 提供 ``send()``、``sendp()``、``sr()``、``sr1()``、``sniff()``、``AsyncSniffer``。 + * GUI 当前聚焦发送任务、请求/响应任务;实时采集改由 Wireshark 负责,必要时再消费其导出的 ``pcap`` / ``pcapng`` 文件。 + +* 包列表与结果集:``scapy/plist.py`` + + * ``PacketList``、``SndRcvList`` 适合映射到 GUI 的包列表、会话列表、请求响应配对视图。 + +* 会话重组:``scapy/sessions.py`` + + * ``DefaultSession``、``IPSession`` 等机制可作为离线分析和流视图的基础。 + +* 应答机:``scapy/ansmachine.py`` + + * 适合做“自动回复器”“协议仿真器”“交互式服务工具”。 + +* 自动机:``scapy/automaton.py`` + + * 可支撑复杂协议流程、状态机式交互、客户端向导和半自动测试工具。 + +* 接口管理:``scapy/interfaces.py`` + + * 提供统一接口抽象、接口展示、L2/L3 socket 选择。 + * GUI 可基于该层构建接口选择器,而不直接依赖平台命令行。 + +* PCAP / PCAPNG 读写:``scapy/utils.py`` + + * 提供 ``rdpcap()``、``wrpcap()``、``PcapReader``、``PcapWriter`` 等。 + * GUI 需要重点支持离线文件分析、筛选结果导出,以及与 Wireshark 抓包文件的互操作。 + +协议与扩展能力 +-------------- + +GUI 不应只覆盖 IP/TCP/UDP/ICMP。仓库中已有大量现成协议支持: + +* 常见网络层与传输层:``scapy/layers/inet.py``、``inet6.py``、``l2.py``、``dns.py``、 + ``dhcp.py``、``http.py``、``tls/``、``quic.py``、``sctp.py``、``vxlan.py``。 +* 无线与近场:``dot11.py``、``bluetooth.py``、``zigbee.py``、``dot15d4.py``。 +* 工业、企业与系统协议:``netflow.py``、``ldap.py``、``kerberos.py``、``smb.py``、 + ``smb2.py``、``dcerpc.py``、``gssapi.py``。 +* 专项协议与扩展:``scapy/contrib/`` 下包含大量扩展协议,尤其是 automotive、工业、 + 专用封装和研究型协议。 + +文档覆盖面也证明 GUI 需要“分层浏览 + 插件发现”的信息架构,而不是固定几个页面: + +* 通用使用:``doc/scapy/usage.rst`` +* 自动机:``doc/scapy/advanced_usage/automaton.rst`` +* 层级协议专题:``doc/scapy/layers/`` + +平台约束 +-------- + +Windows 路线必须正视以下事实: + +* 外部抓包链路和部分注入能力依赖 Npcap / libpcap 路线,相关逻辑分布在 + ``scapy/arch/libpcap.py`` 与 ``scapy/arch/windows/__init__.py``。 +* 部分操作需要管理员权限。 +* 原始 802.11、接口模式切换、底层 socket 能力会受驱动、Npcap 特性与网卡硬件限制。 + + +产品定位 +======== + +建议产品名暂定为 ``Scapy Studio``。它不是“把 shell 套个窗口”,而是一个基于任务与工作区 +模型的协议实验台。 + +目标用户包括: + +* 网络工程师 +* 安全研究人员 +* 协议开发与测试人员 +* 教学与实验场景用户 + +核心使用场景包括: + +* 从模板构造和发送常见报文,如 ARP、ICMP、DNS、TCP、HTTP、DHCP。 +* 构建多层复杂数据包,逐字段可视化编辑,实时查看原始字节与校验字段变化。 +* 使用 Wireshark 等外部工具抓包后,导入 ``pcap`` / ``pcapng``,做离线浏览、搜索、会话聚合和协议树分析。 +* 基于 Scapy 现有自动机、应答机、contrib 协议实现专项工具页。 + + +总体架构 +======== + +分层架构 +-------- + +建议采用四层结构: + +1. 表现层 + + * Qt 主窗口、停靠面板、协议树、表格、十六进制视图、图表与任务面板。 + +2. 应用服务层 + + * 统一封装发送任务、离线分析任务、导出任务、协议模板任务。 + +3. Scapy 适配层 + + * 对 ``sendrecv``、``interfaces``、``utils``、``packet``、``plist``、``sessions``、 + ``ansmachine``、``automaton`` 提供稳定的 GUI 调用接口。 + +4. 运行时与系统层 + + * 线程、权限、日志、配置、缓存、文件系统、Npcap 检测、外部工具集成。 + +建议的 Python 包布局如下: + +:: + + scapy_gui/ + app/ + bootstrap.py + dependency_check.py + ui/ + main_window.py + widgets/ + dialogs/ + models/ + services/ + packet_builder_service.py + replay_service.py + pcap_analysis_service.py + interface_service.py + automation_service.py + template_service.py + adapters/ + scapy_packet_adapter.py + scapy_interface_adapter.py + scapy_pcap_adapter.py + domain/ + workspace.py + task.py + analysis_profile.py + packet_template.py + plugins/ + builtin/ + contrib/ + +线程与任务模型 +-------------- + +GUI 不能把 Scapy 的阻塞调用直接放在主线程里。建议: + +* Qt 主线程仅负责界面更新。 +* ``sr()``、``sr1()``、pcap 读取、批量发送等操作放入后台 worker。 +* 通过 Qt signal / slot 把进度、状态更新、错误信息回传到 UI。 +* 对长任务统一抽象为 ``TaskHandle``,支持开始、暂停、取消、导出结果。 + +这样做的原因是:Scapy 的核心 API 偏脚本式与阻塞式,GUI 需要把它转换为事件驱动模型。 + + +信息架构 +======== + +主窗口建议采用多工作区布局: + +* 左侧:资源导航 + + * 接口 + * 协议模板 + * 最近工程 + * 插件工具 + +* 中央:主工作区标签页 + + * 包构建器 + * 离线分析 + * 请求/响应任务 + * 自动化工具 + +* 右侧:属性与详情面板 + + * 字段编辑器 + * 包协议树 + * 原始十六进制 + * 任务参数 + +* 底部:运行与日志面板 + + * 任务状态 + * 错误输出 + * Scapy 警告 + * 统计信息 + + +核心功能模块 +============ + +1. 协议浏览器 +-------------- + +该模块用于把 Scapy 的协议面公开给用户。 + +需求: + +* 浏览内置层 ``scapy/layers/`` 与可选扩展 ``scapy/contrib/``。 +* 展示协议简介、字段列表、默认值、上层/下层组合关系。 +* 支持搜索协议名、字段名、别名。 +* 支持从协议浏览器直接新建模板。 + +实现建议: + +* 以 ``Packet`` 子类为核心索引。 +* 利用 ``ls()``、``explore()`` 以及字段描述生成可视化说明。 +* 对 ``contrib`` 协议使用按需加载,避免启动期全部导入。 + +2. 包构建器 +----------- + +这是首版最重要的模块之一。 + +能力要求: + +* 通过协议树逐层添加,如 Ether / IP / TCP / Raw。 +* 字段编辑器支持自动值、手动值、随机值、枚举值、十六进制值。 +* 即时显示: + + * ``show()`` 风格结构视图 + * 原始字节预览 + * 十六进制视图 + * 长度、校验和、摘要 + +* 支持模板保存、克隆、导入导出。 +* 支持单包发送、批量发送、模糊测试模式。 + +实现建议: + +* 数据模型内部直接持有 ``Packet`` 实例。 +* 界面编辑后重建或局部更新 ``Packet``。 +* 对自动计算字段使用“自动”态,不强行提前固化。 + +3. 发送与请求响应任务 +--------------------- + +围绕 ``send()``、``sendp()``、``sr()``、``sr1()`` 建立统一任务面板。 + +应支持: + +* L2 / L3 发送模式切换 +* 目标接口选择 +* 发送次数、间隔、超时、重试参数 +* 回包展示与请求响应关联 +* 将结果保存为会话记录 + +建议把返回结果分成三种视图: + +* 发送日志 +* 应答包列表 +* 未应答包列表 + +4. 外部抓包导入工作流 +---------------------- + +实时采集不再由 GUI 承担,而是明确采用 Wireshark 等专业工具完成。 + +这样调整的原因是:当前图形界面在高吞吐抓包场景下容易卡死,UI 响应性与持续采集稳定性不够理想;与其继续在 Qt 页面内承载实时抓包,不如把 GUI 资源集中到构包、发送和离线分析。 + +最低功能集: + +* 引导用户使用 Wireshark 抓包并导出 ``pcap`` / ``pcapng`` +* 提供最近文件、快捷导入和失败诊断入口 +* 支持从导入结果一键跳转到离线分析页 + +增强功能: + +* 监视指定目录中的最新抓包文件 +* 记住最近一次导入来源与过滤条件 +* 为 Wireshark 导出失败、文件占用、格式不兼容提供更明确提示 + +5. 离线分析工作台 +----------------- + +围绕 ``rdpcap()``、``PcapReader``、``PacketList`` 建立文件分析功能。 + +建议支持: + +* 打开大型 pcap / pcapng 文件 +* 懒加载 / 分页读取 +* 过滤、检索、收藏 +* 会话视图、协议分布、端点统计 +* 导出筛选结果 + +首版建议优先采用流式读取,而不是一次性把大文件全部载入内存。 + +6. 会话与流重组视图 +------------------- + +基于 ``sessions.py`` 做更高层展示: + +* IP 分片重组 +* TCP 流视图 +* 请求/响应聚合 +* 特定协议的事务视图 + +此模块是外部抓包文件与离线分析的桥梁,也是后续加入 HTTP、DNS、SMB、NetFlow 等专题面板的基础。 + +7. 自动化与协议工具页 +--------------------- + +Scapy 的价值不止离线解析与发包。GUI 应留出自动化工具页: + +* 应答机工具页:基于 ``AnsweringMachine`` 创建自动回复任务。 +* 自动机工具页:把 ``Automaton`` 封装为向导式流程操作。 +* 研究型协议工具:针对 SMB、Kerberos、Automotive、802.11 等协议提供专题页。 + +首版不必全部实现,但架构必须留出统一注册入口。 + + +Windows 优先设计要点 +==================== + +依赖检查 +-------- + +应用启动时应显式检测: + +* 是否安装 Npcap +* 当前进程是否具备管理员权限 +* 当前接口是否支持目标模式 +* Scapy 可选依赖是否可用 + +如果用户希望在同机通过 Wireshark 抓包,也可以顺带提示 Npcap 状态;但这属于外部采集链路诊断,而不是 GUI 内部实时抓包前置条件。 + +不满足条件时,不要让用户在任务执行后才看到底层 traceback,而应在 GUI 中提前给出诊断信息。 + +接口体验 +-------- + +Windows 下接口名称、描述、GUID、Npcap 名称可能不同。界面应同时展示: + +* 友好名称 +* 描述 +* GUID / Npcap 标识 +* IPv4 / IPv6 地址 +* 能力标签,如可发送、L2/L3 可用性、无线模式可用性 + +权限模型 +-------- + +建议把高风险操作分级: + +* 普通级:离线分析、模板编辑、pcap 浏览 +* 提权级:原始发送、无线注入、接口模式调整 + +并在界面中明确显示当前会话权限状态。 + + +数据模型设计 +============ + +建议定义以下领域对象: + +* ``PacketTemplate`` + + * 保存用户构建的包模板、字段覆盖、标签与说明。 + +* ``AnalysisProfile`` + + * 保存导入来源、过滤器、最近文件、会话类型和列配置。 + +* ``TaskRecord`` + + * 记录发送、导入、分析任务的参数、状态、开始结束时间、错误信息。 + +* ``WorkspaceDocument`` + + * 统一抽象“一个打开的工作标签”,便于恢复会话和保存工程。 + +这些对象应与具体 UI 控件解耦,便于后续做自动化测试与工程保存。 + + +插件与扩展策略 +============== + +由于 ``scapy/contrib/`` 能力很多,GUI 不能把所有工具写死在主程序里。建议: + +* 设计 ``ToolPlugin`` 接口,用于注册新工具页、新协议专题页、新模板集。 +* 内置插件覆盖高频能力:Packet Builder、Send Task、Pcap Analyzer。 +* 扩展插件覆盖专题协议:Automotive、SMB、Kerberos、802.11、Bluetooth。 +* 对 contrib 协议采用按需导入 + 能力检测,避免启动变慢和无谓依赖失败。 + + +开发阶段规划 +============ + +M1: 最小可用版本 +---------------- + +* 主窗口与工作区框架 +* 接口浏览 +* 包构建器 +* ``send()`` / ``sendp()`` / ``sr1()`` 基础任务 +* ``pcap`` / ``pcapng`` 导入与基础离线浏览 +* 包详情树 + 十六进制视图 + +M2: 分析增强版本 +---------------- + +* 离线 pcap / pcapng 分析 +* 会话聚合 +* 过滤、搜索、自定义列 +* 模板管理与工程保存 + +M3: 高阶协议与自动化版本 +------------------------ + +* 应答机工具页 +* 自动机工具页 +* 专题插件体系 +* contrib 协议浏览器 + + +测试与验证策略 +=============== + +建议测试拆成四层: + +* 领域层测试 + + * 模板、任务、配置对象序列化与反序列化。 + +* 适配层测试 + + * 对 Scapy 封装接口做单元测试,验证包构建、参数传递、错误处理。 + +* UI 交互测试 + + * 对关键流程做 Qt 自动化测试,例如新建包、编辑字段、导入 ``pcap``、复制到构建器。 + +* 集成测试 + + * 在具备 Npcap 的 Windows 环境验证接口发现、发送、``pcap`` 导入导出,以及 Wireshark 到 GUI 的导入链路。 + +对首版最关键的验收标准如下: + +* 用户能通过 GUI 构建常见多层数据包并发送。 +* 用户能打开 Wireshark 生成的 ``pcap`` 并浏览协议树与十六进制视图。 +* 用户能把离线分析中的包复制回构建器进行二次编辑。 + + +后续实现建议 +============ + +按照当前仓库情况,推荐的实施顺序是: + +1. 先在仓库外或新增子项目中实现独立 GUI 包,避免直接侵入 Scapy 现有核心模块。 +2. 第一阶段只封装稳定后端接口,不改动 Scapy 包解析与发送主逻辑。 +3. 先打通两条主链路:包构建与发送、离线分析。 +4. 完成插件注册机制后,再逐步纳入 contrib 协议工具页。 + +这样可以最大限度复用 Scapy 现有能力,同时避免 GUI 开发反向污染核心 library。 + + +当前状态 +======== + +截至当前轮设计,项目状态如下: + +已完成事项 +---------- + +* 已完成对 Scapy 文档主入口、核心源码模块和协议层目录的定向探索。 +* 已确认首个 GUI 版本采用 ``PySide6 / Qt``,平台优先级为 Windows。 +* 已识别 GUI 首版最关键的后端锚点:``packet.py``、``fields.py``、``sendrecv.py``、 + ``plist.py``、``sessions.py``、``interfaces.py``、``ansmachine.py``、``automaton.py``。 +* 已识别 Windows 关键约束:Npcap / libpcap、管理员权限、接口能力差异、无线注入限制。 +* 已在 Scapy 文档目录中加入本文档入口。 +* 已完成一次 Sphinx 文档构建验证,确认本文档可被当前文档系统收录。 +* 已建立独立 GUI 子项目骨架:``gui/``。 +* 已建立最小运行入口、主窗口、运行时目录与依赖检查模块。 +* 已在当前开发环境安装 GUI 运行依赖,并完成一次真实 Qt 主窗口离屏创建验证。 +* 已完成第 3 步的第一部分:主窗口已接入接口浏览面板、接口刷新动作和选中详情联动。 +* 已把接口浏览改为后台线程加载,并补充加载中、失败态和更细的能力标签展示。 +* 已完成接口浏览成功路径与失败路径的离屏验证。 +* 已接入包构建器最小可用版,支持常见协议层添加、字段编辑、摘要预览、结构预览和十六进制预览。 +* 已增强包构建器,支持层上移/下移、字段搜索过滤以及 ``Raw.load`` 的多行专用编辑。 +* 已进一步增强包构建器,支持模板保存/加载和层拖拽重排。 +* 已为部分常见字段接入专用编辑控件,当前覆盖枚举下拉、单比特布尔开关和字节字段单行编辑。 +* 已进一步增强字段编辑体验,当前协议枚举支持搜索选择,IPv4、IPv6 和 MAC 地址字段支持专用输入与基础合法性校验。 +* 已把常见二层协议补入包构建器可选层,当前覆盖 ``Dot1Q``、``Dot1AD``、``MACControlPause`` 和 ``MACControlClassBasedFlowControl``。 +* 已为集合型字段补入专用编辑入口;当前 ``IP.options``、``DNS.qd/an/ns/ar`` 等字段会显示多行集合编辑入口,而不是只暴露单行文本框。 +* 已继续深化集合字段编辑器:当前普通字面量列表支持列表项增删改和顺序调整,``DNS.qd`` 支持按 question 子项编辑 ``qname``、``qtype`` 和 ``qclass``。 +* 已为 ``IP.options`` 接入协议感知模板编辑器;当前支持按模板添加 ``NOP``、``EOL``、``RR``、``LSRR``、``SSRR``、``Timestamp``、``Router Alert`` 和 ``Security``,并在提交时转换为真实 ``IPOption_*`` 对象。 +* 已接入发送任务面板,当前支持从包构建器载入当前数据包,执行 ``send()``、``sendp()`` 和 ``sr1()``,并展示请求包结构、十六进制预览、发送日志和应答详情。 +* 已确认并执行功能收缩:由于 GUI 在实时抓包场景下容易卡死、性能跟不上,当前已移除实时抓包页面,改为明确推荐使用 Wireshark 抓包后再导入 GUI 做离线分析。 +* 已接入离线分析工作台,当前支持打开 ``pcap`` / ``pcapng``、后台按上限读取、关键字过滤、包列表浏览、结构/十六进制详情,以及把离线数据包复制回包构建器。 +* 主窗口已完成包构建器、发送任务、离线分析三类核心页面的联动;接口列表会同步提供给发送任务页面,离线分析页也已打通“复制到包构建器”的回流链路。 +* 已接入最小自动化工具入口页和内置工具注册表;当前可以从统一入口跳转到包构建器、发送任务和离线分析页。 +* 已为发送任务、离线分析、导入构包和工具注册表补入针对性单元测试;当前 ``gui/tests/`` 下已有对应最小回归用例。 +* 已开始第 4 步的第一批后端抽象收敛:当前已新增 ``packet_studio/adapters`` 与 ``packet_studio/domain`` 目录起点。 +* 已把数据包摘要、结构预览、十六进制格式化和复制逻辑收敛到 ``ScapyPacketAdapter``,并让 ``send_task_service``、``packet_builder_service`` 依赖该适配层,而不是各自内联实现。 +* 已继续推进第 4 步的第二批抽象收敛:当前离线分析链路已切到统一记录模型,``PcapPacketRecord`` 会携带 ``preview``、``sourceText`` 和 ``protocolName`` 等展示语义。 +* 已新增 ``ScapyPcapAdapter``,用于把 pcap 读取结果收敛为领域记录;``offline_analysis_widget`` 当前已改为消费服务返回的记录对象,而不是在 UI 内再次调用 ``summary()``、``show()`` 和手写 hexdump 逻辑。 +* 已继续推进第 4 步的第三批抽象收敛:当前 ``SendTaskResult``、``PcapLoadResult`` 与 ``TaskError`` 已统一沉淀到 ``domain/task_models.py``,不再分散定义在各个 service 模块里。 +* 发送任务与离线分析 worker 的失败通道当前已开始传递 ``TaskError``,成功结果也会携带统一的 ``summaryText`` / ``logText``;这使 widget 逐步转为消费领域结果对象,而不是自行拼装摘要文案。 +* 已继续推进第 4 步的第四批抽象收敛:当前已新增统一生命周期状态模型 ``TaskPhase`` / ``TaskState``,并开始用于发送任务与离线分析两条主链路的状态表达。 +* 当前 ``send_task_widget`` 和 ``offline_analysis_widget`` 已开始通过共享 ``TaskState`` 更新状态标签;这使运行中、成功、失败等状态不再完全依赖各页面散落的硬编码字符串。 +* 已继续推进第 4 步的第五批抽象收敛:当前已新增更高层领域对象 ``TaskRecord``、``WorkspacePanelSnapshot`` 和 ``WorkspaceDocument``,并补入 ``WorkspaceDocumentService`` 负责构建工作区快照与任务记录。 +* 主窗口当前已开始实际维护 ``WorkspaceDocument`` 和最近任务记录:各核心面板会提供工作区快照,主窗口会把状态消息沉淀为 ``TaskRecord``,并在欢迎页展示当前工作区概览,而不是只保留零散日志文本。 + +当前结论 +-------- + +* GUI 可以作为独立应用层实现,Scapy 继续作为底层 library。 +* 按当前实现复核,第 3 步已经打通新的 M1 核心闭环:用户已经可以完成包构建与发送、导入 Wireshark 生成的 ``pcap`` 做离线分析,并把离线数据包回流到包构建器继续编辑。 +* 针对第 3 步当前实现的最小回归测试已通过;从代码与测试两侧复核,更准确的判断应是“M1 功能项已具备,后续进入增强、抽象与验收阶段”,而不是继续把第 3 步表述为仅在离线分析加入后才算完成。 +* 首版应聚焦两条主链路,而不是同时铺开所有专题协议页面: + + * 包构建与发送 + * 离线 pcap 分析 + +* ``contrib``、``AnsweringMachine``、``Automaton`` 等能力应纳入总体架构,但不应阻塞 M1。 +* 当前未完成项已从“主链路是否可用”转为“主链路如何增强和工程化”,后续重点应转向第 4 步和第 5 步,而不是继续把第 3 步表述为未完成。 +* 按当前实现复核,第 4 步已经从“可开始”进入“主体已落地”的阶段:``adapters``、``domain``、统一任务结果 / 错误模型、统一任务生命周期状态、``WorkspaceDocumentService`` 与最小 ``ToolRegistryService`` 均已存在,且对应聚焦单元测试当前已通过。 +* 但第 4 步暂不宜直接标记为“已完成关闭”。当前仍存在两类明显收尾项:其一,包预览抽象尚未彻底收口,``packet_builder_widget`` 仍在 UI 层直接调用 ``summary()``、``show()`` 和手写十六进制格式化,``send_task_widget`` 仍直接访问 ``sendTaskService._packetAdapter`` 私有成员;其二,插件注册仍停留在最小内置注册表,尚未形成真正的插件接口、生命周期和装配约束。 +* 因此,更准确的结论应是:第 4 步主体能力已经具备,当前进入收尾与验收阶段;下一步重点不是再证明抽象是否必要,而是把剩余 UI 泄漏边界收口,并决定哪些约束必须达到后才把第 4 步正式关闭。 +* 在这个前提下,第 5 步已经具备启动条件,而且适合立即开始;当前最缺的不是新的抽象目录,而是 GUI 冒烟测试、真实 Windows 发送验证,以及 Wireshark 到 GUI 的导入链路验证,用这些验证结果反向暴露第 4 步剩余的边界问题。 + +当前未完成事项 +-------------- + +* 当前仓库中虽然已经建立 ``gui/`` 子项目、运行入口和核心页面,但尚未补“实现规格文档”,页面结构图、模块职责表和更明确的 Qt Model / signal-slot 边界仍未固化为文档。 +* 当前 ``service``、``adapter`` 与 ``domain`` 三层已经形成可运行骨架,``TaskRecord``、``WorkspaceDocument`` 等领域模型也已实际接入主窗口;但配置对象、工程保存格式和更完整的领域边界仍未独立抽象。 +* 当前包预览抽象尚未完全收口:``packet_builder_service`` 虽已提供统一摘要、结构和十六进制接口,但 ``packet_builder_widget`` 仍在 UI 内直接消费 Scapy ``Packet`` 预览;``send_task_widget`` 也仍通过 ``sendTaskService._packetAdapter`` 访问私有实现,说明第 4 步还有最后一段 UI/服务边界需要收束。 +* 当前包构建器仍只覆盖固定常见层,尚未支持协议自动发现、字段分组和更复杂的专用编辑器。 +* 当前接口浏览已经具备后台加载和失败态展示,但尚未加入取消、超时与更完整的恢复策略。 +* 当前接口浏览仍未加入分页、搜索、用户可控排序与更丰富的接口诊断信息。 +* 当前发送任务和离线分析已可交互使用,但仍缺少更高阶的结果视图,例如未应答列表、会话聚合、自定义列、颜色规则、统计面板和导出筛选结果。 +* 当前离线分析虽然已采用 ``PcapReader`` 按上限流式读取,但仍未提供真正的懒加载分页、超大文件持续浏览和更细粒度的读取进度反馈。 +* 当前包构建器已经支持模板持久化、拖拽重排、可搜索枚举以及一部分字段专用控件;集合字段方面已经覆盖 ``IP.options`` 模板编辑、普通字面量列表和 ``DNS.qd`` question 列表,但仍未支持更复杂的 ``PacketListField`` / 嵌套结构控件与模板库管理。 +* 当前自动化工具页仍只是最小入口页与内置注册表,尚未实现真正的插件发现、插件生命周期管理和专题工具装配。 +* 当前测试已覆盖 ``ScapyPacketAdapter``、``WorkspaceDocumentService``、``packet_builder_service`` 导入链路,以及发送、离线分析、工具注册表等聚焦单元测试;但仍缺少关键 GUI 交互冒烟测试,以及真实 Windows 环境下的接口发现、发送、Wireshark 抓包文件导入链路验证。 +* 当前仓库中未发现 IEC 61850 ``GOOSE`` / ``SV`` 的现成协议实现文件或已注册层,因此 GUI 当前不能提供专用的 GOOSE / SV 构建器页面;这部分前置依赖是后端协议层实现,而不是单纯的 UI 缺口。 + +当前已知阻塞与注意事项 +---------------------- + +* 文档构建时,仓库自带的 ``scapy_doc`` 扩展会尝试调用 ``tox -e apitree``。 +* 在当前本地环境中,如未设置 ``SCAPY_APITREE=0``,Sphinx 构建可能因缺少 ``tox`` 而失败。 +* 当前 GUI 子项目使用 ``PySide6-Essentials`` 即可满足骨架阶段需求,无需完整 ``PySide6`` addons。 +* 在当前 Windows 离屏验证中,Qt 输出了字体目录 warning;这不会阻塞骨架开发,但后续打包阶段需要 + 明确字体部署策略。 +* Scapy 接口发现依赖完整初始化链;当前实现需要先触发 ``scapy.all`` 导入,再调用 + ``get_working_ifaces()`` 才能稳定获得接口列表。 +* 原始发送和部分接口能力在 Windows 下仍直接受 Npcap、管理员权限、网卡驱动和接口模式限制;如果用户在同机配合 Wireshark 抓包,也同样受该链路约束;当前单元测试不能替代真实环境验收。 +* 当前第 4 步已具备主体实现;后续收尾时仍需避免在现有 ``service`` 之上再叠一层只做转发的薄封装,而应优先消化已经暴露出的 UI / 服务边界泄漏,例如包预览逻辑重复和对私有适配器成员的直接访问。 + + +后续步骤规划 +============ + +建议后续工作按以下顺序推进。 + +第 1 步:细化实现规格 +--------------------- + +* 在本文档基础上补一版“实现规格文档”。 +* 明确主窗口布局、页面流转、核心组件树、Qt signal / slot 边界。 +* 明确 ``services``、``adapters``、``domain`` 三层的最小接口。 + +交付物: + +* 页面结构图 +* 模块职责表 +* 最小类图或接口清单 + +第 2 步:建立 GUI 子项目骨架 +------------------------------ + +* 新增独立 GUI 子项目目录,避免污染现有 Scapy 核心包。 +* 建立 PySide6 应用启动入口、主窗口、基础日志与配置目录。 +* 打通最小启动链路,保证应用可打开空白主窗口。 + +交付物: + +* 可运行的 ``main`` 入口 +* 基础主窗口 +* 项目目录骨架 + +第 3 步:打通 M1 核心闭环并建立离线分析 +--------------------------------------- + +优先顺序如下: + +1. 接口浏览与依赖诊断 +2. 包构建器 +3. 发送 / ``sr1()`` 任务 +4. ``pcap`` 导入与基础离线浏览 +5. 包详情树与十六进制视图 + +这一阶段的目标不是把界面做全,而是尽快得到一个“能构包、能发包、能导入 Wireshark 抓包文件、能看包”的闭环。 + +第 4 步:建立可扩展后端抽象 +--------------------------- + +* 把 Scapy 调用统一封装进 adapter / service。 +* 统一错误处理、任务生命周期、日志格式和结果模型。 +* 为后续插件机制预留注册接口。 + +当前评估:主体实现已经落地,但暂不建议直接关闭。原因是稳定的 ``adapter`` / ``domain`` / 结果模型边界已经存在,且聚焦单元测试已能覆盖其核心路径;不过 UI 层仍残留少量预览逻辑和私有适配器访问,插件注册也尚未演进到真正的插件契约,因此更准确的状态应是“第 4 步进入收尾与验收阶段”。 + +交付物: + +* ``packet_builder_service`` +* ``interface_service`` +* ``pcap_analysis_service`` +* ``send_task_service`` 与统一的任务结果 / 错误模型 +* 最小 ``tool_registry_service`` 到插件注册接口的演进方案 + +第 5 步:补测试与 Windows 验证 +------------------------------ + +* 为纯 Python 领域对象和 adapter 层补单元测试。 +* 为最关键 GUI 流程补冒烟测试。 +* 在真实 Windows 环境下验证接口发现、发送,以及 Wireshark 抓包文件导入链路。 + +当前评估:可以开始,并且建议立即开始。原因是第 5 步的第一部分实际上已经起步,当前仓库中已经存在多组针对 adapter / domain / service 的聚焦单元测试;接下来最有价值的是补 GUI 冒烟测试与真实 Windows 集成验证,用验证结果决定第 4 步何时可以正式关闭。 + +交付物: + +* 可重复运行的最小测试集 +* Windows 环境验证记录 + +第 6 步:进入 M2 / M3 增强阶段 +-------------------------------- + +* 增加离线分析增强能力,如过滤、搜索、自定义列、会话聚合。 +* 增加工程保存、模板库、插件注册。 +* 再逐步引入 ``AnsweringMachine``、``Automaton`` 与专题协议工具页。 + +建议的近期执行顺序 +------------------ + +如果下一轮直接开始编码,建议采用下面的短周期顺序: + +1. 为接口浏览补取消、超时、排序、搜索与更完整的恢复策略。 +2. 为发送任务和离线分析补 GUI 冒烟测试,并整理一套真实 Windows 发送验证与 Wireshark 导入验证脚本。 +3. 把当前 ``service`` 进一步沉淀为更明确的 ``adapter`` / ``domain`` 分层,补齐 ``TaskRecord``、``AnalysisProfile``、``WorkspaceDocument`` 等对象。 +4. 继续增强包构建器的协议发现、字段分组和复杂字段编辑器。 +5. 在主链路稳定后,再决定是先推进 M2 的分析增强,还是先补插件注册与自动化工具框架。 \ No newline at end of file diff --git a/doc/scapy/index.rst b/doc/scapy/index.rst index a7ae7f7d082..dcd7f437ed4 100644 --- a/doc/scapy/index.rst +++ b/doc/scapy/index.rst @@ -24,6 +24,7 @@ Scapy's documentation is under a `Creative Commons Attribution - Non-Commercial installation usage + gui_application_design advanced_usage/index.rst routing diff --git a/gui/README.rst b/gui/README.rst new file mode 100644 index 00000000000..4a0bdd73c76 --- /dev/null +++ b/gui/README.rst @@ -0,0 +1,50 @@ +Scapy Studio GUI +================ + +这是 Scapy 图形化应用的独立子项目骨架。 + +当前阶段目标: + +* 保持与主仓库解耦 +* 提供最小可运行的 PySide6 启动入口 +* 建立主窗口、运行时目录和依赖检查基础设施 + +运行方式:: + + python -m packet_studio + +Windows 验证脚本:: + + powershell -ExecutionPolicy Bypass -File scripts\windows_npcap_validation.ps1 -SkipManualChecklist + +另一台 Windows 电脑如何测试 +--------------------------- + +有两种方式: + +1. 源码方式 + + * 拷贝整个仓库,至少保留 ``scapy/`` 根目录和 ``gui/`` 子项目。 + * 目标机需要安装 Python 3.9+。 + * 安装 GUI 依赖:``python -m pip install -e .``。 + * 如需 ``sendp`` 或更完整的网卡发现,目标机还需要安装 Npcap;部分功能建议管理员权限运行。 + * 然后在 ``gui/`` 目录执行现有脚本:``powershell -ExecutionPolicy Bypass -File scripts\windows_npcap_validation.ps1``。 + +2. 打包方式 + + * 正式发布时,仅发布 ``onedir`` 版本,双击后会直接打开 Scapy Studio 主界面。 + * 先在开发机安装 PyInstaller:``python -m pip install pyinstaller``。 + * 在 ``gui/`` 目录执行:``powershell -ExecutionPolicy Bypass -File scripts\build_windows_validation_exe.ps1``。 + * 默认生成 ``onedir`` 版本,稳定性更高;产物目录为 ``dist\ScapyStudio``,主程序为 ``ScapyStudio.exe``。 + * 目标机仍建议安装 Npcap;若要做 ``sendp`` 或接口相关验证,通常仍建议管理员权限运行。 + +Windows 发布打包:: + + powershell -ExecutionPolicy Bypass -File scripts\build_windows_release_package.ps1 + +也可以直接双击 ``build_release_package.bat`` 重新打包并生成 zip 发布包。该发布包会包含 ``ScapyStudio`` 目录和安装说明 ``ScapyStudio-Windows-Guide.txt``。 + +说明: + +* 仅拷贝 ``windows_npcap_validation.ps1`` 到另一台机器通常不够,因为它依赖 Python、Scapy、PySide6 以及仓库源码结构。 +* 本次正式发布仅支持 ``onedir`` 目录形式,不提供单文件 exe。 \ No newline at end of file diff --git a/gui/ScapyStudio.spec b/gui/ScapyStudio.spec new file mode 100644 index 00000000000..c73692a614f --- /dev/null +++ b/gui/ScapyStudio.spec @@ -0,0 +1,51 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_all + +datas = [] +binaries = [] +hiddenimports = ['scapy.all'] +tmp_ret = collect_all('PySide6') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] + + +a = Analysis( + ['src\\packet_studio\\__main__.py'], + pathex=['src', '..'], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='ScapyStudio', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='ScapyStudio', +) diff --git a/gui/build_release_package.bat b/gui/build_release_package.bat new file mode 100644 index 00000000000..a633bb0024d --- /dev/null +++ b/gui/build_release_package.bat @@ -0,0 +1,21 @@ +@echo off +setlocal + +cd /d "%~dp0" + +set "NO_PAUSE=" +if /I "%~1"=="--no-pause" set "NO_PAUSE=1" + +powershell -ExecutionPolicy Bypass -File ".\scripts\build_windows_release_package.ps1" +set "EXIT_CODE=%ERRORLEVEL%" + +echo. +if not "%EXIT_CODE%"=="0" ( + echo Build failed with exit code %EXIT_CODE%. +) else ( + echo Release package completed successfully. +) + +if not defined NO_PAUSE pause + +exit /b %EXIT_CODE% \ No newline at end of file diff --git a/gui/pyproject.toml b/gui/pyproject.toml new file mode 100644 index 00000000000..ae40dc0bbce --- /dev/null +++ b/gui/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=68.0.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "scapy-studio-gui" +version = "0.1.0" +description = "Scapy 的图形化工作台骨架" +readme = "README.rst" +requires-python = ">=3.9" +dependencies = [ + "PySide6-Essentials>=6.7,<7", + "scapy", +] + +[project.scripts] +packet-studio = "packet_studio.__main__:main" +packet-studio-validation = "packet_studio.validation_launcher:main" + +[tool.setuptools.package-dir] +"" = "src" + +[tool.setuptools.packages.find] +where = ["src"] +include = ["packet_studio*"] \ No newline at end of file diff --git a/gui/scripts/build_windows_release_package.ps1 b/gui/scripts/build_windows_release_package.ps1 new file mode 100644 index 00000000000..da9cec79436 --- /dev/null +++ b/gui/scripts/build_windows_release_package.ps1 @@ -0,0 +1,82 @@ +param( + [string]$PythonExe = "python" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$guiRoot = Split-Path -Parent $scriptDir +$pyprojectPath = Join-Path $guiRoot "pyproject.toml" +$releaseDir = Join-Path $guiRoot "release" +$releaseNoteSource = Join-Path $guiRoot "release\ScapyStudio-Windows-Guide.txt" +$packageStageDir = Join-Path $releaseDir "ScapyStudio-package" + +function Get-PackageVersion +{ + param( + [string]$ProjectFilePath + ) + + $versionLine = Select-String -Path $ProjectFilePath -Pattern '^version = "([^"]+)"$' | Select-Object -First 1 + if (-not $versionLine) + { + throw "Failed to read version from $ProjectFilePath" + } + + return $versionLine.Matches[0].Groups[1].Value +} + +Push-Location $guiRoot +try +{ + $version = Get-PackageVersion -ProjectFilePath $pyprojectPath + + & (Join-Path $scriptDir "build_windows_validation_exe.ps1") -PythonExe $PythonExe + if ($LASTEXITCODE -ne 0) + { + throw "Executable build failed." + } + + if (-not (Test-Path $releaseDir)) + { + New-Item -ItemType Directory -Path $releaseDir | Out-Null + } + + $zipPath = Join-Path $releaseDir ("ScapyStudio-{0}-windows-x64.zip" -f $version) + $hashPath = "$zipPath.sha256.txt" + + foreach ($path in @($zipPath, $hashPath, $packageStageDir)) + { + if (Test-Path $path) + { + Remove-Item $path -Recurse -Force + } + } + + New-Item -ItemType Directory -Path $packageStageDir | Out-Null + Copy-Item -Path ".\dist\ScapyStudio" -Destination $packageStageDir -Recurse + Copy-Item -Path $releaseNoteSource -Destination $packageStageDir + + Compress-Archive -Path "$packageStageDir\*" -DestinationPath $zipPath -CompressionLevel Optimal + + $hash = (Get-FileHash $zipPath -Algorithm SHA256).Hash + @( + "SHA256 $(Split-Path -Leaf $zipPath)", + $hash + ) | Set-Content -Path $hashPath -Encoding utf8 + + Write-Host "" + Write-Host "Release package created: $zipPath" + Write-Host "SHA256 file created: $hashPath" + Write-Host "Bundled guide file: $(Join-Path $packageStageDir (Split-Path -Leaf $releaseNoteSource))" + Write-Host "SHA256: $hash" +} +finally +{ + if (Test-Path $packageStageDir) + { + Remove-Item $packageStageDir -Recurse -Force + } + Pop-Location +} \ No newline at end of file diff --git a/gui/scripts/build_windows_validation_exe.ps1 b/gui/scripts/build_windows_validation_exe.ps1 new file mode 100644 index 00000000000..3c0cf54ad32 --- /dev/null +++ b/gui/scripts/build_windows_validation_exe.ps1 @@ -0,0 +1,71 @@ +param( + [string]$PythonExe = "python", + [switch]$OneFile +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$guiRoot = Split-Path -Parent $scriptDir + +Push-Location $guiRoot +try +{ + & $PythonExe -m PyInstaller --version 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) + { + throw "PyInstaller not found. Run: $PythonExe -m pip install pyinstaller" + } + + foreach ($legacyPath in @( + "$guiRoot\dist\ScapyStudioValidation", + "$guiRoot\build\ScapyStudioValidation", + "$guiRoot\ScapyStudioValidation.spec" + )) + { + if (Test-Path $legacyPath) + { + try + { + Remove-Item $legacyPath -Recurse -Force + } + catch + { + Write-Warning "Failed to remove legacy artifact: $legacyPath" + } + } + } + + $distMode = if ($OneFile) { "--onefile" } else { "--onedir" } + & $PythonExe -m PyInstaller ` + --noconfirm ` + --clean ` + $distMode ` + --windowed ` + --name ScapyStudio ` + --paths src ` + --paths .. ` + --collect-all PySide6 ` + --hidden-import scapy.all ` + src\packet_studio\__main__.py + if ($LASTEXITCODE -ne 0) + { + throw "PyInstaller build failed with exit code $LASTEXITCODE" + } + + Write-Host "" + Write-Host "Build completed. Output path: $guiRoot\dist\ScapyStudio" + if ($OneFile) + { + Write-Host "Single-file exe: $guiRoot\dist\ScapyStudio.exe" + } + else + { + Write-Host "Distribute the whole directory and launch ScapyStudio.exe." + } +} +finally +{ + Pop-Location +} \ No newline at end of file diff --git a/gui/scripts/windows_npcap_validation.ps1 b/gui/scripts/windows_npcap_validation.ps1 new file mode 100644 index 00000000000..c693a06713e --- /dev/null +++ b/gui/scripts/windows_npcap_validation.ps1 @@ -0,0 +1,110 @@ +param( + [string]$PythonExe = "python", + [switch]$SkipAutomatedTests, + [switch]$SkipGuiSmokeTests, + [switch]$SkipManualChecklist +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Write-Step +{ + param( + [string]$Title + ) + + Write-Host "" + Write-Host "== $Title ==" +} + +function Test-IsAdministrator +{ + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-NpcapStatus +{ + $service = Get-Service -Name "npcap" -ErrorAction SilentlyContinue + if ($null -ne $service) + { + return "installed, service status: $($service.Status)" + } + + $registryPaths = @( + "HKLM:\SOFTWARE\Npcap", + "HKLM:\SOFTWARE\WOW6432Node\Npcap" + ) + foreach ($path in $registryPaths) + { + if (Test-Path $path) + { + return "installed, registry key found" + } + } + + return "not detected" +} + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$guiRoot = Split-Path -Parent $scriptDir + +Push-Location $guiRoot +try +{ + $env:PYTHONPATH = "src;.." + + Write-Step "1. Check prerequisites" + $pythonVersion = & $PythonExe --version + Write-Host "Python: $pythonVersion" + Write-Host "Administrator: $(if (Test-IsAdministrator) { 'yes' } else { 'no' })" + Write-Host "Npcap: $(Get-NpcapStatus)" + Write-Host "Working directory: $guiRoot" + + if (-not $SkipAutomatedTests) + { + Write-Step "2. Run focused automated tests" + $automatedTests = @( + "tests.test_scapy_packet_adapter", + "tests.test_send_task_service", + "tests.test_pcap_analysis_service", + "tests.test_tool_registry_service", + "tests.test_workspace_document_service" + ) + & $PythonExe -m unittest @automatedTests + } + + if (-not $SkipGuiSmokeTests) + { + Write-Step "3. Run offscreen GUI smoke tests" + $env:QT_QPA_PLATFORM = "offscreen" + & $PythonExe -m unittest tests.test_gui_smoke + Remove-Item Env:QT_QPA_PLATFORM -ErrorAction SilentlyContinue + } + + if (-not $SkipManualChecklist) + { + Write-Step "4. Launch GUI for manual validation" + $process = Start-Process -FilePath $PythonExe -ArgumentList "-m", "packet_studio" -WorkingDirectory $guiRoot -PassThru + Write-Host "Manual checklist:" + Write-Host "1. Verify dependency summary, log directory and environment text on the welcome page." + Write-Host "2. Open the interfaces page, refresh the list, and record whether the target adapter is discovered." + Write-Host "3. In packet builder, add IP/ICMP or Ether/ARP and verify summary, structure and hex preview update together." + Write-Host "4. In send task, load the current packet from builder and verify send, sendp or sr1 basic execution paths." + Write-Host "5. In offline analysis, open a pcap or pcapng and verify list, search, details and copy back to builder." + Write-Host "6. On a real Windows plus Npcap host, record admin status, Npcap version, adapter model and any failure symptom." + Write-Host "" + Write-Host "Close the GUI window, then return here and press Enter to finish the script." + Read-Host | Out-Null + if (-not $process.HasExited) + { + Write-Warning "GUI process is still running. Close it manually if needed." + } + } +} +finally +{ + Pop-Location +} diff --git a/gui/src/packet_studio/__init__.py b/gui/src/packet_studio/__init__.py new file mode 100644 index 00000000000..24b122af71a --- /dev/null +++ b/gui/src/packet_studio/__init__.py @@ -0,0 +1,5 @@ +"""Scapy Studio GUI package.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" \ No newline at end of file diff --git a/gui/src/packet_studio/__main__.py b/gui/src/packet_studio/__main__.py new file mode 100644 index 00000000000..4f627d46239 --- /dev/null +++ b/gui/src/packet_studio/__main__.py @@ -0,0 +1,5 @@ +from packet_studio.app import main + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/gui/src/packet_studio/adapters/__init__.py b/gui/src/packet_studio/adapters/__init__.py new file mode 100644 index 00000000000..6b512290ea6 --- /dev/null +++ b/gui/src/packet_studio/adapters/__init__.py @@ -0,0 +1,4 @@ +from packet_studio.adapters.scapy_packet_adapter import ScapyPacketAdapter +from packet_studio.adapters.scapy_pcap_adapter import ScapyPcapAdapter + +__all__ = ["ScapyPacketAdapter", "ScapyPcapAdapter"] \ No newline at end of file diff --git a/gui/src/packet_studio/adapters/scapy_packet_adapter.py b/gui/src/packet_studio/adapters/scapy_packet_adapter.py new file mode 100644 index 00000000000..235f5910ae8 --- /dev/null +++ b/gui/src/packet_studio/adapters/scapy_packet_adapter.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import Any + +from packet_studio.domain.packet_models import CapturedPacketRecord, PacketPreview + + +class ScapyPacketAdapter: + """提供 Packet 预览和复制的稳定适配接口。""" + + def clonePacket(self, packet: Any | None) -> Any | None: + if packet is None: + return None + return packet.copy() + + def buildPreview(self, packet: Any) -> PacketPreview: + return PacketPreview( + summary=self.buildSummary(packet), + structure=self.buildStructureDump(packet), + hexdump=self.buildHexdump(packet), + ) + + def buildCapturedRecord(self, packet: Any) -> CapturedPacketRecord: + packetCopy = self.clonePacket(packet) + return CapturedPacketRecord( + packet=packetCopy, + sourceText=self.buildSourceText(packet), + protocolName=self.buildPrimaryProtocolName(packet), + preview=self.buildPreview(packetCopy), + ) + + def buildSummary(self, packet: Any) -> str: + return packet.summary() + + def buildSourceText(self, packet: Any) -> str: + return str(getattr(packet, "sniffed_on", "") or "") + + def buildPrimaryProtocolName(self, packet: Any) -> str: + for protocolName in ["ARP", "DNS", "ICMP", "ICMPv6Unknown", "TCP", "UDP", "IPv6", "IP", "Raw"]: + if packet.haslayer(protocolName): + return protocolName + return packet.__class__.__name__ + + def buildStructureDump(self, packet: Any) -> str: + return packet.show(dump=True) + + def buildHexdump(self, packet: Any) -> str: + payload = bytes(packet) + if not payload: + return "" + + lines = [] + for offset in range(0, len(payload), 16): + chunk = payload[offset:offset + 16] + hexPart = " ".join(f"{byte:02x}" for byte in chunk) + asciiPart = "".join(chr(byte) if 32 <= byte <= 126 else "." for byte in chunk) + lines.append(f"{offset:04x} {hexPart:<47} {asciiPart}") + return "\n".join(lines) \ No newline at end of file diff --git a/gui/src/packet_studio/adapters/scapy_pcap_adapter.py b/gui/src/packet_studio/adapters/scapy_pcap_adapter.py new file mode 100644 index 00000000000..8897d73eb00 --- /dev/null +++ b/gui/src/packet_studio/adapters/scapy_pcap_adapter.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from packet_studio.adapters.scapy_packet_adapter import ScapyPacketAdapter +from packet_studio.domain.packet_models import PcapPacketRecord + + +class ScapyPcapAdapter: + """提供 pcap 记录到领域模型的稳定适配接口。""" + + def __init__(self, packetAdapter: ScapyPacketAdapter | None = None) -> None: + self._packetAdapter = packetAdapter or ScapyPacketAdapter() + + def buildPacketRecord(self, index: int, packet: Any) -> PcapPacketRecord: + packetCopy = self._packetAdapter.clonePacket(packet) + return PcapPacketRecord( + index=index, + timestampText=self.formatTimestamp(getattr(packet, "time", None)), + sourceText=self._packetAdapter.buildSourceText(packet), + protocolName=self._packetAdapter.buildPrimaryProtocolName(packet), + preview=self._packetAdapter.buildPreview(packetCopy), + packet=packetCopy, + ) + + def formatTimestamp(self, rawTime: Any) -> str: + if rawTime is None: + return "" + + try: + timestamp = float(rawTime) + except Exception: + return str(rawTime) + + try: + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + except Exception: + return str(rawTime) \ No newline at end of file diff --git a/gui/src/packet_studio/app.py b/gui/src/packet_studio/app.py new file mode 100644 index 00000000000..eb5b3ca7583 --- /dev/null +++ b/gui/src/packet_studio/app.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import logging +import sys +from pathlib import Path +from typing import Sequence +from packet_studio.runtime.dependency_check import collect_environment +from packet_studio.runtime.paths import ensure_runtime_directories + + +def configure_logging(log_dir: Path) -> Path: + """初始化最小日志输出。""" + log_file = log_dir / "packet_studio.log" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + handlers=[ + logging.FileHandler(log_file, encoding="utf-8"), + logging.StreamHandler(sys.stdout), + ], + ) + return log_file + + +def main(argv: Sequence[str] | None = None) -> int: + """启动 GUI 应用。""" + try: + from PySide6 import QtWidgets + except ModuleNotFoundError: + print( + "未安装 PySide6,无法启动 Scapy Studio。\n" + "请先在 gui 子项目环境中执行: pip install -e .", + file=sys.stderr, + ) + return 1 + + from packet_studio.main_window import MainWindow + + runtime_dirs = ensure_runtime_directories() + log_file = configure_logging(runtime_dirs.log_dir) + logging.getLogger(__name__).info("日志文件: %s", log_file) + + app_argv = list(argv) if argv is not None else sys.argv + application = QtWidgets.QApplication(app_argv) + application.setApplicationName("Scapy Studio") + application.setOrganizationName("Scapy Studio") + + environment = collect_environment(runtime_dirs) + window = MainWindow(environment=environment, log_file=log_file) + window.show() + return application.exec() \ No newline at end of file diff --git a/gui/src/packet_studio/domain/__init__.py b/gui/src/packet_studio/domain/__init__.py new file mode 100644 index 00000000000..011cc05a880 --- /dev/null +++ b/gui/src/packet_studio/domain/__init__.py @@ -0,0 +1,18 @@ +from packet_studio.domain.packet_models import CapturedPacketRecord, PacketPreview, PcapPacketRecord +from packet_studio.domain.task_models import CaptureStopResult, PcapLoadResult, SendTaskResult, TaskError, TaskPhase, TaskState +from packet_studio.domain.workspace_models import TaskRecord, WorkspaceDocument, WorkspacePanelSnapshot + +__all__ = [ + "PacketPreview", + "CapturedPacketRecord", + "PcapPacketRecord", + "TaskError", + "SendTaskResult", + "CaptureStopResult", + "PcapLoadResult", + "TaskPhase", + "TaskState", + "TaskRecord", + "WorkspacePanelSnapshot", + "WorkspaceDocument", +] \ No newline at end of file diff --git a/gui/src/packet_studio/domain/packet_models.py b/gui/src/packet_studio/domain/packet_models.py new file mode 100644 index 00000000000..009bf822403 --- /dev/null +++ b/gui/src/packet_studio/domain/packet_models.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class PacketPreview: + summary: str + structure: str + hexdump: str + + +@dataclass(frozen=True) +class CapturedPacketRecord: + packet: Any + sourceText: str + protocolName: str + preview: PacketPreview + + @property + def summary(self) -> str: + return self.preview.summary + + +@dataclass(frozen=True) +class PcapPacketRecord: + index: int + timestampText: str + sourceText: str + protocolName: str + preview: PacketPreview + packet: Any + + @property + def summary(self) -> str: + return self.preview.summary \ No newline at end of file diff --git a/gui/src/packet_studio/domain/task_models.py b/gui/src/packet_studio/domain/task_models.py new file mode 100644 index 00000000000..2042a5d8c98 --- /dev/null +++ b/gui/src/packet_studio/domain/task_models.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +from packet_studio.domain.packet_models import PacketPreview, PcapPacketRecord + + +class TaskPhase(str, Enum): + IDLE = "idle" + RUNNING = "running" + SUCCEEDED = "succeeded" + FAILED = "failed" + PAUSED = "paused" + STOPPED = "stopped" + + +@dataclass(frozen=True) +class TaskState: + phase: TaskPhase + statusText: str + + @classmethod + def idle(cls, statusText: str) -> "TaskState": + return cls(TaskPhase.IDLE, statusText) + + @classmethod + def running(cls, statusText: str) -> "TaskState": + return cls(TaskPhase.RUNNING, statusText) + + @classmethod + def succeeded(cls, statusText: str) -> "TaskState": + return cls(TaskPhase.SUCCEEDED, statusText) + + @classmethod + def failed(cls, statusText: str) -> "TaskState": + return cls(TaskPhase.FAILED, statusText) + + @classmethod + def paused(cls, statusText: str) -> "TaskState": + return cls(TaskPhase.PAUSED, statusText) + + @classmethod + def stopped(cls, statusText: str) -> "TaskState": + return cls(TaskPhase.STOPPED, statusText) + + +@dataclass(frozen=True) +class TaskError: + message: str + logText: str = "" + state: TaskState = TaskState.failed("任务失败。") + + @property + def summaryText(self) -> str: + return self.message + + +@dataclass(frozen=True) +class SendTaskResult: + mode: str + sentCount: int + packetPreview: PacketPreview + answerPreview: Optional[PacketPreview] + unansweredCount: int + summaryText: str + logText: str + state: TaskState = TaskState.succeeded("发送任务执行完成。") + + +@dataclass(frozen=True) +class CaptureStopResult: + capturedCount: int + storedResultCount: int + summaryText: str + logText: str = "" + state: TaskState = TaskState.stopped("抓包已停止。") + + +@dataclass(frozen=True) +class PcapLoadResult: + filePath: str + packetRecords: list[PcapPacketRecord] + summaryText: str + logText: str = "" + state: TaskState = TaskState.succeeded("离线抓包文件加载完成。") + + @property + def packetCount(self) -> int: + return len(self.packetRecords) \ No newline at end of file diff --git a/gui/src/packet_studio/domain/workspace_models.py b/gui/src/packet_studio/domain/workspace_models.py new file mode 100644 index 00000000000..4d8af8de1d7 --- /dev/null +++ b/gui/src/packet_studio/domain/workspace_models.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from packet_studio.domain.task_models import TaskPhase, TaskState + + +@dataclass(frozen=True) +class TaskRecord: + sequenceNumber: int + sourceTitle: str + message: str + phase: TaskPhase + detailText: str = "" + + +@dataclass(frozen=True) +class WorkspacePanelSnapshot: + panelId: str + title: str + taskState: TaskState + itemCount: int = 0 + detailText: str = "" + + +@dataclass(frozen=True) +class WorkspaceDocument: + activeTabTitle: str + openTabTitles: list[str] + panelSnapshots: list[WorkspacePanelSnapshot] + taskRecords: list[TaskRecord] + interfaceCount: int + interfaceSummaryText: str + + @property + def taskCount(self) -> int: + return len(self.taskRecords) + + def to_multiline_text(self) -> str: + lines = [ + "工作区状态概览", + "", + f"当前页签: {self.activeTabTitle}", + f"已打开页签数: {len(self.openTabTitles)}", + f"接口数量: {self.interfaceCount}", + f"接口摘要: {self.interfaceSummaryText}", + f"任务记录数: {self.taskCount}", + ] + + if self.taskRecords: + latestTask = self.taskRecords[-1] + lines.append( + f"最近任务: #{latestTask.sequenceNumber} {latestTask.sourceTitle} / {latestTask.phase.value} / {latestTask.message}" + ) + + lines.append("") + lines.append("面板快照") + for snapshot in self.panelSnapshots: + detailText = f" / {snapshot.detailText}" if snapshot.detailText else "" + lines.append( + f"- {snapshot.title}: {snapshot.taskState.phase.value} / {snapshot.taskState.statusText} / 项目数 {snapshot.itemCount}{detailText}" + ) + return "\n".join(lines) \ No newline at end of file diff --git a/gui/src/packet_studio/main_window.py b/gui/src/packet_studio/main_window.py new file mode 100644 index 00000000000..4ba27b932f1 --- /dev/null +++ b/gui/src/packet_studio/main_window.py @@ -0,0 +1,566 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +from PySide6 import QtCore, QtGui, QtWidgets + +from packet_studio.domain.task_models import TaskPhase, TaskState +from packet_studio.domain.workspace_models import WorkspaceDocument, WorkspacePanelSnapshot, TaskRecord +from packet_studio.runtime.dependency_check import AppEnvironment +from packet_studio.services.interface_service import InterfaceRecord, InterfaceService +from packet_studio.services.workspace_document_service import WorkspaceDocumentService +from packet_studio.widgets.automation_tools_widget import AutomationToolsWidget +from packet_studio.widgets.offline_analysis_widget import OfflineAnalysisWidget +from packet_studio.widgets.packet_builder_widget import PacketBuilderWidget +from packet_studio.widgets.send_task_widget import SendTaskWidget + + +class InterfaceLoadWorker(QtCore.QObject): + finished = QtCore.Signal(int, bool, list) + failed = QtCore.Signal(int, str) + + def __init__( + self, + interfaceService: InterfaceService, + generation: int, + initialLoad: bool, + ) -> None: + super().__init__() + self.interfaceService = interfaceService + self.generation = generation + self.initialLoad = initialLoad + + @QtCore.Slot() + def run(self) -> None: + try: + records = self.interfaceService.loadInterfaces() + except Exception as exc: + self.failed.emit(self.generation, str(exc)) + return + self.finished.emit(self.generation, self.initialLoad, records) + + +class MainWindow(QtWidgets.QMainWindow): + """Scapy Studio 的最小主窗口。""" + + def __init__(self, environment: AppEnvironment, log_file: Path) -> None: + super().__init__() + self.environment = environment + self.log_file = log_file + self.interfaceService = InterfaceService() + self.interfaceRecords: list[InterfaceRecord] = [] + self.interfaceLoadThread: Optional[QtCore.QThread] = None + self.interfaceLoadWorker: Optional[InterfaceLoadWorker] = None + self.interfaceLoadGeneration = 0 + self.interfaceLoadActive = False + self.workspaceDocumentService = WorkspaceDocumentService() + self.taskRecords: list[TaskRecord] = [] + self.workspaceDocument: Optional[WorkspaceDocument] = None + + self.setWindowTitle("Scapy Studio") + self.resize(1440, 900) + + self._setup_ui() + self._populate_environment() + QtCore.QTimer.singleShot(0, self._start_initial_interface_load) + + def _setup_ui(self) -> None: + self.leftNav = QtWidgets.QListWidget() + self.leftNav.addItems( + [ + "欢迎", + "接口", + "包构建器", + "发送任务", + "离线分析", + "自动化工具", + ] + ) + self.leftNav.setCurrentRow(0) + + self.workspace_tabs = QtWidgets.QTabWidget() + self.leftNav.currentRowChanged.connect(self.workspace_tabs.setCurrentIndex) + self.workspace_tabs.currentChanged.connect(self.leftNav.setCurrentRow) + self.workspace_tabs.currentChanged.connect(self._handle_workspace_tab_changed) + self.packetBuilderTab = PacketBuilderWidget() + self.sendTaskTab = SendTaskWidget() + self.offlineAnalysisTab = OfflineAnalysisWidget() + self.automationToolsTab = AutomationToolsWidget() + self.packetBuilderTab.createStreamRequested.connect(self.sendTaskTab.addStreamFromPacket) + self.packetBuilderTab.saveStreamRequested.connect(self._handle_save_stream_to_send_task) + self.sendTaskTab.editPacketRequested.connect(self._handle_edit_send_stream_packet) + self.offlineAnalysisTab.importPacketRequested.connect(self._handle_import_capture_packet) + self.automationToolsTab.openToolRequested.connect(self._handle_open_tool_tab) + self.workspace_tabs.addTab(self._build_welcome_tab(), "欢迎") + self.workspace_tabs.addTab(self._build_interfaces_tab(), "接口") + self.workspace_tabs.addTab(self.packetBuilderTab, "包构建器") + self.workspace_tabs.addTab(self.sendTaskTab, "发送任务") + self.workspace_tabs.addTab(self.offlineAnalysisTab, "离线分析") + self.workspace_tabs.addTab(self.automationToolsTab, "自动化工具") + + splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) + splitter.addWidget(self.leftNav) + splitter.addWidget(self.workspace_tabs) + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + self.setCentralWidget(splitter) + + details_dock = QtWidgets.QDockWidget("详情", self) + details_dock.setAllowedAreas( + QtCore.Qt.DockWidgetArea.RightDockWidgetArea + ) + details_dock.setWidget(self._build_details_panel()) + self.addDockWidget(QtCore.Qt.DockWidgetArea.RightDockWidgetArea, details_dock) + + log_dock = QtWidgets.QDockWidget("运行与日志", self) + log_dock.setAllowedAreas( + QtCore.Qt.DockWidgetArea.BottomDockWidgetArea + ) + log_dock.setWidget(self._build_log_panel()) + self.addDockWidget(QtCore.Qt.DockWidgetArea.BottomDockWidgetArea, log_dock) + self.sendTaskTab.statusMessage.connect( + lambda message: self._handle_panel_status_message( + "发送任务", + self.sendTaskTab.buildWorkspaceSnapshot, + message, + ) + ) + self.offlineAnalysisTab.statusMessage.connect( + lambda message: self._handle_panel_status_message( + "离线分析", + self.offlineAnalysisTab.buildWorkspaceSnapshot, + message, + ) + ) + self.automationToolsTab.statusMessage.connect( + lambda message: self._handle_panel_status_message( + "自动化工具", + self.automationToolsTab.buildWorkspaceSnapshot, + message, + ) + ) + + status_bar = self.statusBar() + status_bar.showMessage("GUI 子项目骨架已启动") + self._refresh_workspace_document() + + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + if self.interfaceLoadActive or self.sendTaskTab.hasRunningTask() or self.offlineAnalysisTab.isLoading(): + QtWidgets.QMessageBox.warning( + self, + "后台任务仍在运行", + "请等待接口刷新、发送任务或离线分析完成后再关闭窗口。", + ) + event.ignore() + return + if self.interfaceLoadThread is not None and self.interfaceLoadThread.isRunning(): + self.interfaceLoadThread.quit() + self.interfaceLoadThread.wait(3000) + super().closeEvent(event) + + def _build_interfaces_tab(self) -> QtWidgets.QWidget: + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + + topBar = QtWidgets.QHBoxLayout() + self.interfaceSummaryLabel = QtWidgets.QLabel("正在加载接口列表...") + self.interfaceSummaryLabel.setWordWrap(True) + self.refreshInterfacesButton = QtWidgets.QPushButton("刷新接口") + self.refreshInterfacesButton.clicked.connect(self._handle_refresh_interfaces) + topBar.addWidget(self.interfaceSummaryLabel, 1) + topBar.addWidget(self.refreshInterfacesButton) + + self.interfaceLoadingBar = QtWidgets.QProgressBar() + self.interfaceLoadingBar.setRange(0, 0) + self.interfaceLoadingBar.setVisible(False) + self.interfaceLoadingBar.setTextVisible(False) + + self.interfaceTable = QtWidgets.QTableWidget(0, 7) + self.interfaceTable.setHorizontalHeaderLabels( + ["名称", "描述", "网络名", "MAC", "IPv4", "IPv6", "能力"] + ) + self.interfaceTable.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + self.interfaceTable.setSelectionBehavior( + QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows + ) + self.interfaceTable.setSelectionMode( + QtWidgets.QAbstractItemView.SelectionMode.SingleSelection + ) + self.interfaceTable.verticalHeader().setVisible(False) + self.interfaceTable.horizontalHeader().setStretchLastSection(True) + self.interfaceTable.horizontalHeader().setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents + ) + self.interfaceTable.horizontalHeader().setSectionResizeMode( + 1, QtWidgets.QHeaderView.ResizeMode.Stretch + ) + self.interfaceTable.itemSelectionChanged.connect(self._handle_interface_selection) + + layout.addLayout(topBar) + layout.addWidget(self.interfaceLoadingBar) + layout.addWidget(self.interfaceTable, 1) + return widget + + def _build_welcome_tab(self) -> QtWidgets.QWidget: + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + + title = QtWidgets.QLabel("Scapy Studio") + title.setObjectName("titleLabel") + title.setStyleSheet("font-size: 28px; font-weight: 700;") + + subtitle = QtWidgets.QLabel( + "当前阶段已打通接口浏览、包构建与发送、离线分析主链路。" + ) + subtitle.setWordWrap(True) + + self.environment_summary = QtWidgets.QTextEdit() + self.environment_summary.setReadOnly(True) + self.workspace_summary = QtWidgets.QPlainTextEdit() + self.workspace_summary.setReadOnly(True) + + layout.addWidget(title) + layout.addWidget(subtitle) + layout.addWidget(self.environment_summary, 1) + layout.addWidget(self.workspace_summary, 1) + return widget + + def _build_placeholder_tab(self, message: str) -> QtWidgets.QWidget: + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + label = QtWidgets.QLabel(message) + label.setWordWrap(True) + label.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + layout.addWidget(label) + layout.addStretch(1) + return widget + + def _build_details_panel(self) -> QtWidgets.QWidget: + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + + label = QtWidgets.QLabel("后续这里会承载字段编辑器、协议树和任务参数。") + label.setWordWrap(True) + layout.addWidget(label) + + self.details_list = QtWidgets.QTreeWidget() + self.details_list.setHeaderLabels(["项目", "状态"]) + layout.addWidget(self.details_list, 1) + return widget + + def _build_log_panel(self) -> QtWidgets.QWidget: + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + + self.log_view = QtWidgets.QPlainTextEdit() + self.log_view.setReadOnly(True) + self.log_view.setPlainText( + "运行日志面板已建立。\n" + f"日志文件: {self.log_file}" + ) + layout.addWidget(self.log_view) + return widget + + def _populate_environment(self) -> None: + self.environment_summary.setPlainText(self.environment.to_multiline_text()) + self._set_details_items( + [ + ("Scapy", self.environment.scapy_version or "未检测到"), + ("PySide6", self.environment.pyside6_version or "运行时导入"), + ("Npcap", self.environment.npcap_status), + ("管理员权限", "是" if self.environment.is_elevated else "否"), + ("配置目录", str(self.environment.config_dir)), + ("日志目录", str(self.environment.log_dir)), + ] + ) + self._refresh_workspace_document() + + def _start_initial_interface_load(self) -> None: + self._start_interface_load(initialLoad=True) + + def _start_interface_load(self, initialLoad: bool = False) -> None: + if self.interfaceLoadActive: + self.interfaceSummaryLabel.setText("接口加载仍在进行中,请稍候。") + return + + self.interfaceLoadGeneration += 1 + generation = self.interfaceLoadGeneration + self.interfaceLoadActive = True + self._set_interface_loading_state(True) + if initialLoad: + self.interfaceSummaryLabel.setText("正在后台加载接口列表...") + else: + self.interfaceSummaryLabel.setText("正在后台刷新接口列表...") + self.log_view.appendPlainText("开始后台加载接口列表。") + self._append_task_record("接口", self.interfaceSummaryLabel.text(), TaskPhase.RUNNING) + self._refresh_workspace_document() + + thread = QtCore.QThread(self) + worker = InterfaceLoadWorker(self.interfaceService, generation, initialLoad) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._on_interfaces_loaded) + worker.failed.connect(self._on_interfaces_failed) + worker.finished.connect(thread.quit) + worker.failed.connect(thread.quit) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(self._on_interface_thread_finished) + + self.interfaceLoadThread = thread + self.interfaceLoadWorker = worker + thread.start() + + def _populate_interface_table(self) -> None: + self.interfaceTable.clearContents() + self.interfaceTable.setRowCount(len(self.interfaceRecords)) + for rowIndex, interfaceRecord in enumerate(self.interfaceRecords): + values = [ + interfaceRecord.name, + interfaceRecord.description, + interfaceRecord.networkName, + interfaceRecord.mac, + interfaceRecord.ipv4, + interfaceRecord.ipv6, + interfaceRecord.capabilitySummary, + ] + for columnIndex, value in enumerate(values): + item = QtWidgets.QTableWidgetItem(value) + item.setData(QtCore.Qt.ItemDataRole.UserRole, rowIndex) + self.interfaceTable.setItem(rowIndex, columnIndex, item) + + def _handle_refresh_interfaces(self) -> None: + self.statusBar().showMessage("正在刷新接口列表...") + self._start_interface_load() + + def _handle_interface_selection(self) -> None: + selectedItems = self.interfaceTable.selectedItems() + if not selectedItems: + return + rowIndex = selectedItems[0].data(QtCore.Qt.ItemDataRole.UserRole) + interfaceRecord = self.interfaceRecords[rowIndex] + self._show_interface_details(interfaceRecord) + + def _show_interface_details(self, interfaceRecord: InterfaceRecord) -> None: + self._set_details_items( + [ + ("名称", interfaceRecord.name), + ("描述", interfaceRecord.description), + ("网络名", interfaceRecord.networkName), + ("Provider", interfaceRecord.provider), + ("索引", str(interfaceRecord.index)), + ("MAC", interfaceRecord.mac or ""), + ("IPv4", interfaceRecord.ipv4 or ""), + ("IPv6", interfaceRecord.ipv6 or ""), + ("Flags", interfaceRecord.flags), + ("状态", "可用" if interfaceRecord.isValid else "不可用"), + ("能力", interfaceRecord.capabilitySummary), + ] + ) + + @QtCore.Slot(object) + def _handle_import_capture_packet(self, packet: object) -> None: + try: + self.packetBuilderTab.setEditingStreamMode(False) + self.packetBuilderTab.loadPacket(packet) + except Exception as exc: + self.statusBar().showMessage(f"导入数据包失败: {exc}", 5000) + self.log_view.appendPlainText(f"导入数据包失败: {exc}") + return + + self.workspace_tabs.setCurrentWidget(self.packetBuilderTab) + self.statusBar().showMessage("已将数据包复制到包构建器。", 5000) + self.log_view.appendPlainText("已将数据包复制到包构建器。") + + @QtCore.Slot(object) + def _handle_edit_send_stream_packet(self, packet: object) -> None: + selectedTemplateId = self.sendTaskTab.getCurrentSelectedTemplateId() + if selectedTemplateId is None: + self.statusBar().showMessage("当前没有选中的流模板。", 5000) + return + try: + self.sendTaskTab.beginEditingStream(selectedTemplateId) + self.packetBuilderTab.setEditingStreamMode(True) + self.packetBuilderTab.loadPacket(packet) + except Exception as exc: + self.packetBuilderTab.setEditingStreamMode(False) + self.statusBar().showMessage(f"载入流模板到包构建器失败: {exc}", 5000) + self.log_view.appendPlainText(f"载入流模板到包构建器失败: {exc}") + return + + self.workspace_tabs.setCurrentWidget(self.packetBuilderTab) + self.statusBar().showMessage("已跳转到包构建器,可继续复杂编辑。", 5000) + self.log_view.appendPlainText("已从发送任务跳转到包构建器继续编辑。") + + @QtCore.Slot(object) + def _handle_save_stream_to_send_task(self, packet: object) -> None: + saved = self.sendTaskTab.saveEditedStream(packet) + if not saved: + self.statusBar().showMessage("当前没有可回写的流模板,请先从发送任务进入编辑。", 5000) + self.log_view.appendPlainText("保存回流模板失败:当前没有编辑中的流模板。") + return + + self.packetBuilderTab.setEditingStreamMode(False) + self.workspace_tabs.setCurrentWidget(self.sendTaskTab) + self.statusBar().showMessage("已保存回当前流模板。", 5000) + self.log_view.appendPlainText("已将包构建器修改保存回当前流模板。") + + @QtCore.Slot(str) + def _handle_open_tool_tab(self, targetTabTitle: str) -> None: + for index in range(self.workspace_tabs.count()): + if self.workspace_tabs.tabText(index) == targetTabTitle: + self.workspace_tabs.setCurrentIndex(index) + self.statusBar().showMessage(f"已打开工具页: {targetTabTitle}", 5000) + return + + self.statusBar().showMessage(f"未找到工具页: {targetTabTitle}", 5000) + + @QtCore.Slot(int, bool, list) + def _on_interfaces_loaded( + self, + generation: int, + initialLoad: bool, + records: list[InterfaceRecord], + ) -> None: + if generation != self.interfaceLoadGeneration: + return + + self.interfaceRecords = records + self.sendTaskTab.setInterfaceRecords(records) + self._populate_interface_table() + count = len(self.interfaceRecords) + if count == 0: + self.interfaceSummaryLabel.setText( + "当前没有发现可用接口。请确认 Npcap、权限和网络适配器状态。" + ) + self.statusBar().showMessage("接口扫描完成,但没有可用接口。", 5000) + if initialLoad: + self.log_view.appendPlainText("未发现可用接口。") + self._append_task_record("接口", self.interfaceSummaryLabel.text(), TaskPhase.SUCCEEDED) + self._refresh_workspace_document() + return + + self.interfaceSummaryLabel.setText(f"已发现 {count} 个可用接口。") + self.statusBar().showMessage(f"接口列表已刷新,共 {count} 个接口。", 5000) + self.log_view.appendPlainText(f"接口扫描完成,共 {count} 个可用接口。") + self.interfaceTable.selectRow(0) + self._append_task_record("接口", self.interfaceSummaryLabel.text(), TaskPhase.SUCCEEDED) + self._refresh_workspace_document() + + @QtCore.Slot(int, str) + def _on_interfaces_failed(self, generation: int, message: str) -> None: + if generation != self.interfaceLoadGeneration: + return + + self.interfaceRecords = [] + self.sendTaskTab.setInterfaceRecords([]) + self.interfaceTable.clearContents() + self.interfaceTable.setRowCount(0) + self.interfaceSummaryLabel.setText(f"接口加载失败: {message}") + self.statusBar().showMessage("接口加载失败", 5000) + self.log_view.appendPlainText(f"接口加载失败: {message}") + self._set_details_items( + [ + ("接口状态", "加载失败"), + ("失败原因", message), + ] + ) + self._append_task_record("接口", f"接口加载失败: {message}", TaskPhase.FAILED) + self._refresh_workspace_document() + + def _on_interface_thread_finished(self) -> None: + self.interfaceLoadActive = False + self._set_interface_loading_state(False) + self.interfaceLoadThread = None + self.interfaceLoadWorker = None + self._refresh_workspace_document() + + def _set_interface_loading_state(self, isLoading: bool) -> None: + self.refreshInterfacesButton.setEnabled(not isLoading) + self.interfaceTable.setEnabled(not isLoading) + self.interfaceLoadingBar.setVisible(isLoading) + + def _set_details_items(self, items: list[tuple[str, str]]) -> None: + self.details_list.clear() + for name, value in items: + item = QtWidgets.QTreeWidgetItem([name, value]) + self.details_list.addTopLevelItem(item) + + def _handle_panel_status_message( + self, + sourceTitle: str, + snapshotBuilder: callable, + message: str, + ) -> None: + self.log_view.appendPlainText(message) + snapshot = snapshotBuilder() + self._append_task_record(sourceTitle, message, snapshot.taskState.phase, snapshot.detailText) + self._refresh_workspace_document() + + def _handle_workspace_tab_changed(self, _index: int) -> None: + self._refresh_workspace_document() + + def _append_task_record( + self, + sourceTitle: str, + message: str, + phase: TaskPhase, + detailText: str = "", + ) -> None: + record = self.workspaceDocumentService.createTaskRecord( + sequenceNumber=len(self.taskRecords) + 1, + sourceTitle=sourceTitle, + message=message, + phase=phase, + detailText=detailText, + ) + self.taskRecords.append(record) + if len(self.taskRecords) > 50: + self.taskRecords = self.taskRecords[-50:] + + def _collect_workspace_snapshots(self) -> list[WorkspacePanelSnapshot]: + interfaceStatusText = "接口页尚未初始化。" + interfaceCount = len(self.interfaceRecords) + if hasattr(self, "interfaceSummaryLabel"): + interfaceStatusText = self.interfaceSummaryLabel.text() + + if self.interfaceLoadActive: + interfaceState = TaskState.running(interfaceStatusText) + elif "失败" in interfaceStatusText: + interfaceState = TaskState.failed(interfaceStatusText) + elif interfaceCount: + interfaceState = TaskState.succeeded(interfaceStatusText) + else: + interfaceState = TaskState.idle(interfaceStatusText) + + snapshots = [ + WorkspacePanelSnapshot( + panelId="interfaces", + title="接口", + taskState=interfaceState, + itemCount=interfaceCount, + detailText=interfaceStatusText, + ), + self.packetBuilderTab.buildWorkspaceSnapshot(), + self.sendTaskTab.buildWorkspaceSnapshot(), + self.offlineAnalysisTab.buildWorkspaceSnapshot(), + self.automationToolsTab.buildWorkspaceSnapshot(), + ] + return snapshots + + def _refresh_workspace_document(self) -> None: + activeIndex = self.workspace_tabs.currentIndex() + activeTabTitle = self.workspace_tabs.tabText(activeIndex) if activeIndex >= 0 else "欢迎" + openTabTitles = [self.workspace_tabs.tabText(index) for index in range(self.workspace_tabs.count())] + self.workspaceDocument = self.workspaceDocumentService.buildWorkspaceDocument( + activeTabTitle=activeTabTitle, + openTabTitles=openTabTitles, + panelSnapshots=self._collect_workspace_snapshots(), + taskRecords=self.taskRecords, + interfaceCount=len(self.interfaceRecords), + interfaceSummaryText=self.interfaceSummaryLabel.text() if hasattr(self, "interfaceSummaryLabel") else "", + ) + if hasattr(self, "workspace_summary"): + self.workspace_summary.setPlainText(self.workspaceDocument.to_multiline_text()) diff --git a/gui/src/packet_studio/runtime/__init__.py b/gui/src/packet_studio/runtime/__init__.py new file mode 100644 index 00000000000..69e6e96441d --- /dev/null +++ b/gui/src/packet_studio/runtime/__init__.py @@ -0,0 +1 @@ +"""Runtime helpers for Scapy Studio.""" \ No newline at end of file diff --git a/gui/src/packet_studio/runtime/dependency_check.py b/gui/src/packet_studio/runtime/dependency_check.py new file mode 100644 index 00000000000..8eea83395b9 --- /dev/null +++ b/gui/src/packet_studio/runtime/dependency_check.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import ctypes +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from packet_studio.runtime.paths import RuntimeDirectories + + +@dataclass(frozen=True) +class AppEnvironment: + scapy_version: Optional[str] + pyside6_version: Optional[str] + npcap_status: str + is_elevated: bool + config_dir: Path + log_dir: Path + cache_dir: Path + + def to_multiline_text(self) -> str: + lines = [ + "Scapy Studio 运行环境概览", + "", + f"Scapy 版本: {self.scapy_version or '未检测到'}", + f"PySide6 版本: {self.pyside6_version or '运行时导入'}", + f"Npcap 状态: {self.npcap_status}", + f"管理员权限: {'是' if self.is_elevated else '否'}", + f"配置目录: {self.config_dir}", + f"日志目录: {self.log_dir}", + f"缓存目录: {self.cache_dir}", + ] + return "\n".join(lines) + + +def collect_environment(runtime_dirs: RuntimeDirectories) -> AppEnvironment: + return AppEnvironment( + scapy_version=_detect_scapy_version(), + pyside6_version=_detect_pyside6_version(), + npcap_status=_detect_npcap_status(), + is_elevated=_is_elevated(), + config_dir=runtime_dirs.config_dir, + log_dir=runtime_dirs.log_dir, + cache_dir=runtime_dirs.cache_dir, + ) + + +def _detect_scapy_version() -> Optional[str]: + try: + import scapy # type: ignore + + return getattr(scapy, "VERSION", None) + except Exception: + return None + + +def _detect_pyside6_version() -> Optional[str]: + try: + import PySide6 # type: ignore + + return getattr(PySide6, "__version__", None) + except Exception: + return None + + +def _detect_npcap_status() -> str: + if os.name != "nt": + return "当前不是 Windows 环境" + + program_files = os.environ.get("ProgramFiles", r"C:\Program Files") + candidates = [ + Path(program_files) / "Npcap", + Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32" / "Npcap", + ] + for candidate in candidates: + if candidate.exists(): + return f"已检测到: {candidate}" + return "未检测到 Npcap 安装目录" + + +def _is_elevated() -> bool: + if os.name != "nt": + return os.getuid() == 0 if hasattr(os, "getuid") else False + + try: + return bool(ctypes.windll.shell32.IsUserAnAdmin()) + except Exception: + return False \ No newline at end of file diff --git a/gui/src/packet_studio/runtime/paths.py b/gui/src/packet_studio/runtime/paths.py new file mode 100644 index 00000000000..9a98e2cc157 --- /dev/null +++ b/gui/src/packet_studio/runtime/paths.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class RuntimeDirectories: + config_dir: Path + log_dir: Path + cache_dir: Path + + +def _base_data_dir() -> Path: + app_data = os.environ.get("APPDATA") + if app_data: + return Path(app_data) + return Path.home() / ".config" + + +def _base_cache_dir() -> Path: + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + return Path(local_app_data) + return Path.home() / ".cache" + + +def ensure_runtime_directories() -> RuntimeDirectories: + base_config_dir = _base_data_dir() / "ScapyStudio" + base_log_dir = base_config_dir / "logs" + base_cache_dir = _base_cache_dir() / "ScapyStudio" + + for directory in [base_config_dir, base_log_dir, base_cache_dir]: + directory.mkdir(parents=True, exist_ok=True) + + return RuntimeDirectories( + config_dir=base_config_dir, + log_dir=base_log_dir, + cache_dir=base_cache_dir, + ) \ No newline at end of file diff --git a/gui/src/packet_studio/services/__init__.py b/gui/src/packet_studio/services/__init__.py new file mode 100644 index 00000000000..f81e3bf5324 --- /dev/null +++ b/gui/src/packet_studio/services/__init__.py @@ -0,0 +1 @@ +"""Application services for Scapy Studio.""" \ No newline at end of file diff --git a/gui/src/packet_studio/services/interface_service.py b/gui/src/packet_studio/services/interface_service.py new file mode 100644 index 00000000000..2ed67b88371 --- /dev/null +++ b/gui/src/packet_studio/services/interface_service.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import List + + +@dataclass(frozen=True) +class InterfaceRecord: + name: str + description: str + networkName: str + mac: str + ipv4: str + ipv6: str + provider: str + isValid: bool + flags: str + index: int + + @property + def isLoopback(self) -> bool: + loweredName = self.name.lower() + loweredDescription = self.description.lower() + loweredNetworkName = self.networkName.lower() + return ( + "loopback" in loweredName + or "loopback" in loweredDescription + or "loopback" in loweredNetworkName + ) + + @property + def capabilityTags(self) -> list[str]: + tags = [] + if self.isValid: + tags.append("可用") + else: + tags.append("不可用") + if self.isLoopback: + tags.append("回环") + if self.ipv4: + tags.append("IPv4") + if self.ipv6: + tags.append("IPv6") + if self.mac: + tags.append("L2") + if self.provider: + tags.append(self.provider) + return tags + + @property + def capabilitySummary(self) -> str: + return " / ".join(self.capabilityTags) + + +class InterfaceService: + """封装 Scapy 接口发现流程。""" + + def loadInterfaces(self) -> List[InterfaceRecord]: + import scapy.all as scapy # noqa: F401 + from scapy.config import conf + from scapy.interfaces import get_working_ifaces + + conf.ifaces.reload() + interfaces = get_working_ifaces() + records = [] + for interface in interfaces: + ipv4 = interface.ip or "" + ipv6 = ", ".join(interface.ips[6]) if interface.ips[6] else "" + records.append( + InterfaceRecord( + name=interface.name or interface.description or interface.network_name, + description=interface.description or interface.name, + networkName=interface.network_name, + mac=interface.mac or "", + ipv4=ipv4, + ipv6=ipv6, + provider=interface.provider.name, + isValid=interface.is_valid(), + flags=str(getattr(interface, "flags", "")), + index=int(interface.index), + ) + ) + records.sort(key=lambda interfaceRecord: interfaceRecord.name.lower()) + return records \ No newline at end of file diff --git a/gui/src/packet_studio/services/packet_builder_service.py b/gui/src/packet_studio/services/packet_builder_service.py new file mode 100644 index 00000000000..63ef5d05d7e --- /dev/null +++ b/gui/src/packet_studio/services/packet_builder_service.py @@ -0,0 +1,992 @@ +from __future__ import annotations + +import ast +import importlib +import json +import pkgutil +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple + +from packet_studio.adapters.scapy_packet_adapter import ScapyPacketAdapter + + +@dataclass(frozen=True) +class AvailableLayer: + key: str + label: str + packetClassName: str + category: str + + +@dataclass(frozen=True) +class LayerFieldRecord: + name: str + fieldType: str + defaultValue: str + currentValue: str + editorKind: str + choices: Tuple[Tuple[str, str], ...] = () + placeholderText: str = "" + collectionKind: str = "" + + +@dataclass(frozen=True) +class LayerRecord: + index: int + name: str + summary: str + + +class PacketBuilderService: + """最小包构建器服务。""" + + CATEGORY_COMMON = "常用层" + CATEGORY_WIRELESS = "无线与近场" + CATEGORY_INDUSTRIAL = "工业与企业" + CATEGORY_CONTRIB = "Contrib 扩展" + CATEGORY_OTHER = "其他" + CATEGORY_ORDER: Tuple[str, ...] = ( + CATEGORY_COMMON, + CATEGORY_WIRELESS, + CATEGORY_INDUSTRIAL, + CATEGORY_CONTRIB, + CATEGORY_OTHER, + ) + + _COMMON_MODULE_NAMES: Tuple[str, ...] = ( + "inet", + "inet6", + "l2", + "dns", + "dhcp", + "dhcp6", + "http", + "tls", + "quic", + "sctp", + "vxlan", + "ntp", + "rip", + "ppp", + "tftp", + "radius", + "l2tp", + "isakmp", + ) + _WIRELESS_MODULE_NAMES: Tuple[str, ...] = ( + "bluetooth", + "dot11", + "zigbee", + "dot15d4", + "sixlowpan", + "nfc", + "ubertooth", + ) + _INDUSTRIAL_MODULE_NAMES: Tuple[str, ...] = ( + "netflow", + "ldap", + "kerberos", + "smb", + "smb2", + "dcerpc", + "gssapi", + "ntlm", + "msrpce", + "pnio", + "modbus", + "tacacs", + ) + + _LEGACY_LAYER_DEFINITIONS: Tuple[Tuple[str, str, str], ...] = ( + ("ether", "Ethernet", "Ether"), + ("arp", "ARP", "ARP"), + ("ip", "IPv4", "IP"), + ("ipv6", "IPv6", "IPv6"), + ("tcp", "TCP", "TCP"), + ("udp", "UDP", "UDP"), + ("icmp", "ICMP", "ICMP"), + ("dns", "DNS", "DNS"), + ("dot1q", "802.1Q VLAN", "Dot1Q"), + ("dot1ad", "802.1ad QinQ", "Dot1AD"), + ("mac_pause", "802.3x Pause Frame", "MACControlPause"), + ("mac_gate", "802.3 Gate Control", "MACControlGate"), + ("mac_report", "802.3 Report Control", "MACControlReport"), + ("mac_register_req", "802.3 Register Request", "MACControlRegisterReq"), + ("mac_register", "802.3 Register", "MACControlRegister"), + ("mac_register_ack", "802.3 Register Acknowledge", "MACControlRegisterAck"), + ( + "mac_pfc", + "802.1Qbb Priority Flow Control", + "MACControlClassBasedFlowControl", + ), + ("raw", "Raw Payload", "Raw"), + ) + + _DISCOVERY_PACKAGE_NAMES: Tuple[str, ...] = ("scapy.layers", "scapy.contrib") + _DISCOVERY_MODULE_PREFIXES: Tuple[str, ...] = ("scapy.layers.", "scapy.contrib.") + _EXCLUDED_PACKET_CLASS_NAMES: Tuple[str, ...] = ( + "Packet", + "NoPayload", + "Padding", + "ASN1_Packet", + "MACControl", + ) + + _COMMON_ETHER_TYPE_CHOICES: Tuple[Tuple[int, str], ...] = ( + (0x0800, "IPv4"), + (0x0806, "ARP"), + (0x86DD, "IPv6"), + (0x8100, "802.1Q VLAN"), + (0x88A8, "802.1ad QinQ"), + (0x8808, "Ethernet PAUSE"), + (0x8809, "Slow Protocols"), + (0x8847, "MPLS Unicast"), + (0x8848, "MPLS Multicast"), + (0x8863, "PPPoE Discovery"), + (0x8864, "PPPoE Session"), + (0x88CC, "LLDP"), + (0x88E5, "MACsec"), + (0x88F7, "PTP"), + ) + + def __init__(self) -> None: + import scapy.all as scapy + from scapy.packet import Packet + from scapy.layers.inet import ( + IPOption_EOL, + IPOption_LSRR, + IPOption_NOP, + IPOption_RR, + IPOption_Router_Alert, + IPOption_SSRR, + IPOption_Security, + IPOption_Timestamp, + ) + from scapy.contrib.mac_control import ( + MACControlGate, + MACControlClassBasedFlowControl, + MACControlPause, + MACControlRegister, + MACControlRegisterAck, + MACControlRegisterReq, + MACControlReport, + ) + + self._scapy = scapy + self._packetBaseClass = Packet + self._availableLayerTypes: Dict[str, Callable[[], Any]] = {} + self._availableLayerClasses: Dict[str, type[Any]] = {} + self._availableLayers: List[AvailableLayer] = [] + self._layerKeysByClass: Dict[type[Any], str] = {} + + legacyLayerClasses: Dict[str, type[Any]] = { + "Ether": scapy.Ether, + "ARP": scapy.ARP, + "IP": scapy.IP, + "IPv6": scapy.IPv6, + "TCP": scapy.TCP, + "UDP": scapy.UDP, + "ICMP": scapy.ICMP, + "DNS": scapy.DNS, + "Dot1Q": scapy.Dot1Q, + "Dot1AD": scapy.Dot1AD, + "MACControlPause": MACControlPause, + "MACControlGate": MACControlGate, + "MACControlReport": MACControlReport, + "MACControlRegisterReq": MACControlRegisterReq, + "MACControlRegister": MACControlRegister, + "MACControlRegisterAck": MACControlRegisterAck, + "MACControlClassBasedFlowControl": MACControlClassBasedFlowControl, + "Raw": scapy.Raw, + } + + for key, label, className in self._LEGACY_LAYER_DEFINITIONS: + self._registerAvailableLayer(key, label, legacyLayerClasses[className]) + + self._loadOptionalProtocolModules() + self._registerDiscoveredLayers() + self._layers: List[Any] = [] + self._ipOptionFactories: Dict[str, Callable[..., Any]] = { + "NOP": IPOption_NOP, + "EOL": IPOption_EOL, + "RR": IPOption_RR, + "LSRR": IPOption_LSRR, + "SSRR": IPOption_SSRR, + "Timestamp": IPOption_Timestamp, + "RouterAlert": IPOption_Router_Alert, + "Security": IPOption_Security, + } + self._packetAdapter = ScapyPacketAdapter() + + def listAvailableLayers(self) -> List[AvailableLayer]: + return list(self._availableLayers) + + def listAvailableLayerCategories(self) -> List[str]: + presentCategories = {layer.category for layer in self._availableLayers} + return [category for category in self.CATEGORY_ORDER if category in presentCategories] + + def _registerAvailableLayer( + self, + key: str, + label: str, + packetType: type[Any], + ) -> None: + if packetType in self._layerKeysByClass: + return + + self._availableLayerTypes[key] = packetType + self._availableLayerClasses[key] = packetType + category = self._categorizePacketType(packetType) + self._availableLayers.append(AvailableLayer(key, label, packetType.__name__, category)) + self._layerKeysByClass[packetType] = key + + def _loadOptionalProtocolModules(self) -> None: + for packageName in self._DISCOVERY_PACKAGE_NAMES: + self._importProtocolModules(packageName) + + def _importProtocolModules(self, packageName: str) -> None: + try: + package = importlib.import_module(packageName) + except Exception: + return + + packagePath = getattr(package, "__path__", None) + if packagePath is None: + return + + moduleNames = sorted( + moduleInfo.name + for moduleInfo in pkgutil.walk_packages(packagePath, prefix=packageName + ".") + ) + + for moduleName in moduleNames: + if moduleName.endswith(".__main__"): + continue + try: + importlib.import_module(moduleName) + except Exception: + continue + + def _registerDiscoveredLayers(self) -> None: + discoveredPacketTypes = sorted( + self._iterDiscoveredPacketTypes(), + key=lambda packetType: (packetType.__module__, packetType.__name__), + ) + + for packetType in discoveredPacketTypes: + if not self._shouldExposePacketType(packetType): + continue + if packetType in self._layerKeysByClass: + continue + + key = self._buildUniqueLayerKey(packetType) + label = self._buildLayerLabel(packetType) + self._registerAvailableLayer(key, label, packetType) + + legacyKeys = {key for key, _, _ in self._LEGACY_LAYER_DEFINITIONS} + legacyLayers = [layer for layer in self._availableLayers if layer.key in legacyKeys] + discoveredLayers = [layer for layer in self._availableLayers if layer.key not in legacyKeys] + discoveredLayers.sort(key=lambda layer: (layer.label.casefold(), layer.packetClassName.casefold())) + self._availableLayers = legacyLayers + discoveredLayers + + def _iterDiscoveredPacketTypes(self) -> List[type[Any]]: + discoveredPacketTypes: Dict[str, type[Any]] = {} + + for module in list(sys.modules.values()): + moduleName = getattr(module, "__name__", "") + if not moduleName.startswith(self._DISCOVERY_MODULE_PREFIXES): + continue + + moduleDictionary = getattr(module, "__dict__", None) + if not isinstance(moduleDictionary, dict): + continue + + for value in moduleDictionary.values(): + if not isinstance(value, type): + continue + if not issubclass(value, self._packetBaseClass): + continue + discoveredPacketTypes[f"{value.__module__}.{value.__name__}"] = value + + return list(discoveredPacketTypes.values()) + + def _shouldExposePacketType(self, packetType: type[Any]) -> bool: + if packetType.__name__ in self._EXCLUDED_PACKET_CLASS_NAMES: + return False + if packetType.__name__.startswith("_"): + return False + if packetType.__module__ == "scapy.packet": + return packetType is self._scapy.Raw + if not packetType.__module__.startswith(self._DISCOVERY_MODULE_PREFIXES): + return False + + try: + packetType() + except Exception: + return False + + return True + + def _buildUniqueLayerKey(self, packetType: type[Any]) -> str: + baseKey = self._normalizeLayerKey(packetType.__name__) + if baseKey not in self._availableLayerTypes: + return baseKey + + moduleParts = [ + self._normalizeLayerKey(part) + for part in packetType.__module__.split(".") + if part and part not in {"scapy", "layers", "contrib"} + ] + for modulePart in reversed(moduleParts): + candidate = f"{baseKey}_{modulePart}" + if candidate not in self._availableLayerTypes: + return candidate + + suffix = 2 + while True: + candidate = f"{baseKey}_{suffix}" + if candidate not in self._availableLayerTypes: + return candidate + suffix += 1 + + def _buildLayerLabel(self, packetType: type[Any]) -> str: + className = packetType.__name__ + rawDisplayName = getattr(packetType, "name", "") + displayName = rawDisplayName.strip() if isinstance(rawDisplayName, str) else "" + if not displayName or displayName == className: + return className + return f"{displayName} ({className})" + + def _categorizePacketType(self, packetType: type[Any]) -> str: + if packetType in self._layerKeysByClass: + existingKey = self._layerKeysByClass[packetType] + legacyKeys = {key for key, _, _ in self._LEGACY_LAYER_DEFINITIONS} + if existingKey in legacyKeys: + return self.CATEGORY_COMMON + + moduleName = packetType.__module__ + moduleParts = moduleName.split(".") + moduleLeaf = moduleParts[-1] if moduleParts else "" + + if moduleName.startswith("scapy.contrib."): + return self.CATEGORY_CONTRIB + if moduleLeaf in self._WIRELESS_MODULE_NAMES: + return self.CATEGORY_WIRELESS + if moduleLeaf in self._INDUSTRIAL_MODULE_NAMES: + return self.CATEGORY_INDUSTRIAL + if moduleLeaf in self._COMMON_MODULE_NAMES: + return self.CATEGORY_COMMON + return self.CATEGORY_OTHER + + def _normalizeLayerKey(self, value: str) -> str: + normalizedValue = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", value) + normalizedValue = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", normalizedValue) + normalizedValue = re.sub(r"[^0-9A-Za-z]+", "_", normalizedValue).strip("_").lower() + if not normalizedValue: + normalizedValue = "layer" + if normalizedValue[0].isdigit(): + normalizedValue = f"layer_{normalizedValue}" + return normalizedValue + + def reset(self) -> None: + self._layers = [] + + def addLayer(self, key: str) -> None: + packetFactory = self._availableLayerTypes[key] + self._layers.append(packetFactory()) + + def removeLayer(self, index: int) -> None: + del self._layers[index] + + def moveLayer(self, sourceIndex: int, targetIndex: int) -> None: + layer = self._layers.pop(sourceIndex) + self._layers.insert(targetIndex, layer) + + def reorderLayers(self, sourceIndexes: List[int]) -> None: + self._layers = [self._layers[index] for index in sourceIndexes] + + def getLayerRecords(self) -> List[LayerRecord]: + records = [] + for index, layer in enumerate(self._layers): + records.append( + LayerRecord( + index=index, + name=layer.name, + summary=layer.summary(), + ) + ) + return records + + def getFieldRecords(self, layerIndex: int) -> List[LayerFieldRecord]: + layer = self._layers[layerIndex] + records = [] + for field in layer.fields_desc: + currentValue = layer.getfieldval(field.name) + editorKind, choices, placeholderText, collectionKind = self._describeFieldEditor(layer, field) + records.append( + LayerFieldRecord( + name=field.name, + fieldType=field.__class__.__name__, + defaultValue=self._formatFieldValue(layer, field, field.default), + currentValue=self._formatFieldValue(layer, field, currentValue), + editorKind=editorKind, + choices=choices, + placeholderText=placeholderText, + collectionKind=collectionKind, + ) + ) + return records + + def getFieldValue(self, layerIndex: int, fieldName: str) -> str: + layer = self._layers[layerIndex] + field = layer.get_field(fieldName) + return self._formatFieldValue(layer, field, layer.getfieldval(fieldName)) + + def getFieldNativeValue(self, layerIndex: int, fieldName: str) -> Any: + layer = self._layers[layerIndex] + return layer.getfieldval(fieldName) + + def exportTemplate(self) -> Dict[str, Any]: + layers = [] + for layer in self._layers: + layerKey = self._resolveLayerKey(layer) + serializedFields = {} + for fieldName, currentValue in layer.fields.items(): + defaultValue = layer.default_fields.get(fieldName) + serializedValue = self._serializeFieldValue(layer, fieldName, currentValue) + defaultSerializedValue = self._serializeFieldValue(layer, fieldName, defaultValue) + if serializedValue == defaultSerializedValue: + continue + serializedFields[fieldName] = serializedValue + layers.append( + { + "key": layerKey, + "fields": serializedFields, + } + ) + return { + "version": 1, + "layers": layers, + } + + def importTemplate(self, payload: Dict[str, Any]) -> None: + layers = payload.get("layers", []) + self.reset() + for layerDefinition in layers: + layerKey = layerDefinition["key"] + self.addLayer(layerKey) + layerIndex = len(self._layers) - 1 + for fieldName, rawValue in layerDefinition.get("fields", {}).items(): + self.setSerializedFieldValue(layerIndex, fieldName, rawValue) + + def saveTemplate(self, filePath: str) -> None: + payload = self.exportTemplate() + Path(filePath).write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + def loadTemplate(self, filePath: str) -> None: + payload = json.loads(Path(filePath).read_text(encoding="utf-8")) + self.importTemplate(payload) + + def importPacket(self, packet: Any) -> None: + importedLayers: List[Any] = [] + + currentLayer = packet.copy() + while currentLayer is not None and currentLayer.__class__.__name__ != "NoPayload": + nextLayer = getattr(currentLayer, "payload", None) + + if not self._shouldSkipImportedLayer(currentLayer): + layerCopy = currentLayer.copy() + layerCopy.remove_payload() + self._appendImportedLayer(importedLayers, layerCopy) + + if nextLayer is None or nextLayer.__class__.__name__ == "NoPayload": + break + currentLayer = nextLayer + + self._layers = importedLayers + + def _appendImportedLayer(self, importedLayers: List[Any], layer: Any) -> None: + self._resolveLayerKey(layer) + importedLayers.append(layer) + + def _shouldSkipImportedLayer(self, layer: Any) -> bool: + if self._canResolveLayerKey(layer): + return False + + payload = getattr(layer, "payload", None) + if payload is None or payload.__class__.__name__ == "NoPayload": + return False + + publicFields = [field for field in layer.fields_desc if not field.name.startswith("_")] + return len(publicFields) == 0 + + def setFieldValue(self, layerIndex: int, fieldName: str, rawValue: str) -> None: + layer = self._layers[layerIndex] + if rawValue == "": + layer.delfieldval(fieldName) + return + + field = layer.get_field(fieldName) + parsedValue = self._parseFieldValue(rawValue, field.default) + parsedValue = self._coerceFieldValue(layer, fieldName, field, parsedValue) + layer.setfieldval(fieldName, parsedValue) + + def setSerializedFieldValue(self, layerIndex: int, fieldName: str, rawValue: Any) -> None: + if isinstance(rawValue, str): + self.setFieldValue(layerIndex, fieldName, rawValue) + return + + layer = self._layers[layerIndex] + field = layer.get_field(fieldName) + parsedValue = self._coerceFieldValue(layer, fieldName, field, rawValue) + layer.setfieldval(fieldName, parsedValue) + + def buildSummary(self) -> str: + packet = self.buildPacket() + if packet is None: + return "尚未添加任何协议层。" + return self._packetAdapter.buildSummary(packet) + + def buildHexdump(self) -> str: + packet = self.buildPacket() + if packet is None: + return "" + return self._packetAdapter.buildHexdump(packet) + + def buildStructureDump(self) -> str: + packet = self.buildPacket() + if packet is None: + return "尚未添加任何协议层。" + return self._packetAdapter.buildStructureDump(packet) + + def buildPacket(self) -> Optional[Any]: + if not self._layers: + return None + + packet = self._layers[0].copy() + for layer in self._layers[1:]: + packet = packet / layer.copy() + return packet + + def _parseFieldValue(self, rawValue: str, defaultValue: Any) -> Any: + stripped = rawValue.strip() + if isinstance(defaultValue, bytes): + if stripped.startswith("b'") or stripped.startswith('b"'): + return ast.literal_eval(stripped) + return rawValue.encode("utf-8") + + if stripped.lower() in {"true", "false"}: + return stripped.lower() == "true" + + try: + return ast.literal_eval(stripped) + except Exception: + pass + + if isinstance(defaultValue, int): + return int(stripped, 0) + + return rawValue + + def _coerceFieldValue( + self, + layer: Any, + fieldName: str, + field: Any, + value: Any, + ) -> Any: + if layer.__class__ is self._scapy.IP and fieldName == "options": + return self._coerceIpOptionList(value) + + if layer.__class__ is self._scapy.DNS and fieldName == "qd": + return self._coerceDnsQuestionList(value) + + return value + + def _coerceDnsQuestionList(self, value: Any) -> Any: + if not isinstance(value, list): + return value + + questions = [] + for item in value: + if isinstance(item, self._scapy.DNSQR): + questions.append(item) + continue + + if isinstance(item, dict): + questions.append( + self._scapy.DNSQR( + qname=item.get("qname", ""), + qtype=item.get("qtype", "A"), + qclass=item.get("qclass", "IN"), + ) + ) + continue + + if isinstance(item, str): + questions.append( + self._scapy.DNSQR( + qname=item, + qtype="A", + qclass="IN", + ) + ) + continue + + questions.append(item) + + return questions + + def _serializeDnsQuestionList(self, value: Any) -> Any: + if not isinstance(value, list): + return self._valueToEditorText(value) + + questions = [] + for item in value: + if isinstance(item, self._scapy.DNSQR): + qname = getattr(item, "qname", b"") + if isinstance(qname, bytes): + qname = qname.decode("utf-8", errors="replace") + questions.append( + { + "qname": str(qname).rstrip("."), + "qtype": self._dnsQuestionFieldLabel(item, "qtype", "A"), + "qclass": self._dnsQuestionFieldLabel(item, "qclass", "IN"), + } + ) + continue + + if isinstance(item, dict): + questions.append( + { + "qname": str(item.get("qname", "")), + "qtype": str(item.get("qtype", "A")), + "qclass": str(item.get("qclass", "IN")), + } + ) + continue + + if isinstance(item, str): + questions.append( + { + "qname": item, + "qtype": "A", + "qclass": "IN", + } + ) + continue + + questions.append(self._valueToEditorText(item)) + + return questions + + def _dnsQuestionFieldLabel(self, packet: Any, fieldName: str, fallback: str) -> str: + try: + field = packet.get_field(fieldName) + except Exception: + return fallback + + fieldValue = getattr(packet, fieldName, fallback) + label = field.i2repr(packet, fieldValue) + if isinstance(label, str) and label: + return label + return self._valueToEditorText(fieldValue) or fallback + + def _coerceIpOptionList(self, value: Any) -> Any: + if not isinstance(value, list): + return value + + options = [] + for item in value: + if item.__class__.__name__.startswith("IPOption"): + options.append(item) + continue + + if isinstance(item, str): + factory = self._ipOptionFactories.get(item) + if factory is not None: + options.append(factory()) + continue + + if isinstance(item, dict): + optionType = str(item.get("type", "")).strip() + factory = self._ipOptionFactories.get(optionType) + if factory is None: + options.append(item) + continue + + optionFields = self._extractIpOptionFields(item) + options.append(factory(**optionFields)) + continue + + options.append(item) + + return options + + def _serializeIpOptionList(self, value: Any) -> Any: + if not isinstance(value, list): + return self._valueToEditorText(value) + + options = [] + for item in value: + className = item.__class__.__name__ + if className == "IPOption_NOP": + options.append({"type": "NOP"}) + elif className == "IPOption_EOL": + options.append({"type": "EOL"}) + elif className in {"IPOption_RR", "IPOption_LSRR", "IPOption_SSRR"}: + options.append( + { + "type": className.removeprefix("IPOption_"), + "routers": list(getattr(item, "routers", [])), + } + ) + elif className == "IPOption_Timestamp": + options.append( + { + "type": "Timestamp", + "flg": str(getattr(item, "flg", "timestamp_only")), + "internet_address": str(getattr(item, "internet_address", "0.0.0.0")), + "timestamp": int(getattr(item, "timestamp", 0)), + } + ) + elif className == "IPOption_Router_Alert": + options.append({"type": "RouterAlert"}) + elif className == "IPOption_Security": + options.append( + { + "type": "Security", + "security": int(getattr(item, "security", 0)), + "compartment": int(getattr(item, "compartment", 0)), + "handling_restrictions": int(getattr(item, "handling_restrictions", 0)), + "transmission_control_code": str(getattr(item, "transmission_control_code", "xxx")), + } + ) + elif isinstance(item, dict): + options.append(dict(item)) + else: + options.append(self._valueToEditorText(item)) + + return options + + def _extractIpOptionFields(self, payload: Dict[str, Any]) -> Dict[str, Any]: + optionFields = dict(payload) + optionFields.pop("type", None) + + routers = optionFields.get("routers") + if isinstance(routers, str): + optionFields["routers"] = [router.strip() for router in routers.split(",") if router.strip()] + + if "timestamp" in optionFields: + optionFields["timestamp"] = int(optionFields["timestamp"]) + if "pointer" in optionFields: + optionFields["pointer"] = int(optionFields["pointer"]) + if "flg" in optionFields: + flgValue = optionFields["flg"] + flgMap = { + "timestamp_only": 0, + "timestamp_and_ip_addr": 1, + "prespecified_ip_addr": 3, + } + if isinstance(flgValue, str) and flgValue in flgMap: + optionFields["flg"] = flgMap[flgValue] + if "security" in optionFields: + optionFields["security"] = int(optionFields["security"]) + if "compartment" in optionFields: + optionFields["compartment"] = int(optionFields["compartment"]) + if "handling_restrictions" in optionFields: + optionFields["handling_restrictions"] = int(optionFields["handling_restrictions"]) + + return optionFields + + def _resolveLayerKey(self, layer: Any) -> str: + exactMatch = self._findExactLayerKey(layer) + if exactMatch is not None: + return exactMatch + + for key, packetType in self._availableLayerClasses.items(): + if isinstance(layer, packetType): + return key + raise ValueError(f"Unsupported layer type: {layer.__class__.__name__}") + + def _canResolveLayerKey(self, layer: Any) -> bool: + try: + self._resolveLayerKey(layer) + return True + except ValueError: + return False + + def _findExactLayerKey(self, layer: Any) -> Optional[str]: + for key, packetType in self._availableLayerClasses.items(): + if layer.__class__ is packetType: + return key + return None + + def _serializeFieldValue(self, layer: Any, fieldName: str, value: Any) -> Any: + if layer.__class__ is self._scapy.IP and fieldName == "options": + return self._serializeIpOptionList(value) + + if layer.__class__ is self._scapy.DNS and fieldName == "qd": + return self._serializeDnsQuestionList(value) + + return self._valueToEditorText(value) + + def _describeFieldEditor( + self, + layer: Any, + field: Any, + ) -> Tuple[str, Tuple[Tuple[str, str], ...], str, str]: + if self._isMacField(field): + return "mac", (), "示例: 00:11:22:33:44:55", "" + + if self._isIPv4Field(field): + return "ipv4", (), "示例: 192.168.1.10", "" + + if self._isIPv6Field(field): + return "ipv6", (), "示例: 2001:db8::10", "" + + if self._isCollectionField(field): + collectionKind = self._describeCollectionKind(field) + return ( + "collection", + (), + "支持 Python 字面量列表/元组,例如 []、['value']。复杂 PacketList 当前优先支持清空或保持默认值。", + collectionKind, + ) + + choices = self._extractFieldChoices(layer, field) + if choices: + placeholderText = "" + if self._isEtherTypeField(layer, field): + placeholderText = "支持 0x0800、0x86DD、0x8808 等十六进制 Ethertype。" + return "enum", choices, placeholderText, "" + + if self._isBooleanField(field): + return "bool", (), "", "" + + if isinstance(field.default, bytes): + return "bytes", (), "支持直接输入文本,或输入 b'\\x00\\x01' 形式的字节串。", "" + + return "text", (), "", "" + + def _extractFieldChoices(self, layer: Any, field: Any) -> Tuple[Tuple[str, str], ...]: + unwrappedField = self._unwrapField(field) + mapping = getattr(unwrappedField, "i2s", None) + choices = [] + seenKeys: set[str] = set() + + if self._isEtherTypeField(layer, field): + for key, label in self._COMMON_ETHER_TYPE_CHOICES: + keyText = self._formatChoiceValue(layer, field, key) + choices.append((keyText, f"{label} ({keyText})")) + seenKeys.add(keyText) + + if mapping: + for key, label in mapping.items(): + keyText = self._formatChoiceValue(layer, field, key) + if keyText in seenKeys: + continue + choices.append((keyText, f"{label} ({keyText})")) + seenKeys.add(keyText) + + return tuple(choices) + + def _formatFieldValue(self, layer: Any, field: Any, value: Any) -> str: + if self._isEtherTypeField(layer, field) and isinstance(value, int): + return self._formatHexValue(value) + return self._valueToEditorText(value) + + def _formatChoiceValue(self, layer: Any, field: Any, value: Any) -> str: + if self._isEtherTypeField(layer, field) and isinstance(value, int): + return self._formatHexValue(value) + return self._valueToEditorText(value) + + def _formatHexValue(self, value: int) -> str: + return f"0x{value:04X}" + + def _isBooleanField(self, field: Any) -> bool: + unwrappedField = self._unwrapField(field) + if type(unwrappedField.default) is bool: + return True + + size = getattr(unwrappedField, "size", None) + return ( + size == 1 + and unwrappedField.default in {0, 1} + and not getattr(unwrappedField, "i2s", None) + ) + + def _isMacField(self, field: Any) -> bool: + unwrappedField = self._unwrapField(field) + return self._fieldInheritsFrom(unwrappedField, "MACField") + + def _isIPv4Field(self, field: Any) -> bool: + unwrappedField = self._unwrapField(field) + return ( + self._fieldInheritsFrom(unwrappedField, "IPField") + or field.name in {"psrc", "pdst"} + ) + + def _isIPv6Field(self, field: Any) -> bool: + unwrappedField = self._unwrapField(field) + return self._fieldInheritsFrom(unwrappedField, "IP6Field") + + def _isCollectionField(self, field: Any) -> bool: + unwrappedField = self._unwrapField(field) + return ( + isinstance(unwrappedField.default, (list, tuple)) + or unwrappedField.__class__.__name__ in { + "FieldListField", + "PacketListField", + "_DNSPacketListField", + } + ) + + def _describeCollectionKind(self, field: Any) -> str: + unwrappedField = self._unwrapField(field) + if field.name == "options" and unwrappedField.__class__.__name__ == "PacketListField": + return "ip_options" + + if unwrappedField.__class__.__name__ == "_DNSPacketListField" and field.name == "qd": + return "dns_questions" + + if isinstance(unwrappedField.default, (list, tuple)): + return "literal_list" + + return "raw" + + def _unwrapField(self, field: Any) -> Any: + return getattr(field, "fld", field) + + def _isEtherTypeField(self, layer: Any, field: Any) -> bool: + if field.name != "type": + return False + + return layer.__class__ in { + self._scapy.Ether, + self._scapy.Dot1Q, + self._scapy.Dot1AD, + } + + def _fieldInheritsFrom(self, field: Any, className: str) -> bool: + return any(base.__name__ == className for base in field.__class__.__mro__) + + def _valueToEditorText(self, value: Any) -> str: + if value is None: + return "" + if isinstance(value, bytes): + try: + return value.decode("utf-8") + except UnicodeDecodeError: + return repr(value) + if isinstance(value, str): + return value + if isinstance(value, (int, float, bool)): + return str(value) + return repr(value) \ No newline at end of file diff --git a/gui/src/packet_studio/services/pcap_analysis_service.py b/gui/src/packet_studio/services/pcap_analysis_service.py new file mode 100644 index 00000000000..c7df4709f2f --- /dev/null +++ b/gui/src/packet_studio/services/pcap_analysis_service.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import Any + +from packet_studio.adapters.scapy_pcap_adapter import ScapyPcapAdapter +from packet_studio.domain.packet_models import PcapPacketRecord +from packet_studio.domain.task_models import PcapLoadResult, TaskState + + +class PcapAnalysisService: + """封装 pcap/pcapng 基础读取流程。""" + + def __init__( + self, + pcapReaderFactory: Any | None = None, + pcapAdapter: ScapyPcapAdapter | None = None, + ) -> None: + if pcapReaderFactory is None: + from scapy.utils import PcapReader + + self._pcapReaderFactory = PcapReader + else: + self._pcapReaderFactory = pcapReaderFactory + self._pcapAdapter = pcapAdapter or ScapyPcapAdapter() + + def loadPackets(self, filePath: str, maxPackets: int = 500) -> PcapLoadResult: + packetRecords: list[PcapPacketRecord] = [] + with self._pcapReaderFactory(filePath) as reader: + for index, packet in enumerate(reader, start=1): + packetRecords.append(self._pcapAdapter.buildPacketRecord(index, packet)) + if maxPackets > 0 and index >= maxPackets: + break + + summaryText = f"离线抓包文件加载完成,共 {len(packetRecords)} 个数据包。" + + return PcapLoadResult( + filePath=filePath, + packetRecords=packetRecords, + summaryText=summaryText, + logText=summaryText, + state=TaskState.succeeded("离线抓包文件加载完成。"), + ) diff --git a/gui/src/packet_studio/services/send_task_service.py b/gui/src/packet_studio/services/send_task_service.py new file mode 100644 index 00000000000..3b8ec0c4bab --- /dev/null +++ b/gui/src/packet_studio/services/send_task_service.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import time +from typing import Any, Callable, Optional + +from packet_studio.adapters.scapy_packet_adapter import ScapyPacketAdapter +from packet_studio.domain.packet_models import PacketPreview +from packet_studio.domain.task_models import SendTaskResult, TaskState + + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SendTaskRequest: + mode: str + sendStrategy: str = "burst" + interfaceName: str = "" + count: int = 1 + intervalSeconds: float = 0.0 + timeoutSeconds: float = 1.0 + retryCount: int = 0 + + +class SendTaskService: + """封装 send、sendp 和 sr1 的最小服务层。""" + + def __init__( + self, + scapyModule: Any | None = None, + packetAdapter: ScapyPacketAdapter | None = None, + ) -> None: + if scapyModule is None: + import scapy.all as scapy + + self._scapy = scapy + else: + self._scapy = scapyModule + self._packetAdapter = packetAdapter or ScapyPacketAdapter() + + def buildPacketPreview(self, packet: Any | None) -> Optional[PacketPreview]: + if packet is None: + return None + packetCopy = self._packetAdapter.clonePacket(packet) + return self._packetAdapter.buildPreview(packetCopy) + + def execute( + self, + request: SendTaskRequest, + packetOrPackets: Any | list[Any] | None, + stopRequested: Callable[[], bool] | None = None, + sleep: Callable[[float], None] | None = None, + ) -> SendTaskResult: + packets = self._normalize_packets(packetOrPackets) + if not packets: + raise ValueError("当前没有可发送的数据包,请先在包构建器中添加协议层。") + if request.sendStrategy not in {"burst", "continuous"}: + raise ValueError(f"不支持的发送策略: {request.sendStrategy}") + if request.mode == "sr1" and request.sendStrategy == "continuous": + raise ValueError("sr1 当前仅支持 burst 模式,不支持 continuous 持续发送。") + + shouldStop = stopRequested or (lambda: False) + sleepFn = sleep or time.sleep + packetPreview = self._packetAdapter.buildPreview( + self._packetAdapter.clonePacket(packets[0]) + ) + interfaceName = request.interfaceName.strip() + streamCount = len(packets) + logLines = [ + f"执行模式: {request.mode}", + f"发送策略: {request.sendStrategy}", + f"流数量: {streamCount}", + ] + + if request.mode == "send": + sentCount, stoppedEarly = self._run_send_loop( + packets=packets, + request=request, + interfaceName=interfaceName, + shouldStop=shouldStop, + sleepFn=sleepFn, + ) + logLines.extend( + [ + "底层发送: send (L3)", + f"发送轮次: {'持续发送' if request.sendStrategy == 'continuous' else request.count}", + f"发送数量: {sentCount}", + f"发送间隔: {request.intervalSeconds:.3f}s", + ] + ) + if interfaceName: + logLines.append("L3 send 由 Scapy 路由自动选择接口,未显式传入 iface。") + state = TaskState.stopped("发送任务已停止。") if stoppedEarly else TaskState.succeeded("发送任务执行完成。") + summaryPrefix = "已停止" if stoppedEarly else "已完成" + summaryText = ( + f"模式: {request.mode},{summaryPrefix}发送 {sentCount} 个数据包," + f"共 {streamCount} 条流,未应答 0 个。" + ) + return SendTaskResult( + mode=request.mode, + sentCount=sentCount, + packetPreview=packetPreview, + answerPreview=None, + unansweredCount=0, + summaryText=summaryText, + logText="\n".join(logLines), + state=state, + ) + + if request.mode == "sendp": + sentCount, stoppedEarly = self._run_send_loop( + packets=packets, + request=request, + interfaceName=interfaceName, + shouldStop=shouldStop, + sleepFn=sleepFn, + ) + logLines.extend( + [ + "底层发送: sendp (L2)", + f"发送轮次: {'持续发送' if request.sendStrategy == 'continuous' else request.count}", + f"发送数量: {sentCount}", + f"发送间隔: {request.intervalSeconds:.3f}s", + f"发送接口: {interfaceName or '自动'}", + ] + ) + state = TaskState.stopped("发送任务已停止。") if stoppedEarly else TaskState.succeeded("发送任务执行完成。") + summaryPrefix = "已停止" if stoppedEarly else "已完成" + summaryText = ( + f"模式: {request.mode},{summaryPrefix}发送 {sentCount} 个数据包," + f"共 {streamCount} 条流,未应答 0 个。" + ) + return SendTaskResult( + mode=request.mode, + sentCount=sentCount, + packetPreview=packetPreview, + answerPreview=None, + unansweredCount=0, + summaryText=summaryText, + logText="\n".join(logLines), + state=state, + ) + + if request.mode == "sr1": + sentCount, unansweredCount, answerPreview, stoppedEarly = self._run_sr1_loop( + packets=packets, + request=request, + shouldStop=shouldStop, + ) + logLines.extend( + [ + "底层发送: sr1 (L3 请求/响应)", + f"发送轮次: {request.count}", + f"发送数量: {sentCount}", + f"超时时间: {request.timeoutSeconds:.3f}s", + f"重试次数: {request.retryCount}", + ] + ) + if interfaceName: + logLines.append("L3 sr1 由 Scapy 路由自动选择接口,未显式传入 iface。") + logLines.append("收到应答。" if answerPreview is not None else "未收到应答。") + state = TaskState.stopped("发送任务已停止。") if stoppedEarly else TaskState.succeeded("发送任务执行完成。") + summaryPrefix = "已停止" if stoppedEarly else "已完成" + summaryText = ( + f"模式: {request.mode},{summaryPrefix}发送 {sentCount} 个数据包," + f"共 {streamCount} 条流,未应答 {unansweredCount} 个。" + ) + return SendTaskResult( + mode=request.mode, + sentCount=sentCount, + packetPreview=packetPreview, + answerPreview=answerPreview, + unansweredCount=unansweredCount, + summaryText=summaryText, + logText="\n".join(logLines), + state=state, + ) + + raise ValueError(f"不支持的发送模式: {request.mode}") + + def _normalize_packets(self, packetOrPackets: Any | list[Any] | None) -> list[Any]: + if packetOrPackets is None: + return [] + if isinstance(packetOrPackets, list): + return [packet for packet in packetOrPackets if packet is not None] + return [packetOrPackets] + + def _run_send_loop( + self, + packets: list[Any], + request: SendTaskRequest, + interfaceName: str, + shouldStop: Callable[[], bool], + sleepFn: Callable[[float], None], + ) -> tuple[int, bool]: + sentCount = 0 + completedRounds = 0 + + while True: + for packet in packets: + if shouldStop(): + return sentCount, True + sendArgs: dict[str, Any] = { + "count": 1, + "inter": 0.0, + "verbose": False, + "return_packets": True, + } + if request.mode == "sendp" and interfaceName: + sendArgs["iface"] = interfaceName + sendMethod = self._scapy.sendp if request.mode == "sendp" else self._scapy.send + sentPackets = sendMethod(self._packetAdapter.clonePacket(packet), **sendArgs) + sentCount += len(sentPackets or []) + if request.intervalSeconds > 0 and not shouldStop(): + sleepFn(request.intervalSeconds) + + completedRounds += 1 + if request.sendStrategy == "burst" and completedRounds >= request.count: + return sentCount, False + + def _run_sr1_loop( + self, + packets: list[Any], + request: SendTaskRequest, + shouldStop: Callable[[], bool], + ) -> tuple[int, int, PacketPreview | None, bool]: + sentCount = 0 + unansweredCount = 0 + answerPreview: PacketPreview | None = None + + for _roundIndex in range(request.count): + for packet in packets: + if shouldStop(): + return sentCount, unansweredCount, answerPreview, True + answerPacket = self._scapy.sr1( + self._packetAdapter.clonePacket(packet), + timeout=request.timeoutSeconds, + retry=request.retryCount, + verbose=False, + ) + sentCount += 1 + if answerPacket is None: + unansweredCount += 1 + continue + answerPreview = self._packetAdapter.buildPreview(answerPacket) + + return sentCount, unansweredCount, answerPreview, False diff --git a/gui/src/packet_studio/services/tool_registry_service.py b/gui/src/packet_studio/services/tool_registry_service.py new file mode 100644 index 00000000000..1672f0d3a0c --- /dev/null +++ b/gui/src/packet_studio/services/tool_registry_service.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ToolRegistration: + toolId: str + title: str + description: str + targetTabTitle: str + category: str + + +class ToolRegistryService: + """提供最小内置工具注册表。""" + + def listTools(self) -> list[ToolRegistration]: + return [ + ToolRegistration( + toolId="packet-builder", + title="包构建器", + description="构建多层数据包,逐字段编辑并实时查看结构与十六进制预览。", + targetTabTitle="包构建器", + category="核心工作流", + ), + ToolRegistration( + toolId="send-task", + title="发送任务", + description="执行 send、sendp、sr1 任务,并查看请求响应结果。", + targetTabTitle="发送任务", + category="核心工作流", + ), + ToolRegistration( + toolId="offline-analysis", + title="离线分析", + description="打开 pcap 或 pcapng 文件,浏览离线抓包结果并复制回构建器。", + targetTabTitle="离线分析", + category="核心工作流", + ), + ] \ No newline at end of file diff --git a/gui/src/packet_studio/services/workspace_document_service.py b/gui/src/packet_studio/services/workspace_document_service.py new file mode 100644 index 00000000000..fc6657de463 --- /dev/null +++ b/gui/src/packet_studio/services/workspace_document_service.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from packet_studio.domain.task_models import TaskPhase +from packet_studio.domain.workspace_models import TaskRecord, WorkspaceDocument, WorkspacePanelSnapshot + + +class WorkspaceDocumentService: + """构建工作区快照与任务记录。""" + + def createTaskRecord( + self, + sequenceNumber: int, + sourceTitle: str, + message: str, + phase: TaskPhase, + detailText: str = "", + ) -> TaskRecord: + return TaskRecord( + sequenceNumber=sequenceNumber, + sourceTitle=sourceTitle, + message=message, + phase=phase, + detailText=detailText, + ) + + def buildWorkspaceDocument( + self, + activeTabTitle: str, + openTabTitles: list[str], + panelSnapshots: list[WorkspacePanelSnapshot], + taskRecords: list[TaskRecord], + interfaceCount: int, + interfaceSummaryText: str, + ) -> WorkspaceDocument: + return WorkspaceDocument( + activeTabTitle=activeTabTitle, + openTabTitles=list(openTabTitles), + panelSnapshots=list(panelSnapshots), + taskRecords=list(taskRecords), + interfaceCount=interfaceCount, + interfaceSummaryText=interfaceSummaryText, + ) \ No newline at end of file diff --git a/gui/src/packet_studio/validation_launcher.py b/gui/src/packet_studio/validation_launcher.py new file mode 100644 index 00000000000..fa5635a4b22 --- /dev/null +++ b/gui/src/packet_studio/validation_launcher.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import logging +import sys +from pathlib import Path +from typing import Sequence + +from packet_studio.app import configure_logging +from packet_studio.main_window import MainWindow +from packet_studio.runtime.dependency_check import AppEnvironment, collect_environment +from packet_studio.runtime.paths import ensure_runtime_directories + + +def _build_checklist_text(environment: AppEnvironment, log_file: Path) -> str: + lines = [ + "Scapy Studio Windows 验收启动器", + "", + f"Scapy 版本: {environment.scapy_version or '未检测到'}", + f"PySide6 版本: {environment.pyside6_version or '未检测到'}", + f"Npcap 状态: {environment.npcap_status}", + f"管理员权限: {'是' if environment.is_elevated else '否'}", + f"日志文件: {log_file}", + "", + "建议人工验收清单:", + "1. 在欢迎页确认环境摘要、日志目录和依赖状态。", + "2. 打开接口页,刷新并确认目标网卡是否可见。", + "3. 在包构建器中添加 IP/ICMP 或 Ether/ARP,确认摘要、结构和十六进制联动。", + "4. 在发送任务页验证 send、sendp 或 sr1 基本路径。", + "5. 在离线分析页打开 pcap 或 pcapng,验证列表、过滤、详情和复制回构包。", + "", + "说明:", + "- sendp 和部分接口能力仍依赖 Npcap、管理员权限和网卡驱动。", + "- 如果目标机只做人工验收,优先使用此启动器;自动化测试仍建议在开发机源码环境运行。", + ] + return "\n".join(lines) + + +def main(argv: Sequence[str] | None = None) -> int: + try: + from PySide6 import QtWidgets + except ModuleNotFoundError: + print( + "未安装 PySide6,无法启动 Windows 验收启动器。\n" + "请先在 gui 子项目环境中执行: pip install -e .", + file=sys.stderr, + ) + return 1 + + runtime_dirs = ensure_runtime_directories() + log_file = configure_logging(runtime_dirs.log_dir) + logging.getLogger(__name__).info("日志文件: %s", log_file) + + app_argv = list(argv) if argv is not None else sys.argv + application = QtWidgets.QApplication(app_argv) + application.setApplicationName("Scapy Studio Validation") + application.setOrganizationName("Scapy Studio") + + environment = collect_environment(runtime_dirs) + messageBox = QtWidgets.QMessageBox() + messageBox.setWindowTitle("Scapy Studio Windows 验收") + messageBox.setIcon(QtWidgets.QMessageBox.Icon.Information) + messageBox.setText("将启动 Scapy Studio GUI,并显示当前机器的验收前置信息。") + messageBox.setDetailedText(_build_checklist_text(environment, log_file)) + launchButton = messageBox.addButton("启动 GUI 验收", QtWidgets.QMessageBox.ButtonRole.AcceptRole) + messageBox.addButton("取消", QtWidgets.QMessageBox.ButtonRole.RejectRole) + messageBox.exec() + if messageBox.clickedButton() is not launchButton: + return 0 + + window = MainWindow(environment=environment, log_file=log_file) + window.show() + return application.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/gui/src/packet_studio/widgets/__init__.py b/gui/src/packet_studio/widgets/__init__.py new file mode 100644 index 00000000000..3e7a0f4e98f --- /dev/null +++ b/gui/src/packet_studio/widgets/__init__.py @@ -0,0 +1 @@ +"""UI widgets for Scapy Studio.""" \ No newline at end of file diff --git a/gui/src/packet_studio/widgets/automation_tools_widget.py b/gui/src/packet_studio/widgets/automation_tools_widget.py new file mode 100644 index 00000000000..b06066869cb --- /dev/null +++ b/gui/src/packet_studio/widgets/automation_tools_widget.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from PySide6 import QtCore, QtWidgets + +from packet_studio.domain.task_models import TaskState +from packet_studio.domain.workspace_models import WorkspacePanelSnapshot +from packet_studio.services.tool_registry_service import ToolRegistration, ToolRegistryService + + +class AutomationToolsWidget(QtWidgets.QWidget): + """最小自动化工具入口页。""" + + openToolRequested = QtCore.Signal(str) + statusMessage = QtCore.Signal(str) + + def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: + super().__init__(parent) + self.toolRegistryService = ToolRegistryService() + self.currentTaskState = TaskState.idle("请选择一个工具入口。") + self._setup_ui() + self._populate_tools() + + def buildWorkspaceSnapshot(self) -> WorkspacePanelSnapshot: + return WorkspacePanelSnapshot( + panelId="automation-tools", + title="自动化工具", + taskState=self.currentTaskState, + itemCount=self.toolList.topLevelItemCount(), + detailText=self.statusLabel.text(), + ) + + def _setup_ui(self) -> None: + rootLayout = QtWidgets.QVBoxLayout(self) + + titleLabel = QtWidgets.QLabel("自动化工具") + titleLabel.setStyleSheet("font-size: 22px; font-weight: 700;") + subtitleLabel = QtWidgets.QLabel( + "当前阶段先提供最小工具注册入口。后续可在这里接入插件发现、专题协议工具页和自动机向导。" + ) + subtitleLabel.setWordWrap(True) + + self.toolList = QtWidgets.QTreeWidget() + self.toolList.setHeaderLabels(["工具", "分类", "说明"]) + self.toolList.header().setStretchLastSection(True) + self.toolList.itemSelectionChanged.connect(self._handle_selection_changed) + + actionLayout = QtWidgets.QHBoxLayout() + self.openToolButton = QtWidgets.QPushButton("打开选中工具") + self.openToolButton.clicked.connect(self._handle_open_tool) + self.statusLabel = QtWidgets.QLabel("请选择一个工具入口。") + self.statusLabel.setWordWrap(True) + actionLayout.addWidget(self.openToolButton) + actionLayout.addWidget(self.statusLabel, 1) + + rootLayout.addWidget(titleLabel) + rootLayout.addWidget(subtitleLabel) + rootLayout.addWidget(self.toolList, 1) + rootLayout.addLayout(actionLayout) + + def _populate_tools(self) -> None: + self.toolList.clear() + for tool in self.toolRegistryService.listTools(): + item = QtWidgets.QTreeWidgetItem([tool.title, tool.category, tool.description]) + item.setData(0, QtCore.Qt.ItemDataRole.UserRole, tool.targetTabTitle) + self.toolList.addTopLevelItem(item) + if self.toolList.topLevelItemCount() > 0: + self.toolList.setCurrentItem(self.toolList.topLevelItem(0)) + self._update_button_state() + + def _handle_selection_changed(self) -> None: + selectedItems = self.toolList.selectedItems() + if not selectedItems: + self.statusLabel.setText("请选择一个工具入口。") + self.currentTaskState = TaskState.idle(self.statusLabel.text()) + self._update_button_state() + return + + selectedTitle = selectedItems[0].text(0) + self.statusLabel.setText(f"已选择工具: {selectedTitle}") + self.currentTaskState = TaskState.idle(self.statusLabel.text()) + self._update_button_state() + + def _handle_open_tool(self) -> None: + selectedItems = self.toolList.selectedItems() + if not selectedItems: + self.statusLabel.setText("请先选择一个工具入口。") + return + + targetTabTitle = str(selectedItems[0].data(0, QtCore.Qt.ItemDataRole.UserRole)) + self.openToolRequested.emit(targetTabTitle) + self.statusLabel.setText(f"正在打开工具: {selectedItems[0].text(0)}") + self.currentTaskState = TaskState.running(self.statusLabel.text()) + self.statusMessage.emit(f"从自动化工具页打开: {selectedItems[0].text(0)}") + + def _update_button_state(self) -> None: + self.openToolButton.setEnabled(bool(self.toolList.selectedItems())) \ No newline at end of file diff --git a/gui/src/packet_studio/widgets/offline_analysis_widget.py b/gui/src/packet_studio/widgets/offline_analysis_widget.py new file mode 100644 index 00000000000..086fb156ac7 --- /dev/null +++ b/gui/src/packet_studio/widgets/offline_analysis_widget.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +import collections +from typing import Optional + +from PySide6 import QtCore, QtGui, QtWidgets + +from packet_studio.domain.packet_models import PcapPacketRecord +from packet_studio.domain.task_models import PcapLoadResult, TaskError, TaskState +from packet_studio.domain.workspace_models import WorkspacePanelSnapshot +from packet_studio.services.pcap_analysis_service import ( + PcapAnalysisService, +) + + +class OfflineAnalysisWorker(QtCore.QObject): + finished = QtCore.Signal(object) + failed = QtCore.Signal(object) + + def __init__( + self, + pcapAnalysisService: PcapAnalysisService, + filePath: str, + maxPackets: int, + ) -> None: + super().__init__() + self.pcapAnalysisService = pcapAnalysisService + self.filePath = filePath + self.maxPackets = maxPackets + + @QtCore.Slot() + def run(self) -> None: + try: + result = self.pcapAnalysisService.loadPackets(self.filePath, self.maxPackets) + except Exception as exc: + self.failed.emit(TaskError(message=str(exc), logText=str(exc))) + return + self.finished.emit(result) + + +class OfflineAnalysisWidget(QtWidgets.QWidget): + """最小离线 pcap 分析工作台。""" + + statusMessage = QtCore.Signal(str) + importPacketRequested = QtCore.Signal(object) + + def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: + super().__init__(parent) + self.pcapAnalysisService = PcapAnalysisService() + self.packetRecords: list[PcapPacketRecord] = [] + self.visiblePacketIndexes: list[int] = [] + self.currentFilePath = "" + self.workerThread: Optional[QtCore.QThread] = None + self.worker: Optional[OfflineAnalysisWorker] = None + self.currentTaskState = TaskState.idle("准备就绪。") + + self._setup_ui() + self._update_button_state() + + def isLoading(self) -> bool: + return self.workerThread is not None + + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + if self.isLoading(): + QtWidgets.QMessageBox.warning( + self, + "离线分析仍在加载", + "请等待当前离线加载完成后再关闭窗口。", + ) + event.ignore() + return + super().closeEvent(event) + + def buildWorkspaceSnapshot(self) -> WorkspacePanelSnapshot: + detailText = self.currentFilePath or self.summaryLabel.text() + return WorkspacePanelSnapshot( + panelId="offline-analysis", + title="离线分析", + taskState=self.currentTaskState, + itemCount=len(self.packetRecords), + detailText=detailText, + ) + + def _setup_ui(self) -> None: + rootLayout = QtWidgets.QVBoxLayout(self) + + controlsLayout = QtWidgets.QGridLayout() + controlsLayout.addWidget(QtWidgets.QLabel("文件"), 0, 0) + self.filePathEdit = QtWidgets.QLineEdit() + self.filePathEdit.setReadOnly(True) + controlsLayout.addWidget(self.filePathEdit, 0, 1, 1, 3) + + controlsLayout.addWidget(QtWidgets.QLabel("读取上限"), 1, 0) + self.maxPacketsSpin = QtWidgets.QSpinBox() + self.maxPacketsSpin.setRange(1, 1000000) + self.maxPacketsSpin.setValue(500) + controlsLayout.addWidget(self.maxPacketsSpin, 1, 1) + + controlsLayout.addWidget(QtWidgets.QLabel("显示过滤"), 1, 2) + self.searchEdit = QtWidgets.QLineEdit() + self.searchEdit.setPlaceholderText("按摘要、时间、接口、协议关键字搜索") + self.searchEdit.textChanged.connect(self._handle_search_changed) + controlsLayout.addWidget(self.searchEdit, 1, 3) + + actionLayout = QtWidgets.QHBoxLayout() + self.openFileButton = QtWidgets.QPushButton("打开 pcap") + self.reloadButton = QtWidgets.QPushButton("重新加载") + self.copyToBuilderButton = QtWidgets.QPushButton("复制到包构建器") + self.statusLabel = QtWidgets.QLabel("准备就绪。") + self.statusLabel.setWordWrap(True) + self.openFileButton.clicked.connect(self._handle_open_file) + self.reloadButton.clicked.connect(self._handle_reload) + self.copyToBuilderButton.clicked.connect(self._handle_copy_to_builder) + actionLayout.addWidget(self.openFileButton) + actionLayout.addWidget(self.reloadButton) + actionLayout.addWidget(self.copyToBuilderButton) + actionLayout.addWidget(self.statusLabel, 1) + + contentSplitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) + + tablePane = QtWidgets.QWidget() + tableLayout = QtWidgets.QVBoxLayout(tablePane) + self.summaryLabel = QtWidgets.QLabel("尚未打开离线抓包文件。") + self.summaryLabel.setWordWrap(True) + tableLayout.addWidget(self.summaryLabel) + self.statsLabel = QtWidgets.QLabel("") + self.statsLabel.setWordWrap(True) + tableLayout.addWidget(self.statsLabel) + self.packetTable = QtWidgets.QTableWidget(0, 4) + self.packetTable.setHorizontalHeaderLabels(["序号", "时间", "接口", "摘要"]) + self.packetTable.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers) + self.packetTable.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) + self.packetTable.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection) + self.packetTable.verticalHeader().setVisible(False) + self.packetTable.horizontalHeader().setStretchLastSection(True) + self.packetTable.itemSelectionChanged.connect(self._handle_packet_selection_changed) + tableLayout.addWidget(self.packetTable, 1) + + detailPane = QtWidgets.QWidget() + detailLayout = QtWidgets.QVBoxLayout(detailPane) + self.packetDetailSummary = QtWidgets.QLineEdit() + self.packetDetailSummary.setReadOnly(True) + detailLayout.addWidget(self.packetDetailSummary) + detailTabs = QtWidgets.QTabWidget() + self.packetStructureEdit = QtWidgets.QPlainTextEdit() + self.packetStructureEdit.setReadOnly(True) + self.packetHexdumpEdit = QtWidgets.QPlainTextEdit() + self.packetHexdumpEdit.setReadOnly(True) + detailTabs.addTab(self.packetStructureEdit, "结构") + detailTabs.addTab(self.packetHexdumpEdit, "十六进制") + detailLayout.addWidget(detailTabs, 1) + + contentSplitter.addWidget(tablePane) + contentSplitter.addWidget(detailPane) + contentSplitter.setStretchFactor(0, 1) + contentSplitter.setStretchFactor(1, 1) + + rootLayout.addLayout(controlsLayout) + rootLayout.addLayout(actionLayout) + rootLayout.addWidget(contentSplitter, 1) + + def _handle_open_file(self) -> None: + filePath, _ = QtWidgets.QFileDialog.getOpenFileName( + self, + "打开离线抓包文件", + "", + "Capture Files (*.pcap *.pcapng *.cap *.pcap.gz *.pcapng.gz);;All Files (*)", + ) + if not filePath: + return + self._start_loading(filePath) + + def _handle_reload(self) -> None: + if not self.currentFilePath: + self.statusLabel.setText("请先打开一个离线抓包文件。") + return + self._start_loading(self.currentFilePath) + + def _handle_copy_to_builder(self) -> None: + selectedItems = self.packetTable.selectedItems() + if not selectedItems: + self.statusLabel.setText("请先选择一个离线数据包。") + return + + rowIndex = int(selectedItems[0].data(QtCore.Qt.ItemDataRole.UserRole)) + record = self.packetRecords[rowIndex] + self.importPacketRequested.emit(record.packet.copy()) + self.statusLabel.setText("已将选中离线数据包发送到包构建器。") + self.statusMessage.emit("已将选中离线数据包复制到包构建器。") + + def _handle_search_changed(self, _text: str) -> None: + self._rebuild_packet_table() + + def _start_loading(self, filePath: str) -> None: + if self.isLoading(): + self.statusLabel.setText("离线文件仍在加载中,请稍候。") + return + + self.currentFilePath = filePath + self.filePathEdit.setText(filePath) + self._apply_task_state(TaskState.running("正在后台加载离线抓包文件...")) + self.summaryLabel.setText("正在读取离线抓包文件...") + self._set_loading_state(True) + + thread = QtCore.QThread(self) + worker = OfflineAnalysisWorker( + self.pcapAnalysisService, + filePath, + int(self.maxPacketsSpin.value()), + ) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._on_load_finished) + worker.failed.connect(self._on_load_failed) + worker.finished.connect(thread.quit) + worker.failed.connect(thread.quit) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(self._on_thread_finished) + + self.workerThread = thread + self.worker = worker + thread.start() + + @QtCore.Slot(object) + def _on_load_finished(self, result: PcapLoadResult) -> None: + self.currentFilePath = result.filePath + self.filePathEdit.setText(result.filePath) + self.packetRecords = list(result.packetRecords) + self.packetDetailSummary.clear() + self.packetStructureEdit.clear() + self.packetHexdumpEdit.clear() + self._rebuild_packet_table() + self._apply_task_state(result.state) + self.statusMessage.emit(result.summaryText) + + @QtCore.Slot(object) + def _on_load_failed(self, error: TaskError) -> None: + self.packetRecords = [] + self.packetTable.clearContents() + self.packetTable.setRowCount(0) + self.packetDetailSummary.clear() + self.packetStructureEdit.clear() + self.packetHexdumpEdit.clear() + self.summaryLabel.setText(f"离线抓包文件加载失败: {error.summaryText}") + self.statsLabel.clear() + self._apply_task_state(error.state) + self.statusMessage.emit(f"离线抓包文件加载失败: {error.summaryText}") + + def _on_thread_finished(self) -> None: + self.workerThread = None + self.worker = None + self._set_loading_state(False) + self._update_button_state() + + def _handle_packet_selection_changed(self) -> None: + selectedItems = self.packetTable.selectedItems() + self._update_button_state() + if not selectedItems: + return + sourceIndex = int(selectedItems[0].data(QtCore.Qt.ItemDataRole.UserRole)) + record = self.packetRecords[sourceIndex] + self.packetDetailSummary.setText(record.preview.summary) + self.packetStructureEdit.setPlainText(record.preview.structure) + self.packetHexdumpEdit.setPlainText(record.preview.hexdump) + + def _set_loading_state(self, isLoading: bool) -> None: + self.openFileButton.setEnabled(not isLoading) + self.reloadButton.setEnabled(not isLoading and bool(self.currentFilePath)) + self.maxPacketsSpin.setEnabled(not isLoading) + self.packetTable.setEnabled(not isLoading) + + def _update_button_state(self) -> None: + hasSelection = self.packetTable.currentRow() >= 0 + isLoading = self.isLoading() + self.copyToBuilderButton.setEnabled(hasSelection and not isLoading) + self.reloadButton.setEnabled((not isLoading) and bool(self.currentFilePath)) + + def _apply_task_state(self, state: TaskState) -> None: + self.currentTaskState = state + self.statusLabel.setText(state.statusText) + + def _rebuild_packet_table(self) -> None: + previousSourceIndex = self._current_selected_source_index() + self.visiblePacketIndexes = self._filtered_packet_indexes() + self.packetTable.clearContents() + self.packetTable.setRowCount(len(self.visiblePacketIndexes)) + + for rowIndex, sourceIndex in enumerate(self.visiblePacketIndexes): + record = self.packetRecords[sourceIndex] + values = [ + str(record.index), + record.timestampText, + record.sourceText, + record.summary, + ] + for columnIndex, value in enumerate(values): + item = QtWidgets.QTableWidgetItem(value) + item.setData(QtCore.Qt.ItemDataRole.UserRole, sourceIndex) + self.packetTable.setItem(rowIndex, columnIndex, item) + + self._refresh_summary_labels() + + if not self.visiblePacketIndexes: + self.packetDetailSummary.clear() + self.packetStructureEdit.clear() + self.packetHexdumpEdit.clear() + return + + if previousSourceIndex in self.visiblePacketIndexes: + self.packetTable.selectRow(self.visiblePacketIndexes.index(previousSourceIndex)) + return + + self.packetTable.selectRow(0) + + def _filtered_packet_indexes(self) -> list[int]: + query = self.searchEdit.text().strip().lower() + if not query: + return list(range(len(self.packetRecords))) + + visibleIndexes = [] + for index, record in enumerate(self.packetRecords): + haystack = "\n".join([ + record.summary, + record.timestampText, + record.sourceText, + record.protocolName, + ]).lower() + if query in haystack: + visibleIndexes.append(index) + return visibleIndexes + + def _refresh_summary_labels(self) -> None: + totalCount = len(self.packetRecords) + visibleCount = len(self.visiblePacketIndexes) + if totalCount == 0: + self.summaryLabel.setText("尚未打开离线抓包文件。") + self.statsLabel.clear() + return + + if visibleCount == totalCount: + self.summaryLabel.setText( + f"已从 {self.currentFilePath} 读取 {totalCount} 个数据包。" + ) + else: + self.summaryLabel.setText( + f"已从 {self.currentFilePath} 读取 {totalCount} 个数据包,当前显示 {visibleCount} 个。" + ) + + counter = collections.Counter( + self.packetRecords[index].protocolName + for index in self.visiblePacketIndexes + ) + statsText = "、".join( + f"{protocol}: {count}" for protocol, count in counter.most_common(4) + ) + self.statsLabel.setText(f"基础统计: {statsText}" if statsText else "") + + def _current_selected_source_index(self) -> int | None: + selectedItems = self.packetTable.selectedItems() + if not selectedItems: + return None + return int(selectedItems[0].data(QtCore.Qt.ItemDataRole.UserRole)) diff --git a/gui/src/packet_studio/widgets/packet_builder_widget.py b/gui/src/packet_studio/widgets/packet_builder_widget.py new file mode 100644 index 00000000000..8fbb1dfd4c8 --- /dev/null +++ b/gui/src/packet_studio/widgets/packet_builder_widget.py @@ -0,0 +1,1319 @@ +from __future__ import annotations + +import ast +import ipaddress +import re +from pathlib import Path + +from PySide6 import QtCore, QtGui, QtWidgets + +from packet_studio.domain.task_models import TaskState +from packet_studio.domain.workspace_models import WorkspacePanelSnapshot +from packet_studio.services.packet_builder_service import AvailableLayer, LayerFieldRecord, PacketBuilderService + + +class LayerListWidget(QtWidgets.QListWidget): + orderChanged = QtCore.Signal() + + def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: + super().__init__(parent) + self.setDragDropMode(QtWidgets.QAbstractItemView.DragDropMode.InternalMove) + self.setDefaultDropAction(QtCore.Qt.DropAction.MoveAction) + + def dropEvent(self, event: QtGui.QDropEvent) -> None: + super().dropEvent(event) + self.orderChanged.emit() + + +class PacketBuilderWidget(QtWidgets.QWidget): + """最小可用包构建器。""" + + packetChanged = QtCore.Signal(object) + createStreamRequested = QtCore.Signal(object) + saveStreamRequested = QtCore.Signal(object) + + def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: + super().__init__(parent) + self.packetBuilderService = PacketBuilderService() + self._availableLayers = self.packetBuilderService.listAvailableLayers() + self._updatingFieldTable = False + self._filteredFieldNames: list[str] = [] + self._visibleFieldRecords: dict[str, LayerFieldRecord] = {} + self._editingStreamMode = False + + self._setup_ui() + self._refresh_all() + + def getCurrentPacket(self) -> object | None: + packet = self.packetBuilderService.buildPacket() + if packet is None: + return None + return packet.copy() + + def buildWorkspaceSnapshot(self) -> WorkspacePanelSnapshot: + statusText = self.builderStatusLabel.text() + taskState = TaskState.failed(statusText) if "失败" in statusText else TaskState.idle(statusText) + return WorkspacePanelSnapshot( + panelId="packet-builder", + title="包构建器", + taskState=taskState, + itemCount=len(self.packetBuilderService.getLayerRecords()), + detailText=self.summaryEdit.text(), + ) + + def loadPacket(self, packet: object) -> None: + try: + self.packetBuilderService.importPacket(packet) + except Exception as exc: + self.builderStatusLabel.setText(f"导入数据包失败: {exc}") + raise + + self.builderStatusLabel.setText("已导入当前数据包。") + self._refresh_all(selectLast=False, selectedRow=0) + + def _setup_ui(self) -> None: + rootLayout = QtWidgets.QVBoxLayout(self) + + controlsLayout = QtWidgets.QHBoxLayout() + self.layerCategoryCombo = QtWidgets.QComboBox() + self.layerCategoryCombo.addItem("全部", "全部") + for category in self.packetBuilderService.listAvailableLayerCategories(): + self.layerCategoryCombo.addItem(category, category) + self.layerTypeCombo = QtWidgets.QComboBox() + self.layerTypeCombo.setEditable(True) + self.layerTypeCombo.setInsertPolicy(QtWidgets.QComboBox.InsertPolicy.NoInsert) + self.layerCategoryCombo.currentIndexChanged.connect(self._handle_layer_category_changed) + self._rebuild_layer_type_combo(preferredKey="ip") + + self.addLayerButton = QtWidgets.QPushButton("添加层") + self.removeLayerButton = QtWidgets.QPushButton("删除选中层") + self.moveLayerUpButton = QtWidgets.QPushButton("上移") + self.moveLayerDownButton = QtWidgets.QPushButton("下移") + self.createStreamButton = QtWidgets.QPushButton("创建流") + self.saveStreamButton = QtWidgets.QPushButton("保存到当前流") + self.saveTemplateButton = QtWidgets.QPushButton("保存模板") + self.loadTemplateButton = QtWidgets.QPushButton("加载模板") + self.resetButton = QtWidgets.QPushButton("重置") + self.builderStatusLabel = QtWidgets.QLabel("准备就绪。") + self.builderStatusLabel.setWordWrap(True) + + self.addLayerButton.clicked.connect(self._handle_add_layer) + self.removeLayerButton.clicked.connect(self._handle_remove_layer) + self.moveLayerUpButton.clicked.connect(self._handle_move_layer_up) + self.moveLayerDownButton.clicked.connect(self._handle_move_layer_down) + self.createStreamButton.clicked.connect(self._handle_create_stream) + self.saveStreamButton.clicked.connect(self._handle_save_stream) + self.saveTemplateButton.clicked.connect(self._handle_save_template) + self.loadTemplateButton.clicked.connect(self._handle_load_template) + self.resetButton.clicked.connect(self._handle_reset) + + controlsLayout.addWidget(QtWidgets.QLabel("分类")) + controlsLayout.addWidget(self.layerCategoryCombo) + controlsLayout.addWidget(QtWidgets.QLabel("层类型")) + controlsLayout.addWidget(self.layerTypeCombo) + controlsLayout.addWidget(self.addLayerButton) + controlsLayout.addWidget(self.removeLayerButton) + controlsLayout.addWidget(self.moveLayerUpButton) + controlsLayout.addWidget(self.moveLayerDownButton) + controlsLayout.addWidget(self.createStreamButton) + controlsLayout.addWidget(self.saveStreamButton) + controlsLayout.addWidget(self.saveTemplateButton) + controlsLayout.addWidget(self.loadTemplateButton) + controlsLayout.addWidget(self.resetButton) + controlsLayout.addWidget(self.builderStatusLabel, 1) + + contentSplitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) + + leftPane = QtWidgets.QWidget() + leftLayout = QtWidgets.QVBoxLayout(leftPane) + leftLayout.addWidget(QtWidgets.QLabel("当前协议层")) + self.layerList = LayerListWidget() + self.layerList.currentRowChanged.connect(self._handle_layer_selection_changed) + self.layerList.orderChanged.connect(self._handle_layer_reordered) + leftLayout.addWidget(self.layerList, 1) + + middlePane = QtWidgets.QWidget() + middleLayout = QtWidgets.QVBoxLayout(middlePane) + middleLayout.addWidget(QtWidgets.QLabel("字段编辑")) + self.fieldSearchEdit = QtWidgets.QLineEdit() + self.fieldSearchEdit.setPlaceholderText("搜索字段名或字段类型") + self.fieldSearchEdit.textChanged.connect(self._handle_field_search_changed) + middleLayout.addWidget(self.fieldSearchEdit) + self.fieldTable = QtWidgets.QTableWidget(0, 4) + self.fieldTable.setHorizontalHeaderLabels(["字段", "类型", "默认值", "当前值"]) + self.fieldTable.verticalHeader().setVisible(False) + self.fieldTable.horizontalHeader().setStretchLastSection(True) + self.fieldTable.setSelectionBehavior( + QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows + ) + self.fieldTable.itemChanged.connect(self._handle_field_item_changed) + middleLayout.addWidget(self.fieldTable, 1) + + self.rawPayloadGroup = QtWidgets.QGroupBox("Raw Payload 编辑") + rawPayloadLayout = QtWidgets.QVBoxLayout(self.rawPayloadGroup) + rawPayloadHint = QtWidgets.QLabel( + "当选中 Raw 层时,可在这里用多行文本编辑 load 字段。" + ) + rawPayloadHint.setWordWrap(True) + self.rawPayloadEdit = QtWidgets.QPlainTextEdit() + self.applyRawPayloadButton = QtWidgets.QPushButton("应用 Payload") + self.applyRawPayloadButton.clicked.connect(self._handle_apply_raw_payload) + rawPayloadLayout.addWidget(rawPayloadHint) + rawPayloadLayout.addWidget(self.rawPayloadEdit, 1) + rawPayloadLayout.addWidget(self.applyRawPayloadButton) + self.rawPayloadGroup.setVisible(False) + middleLayout.addWidget(self.rawPayloadGroup) + + rightPane = QtWidgets.QWidget() + rightLayout = QtWidgets.QVBoxLayout(rightPane) + rightLayout.addWidget(QtWidgets.QLabel("摘要")) + self.summaryEdit = QtWidgets.QLineEdit() + self.summaryEdit.setReadOnly(True) + rightLayout.addWidget(self.summaryEdit) + + previewTabs = QtWidgets.QTabWidget() + self.structureEdit = QtWidgets.QPlainTextEdit() + self.structureEdit.setReadOnly(True) + self.hexdumpEdit = QtWidgets.QPlainTextEdit() + self.hexdumpEdit.setReadOnly(True) + previewTabs.addTab(self.structureEdit, "结构") + previewTabs.addTab(self.hexdumpEdit, "十六进制") + rightLayout.addWidget(previewTabs, 1) + + contentSplitter.addWidget(leftPane) + contentSplitter.addWidget(middlePane) + contentSplitter.addWidget(rightPane) + contentSplitter.setStretchFactor(0, 0) + contentSplitter.setStretchFactor(1, 1) + contentSplitter.setStretchFactor(2, 1) + + rootLayout.addLayout(controlsLayout) + rootLayout.addWidget(contentSplitter, 1) + self._update_stream_buttons() + + def setEditingStreamMode(self, isEditing: bool) -> None: + self._editingStreamMode = isEditing + self._update_stream_buttons() + + def _handle_add_layer(self) -> None: + layerKey = self._resolve_selected_layer_key() + if layerKey is None: + self.builderStatusLabel.setText("请选择有效的协议层。") + return + self.packetBuilderService.addLayer(layerKey) + self.builderStatusLabel.setText(f"已添加协议层: {self.layerTypeCombo.currentText()}") + self._refresh_all(selectLast=True) + + def _resolve_selected_layer_key(self) -> str | None: + currentText = self.layerTypeCombo.currentText().strip() + currentIndex = self.layerTypeCombo.currentIndex() + + if currentIndex >= 0 and self.layerTypeCombo.itemText(currentIndex) == currentText: + currentData = self.layerTypeCombo.itemData(currentIndex) + if isinstance(currentData, str) and currentData: + return currentData + + normalizedText = currentText.casefold() + for itemIndex in range(self.layerTypeCombo.count()): + if self.layerTypeCombo.itemText(itemIndex).casefold() != normalizedText: + continue + self.layerTypeCombo.setCurrentIndex(itemIndex) + currentData = self.layerTypeCombo.itemData(itemIndex) + if isinstance(currentData, str) and currentData: + return currentData + + return None + + def _handle_layer_category_changed(self) -> None: + self._rebuild_layer_type_combo() + + def _rebuild_layer_type_combo(self, preferredKey: str | None = None) -> None: + currentKey = preferredKey or self._resolve_combo_selected_key(self.layerTypeCombo) + selectedCategory = str(self.layerCategoryCombo.currentData() or "全部") + visibleLayers = self._filter_layers_by_category(selectedCategory) + + self.layerTypeCombo.blockSignals(True) + self.layerTypeCombo.clear() + defaultIndex = 0 + for index, layer in enumerate(visibleLayers): + self.layerTypeCombo.addItem(layer.label, layer.key) + if layer.key == currentKey: + defaultIndex = index + elif currentKey is None and layer.key == "ip": + defaultIndex = index + if self.layerTypeCombo.count() > 0: + self.layerTypeCombo.setCurrentIndex(defaultIndex) + self.layerTypeCombo.blockSignals(False) + self.layerTypeCombo.lineEdit().setPlaceholderText("搜索协议层") + self._install_contains_completer(self.layerTypeCombo) + + def _filter_layers_by_category(self, category: str) -> list[AvailableLayer]: + if category == "全部": + return list(self._availableLayers) + return [layer for layer in self._availableLayers if layer.category == category] + + def _resolve_combo_selected_key(self, comboBox: QtWidgets.QComboBox) -> str | None: + currentIndex = comboBox.currentIndex() + if currentIndex < 0: + return None + currentData = comboBox.itemData(currentIndex) + if isinstance(currentData, str) and currentData: + return currentData + return None + + def _install_contains_completer(self, comboBox: QtWidgets.QComboBox) -> None: + completer = QtWidgets.QCompleter( + [comboBox.itemText(index) for index in range(comboBox.count())], + comboBox, + ) + completer.setCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive) + completer.setFilterMode(QtCore.Qt.MatchFlag.MatchContains) + comboBox.setCompleter(completer) + + def _handle_remove_layer(self) -> None: + layerIndex = self.layerList.currentRow() + if layerIndex < 0: + self.builderStatusLabel.setText("请先选择要删除的协议层。") + return + self.packetBuilderService.removeLayer(layerIndex) + self.builderStatusLabel.setText("已删除选中协议层。") + self._refresh_all() + + def _handle_move_layer_up(self) -> None: + layerIndex = self.layerList.currentRow() + if layerIndex <= 0: + self.builderStatusLabel.setText("当前协议层已经在最上方。") + return + self.packetBuilderService.moveLayer(layerIndex, layerIndex - 1) + self.builderStatusLabel.setText("已上移选中协议层。") + self._refresh_all(selectedRow=layerIndex - 1) + + def _handle_move_layer_down(self) -> None: + layerIndex = self.layerList.currentRow() + if layerIndex < 0 or layerIndex >= self.layerList.count() - 1: + self.builderStatusLabel.setText("当前协议层已经在最下方。") + return + self.packetBuilderService.moveLayer(layerIndex, layerIndex + 1) + self.builderStatusLabel.setText("已下移选中协议层。") + self._refresh_all(selectedRow=layerIndex + 1) + + def _handle_reset(self) -> None: + self.packetBuilderService.reset() + self.builderStatusLabel.setText("包构建器已重置。") + self._refresh_all() + + def _handle_create_stream(self) -> None: + packet = self.getCurrentPacket() + if packet is None: + self.builderStatusLabel.setText("当前没有可创建流模板的数据包。") + return + self.createStreamRequested.emit(packet) + self.builderStatusLabel.setText("已创建流模板,可在发送任务中勾选发送。") + + def _handle_save_stream(self) -> None: + packet = self.getCurrentPacket() + if packet is None: + self.builderStatusLabel.setText("当前没有可保存回流模板的数据包。") + return + self.saveStreamRequested.emit(packet) + + def _handle_save_template(self) -> None: + filePath, _ = QtWidgets.QFileDialog.getSaveFileName( + self, + "保存包模板", + str(Path.home() / "packet-template.json"), + "JSON Files (*.json)", + ) + if not filePath: + return + self._save_template_to_path(filePath) + + def _handle_load_template(self) -> None: + filePath, _ = QtWidgets.QFileDialog.getOpenFileName( + self, + "加载包模板", + str(Path.home()), + "JSON Files (*.json)", + ) + if not filePath: + return + self._load_template_from_path(filePath) + + def _handle_layer_selection_changed(self, _currentRow: int) -> None: + self._refresh_field_table() + self._refresh_layer_actions() + self._refresh_raw_payload_editor() + + def _update_stream_buttons(self) -> None: + self.saveStreamButton.setEnabled(self._editingStreamMode) + + def _handle_field_search_changed(self, _text: str) -> None: + self._refresh_field_table() + + def _handle_layer_reordered(self) -> None: + order = [] + for rowIndex in range(self.layerList.count()): + item = self.layerList.item(rowIndex) + order.append(int(item.data(QtCore.Qt.ItemDataRole.UserRole))) + self.packetBuilderService.reorderLayers(order) + currentRow = self.layerList.currentRow() + self.builderStatusLabel.setText("已通过拖拽调整协议层顺序。") + self._refresh_all(selectedRow=currentRow) + + def _handle_apply_raw_payload(self) -> None: + layerIndex = self.layerList.currentRow() + if layerIndex < 0: + return + try: + self.packetBuilderService.setFieldValue( + layerIndex, + "load", + self.rawPayloadEdit.toPlainText(), + ) + except Exception as exc: + self.builderStatusLabel.setText(f"Raw Payload 更新失败: {exc}") + return + + self.builderStatusLabel.setText("已更新 Raw Payload。") + self._refresh_all(selectedRow=layerIndex) + + def _save_template_to_path(self, filePath: str) -> None: + try: + self.packetBuilderService.saveTemplate(filePath) + except Exception as exc: + self.builderStatusLabel.setText(f"模板保存失败: {exc}") + return + self.builderStatusLabel.setText(f"模板已保存: {filePath}") + + def _load_template_from_path(self, filePath: str) -> None: + try: + self.packetBuilderService.loadTemplate(filePath) + except Exception as exc: + self.builderStatusLabel.setText(f"模板加载失败: {exc}") + return + self.builderStatusLabel.setText(f"模板已加载: {filePath}") + self._refresh_all() + + def _handle_field_item_changed(self, item: QtWidgets.QTableWidgetItem) -> None: + if self._updatingFieldTable: + return + if item.column() != 3: + return + + layerIndex = self.layerList.currentRow() + if layerIndex < 0: + return + + fieldNameItem = self.fieldTable.item(item.row(), 0) + if fieldNameItem is None: + return + + self._apply_field_value(fieldNameItem.text(), item.text()) + + def _refresh_all( + self, + selectLast: bool = False, + selectedRow: int | None = None, + ) -> None: + self._refresh_layer_list(preserveSelection=not selectLast) + if selectedRow is not None and 0 <= selectedRow < self.layerList.count(): + self.layerList.setCurrentRow(selectedRow) + elif selectLast and self.layerList.count() > 0: + self.layerList.setCurrentRow(self.layerList.count() - 1) + elif self.layerList.currentRow() < 0 and self.layerList.count() > 0: + self.layerList.setCurrentRow(0) + self._refresh_field_table() + self._refresh_layer_actions() + self._refresh_raw_payload_editor() + self._refresh_previews() + self.removeLayerButton.setEnabled(self.layerList.count() > 0) + + def _refresh_layer_list(self, preserveSelection: bool = False) -> None: + selectedRow = self.layerList.currentRow() + self.layerList.clear() + for layerRecord in self.packetBuilderService.getLayerRecords(): + item = QtWidgets.QListWidgetItem( + f"{layerRecord.index + 1}. {layerRecord.name} [{layerRecord.summary}]" + ) + item.setData(QtCore.Qt.ItemDataRole.UserRole, layerRecord.index) + self.layerList.addItem(item) + if preserveSelection and 0 <= selectedRow < self.layerList.count(): + self.layerList.setCurrentRow(selectedRow) + + def _refresh_field_table(self) -> None: + layerIndex = self.layerList.currentRow() + self._updatingFieldTable = True + self.fieldTable.clearContents() + self._filteredFieldNames = [] + if layerIndex < 0: + self.fieldTable.setRowCount(0) + self._visibleFieldRecords = {} + self._updatingFieldTable = False + return + + fieldRecords = self.packetBuilderService.getFieldRecords(layerIndex) + searchText = self.fieldSearchEdit.text().strip().lower() + if searchText: + fieldRecords = [ + fieldRecord for fieldRecord in fieldRecords + if searchText in fieldRecord.name.lower() + or searchText in fieldRecord.fieldType.lower() + ] + self._visibleFieldRecords = { + fieldRecord.name: fieldRecord for fieldRecord in fieldRecords + } + self._filteredFieldNames = [fieldRecord.name for fieldRecord in fieldRecords] + self.fieldTable.setRowCount(len(fieldRecords)) + for rowIndex, fieldRecord in enumerate(fieldRecords): + nameItem = QtWidgets.QTableWidgetItem(fieldRecord.name) + nameItem.setFlags(nameItem.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) + typeItem = QtWidgets.QTableWidgetItem(fieldRecord.fieldType) + typeItem.setFlags(typeItem.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) + defaultItem = QtWidgets.QTableWidgetItem(fieldRecord.defaultValue) + defaultItem.setFlags(defaultItem.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) + self.fieldTable.setItem(rowIndex, 0, nameItem) + self.fieldTable.setItem(rowIndex, 1, typeItem) + self.fieldTable.setItem(rowIndex, 2, defaultItem) + self._populate_value_cell(rowIndex, fieldRecord) + self._updatingFieldTable = False + + def _refresh_layer_actions(self) -> None: + currentRow = self.layerList.currentRow() + layerCount = self.layerList.count() + hasSelection = currentRow >= 0 + self.removeLayerButton.setEnabled(hasSelection) + self.moveLayerUpButton.setEnabled(hasSelection and currentRow > 0) + self.moveLayerDownButton.setEnabled(hasSelection and currentRow < layerCount - 1) + + def _refresh_raw_payload_editor(self) -> None: + layerIndex = self.layerList.currentRow() + isRawLayer = False + if layerIndex >= 0: + layerRecord = self.packetBuilderService.getLayerRecords()[layerIndex] + isRawLayer = layerRecord.name == "Raw" + self.rawPayloadGroup.setVisible(isRawLayer) + if isRawLayer: + self.rawPayloadEdit.setPlainText( + self.packetBuilderService.getFieldValue(layerIndex, "load") + ) + + def _refresh_previews(self) -> None: + packet = self.packetBuilderService.buildPacket() + if packet is None: + self.summaryEdit.setText("尚未添加任何协议层。") + self.structureEdit.setPlainText("尚未添加任何协议层。") + self.hexdumpEdit.setPlainText("") + self.packetChanged.emit(None) + return + + self.summaryEdit.setText(self.packetBuilderService.buildSummary()) + self.structureEdit.setPlainText(self.packetBuilderService.buildStructureDump()) + self.hexdumpEdit.setPlainText(self.packetBuilderService.buildHexdump()) + + self.packetChanged.emit(packet.copy()) + + def _populate_value_cell(self, rowIndex: int, fieldRecord: LayerFieldRecord) -> None: + if fieldRecord.editorKind == "collection": + container = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + valuePreview = QtWidgets.QLineEdit(self._collection_display_text(fieldRecord.currentValue)) + valuePreview.setReadOnly(True) + valuePreview.setPlaceholderText(fieldRecord.placeholderText) + editButton = QtWidgets.QPushButton("编辑...") + editButton.clicked.connect( + lambda _checked=False, record=fieldRecord: self._open_collection_editor(record) + ) + layout.addWidget(valuePreview, 1) + layout.addWidget(editButton) + self.fieldTable.setCellWidget(rowIndex, 3, container) + return + + if fieldRecord.editorKind == "enum": + comboBox = QtWidgets.QComboBox() + comboBox.setEditable(True) + comboBox.setInsertPolicy(QtWidgets.QComboBox.InsertPolicy.NoInsert) + comboBox.addItem("使用默认值", "") + currentIndex = 0 + for choiceValue, choiceLabel in fieldRecord.choices: + comboBox.addItem(choiceLabel, choiceValue) + if choiceValue == fieldRecord.currentValue: + currentIndex = comboBox.count() - 1 + if fieldRecord.currentValue and currentIndex == 0: + comboBox.addItem(f"自定义值 ({fieldRecord.currentValue})", fieldRecord.currentValue) + currentIndex = comboBox.count() - 1 + comboBox.setCurrentIndex(currentIndex) + completer = QtWidgets.QCompleter( + [comboBox.itemText(index) for index in range(comboBox.count())], + comboBox, + ) + completer.setCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive) + completer.setFilterMode(QtCore.Qt.MatchFlag.MatchContains) + comboBox.setCompleter(completer) + comboBox.activated.connect( + lambda _index, name=fieldRecord.name, widget=comboBox: self._handle_enum_editor_activated( + name, + widget, + ) + ) + comboBox.lineEdit().editingFinished.connect( + lambda name=fieldRecord.name, widget=comboBox: self._handle_enum_editor_editing_finished( + name, + widget, + ) + ) + self.fieldTable.setCellWidget(rowIndex, 3, comboBox) + return + + if fieldRecord.editorKind == "bool": + checkBox = QtWidgets.QCheckBox() + checkBox.setChecked(fieldRecord.currentValue.lower() in {"1", "true"}) + container = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout(container) + layout.setContentsMargins(6, 0, 6, 0) + layout.addStretch(1) + layout.addWidget(checkBox) + layout.addStretch(1) + checkBox.toggled.connect( + lambda checked, name=fieldRecord.name: self._handle_editor_value_changed( + name, + "1" if checked else "0", + ) + ) + self.fieldTable.setCellWidget(rowIndex, 3, container) + return + + if fieldRecord.editorKind == "bytes": + lineEdit = QtWidgets.QLineEdit(fieldRecord.currentValue) + lineEdit.setPlaceholderText(fieldRecord.placeholderText) + lineEdit.setClearButtonEnabled(True) + lineEdit.editingFinished.connect( + lambda name=fieldRecord.name, widget=lineEdit: self._handle_editor_value_changed( + name, + widget.text(), + ) + ) + self.fieldTable.setCellWidget(rowIndex, 3, lineEdit) + return + + if fieldRecord.editorKind in {"ipv4", "ipv6", "mac"}: + lineEdit = QtWidgets.QLineEdit(fieldRecord.currentValue) + lineEdit.setPlaceholderText(fieldRecord.placeholderText) + lineEdit.setClearButtonEnabled(True) + if fieldRecord.editorKind == "mac": + validator = QtGui.QRegularExpressionValidator( + QtCore.QRegularExpression(r"[0-9A-Fa-f:-]{0,17}"), + lineEdit, + ) + lineEdit.setValidator(validator) + lineEdit.editingFinished.connect( + lambda name=fieldRecord.name, widget=lineEdit: self._handle_editor_value_changed( + name, + widget.text(), + ) + ) + self.fieldTable.setCellWidget(rowIndex, 3, lineEdit) + return + + valueItem = QtWidgets.QTableWidgetItem(fieldRecord.currentValue) + self.fieldTable.setItem(rowIndex, 3, valueItem) + + def _open_collection_editor(self, fieldRecord: LayerFieldRecord) -> None: + if fieldRecord.collectionKind == "ip_options": + self._open_ip_options_editor(fieldRecord) + return + + if fieldRecord.collectionKind == "dns_questions": + self._open_dns_question_editor(fieldRecord) + return + + if fieldRecord.collectionKind == "literal_list": + self._open_literal_list_editor(fieldRecord) + return + + dialog = QtWidgets.QDialog(self) + dialog.setWindowTitle(f"编辑集合字段: {fieldRecord.name}") + dialog.resize(720, 420) + + layout = QtWidgets.QVBoxLayout(dialog) + hintLabel = QtWidgets.QLabel(fieldRecord.placeholderText or "请输入 Python 字面量集合。") + hintLabel.setWordWrap(True) + layout.addWidget(hintLabel) + + textEdit = QtWidgets.QPlainTextEdit(fieldRecord.currentValue or fieldRecord.defaultValue) + layout.addWidget(textEdit, 1) + + buttonBox = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Ok + | QtWidgets.QDialogButtonBox.StandardButton.Cancel + ) + resetButton = buttonBox.addButton("恢复默认", QtWidgets.QDialogButtonBox.ButtonRole.ResetRole) + resetButton.clicked.connect(lambda: textEdit.setPlainText("")) + buttonBox.accepted.connect(dialog.accept) + buttonBox.rejected.connect(dialog.reject) + layout.addWidget(buttonBox) + + if dialog.exec() != int(QtWidgets.QDialog.DialogCode.Accepted): + return + + self._apply_field_value(fieldRecord.name, textEdit.toPlainText().strip()) + + def _open_ip_options_editor(self, fieldRecord: LayerFieldRecord) -> None: + optionItems = self._parse_ip_options_collection(fieldRecord.name) + + dialog = QtWidgets.QDialog(self) + dialog.setWindowTitle(f"编辑 IP Options: {fieldRecord.name}") + dialog.resize(860, 460) + + layout = QtWidgets.QVBoxLayout(dialog) + hintLabel = QtWidgets.QLabel( + "支持按模板添加常用 IP option:NOP、EOL、RR、LSRR、SSRR、Timestamp、Router Alert、Security。" + ) + hintLabel.setWordWrap(True) + layout.addWidget(hintLabel) + + table = QtWidgets.QTableWidget(0, 3) + table.setHorizontalHeaderLabels(["类型", "主要参数", "摘要"]) + table.horizontalHeader().setStretchLastSection(True) + table.verticalHeader().setVisible(False) + for optionItem in optionItems: + self._append_ip_option_row(table, optionItem) + layout.addWidget(table, 1) + + buttonLayout = QtWidgets.QHBoxLayout() + addMenuButton = QtWidgets.QPushButton("添加模板") + editButton = QtWidgets.QPushButton("编辑") + removeButton = QtWidgets.QPushButton("删除") + moveUpButton = QtWidgets.QPushButton("上移") + moveDownButton = QtWidgets.QPushButton("下移") + buttonLayout.addWidget(addMenuButton) + buttonLayout.addWidget(editButton) + buttonLayout.addWidget(removeButton) + buttonLayout.addWidget(moveUpButton) + buttonLayout.addWidget(moveDownButton) + buttonLayout.addStretch(1) + layout.addLayout(buttonLayout) + + menu = QtWidgets.QMenu(addMenuButton) + for optionType in ["NOP", "EOL", "RR", "LSRR", "SSRR", "Timestamp", "RouterAlert", "Security"]: + action = menu.addAction(optionType) + action.triggered.connect( + lambda _checked=False, optionType=optionType: self._add_ip_option_row(table, optionType) + ) + addMenuButton.setMenu(menu) + + editButton.clicked.connect(lambda: self._edit_ip_option_row(table)) + removeButton.clicked.connect(lambda: self._remove_table_row(table)) + moveUpButton.clicked.connect(lambda: self._move_table_row(table, -1)) + moveDownButton.clicked.connect(lambda: self._move_table_row(table, 1)) + + buttonBox = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Ok + | QtWidgets.QDialogButtonBox.StandardButton.Cancel + ) + buttonBox.accepted.connect(dialog.accept) + buttonBox.rejected.connect(dialog.reject) + layout.addWidget(buttonBox) + + if dialog.exec() != int(QtWidgets.QDialog.DialogCode.Accepted): + return + + optionPayload = [] + for rowIndex in range(table.rowCount()): + itemData = table.item(rowIndex, 0).data(QtCore.Qt.ItemDataRole.UserRole) + if itemData is not None: + optionPayload.append(dict(itemData)) + self._apply_field_value(fieldRecord.name, repr(optionPayload)) + + def _open_literal_list_editor(self, fieldRecord: LayerFieldRecord) -> None: + values = self._parse_literal_collection(fieldRecord.currentValue, fieldRecord.defaultValue) + + dialog = QtWidgets.QDialog(self) + dialog.setWindowTitle(f"编辑列表字段: {fieldRecord.name}") + dialog.resize(720, 420) + + layout = QtWidgets.QVBoxLayout(dialog) + hintLabel = QtWidgets.QLabel("每一项按 Python 字面量编辑,例如 'text'、123、('RA', b'\\x00\\x00')。") + hintLabel.setWordWrap(True) + layout.addWidget(hintLabel) + + itemList = QtWidgets.QListWidget() + for value in values: + itemList.addItem(repr(value)) + layout.addWidget(itemList, 1) + + buttonLayout = QtWidgets.QHBoxLayout() + addButton = QtWidgets.QPushButton("添加") + editButton = QtWidgets.QPushButton("编辑") + removeButton = QtWidgets.QPushButton("删除") + moveUpButton = QtWidgets.QPushButton("上移") + moveDownButton = QtWidgets.QPushButton("下移") + buttonLayout.addWidget(addButton) + buttonLayout.addWidget(editButton) + buttonLayout.addWidget(removeButton) + buttonLayout.addWidget(moveUpButton) + buttonLayout.addWidget(moveDownButton) + buttonLayout.addStretch(1) + layout.addLayout(buttonLayout) + + addButton.clicked.connect(lambda: self._add_literal_list_item(itemList)) + editButton.clicked.connect(lambda: self._edit_literal_list_item(itemList)) + removeButton.clicked.connect(lambda: self._remove_literal_list_item(itemList)) + moveUpButton.clicked.connect(lambda: self._move_literal_list_item(itemList, -1)) + moveDownButton.clicked.connect(lambda: self._move_literal_list_item(itemList, 1)) + + buttonBox = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Ok + | QtWidgets.QDialogButtonBox.StandardButton.Cancel + ) + buttonBox.accepted.connect(dialog.accept) + buttonBox.rejected.connect(dialog.reject) + layout.addWidget(buttonBox) + + if dialog.exec() != int(QtWidgets.QDialog.DialogCode.Accepted): + return + + items = [itemList.item(index).text() for index in range(itemList.count())] + rawValue = f"[{', '.join(items)}]" if items else "[]" + self._apply_field_value(fieldRecord.name, rawValue) + + def _open_dns_question_editor(self, fieldRecord: LayerFieldRecord) -> None: + questions = self._parse_dns_question_collection(fieldRecord.name) + + dialog = QtWidgets.QDialog(self) + dialog.setWindowTitle(f"编辑 DNS Question 列表: {fieldRecord.name}") + dialog.resize(760, 420) + + layout = QtWidgets.QVBoxLayout(dialog) + hintLabel = QtWidgets.QLabel("支持增删改 DNS question 子项,当前覆盖 qname、qtype、qclass。") + hintLabel.setWordWrap(True) + layout.addWidget(hintLabel) + + table = QtWidgets.QTableWidget(0, 3) + table.setHorizontalHeaderLabels(["qname", "qtype", "qclass"]) + table.horizontalHeader().setStretchLastSection(True) + table.verticalHeader().setVisible(False) + for question in questions: + self._append_dns_question_row(table, question) + layout.addWidget(table, 1) + + buttonLayout = QtWidgets.QHBoxLayout() + addButton = QtWidgets.QPushButton("添加") + removeButton = QtWidgets.QPushButton("删除") + moveUpButton = QtWidgets.QPushButton("上移") + moveDownButton = QtWidgets.QPushButton("下移") + buttonLayout.addWidget(addButton) + buttonLayout.addWidget(removeButton) + buttonLayout.addWidget(moveUpButton) + buttonLayout.addWidget(moveDownButton) + buttonLayout.addStretch(1) + layout.addLayout(buttonLayout) + + addButton.clicked.connect(lambda: self._append_dns_question_row(table, {})) + removeButton.clicked.connect(lambda: self._remove_table_row(table)) + moveUpButton.clicked.connect(lambda: self._move_table_row(table, -1)) + moveDownButton.clicked.connect(lambda: self._move_table_row(table, 1)) + + buttonBox = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Ok + | QtWidgets.QDialogButtonBox.StandardButton.Cancel + ) + buttonBox.accepted.connect(dialog.accept) + buttonBox.rejected.connect(dialog.reject) + layout.addWidget(buttonBox) + + if dialog.exec() != int(QtWidgets.QDialog.DialogCode.Accepted): + return + + questionItems = [] + for rowIndex in range(table.rowCount()): + qname = self._table_item_text(table, rowIndex, 0) + if not qname: + continue + questionItem = { + "qname": qname, + "qtype": self._table_item_text(table, rowIndex, 1) or "A", + "qclass": self._table_item_text(table, rowIndex, 2) or "IN", + } + questionItems.append(questionItem) + + self._apply_field_value(fieldRecord.name, repr(questionItems)) + + def _add_literal_list_item(self, itemList: QtWidgets.QListWidget) -> None: + value = self._prompt_literal_value("添加列表项", "请输入 Python 字面量") + if value is None: + return + itemList.addItem(value) + itemList.setCurrentRow(itemList.count() - 1) + + def _edit_literal_list_item(self, itemList: QtWidgets.QListWidget) -> None: + currentItem = itemList.currentItem() + if currentItem is None: + return + value = self._prompt_literal_value("编辑列表项", "请输入 Python 字面量", currentItem.text()) + if value is None: + return + currentItem.setText(value) + + def _remove_literal_list_item(self, itemList: QtWidgets.QListWidget) -> None: + currentRow = itemList.currentRow() + if currentRow < 0: + return + itemList.takeItem(currentRow) + + def _move_literal_list_item(self, itemList: QtWidgets.QListWidget, step: int) -> None: + currentRow = itemList.currentRow() + targetRow = currentRow + step + if currentRow < 0 or targetRow < 0 or targetRow >= itemList.count(): + return + item = itemList.takeItem(currentRow) + itemList.insertItem(targetRow, item) + itemList.setCurrentRow(targetRow) + + def _prompt_literal_value( + self, + title: str, + label: str, + initialValue: str = "", + ) -> str | None: + value, accepted = QtWidgets.QInputDialog.getMultiLineText( + self, + title, + label, + initialValue, + ) + if not accepted: + return None + value = value.strip() + if value == "": + return None + try: + ast.literal_eval(value) + except Exception as exc: + self.builderStatusLabel.setText(f"列表项字面量无效: {exc}") + return None + return value + + def _append_dns_question_row( + self, + table: QtWidgets.QTableWidget, + question: dict[str, object], + ) -> None: + rowIndex = table.rowCount() + table.insertRow(rowIndex) + table.setItem(rowIndex, 0, QtWidgets.QTableWidgetItem(str(question.get("qname", "")))) + table.setItem(rowIndex, 1, QtWidgets.QTableWidgetItem(str(question.get("qtype", "A")))) + table.setItem(rowIndex, 2, QtWidgets.QTableWidgetItem(str(question.get("qclass", "IN")))) + + def _append_ip_option_row( + self, + table: QtWidgets.QTableWidget, + optionItem: dict[str, object], + ) -> None: + rowIndex = table.rowCount() + table.insertRow(rowIndex) + typeItem = QtWidgets.QTableWidgetItem(str(optionItem.get("type", "NOP"))) + typeItem.setData(QtCore.Qt.ItemDataRole.UserRole, dict(optionItem)) + argsItem = QtWidgets.QTableWidgetItem(self._ip_option_argument_text(optionItem)) + summaryItem = QtWidgets.QTableWidgetItem(self._ip_option_summary(optionItem)) + table.setItem(rowIndex, 0, typeItem) + table.setItem(rowIndex, 1, argsItem) + table.setItem(rowIndex, 2, summaryItem) + + def _add_ip_option_row(self, table: QtWidgets.QTableWidget, optionType: str) -> None: + optionItem = self._default_ip_option_item(optionType) + editedItem = self._edit_ip_option_item(optionItem) + if editedItem is None: + return + self._append_ip_option_row(table, editedItem) + table.setCurrentCell(table.rowCount() - 1, 0) + + def _edit_ip_option_row(self, table: QtWidgets.QTableWidget) -> None: + currentRow = table.currentRow() + if currentRow < 0: + return + typeItem = table.item(currentRow, 0) + if typeItem is None: + return + optionItem = typeItem.data(QtCore.Qt.ItemDataRole.UserRole) + if not isinstance(optionItem, dict): + return + editedItem = self._edit_ip_option_item(dict(optionItem)) + if editedItem is None: + return + typeItem.setText(str(editedItem.get("type", "NOP"))) + typeItem.setData(QtCore.Qt.ItemDataRole.UserRole, editedItem) + table.item(currentRow, 1).setText(self._ip_option_argument_text(editedItem)) + table.item(currentRow, 2).setText(self._ip_option_summary(editedItem)) + + def _edit_ip_option_item(self, optionItem: dict[str, object]) -> dict[str, object] | None: + optionType = str(optionItem.get("type", "NOP")) + if optionType in {"NOP", "EOL", "RouterAlert"}: + return optionItem + + if optionType in {"RR", "LSRR", "SSRR"}: + routers, accepted = QtWidgets.QInputDialog.getMultiLineText( + self, + f"编辑 {optionType}", + "请输入路由器 IP 列表,每行一个地址", + "\n".join(optionItem.get("routers", [])), + ) + if not accepted: + return None + optionItem["routers"] = [line.strip() for line in routers.splitlines() if line.strip()] + return optionItem + + if optionType == "Timestamp": + dialog = QtWidgets.QDialog(self) + dialog.setWindowTitle("编辑 Timestamp") + form = QtWidgets.QFormLayout(dialog) + flgCombo = QtWidgets.QComboBox() + flgCombo.addItem("timestamp_only", "timestamp_only") + flgCombo.addItem("timestamp_and_ip_addr", "timestamp_and_ip_addr") + flgCombo.addItem("prespecified_ip_addr", "prespecified_ip_addr") + currentFlag = str(optionItem.get("flg", "timestamp_only")) + index = flgCombo.findData(currentFlag) + flgCombo.setCurrentIndex(index if index >= 0 else 0) + addressEdit = QtWidgets.QLineEdit(str(optionItem.get("internet_address", "0.0.0.0"))) + timestampSpin = QtWidgets.QSpinBox() + timestampSpin.setRange(0, 2_147_483_647) + timestampSpin.setValue(int(optionItem.get("timestamp", 0))) + form.addRow("模式", flgCombo) + form.addRow("地址", addressEdit) + form.addRow("时间戳", timestampSpin) + buttonBox = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Ok + | QtWidgets.QDialogButtonBox.StandardButton.Cancel + ) + form.addRow(buttonBox) + buttonBox.accepted.connect(dialog.accept) + buttonBox.rejected.connect(dialog.reject) + if dialog.exec() != int(QtWidgets.QDialog.DialogCode.Accepted): + return None + optionItem["flg"] = str(flgCombo.currentData()) + optionItem["internet_address"] = addressEdit.text().strip() or "0.0.0.0" + optionItem["timestamp"] = int(timestampSpin.value()) + return optionItem + + if optionType == "Security": + dialog = QtWidgets.QDialog(self) + dialog.setWindowTitle("编辑 Security") + form = QtWidgets.QFormLayout(dialog) + securitySpin = QtWidgets.QSpinBox() + compartmentSpin = QtWidgets.QSpinBox() + restrictionsSpin = QtWidgets.QSpinBox() + for widget, value in [ + (securitySpin, int(optionItem.get("security", 0))), + (compartmentSpin, int(optionItem.get("compartment", 0))), + (restrictionsSpin, int(optionItem.get("handling_restrictions", 0))), + ]: + widget.setRange(0, 65535) + widget.setValue(value) + tccEdit = QtWidgets.QLineEdit(str(optionItem.get("transmission_control_code", "xxx"))) + form.addRow("security", securitySpin) + form.addRow("compartment", compartmentSpin) + form.addRow("handling_restrictions", restrictionsSpin) + form.addRow("transmission_control_code", tccEdit) + buttonBox = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.StandardButton.Ok + | QtWidgets.QDialogButtonBox.StandardButton.Cancel + ) + form.addRow(buttonBox) + buttonBox.accepted.connect(dialog.accept) + buttonBox.rejected.connect(dialog.reject) + if dialog.exec() != int(QtWidgets.QDialog.DialogCode.Accepted): + return None + optionItem["security"] = int(securitySpin.value()) + optionItem["compartment"] = int(compartmentSpin.value()) + optionItem["handling_restrictions"] = int(restrictionsSpin.value()) + optionItem["transmission_control_code"] = tccEdit.text().strip() or "xxx" + return optionItem + + return optionItem + + def _default_ip_option_item(self, optionType: str) -> dict[str, object]: + defaults: dict[str, dict[str, object]] = { + "NOP": {"type": "NOP"}, + "EOL": {"type": "EOL"}, + "RR": {"type": "RR", "routers": []}, + "LSRR": {"type": "LSRR", "routers": []}, + "SSRR": {"type": "SSRR", "routers": []}, + "Timestamp": { + "type": "Timestamp", + "flg": "timestamp_only", + "internet_address": "0.0.0.0", + "timestamp": 0, + }, + "RouterAlert": {"type": "RouterAlert"}, + "Security": { + "type": "Security", + "security": 0, + "compartment": 0, + "handling_restrictions": 0, + "transmission_control_code": "xxx", + }, + } + return dict(defaults[optionType]) + + def _ip_option_argument_text(self, optionItem: dict[str, object]) -> str: + optionType = str(optionItem.get("type", "NOP")) + if optionType in {"RR", "LSRR", "SSRR"}: + routers = optionItem.get("routers", []) + return ", ".join(str(router) for router in routers) or "无" + if optionType == "Timestamp": + return f"{optionItem.get('flg', 'timestamp_only')}, ts={optionItem.get('timestamp', 0)}" + if optionType == "Security": + return f"sec={optionItem.get('security', 0)}, comp={optionItem.get('compartment', 0)}" + return "-" + + def _ip_option_summary(self, optionItem: dict[str, object]) -> str: + optionType = str(optionItem.get("type", "NOP")) + summaryMap = { + "NOP": "No Operation", + "EOL": "End of Options List", + "RR": "Record Route", + "LSRR": "Loose Source Route", + "SSRR": "Strict Source Route", + "Timestamp": "Timestamp", + "RouterAlert": "Router Alert", + "Security": "Security", + } + return summaryMap.get(optionType, optionType) + + def _remove_table_row(self, table: QtWidgets.QTableWidget) -> None: + currentRow = table.currentRow() + if currentRow >= 0: + table.removeRow(currentRow) + + def _move_table_row(self, table: QtWidgets.QTableWidget, step: int) -> None: + currentRow = table.currentRow() + targetRow = currentRow + step + if currentRow < 0 or targetRow < 0 or targetRow >= table.rowCount(): + return + + rowItems = [table.takeItem(currentRow, column) for column in range(table.columnCount())] + table.removeRow(currentRow) + table.insertRow(targetRow) + for columnIndex, item in enumerate(rowItems): + if item is None: + item = QtWidgets.QTableWidgetItem("") + table.setItem(targetRow, columnIndex, item) + table.setCurrentCell(targetRow, 0) + + def _table_item_text(self, table: QtWidgets.QTableWidget, rowIndex: int, columnIndex: int) -> str: + item = table.item(rowIndex, columnIndex) + return item.text().strip() if item is not None else "" + + def _parse_literal_collection(self, currentValue: str, defaultValue: str) -> list[object]: + for candidate in [currentValue, defaultValue]: + candidate = candidate.strip() + if not candidate: + continue + try: + value = ast.literal_eval(candidate) + except Exception: + continue + if isinstance(value, tuple): + return list(value) + if isinstance(value, list): + return list(value) + return [] + + def _parse_dns_question_collection(self, fieldName: str) -> list[dict[str, object]]: + layerIndex = self.layerList.currentRow() + if layerIndex < 0: + return [] + + nativeValue = self.packetBuilderService.getFieldNativeValue(layerIndex, fieldName) + questions: list[dict[str, object]] = [] + for item in nativeValue or []: + if isinstance(item, dict): + questions.append( + { + "qname": str(item.get("qname", "")), + "qtype": str(item.get("qtype", "A")), + "qclass": str(item.get("qclass", "IN")), + } + ) + continue + + if isinstance(item, str): + questions.append({"qname": item, "qtype": "A", "qclass": "IN"}) + continue + + if item.__class__.__name__ == "DNSQR": + qname = getattr(item, "qname", b"") + if isinstance(qname, bytes): + qname = qname.decode("utf-8", errors="replace") + questions.append( + { + "qname": str(qname).rstrip("."), + "qtype": self._dns_question_field_label(item, "qtype", "A"), + "qclass": self._dns_question_field_label(item, "qclass", "IN"), + } + ) + + return questions + + def _dns_question_field_label(self, packet: object, fieldName: str, fallback: str) -> str: + try: + field = packet.get_field(fieldName) + except Exception: + return fallback + + fieldValue = getattr(packet, fieldName, fallback) + label = field.i2repr(packet, fieldValue) + if isinstance(label, str) and label: + return label + return str(fieldValue) + + def _parse_ip_options_collection(self, fieldName: str) -> list[dict[str, object]]: + layerIndex = self.layerList.currentRow() + if layerIndex < 0: + return [] + + nativeValue = self.packetBuilderService.getFieldNativeValue(layerIndex, fieldName) + optionItems: list[dict[str, object]] = [] + for item in nativeValue or []: + className = item.__class__.__name__ + if className == "IPOption_NOP": + optionItems.append({"type": "NOP"}) + elif className == "IPOption_EOL": + optionItems.append({"type": "EOL"}) + elif className in {"IPOption_RR", "IPOption_LSRR", "IPOption_SSRR"}: + optionItems.append( + { + "type": className.removeprefix("IPOption_"), + "routers": list(getattr(item, "routers", [])), + } + ) + elif className == "IPOption_Timestamp": + optionItems.append( + { + "type": "Timestamp", + "flg": str(getattr(item, "flg", "timestamp_only")), + "internet_address": str(getattr(item, "internet_address", "0.0.0.0")), + "timestamp": int(getattr(item, "timestamp", 0)), + } + ) + elif className == "IPOption_Router_Alert": + optionItems.append({"type": "RouterAlert"}) + elif className == "IPOption_Security": + optionItems.append( + { + "type": "Security", + "security": int(getattr(item, "security", 0)), + "compartment": int(getattr(item, "compartment", 0)), + "handling_restrictions": int(getattr(item, "handling_restrictions", 0)), + "transmission_control_code": str(getattr(item, "transmission_control_code", "xxx")), + } + ) + return optionItems + + def _handle_editor_value_changed(self, fieldName: str, rawValue: str) -> None: + if self._updatingFieldTable: + return + self._apply_field_value(fieldName, rawValue) + + def _handle_enum_editor_activated( + self, + fieldName: str, + comboBox: QtWidgets.QComboBox, + ) -> None: + self._handle_editor_value_changed(fieldName, self._current_combo_value(comboBox)) + + def _handle_enum_editor_editing_finished( + self, + fieldName: str, + comboBox: QtWidgets.QComboBox, + ) -> None: + if comboBox.view().isVisible(): + return + + completer = comboBox.completer() + if completer is not None and completer.popup().isVisible(): + return + + self._handle_editor_value_changed(fieldName, self._current_combo_value(comboBox)) + + def _apply_field_value(self, fieldName: str, rawValue: str) -> None: + layerIndex = self.layerList.currentRow() + if layerIndex < 0: + return + + validationError = self._validate_field_value(fieldName, rawValue) + if validationError: + self.builderStatusLabel.setText(validationError) + self._refresh_field_table() + return + + try: + self.packetBuilderService.setFieldValue(layerIndex, fieldName, rawValue) + except Exception as exc: + self.builderStatusLabel.setText(f"字段更新失败: {exc}") + self._refresh_field_table() + return + + self.builderStatusLabel.setText(f"已更新字段: {fieldName}") + self._refresh_previews() + self._refresh_layer_list(preserveSelection=True) + self._refresh_raw_payload_editor() + + def _current_combo_value(self, comboBox: QtWidgets.QComboBox) -> str: + if comboBox.isEditable(): + currentText = comboBox.lineEdit().text().strip() + if currentText: + currentIndex = comboBox.currentIndex() + selectedText = comboBox.itemText(currentIndex).strip() if currentIndex >= 0 else "" + if currentText != selectedText: + return self._normalize_combo_text(currentText) + + currentData = comboBox.currentData() + if currentData is not None and comboBox.currentIndex() >= 0: + return str(currentData) + + currentText = comboBox.currentText().strip() + return self._normalize_combo_text(currentText) + + def _normalize_combo_text(self, currentText: str) -> str: + if currentText == "使用默认值": + return "" + + match = re.search(r"\(([^()]+)\)\s*$", currentText) + if match: + return match.group(1).strip() + return currentText + + def _validate_field_value(self, fieldName: str, rawValue: str) -> str | None: + if rawValue == "": + return None + + fieldRecord = self._visibleFieldRecords.get(fieldName) + if fieldRecord is None: + return None + + if fieldRecord.editorKind == "ipv4": + try: + ipaddress.IPv4Address(rawValue) + except ValueError: + return f"字段 {fieldName} 需要合法的 IPv4 地址。" + + if fieldRecord.editorKind == "ipv6": + try: + ipaddress.IPv6Address(rawValue) + except ValueError: + return f"字段 {fieldName} 需要合法的 IPv6 地址。" + + if fieldRecord.editorKind == "mac": + if not re.fullmatch(r"([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}", rawValue): + return f"字段 {fieldName} 需要合法的 MAC 地址。" + + return None + + def _collection_display_text(self, rawValue: str) -> str: + compactValue = rawValue.replace("\n", " ").strip() + if not compactValue: + return "使用默认值" + if len(compactValue) > 80: + return f"{compactValue[:77]}..." + return compactValue \ No newline at end of file diff --git a/gui/src/packet_studio/widgets/send_task_widget.py b/gui/src/packet_studio/widgets/send_task_widget.py new file mode 100644 index 00000000000..7fba7c48d73 --- /dev/null +++ b/gui/src/packet_studio/widgets/send_task_widget.py @@ -0,0 +1,652 @@ +from __future__ import annotations + +import re +import threading +from dataclasses import dataclass +from typing import Any, Optional + +from PySide6 import QtCore, QtGui, QtWidgets + +from packet_studio.domain.packet_models import PacketPreview +from packet_studio.domain.task_models import SendTaskResult, TaskError, TaskState +from packet_studio.domain.workspace_models import WorkspacePanelSnapshot +from packet_studio.services.interface_service import InterfaceRecord +from packet_studio.services.send_task_service import SendTaskRequest, SendTaskService + + +@dataclass +class StreamTemplateEntry: + templateId: int + name: str + packet: Any + preview: PacketPreview + enabled: bool = True + sourceMac: str = "" + destinationMac: str = "" + hasEtherLayer: bool = False + + +class SendTaskWorker(QtCore.QObject): + finished = QtCore.Signal(object) + failed = QtCore.Signal(object) + + def __init__( + self, + sendTaskService: SendTaskService, + request: SendTaskRequest, + packets: list[Any], + stopEvent: threading.Event, + ) -> None: + super().__init__() + self.sendTaskService = sendTaskService + self.request = request + self.packets = packets + self.stopEvent = stopEvent + + @QtCore.Slot() + def run(self) -> None: + try: + result = self.sendTaskService.execute( + self.request, + self.packets, + stopRequested=self.stopEvent.is_set, + ) + except Exception as exc: + self.failed.emit(TaskError(message=str(exc), logText=str(exc))) + return + self.finished.emit(result) + + +class SendTaskWidget(QtWidgets.QWidget): + """发送与 sr1 请求响应任务面板。""" + + statusMessage = QtCore.Signal(str) + editPacketRequested = QtCore.Signal(object) + _MAC_PATTERN = re.compile(r"^(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$") + + def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: + super().__init__(parent) + self.sendTaskService = SendTaskService() + self.interfaceRecords: list[InterfaceRecord] = [] + self.streamTemplates: list[StreamTemplateEntry] = [] + self.nextTemplateId = 1 + self.currentEditingTemplateId: int | None = None + self.workerThread: Optional[QtCore.QThread] = None + self.worker: Optional[SendTaskWorker] = None + self.stopEvent: Optional[threading.Event] = None + self.currentTaskState = TaskState.idle("准备就绪。") + + self._setup_ui() + self._refresh_stream_table() + self._update_mode_state() + + def hasRunningTask(self) -> bool: + return self.workerThread is not None + + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + if self.hasRunningTask(): + QtWidgets.QMessageBox.warning( + self, + "发送任务仍在运行", + "请等待当前发送任务结束后再关闭窗口。", + ) + event.ignore() + return + super().closeEvent(event) + + def setInterfaceRecords(self, interfaceRecords: list[InterfaceRecord]) -> None: + self.interfaceRecords = list(interfaceRecords) + selectedValue = self.interfaceCombo.currentData() + self.interfaceCombo.blockSignals(True) + self.interfaceCombo.clear() + self.interfaceCombo.addItem("自动选择 / 路由决定", "") + restoredIndex = 0 + for index, interfaceRecord in enumerate(self.interfaceRecords, start=1): + label = f"{interfaceRecord.name} [{interfaceRecord.capabilitySummary}]" + self.interfaceCombo.addItem( + label, + interfaceRecord.networkName or interfaceRecord.name, + ) + if selectedValue and self.interfaceCombo.itemData(index) == selectedValue: + restoredIndex = index + self.interfaceCombo.setCurrentIndex(restoredIndex) + self.interfaceCombo.blockSignals(False) + + def buildWorkspaceSnapshot(self) -> WorkspacePanelSnapshot: + return WorkspacePanelSnapshot( + panelId="send-task", + title="发送任务", + taskState=self.currentTaskState, + itemCount=len(self.streamTemplates), + detailText=self.resultSummaryLabel.text(), + ) + + @QtCore.Slot(object) + def addStreamFromPacket(self, packet: Any) -> None: + packetCopy = packet.copy() if packet is not None and hasattr(packet, "copy") else packet + preview = self.sendTaskService.buildPacketPreview(packetCopy) + if packetCopy is None or preview is None: + self.statusLabel.setText("无法创建流模板:当前数据包为空。") + return + + sourceMac, destinationMac, hasEtherLayer = self._extract_mac_fields(packetCopy) + entry = StreamTemplateEntry( + templateId=self.nextTemplateId, + name=f"流 {self.nextTemplateId}", + packet=packetCopy, + preview=preview, + sourceMac=sourceMac, + destinationMac=destinationMac, + hasEtherLayer=hasEtherLayer, + ) + self.nextTemplateId += 1 + self.streamTemplates.append(entry) + self._refresh_stream_table(selectedTemplateId=entry.templateId) + self._apply_preview(entry.preview) + self.statusLabel.setText(f"已新增流模板: {entry.name}") + self.statusMessage.emit(f"已新增流模板: {entry.name}") + + def beginEditingStream(self, templateId: int) -> None: + if self._find_stream(templateId) is None: + self.currentEditingTemplateId = None + return + self.currentEditingTemplateId = templateId + + def getCurrentSelectedTemplateId(self) -> int | None: + entry = self._current_selected_stream() + if entry is None: + return None + return entry.templateId + + def saveEditedStream(self, packet: Any) -> bool: + if self.currentEditingTemplateId is None: + return False + entry = self._find_stream(self.currentEditingTemplateId) + if entry is None: + self.currentEditingTemplateId = None + return False + packetCopy = packet.copy() if packet is not None and hasattr(packet, "copy") else packet + preview = self.sendTaskService.buildPacketPreview(packetCopy) + if packetCopy is None or preview is None: + return False + entry.packet = packetCopy + entry.preview = preview + entry.sourceMac, entry.destinationMac, entry.hasEtherLayer = self._extract_mac_fields(packetCopy) + self._refresh_stream_table(selectedTemplateId=entry.templateId) + self.statusLabel.setText(f"已保存回流模板: {entry.name}") + self.statusMessage.emit(f"已保存回流模板: {entry.name}") + self.currentEditingTemplateId = None + return True + + def _setup_ui(self) -> None: + rootLayout = QtWidgets.QVBoxLayout(self) + + controlsLayout = QtWidgets.QGridLayout() + controlsLayout.addWidget(QtWidgets.QLabel("执行模式"), 0, 0) + self.modeCombo = QtWidgets.QComboBox() + self.modeCombo.addItem("send (L3 发送)", "send") + self.modeCombo.addItem("sendp (L2 发送)", "sendp") + self.modeCombo.addItem("sr1 (L3 请求/响应)", "sr1") + self.modeCombo.setCurrentIndex(1) + self.modeCombo.currentIndexChanged.connect(self._handle_mode_changed) + controlsLayout.addWidget(self.modeCombo, 0, 1) + + controlsLayout.addWidget(QtWidgets.QLabel("发送策略"), 0, 2) + self.strategyCombo = QtWidgets.QComboBox() + self.strategyCombo.addItem("burst (按轮次发送)", "burst") + self.strategyCombo.addItem("continuous (持续发送)", "continuous") + self.strategyCombo.currentIndexChanged.connect(self._handle_strategy_changed) + controlsLayout.addWidget(self.strategyCombo, 0, 3) + + controlsLayout.addWidget(QtWidgets.QLabel("接口"), 1, 0) + self.interfaceCombo = QtWidgets.QComboBox() + self.interfaceCombo.addItem("自动选择 / 路由决定", "") + controlsLayout.addWidget(self.interfaceCombo, 1, 1) + + controlsLayout.addWidget(QtWidgets.QLabel("发送轮次"), 1, 2) + self.countSpin = QtWidgets.QSpinBox() + self.countSpin.setRange(1, 100000) + self.countSpin.setValue(1) + controlsLayout.addWidget(self.countSpin, 1, 3) + + controlsLayout.addWidget(QtWidgets.QLabel("发送间隔 (s)"), 2, 0) + self.intervalSpin = QtWidgets.QDoubleSpinBox() + self.intervalSpin.setRange(0.0, 3600.0) + self.intervalSpin.setDecimals(3) + self.intervalSpin.setSingleStep(0.1) + controlsLayout.addWidget(self.intervalSpin, 2, 1) + + controlsLayout.addWidget(QtWidgets.QLabel("超时 (s)"), 2, 2) + self.timeoutSpin = QtWidgets.QDoubleSpinBox() + self.timeoutSpin.setRange(0.1, 3600.0) + self.timeoutSpin.setDecimals(3) + self.timeoutSpin.setValue(1.0) + controlsLayout.addWidget(self.timeoutSpin, 2, 3) + + controlsLayout.addWidget(QtWidgets.QLabel("重试次数"), 3, 0) + self.retrySpin = QtWidgets.QSpinBox() + self.retrySpin.setRange(0, 100) + controlsLayout.addWidget(self.retrySpin, 3, 1) + + actionLayout = QtWidgets.QHBoxLayout() + self.executeButton = QtWidgets.QPushButton("开始执行") + self.executeButton.clicked.connect(self._handle_execute) + self.stopButton = QtWidgets.QPushButton("停止") + self.stopButton.clicked.connect(self._handle_stop) + self.statusLabel = QtWidgets.QLabel("准备就绪。") + self.statusLabel.setWordWrap(True) + actionLayout.addWidget(self.executeButton) + actionLayout.addWidget(self.stopButton) + actionLayout.addWidget(self.statusLabel, 1) + + contentSplitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal) + + streamPane = QtWidgets.QWidget() + streamLayout = QtWidgets.QVBoxLayout(streamPane) + streamLayout.addWidget(QtWidgets.QLabel("流模板")) + self.streamTable = QtWidgets.QTableWidget(0, 5) + self.streamTable.setHorizontalHeaderLabels(["发送", "名称", "摘要", "源 MAC", "目标 MAC"]) + self.streamTable.verticalHeader().setVisible(False) + self.streamTable.setSelectionBehavior( + QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows + ) + self.streamTable.setSelectionMode( + QtWidgets.QAbstractItemView.SelectionMode.SingleSelection + ) + self.streamTable.horizontalHeader().setStretchLastSection(False) + self.streamTable.horizontalHeader().setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents + ) + self.streamTable.horizontalHeader().setSectionResizeMode( + 1, QtWidgets.QHeaderView.ResizeMode.Interactive + ) + self.streamTable.horizontalHeader().setSectionResizeMode( + 2, QtWidgets.QHeaderView.ResizeMode.Interactive + ) + self.streamTable.horizontalHeader().setSectionResizeMode( + 3, QtWidgets.QHeaderView.ResizeMode.Stretch + ) + self.streamTable.horizontalHeader().setSectionResizeMode( + 4, QtWidgets.QHeaderView.ResizeMode.Stretch + ) + self.streamTable.setColumnWidth(1, 130) + self.streamTable.setColumnWidth(2, 220) + self.streamTable.itemSelectionChanged.connect(self._handle_stream_selection_changed) + streamLayout.addWidget(self.streamTable, 1) + + streamActionsLayout = QtWidgets.QHBoxLayout() + self.selectAllStreamsButton = QtWidgets.QPushButton("全部勾选") + self.clearCheckedStreamsButton = QtWidgets.QPushButton("全部取消") + self.removeStreamButton = QtWidgets.QPushButton("移除选中流") + self.editInBuilderButton = QtWidgets.QPushButton("跳到包构建器编辑") + self.selectAllStreamsButton.clicked.connect(lambda: self._set_all_streams_enabled(True)) + self.clearCheckedStreamsButton.clicked.connect(lambda: self._set_all_streams_enabled(False)) + self.removeStreamButton.clicked.connect(self._handle_remove_selected_stream) + self.editInBuilderButton.clicked.connect(self._handle_edit_selected_stream) + streamActionsLayout.addWidget(self.selectAllStreamsButton) + streamActionsLayout.addWidget(self.clearCheckedStreamsButton) + streamActionsLayout.addWidget(self.removeStreamButton) + streamActionsLayout.addWidget(self.editInBuilderButton) + streamLayout.addLayout(streamActionsLayout) + + previewSplitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical) + + requestPane = QtWidgets.QWidget() + requestLayout = QtWidgets.QVBoxLayout(requestPane) + requestLayout.addWidget(QtWidgets.QLabel("选中流模板预览")) + self.packetSummaryEdit = QtWidgets.QLineEdit() + self.packetSummaryEdit.setReadOnly(True) + requestLayout.addWidget(self.packetSummaryEdit) + requestTabs = QtWidgets.QTabWidget() + self.packetStructureEdit = QtWidgets.QPlainTextEdit() + self.packetStructureEdit.setReadOnly(True) + self.packetHexdumpEdit = QtWidgets.QPlainTextEdit() + self.packetHexdumpEdit.setReadOnly(True) + requestTabs.addTab(self.packetStructureEdit, "结构") + requestTabs.addTab(self.packetHexdumpEdit, "十六进制") + requestLayout.addWidget(requestTabs, 1) + + resultPane = QtWidgets.QWidget() + resultLayout = QtWidgets.QVBoxLayout(resultPane) + self.resultSummaryLabel = QtWidgets.QLabel("尚未执行发送任务。") + self.resultSummaryLabel.setWordWrap(True) + resultLayout.addWidget(self.resultSummaryLabel) + resultTabs = QtWidgets.QTabWidget() + self.resultLogEdit = QtWidgets.QPlainTextEdit() + self.resultLogEdit.setReadOnly(True) + self.answerStructureEdit = QtWidgets.QPlainTextEdit() + self.answerStructureEdit.setReadOnly(True) + self.answerHexdumpEdit = QtWidgets.QPlainTextEdit() + self.answerHexdumpEdit.setReadOnly(True) + resultTabs.addTab(self.resultLogEdit, "发送日志") + resultTabs.addTab(self.answerStructureEdit, "应答结构") + resultTabs.addTab(self.answerHexdumpEdit, "应答十六进制") + resultLayout.addWidget(resultTabs, 1) + + previewSplitter.addWidget(requestPane) + previewSplitter.addWidget(resultPane) + previewSplitter.setStretchFactor(0, 1) + previewSplitter.setStretchFactor(1, 1) + + contentSplitter.addWidget(streamPane) + contentSplitter.addWidget(previewSplitter) + contentSplitter.setStretchFactor(0, 1) + contentSplitter.setStretchFactor(1, 1) + + rootLayout.addLayout(controlsLayout) + rootLayout.addLayout(actionLayout) + rootLayout.addWidget(contentSplitter, 1) + + def _build_request(self) -> SendTaskRequest: + mode = str(self.modeCombo.currentData()) + interfaceName = "" + if mode == "sendp": + interfaceName = str(self.interfaceCombo.currentData() or "") + return SendTaskRequest( + mode=mode, + sendStrategy=str(self.strategyCombo.currentData()), + interfaceName=interfaceName, + count=int(self.countSpin.value()), + intervalSeconds=float(self.intervalSpin.value()), + timeoutSeconds=float(self.timeoutSpin.value()), + retryCount=int(self.retrySpin.value()), + ) + + def _handle_mode_changed(self) -> None: + self._update_mode_state() + + def _handle_strategy_changed(self) -> None: + self._update_mode_state() + + def _update_mode_state(self) -> None: + mode = str(self.modeCombo.currentData()) + isRunning = self.workerThread is not None + isSr1 = mode == "sr1" + usesInterface = mode == "sendp" + if isSr1 and self.strategyCombo.currentData() != "burst": + self.strategyCombo.blockSignals(True) + self.strategyCombo.setCurrentIndex(self.strategyCombo.findData("burst")) + self.strategyCombo.blockSignals(False) + strategy = str(self.strategyCombo.currentData()) + isContinuous = strategy == "continuous" + self.strategyCombo.setEnabled(not isSr1 and not isRunning) + self.countSpin.setEnabled(not isContinuous and not isRunning) + self.intervalSpin.setEnabled(not isSr1 and not isRunning) + self.timeoutSpin.setEnabled(isSr1 and not isRunning) + self.retrySpin.setEnabled(isSr1 and not isRunning) + self.interfaceCombo.setEnabled(usesInterface and not isRunning) + if usesInterface: + self.interfaceCombo.setToolTip("L2 发送会显式使用所选接口。") + else: + self.interfaceCombo.setToolTip("L3 send/sr1 由 Scapy 路由自动选择接口。") + + def _handle_execute(self) -> None: + packets = self._selected_packets() + if not packets: + self.statusLabel.setText("请先创建并勾选至少一条流模板。") + self.statusMessage.emit("发送任务启动失败:没有已勾选的流模板。") + return + if self.workerThread is not None: + self.statusLabel.setText("已有发送任务正在执行,请稍候。") + return + + request = self._build_request() + self.resultLogEdit.clear() + self.answerStructureEdit.clear() + self.answerHexdumpEdit.clear() + self.resultSummaryLabel.setText("发送任务执行中...") + self._apply_task_state(TaskState.running("正在后台执行发送任务...")) + self.statusMessage.emit( + f"开始执行发送任务: {request.mode} / {request.sendStrategy} / {len(packets)} 条流" + ) + self._set_running_state(True) + self.stopEvent = threading.Event() + + thread = QtCore.QThread(self) + worker = SendTaskWorker(self.sendTaskService, request, packets, self.stopEvent) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.finished.connect(self._on_task_finished) + worker.failed.connect(self._on_task_failed) + worker.finished.connect(thread.quit) + worker.failed.connect(thread.quit) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(self._on_thread_finished) + + self.workerThread = thread + self.worker = worker + thread.start() + + def _handle_stop(self) -> None: + if self.stopEvent is None or self.stopEvent.is_set(): + return + self.stopEvent.set() + self.stopButton.setEnabled(False) + self.statusLabel.setText("正在停止当前发送任务...") + self.statusMessage.emit("已请求停止当前发送任务。") + + @QtCore.Slot(object) + def _on_task_finished(self, result: SendTaskResult) -> None: + self.resultLogEdit.setPlainText(result.logText) + if result.answerPreview is None: + self.answerStructureEdit.setPlainText("本次任务没有应答数据包。") + self.answerHexdumpEdit.setPlainText("") + else: + self.answerStructureEdit.setPlainText(result.answerPreview.structure) + self.answerHexdumpEdit.setPlainText(result.answerPreview.hexdump) + self.resultSummaryLabel.setText(result.summaryText) + self._apply_task_state(result.state) + self.statusMessage.emit(result.summaryText) + + @QtCore.Slot(object) + def _on_task_failed(self, error: TaskError) -> None: + self.resultSummaryLabel.setText(f"发送任务失败: {error.summaryText}") + self.resultLogEdit.setPlainText(error.logText or error.summaryText) + self._apply_task_state(error.state) + self.statusMessage.emit(f"发送任务失败: {error.summaryText}") + + def _on_thread_finished(self) -> None: + self.workerThread = None + self.worker = None + self.stopEvent = None + self._set_running_state(False) + self._sync_preview_with_selection() + + def _set_running_state(self, isRunning: bool) -> None: + self.modeCombo.setEnabled(not isRunning) + self.streamTable.setEnabled(not isRunning) + hasSelectedStream = self._current_selected_stream() is not None + hasTemplates = bool(self.streamTemplates) + self.selectAllStreamsButton.setEnabled(not isRunning and hasTemplates) + self.clearCheckedStreamsButton.setEnabled(not isRunning and hasTemplates) + self.removeStreamButton.setEnabled(not isRunning and hasSelectedStream) + self.editInBuilderButton.setEnabled(not isRunning and hasSelectedStream) + self.executeButton.setEnabled(not isRunning and bool(self._selected_packets())) + self.stopButton.setEnabled(isRunning) + self._update_mode_state() + + def _apply_task_state(self, state: TaskState) -> None: + self.currentTaskState = state + self.statusLabel.setText(state.statusText) + + def _refresh_stream_table(self, selectedTemplateId: int | None = None) -> None: + currentSelected = selectedTemplateId + if currentSelected is None: + currentEntry = self._current_selected_stream() + currentSelected = currentEntry.templateId if currentEntry is not None else None + + self.streamTable.blockSignals(True) + self.streamTable.clearContents() + self.streamTable.setRowCount(len(self.streamTemplates)) + for rowIndex, entry in enumerate(self.streamTemplates): + self._populate_stream_row(rowIndex, entry) + self.streamTable.blockSignals(False) + + selectedRow = 0 if self.streamTemplates else -1 + if currentSelected is not None: + for rowIndex, entry in enumerate(self.streamTemplates): + if entry.templateId == currentSelected: + selectedRow = rowIndex + break + if selectedRow >= 0: + self.streamTable.selectRow(selectedRow) + self._sync_preview_with_selection() + self._set_running_state(self.workerThread is not None) + + def _populate_stream_row(self, rowIndex: int, entry: StreamTemplateEntry) -> None: + checkContainer = QtWidgets.QWidget() + checkLayout = QtWidgets.QHBoxLayout(checkContainer) + checkLayout.setContentsMargins(0, 0, 0, 0) + checkLayout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + checkBox = QtWidgets.QCheckBox() + checkBox.setChecked(entry.enabled) + checkBox.stateChanged.connect( + lambda state, templateId=entry.templateId: self._handle_stream_enabled_changed(templateId, state) + ) + checkLayout.addWidget(checkBox) + self.streamTable.setCellWidget(rowIndex, 0, checkContainer) + + nameItem = QtWidgets.QTableWidgetItem(entry.name) + nameItem.setFlags(nameItem.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) + summaryItem = QtWidgets.QTableWidgetItem(entry.preview.summary) + summaryItem.setFlags(summaryItem.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) + self.streamTable.setItem(rowIndex, 1, nameItem) + self.streamTable.setItem(rowIndex, 2, summaryItem) + self.streamTable.setCellWidget(rowIndex, 3, self._build_mac_editor(entry, "src", entry.sourceMac)) + self.streamTable.setCellWidget(rowIndex, 4, self._build_mac_editor(entry, "dst", entry.destinationMac)) + + def _build_mac_editor( + self, + entry: StreamTemplateEntry, + fieldName: str, + value: str, + ) -> QtWidgets.QWidget: + lineEdit = QtWidgets.QLineEdit(value) + lineEdit.setPlaceholderText("无 Ether 层" if not entry.hasEtherLayer else "00:11:22:33:44:55") + lineEdit.setEnabled(entry.hasEtherLayer and self.workerThread is None) + if entry.hasEtherLayer: + lineEdit.editingFinished.connect( + lambda templateId=entry.templateId, targetField=fieldName, editor=lineEdit: self._apply_mac_edit( + templateId, + targetField, + editor, + ) + ) + return lineEdit + + def _handle_stream_enabled_changed(self, templateId: int, state: int) -> None: + entry = self._find_stream(templateId) + if entry is None: + return + entry.enabled = state == int(QtCore.Qt.CheckState.Checked.value) + self._set_running_state(self.workerThread is not None) + + def _apply_mac_edit( + self, + templateId: int, + fieldName: str, + editor: QtWidgets.QLineEdit, + ) -> None: + entry = self._find_stream(templateId) + if entry is None: + return + value = editor.text().strip() + if not value: + editor.setText(entry.sourceMac if fieldName == "src" else entry.destinationMac) + return + if self._MAC_PATTERN.match(value) is None: + self.statusLabel.setText("MAC 地址格式无效,请使用 00:11:22:33:44:55。") + editor.setText(entry.sourceMac if fieldName == "src" else entry.destinationMac) + return + etherLayer = self._extract_ether_layer(entry.packet) + if etherLayer is None: + self.statusLabel.setText("当前流模板不包含 Ether 层,无法修改 MAC 地址。") + return + setattr(etherLayer, fieldName, value) + entry.preview = self.sendTaskService.buildPacketPreview(entry.packet) or entry.preview + entry.sourceMac, entry.destinationMac, entry.hasEtherLayer = self._extract_mac_fields(entry.packet) + self.statusLabel.setText(f"已更新 {entry.name} 的 MAC 地址。") + self._refresh_stream_table(selectedTemplateId=templateId) + + def _set_all_streams_enabled(self, enabled: bool) -> None: + for entry in self.streamTemplates: + entry.enabled = enabled + self._refresh_stream_table() + + def _handle_remove_selected_stream(self) -> None: + entry = self._current_selected_stream() + if entry is None: + return + self.streamTemplates = [streamEntry for streamEntry in self.streamTemplates if streamEntry.templateId != entry.templateId] + self.statusLabel.setText(f"已移除流模板: {entry.name}") + self._refresh_stream_table() + + def _handle_edit_selected_stream(self) -> None: + entry = self._current_selected_stream() + if entry is None: + self.statusLabel.setText("请先选中要跳转编辑的流模板。") + return + packet = entry.packet.copy() if hasattr(entry.packet, "copy") else entry.packet + self.editPacketRequested.emit(packet) + self.statusMessage.emit(f"跳转到包构建器编辑: {entry.name}") + + def _handle_stream_selection_changed(self) -> None: + self._sync_preview_with_selection() + self._set_running_state(self.workerThread is not None) + + def _sync_preview_with_selection(self) -> None: + entry = self._current_selected_stream() + if entry is None: + self.packetSummaryEdit.setText("当前没有可发送的流模板。") + self.packetStructureEdit.setPlainText("请先在包构建器中创建流模板。") + self.packetHexdumpEdit.setPlainText("") + return + self._apply_preview(entry.preview) + + def _apply_preview(self, preview: PacketPreview) -> None: + self.packetSummaryEdit.setText(preview.summary) + self.packetStructureEdit.setPlainText(preview.structure) + self.packetHexdumpEdit.setPlainText(preview.hexdump) + + def _selected_packets(self) -> list[Any]: + packets: list[Any] = [] + for entry in self.streamTemplates: + if not entry.enabled: + continue + packets.append(entry.packet.copy() if hasattr(entry.packet, "copy") else entry.packet) + return packets + + def _current_selected_stream(self) -> StreamTemplateEntry | None: + selectedItems = self.streamTable.selectedItems() + if not selectedItems: + return None + rowIndex = selectedItems[0].row() + if rowIndex < 0 or rowIndex >= len(self.streamTemplates): + return None + return self.streamTemplates[rowIndex] + + def _find_stream(self, templateId: int) -> StreamTemplateEntry | None: + for entry in self.streamTemplates: + if entry.templateId == templateId: + return entry + return None + + def _extract_ether_layer(self, packet: Any) -> Any | None: + if packet is None or not hasattr(packet, "haslayer") or not hasattr(packet, "getlayer"): + return None + try: + if packet.haslayer("Ether"): + return packet.getlayer("Ether") + except Exception: + return None + return None + + def _extract_mac_fields(self, packet: Any) -> tuple[str, str, bool]: + etherLayer = self._extract_ether_layer(packet) + if etherLayer is None: + return "", "", False + return ( + str(getattr(etherLayer, "src", "") or ""), + str(getattr(etherLayer, "dst", "") or ""), + True, + ) diff --git a/gui/tests/test_gui_smoke.py b/gui/tests/test_gui_smoke.py new file mode 100644 index 00000000000..77e187fbb56 --- /dev/null +++ b/gui/tests/test_gui_smoke.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import os +import time +import unittest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import scapy.all as scapy +from PySide6 import QtCore, QtWidgets +from scapy.contrib.mac_control import MACControlPause + +from packet_studio.domain.packet_models import PacketPreview, PcapPacketRecord +from packet_studio.domain.task_models import PcapLoadResult, SendTaskResult, TaskState +from packet_studio.widgets.offline_analysis_widget import OfflineAnalysisWidget +from packet_studio.widgets.packet_builder_widget import PacketBuilderWidget +from packet_studio.widgets.send_task_widget import SendTaskWidget + + +def getOrCreateApplication() -> QtWidgets.QApplication: + application = QtWidgets.QApplication.instance() + if application is None: + application = QtWidgets.QApplication([]) + return application + + +class FakePacket: + def __init__(self, summary: str, structure: str, payload: bytes) -> None: + self._summary = summary + self._structure = structure + self._payload = payload + + def copy(self) -> "FakePacket": + return FakePacket(self._summary, self._structure, self._payload) + + def summary(self) -> str: + return self._summary + + def show(self, dump: bool = False) -> str: + if dump: + return self._structure + raise AssertionError("仅测试 dump=True 路径") + + def __bytes__(self) -> bytes: + return self._payload + + +class FakeSendTaskService: + def buildPacketPreview(self, packet: FakePacket | None) -> PacketPreview | None: + if packet is None: + return None + return PacketPreview( + summary=packet.summary(), + structure=packet.show(dump=True), + hexdump="0000 01 02 ..", + ) + + def execute( + self, + _request: object, + _packets: object | None, + stopRequested: object | None = None, + ) -> SendTaskResult: + return SendTaskResult( + mode="send", + sentCount=1, + packetPreview=PacketPreview( + summary="IP / TCP", + structure="request dump", + hexdump="0000 01 .", + ), + answerPreview=PacketPreview( + summary="IP / ICMP", + structure="reply dump", + hexdump="0000 08 00 ..", + ), + unansweredCount=0, + summaryText="模式: send,已发送 1 个数据包,未应答 0 个。", + logText="执行模式: send (L3)", + state=TaskState.succeeded("发送任务执行完成。"), + ) + + +class GuiSmokeTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.application = getOrCreateApplication() + + def waitUntil(self, predicate: object, timeoutSeconds: float = 3.0) -> None: + deadline = time.monotonic() + timeoutSeconds + while time.monotonic() < deadline: + self.application.processEvents(QtCore.QEventLoop.ProcessEventsFlag.AllEvents, 50) + if predicate(): + return + time.sleep(0.01) + self.fail("等待 GUI 状态变化超时。") + + def test_packet_builder_widget_add_layer_updates_preview(self) -> None: + widget = PacketBuilderWidget() + emittedPackets: list[object | None] = [] + widget.packetChanged.connect(emittedPackets.append) + emittedPackets.clear() + + widget.addLayerButton.click() + + self.application.processEvents() + self.assertNotEqual(widget.summaryEdit.text(), "尚未添加任何协议层。") + self.assertTrue(widget.structureEdit.toPlainText()) + self.assertTrue(widget.hexdumpEdit.toPlainText()) + self.assertTrue(emittedPackets) + self.assertIsNotNone(emittedPackets[-1]) + widget.deleteLater() + + def test_packet_builder_widget_category_filter_hides_common_layers(self) -> None: + widget = PacketBuilderWidget() + + wirelessIndex = widget.layerCategoryCombo.findText("无线与近场") + self.assertGreaterEqual(wirelessIndex, 0) + + widget.layerCategoryCombo.setCurrentIndex(wirelessIndex) + self.application.processEvents() + + self.assertEqual(widget.layerTypeCombo.findData("ip"), -1) + self.assertGreater(widget.layerTypeCombo.count(), 0) + + allIndex = widget.layerCategoryCombo.findText("全部") + widget.layerCategoryCombo.setCurrentIndex(allIndex) + self.application.processEvents() + + self.assertGreaterEqual(widget.layerTypeCombo.findData("ip"), 0) + widget.deleteLater() + + def test_packet_builder_enum_popup_does_not_commit_on_open(self) -> None: + widget = PacketBuilderWidget() + widget.packetBuilderService.addLayer("icmp") + widget._refresh_all(selectLast=True) + + typeRow = -1 + for rowIndex in range(widget.fieldTable.rowCount()): + item = widget.fieldTable.item(rowIndex, 0) + if item is not None and item.text() == "type": + typeRow = rowIndex + break + + self.assertGreaterEqual(typeRow, 0) + comboBox = widget.fieldTable.cellWidget(typeRow, 3) + self.assertIsInstance(comboBox, QtWidgets.QComboBox) + + appliedValues: list[tuple[str, str]] = [] + originalApplyFieldValue = widget._apply_field_value + + def trackingApplyFieldValue(fieldName: str, rawValue: str) -> None: + appliedValues.append((fieldName, rawValue)) + originalApplyFieldValue(fieldName, rawValue) + + widget._apply_field_value = trackingApplyFieldValue + comboBox.showPopup() + self.application.processEvents() + comboBox.lineEdit().editingFinished.emit() + self.application.processEvents() + + self.assertEqual(appliedValues, []) + self.assertTrue(comboBox.view().isVisible()) + comboBox.hidePopup() + widget.deleteLater() + + def test_packet_builder_enum_custom_hex_value_is_applied(self) -> None: + widget = PacketBuilderWidget() + widget.packetBuilderService.addLayer("ether") + widget._refresh_all(selectLast=True) + + typeRow = -1 + for rowIndex in range(widget.fieldTable.rowCount()): + item = widget.fieldTable.item(rowIndex, 0) + if item is not None and item.text() == "type": + typeRow = rowIndex + break + + self.assertGreaterEqual(typeRow, 0) + comboBox = widget.fieldTable.cellWidget(typeRow, 3) + self.assertIsInstance(comboBox, QtWidgets.QComboBox) + + comboBox.lineEdit().setText("0x8808") + widget._handle_enum_editor_editing_finished("type", comboBox) + self.application.processEvents() + + self.assertEqual(widget.packetBuilderService.getFieldNativeValue(0, "type"), 0x8808) + self.assertEqual(widget.packetBuilderService.getFieldValue(0, "type"), "0x8808") + self.assertIn("0x8808", widget.summaryEdit.text()) + widget.deleteLater() + + def test_send_task_widget_execute_updates_result_preview(self) -> None: + packet = FakePacket("IP / TCP", "request dump", b"\x01\x02") + widget = SendTaskWidget() + widget.sendTaskService = FakeSendTaskService() + widget.addStreamFromPacket(packet.copy()) + + widget.executeButton.click() + + self.waitUntil(lambda: widget.workerThread is None) + self.assertEqual(widget.streamTable.rowCount(), 1) + self.assertEqual(widget.packetSummaryEdit.text(), "IP / TCP") + self.assertIn("request dump", widget.packetStructureEdit.toPlainText()) + self.assertIn("已发送 1 个数据包", widget.resultSummaryLabel.text()) + self.assertIn("reply dump", widget.answerStructureEdit.toPlainText()) + widget.deleteLater() + + def test_send_task_widget_only_enables_interface_for_sendp(self) -> None: + widget = SendTaskWidget() + + self.assertTrue(widget.interfaceCombo.isEnabled()) + widget.modeCombo.setCurrentIndex(0) + self.application.processEvents() + self.assertFalse(widget.interfaceCombo.isEnabled()) + widget.modeCombo.setCurrentIndex(2) + self.application.processEvents() + self.assertFalse(widget.interfaceCombo.isEnabled()) + widget.deleteLater() + + def test_send_task_widget_defaults_to_l2_mode_and_fixed_width_columns(self) -> None: + widget = SendTaskWidget() + + self.assertEqual(widget.modeCombo.currentData(), "sendp") + self.assertEqual(widget.streamTable.columnWidth(1), 130) + self.assertEqual(widget.streamTable.columnWidth(2), 220) + widget.deleteLater() + + def test_packet_builder_create_stream_emits_packet(self) -> None: + widget = PacketBuilderWidget() + emittedPackets: list[object] = [] + widget.createStreamRequested.connect(emittedPackets.append) + + widget.addLayerButton.click() + self.application.processEvents() + widget.createStreamButton.click() + self.application.processEvents() + + self.assertEqual(len(emittedPackets), 1) + self.assertIsNotNone(emittedPackets[0]) + self.assertIn("已创建流模板", widget.builderStatusLabel.text()) + widget.deleteLater() + + def test_packet_builder_save_stream_emits_packet_in_edit_mode(self) -> None: + widget = PacketBuilderWidget() + savedPackets: list[object] = [] + widget.saveStreamRequested.connect(savedPackets.append) + + widget.addLayerButton.click() + self.application.processEvents() + self.assertFalse(widget.saveStreamButton.isEnabled()) + + widget.setEditingStreamMode(True) + self.application.processEvents() + self.assertTrue(widget.saveStreamButton.isEnabled()) + + widget.saveStreamButton.click() + self.application.processEvents() + + self.assertEqual(len(savedPackets), 1) + self.assertIsNotNone(savedPackets[0]) + widget.deleteLater() + + def test_offline_analysis_widget_load_result_populates_table_and_copy(self) -> None: + widget = OfflineAnalysisWidget() + emittedPackets: list[object] = [] + widget.importPacketRequested.connect(emittedPackets.append) + packet = FakePacket("IP / UDP", "offline dump", b"\x11\x22") + result = PcapLoadResult( + filePath="capture.pcap", + packetRecords=[ + PcapPacketRecord( + index=1, + timestampText="2026-05-11 10:00:00.000", + sourceText="Ethernet0", + protocolName="UDP", + preview=PacketPreview( + summary="IP / UDP", + structure="offline dump", + hexdump="0000 11 22 .\"", + ), + packet=packet, + ), + ], + summaryText="离线抓包文件加载完成,共 1 个数据包。", + logText="离线抓包文件加载完成,共 1 个数据包。", + state=TaskState.succeeded("离线抓包文件加载完成。"), + ) + + widget._on_load_finished(result) + + self.application.processEvents() + self.assertEqual(widget.packetTable.rowCount(), 1) + self.assertIn("capture.pcap", widget.summaryLabel.text()) + self.assertEqual(widget.packetDetailSummary.text(), "IP / UDP") + widget.copyToBuilderButton.click() + self.application.processEvents() + self.assertEqual(len(emittedPackets), 1) + self.assertEqual(emittedPackets[0].summary(), "IP / UDP") + widget.deleteLater() + + def test_packet_builder_widget_loads_decoded_mac_control_pause_packet(self) -> None: + widget = PacketBuilderWidget() + packet = scapy.Ether( + bytes( + scapy.Ether(type=0x8808) + / MACControlPause(pause_time=3) + ) + ) + + widget.loadPacket(packet) + + self.application.processEvents() + self.assertEqual(widget.builderStatusLabel.text(), "已导入当前数据包。") + self.assertEqual( + [record.name for record in widget.packetBuilderService.getLayerRecords()], + ["Ethernet", "MACControlPause", "Raw"], + ) + self.assertIn("MACControlPause", widget.summaryEdit.text()) + widget.deleteLater() + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/gui/tests/test_packet_builder_service_import.py b/gui/tests/test_packet_builder_service_import.py new file mode 100644 index 00000000000..063d930fe8d --- /dev/null +++ b/gui/tests/test_packet_builder_service_import.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import unittest + +from packet_studio.services.packet_builder_service import PacketBuilderService + + +class PacketBuilderServiceImportTests(unittest.TestCase): + def test_list_available_layers_includes_additional_scapy_protocols(self) -> None: + service = PacketBuilderService() + + availableLayerNames = {layer.packetClassName for layer in service.listAvailableLayers()} + + self.assertIn("LLC", availableLayerNames) + self.assertIn("STP", availableLayerNames) + self.assertIn("BOOTP", availableLayerNames) + + def test_dynamic_layer_labels_do_not_use_packet_name_descriptor_repr(self) -> None: + service = PacketBuilderService() + + dhcp6ReplyLayer = next( + layer for layer in service.listAvailableLayers() if layer.packetClassName == "DHCP6_Reply" + ) + + self.assertEqual(dhcp6ReplyLayer.label, "DHCP6_Reply") + self.assertNotIn(" None: + service = PacketBuilderService() + import scapy.all as scapy + + packet = scapy.IP(dst="1.1.1.1") / scapy.ICMP() / scapy.Raw(load=b"abc") + + service.importPacket(packet) + + layerRecords = service.getLayerRecords() + self.assertEqual([record.name for record in layerRecords], ["IP", "ICMP", "Raw"]) + rebuiltPacket = service.buildPacket() + self.assertIsNotNone(rebuiltPacket) + self.assertEqual(bytes(rebuiltPacket), bytes(packet)) + + def test_ether_type_field_uses_hex_and_contains_common_choices(self) -> None: + service = PacketBuilderService() + service.addLayer("ether") + + fieldRecords = service.getFieldRecords(0) + typeField = next(record for record in fieldRecords if record.name == "type") + + self.assertEqual(typeField.currentValue, "0x9000") + self.assertIn(("0x0800", "IPv4 (0x0800)"), typeField.choices) + self.assertIn(("0x8808", "Ethernet PAUSE (0x8808)"), typeField.choices) + + def test_ether_type_field_accepts_custom_hex_value(self) -> None: + service = PacketBuilderService() + service.addLayer("ether") + + service.setFieldValue(0, "type", "0x8808") + + self.assertEqual(service.getFieldNativeValue(0, "type"), 0x8808) + self.assertEqual(service.getFieldValue(0, "type"), "0x8808") + + def test_import_packet_skips_abstract_mac_control_wrapper(self) -> None: + service = PacketBuilderService() + import scapy.all as scapy + from scapy.contrib.mac_control import MACControlPause + + packet = scapy.Ether(bytes(scapy.Ether(type=0x8808) / MACControlPause(pause_time=3))) + + service.importPacket(packet) + + layerRecords = service.getLayerRecords() + self.assertEqual( + [record.name for record in layerRecords], + ["Ethernet", "MACControlPause", "Raw"], + ) + rebuiltPacket = service.buildPacket() + self.assertIsNotNone(rebuiltPacket) + self.assertEqual(bytes(rebuiltPacket), bytes(packet)) + + def test_import_packet_supports_previously_missing_llc_and_stp_layers(self) -> None: + service = PacketBuilderService() + import scapy.all as scapy + + packet = scapy.Ether() / scapy.LLC() / scapy.STP() + + service.importPacket(packet) + + layerRecords = service.getLayerRecords() + self.assertEqual( + [record.name for record in layerRecords], + ["Ethernet", "LLC", "Spanning Tree Protocol"], + ) + rebuiltPacket = service.buildPacket() + self.assertIsNotNone(rebuiltPacket) + self.assertEqual(bytes(rebuiltPacket), bytes(packet)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/gui/tests/test_pcap_analysis_service.py b/gui/tests/test_pcap_analysis_service.py new file mode 100644 index 00000000000..a049da248e7 --- /dev/null +++ b/gui/tests/test_pcap_analysis_service.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import os +import tempfile +import unittest + +import scapy.all as scapy + +from packet_studio.domain.task_models import TaskPhase +from packet_studio.services.pcap_analysis_service import PcapAnalysisService + + +class PcapAnalysisServiceTests(unittest.TestCase): + def test_load_packets_reads_expected_count(self) -> None: + fd, filePath = tempfile.mkstemp(suffix=".pcap") + os.close(fd) + try: + scapy.wrpcap( + filePath, + [ + scapy.IP(dst="1.1.1.1") / scapy.ICMP(), + scapy.IP(dst="8.8.8.8") / scapy.UDP(dport=53), + ], + ) + + service = PcapAnalysisService() + result = service.loadPackets(filePath, maxPackets=10) + + self.assertEqual(result.packetCount, 2) + self.assertIn("ICMP", result.packetRecords[0].summary) + self.assertIn("UDP", result.packetRecords[1].summary) + self.assertEqual(result.packetRecords[0].protocolName, "ICMP") + self.assertIn("IP", result.packetRecords[0].preview.structure) + self.assertTrue(result.packetRecords[0].preview.hexdump) + self.assertIn("加载完成,共 2 个数据包", result.summaryText) + self.assertEqual(result.state.phase, TaskPhase.SUCCEEDED) + finally: + if os.path.exists(filePath): + os.unlink(filePath) + + def test_load_packets_honors_limit(self) -> None: + fd, filePath = tempfile.mkstemp(suffix=".pcap") + os.close(fd) + try: + scapy.wrpcap( + filePath, + [ + scapy.IP(dst="1.1.1.1") / scapy.ICMP(), + scapy.IP(dst="8.8.8.8") / scapy.UDP(dport=53), + ], + ) + + service = PcapAnalysisService() + result = service.loadPackets(filePath, maxPackets=1) + + self.assertEqual(result.packetCount, 1) + finally: + if os.path.exists(filePath): + os.unlink(filePath) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/gui/tests/test_scapy_packet_adapter.py b/gui/tests/test_scapy_packet_adapter.py new file mode 100644 index 00000000000..98023c29921 --- /dev/null +++ b/gui/tests/test_scapy_packet_adapter.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import unittest + +from packet_studio.adapters.scapy_packet_adapter import ScapyPacketAdapter + + +class FakePacket: + def __init__(self, summary: str, dump: str, payload: bytes) -> None: + self._summary = summary + self._dump = dump + self._payload = payload + + def copy(self) -> "FakePacket": + return FakePacket(self._summary, self._dump, self._payload) + + def summary(self) -> str: + return self._summary + + def show(self, dump: bool = False) -> str: + if dump: + return self._dump + raise AssertionError("仅测试 dump=True 路径") + + def __bytes__(self) -> bytes: + return self._payload + + +class ScapyPacketAdapterTests(unittest.TestCase): + def test_build_preview_formats_packet_fields(self) -> None: + adapter = ScapyPacketAdapter() + packet = FakePacket("IP / TCP", "packet dump", b"\x01\x02ABC") + + preview = adapter.buildPreview(packet) + + self.assertEqual(preview.summary, "IP / TCP") + self.assertEqual(preview.structure, "packet dump") + self.assertIn("0000", preview.hexdump) + self.assertIn("01 02 41 42 43", preview.hexdump) + + def test_clone_packet_returns_copy(self) -> None: + adapter = ScapyPacketAdapter() + packet = FakePacket("IP", "dump", b"\x00") + + clonedPacket = adapter.clonePacket(packet) + + self.assertIsNot(packet, clonedPacket) + self.assertEqual(bytes(clonedPacket), bytes(packet)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/gui/tests/test_send_task_service.py b/gui/tests/test_send_task_service.py new file mode 100644 index 00000000000..34547614f94 --- /dev/null +++ b/gui/tests/test_send_task_service.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import unittest +from itertools import count + +from packet_studio.domain.task_models import TaskPhase +from packet_studio.services.send_task_service import SendTaskRequest, SendTaskService + + +class FakePacket: + def __init__(self, summary: str, dump: str, payload: bytes) -> None: + self._summary = summary + self._dump = dump + self._payload = payload + + def copy(self) -> "FakePacket": + return FakePacket(self._summary, self._dump, self._payload) + + def summary(self) -> str: + return self._summary + + def show(self, dump: bool = False) -> str: + if dump: + return self._dump + raise AssertionError("仅测试 dump=True 路径") + + def __bytes__(self) -> bytes: + return self._payload + + +class FakeScapy: + def __init__(self) -> None: + self.calls: list[tuple[str, object, dict[str, object]]] = [] + self.sr1Response: object | None = None + + def send(self, packet: object, **kwargs: object) -> list[object]: + self.calls.append(("send", packet, dict(kwargs))) + count = int(kwargs.get("count", 1)) + return [packet] * count + + def sendp(self, packet: object, **kwargs: object) -> list[object]: + self.calls.append(("sendp", packet, dict(kwargs))) + count = int(kwargs.get("count", 1)) + return [packet] * count + + def sr1(self, packet: object, **kwargs: object) -> object | None: + self.calls.append(("sr1", packet, dict(kwargs))) + return self.sr1Response + + +class SendTaskServiceTests(unittest.TestCase): + def test_send_ignores_interface_for_l3_mode(self) -> None: + fakeScapy = FakeScapy() + service = SendTaskService(fakeScapy) + packet = FakePacket("IP / TCP", "packet dump", b"\x01\x02") + + result = service.execute( + SendTaskRequest( + mode="send", + interfaceName="Ethernet0", + count=3, + intervalSeconds=0.25, + ), + packet, + ) + + self.assertEqual(result.sentCount, 3) + self.assertEqual(fakeScapy.calls[0][0], "send") + self.assertNotIn("iface", fakeScapy.calls[0][2]) + self.assertEqual(len(fakeScapy.calls), 3) + self.assertEqual(fakeScapy.calls[0][2]["count"], 1) + self.assertEqual(fakeScapy.calls[0][2]["inter"], 0.0) + self.assertIn("未显式传入 iface", result.logText) + self.assertIn("已完成发送 3 个", result.summaryText) + self.assertEqual(result.state.phase, TaskPhase.SUCCEEDED) + + def test_sendp_passes_interface_for_l2_mode(self) -> None: + fakeScapy = FakeScapy() + service = SendTaskService(fakeScapy) + packet = FakePacket("Ether / ARP", "arp dump", b"\xaa\xbb") + + result = service.execute( + SendTaskRequest( + mode="sendp", + interfaceName="\\Device\\NPF_{123}", + count=2, + intervalSeconds=0.1, + ), + packet, + ) + + self.assertEqual(result.sentCount, 2) + self.assertEqual(fakeScapy.calls[0][0], "sendp") + self.assertEqual(fakeScapy.calls[0][2]["iface"], "\\Device\\NPF_{123}") + self.assertIn("未应答 0 个", result.summaryText) + self.assertEqual(result.state.phase, TaskPhase.SUCCEEDED) + + def test_sr1_returns_answer_preview(self) -> None: + fakeScapy = FakeScapy() + fakeScapy.sr1Response = FakePacket("IP / ICMP", "reply dump", b"\x08\x00") + service = SendTaskService(fakeScapy) + packet = FakePacket("IP / ICMP", "request dump", b"\x01") + + result = service.execute( + SendTaskRequest( + mode="sr1", + timeoutSeconds=2.5, + retryCount=1, + ), + packet, + ) + + self.assertEqual(result.sentCount, 1) + self.assertEqual(result.unansweredCount, 0) + self.assertIsNotNone(result.answerPreview) + self.assertEqual(result.answerPreview.summary, "IP / ICMP") + self.assertEqual(fakeScapy.calls[0][0], "sr1") + self.assertEqual(fakeScapy.calls[0][2]["timeout"], 2.5) + self.assertEqual(fakeScapy.calls[0][2]["retry"], 1) + self.assertIn("未应答 0 个", result.summaryText) + self.assertEqual(result.state.phase, TaskPhase.SUCCEEDED) + + def test_send_rotates_across_multiple_streams(self) -> None: + fakeScapy = FakeScapy() + service = SendTaskService(fakeScapy) + packets = [ + FakePacket("IP / TCP 1", "packet dump 1", b"\x01"), + FakePacket("IP / TCP 2", "packet dump 2", b"\x02"), + ] + + result = service.execute( + SendTaskRequest( + mode="send", + count=2, + intervalSeconds=0.0, + ), + packets, + ) + + self.assertEqual(result.sentCount, 4) + self.assertEqual(len(fakeScapy.calls), 4) + self.assertIn("流数量: 2", result.logText) + self.assertIn("共 2 条流", result.summaryText) + + def test_continuous_send_can_be_stopped(self) -> None: + fakeScapy = FakeScapy() + service = SendTaskService(fakeScapy) + packet = FakePacket("IP / TCP", "packet dump", b"\x01\x02") + invocationCounter = count() + + def stopRequested() -> bool: + return next(invocationCounter) >= 3 + + sleepCalls: list[float] = [] + + result = service.execute( + SendTaskRequest( + mode="send", + sendStrategy="continuous", + intervalSeconds=0.2, + ), + packet, + stopRequested=stopRequested, + sleep=sleepCalls.append, + ) + + self.assertEqual(result.sentCount, 2) + self.assertEqual(result.state.phase, TaskPhase.STOPPED) + self.assertEqual(sleepCalls, [0.2]) + self.assertIn("已停止发送 2 个数据包", result.summaryText) + + def test_sr1_continuous_is_rejected(self) -> None: + fakeScapy = FakeScapy() + service = SendTaskService(fakeScapy) + packet = FakePacket("IP / ICMP", "request dump", b"\x01") + + with self.assertRaisesRegex(ValueError, "sr1 当前仅支持 burst 模式"): + service.execute( + SendTaskRequest(mode="sr1", sendStrategy="continuous"), + packet, + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/gui/tests/test_tool_registry_service.py b/gui/tests/test_tool_registry_service.py new file mode 100644 index 00000000000..722f8cae2fa --- /dev/null +++ b/gui/tests/test_tool_registry_service.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import unittest + +from packet_studio.services.tool_registry_service import ToolRegistryService + + +class ToolRegistryServiceTests(unittest.TestCase): + def test_list_tools_contains_core_entries(self) -> None: + service = ToolRegistryService() + + tools = service.listTools() + + self.assertGreaterEqual(len(tools), 3) + titles = [tool.title for tool in tools] + self.assertIn("包构建器", titles) + self.assertIn("发送任务", titles) + self.assertIn("离线分析", titles) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/gui/tests/test_workspace_document_service.py b/gui/tests/test_workspace_document_service.py new file mode 100644 index 00000000000..b68b500610e --- /dev/null +++ b/gui/tests/test_workspace_document_service.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import unittest + +from packet_studio.domain.task_models import TaskPhase, TaskState +from packet_studio.domain.workspace_models import WorkspacePanelSnapshot +from packet_studio.services.workspace_document_service import WorkspaceDocumentService + + +class WorkspaceDocumentServiceTests(unittest.TestCase): + def test_create_task_record_assigns_fields(self) -> None: + service = WorkspaceDocumentService() + + record = service.createTaskRecord( + sequenceNumber=3, + sourceTitle="发送任务", + message="发送任务执行完成。", + phase=TaskPhase.SUCCEEDED, + detailText="模式: send", + ) + + self.assertEqual(record.sequenceNumber, 3) + self.assertEqual(record.sourceTitle, "发送任务") + self.assertEqual(record.phase, TaskPhase.SUCCEEDED) + self.assertEqual(record.detailText, "模式: send") + + def test_build_workspace_document_generates_summary_text(self) -> None: + service = WorkspaceDocumentService() + panelSnapshots = [ + WorkspacePanelSnapshot( + panelId="send-task", + title="发送任务", + taskState=TaskState.succeeded("发送任务执行完成。"), + itemCount=1, + detailText="模式: send", + ), + ] + taskRecords = [ + service.createTaskRecord(1, "发送任务", "发送任务执行完成。", TaskPhase.SUCCEEDED), + ] + + document = service.buildWorkspaceDocument( + activeTabTitle="发送任务", + openTabTitles=["欢迎", "发送任务"], + panelSnapshots=panelSnapshots, + taskRecords=taskRecords, + interfaceCount=2, + interfaceSummaryText="已发现 2 个可用接口。", + ) + + self.assertEqual(document.taskCount, 1) + summaryText = document.to_multiline_text() + self.assertIn("当前页签: 发送任务", summaryText) + self.assertIn("任务记录数: 1", summaryText) + self.assertIn("发送任务", summaryText) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file