diff --git a/AGENTS.md b/AGENTS.md
index 085a9bf..5610418 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,54 +1,146 @@
-# AGENTS
-
-## 项目简介
-- 本项目 `xidio` 是一个面向西安电子科技大学(西电)校园网场景的网络诊断辅助工具。
-- 当前核心目标是采集网络环境信息,帮助定位“无法上网 / 网络异常 / 路由或代理配置异常”等问题。
-- 现阶段以 **Windows 端诊断** 为主,CLI 输出供人工排障使用。
-
-## 代码结构
-- `xidio/xidio.Core`:平台无关核心层。
-- `xidio/xidio.Platform.Windows`:Windows 平台实现(WMI + WLAN API)。
-- `xidio/xidio.Platform.macOS`:macOS 平台实现(system_profiler + networksetup + netstat + airport)。
-- `xidio/xidio.CLI`:命令行入口与诊断报告输出(多平台构建:`net10.0-windows` / `net10.0`)。
-- `xidio/xidio.slnx`:解决方案入口。
-
-## 技术栈
-- 语言:`C#`
-- SDK / 框架:`.NET 10`(`net10.0` / `net10.0-windows`)
-- 平台能力:
- - `System.Net.NetworkInformation`(网卡、IP、网关、DNS)
- - `System.Management`(WMI 查询驱动版本、路由、接口指标)
- - `wlanapi.dll` P/Invoke(SSID/BSSID 无线连接信息)
-- 产物形态:CLI 可执行程序(启用 AOT 发布配置)
-
-## 当前实现重点
-- `NetworkDiagnosticsCollector` 负责聚合诊断数据并生成 `NetworkDiagnosticReport`。
-- 通过 `IPlatformNetworkDiagnosticsProvider` 抽象平台差异;Windows 端由 `WindowsPlatformNetworkDiagnosticsProvider` 实现。
-- 已包含虚拟网卡过滤逻辑(如 Hyper-V、VMware、WSL、WireGuard、Npcap 等关键字)。
-- 输出包含:
- - 主网卡识别(有线 / 无线 / PPPoE)
- - IP、网关、DHCP、DNS、路由与接口 Metric
- - 网卡驱动版本
- - 系统代理状态
-
-## 开发注意事项(给后续 AI Agents)
-- 保持分层边界:
- - `xidio.Core` 不应依赖 Windows 专有 API。
- - 平台相关实现放在 `xidio.Platform.*`。
-- 新增平台能力时,优先扩展 `IPlatformNetworkDiagnosticsProvider`,不要把平台分支直接写进 CLI。
-- 维持“诊断优先、不中断”原则:
- - 采集失败时优先降级返回,不要因单点异常导致整体退出。
- - 参考现有 `SafeGet` 和 provider 内部异常兜底模式。
-- 变更虚拟网卡过滤规则时,注意误杀真实网卡风险;需结合中英文设备名与常见驱动关键词。
-- CLI 文案可调整,但尽量保持关键信息字段稳定,避免影响后续日志解析或人工比对。
-- 当前仓库暂无测试项目;若引入复杂逻辑(分类、过滤、路由排序),建议补充单元测试。
-
-## 常用命令
-- 还原依赖:`dotnet restore xidio/xidio.slnx`
-- 构建:`dotnet build xidio/xidio.slnx -c Release`
-- 运行 CLI:`dotnet run --project xidio/xidio.CLI/xidio.CLI.csproj`
-- 发布(示例):`dotnet publish xidio/xidio.CLI/xidio.CLI.csproj -c Release`
-
-## 现状与后续
-- `xidio.Core/Repairs` 与 `xidio.Core/Reporting` 目录已预留,尚未落地代码。
-- 后续可在“诊断结论 + 自动修复建议”方向扩展,但应先保证采集结果稳定、可复现。
+# xidio Agents Instructions
+
+xidio(XiDian Internet Diagnostic Intelligence Operator)是一款跨平台的校园网诊断工具,由 Project Hazelita 基于 C# 和 .NET 开发,专门针对西安电子科技大学的网络环境。
+
+在为 xidio 贡献代码前,请先阅读 `README.md` 了解项目最新情况,并仔细阅读本文件、遵循下面的指引。请确保你的修改是**最小化**的,并且与项目的现有内容保持一致、互不冲突。
+
+## 各部分功能
+
+`xidio.Core` 主要负责诊断、修复和上报。
+
+### 诊断 / Diagnostics
+
+xidio 需要诊断用户设备,确定网络问题的根源。诊断过程分为**信息收集**和**分析**两步。
+
+#### 收集信息
+
+xidio 将从用户及其设备上收集必要信息。以下列出部分需要采集的信息类别。
+
+##### 用户场景信息
+
+xidio 会主动询问:
+
+1. 用户所在位置。
+2. 当前连接方式。连接方式划分为:
+ - **直接连接**:用户直接通过 Wi-Fi 连接校内任意 AP,或通过网口以 PPPoE 方式连接。
+ - **间接连接**:用户通过路由器等方式接入,数据经校园网设施传输。
+ - **其他连接**:用户通过手机热点等方式连接,数据未经过校园网设施。
+3. 遇到的问题。例如:无法连接 Wi-Fi;已连接但显示“无 Internet”;未跳转到 Portal;Portal 认证成功但无法打开网页;部分应用不能使用;拨号失败;拨号成功但无 Internet 等。
+4. 问题波及范围。例如:仅本机有问题;同宿舍其他人也有问题;同楼层其他人也有问题;不清楚。
+
+这些信息有助于网管会判断是否为区域性问题。
+
+##### 系统与网卡基础信息
+
+xidio 需要收集:
+
+1. 操作系统信息:操作系统类型(Windows、Linux 或 macOS)、版本号;xidio 版本号;xidio 是否以管理员/root 权限运行。
+2. 时间:本机时间、某 NTP 服务器时间,以及两者的差值。
+3. 网络接口:所有网络接口及主网络接口(物理接口)的接口名称、类型、是否启用、是否已连接、(若有)MAC 地址、链路速度、MTU、接口 metric 等,以及当前默认路由所使用的具体网卡。
+
+如果用户使用 Wi-Fi 连接,还需收集:当前 Wi-Fi 的 SSID、BSSID、RSSI、频段、信道、PHY 类型、认证/加密类型、连接时长、当前连接速率,以及可见的 SSID 列表。
+
+如果用户使用 PPPoE 连接,还需收集:网线是否插好;对端是否有链路;链路速度;是否存在 PPPoE/宽带连接,若存在,则获取 PPPoE 的当前状态、分配到的 IP、DNS、默认路由。在可行的情况下,可尝试进行一次 RAS 拨号并记录 RAS 错误码;(在 Windows 上)还要检查宽带连接名称是否含有非 ASCII 字符,以及用户是否安装了 Npcap 或 WinPcap。
+
+##### IP、DHCP、DNS、路由、ARP 信息
+
+如果用户已连接到至少一个网络,xidio 将收集:IPv4 地址、IPv6 地址、子网掩码/前缀长度、是否通过 DHCP 获取地址(若是,则收集 DHCP 服务器地址、租约开始和过期时间)、默认网关、DNS 服务器、路由表、ARP/IPv6 Neighbor 表。
+
+##### 本机因素
+
+在征得用户同意后,xidio 可以进一步收集:
+
+1. 系统代理设置:包括 Windows Internet Options 代理、WinHTTP 代理、系统代理、环境变量 `HTTP_PROXY`/`HTTPS_PROXY`、macOS 网络代理等。
+2. VPN/TUN/TAP 相关状态:如 Clash TUN、v2rayN、WireGuard、OpenVPN、ZeroTier、Tailscale、WARP、VMware/VirtualBox 虚拟网卡、WSL 虚拟网卡等。
+3. DNS-over-HTTPS 与 Secure DNS 状态。
+4. hosts 文件内容。
+5. 防火墙状态。
+6. 安全软件及过滤型驱动。
+7. 浏览器代理设置。
+
+##### 认证与业务状态
+
+在征得用户同意后,xidio 可检查:认证服务器是否可达;当前用户是否已通过校园网认证;当前账号是否在线;当前设备是否出现在在线列表中;套餐是否生效;用户所属运营商/套餐类型;宽带优先级;是否欠费;是否达到设备数量上限。
+
+##### 主动探测信息
+
+我们选定以下目标进行主动探测:
+
+1. 校园内目标:认证服务器域名和 IP、校内若干服务的 HTTP/HTTPS 域名。
+2. 校园外目标:xidio 官方域名、HTTP 204 端点、HTTPS 固定响应体端点、TCP Echo 或 Connect 端点、UDP Echo/Jitter 端点。
+
+执行探测或收集:ARP 默认网关信息;ICMP Ping 情况;到 80、443、53(TCP)的 TCP Connect 情况;UDP DNS 查询;UDP Echo/Jitter/丢包测试;HTTP GET 情况;HTTPS 握手与证书校验;DNS 解析情况(包括使用系统解析器和直接向当前 DNS 服务器查询,分别测试 A 和 AAAA 记录)。更进一步,还可进行 Traceroute/MTR、MTU/PMTUD 测试。
+
+#### 分析
+
+我们按以下顺序逐层排查问题。每次检测需覆盖所有层,且每层不能仅返回“是/否”,必须附带完整的探测信息。
+
+```text
+本机网卡 -> 有线/Wi-Fi 情况 -> IP 获取 -> 默认网关可达 -> 认证服务可达 -> 认证服务正常 -> DNS 正常 -> 探测目标可达 -> 具体协议
+```
+
+下面给出典型的判断流程,xidio 默认按照该顺序排查,后续也支持用户自定义判断规则。
+
+##### Wi-Fi 场景
+
+###### 无法连接 Wi-Fi
+
+适用于设备附近扫不到校园 SSID、信号极弱,或认证失败的情形。SSID 不可见可能是 AP 覆盖不足或无线网卡问题;SSID 可见且信号不弱但无法连接,可能是配置文件损坏、认证方式错误或 AP 故障。xidio 可建议用户忘记网络后重新连接、移动到 AP 附近等。
+
+###### Wi-Fi 连接成功但未获取到 IP
+
+适用于已关联 SSID/BSSID,但 IPv4 地址为 `169.254.x.x` 或为空,DHCP 服务器为空或请求超时等情形。若连接其他 SSID 也失败,则可能是本机 DHCP 服务、防火墙或驱动问题;若同一 AP 下仅个别用户失败,可能是本机网络栈或网卡驱动问题;若多个用户同时失败,则可能是 AP/接入交换机/VLAN/DHCP 中继问题。
+
+###### 获取到 IP 但没有默认网关
+
+适用于 DHCP 成功分配 IP,但默认网关为空,或路由表中无 `0.0.0.0/0` 的情形。可能由 DHCP 配置异常,或用户自行配置了错误的网络参数导致。
+
+###### 有默认网关但不可达
+
+适用于有默认网关,但 ARP 表中无对应 MAC 地址,且 Ping/TCP 探测网关失败、校园认证 IP 也不可达等情况。可能是 AP/交换机/网关/VLAN 异常。
+
+###### 网关可达但认证页面打不开
+
+如果解析 Portal 域名失败但 IP 可达,则可能是 DNS 故障,此时可检查 DNS 和 DoH/公共 DNS 状况。
+
+如果 Portal 的域名和 IP 均不可达,可能是校内认证服务存在故障。
+
+###### 认证成功但无法上网
+
+按层次分析:如果能访问公网 IP 但无法解析域名,则问题可能在 DNS。若 DNS 正常但 TCP 80/443 不通,可能是出口路由、防火墙、代理或 VPN 问题。若 TCP 80/443 正常但浏览器无法打开网页,可能是系统代理、浏览器代理、证书、系统时间或插件问题。若 TCP 正常但 UDP 丢包严重,可能与 UDP/QoS/运营商出口/CDN/上游拥塞有关。若 IPv4 正常但 IPv6 异常(或反之),则可能是双栈配置问题,浏览器 Happy Eyeballs 机制或地址优先级导致体验异常。
+
+##### PPPoE 场景
+
+###### 以太网无链路
+
+适用于以太网未连接、无 carrier 或链路速度为 0 的情形。通常由网线未连接或连接错误导致。
+
+###### 有链路但未拨号
+
+适用于以太网处于 `up` 状态,但没有 PPP 接口和 PPPoE 默认路由的情形,一般是因为尚未拨号。
+
+###### PPPoE 拨号失败
+
+若有拨号错误码,可对照错误码进行诊断。如果接口有链路但 PPPoE Discovery 无任何响应,则优先考虑接入侧问题。
+
+###### PPPoE 拨号成功但无法联网
+
+需检查 PPP 接口是否获取到 IP、DNS,默认路由是否经过 PPP 接口,以及是否开启了系统代理等。
+
+##### 其他连接场景
+
+对于其他连接方式,xidio 不提供专门的分析帮助,因为这些情况通常与校园网无关。
+
+#### 其他问题
+
+##### Captive Portal Detection 失效
+
+若未自动弹出认证页面,但可手动访问 Portal,则只是 Captive Portal 探测机制失效;若域名访问失败而 IP 访问成功,则为 DNS 问题;若两者均失败,则通常是认证服务器路径或网关问题。
+
+##### Windows 侧的“无 Internet”连接
+
+Windows 显示的“无 Internet”主要依赖 NCSI 探测结果。xidio 不应仅以此为依据,应手动测试默认网关、Portal、DNS、HTTP 204、HTTPS 以及主动探测目标等情况,以判断是否真正无 Internet 连接。
+
+##### 仅部分应用无法访问互联网
+
+适用于能上 QQ 但不能打开网页等场景。此类问题成因复杂,xidio 可进行基本探测,如基础 HTTP/HTTPS 连通性、DNS 解析、TCP 443、UDP 443/QUIC 等。
diff --git a/README.md b/README.md
index 1e89ae2..54877af 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,6 @@
-

