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 @@

- xidio Logo
xidio Logo @@ -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(); var interfaceId = networkInterface.Id; - var v4Metrics = GetInterfaceMetricFromRoutes(interfaceId, "inet"); + var v4Metrics = await GetInterfaceMetricFromRoutesAsync(interfaceId, "inet", cancellationToken); if (v4Metrics.HasValue) { result.Add(new InterfaceMetricInfo @@ -116,7 +147,7 @@ public IReadOnlyList GetInterfaceMetrics(NetworkInterface n }); } - var v6Metrics = GetInterfaceMetricFromRoutes(interfaceId, "inet6"); + var v6Metrics = await GetInterfaceMetricFromRoutesAsync(interfaceId, "inet6", cancellationToken); if (v6Metrics.HasValue) { result.Add(new InterfaceMetricInfo @@ -129,24 +160,39 @@ public IReadOnlyList GetInterfaceMetrics(NetworkInterface n return result; } - public IReadOnlyList GetRoutes(NetworkInterface networkInterface) + public async ValueTask> GetRoutesAsync( + NetworkInterface networkInterface, + CancellationToken cancellationToken) { var result = new List(); var interfaceId = networkInterface.Id; - result.AddRange(ParseNetstatRoutes(interfaceId, "inet", (int)AddressFamily.InterNetwork)); - result.AddRange(ParseNetstatRoutes(interfaceId, "inet6", (int)AddressFamily.InterNetworkV6)); + result.AddRange(await ParseNetstatRoutesAsync(interfaceId, "inet", (int)AddressFamily.InterNetwork, cancellationToken)); + result.AddRange(await ParseNetstatRoutesAsync(interfaceId, "inet6", (int)AddressFamily.InterNetworkV6, cancellationToken)); return result; } - private static List ParseNetstatRoutes( + public ValueTask> GetNetworkNeighborsAsync( + NetworkInterface networkInterface, + CancellationToken cancellationToken) + { + return ValueTask.FromResult>(Array.Empty()); + } + + public ValueTask> GetDefaultRoutesAsync(CancellationToken cancellationToken) + { + return ValueTask.FromResult>(Array.Empty()); + } + + private static async Task> ParseNetstatRoutesAsync( string interfaceId, string addressFamilyFlag, - int addressFamily) + int addressFamily, + CancellationToken cancellationToken) { var routes = new List(); - var output = RunCommand("/usr/sbin/netstat", $"-rn -f {addressFamilyFlag}"); + var output = await RunCommandAsync("/usr/sbin/netstat", $"-rn -f {addressFamilyFlag}", cancellationToken); if (output is null) return routes; @@ -155,6 +201,8 @@ private static List ParseNetstatRoutes( foreach (var line in lines) { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(line)) { if (inTable) @@ -177,7 +225,6 @@ private static List ParseNetstatRoutes( var destination = parts[0]; var gateway = parts[1]; - var flags = parts[2]; var netif = parts[3]; if (!string.Equals(netif, interfaceId, StringComparison.OrdinalIgnoreCase) && @@ -197,9 +244,12 @@ private static List ParseNetstatRoutes( return routes; } - private static int? GetInterfaceMetricFromRoutes(string interfaceId, string addressFamilyFlag) + private static async Task GetInterfaceMetricFromRoutesAsync( + string interfaceId, + string addressFamilyFlag, + CancellationToken cancellationToken) { - var output = RunCommand("/usr/sbin/netstat", $"-rn -f {addressFamilyFlag}"); + var output = await RunCommandAsync("/usr/sbin/netstat", $"-rn -f {addressFamilyFlag}", cancellationToken); if (output is null) return null; @@ -208,6 +258,8 @@ private static List ParseNetstatRoutes( foreach (var line in lines) { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(line)) { if (inTable) @@ -239,16 +291,18 @@ private static List ParseNetstatRoutes( return null; } - private static Dictionary ParseHardwarePorts() + private static async Task> ParseHardwarePortsAsync(CancellationToken cancellationToken) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - var output = RunCommand("/usr/sbin/networksetup", "-listallhardwareports"); + var output = await RunCommandAsync("/usr/sbin/networksetup", "-listallhardwareports", cancellationToken); if (output is null) return result; string? currentPort = null; foreach (var line in output.Split('\n')) { + cancellationToken.ThrowIfCancellationRequested(); + var trimmed = line.Trim(); if (trimmed.StartsWith("Hardware Port:", StringComparison.OrdinalIgnoreCase)) @@ -299,29 +353,50 @@ private static Dictionary ParseNetworkDriverVersions(string syst return result; } - private static string? RunCommand(string command, string arguments) + private static async Task RunCommandAsync( + string command, + string arguments, + CancellationToken cancellationToken) { - try + using var process = new Process(); + process.StartInfo = new ProcessStartInfo { - using var process = new Process - { - StartInfo = new ProcessStartInfo - { - FileName = command, - Arguments = arguments, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - } - }; + FileName = command, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + try + { process.Start(); - var output = process.StandardOutput.ReadToEnd(); - process.WaitForExit(5000); + + var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + await process.WaitForExitAsync(cancellationToken); + + var output = await outputTask; + await errorTask; return process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output) ? output : null; } + catch (OperationCanceledException) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch + { + // ignored + } + + throw; + } catch { return null;