@@ -18,197 +17,6 @@ _✨**Xidian Internet Diagnostic Intelligence Operator**✨_\
-## Roadmap
-
-### 由用户回答
-
-- 当前位置:
- - 南校区 / 北校区
- - 宿舍楼 / 教学楼 / 图书馆 / 实验室
- - 楼栋、楼层,可选
-- 当前连接方式:
- - Wi-Fi
- - 宿舍有线直连
- - 通过路由器
- - 手机热点
- - 不确定
-- 现象:
- - 连不上 Wi-Fi
- - 连上 Wi-Fi 但无 Internet
- - 认证页不弹
- - 认证成功但打不开网页
- - QQ/微信/游戏/浏览器某个能用某个不能用
- - 有线拨号失败
- - 有线拨号成功但没网
-- 是否别人也有问题:
- - 只有我
- - 同宿舍也有
- - 同楼层也有
- - 不清楚
-
-### 系统与网卡基础信息
-
-- 操作系统:
- - Windows / macOS / Linux
- - 版本号
- - xidio 版本
- - 是否管理员权限运行
-- 系统时间:
- - 本机时间
- - 与网络时间/HTTPS Date 头的偏差
- 因为时间错了会导致 HTTPS、认证页面、证书校验异常。
-- 所有网络接口:
- - 接口名称
- - 接口类型:Ethernet / Wi-Fi / PPP / VPN / Loopback / Virtual
- - 是否启用
- - 是否连接
- - MAC 地址,默认应脱敏或哈希
- - 链路速度
- - MTU
- - 接口 metric
- - 收发包统计、错误包、丢弃包
-- 当前默认路由到底走哪块网卡
-
-如果是 Wi-Fi:
-
-- 当前是否已关联 Wi-Fi
-- SSID
-- BSSID,也就是 AP 的 MAC,建议默认脱敏
-- 信号强度 RSSI / Signal Quality
-- 频段:
- - 2.4GHz
- - 5GHz
- - 6GHz,如果有
-- 信道
-- PHY 类型:
- - 802.11n/ac/ax 等
-- 认证/加密类型
-- 连接时长
-- 当前连接速率
-- 可见的校园网 SSID 列表
-- 当前 AP 是否是已知校园 AP
-- 最近 Wi-Fi 连接失败原因码,能拿到的话
-
-如果是 PPPoE:
-
-- 以太网接口是否有 carrier:
- - 网线是否插上
- - 对端是否有链路
-- 链路速度:
- - 10M/100M/1G
-- 是否存在 PPPoE/宽带连接
-- PPPoE 当前状态:
- - 未拨号
- - 正在连接
- - 已连接
- - 已断开
-- PPPoE 分配到的:
- - IP
- - DNS
- - 默认路由
-- Windows RAS 错误码:
- - 691:账号/密码/认证失败
- - 651:PPPoE 服务不可达/链路层问题/驱动问题
- - 678/718:远端无响应/超时
- - 720: 协 -连接关闭
-- 宽带连接名称是否包含非 ASCII 字符
-- 是否安装 Npcap/WinPcap
-
-### IP、DHCP、DNS、路由、ARP
-
-- IPv4 地址
-- IPv6 地址
-- 子网掩码/前缀长度
-- 是否 DHCP 获取
-- DHCP 服务器地址
-- DHCP 租约开始/过期时间
-- 默认网关
-- DNS 服务器
-- 路由表
-- ARP 表 / IPv6 Neighbor 表
-- 是否存在 169.254.x.x 地址
-- 是否存在奇怪的静态 IP
-- 是否同时存在多个默认路由
-- 每条默认路由的 metric
-- 当前访问校园服务器/公网服务器时实际走哪条路由
-
-### 本机干扰因素
-
-- 系统代理:
- - Windows Internet Options Proxy
- - WinHTTP Proxy
- - 环境变量 `HTTP_PROXY` / `HTTPS_PROXY`
- - macOS network proxy
-- 是否开启 VPN/TUN/TAP:
- - Clash TUN
- - v2rayN
- - WireGuard
- - OpenVPN
- - ZeroTier
- - Tailscale
- - WARP
- - VMware/VirtualBox 虚拟网卡
- - WSL 虚拟网卡
-- DNS-over-HTTPS / Secure DNS 状态,能检测多少算多少
-- hosts 文件是否包含校园认证域名、常见网站域名
-- 防火墙状态
-- 安全软件/过滤驱动,建议只检测常见网络过滤组件,不要默认上传完整进程列表
-- 浏览器代理设置,至少 Edge/Chrome 可以检测系统代理;浏览器独立 DoH 比较难,但可以提示用户
-
-### 校园认证与业务状态
-
-- 当前是否已认证
-- 当前账号在线状态
-- 当前设备是否在在线会话列表里
-- 套餐是否生效
-- 运营商/套餐类型
-- 宽带优先级/出口优先级
-- 是否欠费
-- 是否达到设备数上限
-- 是否存在异常在线设备
-- 最近登录错误码
-- 是否被强制下线
-- 认证服务器是否可达
-
-### 主动探测
-
-- ARP 默认网关
-- ICMP Ping,注意 ICMP 失败不能直接代表网络失败
-- TCP Connect:
- - 80
- - 443
- - 53 TCP
-- UDP DNS 查询
-- UDP echo/jitter/loss 测试
-- HTTP GET,不跟随重定向
-- HTTPS 握手与证书校验
-- DNS 解析:
- - 使用系统解析器
- - 直接向当前 DNS 服务器查询
- - 分别测 A/AAAA
-- Traceroute/MTR,深度模式再跑
-- MTU/PMTUD 测试,尤其 PPPoE 场景
-
-### 分级探测
+## 简介
-```text
-本机网卡
- ↓
-有线链路 / Wi-Fi 关联
- ↓
-IP 获取:DHCP / PPPoE
- ↓
-默认网关可达
- ↓
-校园认证服务器可达
- ↓
-认证状态正常
- ↓
-DNS 正常
- ↓
-公网 IP 可达
- ↓
-公网域名可达
- ↓
-具体协议:HTTP / HTTPS / UDP / 游戏 / 微信等
-```
+xidio _(XiDian Internet Diagnostic Intelligence Operator)_ 是一款基于 C# 和 .NET 架构的、由 Project Hazelita 开发的、跨平台的适用于西安电子科技大学的校园网诊断工具。xidio 以 [NanCunChild](https://github.com/NanCunChild) 的项目 [Marduk](https://github.com/NanCunChild/Marduk) 为灵感来源,同时具有 CLI 和 GUI 两种用户界面,致力于成为一款简单易用且足够强大的辅助工具。
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..d206507
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,126 @@
+# 项目整体架构
+
+下面我们介绍 xidio 的项目整体架构。这包括代码架构、xidio 的诊断和修复功能的流程架构和上报流程。
+
+## 代码架构
+
+项目使用 C# 开发,SDK 基于 .NET 10(`net10.0` 和 `net10.0-windows`)。整个解决方案划分为以下模块:
+
+- `xidio.Core`:xidio 的核心模块,封装了所有诊断、修复和上报逻辑,供 CLI 与 GUI 调用。
+- `xidio.CLI`:xidio 的命令行版本,同时也可作为终端内的交互式工具使用。我们将使用 `System.CommandLine` 和 `Spectre.Console`。
+- `xidio.GUI`:xidio 的图形界面版本,基于 Avalonia 框架实现跨平台功能。
+
+### xidio 的调用能力和平台特性
+
+为了维持项目的跨平台性,我们建议优先采用 .NET 提供的能力,在平台强关联的功能中再调用或封装操作系统提供的接口。
+
+#### 通用层
+
+我们可以使用 .NET 提供的:
+
+- `System.Net.NetworkInformation.NetworkInterface`
+- `IPGlobalProperties`
+- `Ping`
+- `Socket`
+- `HttpClient`
+- 自己实现 DNS UDP/TCP 查询,或者使用可靠 DNS 库
+- JSON/YAML 规则配置
+
+#### Windows
+
+我们可以调用或封装:
+
+```powershell
+Get-NetIPConfiguration
+Get-NetRoute
+Get-NetAdapter
+Get-NetAdapterStatistics
+Get-DnsClientServerAddress
+```
+
+```cmd
+ipconfig /all
+route print
+arp -a
+netsh wlan show interfaces
+netsh wlan show networks mode=bssid
+netsh winhttp show proxy
+rasdial
+```
+
+并获取来自 WLAN AutoConfig、DHCP Client、DNS Client 和 RasClient 的日志。不过在部分情况下,我们也可以使用 P/Invoke 进行 Win32 API 的调用。
+
+#### Linux
+
+我们可以调用或封装:
+
+```bash
+ip addr
+ip route
+ip neigh
+resolvectl status
+nmcli
+iw dev wlan0 link
+journalctl -u NetworkManager
+```
+
+#### macOS
+
+我们可以调用或封装:
+
+```bash
+ifconfig
+netstat -rn
+route -n get default
+arp -an
+scutil --dns
+networksetup
+airport -I
+```
+
+## 诊断和修复功能的流程架构
+
+xidio 的所有功能必须是分级的,因为每一级别的功能都会带来不同程度的影响。
+
+### Drip
+
+Drip 模式的所有操作要求是 read-only 的。在这一层中,xidio 仅收集数据和分析,不进行任何修改系统的修复。
+
+### Drift
+
+Drift 模式的 xidio 可以提供一些基础的修复能力。这些能力是可回滚且不会造成破坏的,且即使失败也基本不会让情况变差。这包括但不限于
+
+1. 关闭失效代理。在系统代理开启、代理地址是本机或局域网地址、xidio 无法连接该代理端口、HTTP/HTTPS 访问失败、直连访问成功的情况下,xidio 支持关闭失效代理。
+2. 刷新 DNS 缓存。
+3. DHCP 重新获取地址。
+
+### Current
+
+Current 模式的 xidio 可以提供一些可回滚但可能有影响的修复,这些修复会修改系统网络配置,需要明确征求用户同意。这包括但不限于:
+
+1. 修改 DNS。包括修改为自动设置 DNS 或设置为某个指定的 DNS。
+2. 调整 metric。在有网络适配器有网络连接,但默认路由并没有经过该适配器时可用。
+3. 临时禁用明显冲突的虚拟网卡。在它们持有默认路由或 DNS 指向它们等情况下可用。
+4. 禁用 IPv6。一般地,我们并不推荐禁用 IPv6,但它在西电校园网环境中有奇效,尤其是当哔哩哔哩等网站过于卡顿时。
+
+### Surge
+
+Surge 模式的 xidio 可以进行侵入式的修复。这些修改可能造成很大的影响,因此必须要征得用户确认。这包括但不限于:
+
+1. 重置 Winsock。
+2. 重置 IP 协议栈。
+3. 进行 Windows 网络重置。
+4. 回滚、更新驱动或删除网卡设备。
+5. 修改电源管理策略。如关闭「允许计算机关闭此设备以节约电源」。
+
+上述的所有操作都必须留下日志。除 Surge 模式的操作外,其他模式的操作都必须是可回滚的。
+
+## 上报流程
+
+我们将上报分为三类。
+
+- 第一类是崩溃/异常上报,包括 xidio 的崩溃情况、某个平台 provider 的异常、Avalonia UI 的未处理异常、某个 probe 的失败、版本、OS、堆栈等。这类上报将使用 Sentry 或 GlitchTip。
+- 第二类是使用情况统计,包括 xidio 的实际使用人数、各个功能的使用比例、CLI 和 GUI 的使用占比、版本分布等。这类上报将使用 xidio 自定义的 collector。
+- 第三类是校园网情况上报。
+
+所有的上报都必须是 opt-in 的,且提供这样的选项:在每次上报之前由用户确认上报信息。
diff --git a/docs/core_development_guideline.md b/docs/core_development_guideline.md
new file mode 100644
index 0000000..a16cfd6
--- /dev/null
+++ b/docs/core_development_guideline.md
@@ -0,0 +1,28 @@
+# `xidio.Core` 开发准则
+
+我们在这里引入一些 `xidio.Core` 的开发准则。围绕 `xidio.Core` 的开发必须遵循这些准则,以保证它可供 `xidio.CLI` 和 `xidio.GUI` 依赖。
+
+## `xidio.Core` 不应依赖 `Console`、`Avalonia`、`Spectre`
+
+`xidio.Core` 应负责且只应该负责探测、诊断、生成结构化结果、执行修复动作和上报。也就是说,`xidio.Core` 里不应当出现
+
+```csharp
+Console.WriteLine(...)
+AnsiConsole.MarkupLine(...)
+MessageBox.Show(...)
+Avalonia.Threading.Dispatcher.UIThread...
+```
+
+## `xidio.Core` 应当输出结构化数据而非仅字符串
+
+`xidio.Core` 的诊断、修复和上报请求和结果都应当作为结构化数据而非字符串。
+
+## `xidio.Core` 的诊断、修复和上报逻辑必须区分
+
+这一点是显然且必须做到的。
+
+## `xidio.Core` 必须是异步的
+
+首先,`xidio.Core` 不应当直接碰 UI 对象。
+
+其次,`xidio.Core` 中所有的操作都必须是异步的。对于部分依赖系统 API 的操作,也应当使用恰当的方式封装为异步的。`xidio.Core` 需要支持进度事件,这样的事件可以帮助 CLI 和 GUI 渲染进度条。`xidio.Core` 的所有长任务都需要支持 `CancellationToken`,不能写阻塞等待。
diff --git a/xidio/xidio.CLI/ConsoleDiagnosticQuestionnaire.cs b/xidio/xidio.CLI/ConsoleDiagnosticQuestionnaire.cs
new file mode 100644
index 0000000..5d2fc6f
--- /dev/null
+++ b/xidio/xidio.CLI/ConsoleDiagnosticQuestionnaire.cs
@@ -0,0 +1,152 @@
+using Spectre.Console;
+using xidio.Core.Models;
+
+namespace xidio.CLI;
+
+internal static class ConsoleDiagnosticQuestionnaire
+{
+ public static Task AskAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var location = PromptLocation(cancellationToken);
+
+ cancellationToken.ThrowIfCancellationRequested();
+ var connectionMethod = PromptChoice(
+ "你的设备是如何连接校园网的?",
+ [
+ new("直接连接:连接到 stu-xdwlan 等校园网 Wi-Fi 节点,或使用有线直接连接墙口", ConnectionMethod.DirectCampusNetwork),
+ new("间接连接:通过连接到宿舍的路由器等方式,间接连接到校园网", ConnectionMethod.CampusNetworkViaRouter),
+ new("其他连接:使用移动数据的手机热点等,并没有直接连接到校园网", ConnectionMethod.OtherNetwork),
+ new("不确定", ConnectionMethod.Unknown)
+ ]);
+
+ cancellationToken.ThrowIfCancellationRequested();
+ var accessMethod = PromptChoice(
+ "你的设备当前使用了哪种接入方式?",
+ [
+ new("Wi-Fi 连接(连接到校园网 Wi-Fi 节点或路由器等)", NetworkAccessMethod.Wireless),
+ new("有线连接(未拨号,直接使用网线连接到路由器等)", NetworkAccessMethod.Ethernet),
+ new("PPPoE / 宽带拨号(连接到了墙口的网口)", NetworkAccessMethod.Pppoe),
+ new("不确定", NetworkAccessMethod.Unknown)
+ ]);
+
+ cancellationToken.ThrowIfCancellationRequested();
+ var problemSymptom = PromptChoice(
+ "你现在遇到的主要问题是什么?",
+ [
+ new("没有显著问题,仅进行一次诊断", ProblemSymptom.NoProblem),
+ new("无法连接 Wi-Fi", ProblemSymptom.CannotConnectWifi),
+ new("Wi-Fi 连接成功,但提示无 Internet", ProblemSymptom.ConnectedNoInternet),
+ new("未弹出校园网认证页", ProblemSymptom.CaptivePortalNotShown),
+ new("已认证,但仍无法上网", ProblemSymptom.PortalAuthenticatedNoWeb),
+ new("可上网,但部分应用可上网,部分应用无法上网", ProblemSymptom.SomeApplicationsUnavailable),
+ new("拨号失败", ProblemSymptom.PppoeDialFailed),
+ new("拨号成功,但无法上网", ProblemSymptom.PppoeConnectedNoInternet),
+ new("其他问题", ProblemSymptom.Other),
+ new("不确定", ProblemSymptom.Unknown)
+ ]);
+
+ cancellationToken.ThrowIfCancellationRequested();
+ var impactScope = problemSymptom == ProblemSymptom.NoProblem
+ ? ImpactScope.Unknown
+ : PromptChoice(
+ "是否有其他设备或用户也有此问题?",
+ [
+ new("只有这台设备有该问题", ImpactScope.OnlyThisDevice),
+ new("同宿舍 / 同房间也有人遇到", ImpactScope.SameRoomOrDormitory),
+ new("同楼层 / 附近区域也有人遇到", ImpactScope.SameFloorOrArea),
+ new("更大范围都有人遇到", ImpactScope.WiderArea),
+ new("不清楚", ImpactScope.Unknown)
+ ]);
+
+ return Task.FromResult(new UserScenarioInfo
+ {
+ Location = location,
+ ConnectionMethod = connectionMethod,
+ AccessMethod = accessMethod,
+ ProblemSymptom = problemSymptom,
+ ImpactScope = impactScope
+ });
+ }
+
+ private static string PromptLocation(CancellationToken cancellationToken)
+ {
+ var location = AnsiConsole.Prompt(new SelectionPrompt()
+ .Title("你的设备目前位于哪里?")
+ .PageSize(12)
+ .AddChoices(
+ "海棠公寓",
+ "竹园公寓",
+ "丁香公寓",
+ "图书馆",
+ "网安大楼",
+ "家属区",
+ "综合楼",
+ "北校区",
+ "其他"));
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return location switch
+ {
+ "海棠公寓" or "竹园公寓" or "丁香公寓" => PromptDormitoryBuilding(location),
+ "图书馆" => PromptLibraryArea(),
+ "网安大楼" => PromptNetworkSecurityBuildingArea(),
+ "综合楼" => PromptComprehensiveBuilding(),
+ _ => location
+ };
+ }
+
+ private static string PromptDormitoryBuilding(string dormitory)
+ {
+ var buildingNumber = AnsiConsole.Prompt(new TextPrompt($"请输入所在{dormitory}的楼栋数(一个正整数):")
+ .Validate(number => number > 0
+ ? ValidationResult.Success()
+ : ValidationResult.Error("请输入一个正整数")));
+
+ return $"{dormitory} {buildingNumber} 号楼";
+ }
+
+ private static string PromptLibraryArea()
+ {
+ var area = AnsiConsole.Prompt(new SelectionPrompt()
+ .Title("你现在位于图书馆哪个区域?")
+ .AddChoices("A", "B", "C", "D"));
+
+ return $"图书馆 {area} 区";
+ }
+
+ private static string PromptNetworkSecurityBuildingArea()
+ {
+ var area = AnsiConsole.Prompt(new SelectionPrompt()
+ .Title("你现在位于网安大楼哪个学院区域?")
+ .PageSize(8)
+ .AddChoices(
+ "人工智能学院",
+ "网络安全学院",
+ "集成电路学院",
+ "计算机科学学院",
+ "不确定"));
+
+ return area == "不确定" ? "网安大楼" : $"网安大楼 {area}";
+ }
+
+ private static string PromptComprehensiveBuilding()
+ {
+ return AnsiConsole.Prompt(new SelectionPrompt()
+ .Title("你现在位于哪个综合楼?")
+ .AddChoices("旧综合楼", "新综合楼"));
+ }
+
+ private static T PromptChoice(string title, IReadOnlyList> choices)
+ {
+ var selectedLabel = AnsiConsole.Prompt(new SelectionPrompt()
+ .Title(title)
+ .PageSize(10)
+ .AddChoices(choices.Select(choice => choice.Label)));
+
+ return choices.First(choice => choice.Label == selectedLabel).Value;
+ }
+
+ private sealed record PromptOption(string Label, T Value);
+}
diff --git a/xidio/xidio.CLI/ConsoleDiagnosticReporter.cs b/xidio/xidio.CLI/ConsoleDiagnosticReporter.cs
index db29d77..78a403f 100644
--- a/xidio/xidio.CLI/ConsoleDiagnosticReporter.cs
+++ b/xidio/xidio.CLI/ConsoleDiagnosticReporter.cs
@@ -1,4 +1,5 @@
using System.Net;
+using System.Net.NetworkInformation;
using System.Net.Sockets;
using Spectre.Console;
using xidio.Core.Models;
@@ -9,119 +10,448 @@ internal static class ConsoleDiagnosticReporter
{
public static void Print(NetworkDiagnosticReport report)
{
- PrintBanner();
- AnsiConsole.WriteLine(PromptLocation());
- PrintTimestamp(report.CollectedAt);
+ AnsiConsole.WriteLine();
+ PrintReportSummary(report);
+ PrintSystemInfo(report.OperatingSystem);
+ PrintClockInfo(report.Clock);
+ PrintUserScenario(report.UserScenario);
+ PrintDefaultRoutes(report.DefaultRoutes);
PrintNetworkInterfaces(report);
- PrintHostname(report.HostName);
- PrintProxyStatus(report.SystemProxy);
- Console.ReadKey();
+ PrintPppoeInfo(report.Pppoe);
+ PrintProbeResults(report.ProbeResults);
}
- private static string PromptLocation()
+ public static void PrintBanner()
{
- return AnsiConsole.Prompt(new SelectionPrompt()
- .Title("你的设备目前位于哪里?通常情况下,同一设备在不同位置的表现也会有差异。")
- .AddChoiceGroup("海棠公寓")
- .AddChoiceGroup("竹园公寓")
- .AddChoiceGroup("丁香公寓")
- .AddChoiceGroup("图书馆", "A", "B", "C", "D")
- .AddChoiceGroup("网安大楼", "人工智能", "网络安全", "集成电路", "计算机科学")
- .AddChoiceGroup("家属区")
- .AddChoiceGroup("综合楼", "旧综合楼", "新综合楼")
- .AddChoiceGroup("其他", "公路或街道", "北校区", "其他")
- );
+ AnsiConsole.MarkupLine("[purple]Welcome to xidio.CLI v0.1.0, Xidian Internet Diagnostic Intelligence Operator.[/]");
+ AnsiConsole.MarkupLine("[purple]This project is developed by Project Hazelita, and uses the MIT license.[/]");
+ AnsiConsole.WriteLine();
}
- private static void PrintBanner()
+ private static void PrintReportSummary(NetworkDiagnosticReport report)
{
- var appName = new FigletText("XIDIO")
+ PrintSection("报告概要");
+
+ var summaryTable = CreateKeyValueTable();
+ summaryTable.AddRow("报告生成时间", Escape($"{report.CollectedAt:yyyy-MM-dd HH:mm:ss}"));
+ summaryTable.AddRow("报告生成 Unix 时间戳", report.CollectedAt.ToUnixTimeSeconds().ToString());
+ summaryTable.AddRow("主机名", Escape(report.HostName));
+ summaryTable.AddRow("系统代理", FormatProxyStatus(report.SystemProxy));
+ summaryTable.AddRow("网络接口总数", report.TotalNetworkInterfaceCount.ToString());
+ summaryTable.AddRow("主要网络接口个数", report.PrimaryInterfaces.Count.ToString());
+ summaryTable.AddRow("物理适配器个数", report.NetworkAdapterDriverInfos.Count.ToString());
+
+ AnsiConsole.Write(summaryTable);
+ }
+
+ private static void PrintSystemInfo(OperatingSystemDiagnosticInfo operatingSystem)
+ {
+ PrintSection("系统信息");
+
+ var systemTable = CreateKeyValueTable();
+ systemTable.AddRow("操作系统 / OS", Escape(operatingSystem.Family));
+ systemTable.AddRow("描述", Escape(operatingSystem.Description));
+ systemTable.AddRow("版本", Escape(operatingSystem.Version));
+ systemTable.AddRow("架构", Escape(operatingSystem.Architecture));
+ systemTable.AddRow("xidio 版本", Escape(operatingSystem.XidioVersion));
+ systemTable.AddRow("是否提升权限", FormatNullableBool(operatingSystem.IsElevated));
+
+ AnsiConsole.Write(systemTable);
+ }
+
+ private static void PrintClockInfo(ClockDiagnosticInfo clock)
+ {
+ PrintSection("时间校验");
+
+ var clockTable = CreateKeyValueTable();
+ clockTable.AddRow("本机时间", Escape($"{clock.LocalTime:yyyy-MM-dd HH:mm:ss zzz}"));
+ clockTable.AddRow("NTP 服务器", Escape(clock.NtpServer));
+ clockTable.AddRow("NTP 时间", clock.NtpTime is null
+ ? "[grey][/]"
+ : Escape($"{clock.NtpTime:yyyy-MM-dd HH:mm:ss zzz}"));
+ clockTable.AddRow("时间偏差", clock.Offset is null
+ ? "[grey][/]"
+ : Escape(FormatTimeSpan(clock.Offset.Value)));
+ clockTable.AddRow("错误", string.IsNullOrWhiteSpace(clock.Error)
+ ? "[grey][/]"
+ : Escape(clock.Error));
+
+ AnsiConsole.Write(clockTable);
+ }
+
+ private static void PrintUserScenario(UserScenarioInfo scenario)
+ {
+ PrintSection("用户场景");
+
+ var scenarioTable = CreateKeyValueTable();
+ scenarioTable.AddRow("位置", Escape(scenario.Location));
+ scenarioTable.AddRow("连接方式", Escape(FormatConnectionMethod(scenario.ConnectionMethod)));
+ scenarioTable.AddRow("接入方式", Escape(FormatNetworkAccessMethod(scenario.AccessMethod)));
+ scenarioTable.AddRow("主要问题", Escape(FormatProblemSymptom(scenario.ProblemSymptom)));
+ scenarioTable.AddRow("影响范围", Escape(FormatImpactScope(scenario.ImpactScope, scenario.ProblemSymptom)));
+
+ AnsiConsole.Write(scenarioTable);
+ }
+
+ private static void PrintDefaultRoutes(IReadOnlyList defaultRoutes)
+ {
+ PrintSection("默认路由");
+
+ if (defaultRoutes.Count == 0)
{
- Color = Color.Purple3,
- Justification = Justify.Center
- };
-
- var version = new Text("v0.1.0", new Style(Color.Purple3))
+ AnsiConsole.MarkupLine("[grey]未获取到默认路由信息。[/]");
+ return;
+ }
+
+ var routeTable = new Table()
+ .RoundedBorder()
+ .BorderColor(Color.Grey)
+ .Expand();
+
+ routeTable.AddColumn("协议");
+ routeTable.AddColumn("下一跳");
+ routeTable.AddColumn("接口");
+ routeTable.AddColumn("Metric");
+
+ foreach (var route in defaultRoutes)
{
- Justification = Justify.Center
- };
-
- AnsiConsole.Write(appName);
- AnsiConsole.Write(version);
- Console.WriteLine();
- AnsiConsole.Write(new Text("Xidian Internet Diagnostic Intelligence Operator / 西电校园网诊断工具"){ Justification = Justify.Center });
- AnsiConsole.Write(new Text("由 Project Hazelita 开发 / 以 MIT License 开源"){ Justification = Justify.Center });
- Console.WriteLine("\n\n");
+ routeTable.AddRow(
+ Escape(FormatAddressFamily(route.AddressFamily)),
+ Escape(FormatNextHop(route.NextHop)),
+ Escape(string.IsNullOrWhiteSpace(route.InterfaceAlias)
+ ? $"ifIndex {route.InterfaceIndex}"
+ : $"{route.InterfaceAlias} (ifIndex {route.InterfaceIndex})"),
+ Escape($"route {route.RouteMetric}, interface {route.InterfaceMetric}, total {route.TotalMetric}"));
+ }
+
+ AnsiConsole.Write(routeTable);
}
- private static void PrintTimestamp(DateTimeOffset collectedAt)
+ private static void PrintNetworkInterfaces(NetworkDiagnosticReport report)
+ {
+ PrintSection("物理网络适配器");
+ PrintAdapterDriverInfos(report.NetworkAdapterDriverInfos);
+
+ PrintSection("主要网络连接");
+ PrintPrimaryInterfaces(report.PrimaryInterfaces);
+ }
+
+ private static void PrintPppoeInfo(PppoeDiagnosticInfo pppoe)
{
+ PrintSection("PPPoE 概要");
+
+ var pppoeTable = CreateKeyValueTable();
+ pppoeTable.AddRow("检测到 PPPoE 接口", FormatBool(pppoe.HasPppoeInterface));
+ pppoeTable.AddRow("PPPoE 接口名称", FormatStringList(pppoe.InterfaceNames));
+ pppoeTable.AddRow("PPPoE 默认路由", FormatBool(pppoe.HasDefaultRoute));
+
+ AnsiConsole.Write(pppoeTable);
+ }
+
+ private static void PrintProbeResults(IReadOnlyList probeResults)
+ {
+ PrintSection("主动探测");
+
+ if (probeResults.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[grey]未执行主动探测。[/]");
+ return;
+ }
+
+ foreach (var result in probeResults)
+ PrintProbeResult(result);
+ }
+
+ private static void PrintProbeResult(TargetProbeResult result)
+ {
+ AnsiConsole.WriteLine();
AnsiConsole.MarkupLine(
- $"[green]该诊断报告生成于 {collectedAt:yyyy-MM-dd HH:mm:ss},Unix 时间戳 {collectedAt.ToUnixTimeSeconds()}。[/]");
- Console.WriteLine();
+ $"[bold]{Escape(result.Target.Name)}[/] [grey]{Escape(result.Target.Host)}[/]");
+
+ var probeTable = new Table()
+ .SimpleBorder()
+ .BorderColor(Color.Grey)
+ .Expand();
+
+ probeTable.AddColumn("项目");
+ probeTable.AddColumn("结果");
+ probeTable.AddColumn("详情");
+
+ foreach (var dnsResult in result.SystemDnsResults)
+ probeTable.AddRow($"系统 DNS {dnsResult.QueryType}", FormatSuccess(dnsResult.Succeeded), FormatDnsResult(dnsResult));
+
+ foreach (var dnsResult in result.DirectDnsResults)
+ probeTable.AddRow($"直连 DNS {dnsResult.QueryType}", FormatSuccess(dnsResult.Succeeded), FormatDnsResult(dnsResult));
+
+ probeTable.AddRow("ICMP Ping", FormatSuccess(result.Ping.Succeeded), FormatPingResult(result.Ping));
+
+ foreach (var tcpResult in result.TcpConnectResults)
+ probeTable.AddRow($"TCP {tcpResult.Port}", FormatSuccess(tcpResult.Succeeded), FormatTcpResult(tcpResult));
+
+ if (result.Http is not null)
+ probeTable.AddRow("HTTP GET", FormatSuccess(result.Http.Succeeded), FormatHttpResult(result.Http));
+
+ if (result.Https is not null)
+ probeTable.AddRow("HTTPS GET", FormatSuccess(result.Https.Succeeded), FormatHttpResult(result.Https));
+
+ AnsiConsole.Write(probeTable);
}
- private static void PrintNetworkInterfaces(NetworkDiagnosticReport report)
+ private static void PrintAdapterDriverInfos(IReadOnlyList adapters)
{
- Console.WriteLine($"检测到了 {report.NetworkAdapterDriverInfos.Count} 个物理网络适配器。\n");
+ if (adapters.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[grey]未获取到物理网络适配器驱动信息。[/]");
+ return;
+ }
+
+ var adapterTable = new Table()
+ .RoundedBorder()
+ .BorderColor(Color.Grey)
+ .Expand();
+
+ adapterTable.AddColumn(new TableColumn("名称"));
+ adapterTable.AddColumn(new TableColumn("描述"));
+ adapterTable.AddColumn(new TableColumn("驱动版本"));
- var adapterTable = new Table().RoundedBorder().BorderColor(Color.Grey).Title("物理网络适配器");
-
- adapterTable.AddColumn(new TableColumn("适配器类型").Centered());
- adapterTable.AddColumn(new TableColumn("适配器名称").Centered());
- adapterTable.AddColumn(new TableColumn("驱动版本").Centered());
-
- foreach (var adapter in report.NetworkAdapterDriverInfos)
+ foreach (var adapter in adapters)
{
- adapterTable.AddRow(adapter.Name, adapter.Description, adapter.DriverVersion);
+ adapterTable.AddRow(
+ Escape(adapter.Name),
+ Escape(adapter.Description),
+ Escape(adapter.DriverVersion));
}
-
+
AnsiConsole.Write(adapterTable);
+ }
+
+ private static void PrintPrimaryInterfaces(IReadOnlyList primaryInterfaces)
+ {
+ if (primaryInterfaces.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[yellow]未识别到主要网络接口。[/]");
+ return;
+ }
- Console.WriteLine();
- Console.WriteLine(
- $"你的设备上共有 {report.TotalNetworkInterfaceCount} 个网络接口,其中 {report.PrimaryInterfaces.Count} 个是主要网络接口。\n");
-
- var primaryInterfacesTable = new Table().RoundedBorder().BorderColor(Color.Grey).Title("主要网络连接");
+ var overviewTable = new Table()
+ .RoundedBorder()
+ .BorderColor(Color.Grey)
+ .Expand();
- primaryInterfacesTable.AddColumn(new TableColumn("网络类型").Centered());
- primaryInterfacesTable.AddColumn(new TableColumn("描述").Centered());
- primaryInterfacesTable.AddColumn(new TableColumn("状态").Centered());
+ overviewTable.AddColumn(new TableColumn("类型"));
+ overviewTable.AddColumn(new TableColumn("名称"));
+ overviewTable.AddColumn(new TableColumn("启用"));
+ overviewTable.AddColumn(new TableColumn("连接"));
+ overviewTable.AddColumn(new TableColumn("状态"));
+ overviewTable.AddColumn(new TableColumn("链路速度"));
- foreach (var primaryInterface in report.PrimaryInterfaces)
+ foreach (var primaryInterface in primaryInterfaces)
{
- primaryInterfacesTable.AddRow(primaryInterface.Kind.ToString(), primaryInterface.Description, primaryInterface.OperationalStatus.ToString());
-
- if (primaryInterface.WirelessConnection is not null)
- {
- Console.WriteLine(
- $" SSID: {primaryInterface.WirelessConnection.Ssid} | BSSID: {primaryInterface.WirelessConnection.Bssid} | AP MAC: {primaryInterface.WirelessConnection.ApMac}");
- }
- else if (primaryInterface.Kind == PrimaryInterfaceKind.Wireless &&
- primaryInterface.NetworkDetails is not null)
- {
- Console.WriteLine(" Wireless connection details are unavailable.");
- }
+ overviewTable.AddRow(
+ Escape(FormatPrimaryInterfaceKind(primaryInterface.Kind)),
+ Escape(primaryInterface.Name),
+ FormatNullableBool(primaryInterface.IsEnabled),
+ FormatBool(primaryInterface.IsConnected),
+ FormatOperationalStatus(primaryInterface.OperationalStatus),
+ Escape(FormatLinkSpeed(primaryInterface.LinkSpeedBitsPerSecond)));
+ }
+
+ AnsiConsole.Write(overviewTable);
+
+ foreach (var primaryInterface in primaryInterfaces)
+ PrintPrimaryInterfaceDetails(primaryInterface);
+ }
+
+ private static void PrintPrimaryInterfaceDetails(PrimaryInterfaceInfo primaryInterface)
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine(
+ $"[bold]{Escape(FormatPrimaryInterfaceKind(primaryInterface.Kind))}[/] [grey]{Escape(primaryInterface.Name)}[/]");
+
+ var detailsTable = CreateKeyValueTable();
+ detailsTable.AddRow("接口 ID", Escape(primaryInterface.Id));
+ detailsTable.AddRow("描述", Escape(primaryInterface.Description));
+ detailsTable.AddRow("启用", FormatNullableBool(primaryInterface.IsEnabled));
+ detailsTable.AddRow("已连接", FormatBool(primaryInterface.IsConnected));
+ detailsTable.AddRow("状态", FormatOperationalStatus(primaryInterface.OperationalStatus));
+ detailsTable.AddRow("接口类型", Escape(primaryInterface.NetworkInterfaceType.ToString()));
+ detailsTable.AddRow("MAC 地址", Escape(primaryInterface.MacAddress ?? ""));
+ detailsTable.AddRow("链路速度", Escape(FormatLinkSpeed(primaryInterface.LinkSpeedBitsPerSecond)));
+ detailsTable.AddRow("MTU", Escape(primaryInterface.Mtu?.ToString() ?? ""));
+
+ if (primaryInterface.WirelessConnection is not null)
+ {
+ detailsTable.AddRow("SSID", Escape(primaryInterface.WirelessConnection.Ssid));
+ detailsTable.AddRow("BSSID", Escape(primaryInterface.WirelessConnection.Bssid));
+ detailsTable.AddRow("RSSI", Escape(primaryInterface.WirelessConnection.RssiDbm is null
+ ? ""
+ : $"{primaryInterface.WirelessConnection.RssiDbm} dBm"));
+ detailsTable.AddRow("信号质量", Escape(primaryInterface.WirelessConnection.SignalQualityPercent is null
+ ? ""
+ : $"{primaryInterface.WirelessConnection.SignalQualityPercent}%"));
+ detailsTable.AddRow("频段/信道", Escape(FormatWirelessChannel(primaryInterface.WirelessConnection)));
+ detailsTable.AddRow("PHY", Escape(primaryInterface.WirelessConnection.PhyType ?? ""));
+ detailsTable.AddRow("认证/加密", Escape(FormatWirelessSecurity(primaryInterface.WirelessConnection)));
+ detailsTable.AddRow("当前速率", Escape(FormatWirelessRates(primaryInterface.WirelessConnection)));
+ }
+ else if (primaryInterface is { Kind: PrimaryInterfaceKind.Wireless, NetworkDetails: not null })
+ {
+ detailsTable.AddRow("无线连接", "[yellow]未获取到 SSID/BSSID[/]");
+ }
+
+ if (primaryInterface.NetworkDetails is null)
+ {
+ detailsTable.AddRow("网络配置", "[grey][/]");
+ AnsiConsole.Write(detailsTable);
+ return;
+ }
+
+ var details = primaryInterface.NetworkDetails;
+ detailsTable.AddRow("IPv4 Address(es)", FormatIpv4UnicastAddresses(details.IPv4Addresses));
+ detailsTable.AddRow("IPv6 Address(es)", FormatIpv6UnicastAddresses(details.IPv6Addresses));
+ detailsTable.AddRow("Default Gateway(s)", FormatIpAddresses(details.DefaultGateways));
+ detailsTable.AddRow("DHCP Enabled", FormatNullableBool(details.DhcpInfo?.IsEnabled));
+ detailsTable.AddRow("DHCP Lease", Escape(FormatDhcpLease(details.DhcpInfo)));
+ detailsTable.AddRow("DHCP Server(s)", FormatIpAddresses(details.DhcpServers));
+ detailsTable.AddRow("DNS Server(s)", FormatIpAddresses(details.DnsServers));
+ detailsTable.AddRow("Interface Metric", FormatInterfaceMetrics(details.InterfaceMetrics, details.Routes));
+
+ AnsiConsole.Write(detailsTable);
+ PrintRouteSummary(details.Routes);
+ PrintNeighborSummary(details.Neighbors);
+
+ if (primaryInterface.WirelessConnection is not null)
+ PrintVisibleWirelessNetworks(primaryInterface.WirelessConnection.VisibleNetworks);
+ }
+
+ private static void PrintRouteSummary(IReadOnlyList routes)
+ {
+ var routeTable = new Table()
+ .SimpleBorder()
+ .BorderColor(Color.Grey)
+ .Expand();
+
+ routeTable.AddColumn(new TableColumn("协议"));
+ routeTable.AddColumn(new TableColumn("路由数"));
+ routeTable.AddColumn(new TableColumn("默认路由"));
+ routeTable.AddColumn(new TableColumn("样例路由"));
+
+ AddRouteFamilySummary(routeTable, "IPv4", routes, (int)AddressFamily.InterNetwork);
+ AddRouteFamilySummary(routeTable, "IPv6", routes, (int)AddressFamily.InterNetworkV6);
+
+ AnsiConsole.Write(routeTable);
+ }
+
+ private static void PrintNeighborSummary(IReadOnlyList neighbors)
+ {
+ var relevantNeighbors = neighbors
+ .Take(8)
+ .ToList();
+
+ if (relevantNeighbors.Count == 0)
+ return;
+
+ var neighborTable = new Table()
+ .SimpleBorder()
+ .BorderColor(Color.Grey)
+ .Expand();
+
+ neighborTable.AddColumn("协议");
+ neighborTable.AddColumn("IP");
+ neighborTable.AddColumn("链路层地址");
+ neighborTable.AddColumn("状态");
+
+ foreach (var neighbor in relevantNeighbors)
+ {
+ neighborTable.AddRow(
+ Escape(FormatAddressFamily(neighbor.AddressFamily)),
+ Escape(neighbor.IpAddress),
+ Escape(string.IsNullOrWhiteSpace(neighbor.LinkLayerAddress) ? "" : neighbor.LinkLayerAddress),
+ Escape(neighbor.State));
+ }
+
+ AnsiConsole.Write(neighborTable);
+ }
- if (primaryInterface.NetworkDetails is not null)
- PrintPrimaryInterfaceNetworkDetails(primaryInterface.NetworkDetails);
+ private static void PrintVisibleWirelessNetworks(IReadOnlyList visibleNetworks)
+ {
+ if (visibleNetworks.Count == 0)
+ return;
+
+ var wirelessTable = new Table()
+ .SimpleBorder()
+ .BorderColor(Color.Grey)
+ .Expand();
+
+ wirelessTable.AddColumn("可见 SSID");
+ wirelessTable.AddColumn("信号");
+ wirelessTable.AddColumn("认证/加密");
+ wirelessTable.AddColumn("BSSID 数");
+
+ foreach (var network in visibleNetworks.Take(12))
+ {
+ wirelessTable.AddRow(
+ Escape(network.Ssid),
+ Escape(network.SignalQualityPercent is null ? "" : $"{network.SignalQualityPercent}%"),
+ Escape($"{network.Authentication ?? ""} / {network.Cipher ?? ""}"),
+ Escape(network.BssidCount?.ToString() ?? ""));
}
-
- AnsiConsole.Write(primaryInterfacesTable);
+
+ AnsiConsole.Write(wirelessTable);
}
- private static void PrintPrimaryInterfaceNetworkDetails(InterfaceNetworkDetails details)
+ private static void AddRouteFamilySummary(
+ Table routeTable,
+ string label,
+ IReadOnlyList routes,
+ int addressFamily)
{
- Console.WriteLine($" IPv4 Address(es): {FormatIpv4UnicastAddresses(details.IPv4Addresses)}");
- Console.WriteLine($" IPv6 Address(es): {FormatIpv6UnicastAddresses(details.IPv6Addresses)}");
- Console.WriteLine($" Default Gateway(s): {FormatIpAddresses(details.DefaultGateways)}");
- Console.WriteLine($" DHCP Server(s): {FormatIpAddresses(details.DhcpServers)}");
- Console.WriteLine($" DNS Server(s): {FormatIpAddresses(details.DnsServers)}");
- Console.WriteLine(
- $" Interface Metric: {FormatInterfaceMetrics(details.InterfaceMetrics, details.Routes)}");
- Console.WriteLine(" Route Table Summary:");
- PrintRouteFamilySummary("IPv4", details.Routes, (int)AddressFamily.InterNetwork);
- PrintRouteFamilySummary("IPv6", details.Routes, (int)AddressFamily.InterNetworkV6);
+ var familyRoutes = routes
+ .Where(route => route.AddressFamily == addressFamily)
+ .OrderBy(route => route.TotalMetric)
+ .ThenBy(route => route.RouteMetric)
+ .ThenBy(route => route.DestinationPrefix)
+ .ToList();
+
+ if (familyRoutes.Count == 0)
+ {
+ routeTable.AddRow(label, "0", "[grey][/]", "[grey][/]");
+ return;
+ }
+
+ var defaultRoutes = familyRoutes
+ .Where(IsDefaultRoute)
+ .Take(3)
+ .Select(FormatRoute);
+ var sampleRoutes = familyRoutes
+ .Where(route => !IsDefaultRoute(route))
+ .Take(3)
+ .Select(FormatRoute);
+
+ routeTable.AddRow(
+ label,
+ familyRoutes.Count.ToString(),
+ FormatStringList(defaultRoutes),
+ FormatStringList(sampleRoutes));
+ }
+
+ private static Table CreateKeyValueTable()
+ {
+ var table = new Table()
+ .RoundedBorder()
+ .BorderColor(Color.Grey)
+ .Expand();
+
+ table.AddColumn(new TableColumn("字段"));
+ table.AddColumn(new TableColumn("值"));
+
+ return table;
+ }
+
+ private static void PrintSection(string title)
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine($"[bold purple]{Escape(title)}[/]");
}
private static string FormatIpv4UnicastAddresses(IReadOnlyList addresses)
@@ -135,29 +465,30 @@ private static string FormatIpv4UnicastAddresses(IReadOnlyList" : string.Join(", ", formatted);
+ return FormatStringList(formatted);
}
private static string FormatIpv6UnicastAddresses(IReadOnlyList addresses)
{
- var formatted = addresses
- .Select(addressInfo => $"{addressInfo.Address}/{addressInfo.PrefixLength}")
- .ToList();
-
- return formatted.Count == 0 ? "" : string.Join(", ", formatted);
+ return FormatStringList(addresses.Select(addressInfo => $"{addressInfo.Address}/{addressInfo.PrefixLength}"));
}
private static string FormatIpAddresses(IEnumerable addresses)
{
- var formatted = addresses
- .Select(address => address.ToString())
- .Where(address => !string.IsNullOrWhiteSpace(address))
+ return FormatStringList(addresses.Select(address => address.ToString()));
+ }
+
+ private static string FormatStringList(IEnumerable values)
+ {
+ var formatted = values
+ .Where(value => !string.IsNullOrWhiteSpace(value))
.ToList();
- return formatted.Count == 0 ? "" : string.Join(", ", formatted);
+ return formatted.Count == 0
+ ? "[grey][/]"
+ : Escape(string.Join(Environment.NewLine, formatted));
}
private static string FormatInterfaceMetrics(
@@ -181,46 +512,9 @@ private static string FormatInterfaceMetrics(
.ToList();
}
- return formattedMetrics.Count == 0 ? "" : string.Join(", ", formattedMetrics);
- }
-
- private static void PrintRouteFamilySummary(
- string label,
- IReadOnlyList routes,
- int addressFamily)
- {
- var familyRoutes = routes
- .Where(route => route.AddressFamily == addressFamily)
- .OrderBy(route => route.TotalMetric)
- .ThenBy(route => route.RouteMetric)
- .ThenBy(route => route.DestinationPrefix)
- .ToList();
-
- if (familyRoutes.Count == 0)
- {
- Console.WriteLine($" {label}: ");
- return;
- }
-
- var defaultRoutes = familyRoutes
- .Where(IsDefaultRoute)
- .Take(3)
- .Select(FormatRoute)
- .ToList();
- var sampleRoutes = familyRoutes
- .Where(route => !IsDefaultRoute(route))
- .Take(3)
- .Select(FormatRoute)
- .ToList();
- var defaultRouteText = defaultRoutes.Count == 0
- ? ""
- : string.Join("; ", defaultRoutes);
- var sampleRouteText = sampleRoutes.Count == 0
- ? ""
- : string.Join("; ", sampleRoutes);
-
- Console.WriteLine(
- $" {label}: {familyRoutes.Count} route(s); default: {defaultRouteText}; sample: {sampleRouteText}");
+ return formattedMetrics.Count == 0
+ ? "[grey][/]"
+ : Escape(string.Join(Environment.NewLine, formattedMetrics));
}
private static bool IsDefaultRoute(InterfaceRouteInfo route)
@@ -248,16 +542,207 @@ private static string FormatAddressFamily(int addressFamily)
: $"AF{addressFamily}";
}
- private static void PrintHostname(string hostName)
+ private static string FormatProxyStatus(SystemProxyInfo systemProxy)
+ {
+ return systemProxy.IsEnabled
+ ? $"[yellow]on[/] {Escape(systemProxy.ProxyUri?.ToString() ?? "")}"
+ : "[green]off[/]";
+ }
+
+ private static string FormatBool(bool value)
+ {
+ return value ? "[green]是[/]" : "[yellow]否[/]";
+ }
+
+ private static string FormatNullableBool(bool? value)
+ {
+ return value is null ? "[grey][/]" : FormatBool(value.Value);
+ }
+
+ private static string FormatSuccess(bool value)
+ {
+ return value ? "[green]OK[/]" : "[yellow]FAIL[/]";
+ }
+
+ private static string FormatLinkSpeed(long? bitsPerSecond)
+ {
+ if (bitsPerSecond is null or <= 0)
+ return "";
+
+ var mbps = bitsPerSecond.Value / 1_000_000d;
+ return mbps >= 1000
+ ? $"{mbps / 1000d:0.##} Gbps"
+ : $"{mbps:0.##} Mbps";
+ }
+
+ private static string FormatTimeSpan(TimeSpan timeSpan)
+ {
+ return timeSpan.TotalMilliseconds switch
+ {
+ >= 1000 or <= -1000 => $"{timeSpan.TotalSeconds:0.###} s",
+ _ => $"{timeSpan.TotalMilliseconds:0.###} ms"
+ };
+ }
+
+ private static string FormatDuration(TimeSpan? duration)
+ {
+ return duration is null ? "" : FormatTimeSpan(duration.Value);
+ }
+
+ private static string FormatNextHop(string nextHop)
+ {
+ return string.IsNullOrWhiteSpace(nextHop) || nextHop is "0.0.0.0" or "::"
+ ? "on-link"
+ : nextHop;
+ }
+
+ private static string FormatDhcpLease(InterfaceDhcpInfo? dhcpInfo)
+ {
+ if (dhcpInfo is null)
+ return "";
+
+ var obtained = dhcpInfo.LeaseObtained is null
+ ? ""
+ : $"{dhcpInfo.LeaseObtained:yyyy-MM-dd HH:mm:ss}";
+ var expires = dhcpInfo.LeaseExpires is null
+ ? ""
+ : $"{dhcpInfo.LeaseExpires:yyyy-MM-dd HH:mm:ss}";
+
+ return $"{obtained} -> {expires}";
+ }
+
+ private static string FormatWirelessChannel(WirelessConnectionInfo wireless)
+ {
+ var frequency = wireless.FrequencyGhz is null ? "" : $"{wireless.FrequencyGhz:0.###} GHz";
+ var channel = wireless.Channel is null ? "" : wireless.Channel.ToString();
+
+ return $"{frequency}, channel {channel}";
+ }
+
+ private static string FormatWirelessSecurity(WirelessConnectionInfo wireless)
+ {
+ return $"{wireless.Authentication ?? ""} / {wireless.Cipher ?? ""}";
+ }
+
+ private static string FormatWirelessRates(WirelessConnectionInfo wireless)
+ {
+ var rx = wireless.ReceiveRateMbps is null ? "" : $"{wireless.ReceiveRateMbps:0.##} Mbps";
+ var tx = wireless.TransmitRateMbps is null ? "" : $"{wireless.TransmitRateMbps:0.##} Mbps";
+
+ return $"Rx {rx}, Tx {tx}";
+ }
+
+ private static string FormatDnsResult(DnsProbeResult result)
+ {
+ var source = string.IsNullOrWhiteSpace(result.DnsServer) ? "system" : result.DnsServer;
+ var addresses = result.Addresses.Count == 0
+ ? ""
+ : string.Join(", ", result.Addresses.Select(address => address.ToString()));
+ var suffix = string.IsNullOrWhiteSpace(result.Error) ? "" : $"; {result.Error}";
+
+ return Escape($"{source}; {FormatDuration(result.Duration)}; {addresses}{suffix}");
+ }
+
+ private static string FormatPingResult(PingProbeResult result)
+ {
+ var roundtrip = result.RoundtripTimeMilliseconds is null ? "" : $"{result.RoundtripTimeMilliseconds} ms";
+ var status = result.Status ?? result.Error ?? "";
+
+ return Escape($"{status}; {roundtrip}");
+ }
+
+ private static string FormatTcpResult(TcpConnectProbeResult result)
+ {
+ var suffix = string.IsNullOrWhiteSpace(result.Error) ? "" : $"; {result.Error}";
+ return Escape($"{FormatDuration(result.Duration)}{suffix}");
+ }
+
+ private static string FormatHttpResult(HttpProbeResult result)
+ {
+ var status = result.StatusCode is null ? "" : result.StatusCode.ToString();
+ var suffix = string.IsNullOrWhiteSpace(result.Error) ? "" : $"; {result.Error}";
+
+ return Escape($"{result.Uri}; status {status}; {FormatDuration(result.Duration)}{suffix}");
+ }
+
+ private static string FormatOperationalStatus(OperationalStatus status)
{
- Console.WriteLine($"Hostname: {hostName}");
+ return status switch
+ {
+ OperationalStatus.Up => "[green]Up[/]",
+ OperationalStatus.Down => "[yellow]Down[/]",
+ OperationalStatus.NotPresent => "[grey]NotPresent[/]",
+ _ => Escape(status.ToString())
+ };
+ }
+
+ private static string FormatPrimaryInterfaceKind(PrimaryInterfaceKind kind)
+ {
+ return kind switch
+ {
+ PrimaryInterfaceKind.Ethernet => "Ethernet",
+ PrimaryInterfaceKind.Wireless => "Wireless",
+ PrimaryInterfaceKind.Pppoe => "PPPoE",
+ _ => kind.ToString()
+ };
+ }
+
+ private static string FormatConnectionMethod(ConnectionMethod connectionMethod)
+ {
+ return connectionMethod switch
+ {
+ ConnectionMethod.DirectCampusNetwork => "直接连接:校园 Wi-Fi / 宿舍有线 PPPoE",
+ ConnectionMethod.CampusNetworkViaRouter => "间接连接:通过路由器等设备",
+ ConnectionMethod.OtherNetwork => "其他连接:手机热点 / 校外网络等",
+ _ => "不确定"
+ };
+ }
+
+ private static string FormatNetworkAccessMethod(NetworkAccessMethod accessMethod)
+ {
+ return accessMethod switch
+ {
+ NetworkAccessMethod.Wireless => "Wi-Fi",
+ NetworkAccessMethod.Ethernet => "有线连接(不拨号)",
+ NetworkAccessMethod.Pppoe => "PPPoE / 宽带拨号",
+ _ => "不确定"
+ };
+ }
+
+ private static string FormatProblemSymptom(ProblemSymptom problemSymptom)
+ {
+ return problemSymptom switch
+ {
+ ProblemSymptom.NoProblem => "没有问题,只是进行一次诊断",
+ ProblemSymptom.CannotConnectWifi => "连不上 Wi-Fi",
+ ProblemSymptom.ConnectedNoInternet => "连上了但显示无 Internet",
+ ProblemSymptom.CaptivePortalNotShown => "认证页不弹出",
+ ProblemSymptom.PortalAuthenticatedNoWeb => "认证成功但打不开网页",
+ ProblemSymptom.SomeApplicationsUnavailable => "部分应用能用,部分应用不能用",
+ ProblemSymptom.PppoeDialFailed => "有线拨号失败",
+ ProblemSymptom.PppoeConnectedNoInternet => "有线拨号成功但没有网",
+ ProblemSymptom.Other => "其他问题",
+ _ => "不确定"
+ };
+ }
+
+ private static string FormatImpactScope(ImpactScope impactScope, ProblemSymptom problemSymptom)
+ {
+ if (problemSymptom == ProblemSymptom.NoProblem)
+ return "未询问";
+
+ return impactScope switch
+ {
+ ImpactScope.OnlyThisDevice => "只有这台设备",
+ ImpactScope.SameRoomOrDormitory => "同宿舍 / 同房间也有人遇到",
+ ImpactScope.SameFloorOrArea => "同楼层 / 附近区域也有人遇到",
+ ImpactScope.WiderArea => "更大范围都有人遇到",
+ _ => "不清楚"
+ };
}
- private static void PrintProxyStatus(SystemProxyInfo systemProxy)
+ private static string Escape(string value)
{
- if (systemProxy.IsEnabled)
- Console.WriteLine($"System Proxy is on. The proxy URL is {systemProxy.ProxyUri}.");
- else
- Console.WriteLine("System Proxy is off.");
+ return Markup.Escape(value);
}
-}
\ No newline at end of file
+}
diff --git a/xidio/xidio.CLI/Program.cs b/xidio/xidio.CLI/Program.cs
index 1e4495c..e9e99d1 100644
--- a/xidio/xidio.CLI/Program.cs
+++ b/xidio/xidio.CLI/Program.cs
@@ -1,6 +1,7 @@
using Spectre.Console;
using xidio.Core.Abstractions;
using xidio.Core.Diagnostics;
+using xidio.Core.Models;
#if WINDOWS
using xidio.Platform.Windows;
#else
@@ -11,12 +12,57 @@ namespace xidio.CLI;
internal static class Program
{
- private static void Main(string[] args)
+ private static async Task Main(string[] args)
{
- var collector = new NetworkDiagnosticsCollector(CreatePlatformProvider());
- var report = collector.Collect();
-
- ConsoleDiagnosticReporter.Print(report);
+ using var cancellation = new CancellationTokenSource();
+ Console.CancelKeyPress += (_, eventArgs) =>
+ {
+ eventArgs.Cancel = true;
+ cancellation.Cancel();
+ };
+
+ try
+ {
+ ConsoleDiagnosticReporter.PrintBanner();
+
+ var userScenario = await ConsoleDiagnosticQuestionnaire.AskAsync(cancellation.Token);
+ var collector = new NetworkDiagnosticsCollector(CreatePlatformProvider());
+ var report = await CollectWithProgressAsync(collector, userScenario, cancellation.Token);
+
+ ConsoleDiagnosticReporter.Print(report);
+ WaitForExitKey();
+ }
+ catch (OperationCanceledException)
+ {
+ AnsiConsole.MarkupLine("[yellow]诊断已取消。[/]");
+ }
+ }
+
+ private static Task CollectWithProgressAsync(
+ NetworkDiagnosticsCollector collector,
+ UserScenarioInfo userScenario,
+ CancellationToken cancellationToken)
+ {
+ return AnsiConsole.Progress()
+ .AutoClear(true)
+ .Columns(
+ new SpinnerColumn(),
+ new PercentageColumn(),
+ new ProgressBarColumn(),
+ new TaskDescriptionColumn()
+ )
+ .StartAsync(async context =>
+ {
+ var task = context.AddTask("正在准备诊断环境", maxValue: 10);
+ var progress = new InlineProgress(state =>
+ {
+ task.Description = state.Message;
+ task.MaxValue = state.TotalSteps;
+ task.Value = Math.Clamp(state.CompletedSteps, 0, state.TotalSteps);
+ });
+
+ return await collector.CollectAsync(userScenario, progress, cancellationToken);
+ });
}
private static IPlatformNetworkDiagnosticsProvider CreatePlatformProvider()
@@ -30,4 +76,19 @@ private static IPlatformNetworkDiagnosticsProvider CreatePlatformProvider()
return NoopPlatformNetworkDiagnosticsProvider.Instance;
#endif
}
+
+ private static void WaitForExitKey()
+ {
+ Console.WriteLine();
+ AnsiConsole.MarkupLine("[grey]按任意键退出。[/]");
+ Console.ReadKey(intercept: true);
+ }
+
+ private sealed class InlineProgress(Action onReport) : IProgress
+ {
+ public void Report(T value)
+ {
+ onReport(value);
+ }
+ }
}
diff --git a/xidio/xidio.Core/Abstractions/IPlatformNetworkDiagnosticsProvider.cs b/xidio/xidio.Core/Abstractions/IPlatformNetworkDiagnosticsProvider.cs
index f2a78e8..370cbe4 100644
--- a/xidio/xidio.Core/Abstractions/IPlatformNetworkDiagnosticsProvider.cs
+++ b/xidio/xidio.Core/Abstractions/IPlatformNetworkDiagnosticsProvider.cs
@@ -5,13 +5,35 @@ namespace xidio.Core.Abstractions;
public interface IPlatformNetworkDiagnosticsProvider
{
- IReadOnlyCollection GetPhysicalNetworkInterfaceIds();
+ ValueTask GetOperatingSystemInfoAsync(CancellationToken cancellationToken);
- IReadOnlyList GetNetworkAdapterDriverInfos();
+ ValueTask> GetPhysicalNetworkInterfaceIdsAsync(CancellationToken cancellationToken);
- WirelessConnectionInfo? GetWirelessConnectionInfo(NetworkInterface networkInterface);
+ ValueTask> GetNetworkAdapterDriverInfosAsync(CancellationToken cancellationToken);
- IReadOnlyList GetInterfaceMetrics(NetworkInterface networkInterface);
+ ValueTask GetInterfacePlatformInfoAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken);
- IReadOnlyList GetRoutes(NetworkInterface networkInterface);
+ ValueTask GetWirelessConnectionInfoAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken);
+
+ ValueTask GetInterfaceDhcpInfoAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken);
+
+ ValueTask> GetInterfaceMetricsAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken);
+
+ ValueTask> GetRoutesAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken);
+
+ ValueTask> GetNetworkNeighborsAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken);
+
+ ValueTask> GetDefaultRoutesAsync(CancellationToken cancellationToken);
}
diff --git a/xidio/xidio.Core/Diagnostics/NetworkDiagnosticsCollector.cs b/xidio/xidio.Core/Diagnostics/NetworkDiagnosticsCollector.cs
index 5c8f26a..b65a240 100644
--- a/xidio/xidio.Core/Diagnostics/NetworkDiagnosticsCollector.cs
+++ b/xidio/xidio.Core/Diagnostics/NetworkDiagnosticsCollector.cs
@@ -1,6 +1,11 @@
using System.Net;
+using System.Buffers.Binary;
+using System.Diagnostics;
using System.Net.NetworkInformation;
using System.Net.Sockets;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Text;
using xidio.Core.Abstractions;
using xidio.Core.Models;
@@ -8,56 +13,554 @@ namespace xidio.Core.Diagnostics;
public sealed class NetworkDiagnosticsCollector(IPlatformNetworkDiagnosticsProvider? platformDiagnostics = null)
{
+ private const int TotalProgressSteps = 10;
+ private const string NtpServer = "ntp.aliyun.com";
+ private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(3);
+ private static readonly IReadOnlyList ProbeTargets =
+ [
+ new()
+ {
+ Name = "西电校园网认证服务器域名",
+ Host = "w.xidian.edu.cn",
+ IsCampusTarget = true
+ },
+ new()
+ {
+ Name = "西电校园网认证服务器 IP",
+ Host = "10.255.44.33",
+ IsCampusTarget = true
+ },
+ new()
+ {
+ Name = "镜雨亭CrystaRin",
+ Host = "crystal.stellalyr.ink",
+ IsCampusTarget = false
+ }
+ ];
private readonly IPlatformNetworkDiagnosticsProvider _platformDiagnostics = platformDiagnostics ?? NoopPlatformNetworkDiagnosticsProvider.Instance;
-
+
+ [Obsolete("Use CollectAsync instead. Synchronous network diagnostics block a long-running operation and may deadlock in context-bound callers.")]
public NetworkDiagnosticReport Collect()
{
+ return Task.Run(() => CollectAsync(UserScenarioInfo.Unspecified)).Result;
+ }
+
+ public Task CollectAsync(
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ return CollectAsync(UserScenarioInfo.Unspecified, progress, cancellationToken);
+ }
+
+ public async Task CollectAsync(
+ UserScenarioInfo userScenario,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ ReportProgress(progress, NetworkDiagnosticStage.Starting, "正在准备诊断环境", 0);
+
var collectedAt = DateTimeOffset.Now;
+ var operatingSystem = await GetOperatingSystemInfoAsync(cancellationToken);
+ ReportProgress(progress, NetworkDiagnosticStage.SystemInfo, "已读取操作系统信息", 1);
+
+ var clock = await GetClockInfoAsync(cancellationToken);
+ ReportProgress(progress, NetworkDiagnosticStage.Clock, "已完成本机时间校验", 2);
+
var allInterfaces = NetworkInterface.GetAllNetworkInterfaces();
- var physicalInterfaceIds = SafeGet(_platformDiagnostics.GetPhysicalNetworkInterfaceIds, []);
- var adapterDriverInfos = SafeGet(_platformDiagnostics.GetNetworkAdapterDriverInfos, []);
- var primaryInterfaces = GetPrimaryInterfaces(allInterfaces, physicalInterfaceIds);
+ ReportProgress(progress, NetworkDiagnosticStage.NetworkInterfaces, "已读取系统网络接口", 3);
+
+ var physicalInterfaceIds = await SafeGetAsync(
+ ct => _platformDiagnostics.GetPhysicalNetworkInterfaceIdsAsync(ct),
+ [],
+ cancellationToken);
+ ReportProgress(progress, NetworkDiagnosticStage.PhysicalAdapters, "已识别物理网络适配器", 4);
+
+ var adapterDriverInfos = await SafeGetAsync(
+ ct => _platformDiagnostics.GetNetworkAdapterDriverInfosAsync(ct),
+ [],
+ cancellationToken);
+ ReportProgress(progress, NetworkDiagnosticStage.DriverInfo, "已读取网卡驱动信息", 5);
- return new NetworkDiagnosticReport
+ var primaryInterfaces = await GetPrimaryInterfacesAsync(
+ allInterfaces,
+ physicalInterfaceIds,
+ progress,
+ cancellationToken);
+ ReportProgress(progress, NetworkDiagnosticStage.PrimaryInterfaces, "已完成主要网络接口采集", 6);
+
+ var defaultRoutes = await SafeGetAsync(
+ ct => _platformDiagnostics.GetDefaultRoutesAsync(ct),
+ Array.Empty(),
+ cancellationToken);
+ ReportProgress(progress, NetworkDiagnosticStage.DefaultRoutes, "已读取默认路由信息", 7);
+
+ var systemProxy = SafeGet(GetSystemProxyInfo, new SystemProxyInfo { IsEnabled = false });
+ ReportProgress(progress, NetworkDiagnosticStage.SystemProxy, "已读取系统代理状态", 8);
+
+ var probeResults = await CollectProbeResultsAsync(primaryInterfaces, progress, cancellationToken);
+ ReportProgress(progress, NetworkDiagnosticStage.ActiveProbes, "已完成主动探测", 9);
+
+ var report = new NetworkDiagnosticReport
{
CollectedAt = collectedAt,
TotalNetworkInterfaceCount = allInterfaces.Length,
- HostName = Dns.GetHostName(),
- SystemProxy = GetSystemProxyInfo(),
+ HostName = SafeGet(Dns.GetHostName, ""),
+ OperatingSystem = operatingSystem,
+ Clock = clock,
+ UserScenario = userScenario,
+ SystemProxy = systemProxy,
NetworkAdapterDriverInfos = adapterDriverInfos,
- PrimaryInterfaces = primaryInterfaces
+ DefaultRoutes = defaultRoutes,
+ PrimaryInterfaces = primaryInterfaces,
+ Pppoe = GetPppoeDiagnosticInfo(primaryInterfaces),
+ ProbeResults = probeResults
+ };
+
+ ReportProgress(progress, NetworkDiagnosticStage.Completed, "诊断信息采集完成", TotalProgressSteps);
+ return report;
+ }
+
+ private async Task GetOperatingSystemInfoAsync(CancellationToken cancellationToken)
+ {
+ var platformInfo = await SafeGetAsync(
+ ct => _platformDiagnostics.GetOperatingSystemInfoAsync(ct),
+ null,
+ cancellationToken);
+
+ return platformInfo ?? new OperatingSystemDiagnosticInfo
+ {
+ Family = GetOperatingSystemFamily(),
+ Description = RuntimeInformation.OSDescription,
+ Version = RuntimeInformation.OSDescription,
+ Architecture = RuntimeInformation.ProcessArchitecture.ToString(),
+ XidioVersion = GetXidioVersion(),
+ IsElevated = null
+ };
+ }
+
+ private static async Task GetClockInfoAsync(CancellationToken cancellationToken)
+ {
+ var localTime = DateTimeOffset.Now;
+
+ try
+ {
+ var ntpTime = await QueryNtpTimeAsync(NtpServer, cancellationToken);
+
+ return new ClockDiagnosticInfo
+ {
+ LocalTime = localTime,
+ NtpServer = NtpServer,
+ NtpTime = ntpTime,
+ Offset = localTime - ntpTime
+ };
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ return new ClockDiagnosticInfo
+ {
+ LocalTime = localTime,
+ NtpServer = NtpServer,
+ Error = ex.Message
+ };
+ }
+ }
+
+ private async Task> CollectProbeResultsAsync(
+ IReadOnlyList primaryInterfaces,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ var dnsServers = primaryInterfaces
+ .Select(primaryInterface => primaryInterface.NetworkDetails)
+ .Where(details => details is not null)
+ .SelectMany(details => details!.DnsServers)
+ .Distinct()
+ .Take(2)
+ .ToList();
+
+ var results = new List();
+
+ foreach (var target in ProbeTargets)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ ReportProgress(progress, NetworkDiagnosticStage.ActiveProbes, $"正在探测:{target.Name}", 8);
+ results.Add(await ProbeTargetAsync(target, dnsServers, cancellationToken));
+ }
+
+ return results;
+ }
+
+ private static async Task ProbeTargetAsync(
+ ProbeTargetInfo target,
+ IReadOnlyList dnsServers,
+ CancellationToken cancellationToken)
+ {
+ var systemDnsResults = await ProbeSystemDnsAsync(target.Host, cancellationToken);
+ var directDnsResults = await ProbeDirectDnsAsync(target.Host, dnsServers, cancellationToken);
+
+ return new TargetProbeResult
+ {
+ Target = target,
+ SystemDnsResults = systemDnsResults,
+ DirectDnsResults = directDnsResults,
+ Ping = await ProbePingAsync(target.Host, cancellationToken),
+ TcpConnectResults =
+ [
+ await ProbeTcpConnectAsync(target.Host, 80, cancellationToken),
+ await ProbeTcpConnectAsync(target.Host, 443, cancellationToken),
+ await ProbeTcpConnectAsync(target.Host, 53, cancellationToken)
+ ],
+ Http = await ProbeHttpAsync(new Uri($"http://{target.Host}/"), cancellationToken),
+ Https = await ProbeHttpAsync(new Uri($"https://{target.Host}/"), cancellationToken)
};
}
- private IReadOnlyList GetPrimaryInterfaces(
+ private static async Task> ProbeSystemDnsAsync(
+ string host,
+ CancellationToken cancellationToken)
+ {
+ if (IPAddress.TryParse(host, out var ipAddress))
+ {
+ return
+ [
+ new DnsProbeResult
+ {
+ QueryName = host,
+ QueryType = ipAddress.AddressFamily == AddressFamily.InterNetwork ? "A" : "AAAA",
+ Succeeded = true,
+ Addresses = [ipAddress]
+ }
+ ];
+ }
+
+ var stopwatch = Stopwatch.StartNew();
+
+ try
+ {
+ var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken)
+ .WaitAsync(ProbeTimeout, cancellationToken);
+ stopwatch.Stop();
+
+ return
+ [
+ CreateSystemDnsResult(host, "A", addresses, AddressFamily.InterNetwork, stopwatch.Elapsed, null),
+ CreateSystemDnsResult(host, "AAAA", addresses, AddressFamily.InterNetworkV6, stopwatch.Elapsed, null)
+ ];
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ stopwatch.Stop();
+
+ return
+ [
+ CreateSystemDnsResult(host, "A", [], AddressFamily.InterNetwork, stopwatch.Elapsed, ex.Message),
+ CreateSystemDnsResult(host, "AAAA", [], AddressFamily.InterNetworkV6, stopwatch.Elapsed, ex.Message)
+ ];
+ }
+ }
+
+ private static DnsProbeResult CreateSystemDnsResult(
+ string host,
+ string queryType,
+ IEnumerable addresses,
+ AddressFamily addressFamily,
+ TimeSpan duration,
+ string? error)
+ {
+ var filteredAddresses = addresses
+ .Where(address => address.AddressFamily == addressFamily)
+ .ToList();
+
+ return new DnsProbeResult
+ {
+ QueryName = host,
+ QueryType = queryType,
+ Succeeded = error is null && filteredAddresses.Count > 0,
+ Addresses = filteredAddresses,
+ Duration = duration,
+ Error = error
+ };
+ }
+
+ private static async Task> ProbeDirectDnsAsync(
+ string host,
+ IReadOnlyList dnsServers,
+ CancellationToken cancellationToken)
+ {
+ if (IPAddress.TryParse(host, out _) || dnsServers.Count == 0)
+ return [];
+
+ var results = new List();
+
+ foreach (var dnsServer in dnsServers)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ results.Add(await ProbeDirectDnsAsync(host, "A", 1, dnsServer, cancellationToken));
+ results.Add(await ProbeDirectDnsAsync(host, "AAAA", 28, dnsServer, cancellationToken));
+ }
+
+ return results;
+ }
+
+ private static async Task ProbeDirectDnsAsync(
+ string host,
+ string queryType,
+ ushort queryTypeValue,
+ IPAddress dnsServer,
+ CancellationToken cancellationToken)
+ {
+ var stopwatch = Stopwatch.StartNew();
+
+ try
+ {
+ var query = BuildDnsQuery(host, queryTypeValue);
+ using var udpClient = new UdpClient(dnsServer.AddressFamily);
+
+ await udpClient.SendAsync(query, query.Length, new IPEndPoint(dnsServer, 53))
+ .WaitAsync(ProbeTimeout, cancellationToken);
+
+ var response = await udpClient.ReceiveAsync(cancellationToken)
+ .AsTask()
+ .WaitAsync(ProbeTimeout, cancellationToken);
+ stopwatch.Stop();
+
+ var addresses = ParseDnsAddresses(response.Buffer, queryTypeValue);
+
+ return new DnsProbeResult
+ {
+ QueryName = host,
+ QueryType = queryType,
+ DnsServer = dnsServer.ToString(),
+ Succeeded = addresses.Count > 0,
+ Addresses = addresses,
+ Duration = stopwatch.Elapsed
+ };
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ stopwatch.Stop();
+
+ return new DnsProbeResult
+ {
+ QueryName = host,
+ QueryType = queryType,
+ DnsServer = dnsServer.ToString(),
+ Succeeded = false,
+ Addresses = [],
+ Duration = stopwatch.Elapsed,
+ Error = ex.Message
+ };
+ }
+ }
+
+ private static async Task ProbePingAsync(string host, CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var ping = new Ping();
+ var reply = await ping.SendPingAsync(host, (int)ProbeTimeout.TotalMilliseconds)
+ .WaitAsync(ProbeTimeout + TimeSpan.FromMilliseconds(250), cancellationToken);
+
+ return new PingProbeResult
+ {
+ Succeeded = reply.Status == IPStatus.Success,
+ RoundtripTimeMilliseconds = reply.Status == IPStatus.Success ? reply.RoundtripTime : null,
+ Status = reply.Status.ToString()
+ };
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ return new PingProbeResult
+ {
+ Succeeded = false,
+ Error = ex.Message
+ };
+ }
+ }
+
+ private static async Task ProbeTcpConnectAsync(
+ string host,
+ int port,
+ CancellationToken cancellationToken)
+ {
+ var stopwatch = Stopwatch.StartNew();
+
+ try
+ {
+ using var tcpClient = new TcpClient();
+ await tcpClient.ConnectAsync(host, port, cancellationToken)
+ .AsTask()
+ .WaitAsync(ProbeTimeout, cancellationToken);
+ stopwatch.Stop();
+
+ return new TcpConnectProbeResult
+ {
+ Port = port,
+ Succeeded = true,
+ Duration = stopwatch.Elapsed
+ };
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ stopwatch.Stop();
+
+ return new TcpConnectProbeResult
+ {
+ Port = port,
+ Succeeded = false,
+ Duration = stopwatch.Elapsed,
+ Error = ex.Message
+ };
+ }
+ }
+
+ private static async Task ProbeHttpAsync(Uri uri, CancellationToken cancellationToken)
+ {
+ var stopwatch = Stopwatch.StartNew();
+
+ try
+ {
+ using var handler = new HttpClientHandler
+ {
+ AllowAutoRedirect = false
+ };
+ using var httpClient = new HttpClient(handler)
+ {
+ Timeout = ProbeTimeout
+ };
+ using var request = new HttpRequestMessage(HttpMethod.Get, uri);
+ using var response = await httpClient.SendAsync(
+ request,
+ HttpCompletionOption.ResponseHeadersRead,
+ cancellationToken);
+ stopwatch.Stop();
+
+ return new HttpProbeResult
+ {
+ Uri = uri,
+ Succeeded = true,
+ StatusCode = (int)response.StatusCode,
+ Duration = stopwatch.Elapsed
+ };
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ stopwatch.Stop();
+
+ return new HttpProbeResult
+ {
+ Uri = uri,
+ Succeeded = false,
+ Duration = stopwatch.Elapsed,
+ Error = "Timeout"
+ };
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ stopwatch.Stop();
+
+ return new HttpProbeResult
+ {
+ Uri = uri,
+ Succeeded = false,
+ Duration = stopwatch.Elapsed,
+ Error = ex.Message
+ };
+ }
+ }
+
+ private async Task> GetPrimaryInterfacesAsync(
IEnumerable networkInterfaces,
- IReadOnlyCollection physicalInterfaceIds)
+ IReadOnlyCollection physicalInterfaceIds,
+ IProgress? progress,
+ CancellationToken cancellationToken)
{
var result = new List();
var hasPlatformPhysicalIds = physicalInterfaceIds.Count > 0;
- var query =
- from networkInterface in networkInterfaces
- let kind = ClassifyPrimaryInterface(networkInterface, physicalInterfaceIds, hasPlatformPhysicalIds)
- where kind != null
- let isUp = networkInterface.OperationalStatus == OperationalStatus.Up
- let wirelessInfo = isUp && kind == PrimaryInterfaceKind.Wireless
- ? SafeGet(() => _platformDiagnostics.GetWirelessConnectionInfo(networkInterface), null)
- : null
- let networkDetails = isUp ? GetNetworkDetails(networkInterface) : null
- select new PrimaryInterfaceInfo
+ foreach (var networkInterface in networkInterfaces)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var kind = ClassifyPrimaryInterface(networkInterface, physicalInterfaceIds, hasPlatformPhysicalIds);
+ if (kind is null)
+ continue;
+
+ var isUp = networkInterface.OperationalStatus == OperationalStatus.Up;
+ var platformInfo = await SafeGetAsync(
+ ct => _platformDiagnostics.GetInterfacePlatformInfoAsync(networkInterface, ct),
+ null,
+ cancellationToken);
+ WirelessConnectionInfo? wirelessInfo = null;
+ InterfaceNetworkDetails? networkDetails = null;
+
+ if (isUp && kind == PrimaryInterfaceKind.Wireless)
+ {
+ ReportProgress(
+ progress,
+ NetworkDiagnosticStage.WirelessInfo,
+ $"正在读取无线连接信息:{networkInterface.Name}",
+ 3);
+
+ wirelessInfo = await SafeGetAsync(
+ ct => _platformDiagnostics.GetWirelessConnectionInfoAsync(networkInterface, ct),
+ null,
+ cancellationToken);
+ }
+
+ if (isUp)
+ {
+ ReportProgress(
+ progress,
+ NetworkDiagnosticStage.NetworkDetails,
+ $"正在读取网络配置:{networkInterface.Name}",
+ 3);
+
+ networkDetails = await GetNetworkDetailsAsync(networkInterface, cancellationToken);
+ }
+
+ result.Add(new PrimaryInterfaceInfo
{
Id = networkInterface.Id,
Name = networkInterface.Name,
Description = networkInterface.Description,
Kind = kind.Value,
+ IsEnabled = platformInfo?.IsEnabled,
+ IsConnected = isUp,
OperationalStatus = networkInterface.OperationalStatus,
NetworkInterfaceType = networkInterface.NetworkInterfaceType,
+ MacAddress = FormatPhysicalAddress(networkInterface.GetPhysicalAddress()),
+ LinkSpeedBitsPerSecond = networkInterface.Speed > 0 ? networkInterface.Speed : null,
+ Mtu = platformInfo?.Mtu,
WirelessConnection = wirelessInfo,
NetworkDetails = networkDetails
- };
-
- result.AddRange(query);
+ });
+ }
return result;
}
@@ -88,19 +591,22 @@ from networkInterface in networkInterfaces
// 最终判断是否是 Wireless80211
if (type == NetworkInterfaceType.Wireless80211)
return PrimaryInterfaceKind.Wireless;
-
+
// 最终排除蓝牙,标记为 Ethernet
return LooksLikeBluetooth(networkInterface.Name, networkInterface.Description)
? null
: PrimaryInterfaceKind.Ethernet;
}
- private InterfaceNetworkDetails? GetNetworkDetails(NetworkInterface networkInterface)
+ private async Task GetNetworkDetailsAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
{
IPInterfaceProperties properties;
try
{
+ cancellationToken.ThrowIfCancellationRequested();
properties = networkInterface.GetIPProperties();
}
catch (NetworkInformationException)
@@ -108,8 +614,22 @@ from networkInterface in networkInterfaces
return null;
}
- var routes = SafeGet(() => _platformDiagnostics.GetRoutes(networkInterface), []);
- var metrics = SafeGet(() => _platformDiagnostics.GetInterfaceMetrics(networkInterface), []);
+ var routes = await SafeGetAsync(
+ ct => _platformDiagnostics.GetRoutesAsync(networkInterface, ct),
+ Array.Empty(),
+ cancellationToken);
+ var metrics = await SafeGetAsync(
+ ct => _platformDiagnostics.GetInterfaceMetricsAsync(networkInterface, ct),
+ Array.Empty(),
+ cancellationToken);
+ var dhcpInfo = await SafeGetAsync(
+ ct => _platformDiagnostics.GetInterfaceDhcpInfoAsync(networkInterface, ct),
+ null,
+ cancellationToken);
+ var neighbors = await SafeGetAsync(
+ ct => _platformDiagnostics.GetNetworkNeighborsAsync(networkInterface, ct),
+ Array.Empty(),
+ cancellationToken);
return new InterfaceNetworkDetails
{
@@ -117,9 +637,11 @@ from networkInterface in networkInterfaces
IPv6Addresses = GetUnicastAddresses(properties, AddressFamily.InterNetworkV6),
DefaultGateways = properties.GatewayAddresses.Select(gateway => gateway.Address).ToList(),
DhcpServers = GetDhcpServerAddresses(properties),
+ DhcpInfo = dhcpInfo,
DnsServers = properties.DnsAddresses.ToList(),
InterfaceMetrics = metrics,
- Routes = routes
+ Routes = routes,
+ Neighbors = neighbors
};
}
@@ -130,6 +652,128 @@ private static IReadOnlyList GetDhcpServerAddresses(IPInterfaceProper
: properties.DhcpServerAddresses.ToList();
}
+ private static async Task QueryNtpTimeAsync(string server, CancellationToken cancellationToken)
+ {
+ var request = new byte[48];
+ request[0] = 0x1B;
+
+ using var udpClient = new UdpClient(AddressFamily.InterNetwork);
+ await udpClient.SendAsync(request, request.Length, server, 123)
+ .WaitAsync(ProbeTimeout, cancellationToken);
+
+ var response = await udpClient.ReceiveAsync(cancellationToken)
+ .AsTask()
+ .WaitAsync(ProbeTimeout, cancellationToken);
+
+ if (response.Buffer.Length < 48)
+ throw new InvalidOperationException("NTP response is too short.");
+
+ var seconds = BinaryPrimitives.ReadUInt32BigEndian(response.Buffer.AsSpan(40, 4));
+ var fraction = BinaryPrimitives.ReadUInt32BigEndian(response.Buffer.AsSpan(44, 4));
+ var milliseconds = seconds * 1000d + fraction * 1000d / uint.MaxValue;
+ var ntpEpoch = new DateTimeOffset(1900, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ return ntpEpoch.AddMilliseconds(milliseconds).ToLocalTime();
+ }
+
+ private static byte[] BuildDnsQuery(string host, ushort queryType)
+ {
+ var id = (ushort)Random.Shared.Next(0, ushort.MaxValue + 1);
+ var labels = host.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ using var stream = new MemoryStream();
+
+ Span header = stackalloc byte[12];
+ BinaryPrimitives.WriteUInt16BigEndian(header[0..2], id);
+ BinaryPrimitives.WriteUInt16BigEndian(header[2..4], 0x0100);
+ BinaryPrimitives.WriteUInt16BigEndian(header[4..6], 1);
+ stream.Write(header);
+
+ foreach (var label in labels)
+ {
+ var labelBytes = Encoding.ASCII.GetBytes(label);
+
+ if (labelBytes.Length is 0 or > 63)
+ throw new InvalidOperationException("Invalid DNS label length.");
+
+ stream.WriteByte((byte)labelBytes.Length);
+ stream.Write(labelBytes);
+ }
+
+ stream.WriteByte(0);
+
+ Span footer = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt16BigEndian(footer[0..2], queryType);
+ BinaryPrimitives.WriteUInt16BigEndian(footer[2..4], 1);
+ stream.Write(footer);
+
+ return stream.ToArray();
+ }
+
+ private static IReadOnlyList ParseDnsAddresses(byte[] response, ushort expectedQueryType)
+ {
+ if (response.Length < 12)
+ return Array.Empty();
+
+ var questionCount = BinaryPrimitives.ReadUInt16BigEndian(response.AsSpan(4, 2));
+ var answerCount = BinaryPrimitives.ReadUInt16BigEndian(response.AsSpan(6, 2));
+ var offset = 12;
+
+ for (var i = 0; i < questionCount; i++)
+ {
+ offset = SkipDnsName(response, offset);
+ offset += 4;
+
+ if (offset > response.Length)
+ return Array.Empty();
+ }
+
+ var addresses = new List();
+
+ for (var i = 0; i < answerCount; i++)
+ {
+ offset = SkipDnsName(response, offset);
+
+ if (offset + 10 > response.Length)
+ break;
+
+ var type = BinaryPrimitives.ReadUInt16BigEndian(response.AsSpan(offset, 2));
+ var recordClass = BinaryPrimitives.ReadUInt16BigEndian(response.AsSpan(offset + 2, 2));
+ var dataLength = BinaryPrimitives.ReadUInt16BigEndian(response.AsSpan(offset + 8, 2));
+ offset += 10;
+
+ if (offset + dataLength > response.Length)
+ break;
+
+ if (recordClass == 1 && type == expectedQueryType &&
+ ((type == 1 && dataLength == 4) || (type == 28 && dataLength == 16)))
+ {
+ addresses.Add(new IPAddress(response.AsSpan(offset, dataLength).ToArray()));
+ }
+
+ offset += dataLength;
+ }
+
+ return addresses;
+ }
+
+ private static int SkipDnsName(byte[] response, int offset)
+ {
+ while (offset < response.Length)
+ {
+ var length = response[offset];
+
+ if (length == 0)
+ return offset + 1;
+
+ if ((length & 0xC0) == 0xC0)
+ return offset + 2;
+
+ offset += length + 1;
+ }
+
+ return response.Length;
+ }
+
private static IReadOnlyList GetUnicastAddresses(
IPInterfaceProperties properties,
AddressFamily addressFamily)
@@ -145,12 +789,32 @@ private static IReadOnlyList GetUnicastAddresses(
.ToList();
}
+ private static PppoeDiagnosticInfo GetPppoeDiagnosticInfo(IReadOnlyList primaryInterfaces)
+ {
+ var pppoeInterfaces = primaryInterfaces
+ .Where(primaryInterface => primaryInterface.Kind == PrimaryInterfaceKind.Pppoe)
+ .ToList();
+
+ return new PppoeDiagnosticInfo
+ {
+ HasPppoeInterface = pppoeInterfaces.Count > 0,
+ InterfaceNames = pppoeInterfaces.Select(primaryInterface => primaryInterface.Name).ToList(),
+ HasDefaultRoute = pppoeInterfaces.Any(primaryInterface =>
+ primaryInterface.NetworkDetails?.Routes.Any(IsDefaultRoute) == true)
+ };
+ }
+
+ private static bool IsDefaultRoute(InterfaceRouteInfo route)
+ {
+ return route.DestinationPrefix is "0.0.0.0/0" or "::/0";
+ }
+
private static SystemProxyInfo GetSystemProxyInfo()
{
var targetUri = new Uri("https://xidio.stellalyr.ink");
- var handler = new HttpClientHandler();
+ using var handler = new HttpClientHandler();
var defaultProxy = handler.Proxy ?? WebRequest.GetSystemWebProxy();
- var proxyUri = defaultProxy?.GetProxy(targetUri);
+ var proxyUri = defaultProxy.GetProxy(targetUri);
var isEnabled = proxyUri is not null && proxyUri != targetUri;
return new SystemProxyInfo
@@ -174,6 +838,35 @@ private static string NormalizeGuid(string guid)
return guid.Trim().Trim('{', '}').ToUpperInvariant();
}
+ private static string? FormatPhysicalAddress(PhysicalAddress physicalAddress)
+ {
+ var bytes = physicalAddress.GetAddressBytes();
+
+ return bytes.Length == 0
+ ? null
+ : string.Join(":", bytes.Select(static b => b.ToString("X2")));
+ }
+
+ private static string GetOperatingSystemFamily()
+ {
+ if (OperatingSystem.IsWindows())
+ return "Windows";
+ if (OperatingSystem.IsMacOS())
+ return "macOS";
+ if (OperatingSystem.IsLinux())
+ return "Linux";
+
+ return "Unknown";
+ }
+
+ private static string GetXidioVersion()
+ {
+ var version = Assembly.GetEntryAssembly()?.GetName().Version
+ ?? typeof(NetworkDiagnosticsCollector).Assembly.GetName().Version;
+
+ return version?.ToString() ?? "0.1.0";
+ }
+
private static bool LooksLikeBluetooth(string name, string descriptionOrPnpId)
{
var text = $"{name} {descriptionOrPnpId}".ToLowerInvariant();
@@ -232,12 +925,63 @@ private static bool LooksLikeVirtualAdapter(string name, string description)
return MatchesVirtualKeywords(text);
}
+ private static void ReportProgress(
+ IProgress? progress,
+ NetworkDiagnosticStage stage,
+ string message,
+ int completedSteps)
+ {
+ progress?.Report(new NetworkDiagnosticProgress
+ {
+ Stage = stage,
+ Message = message,
+ CompletedSteps = completedSteps,
+ TotalSteps = TotalProgressSteps
+ });
+ }
+
+ private static async ValueTask SafeGetAsync(
+ Func> getValue,
+ T fallback,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return await getValue(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (NetworkInformationException)
+ {
+ return fallback;
+ }
+ catch (NotSupportedException)
+ {
+ return fallback;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return fallback;
+ }
+ catch (Exception)
+ {
+ return fallback;
+ }
+ }
+
private static T SafeGet(Func getValue, T fallback)
{
try
{
return getValue();
}
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
catch (NetworkInformationException)
{
return fallback;
diff --git a/xidio/xidio.Core/Diagnostics/NoopPlatformNetworkDiagnosticsProvider.cs b/xidio/xidio.Core/Diagnostics/NoopPlatformNetworkDiagnosticsProvider.cs
index dbcf7ca..809e7e0 100644
--- a/xidio/xidio.Core/Diagnostics/NoopPlatformNetworkDiagnosticsProvider.cs
+++ b/xidio/xidio.Core/Diagnostics/NoopPlatformNetworkDiagnosticsProvider.cs
@@ -12,28 +12,65 @@ private NoopPlatformNetworkDiagnosticsProvider()
{
}
- public IReadOnlyCollection GetPhysicalNetworkInterfaceIds()
+ public ValueTask GetOperatingSystemInfoAsync(CancellationToken cancellationToken)
{
- return Array.Empty();
+ return ValueTask.FromResult(null);
}
- public IReadOnlyList GetNetworkAdapterDriverInfos()
+ public ValueTask> GetPhysicalNetworkInterfaceIdsAsync(CancellationToken cancellationToken)
{
- return Array.Empty();
+ return ValueTask.FromResult>(Array.Empty());
}
- public WirelessConnectionInfo? GetWirelessConnectionInfo(NetworkInterface networkInterface)
+ public ValueTask> GetNetworkAdapterDriverInfosAsync(CancellationToken cancellationToken)
{
- return null;
+ return ValueTask.FromResult>(Array.Empty());
}
- public IReadOnlyList GetInterfaceMetrics(NetworkInterface networkInterface)
+ public ValueTask GetInterfacePlatformInfoAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
{
- return Array.Empty();
+ return ValueTask.FromResult(null);
}
- public IReadOnlyList GetRoutes(NetworkInterface networkInterface)
+ public ValueTask GetWirelessConnectionInfoAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
{
- return Array.Empty();
+ return ValueTask.FromResult(null);
+ }
+
+ public ValueTask GetInterfaceDhcpInfoAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ return ValueTask.FromResult(null);
+ }
+
+ public ValueTask> GetInterfaceMetricsAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ return ValueTask.FromResult>(Array.Empty());
+ }
+
+ public ValueTask> GetRoutesAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ return ValueTask.FromResult>(Array.Empty());
+ }
+
+ public ValueTask> GetNetworkNeighborsAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ return ValueTask.FromResult>(Array.Empty());
+ }
+
+ public ValueTask> GetDefaultRoutesAsync(CancellationToken cancellationToken)
+ {
+ return ValueTask.FromResult>(Array.Empty());
}
}
diff --git a/xidio/xidio.Core/Models/ClockDiagnosticInfo.cs b/xidio/xidio.Core/Models/ClockDiagnosticInfo.cs
new file mode 100644
index 0000000..e3092f5
--- /dev/null
+++ b/xidio/xidio.Core/Models/ClockDiagnosticInfo.cs
@@ -0,0 +1,14 @@
+namespace xidio.Core.Models;
+
+public sealed class ClockDiagnosticInfo
+{
+ public required DateTimeOffset LocalTime { get; init; }
+
+ public required string NtpServer { get; init; }
+
+ public DateTimeOffset? NtpTime { get; init; }
+
+ public TimeSpan? Offset { get; init; }
+
+ public string? Error { get; init; }
+}
diff --git a/xidio/xidio.Core/Models/ConnectionMethod.cs b/xidio/xidio.Core/Models/ConnectionMethod.cs
new file mode 100644
index 0000000..3b4e164
--- /dev/null
+++ b/xidio/xidio.Core/Models/ConnectionMethod.cs
@@ -0,0 +1,9 @@
+namespace xidio.Core.Models;
+
+public enum ConnectionMethod
+{
+ Unknown,
+ DirectCampusNetwork,
+ CampusNetworkViaRouter,
+ OtherNetwork
+}
diff --git a/xidio/xidio.Core/Models/DefaultRouteInfo.cs b/xidio/xidio.Core/Models/DefaultRouteInfo.cs
new file mode 100644
index 0000000..764a3f5
--- /dev/null
+++ b/xidio/xidio.Core/Models/DefaultRouteInfo.cs
@@ -0,0 +1,20 @@
+namespace xidio.Core.Models;
+
+public sealed class DefaultRouteInfo
+{
+ public required string DestinationPrefix { get; init; }
+
+ public required string NextHop { get; init; }
+
+ public required int AddressFamily { get; init; }
+
+ public required int InterfaceIndex { get; init; }
+
+ public required string InterfaceAlias { get; init; }
+
+ public required int RouteMetric { get; init; }
+
+ public required int InterfaceMetric { get; init; }
+
+ public int TotalMetric => RouteMetric + InterfaceMetric;
+}
diff --git a/xidio/xidio.Core/Models/DnsProbeResult.cs b/xidio/xidio.Core/Models/DnsProbeResult.cs
new file mode 100644
index 0000000..988d1aa
--- /dev/null
+++ b/xidio/xidio.Core/Models/DnsProbeResult.cs
@@ -0,0 +1,20 @@
+using System.Net;
+
+namespace xidio.Core.Models;
+
+public sealed class DnsProbeResult
+{
+ public required string QueryName { get; init; }
+
+ public required string QueryType { get; init; }
+
+ public string? DnsServer { get; init; }
+
+ public required bool Succeeded { get; init; }
+
+ public required IReadOnlyList Addresses { get; init; }
+
+ public TimeSpan? Duration { get; init; }
+
+ public string? Error { get; init; }
+}
diff --git a/xidio/xidio.Core/Models/HttpProbeResult.cs b/xidio/xidio.Core/Models/HttpProbeResult.cs
new file mode 100644
index 0000000..cd608f6
--- /dev/null
+++ b/xidio/xidio.Core/Models/HttpProbeResult.cs
@@ -0,0 +1,14 @@
+namespace xidio.Core.Models;
+
+public sealed class HttpProbeResult
+{
+ public required Uri Uri { get; init; }
+
+ public required bool Succeeded { get; init; }
+
+ public int? StatusCode { get; init; }
+
+ public TimeSpan? Duration { get; init; }
+
+ public string? Error { get; init; }
+}
diff --git a/xidio/xidio.Core/Models/ImpactScope.cs b/xidio/xidio.Core/Models/ImpactScope.cs
new file mode 100644
index 0000000..7cb4b52
--- /dev/null
+++ b/xidio/xidio.Core/Models/ImpactScope.cs
@@ -0,0 +1,10 @@
+namespace xidio.Core.Models;
+
+public enum ImpactScope
+{
+ Unknown,
+ OnlyThisDevice,
+ SameRoomOrDormitory,
+ SameFloorOrArea,
+ WiderArea
+}
diff --git a/xidio/xidio.Core/Models/InterfaceDhcpInfo.cs b/xidio/xidio.Core/Models/InterfaceDhcpInfo.cs
new file mode 100644
index 0000000..31dc245
--- /dev/null
+++ b/xidio/xidio.Core/Models/InterfaceDhcpInfo.cs
@@ -0,0 +1,10 @@
+namespace xidio.Core.Models;
+
+public sealed class InterfaceDhcpInfo
+{
+ public bool? IsEnabled { get; init; }
+
+ public DateTimeOffset? LeaseObtained { get; init; }
+
+ public DateTimeOffset? LeaseExpires { get; init; }
+}
diff --git a/xidio/xidio.Core/Models/InterfaceNetworkDetails.cs b/xidio/xidio.Core/Models/InterfaceNetworkDetails.cs
index f5ebc37..e3011f7 100644
--- a/xidio/xidio.Core/Models/InterfaceNetworkDetails.cs
+++ b/xidio/xidio.Core/Models/InterfaceNetworkDetails.cs
@@ -12,9 +12,13 @@ public sealed class InterfaceNetworkDetails
public required IReadOnlyList DhcpServers { get; init; }
+ public InterfaceDhcpInfo? DhcpInfo { get; init; }
+
public required IReadOnlyList DnsServers { get; init; }
public required IReadOnlyList InterfaceMetrics { get; init; }
public required IReadOnlyList Routes { get; init; }
+
+ public required IReadOnlyList Neighbors { get; init; }
}
diff --git a/xidio/xidio.Core/Models/InterfacePlatformInfo.cs b/xidio/xidio.Core/Models/InterfacePlatformInfo.cs
new file mode 100644
index 0000000..4232c63
--- /dev/null
+++ b/xidio/xidio.Core/Models/InterfacePlatformInfo.cs
@@ -0,0 +1,8 @@
+namespace xidio.Core.Models;
+
+public sealed class InterfacePlatformInfo
+{
+ public bool? IsEnabled { get; init; }
+
+ public int? Mtu { get; init; }
+}
diff --git a/xidio/xidio.Core/Models/NetworkAccessMethod.cs b/xidio/xidio.Core/Models/NetworkAccessMethod.cs
new file mode 100644
index 0000000..a45202c
--- /dev/null
+++ b/xidio/xidio.Core/Models/NetworkAccessMethod.cs
@@ -0,0 +1,9 @@
+namespace xidio.Core.Models;
+
+public enum NetworkAccessMethod
+{
+ Unknown,
+ Wireless,
+ Ethernet,
+ Pppoe
+}
diff --git a/xidio/xidio.Core/Models/NetworkDiagnosticProgress.cs b/xidio/xidio.Core/Models/NetworkDiagnosticProgress.cs
new file mode 100644
index 0000000..0bdd2eb
--- /dev/null
+++ b/xidio/xidio.Core/Models/NetworkDiagnosticProgress.cs
@@ -0,0 +1,14 @@
+namespace xidio.Core.Models;
+
+public sealed class NetworkDiagnosticProgress
+{
+ public required NetworkDiagnosticStage Stage { get; init; }
+
+ public required string Message { get; init; }
+
+ public required int CompletedSteps { get; init; }
+
+ public required int TotalSteps { get; init; }
+
+ public double Percentage => TotalSteps <= 0 ? 0 : CompletedSteps * 100d / TotalSteps;
+}
diff --git a/xidio/xidio.Core/Models/NetworkDiagnosticReport.cs b/xidio/xidio.Core/Models/NetworkDiagnosticReport.cs
index 2844599..ae06fc6 100644
--- a/xidio/xidio.Core/Models/NetworkDiagnosticReport.cs
+++ b/xidio/xidio.Core/Models/NetworkDiagnosticReport.cs
@@ -8,9 +8,21 @@ public sealed class NetworkDiagnosticReport
public required string HostName { get; init; }
+ public required OperatingSystemDiagnosticInfo OperatingSystem { get; init; }
+
+ public required ClockDiagnosticInfo Clock { get; init; }
+
+ public required UserScenarioInfo UserScenario { get; init; }
+
public required SystemProxyInfo SystemProxy { get; init; }
public required IReadOnlyList NetworkAdapterDriverInfos { get; init; }
+ public required IReadOnlyList DefaultRoutes { get; init; }
+
public required IReadOnlyList PrimaryInterfaces { get; init; }
+
+ public required PppoeDiagnosticInfo Pppoe { get; init; }
+
+ public required IReadOnlyList ProbeResults { get; init; }
}
diff --git a/xidio/xidio.Core/Models/NetworkDiagnosticStage.cs b/xidio/xidio.Core/Models/NetworkDiagnosticStage.cs
new file mode 100644
index 0000000..47f6d1f
--- /dev/null
+++ b/xidio/xidio.Core/Models/NetworkDiagnosticStage.cs
@@ -0,0 +1,18 @@
+namespace xidio.Core.Models;
+
+public enum NetworkDiagnosticStage
+{
+ Starting,
+ SystemInfo,
+ Clock,
+ NetworkInterfaces,
+ PhysicalAdapters,
+ DriverInfo,
+ PrimaryInterfaces,
+ WirelessInfo,
+ NetworkDetails,
+ DefaultRoutes,
+ SystemProxy,
+ ActiveProbes,
+ Completed
+}
diff --git a/xidio/xidio.Core/Models/NetworkNeighborInfo.cs b/xidio/xidio.Core/Models/NetworkNeighborInfo.cs
new file mode 100644
index 0000000..c475295
--- /dev/null
+++ b/xidio/xidio.Core/Models/NetworkNeighborInfo.cs
@@ -0,0 +1,12 @@
+namespace xidio.Core.Models;
+
+public sealed class NetworkNeighborInfo
+{
+ public required string IpAddress { get; init; }
+
+ public required string LinkLayerAddress { get; init; }
+
+ public required int AddressFamily { get; init; }
+
+ public required string State { get; init; }
+}
diff --git a/xidio/xidio.Core/Models/OperatingSystemDiagnosticInfo.cs b/xidio/xidio.Core/Models/OperatingSystemDiagnosticInfo.cs
new file mode 100644
index 0000000..0683563
--- /dev/null
+++ b/xidio/xidio.Core/Models/OperatingSystemDiagnosticInfo.cs
@@ -0,0 +1,16 @@
+namespace xidio.Core.Models;
+
+public sealed class OperatingSystemDiagnosticInfo
+{
+ public required string Family { get; init; }
+
+ public required string Description { get; init; }
+
+ public required string Version { get; init; }
+
+ public required string Architecture { get; init; }
+
+ public required string XidioVersion { get; init; }
+
+ public bool? IsElevated { get; init; }
+}
diff --git a/xidio/xidio.Core/Models/PingProbeResult.cs b/xidio/xidio.Core/Models/PingProbeResult.cs
new file mode 100644
index 0000000..11c39d1
--- /dev/null
+++ b/xidio/xidio.Core/Models/PingProbeResult.cs
@@ -0,0 +1,12 @@
+namespace xidio.Core.Models;
+
+public sealed class PingProbeResult
+{
+ public required bool Succeeded { get; init; }
+
+ public long? RoundtripTimeMilliseconds { get; init; }
+
+ public string? Status { get; init; }
+
+ public string? Error { get; init; }
+}
diff --git a/xidio/xidio.Core/Models/PppoeDiagnosticInfo.cs b/xidio/xidio.Core/Models/PppoeDiagnosticInfo.cs
new file mode 100644
index 0000000..6ba63f5
--- /dev/null
+++ b/xidio/xidio.Core/Models/PppoeDiagnosticInfo.cs
@@ -0,0 +1,10 @@
+namespace xidio.Core.Models;
+
+public sealed class PppoeDiagnosticInfo
+{
+ public required bool HasPppoeInterface { get; init; }
+
+ public required IReadOnlyList InterfaceNames { get; init; }
+
+ public required bool HasDefaultRoute { get; init; }
+}
diff --git a/xidio/xidio.Core/Models/PrimaryInterfaceInfo.cs b/xidio/xidio.Core/Models/PrimaryInterfaceInfo.cs
index 0beb7fd..9f5b1af 100644
--- a/xidio/xidio.Core/Models/PrimaryInterfaceInfo.cs
+++ b/xidio/xidio.Core/Models/PrimaryInterfaceInfo.cs
@@ -12,10 +12,20 @@ public sealed class PrimaryInterfaceInfo
public required PrimaryInterfaceKind Kind { get; init; }
+ public bool? IsEnabled { get; init; }
+
+ public required bool IsConnected { get; init; }
+
public required OperationalStatus OperationalStatus { get; init; }
public required NetworkInterfaceType NetworkInterfaceType { get; init; }
+ public string? MacAddress { get; init; }
+
+ public long? LinkSpeedBitsPerSecond { get; init; }
+
+ public int? Mtu { get; init; }
+
public WirelessConnectionInfo? WirelessConnection { get; init; }
public InterfaceNetworkDetails? NetworkDetails { get; init; }
diff --git a/xidio/xidio.Core/Models/ProbeTargetInfo.cs b/xidio/xidio.Core/Models/ProbeTargetInfo.cs
new file mode 100644
index 0000000..9807913
--- /dev/null
+++ b/xidio/xidio.Core/Models/ProbeTargetInfo.cs
@@ -0,0 +1,10 @@
+namespace xidio.Core.Models;
+
+public sealed class ProbeTargetInfo
+{
+ public required string Name { get; init; }
+
+ public required string Host { get; init; }
+
+ public required bool IsCampusTarget { get; init; }
+}
diff --git a/xidio/xidio.Core/Models/ProblemSymptom.cs b/xidio/xidio.Core/Models/ProblemSymptom.cs
new file mode 100644
index 0000000..c13b5a9
--- /dev/null
+++ b/xidio/xidio.Core/Models/ProblemSymptom.cs
@@ -0,0 +1,15 @@
+namespace xidio.Core.Models;
+
+public enum ProblemSymptom
+{
+ Unknown,
+ NoProblem,
+ CannotConnectWifi,
+ ConnectedNoInternet,
+ CaptivePortalNotShown,
+ PortalAuthenticatedNoWeb,
+ SomeApplicationsUnavailable,
+ PppoeDialFailed,
+ PppoeConnectedNoInternet,
+ Other
+}
diff --git a/xidio/xidio.Core/Models/TargetProbeResult.cs b/xidio/xidio.Core/Models/TargetProbeResult.cs
new file mode 100644
index 0000000..abb7252
--- /dev/null
+++ b/xidio/xidio.Core/Models/TargetProbeResult.cs
@@ -0,0 +1,18 @@
+namespace xidio.Core.Models;
+
+public sealed class TargetProbeResult
+{
+ public required ProbeTargetInfo Target { get; init; }
+
+ public required IReadOnlyList SystemDnsResults { get; init; }
+
+ public required IReadOnlyList DirectDnsResults { get; init; }
+
+ public required PingProbeResult Ping { get; init; }
+
+ public required IReadOnlyList TcpConnectResults { get; init; }
+
+ public HttpProbeResult? Http { get; init; }
+
+ public HttpProbeResult? Https { get; init; }
+}
diff --git a/xidio/xidio.Core/Models/TcpConnectProbeResult.cs b/xidio/xidio.Core/Models/TcpConnectProbeResult.cs
new file mode 100644
index 0000000..a1dfa93
--- /dev/null
+++ b/xidio/xidio.Core/Models/TcpConnectProbeResult.cs
@@ -0,0 +1,12 @@
+namespace xidio.Core.Models;
+
+public sealed class TcpConnectProbeResult
+{
+ public required int Port { get; init; }
+
+ public required bool Succeeded { get; init; }
+
+ public TimeSpan? Duration { get; init; }
+
+ public string? Error { get; init; }
+}
diff --git a/xidio/xidio.Core/Models/UserScenarioInfo.cs b/xidio/xidio.Core/Models/UserScenarioInfo.cs
new file mode 100644
index 0000000..9763bbd
--- /dev/null
+++ b/xidio/xidio.Core/Models/UserScenarioInfo.cs
@@ -0,0 +1,23 @@
+namespace xidio.Core.Models;
+
+public sealed class UserScenarioInfo
+{
+ public static UserScenarioInfo Unspecified { get; } = new()
+ {
+ Location = "",
+ ConnectionMethod = ConnectionMethod.Unknown,
+ AccessMethod = NetworkAccessMethod.Unknown,
+ ProblemSymptom = ProblemSymptom.Unknown,
+ ImpactScope = ImpactScope.Unknown
+ };
+
+ public required string Location { get; init; }
+
+ public required ConnectionMethod ConnectionMethod { get; init; }
+
+ public required NetworkAccessMethod AccessMethod { get; init; }
+
+ public required ProblemSymptom ProblemSymptom { get; init; }
+
+ public required ImpactScope ImpactScope { get; init; }
+}
diff --git a/xidio/xidio.Core/Models/VisibleWirelessNetworkInfo.cs b/xidio/xidio.Core/Models/VisibleWirelessNetworkInfo.cs
new file mode 100644
index 0000000..bb16bb1
--- /dev/null
+++ b/xidio/xidio.Core/Models/VisibleWirelessNetworkInfo.cs
@@ -0,0 +1,16 @@
+namespace xidio.Core.Models;
+
+public sealed class VisibleWirelessNetworkInfo
+{
+ public required string Ssid { get; init; }
+
+ public int? SignalQualityPercent { get; init; }
+
+ public string? Authentication { get; init; }
+
+ public string? Cipher { get; init; }
+
+ public int? BssidCount { get; init; }
+
+ public bool? IsConnectable { get; init; }
+}
diff --git a/xidio/xidio.Core/Models/WirelessConnectionInfo.cs b/xidio/xidio.Core/Models/WirelessConnectionInfo.cs
index 5e39a33..d3798ad 100644
--- a/xidio/xidio.Core/Models/WirelessConnectionInfo.cs
+++ b/xidio/xidio.Core/Models/WirelessConnectionInfo.cs
@@ -6,5 +6,27 @@ public sealed class WirelessConnectionInfo
public required string Bssid { get; init; }
+ public int? SignalQualityPercent { get; init; }
+
+ public int? RssiDbm { get; init; }
+
+ public int? Channel { get; init; }
+
+ public double? FrequencyGhz { get; init; }
+
+ public string? PhyType { get; init; }
+
+ public string? Authentication { get; init; }
+
+ public string? Cipher { get; init; }
+
+ public double? ReceiveRateMbps { get; init; }
+
+ public double? TransmitRateMbps { get; init; }
+
+ public TimeSpan? ConnectionDuration { get; init; }
+
+ public IReadOnlyList VisibleNetworks { get; init; } = [];
+
public string ApMac => Bssid;
}
diff --git a/xidio/xidio.Platform.Windows/WindowsPlatformNetworkDiagnosticsProvider.cs b/xidio/xidio.Platform.Windows/WindowsPlatformNetworkDiagnosticsProvider.cs
index 996734b..e3e3dd8 100644
--- a/xidio/xidio.Platform.Windows/WindowsPlatformNetworkDiagnosticsProvider.cs
+++ b/xidio/xidio.Platform.Windows/WindowsPlatformNetworkDiagnosticsProvider.cs
@@ -1,5 +1,7 @@
using System.Management;
using System.Net.NetworkInformation;
+using System.Runtime.InteropServices;
+using System.Security.Principal;
using xidio.Core.Abstractions;
using xidio.Core.Models;
@@ -7,7 +9,130 @@ namespace xidio.Platform.Windows;
public sealed class WindowsPlatformNetworkDiagnosticsProvider : IPlatformNetworkDiagnosticsProvider
{
- public IReadOnlyCollection GetPhysicalNetworkInterfaceIds()
+ public ValueTask GetOperatingSystemInfoAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return ValueTask.FromResult(new OperatingSystemDiagnosticInfo
+ {
+ Family = "Windows",
+ Description = RuntimeInformation.OSDescription,
+ Version = Environment.OSVersion.VersionString,
+ Architecture = RuntimeInformation.ProcessArchitecture.ToString(),
+ XidioVersion = "0.1.0",
+ IsElevated = IsWindowsAdministrator()
+ });
+ }
+
+ public async ValueTask> GetPhysicalNetworkInterfaceIdsAsync(CancellationToken cancellationToken)
+ {
+ return await Task.Run(() =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return GetPhysicalNetworkInterfaceIdsCore();
+ }, cancellationToken);
+ }
+
+ public async ValueTask> GetNetworkAdapterDriverInfosAsync(CancellationToken cancellationToken)
+ {
+ return await Task.Run(() =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return GetNetworkAdapterDriverInfosCore();
+ }, cancellationToken);
+ }
+
+ public async ValueTask GetInterfacePlatformInfoAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ return await Task.Run(() =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return GetInterfacePlatformInfoCore(networkInterface);
+ }, cancellationToken);
+ }
+
+ public async ValueTask GetWirelessConnectionInfoAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ if (!Guid.TryParse(networkInterface.Id, out var interfaceGuid))
+ return null;
+
+ return await Task.Run(() =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ return WindowsWlanApi.GetCurrentConnection(interfaceGuid);
+ }
+ catch (DllNotFoundException)
+ {
+ return null;
+ }
+ catch (EntryPointNotFoundException)
+ {
+ return null;
+ }
+ }, cancellationToken);
+ }
+
+ public async ValueTask GetInterfaceDhcpInfoAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ return await Task.Run(() =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return GetInterfaceDhcpInfoCore(networkInterface);
+ }, cancellationToken);
+ }
+
+ public async ValueTask> GetInterfaceMetricsAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ return await Task.Run(() =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return GetInterfaceMetricsCore(networkInterface, cancellationToken);
+ }, cancellationToken);
+ }
+
+ public async ValueTask> GetNetworkNeighborsAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ return await Task.Run(() =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return GetNetworkNeighborsCore(networkInterface, cancellationToken);
+ }, cancellationToken);
+ }
+
+ public async ValueTask> GetDefaultRoutesAsync(CancellationToken cancellationToken)
+ {
+ return await Task.Run(() =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return GetDefaultRoutesCore(cancellationToken);
+ }, cancellationToken);
+ }
+
+ public async ValueTask> GetRoutesAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ return await Task.Run(() =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return GetRoutesCore(networkInterface, cancellationToken);
+ }, cancellationToken);
+ }
+
+ private static IReadOnlyCollection GetPhysicalNetworkInterfaceIdsCore()
{
var result = new HashSet(StringComparer.OrdinalIgnoreCase);
@@ -40,7 +165,7 @@ public IReadOnlyCollection GetPhysicalNetworkInterfaceIds()
return result;
}
- public IReadOnlyList GetNetworkAdapterDriverInfos()
+ private static IReadOnlyList GetNetworkAdapterDriverInfosCore()
{
var driverVersions = GetDriverVersionsByPnpDeviceId();
var result = new List();
@@ -76,31 +201,53 @@ public IReadOnlyList GetNetworkAdapterDriverInfos()
return result;
}
- public WirelessConnectionInfo? GetWirelessConnectionInfo(NetworkInterface networkInterface)
+ private static InterfacePlatformInfo? GetInterfacePlatformInfoCore(NetworkInterface networkInterface)
{
- if (!Guid.TryParse(networkInterface.Id, out var interfaceGuid))
- return null;
+ var isEnabled = GetNetEnabled(networkInterface.Id);
+ var mtu = GetInterfaceMtu(networkInterface);
- try
- {
- return WindowsWlanApi.GetCurrentConnection(interfaceGuid);
- }
- catch (DllNotFoundException)
- {
- return null;
- }
- catch (EntryPointNotFoundException)
+ return isEnabled is null && mtu is null
+ ? null
+ : new InterfacePlatformInfo
+ {
+ IsEnabled = isEnabled,
+ Mtu = mtu
+ };
+ }
+
+ private static InterfaceDhcpInfo? GetInterfaceDhcpInfoCore(NetworkInterface networkInterface)
+ {
+ foreach (var interfaceIndex in GetInterfaceIndices(networkInterface))
{
- return null;
+ using var searcher = new ManagementObjectSearcher(
+ "SELECT DHCPEnabled, DHCPLeaseObtained, DHCPLeaseExpires " +
+ "FROM Win32_NetworkAdapterConfiguration " +
+ $"WHERE InterfaceIndex = {interfaceIndex}");
+
+ foreach (ManagementObject obj in searcher.Get())
+ {
+ return new InterfaceDhcpInfo
+ {
+ IsEnabled = GetWmiBool(obj, "DHCPEnabled"),
+ LeaseObtained = GetWmiDateTimeOffset(obj, "DHCPLeaseObtained"),
+ LeaseExpires = GetWmiDateTimeOffset(obj, "DHCPLeaseExpires")
+ };
+ }
}
+
+ return null;
}
- public IReadOnlyList GetInterfaceMetrics(NetworkInterface networkInterface)
+ private static IReadOnlyList GetInterfaceMetricsCore(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
{
var result = new List();
foreach (var interfaceIndex in GetInterfaceIndices(networkInterface))
{
+ cancellationToken.ThrowIfCancellationRequested();
+
using var searcher = new ManagementObjectSearcher(
new ManagementScope(@"\\.\ROOT\StandardCimv2"),
new ObjectQuery(
@@ -127,12 +274,92 @@ public IReadOnlyList GetInterfaceMetrics(NetworkInterface n
return result;
}
- public IReadOnlyList GetRoutes(NetworkInterface networkInterface)
+ private static IReadOnlyList GetNetworkNeighborsCore(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ var result = new List();
+
+ foreach (var interfaceIndex in GetInterfaceIndices(networkInterface))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using var searcher = new ManagementObjectSearcher(
+ new ManagementScope(@"\\.\ROOT\StandardCimv2"),
+ new ObjectQuery(
+ "SELECT IPAddress, LinkLayerAddress, AddressFamily, State " +
+ "FROM MSFT_NetNeighbor " +
+ $"WHERE InterfaceIndex = {interfaceIndex}"));
+
+ foreach (ManagementObject obj in searcher.Get())
+ {
+ var ipAddress = obj["IPAddress"]?.ToString();
+
+ if (string.IsNullOrWhiteSpace(ipAddress))
+ continue;
+
+ result.Add(new NetworkNeighborInfo
+ {
+ IpAddress = ipAddress,
+ LinkLayerAddress = obj["LinkLayerAddress"]?.ToString() ?? "",
+ AddressFamily = GetWmiInt32(obj, "AddressFamily"),
+ State = FormatNeighborState(GetWmiInt32(obj, "State"))
+ });
+ }
+ }
+
+ return result;
+ }
+
+ private static IReadOnlyList GetDefaultRoutesCore(CancellationToken cancellationToken)
+ {
+ var result = new List();
+
+ using var searcher = new ManagementObjectSearcher(
+ new ManagementScope(@"\\.\ROOT\StandardCimv2"),
+ new ObjectQuery(
+ "SELECT DestinationPrefix, NextHop, AddressFamily, InterfaceIndex, InterfaceAlias, RouteMetric, InterfaceMetric " +
+ "FROM MSFT_NetRoute " +
+ "WHERE DestinationPrefix = '0.0.0.0/0' OR DestinationPrefix = '::/0'"));
+
+ foreach (ManagementObject obj in searcher.Get())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var destinationPrefix = obj["DestinationPrefix"]?.ToString();
+
+ if (string.IsNullOrWhiteSpace(destinationPrefix))
+ continue;
+
+ result.Add(new DefaultRouteInfo
+ {
+ DestinationPrefix = destinationPrefix,
+ NextHop = obj["NextHop"]?.ToString() ?? "",
+ AddressFamily = GetWmiInt32(obj, "AddressFamily"),
+ InterfaceIndex = GetWmiInt32(obj, "InterfaceIndex"),
+ InterfaceAlias = obj["InterfaceAlias"]?.ToString() ?? "",
+ RouteMetric = GetWmiInt32(obj, "RouteMetric"),
+ InterfaceMetric = GetWmiInt32(obj, "InterfaceMetric")
+ });
+ }
+
+ return result
+ .OrderBy(route => route.AddressFamily)
+ .ThenBy(route => route.TotalMetric)
+ .ThenBy(route => route.InterfaceAlias)
+ .ToList();
+ }
+
+ private static IReadOnlyList GetRoutesCore(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
{
var result = new List();
foreach (var interfaceIndex in GetInterfaceIndices(networkInterface))
{
+ cancellationToken.ThrowIfCancellationRequested();
+
using var searcher = new ManagementObjectSearcher(
new ManagementScope(@"\\.\ROOT\StandardCimv2"),
new ObjectQuery(
@@ -229,6 +456,86 @@ private static int GetWmiInt32(ManagementBaseObject obj, string propertyName)
: 0;
}
+ private static bool? GetWmiBool(ManagementBaseObject obj, string propertyName)
+ {
+ return bool.TryParse(obj[propertyName]?.ToString(), out var value)
+ ? value
+ : null;
+ }
+
+ private static DateTimeOffset? GetWmiDateTimeOffset(ManagementBaseObject obj, string propertyName)
+ {
+ var value = obj[propertyName]?.ToString();
+
+ if (string.IsNullOrWhiteSpace(value))
+ return null;
+
+ try
+ {
+ return new DateTimeOffset(ManagementDateTimeConverter.ToDateTime(value));
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static bool? GetNetEnabled(string interfaceId)
+ {
+ if (!Guid.TryParse(interfaceId, out var interfaceGuid))
+ return null;
+
+ using var searcher = new ManagementObjectSearcher(
+ "SELECT NetEnabled FROM Win32_NetworkAdapter " +
+ $"WHERE GUID = '{{{interfaceGuid}}}'");
+
+ foreach (ManagementObject obj in searcher.Get())
+ return GetWmiBool(obj, "NetEnabled");
+
+ return null;
+ }
+
+ private static int? GetInterfaceMtu(NetworkInterface networkInterface)
+ {
+ try
+ {
+ var properties = networkInterface.GetIPProperties();
+ var ipv4Mtu = properties.GetIPv4Properties()?.Mtu;
+
+ if (ipv4Mtu is > 0)
+ return ipv4Mtu;
+
+ var ipv6Mtu = properties.GetIPv6Properties()?.Mtu;
+ return ipv6Mtu is > 0 ? ipv6Mtu : null;
+ }
+ catch (NetworkInformationException)
+ {
+ return null;
+ }
+ }
+
+ private static string FormatNeighborState(int state)
+ {
+ return state switch
+ {
+ 0 => "Unreachable",
+ 1 => "Incomplete",
+ 2 => "Probe",
+ 3 => "Delay",
+ 4 => "Stale",
+ 5 => "Reachable",
+ 6 => "Permanent",
+ _ => state.ToString()
+ };
+ }
+
+ private static bool IsWindowsAdministrator()
+ {
+ using var identity = WindowsIdentity.GetCurrent();
+ var principal = new WindowsPrincipal(identity);
+ return principal.IsInRole(WindowsBuiltInRole.Administrator);
+ }
+
private static string NormalizeGuid(string guid)
{
return guid.Trim().Trim('{', '}').ToUpperInvariant();
diff --git a/xidio/xidio.Platform.Windows/WindowsWlanApi.cs b/xidio/xidio.Platform.Windows/WindowsWlanApi.cs
index b70e005..f83d01c 100644
--- a/xidio/xidio.Platform.Windows/WindowsWlanApi.cs
+++ b/xidio/xidio.Platform.Windows/WindowsWlanApi.cs
@@ -44,10 +44,26 @@ internal static class WindowsWlanApi
if (attributes.IsState != WlanInterfaceState.Connected)
return null;
+ var ssid = DecodeSsid(attributes.AssociationAttributes.Dot11Ssid);
+ var bssid = FormatMacAddress(attributes.AssociationAttributes.Dot11Bssid);
+ var bssDetails = SafeGet(() => GetCurrentBssDetails(clientHandle, interfaceGuid, bssid));
+ var visibleNetworks = SafeGet(
+ () => GetAvailableNetworks(clientHandle, interfaceGuid)) ?? [];
+
return new WirelessConnectionInfo
{
- Ssid = DecodeSsid(attributes.AssociationAttributes.Dot11Ssid),
- Bssid = FormatMacAddress(attributes.AssociationAttributes.Dot11Bssid)
+ Ssid = ssid,
+ Bssid = bssid,
+ SignalQualityPercent = (int)attributes.AssociationAttributes.WlanSignalQuality,
+ RssiDbm = bssDetails?.RssiDbm ?? EstimateRssi(attributes.AssociationAttributes.WlanSignalQuality),
+ Channel = bssDetails?.Channel,
+ FrequencyGhz = bssDetails?.FrequencyGhz,
+ PhyType = FormatPhyType(attributes.AssociationAttributes.Dot11PhyType),
+ Authentication = FormatAuthAlgorithm(attributes.SecurityAttributes.Dot11AuthAlgorithm),
+ Cipher = FormatCipherAlgorithm(attributes.SecurityAttributes.Dot11CipherAlgorithm),
+ ReceiveRateMbps = attributes.AssociationAttributes.RxRate / 1000d,
+ TransmitRateMbps = attributes.AssociationAttributes.TxRate / 1000d,
+ VisibleNetworks = visibleNetworks
};
}
finally
@@ -61,6 +77,107 @@ internal static class WindowsWlanApi
}
}
+ private static T? SafeGet(Func getValue)
+ {
+ try
+ {
+ return getValue();
+ }
+ catch
+ {
+ return default;
+ }
+ }
+
+ private static IReadOnlyList GetAvailableNetworks(IntPtr clientHandle, Guid interfaceGuid)
+ {
+ var queryResult = WlanGetAvailableNetworkList(
+ clientHandle,
+ ref interfaceGuid,
+ 0,
+ IntPtr.Zero,
+ out var listPointer);
+
+ if (queryResult != ErrorSuccess || listPointer == IntPtr.Zero)
+ return Array.Empty();
+
+ try
+ {
+ var numberOfItems = Marshal.ReadInt32(listPointer);
+ var itemPointer = listPointer + 8;
+ var itemSize = Marshal.SizeOf();
+ var networks = new List();
+ var seen = new HashSet(StringComparer.Ordinal);
+
+ for (var index = 0; index < numberOfItems; index++)
+ {
+ var network = Marshal.PtrToStructure(itemPointer + index * itemSize);
+ var ssid = DecodeSsid(network.Dot11Ssid);
+
+ if (string.IsNullOrWhiteSpace(ssid) || !seen.Add(ssid))
+ continue;
+
+ networks.Add(new VisibleWirelessNetworkInfo
+ {
+ Ssid = ssid,
+ SignalQualityPercent = (int)network.WlanSignalQuality,
+ Authentication = FormatAuthAlgorithm(network.Dot11DefaultAuthAlgorithm),
+ Cipher = FormatCipherAlgorithm(network.Dot11DefaultCipherAlgorithm),
+ BssidCount = (int)network.NumberOfBssids,
+ IsConnectable = network.NetworkConnectable != 0
+ });
+ }
+
+ return networks;
+ }
+ finally
+ {
+ WlanFreeMemory(listPointer);
+ }
+ }
+
+ private static BssDetails? GetCurrentBssDetails(IntPtr clientHandle, Guid interfaceGuid, string currentBssid)
+ {
+ var queryResult = WlanGetNetworkBssList(
+ clientHandle,
+ ref interfaceGuid,
+ IntPtr.Zero,
+ Dot11BssType.Any,
+ false,
+ IntPtr.Zero,
+ out var listPointer);
+
+ if (queryResult != ErrorSuccess || listPointer == IntPtr.Zero)
+ return null;
+
+ try
+ {
+ var numberOfItems = Marshal.ReadInt32(listPointer);
+ var itemPointer = listPointer + 8;
+ var itemSize = Marshal.SizeOf();
+
+ for (var index = 0; index < numberOfItems; index++)
+ {
+ var entry = Marshal.PtrToStructure(itemPointer + index * itemSize);
+ var bssid = FormatMacAddress(entry.Dot11Bssid);
+
+ if (!string.Equals(bssid, currentBssid, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ return new BssDetails(
+ entry.Rssi,
+ FrequencyKHzToGhz(entry.ChCenterFrequency),
+ FrequencyKHzToChannel(entry.ChCenterFrequency));
+ }
+
+ return null;
+ }
+ finally
+ {
+ WlanFreeMemory(listPointer);
+ }
+ }
+
private static string DecodeSsid(Dot11Ssid dot11Ssid)
{
var bytes = dot11Ssid.Ssid ?? [];
@@ -88,6 +205,85 @@ private static string FormatMacAddress(byte[] macAddress)
return string.Join(":", macAddress.Select(static b => b.ToString("X2")));
}
+ private static int EstimateRssi(uint signalQuality)
+ {
+ return (int)(signalQuality / 2) - 100;
+ }
+
+ private static double? FrequencyKHzToGhz(uint frequencyKHz)
+ {
+ return frequencyKHz == 0 ? null : Math.Round(frequencyKHz / 1_000_000d, 3);
+ }
+
+ private static int? FrequencyKHzToChannel(uint frequencyKHz)
+ {
+ if (frequencyKHz == 0)
+ return null;
+
+ var frequencyMHz = frequencyKHz / 1000;
+
+ if (frequencyMHz == 2484)
+ return 14;
+ if (frequencyMHz is >= 2412 and <= 2472)
+ return (int)((frequencyMHz - 2407) / 5);
+ if (frequencyMHz is >= 5000 and <= 5900)
+ return (int)((frequencyMHz - 5000) / 5);
+ if (frequencyMHz is >= 5955 and <= 7115)
+ return (int)((frequencyMHz - 5950) / 5);
+
+ return null;
+ }
+
+ private static string FormatPhyType(Dot11PhyType phyType)
+ {
+ return phyType switch
+ {
+ Dot11PhyType.Fhss => "FHSS",
+ Dot11PhyType.Dsss => "DSSS",
+ Dot11PhyType.Ofdm => "802.11a/g",
+ Dot11PhyType.Hrdsss => "HR-DSSS",
+ Dot11PhyType.Erp => "802.11g",
+ Dot11PhyType.Ht => "802.11n",
+ Dot11PhyType.Vht => "802.11ac",
+ Dot11PhyType.He => "802.11ax",
+ Dot11PhyType.Eht => "802.11be",
+ _ => phyType.ToString()
+ };
+ }
+
+ private static string FormatAuthAlgorithm(Dot11AuthAlgorithm authAlgorithm)
+ {
+ return authAlgorithm switch
+ {
+ Dot11AuthAlgorithm.Open => "Open",
+ Dot11AuthAlgorithm.SharedKey => "Shared",
+ Dot11AuthAlgorithm.Wpa => "WPA",
+ Dot11AuthAlgorithm.WpaPsk => "WPA-PSK",
+ Dot11AuthAlgorithm.Rsna => "WPA2",
+ Dot11AuthAlgorithm.RsnaPsk => "WPA2-PSK",
+ Dot11AuthAlgorithm.Wpa3 => "WPA3",
+ Dot11AuthAlgorithm.Wpa3Sae => "WPA3-SAE",
+ _ => authAlgorithm.ToString()
+ };
+ }
+
+ private static string FormatCipherAlgorithm(Dot11CipherAlgorithm cipherAlgorithm)
+ {
+ return cipherAlgorithm switch
+ {
+ Dot11CipherAlgorithm.None => "None",
+ Dot11CipherAlgorithm.Wep40 => "WEP-40",
+ Dot11CipherAlgorithm.Tkip => "TKIP",
+ Dot11CipherAlgorithm.Ccmp => "CCMP",
+ Dot11CipherAlgorithm.Wep104 => "WEP-104",
+ Dot11CipherAlgorithm.Wep => "WEP",
+ Dot11CipherAlgorithm.Gcmp => "GCMP",
+ Dot11CipherAlgorithm.Gcmp256 => "GCMP-256",
+ Dot11CipherAlgorithm.Ccmp256 => "CCMP-256",
+ _ => cipherAlgorithm.ToString()
+ };
+ }
+
[DllImport("wlanapi.dll")]
private static extern uint WlanOpenHandle(
uint dwClientVersion,
@@ -113,6 +309,24 @@ private static extern uint WlanQueryInterface(
out IntPtr ppData,
out WlanOpcodeValueType pWlanOpcodeValueType);
+ [DllImport("wlanapi.dll")]
+ private static extern uint WlanGetAvailableNetworkList(
+ IntPtr hClientHandle,
+ ref Guid pInterfaceGuid,
+ uint dwFlags,
+ IntPtr pReserved,
+ out IntPtr ppAvailableNetworkList);
+
+ [DllImport("wlanapi.dll")]
+ private static extern uint WlanGetNetworkBssList(
+ IntPtr hClientHandle,
+ ref Guid pInterfaceGuid,
+ IntPtr pDot11Ssid,
+ Dot11BssType dot11BssType,
+ bool bSecurityEnabled,
+ IntPtr pReserved,
+ out IntPtr ppWlanBssList);
+
private enum WlanIntfOpcode
{
CurrentConnection = 7
@@ -167,6 +381,9 @@ private enum Dot11PhyType
Erp = 6,
Ht = 7,
Vht = 8,
+ Dmg = 9,
+ He = 10,
+ Eht = 11,
IhvStart = unchecked((int)0x80000000),
IhvEnd = unchecked((int)0xffffffff)
}
@@ -180,6 +397,8 @@ private enum Dot11AuthAlgorithm
WpaNone = 5,
Rsna = 6,
RsnaPsk = 7,
+ Wpa3 = 8,
+ Wpa3Sae = 9,
IhvStart = unchecked((int)0x80000000),
IhvEnd = unchecked((int)0xffffffff)
}
@@ -194,6 +413,9 @@ private enum Dot11CipherAlgorithm
WpaUseGroup = 0x100,
RsnUseGroup = 0x100,
Wep = 0x101,
+ Gcmp = 0x08,
+ Gcmp256 = 0x09,
+ Ccmp256 = 0x0a,
IhvStart = unchecked((int)0x80000000),
IhvEnd = unchecked((int)0xffffffff)
}
@@ -244,4 +466,64 @@ private struct WlanConnectionAttributes
public WlanAssociationAttributes AssociationAttributes;
public WlanSecurityAttributes SecurityAttributes;
}
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct WlanAvailableNetwork
+ {
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = WlanMaxNameLength)]
+ public string ProfileName;
+
+ public Dot11Ssid Dot11Ssid;
+ public Dot11BssType Dot11BssType;
+ public uint NumberOfBssids;
+ public int NetworkConnectable;
+ public uint WlanNotConnectableReason;
+ public uint NumberOfPhyTypes;
+
+ [MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)]
+ public Dot11PhyType[] Dot11PhyTypes;
+
+ public int MorePhyTypes;
+ public uint WlanSignalQuality;
+ public int SecurityEnabled;
+ public Dot11AuthAlgorithm Dot11DefaultAuthAlgorithm;
+ public Dot11CipherAlgorithm Dot11DefaultCipherAlgorithm;
+ public uint Flags;
+ public uint Reserved;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct WlanRateSet
+ {
+ public uint RateSetLength;
+
+ [MarshalAs(UnmanagedType.ByValArray, SizeConst = 126)]
+ public ushort[] RateSet;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct WlanBssEntry
+ {
+ public Dot11Ssid Dot11Ssid;
+ public uint PhyId;
+
+ [MarshalAs(UnmanagedType.ByValArray, SizeConst = 6)]
+ public byte[] Dot11Bssid;
+
+ public Dot11BssType Dot11BssType;
+ public Dot11PhyType Dot11BssPhyType;
+ public int Rssi;
+ public uint LinkQuality;
+ public byte InRegDomain;
+ public ushort BeaconPeriod;
+ public ulong Timestamp;
+ public ulong HostTimestamp;
+ public ushort CapabilityInformation;
+ public uint ChCenterFrequency;
+ public WlanRateSet WlanRateSet;
+ public uint IeOffset;
+ public uint IeSize;
+ }
+
+ private sealed record BssDetails(int RssiDbm, double? FrequencyGhz, int? Channel);
}
diff --git a/xidio/xidio.Platform.macOS/MacPlatformNetworkDiagnosticsProvider.cs b/xidio/xidio.Platform.macOS/MacPlatformNetworkDiagnosticsProvider.cs
index 288dd3c..baff225 100644
--- a/xidio/xidio.Platform.macOS/MacPlatformNetworkDiagnosticsProvider.cs
+++ b/xidio/xidio.Platform.macOS/MacPlatformNetworkDiagnosticsProvider.cs
@@ -8,9 +8,14 @@ namespace xidio.Platform.macOS;
public sealed class MacPlatformNetworkDiagnosticsProvider : IPlatformNetworkDiagnosticsProvider
{
- public IReadOnlyCollection GetPhysicalNetworkInterfaceIds()
+ public ValueTask GetOperatingSystemInfoAsync(CancellationToken cancellationToken)
{
- var output = RunCommand("/usr/sbin/networksetup", "-listallhardwareports");
+ return ValueTask.FromResult(null);
+ }
+
+ public async ValueTask> GetPhysicalNetworkInterfaceIdsAsync(CancellationToken cancellationToken)
+ {
+ var output = await RunCommandAsync("/usr/sbin/networksetup", "-listallhardwareports", cancellationToken);
if (output is null)
return Array.Empty();
@@ -29,13 +34,13 @@ public IReadOnlyCollection GetPhysicalNetworkInterfaceIds()
return ids;
}
- public IReadOnlyList GetNetworkAdapterDriverInfos()
+ public async ValueTask> GetNetworkAdapterDriverInfosAsync(CancellationToken cancellationToken)
{
- var hardwarePorts = ParseHardwarePorts();
+ var hardwarePorts = await ParseHardwarePortsAsync(cancellationToken);
if (hardwarePorts.Count == 0)
return Array.Empty();
- var networkData = RunCommand("/usr/sbin/system_profiler", "SPNetworkDataType");
+ var networkData = await RunCommandAsync("/usr/sbin/system_profiler", "SPNetworkDataType", cancellationToken);
var driverVersions = networkData is not null
? ParseNetworkDriverVersions(networkData)
: new Dictionary(StringComparer.OrdinalIgnoreCase);
@@ -43,6 +48,7 @@ public IReadOnlyList GetNetworkAdapterDriverInfos()
var result = new List();
foreach (var (device, portName) in hardwarePorts)
{
+ cancellationToken.ThrowIfCancellationRequested();
driverVersions.TryGetValue(device, out var driverVersion);
result.Add(new NetworkAdapterDriverInfo
@@ -56,13 +62,23 @@ public IReadOnlyList GetNetworkAdapterDriverInfos()
return result;
}
- public WirelessConnectionInfo? GetWirelessConnectionInfo(NetworkInterface networkInterface)
+ public ValueTask GetInterfacePlatformInfoAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ return ValueTask.FromResult(null);
+ }
+
+ public async ValueTask GetWirelessConnectionInfoAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
{
try
{
- var output = RunCommand(
+ var output = await RunCommandAsync(
"/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport",
- "-I");
+ "-I",
+ cancellationToken);
if (output is null)
return null;
@@ -72,6 +88,8 @@ public IReadOnlyList GetNetworkAdapterDriverInfos()
foreach (var line in output.Split('\n'))
{
+ cancellationToken.ThrowIfCancellationRequested();
+
var trimmed = line.Trim();
if (trimmed.StartsWith("SSID:", StringComparison.OrdinalIgnoreCase))
{
@@ -95,18 +113,31 @@ public IReadOnlyList GetNetworkAdapterDriverInfos()
Bssid = bssid
};
}
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
catch
{
return null;
}
}
- public IReadOnlyList GetInterfaceMetrics(NetworkInterface networkInterface)
+ public ValueTask GetInterfaceDhcpInfoAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
+ {
+ return ValueTask.FromResult(null);
+ }
+
+ public async ValueTask> GetInterfaceMetricsAsync(
+ NetworkInterface networkInterface,
+ CancellationToken cancellationToken)
{
var result = new List