diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index eb0cf13..7279f78 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,visualstudio,rider,csharp -# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,visualstudio,rider,csharp +# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,direnv,visualstudio,visualstudiocode,rider,csharp +# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,direnv,visualstudio,visualstudiocode,rider,csharp ### Csharp ### ## Ignore Visual Studio temporary files, build results, and @@ -399,6 +399,9 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +### direnv ### +/.direnv/ + ### Linux ### # temporary files which can be created if a process still has a handle open of a deleted file @@ -525,6 +528,19 @@ fabric.properties # Android studio 3.1+ serialized cache file .idea/caches/build_file_checksums.ser +### VisualStudioCode ### +!.vscode/*.code-snippets + +# Local History for Visual Studio Code + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + ### Windows ### # Windows thumbnail cache files Thumbs.db @@ -727,6 +743,6 @@ $RECYCLE.BIN/ ### VisualStudio Patch ### # Additional files built by Visual Studio -# End of https://www.toptal.com/developers/gitignore/api/windows,linux,macos,visualstudio,rider,csharp +# End of https://www.toptal.com/developers/gitignore/api/windows,linux,macos,direnv,visualstudio,visualstudiocode,rider,csharp *.lscache diff --git a/AGENTS.md b/AGENTS.md index 5610418..f852932 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,146 +1,46 @@ # xidio Agents Instructions -xidio(XiDian Internet Diagnostic Intelligence Operator)是一款跨平台的校园网诊断工具,由 Project Hazelita 基于 C# 和 .NET 开发,专门针对西安电子科技大学的网络环境。 +xidio(Xidian Internet Diagnostic Intelligence Operator)是 Project Hazelita 基于 C# 和 .NET 10 开发的跨平台西电校园网诊断工具。 -在为 xidio 贡献代码前,请先阅读 `README.md` 了解项目最新情况,并仔细阅读本文件、遵循下面的指引。请确保你的修改是**最小化**的,并且与项目的现有内容保持一致、互不冲突。 +## 开始工作前 -## 各部分功能 +1. 阅读 `README.md` 了解当前能力与构建方式。 +2. 阅读 `docs/architecture.md` 和修改模块的相关文档。 +3. 对照 `ROADMAP.md` 确认需求边界;路线图是开发方向,不代表功能已实现。 +4. 检查当前分支和工作区,保留用户的现有修改。 -`xidio.Core` 主要负责诊断、修复和上报。 +## 修改原则 -### 诊断 / Diagnostics +- 保持修改最小化,避免与任务无关的重构、格式化或依赖升级。 +- 优先使用 .NET 跨平台 API;平台专用能力必须放入对应的 `xidio.Platform.*` Provider。 +- 不得在 `xidio.Core` 中使用 `Console`、Spectre.Console、Avalonia 或其他 UI API。 +- `xidio.Core` 应输出结构化数据,并明确分离诊断、修复和上报逻辑。 +- 所有 I/O 与长时间操作使用异步 API,接受 `CancellationToken`,必要时报告结构化进度。 +- 探测失败也是诊断数据;保留目标、时间、耗时、错误类型与必要原始信息,不得只返回布尔值。 +- 未获得用户明确同意时,不收集 hosts、代理、VPN、防火墙、安全软件、账号业务状态等敏感信息,不执行修复。 +- MAC、BSSID、账号、在线设备与日志中的个人信息默认脱敏;任何上报都必须 opt-in 且可预览。 -xidio 需要诊断用户设备,确定网络问题的根源。诊断过程分为**信息收集**和**分析**两步。 +## 架构边界 -#### 收集信息 +- `xidio.Core`:诊断、分析、修复、上报的抽象、模型与通用逻辑。 +- `xidio.CLI`:终端交互、进度展示、结果渲染与平台 Provider 组装。 +- `xidio.Platform.Windows`:Windows 系统 API、WLAN、RAS/PPPoE 及其他 Windows 专用收集。 +- `xidio.Platform.macOS`:macOS 系统命令与 API 适配。 +- `xidio.Platform.Linux`:Linux 系统 API,以及 `ip`、`resolvectl`、`nmcli`、`iw` 等工具的可控适配。 -xidio 将从用户及其设备上收集必要信息。以下列出部分需要采集的信息类别。 +新增平台信息时,先在 Core 定义平台无关的模型和接口,再在各 Provider 中实现。单一平台不支持某项能力时,应返回明确的“不支持/不可用”结果,不得伪造数据或导致整份报告失败。 -##### 用户场景信息 +## 验证 -xidio 会主动询问: +- 根据修改范围运行最小相关构建或测试,再考虑完整解决方案构建。 +- Windows:`dotnet build xidio/xidio.slnx -c Release`。 +- Linux/macOS:`dotnet build xidio/xidio.CLI/xidio.CLI.csproj -c Release -f net10.0`。 +- 平台专用修改必须说明实际验证平台;不得把交叉编译当作真实系统行为验证。 +- 对外部命令的解析应覆盖空输出、命令不存在、非零退出码、本地化输出和权限不足。 -1. 用户所在位置。 -2. 当前连接方式。连接方式划分为: - - **直接连接**:用户直接通过 Wi-Fi 连接校内任意 AP,或通过网口以 PPPoE 方式连接。 - - **间接连接**:用户通过路由器等方式接入,数据经校园网设施传输。 - - **其他连接**:用户通过手机热点等方式连接,数据未经过校园网设施。 -3. 遇到的问题。例如:无法连接 Wi-Fi;已连接但显示“无 Internet”;未跳转到 Portal;Portal 认证成功但无法打开网页;部分应用不能使用;拨号失败;拨号成功但无 Internet 等。 -4. 问题波及范围。例如:仅本机有问题;同宿舍其他人也有问题;同楼层其他人也有问题;不清楚。 +## Git 与文档 -这些信息有助于网管会判断是否为区域性问题。 - -##### 系统与网卡基础信息 - -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 等。 +- 遵循 Git Flow:功能与普通修复从 `develop` 分支开始,已发布版本的紧急修复从 `main` 建立 `hotfix/*`。 +- 提交信息遵循 Conventional Commits,推荐使用精确 scope,例如 `feat(linux): collect wireless details`。 +- 不要重写、压缩或删除用户现有提交,除非用户明确要求。 +- 功能范围或计划变化时更新 `ROADMAP.md`;用户可见的使用、构建或平台状态变化时同步更新 `README.md` 和 `CHANGELOG.md`。 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6153e41 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +本项目的所有重要变更都将记录在此文件中。 + +本文档格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。 + +## [Unreleased] + +## [0.1.1] - 2026-07-11 + +### Added + +- 增加 Linux 平台 Provider,支持系统与权限、物理网卡与驱动、Wi-Fi、DHCP、路由、metric、默认路由和邻居表信息收集。 +- 增加 Nix flake、锁文件与 direnv 集成,为 Linux 开发环境提供 .NET 10、`iproute2`、NetworkManager 和 `iw`。 +- 增加 Roadmap、Contributing、MIT License 与完整的项目文档。 +- 增加项目图标与 Logo 的 PNG 资源。 + +### Changed + +- 重写 `AGENTS.md`,将开发路线与诊断范围迁移至 `ROADMAP.md`。 +- 根据当前 nixpkgs 支持范围,Nix devShell 支持 `x86_64-linux`、`aarch64-linux` 和 `aarch64-darwin`。 + +### Fixed + +- 修复未征得用户同意即收集系统代理信息的隐私问题。 +- 修复 CLI 原样显示 MAC、BSSID 与邻居链路层地址的问题,默认隐藏设备标识字节。 + +## [0.1.0] - 2026-05-22 + +### Added + +- 增加交互式 CLI,用于收集用户场景并展示网络诊断报告。 +- 增加 `xidio.Core` 诊断抽象、结构化模型、进度报告和可取消的信息收集流程。 +- 增加 Windows 平台网络诊断 Provider,覆盖网络接口、地址、网关、DHCP、DNS、metric、路由和 Wi-Fi 信息。 +- 增加 macOS 平台网络诊断 Provider 的初始实现。 +- 增加项目 Logo、图标与 Figma 设计源文件。 +- 增加 CI、标签发布工作流与 Nerdbank.GitVersioning 版本管理。 + +[0.1.0]: https://github.com/LyCecilion/xidio/releases/tag/v0.1.0 +[0.1.1]: https://github.com/LyCecilion/xidio/compare/v0.1.0...v0.1.1 +[Unreleased]: https://github.com/LyCecilion/xidio/compare/v0.1.1...HEAD diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a0e6d28 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# Contributing to xidio + +感谢你愿意帮助 xidio 更准确地诊断西电校园网问题。我们接受 Issue 和 Pull Request;不论是问题复现、平台适配、诊断规则还是文档改进,都欢迎参与。 + +## 提交 Issue + +在新建 Issue 前,请先搜索是否已有相同问题。报告 Bug 时建议包含: + +- xidio 版本、操作系统与系统版本; +- 连接方式与可稳定复现的步骤; +- 期望结果、实际结果与必要日志; +- 是否以管理员或 root 权限运行。 + +发布日志、截图或诊断报告前,请删除账号、密码、完整 MAC/BSSID、IP 等不必要的个人或网络标识信息。 + +## 提交 Pull Request + +1. 从最新的 `develop` 分支创建短期分支:新功能使用 `feature/`,普通修复使用 `fix/`。仅针对已发布版本的紧急修复从 `main` 创建 `hotfix/`。 +2. 保持修改最小化,不要在同一 PR 中混入无关重构。 +3. 提交信息遵循 [Conventional Commits](https://www.conventionalcommits.org/),例如 `feat(linux): collect default route` 或 `fix(core): preserve probe cancellation`。 +4. 根据修改所在平台运行构建和相关测试,并在 PR 中说明未能验证的平台。 +5. 默认将 PR 提交到 `develop`,在描述中关联 Issue,并说明用户可见变化、验证方法与隐私影响。 + +## 开发约定 + +- 先阅读 [`README.md`](./README.md)、[`AGENTS.md`](./AGENTS.md) 与 [`docs/architecture.md`](./docs/architecture.md)。 +- `xidio.Core` 不得依赖 CLI/GUI,应输出结构化数据,并为长任务提供异步、进度与取消支持。 +- 平台专用能力放入相应的 `xidio.Platform.*` 项目,通过 Core 中的抽象调用。 +- 收集敏感信息或执行修复前必须获得用户明确同意;上报始终保持 opt-in。 + +## 构建 + +Windows: + +```powershell +dotnet restore xidio/xidio.slnx +dotnet build xidio/xidio.slnx -c Release --no-restore +``` + +Linux 或 macOS: + +```bash +dotnet restore xidio/xidio.CLI/xidio.CLI.csproj +dotnet build xidio/xidio.CLI/xidio.CLI.csproj -c Release -f net10.0 --no-restore +``` + +更多开发环境信息见 [`README.md`](./README.md#-development)。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c17d067 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Project Hazelita + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 54877af..7ed0995 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - +
@@ -17,6 +17,112 @@ _✨**Xidian Internet Diagnostic Intelligence Operator**✨_\
-## 简介 +## 📖 About -xidio _(XiDian Internet Diagnostic Intelligence Operator)_ 是一款基于 C# 和 .NET 架构的、由 Project Hazelita 开发的、跨平台的适用于西安电子科技大学的校园网诊断工具。xidio 以 [NanCunChild](https://github.com/NanCunChild) 的项目 [Marduk](https://github.com/NanCunChild/Marduk) 为灵感来源,同时具有 CLI 和 GUI 两种用户界面,致力于成为一款简单易用且足够强大的辅助工具。 +xidio _(Xidian Internet Diagnostic Intelligence Operator)_ 是一款由 Project Hazelita 开发的西安电子科技大学校园网诊断工具。项目基于 C# 和 .NET 10,面向 Windows、Linux 与 macOS,希望将分散的网卡、IP、DHCP、DNS、路由、Wi-Fi 及主动探测信息整理为可理解、可上报的诊断结果。 + +xidio 的灵感来自 [NanCunChild](https://github.com/NanCunChild) 的 [Marduk](https://github.com/NanCunChild/Marduk)。项目当前处于早期开发阶段:v0.1.1 已提供 CLI 与 Windows、Linux、macOS 平台 Provider,完整分析、修复与上报功能仍在实现中。 + +## ✨ Features + +- **交互式场景问询**:通过 CLI 收集位置、连接方式、问题现象与影响范围,为后续判断补充用户上下文。 +- **分层网络信息收集**:收集操作系统、时间、网络接口、IP、DHCP、DNS、默认路由、邻居表和主动探测结果。 +- **Windows 深度适配**:利用 Windows 系统能力获取网卡、Wi-Fi、路由、代理与 PPPoE 等诊断信息。 +- **跨平台架构**:通用诊断逻辑位于 `xidio.Core`,平台差异由独立 Provider 隔离;当前已有 Windows、Linux 和 macOS Provider。 +- **结构化与可取消**:核心诊断输出结构化数据,长时间任务支持进度报告和 `CancellationToken`。 + +## 🚀 Quick Start + +当前版本需要 [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)。从源码运行 CLI: + +```bash +git clone https://github.com/LyCecilion/xidio.git +cd xidio +dotnet restore xidio/xidio.CLI/xidio.CLI.csproj +dotnet run --project xidio/xidio.CLI/xidio.CLI.csproj -f net10.0 +``` + +Windows 上需要调用 Windows 平台 Provider 时,请将最后的目标框架改为 `net10.0-windows`。部分系统信息需要管理员或 root 权限;仅在信任项目源码且确有需要时提权运行。 + +## 📁 Project Structure + +```text +xidio/ +├── assets/ # Logo、图标与设计源文件 +├── docs/ # 架构与核心开发准则 +├── xidio/ +│ ├── xidio.CLI/ # 命令行入口与交互式报告 +│ ├── xidio.Core/ # 诊断模型、收集、分析与抽象 +│ ├── xidio.Platform.Linux/ # Linux 平台 Provider +│ ├── xidio.Platform.Windows/ # Windows 平台 Provider +│ ├── xidio.Platform.macOS/ # macOS 平台 Provider +│ └── xidio.slnx # .NET 解决方案 +├── AGENTS.md # 仓库内 AI Agent 协作约定 +├── CONTRIBUTING.md # 贡献指南 +├── ROADMAP.md # 开发路线与诊断范围 +└── flake.nix # 可选的 Nix 开发环境 +``` + +## 💻 Development + +通用要求: + +- 使用 `global.json` 声明的 .NET 10 SDK;代码风格由 `Directory.Build.props` 中的 .NET Analyzer 规则约束。 +- 修改前阅读 [`AGENTS.md`](./AGENTS.md)、[`docs/architecture.md`](./docs/architecture.md) 和相关模块文档。 +- 分支遵循 Git Flow,提交信息遵循 [Conventional Commits](https://www.conventionalcommits.org/)。 + +### Windows + +安装 .NET 10 SDK,使用 PowerShell 运行: + +```powershell +dotnet restore xidio/xidio.slnx +dotnet build xidio/xidio.slnx -c Debug +dotnet run --project xidio/xidio.CLI/xidio.CLI.csproj -f net10.0-windows +``` + +### Linux + +可以从发行版安装 .NET 10 SDK,也可使用 Nix。原生 SDK 环境下: + +Linux Provider 会按需调用 `ip`、`nmcli` 和 `iw`;建议安装 `iproute2`、NetworkManager 与 `iw`。命令不存在时,xidio 仍会返回 .NET 与 `/sys` 可获取的其他诊断信息。 + +```bash +dotnet restore xidio/xidio.CLI/xidio.CLI.csproj +dotnet build xidio/xidio.CLI/xidio.CLI.csproj -c Debug -f net10.0 +``` + +使用 Nix 时,无需全局安装 .NET SDK: + +```bash +nix develop +dotnet build xidio/xidio.CLI/xidio.CLI.csproj -c Debug -f net10.0 +``` + +### macOS + +安装 .NET 10 SDK 后运行: + +```bash +dotnet restore xidio/xidio.CLI/xidio.CLI.csproj +dotnet build xidio/xidio.CLI/xidio.CLI.csproj -c Debug -f net10.0 +dotnet run --project xidio/xidio.CLI/xidio.CLI.csproj -f net10.0 +``` + +Apple Silicon Mac 也可使用已启用 `aarch64-darwin` 的 `nix develop`。当前 nixpkgs 已不再支持 `x86_64-darwin`,Intel Mac 请使用原生 .NET 10 SDK。 + +## 🗺️ Roadmap + +详细的诊断范围、分析顺序与分阶段开发计划见 [`ROADMAP.md`](./ROADMAP.md)。 + +## 📰 Changelog + +版本变更记录见 [`CHANGELOG.md`](./CHANGELOG.md),文件遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 规范。 + +## 🤝 Contributing + +我们欢迎通过 Issue 报告问题或提出建议,也接受 Pull Request。提交前请阅读 [`CONTRIBUTING.md`](./CONTRIBUTING.md)。 + +## 📜 License + +xidio 以 [MIT License](./LICENSE) 开源。 diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..24ae0b5 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,155 @@ +# xidio Roadmap + +本文档定义 xidio 的开发路线、诊断范围与分析顺序。其中的未勾选项为计划,不代表当前版本已具备对应能力。 + +## 总体目标 + +xidio 需要在 Windows、Linux 和 macOS 上完成“信息收集 → 分层分析 → 可选修复 → 用户同意后上报”的完整流程。每层诊断必须附带完整探测信息,不得只返回“是/否”。 + +```text +本机网卡 -> 有线/Wi-Fi 情况 -> IP 获取 -> 默认网关可达 +-> 认证服务可达 -> 认证服务正常 -> DNS 正常 +-> 探测目标可达 -> 具体协议 +``` + +## 阶段计划 + +### 0.1 基础信息收集 + +- [x] 建立 `xidio.Core`、CLI 和平台 Provider 抽象。 +- [x] 增加用户场景问询与结构化诊断报告。 +- [x] 实现以 Windows 为主的系统、网卡、IP、路由、Wi-Fi 与主动探测信息收集。 +- [x] 提供 macOS Provider 的初始实现。 + +### 0.2 跨平台收集补全 + +- [x] 建立并接入 `xidio.Platform.Linux`,收集系统、物理网卡、Wi-Fi、DHCP、路由、metric 与邻居表信息。 +- [ ] 对齐 Windows、Linux 和 macOS 的基础系统、网卡、路由、DNS、邻居表、代理与 Wi-Fi 收集能力。 +- [ ] 补全 PPPoE 链路、会话、错误码与路由检查。 +- [ ] 为外部命令解析与平台特性增加测试。 + +### 0.3 分析引擎与扩展探测 + +- [ ] 实现本文档定义的 Wi-Fi、PPPoE、Captive Portal、NCSI 和部分应用故障判断流程。 +- [ ] 实现可配置、可测试的判断规则,并保留每个结论的证据链。 +- [ ] 补全直接 DNS、UDP Echo/Jitter/丢包、Traceroute/MTR 与 MTU/PMTUD 探测。 +- [ ] 区分 IPv4/IPv6、TCP/UDP 和 HTTP/HTTPS/QUIC 问题。 + +### 0.4 修复与上报 + +- [ ] 按 Drip、Drift、Current、Surge 等级实现权限、确认、回滚与审计日志。 +- [ ] 实现不同平台的基础修复 Provider,优先支持可回滚操作。 +- [ ] 实现崩溃、匿名使用情况与校园网情况三类上报;所有上报保持 opt-in。 +- [ ] 允许用户在上报前预览、脱敏和删除字段。 + +### 1.0 用户体验与稳定发布 + +- [ ] 提供稳定的 CLI 命令与可导出报告格式。 +- [ ] 完成基于 Avalonia 的跨平台 GUI。 +- [ ] 完成三个目标平台的 CI、发布包、安装指南与真实校园网场景验证。 +- [ ] 完成隐私说明、敏感字段盘点与上报数据保留策略。 + +## 信息收集范围 + +### 用户场景 + +xidio 应主动询问: + +1. 用户所在校区、建筑类型,以及可选的楼栋和楼层。 +2. 当前连接方式: + - **直接连接**:直接通过 Wi-Fi 连接校内 AP,或通过网口以 PPPoE 方式连接。 + - **间接连接**:通过路由器等设备接入,数据仍经校园网设施传输。 + - **其他连接**:通过手机热点等方式连接,数据未经校园网设施。 +3. 问题现象:无法连接 Wi-Fi、已连接但显示无 Internet、未跳转 Portal、认证后无法打开网页、仅部分应用不可用、拨号失败、拨号后无 Internet 等。 +4. 影响范围:仅本机、同宿舍、同楼层或不清楚。 + +对于其他连接方式,xidio 仅提供通用信息,不应将其问题归因于校园网。 + +### 系统、时间与网卡 + +- 操作系统类型与版本、xidio 版本、是否以管理员/root 运行。 +- 本机时间、NTP 或可靠网络时间、两者差值。 +- 所有接口及主物理接口的名称、类型、启用/连接状态、脱敏 MAC、链路速率、MTU、metric、收发包、错误包和丢弃包统计。 +- 默认路由使用的具体网卡,以及访问校内/校外目标时实际命中的路由。 + +Wi-Fi 场景还需收集: + +- 关联状态、SSID、脱敏 BSSID、RSSI/信号质量、频段、信道、PHY 类型和认证/加密类型。 +- 连接时长、当前速率、可见 SSID 列表、是否为已知校园 AP,以及可获得时的最近连接失败原因码。 + +PPPoE 场景还需收集: + +- 网线/carrier 状态、对端链路和链路速率。 +- PPPoE/宽带连接是否存在,以及未拨号、连接中、已连接、已断开等状态。 +- PPP 接口获取的 IP、DNS 和默认路由。 +- 可行时进行一次受控 RAS 拨号并记录错误码,包括 691、651、678/718 和 720 等常见情况。 +- Windows 宽带连接名称是否包含非 ASCII 字符,以及是否安装 Npcap/WinPcap。 + +### IP、DHCP、DNS、路由与邻居表 + +当至少连接一个网络时,收集: + +- IPv4/IPv6 地址、子网掩码/前缀长度,以及是否存在 `169.254.x.x` 或可疑静态地址。 +- DHCP 获取状态、服务器地址、租约开始与过期时间。 +- 默认网关、DNS 服务器、路由表、ARP/IPv6 Neighbor 表。 +- 多默认路由及其 metric,以及实际出站选路。 + +### 本机干扰因素 + +以下收集必须先征得用户同意: + +- Windows Internet Options、WinHTTP、系统、环境变量 `HTTP_PROXY`/`HTTPS_PROXY` 与 macOS 网络代理。 +- Clash TUN、v2rayN、WireGuard、OpenVPN、ZeroTier、Tailscale、WARP、VMware/VirtualBox、WSL 等 VPN/TUN/TAP/虚拟网卡状态。 +- DNS-over-HTTPS/Secure DNS、hosts 文件、防火墙、常见安全软件/过滤驱动与浏览器代理设置。 + +### 认证与业务状态 + +征得用户同意后,可检查: + +- 认证服务器可达性与当前认证状态。 +- 账号在线状态、当前设备是否在在线列表、最近登录错误码与强制下线情况。 +- 套餐是否生效、运营商/套餐类型、宽带/出口优先级、欠费与设备数上限。 +- 是否存在异常在线设备。 + +### 主动探测 + +目标包含校内认证服务域名/IP、若干校内 HTTP/HTTPS 服务,以及校外 xidio 官方域名、HTTP 204、HTTPS 固定响应体、TCP Connect/Echo 和 UDP Echo/Jitter 端点。按需执行: + +- ARP 默认网关检查、ICMP Ping,但不得仅根据 ICMP 失败判定网络不可达。 +- 到 80、443、53/TCP 的 TCP Connect,以及 UDP DNS 查询。 +- UDP Echo/Jitter/丢包测试、HTTP GET(可选不跟随重定向)、HTTPS 握手与证书校验。 +- 系统解析器与直接查询当前 DNS 服务器的 A/AAAA 记录对比。 +- 深度模式下的 Traceroute/MTR 与 MTU/PMTUD 测试,尤其关注 PPPoE。 + +## 典型分析流程 + +### Wi-Fi + +1. **无法连接 Wi-Fi**:SSID 不可见可能是 AP 覆盖或网卡问题;SSID 可见且信号不弱但连接失败,检查配置文件、认证方式与 AP 状态。可建议忘记网络后重连或移近 AP。 +2. **已关联但未获取 IP**:检查空地址、`169.254.x.x`、DHCP 服务器缺失或请求超时。其他 SSID 也失败时优先本机 DHCP/防火墙/驱动;单 AP 下多人失败时优先 AP、交换机、VLAN 或 DHCP 中继。 +3. **已获取 IP 但无默认网关**:检查 DHCP 配置和用户手动配置,确认路由表是否存在 `0.0.0.0/0`。 +4. **默认网关不可达**:结合 ARP、Ping/TCP 网关探测和校园认证 IP 可达性,判断 AP/交换机/网关/VLAN 异常。 +5. **网关可达但 Portal 不可用**:Portal 域名失败而 IP 成功时检查 DNS、DoH 和公共 DNS;域名与 IP 都失败时检查校内认证服务。 +6. **认证成功但无法上网**:依次比较公网 IP、DNS、TCP 80/443、浏览器、代理/VPN、证书/系统时间、UDP 质量与 IPv4/IPv6,考虑 Happy Eyeballs 和地址优先级影响。 + +### PPPoE + +1. **以太网无链路**:检查接口连接、carrier 与链路速率,优先提示网线未接或接错。 +2. **有链路但未拨号**:以太网接口 `up` 但不存在 PPP 接口或 PPPoE 默认路由时,提示用户拨号。 +3. **拨号失败**:优先解释 RAS 错误码;有链路但 PPPoE Discovery 无响应时,优先考虑接入侧问题。 +4. **拨号成功但无法上网**:检查 PPP 接口的 IP、DNS、默认路由和系统代理。 + +### 其他典型问题 + +- **Captive Portal Detection 失效**:可手动打开 Portal 时仅判定为自动探测失效;域名失败而 IP 成功时判定 DNS 问题;两者都失败时检查认证服务器路径或网关。 +- **Windows “无 Internet”**:不得仅依赖 NCSI,应独立测试默认网关、Portal、DNS、HTTP 204、HTTPS 和主动探测目标。 +- **仅部分应用无法联网**:比较 HTTP/HTTPS、DNS、TCP 443、UDP 443/QUIC、代理、VPN 和双栈表现,不根据单一应用直接归因。 + +## 修复安全等级 + +- **Drip**:完全只读,仅收集和分析。 +- **Drift**:可回滚且风险较低的修复,如关闭已确认失效的代理、刷新 DNS 缓存、重新获取 DHCP 租约。 +- **Current**:可回滚但会改变网络配置的修复,如修改 DNS、调整 metric、临时禁用冲突虚拟网卡或在有充分证据时临时禁用 IPv6。 +- **Surge**:侵入式修复,如重置 Winsock/IP 协议栈、Windows 网络重置、驱动回滚/更新/设备删除和电源策略修改。 + +所有修复必须获得用户明确确认并留下日志;除 Surge 外的操作必须可回滚。 diff --git a/assets/xidio_icon.png b/assets/xidio_icon.png new file mode 100644 index 0000000..162440d Binary files /dev/null and b/assets/xidio_icon.png differ diff --git a/assets/xidio_logo.png b/assets/xidio_logo.png new file mode 100644 index 0000000..21605d0 Binary files /dev/null and b/assets/xidio_logo.png differ diff --git a/docs/architecture.md b/docs/architecture.md index d206507..930c773 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,6 +8,7 @@ - `xidio.Core`:xidio 的核心模块,封装了所有诊断、修复和上报逻辑,供 CLI 与 GUI 调用。 - `xidio.CLI`:xidio 的命令行版本,同时也可作为终端内的交互式工具使用。我们将使用 `System.CommandLine` 和 `Spectre.Console`。 +- `xidio.Platform.*`:Windows、Linux 与 macOS 的平台专用信息收集 Provider。 - `xidio.GUI`:xidio 的图形界面版本,基于 Avalonia 框架实现跨平台功能。 ### xidio 的调用能力和平台特性 diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..fe90317 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1783522502, + "narHash": "sha256-iffAls3iaNTyJC2faYcUXSI+Gp02cDjYl+MygxKl2GI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0bb7ec54c8483066ec9d7720e780a5caa71f8612", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..de823ad --- /dev/null +++ b/flake.nix @@ -0,0 +1,61 @@ +{ + description = "xidio .NET development environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { nixpkgs, ... }: + let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ]; + + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + in + { + devShells = forAllSystems ( + system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + default = pkgs.mkShell { + packages = with pkgs; [ + dotnet-sdk_10 + git + pkg-config + ] + ++ lib.optionals stdenv.isLinux [ + clang + iproute2 + iw + lld + networkmanager + ]; + + buildInputs = with pkgs; [ + icu + openssl + zlib + ]; + + DOTNET_CLI_TELEMETRY_OPTOUT = "1"; + DOTNET_NOLOGO = "1"; + + LD_LIBRARY_PATH = pkgs.lib.optionalString pkgs.stdenv.isLinux ( + pkgs.lib.makeLibraryPath [ + pkgs.icu + pkgs.openssl + pkgs.stdenv.cc.cc.lib + pkgs.zlib + ] + ); + }; + } + ); + }; +} diff --git a/version.json b/version.json index cf44616..ef811c6 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "0.1", + "version": "0.1.1", "publicReleaseRefSpec": [ "^refs/tags/v\\d+\\.\\d+\\.\\d+$" ], diff --git a/xidio/xidio.CLI/ConsoleDiagnosticQuestionnaire.cs b/xidio/xidio.CLI/ConsoleDiagnosticQuestionnaire.cs index 5d2fc6f..8e53dea 100644 --- a/xidio/xidio.CLI/ConsoleDiagnosticQuestionnaire.cs +++ b/xidio/xidio.CLI/ConsoleDiagnosticQuestionnaire.cs @@ -59,13 +59,23 @@ public static Task AskAsync(CancellationToken cancellationToke new("不清楚", ImpactScope.Unknown) ]); + cancellationToken.ThrowIfCancellationRequested(); + var collectSensitiveSystemInformation = AnsiConsole.Prompt( + new ConfirmationPrompt( + "是否同意 xidio 进一步收集系统代理等敏感系统信息?诊断结果不会自动上报。") + { + DefaultValue = false + }); + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new UserScenarioInfo { Location = location, ConnectionMethod = connectionMethod, AccessMethod = accessMethod, ProblemSymptom = problemSymptom, - ImpactScope = impactScope + ImpactScope = impactScope, + CollectSensitiveSystemInformation = collectSensitiveSystemInformation }); } diff --git a/xidio/xidio.CLI/ConsoleDiagnosticReporter.cs b/xidio/xidio.CLI/ConsoleDiagnosticReporter.cs index 9a578be..a2f1237 100644 --- a/xidio/xidio.CLI/ConsoleDiagnosticReporter.cs +++ b/xidio/xidio.CLI/ConsoleDiagnosticReporter.cs @@ -92,6 +92,7 @@ private static void PrintUserScenario(UserScenarioInfo scenario) scenarioTable.AddRow("接入方式", Escape(FormatNetworkAccessMethod(scenario.AccessMethod))); scenarioTable.AddRow("主要问题", Escape(FormatProblemSymptom(scenario.ProblemSymptom))); scenarioTable.AddRow("影响范围", Escape(FormatImpactScope(scenario.ImpactScope, scenario.ProblemSymptom))); + scenarioTable.AddRow("敏感系统信息收集", FormatBool(scenario.CollectSensitiveSystemInformation)); AnsiConsole.Write(scenarioTable); } @@ -278,7 +279,7 @@ private static void PrintPrimaryInterfaceDetails(PrimaryInterfaceInfo primaryInt detailsTable.AddRow("已连接", FormatBool(primaryInterface.IsConnected)); detailsTable.AddRow("状态", FormatOperationalStatus(primaryInterface.OperationalStatus)); detailsTable.AddRow("接口类型", Escape(primaryInterface.NetworkInterfaceType.ToString())); - detailsTable.AddRow("MAC 地址", Escape(primaryInterface.MacAddress ?? "")); + detailsTable.AddRow("MAC 地址", Escape(FormatHardwareAddress(primaryInterface.MacAddress))); detailsTable.AddRow("链路速度", Escape(FormatLinkSpeed(primaryInterface.LinkSpeedBitsPerSecond))); detailsTable.AddRow("MTU", Escape(primaryInterface.Mtu is null ? "" @@ -287,7 +288,7 @@ private static void PrintPrimaryInterfaceDetails(PrimaryInterfaceInfo primaryInt if (primaryInterface.WirelessConnection is not null) { detailsTable.AddRow("SSID", Escape(primaryInterface.WirelessConnection.Ssid)); - detailsTable.AddRow("BSSID", Escape(primaryInterface.WirelessConnection.Bssid)); + detailsTable.AddRow("BSSID", Escape(FormatHardwareAddress(primaryInterface.WirelessConnection.Bssid))); detailsTable.AddRow("RSSI", Escape(primaryInterface.WirelessConnection.RssiDbm is null ? "" : $"{primaryInterface.WirelessConnection.RssiDbm} dBm")); @@ -371,7 +372,7 @@ private static void PrintNeighborSummary(IReadOnlyList neig neighborTable.AddRow( Escape(FormatAddressFamily(neighbor.AddressFamily)), Escape(neighbor.IpAddress), - Escape(string.IsNullOrWhiteSpace(neighbor.LinkLayerAddress) ? "" : neighbor.LinkLayerAddress), + Escape(FormatHardwareAddress(neighbor.LinkLayerAddress, "")), Escape(neighbor.State)); } @@ -549,6 +550,9 @@ private static string FormatAddressFamily(int addressFamily) private static string FormatProxyStatus(SystemProxyInfo systemProxy) { + if (!systemProxy.WasCollected) + return "[grey]未收集(未经授权)[/]"; + return systemProxy.IsEnabled ? $"[yellow]on[/] {Escape(systemProxy.ProxyUri?.ToString() ?? "")}" : "[green]off[/]"; @@ -637,6 +641,19 @@ private static string FormatWirelessRates(WirelessConnectionInfo wireless) return $"Rx {rx}, Tx {tx}"; } + private static string FormatHardwareAddress(string? address, string unavailable = "") + { + if (string.IsNullOrWhiteSpace(address)) + return unavailable; + + var separator = address.Contains(':') ? ':' : '-'; + var parts = address.Split(separator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + return parts.Length == 6 && parts.All(part => part.Length == 2) + ? string.Join(separator, parts.Take(3).Concat(["XX", "XX", "XX"])) + : ""; + } + private static string FormatDnsResult(DnsProbeResult result) { var source = string.IsNullOrWhiteSpace(result.DnsServer) ? "system" : result.DnsServer; diff --git a/xidio/xidio.CLI/Program.cs b/xidio/xidio.CLI/Program.cs index c069b4f..e62e06e 100644 --- a/xidio/xidio.CLI/Program.cs +++ b/xidio/xidio.CLI/Program.cs @@ -5,7 +5,9 @@ using xidio.Core.Models; #if WINDOWS using xidio.Platform.Windows; -#else +#elif LINUX +using xidio.Platform.Linux; +#elif MACOS using xidio.Platform.macOS; #endif @@ -74,10 +76,11 @@ private static IPlatformNetworkDiagnosticsProvider CreatePlatformProvider() { #if WINDOWS return new WindowsPlatformNetworkDiagnosticsProvider(); +#elif LINUX + return new LinuxPlatformNetworkDiagnosticsProvider(); +#elif MACOS + return new MacPlatformNetworkDiagnosticsProvider(); #else - if (OperatingSystem.IsMacOS()) - return new MacPlatformNetworkDiagnosticsProvider(); - return NoopPlatformNetworkDiagnosticsProvider.Instance; #endif } diff --git a/xidio/xidio.CLI/xidio.CLI.csproj b/xidio/xidio.CLI/xidio.CLI.csproj index d537605..6cb658a 100644 --- a/xidio/xidio.CLI/xidio.CLI.csproj +++ b/xidio/xidio.CLI/xidio.CLI.csproj @@ -19,7 +19,19 @@ - + + $(DefineConstants);LINUX + + + + $(DefineConstants);MACOS + + + + + + + diff --git a/xidio/xidio.Core/Diagnostics/NetworkDiagnosticsCollector.cs b/xidio/xidio.Core/Diagnostics/NetworkDiagnosticsCollector.cs index 52ba9d7..b1f9051 100644 --- a/xidio/xidio.Core/Diagnostics/NetworkDiagnosticsCollector.cs +++ b/xidio/xidio.Core/Diagnostics/NetworkDiagnosticsCollector.cs @@ -95,8 +95,14 @@ public async Task CollectAsync( cancellationToken); ReportProgress(progress, NetworkDiagnosticStage.DefaultRoutes, "已读取默认路由信息", 7); - var systemProxy = SafeGet(GetSystemProxyInfo, new SystemProxyInfo { IsEnabled = false }); - ReportProgress(progress, NetworkDiagnosticStage.SystemProxy, "已读取系统代理状态", 8); + var systemProxy = userScenario.CollectSensitiveSystemInformation + ? SafeGet(GetSystemProxyInfo, new SystemProxyInfo { WasCollected = true, IsEnabled = false }) + : new SystemProxyInfo { WasCollected = false, IsEnabled = false }; + ReportProgress( + progress, + NetworkDiagnosticStage.SystemProxy, + systemProxy.WasCollected ? "已读取系统代理状态" : "已跳过未授权的系统代理收集", + 8); var probeResults = await CollectProbeResultsAsync(primaryInterfaces, progress, cancellationToken); ReportProgress(progress, NetworkDiagnosticStage.ActiveProbes, "已完成主动探测", 9); @@ -819,6 +825,7 @@ private static SystemProxyInfo GetSystemProxyInfo() return new SystemProxyInfo { + WasCollected = true, IsEnabled = isEnabled, ProxyUri = isEnabled ? proxyUri : null }; diff --git a/xidio/xidio.Core/Models/SystemProxyInfo.cs b/xidio/xidio.Core/Models/SystemProxyInfo.cs index e409dea..f311f91 100644 --- a/xidio/xidio.Core/Models/SystemProxyInfo.cs +++ b/xidio/xidio.Core/Models/SystemProxyInfo.cs @@ -2,6 +2,8 @@ namespace xidio.Core.Models; public sealed class SystemProxyInfo { + public bool WasCollected { get; init; } + public required bool IsEnabled { get; init; } public Uri? ProxyUri { get; init; } diff --git a/xidio/xidio.Core/Models/UserScenarioInfo.cs b/xidio/xidio.Core/Models/UserScenarioInfo.cs index 9763bbd..fb05aa0 100644 --- a/xidio/xidio.Core/Models/UserScenarioInfo.cs +++ b/xidio/xidio.Core/Models/UserScenarioInfo.cs @@ -20,4 +20,6 @@ public sealed class UserScenarioInfo public required ProblemSymptom ProblemSymptom { get; init; } public required ImpactScope ImpactScope { get; init; } + + public bool CollectSensitiveSystemInformation { get; init; } } diff --git a/xidio/xidio.Platform.Linux/LinuxPlatformNetworkDiagnosticsProvider.cs b/xidio/xidio.Platform.Linux/LinuxPlatformNetworkDiagnosticsProvider.cs new file mode 100644 index 0000000..a805c56 --- /dev/null +++ b/xidio/xidio.Platform.Linux/LinuxPlatformNetworkDiagnosticsProvider.cs @@ -0,0 +1,813 @@ +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text.Json; +using xidio.Core.Abstractions; +using xidio.Core.Diagnostics; +using xidio.Core.Models; + +namespace xidio.Platform.Linux; + +public sealed class LinuxPlatformNetworkDiagnosticsProvider : IPlatformNetworkDiagnosticsProvider +{ + private const string SysClassNet = "/sys/class/net"; + private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(5); + + public async ValueTask GetOperatingSystemInfoAsync( + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var osRelease = await ReadOsReleaseAsync(cancellationToken); + osRelease.TryGetValue("PRETTY_NAME", out var description); + osRelease.TryGetValue("VERSION_ID", out var version); + + return new OperatingSystemDiagnosticInfo + { + Family = "Linux", + Description = string.IsNullOrWhiteSpace(description) + ? RuntimeInformation.OSDescription + : description, + Version = string.IsNullOrWhiteSpace(version) + ? Environment.OSVersion.VersionString + : version, + Architecture = RuntimeInformation.ProcessArchitecture.ToString(), + XidioVersion = XidioVersion.InformationalVersion, + IsElevated = GetEffectiveUserId() == 0 + }; + } + + public ValueTask> GetPhysicalNetworkInterfaceIdsAsync( + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!Directory.Exists(SysClassNet)) + return ValueTask.FromResult>(Array.Empty()); + + var interfaces = Directory.EnumerateDirectories(SysClassNet) + .Where(path => Directory.Exists(Path.Combine(path, "device"))) + .Select(Path.GetFileName) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Cast() + .ToHashSet(StringComparer.Ordinal); + + return ValueTask.FromResult>(interfaces); + } + + public async ValueTask> GetNetworkAdapterDriverInfosAsync( + CancellationToken cancellationToken) + { + var physicalIds = await GetPhysicalNetworkInterfaceIdsAsync(cancellationToken); + var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces() + .ToDictionary(networkInterface => networkInterface.Name, StringComparer.Ordinal); + var result = new List(); + + foreach (var interfaceId in physicalIds.Order(StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + networkInterfaces.TryGetValue(interfaceId, out var networkInterface); + + var driverName = GetDriverName(interfaceId); + var driverVersion = driverName is null + ? null + : await ReadTrimmedTextAsync($"/sys/module/{driverName}/version", cancellationToken); + + result.Add(new NetworkAdapterDriverInfo + { + Name = interfaceId, + Description = driverName is null + ? networkInterface?.Description ?? interfaceId + : $"{networkInterface?.Description ?? interfaceId} ({driverName})", + DriverVersion = string.IsNullOrWhiteSpace(driverVersion) + ? "" + : driverVersion + }); + } + + return result; + } + + public async ValueTask GetInterfacePlatformInfoAsync( + NetworkInterface networkInterface, + CancellationToken cancellationToken) + { + var interfacePath = Path.Combine(SysClassNet, networkInterface.Name); + if (!Directory.Exists(interfacePath)) + return null; + + var flagsText = await ReadTrimmedTextAsync(Path.Combine(interfacePath, "flags"), cancellationToken); + var mtuText = await ReadTrimmedTextAsync(Path.Combine(interfacePath, "mtu"), cancellationToken); + + bool? isEnabled = null; + if (TryParseHexUInt64(flagsText, out var flags)) + isEnabled = (flags & 0x1) != 0; + + int? mtu = int.TryParse(mtuText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedMtu) + ? parsedMtu + : null; + + return new InterfacePlatformInfo + { + IsEnabled = isEnabled, + Mtu = mtu + }; + } + + public async ValueTask GetWirelessConnectionInfoAsync( + NetworkInterface networkInterface, + CancellationToken cancellationToken) + { + var iwOutput = await RunCommandAsync( + "iw", + ["dev", networkInterface.Name, "link"], + cancellationToken); + var current = ParseIwLink(iwOutput?.StandardOutput); + + var nmcliOutput = await RunCommandAsync( + "nmcli", + [ + "-t", + "--escape", "yes", + "-f", "IN-USE,SSID,BSSID,SIGNAL,FREQ,CHAN,RATE,SECURITY", + "device", "wifi", "list", + "ifname", networkInterface.Name, + "--rescan", "no" + ], + cancellationToken); + var wifiRows = ParseNmcliWifiRows(nmcliOutput?.StandardOutput); + var activeRow = wifiRows.FirstOrDefault(row => row.IsActive); + + if (current is null && activeRow is null) + return null; + + var ssid = current?.Ssid ?? activeRow?.Ssid; + var bssid = current?.Bssid ?? activeRow?.Bssid; + if (string.IsNullOrWhiteSpace(ssid) || string.IsNullOrWhiteSpace(bssid)) + return null; + + return new WirelessConnectionInfo + { + Ssid = ssid, + Bssid = bssid, + SignalQualityPercent = activeRow?.SignalQualityPercent, + RssiDbm = current?.RssiDbm, + Channel = current?.Channel ?? activeRow?.Channel, + FrequencyGhz = current?.FrequencyGhz ?? activeRow?.FrequencyGhz, + PhyType = InferPhyType(current?.RateDescription), + Authentication = activeRow?.Security, + ReceiveRateMbps = current?.ReceiveRateMbps ?? activeRow?.RateMbps, + TransmitRateMbps = current?.TransmitRateMbps ?? activeRow?.RateMbps, + VisibleNetworks = CreateVisibleNetworks(wifiRows) + }; + } + + public async ValueTask GetInterfaceDhcpInfoAsync( + NetworkInterface networkInterface, + CancellationToken cancellationToken) + { + var output = await RunCommandAsync( + "nmcli", + ["-t", "-f", "DHCP4.OPTION,DHCP6.OPTION", "device", "show", networkInterface.Name], + cancellationToken); + + if (string.IsNullOrWhiteSpace(output?.StandardOutput)) + return null; + + long? expiry = null; + int? leaseSeconds = null; + + foreach (var line in output.StandardOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var separator = line.IndexOf('='); + if (separator < 0) + continue; + + var name = line[..separator].Trim(); + var value = line[(separator + 1)..].Trim(); + + if (name.EndsWith("expiry", StringComparison.OrdinalIgnoreCase) && + long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedExpiry)) + { + expiry = parsedExpiry; + } + else if (name.EndsWith("dhcp_lease_time", StringComparison.OrdinalIgnoreCase) && + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLease)) + { + leaseSeconds = parsedLease; + } + } + + var leaseExpires = expiry is > 0 + ? DateTimeOffset.FromUnixTimeSeconds(expiry.Value) + : (DateTimeOffset?)null; + + return new InterfaceDhcpInfo + { + IsEnabled = true, + LeaseExpires = leaseExpires, + LeaseObtained = leaseExpires is not null && leaseSeconds is > 0 + ? leaseExpires.Value - TimeSpan.FromSeconds(leaseSeconds.Value) + : null + }; + } + + public async ValueTask> GetInterfaceMetricsAsync( + NetworkInterface networkInterface, + CancellationToken cancellationToken) + { + var routes = await GetRoutesAsync(networkInterface, cancellationToken); + + return routes + .GroupBy(route => route.AddressFamily) + .Select(group => new InterfaceMetricInfo + { + AddressFamily = group.Key, + InterfaceMetric = group + .Where(route => route.DestinationPrefix is "0.0.0.0/0" or "::/0") + .Select(route => route.TotalMetric) + .DefaultIfEmpty(group + .Where(route => route.TotalMetric > 0) + .Select(route => route.TotalMetric) + .DefaultIfEmpty(0) + .Min()) + .Min() + }) + .OrderBy(metric => metric.AddressFamily) + .ToList(); + } + + public async ValueTask> GetRoutesAsync( + NetworkInterface networkInterface, + CancellationToken cancellationToken) + { + var routes = new List(); + routes.AddRange(await ReadRoutesAsync(AddressFamily.InterNetwork, networkInterface.Name, cancellationToken)); + routes.AddRange(await ReadRoutesAsync(AddressFamily.InterNetworkV6, networkInterface.Name, cancellationToken)); + return routes; + } + + public async ValueTask> GetNetworkNeighborsAsync( + NetworkInterface networkInterface, + CancellationToken cancellationToken) + { + var output = await RunCommandAsync("ip", ["-j", "neigh", "show"], cancellationToken); + if (string.IsNullOrWhiteSpace(output?.StandardOutput)) + return Array.Empty(); + + try + { + using var document = JsonDocument.Parse(output.StandardOutput); + var result = new List(); + + foreach (var item in document.RootElement.EnumerateArray()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!TryGetString(item, "dev", out var device) || + !string.Equals(device, networkInterface.Name, StringComparison.Ordinal) || + !TryGetString(item, "dst", out var destination) || + destination is null) + { + continue; + } + + _ = TryGetString(item, "lladdr", out var linkLayerAddress); + + result.Add(new NetworkNeighborInfo + { + IpAddress = destination, + LinkLayerAddress = linkLayerAddress ?? "", + AddressFamily = IPAddress.TryParse(destination, out var address) + ? (int)address.AddressFamily + : 0, + State = GetNeighborState(item) + }); + } + + return result; + } + catch (JsonException) + { + return Array.Empty(); + } + } + + public async ValueTask> GetDefaultRoutesAsync( + CancellationToken cancellationToken) + { + var result = new List(); + + foreach (var family in new[] { AddressFamily.InterNetwork, AddressFamily.InterNetworkV6 }) + { + foreach (var route in await ReadRouteEntriesAsync(family, cancellationToken)) + { + if (!IsDefaultDestination(route.Destination)) + continue; + + result.Add(new DefaultRouteInfo + { + DestinationPrefix = family == AddressFamily.InterNetwork ? "0.0.0.0/0" : "::/0", + NextHop = route.NextHop, + AddressFamily = (int)family, + InterfaceIndex = GetInterfaceIndex(route.Device, family), + InterfaceAlias = route.Device, + RouteMetric = route.Metric, + InterfaceMetric = 0 + }); + } + } + + return result + .OrderBy(route => route.AddressFamily) + .ThenBy(route => route.TotalMetric) + .ThenBy(route => route.InterfaceAlias, StringComparer.Ordinal) + .ToList(); + } + + private static async Task> ReadRoutesAsync( + AddressFamily addressFamily, + string interfaceName, + CancellationToken cancellationToken) + { + var entries = await ReadRouteEntriesAsync(addressFamily, cancellationToken); + + return entries + .Where(entry => string.Equals(entry.Device, interfaceName, StringComparison.Ordinal)) + .Select(entry => new InterfaceRouteInfo + { + DestinationPrefix = NormalizeDestination(entry.Destination, addressFamily), + NextHop = entry.NextHop, + AddressFamily = (int)addressFamily, + RouteMetric = entry.Metric, + InterfaceMetric = 0 + }) + .ToList(); + } + + private static async Task> ReadRouteEntriesAsync( + AddressFamily addressFamily, + CancellationToken cancellationToken) + { + var familyFlag = addressFamily == AddressFamily.InterNetwork ? "-4" : "-6"; + var output = await RunCommandAsync( + "ip", + ["-j", familyFlag, "route", "show", "table", "all"], + cancellationToken); + + if (string.IsNullOrWhiteSpace(output?.StandardOutput)) + return Array.Empty(); + + try + { + using var document = JsonDocument.Parse(output.StandardOutput); + var result = new List(); + + foreach (var item in document.RootElement.EnumerateArray()) + { + if (!TryGetString(item, "dev", out var device) || string.IsNullOrWhiteSpace(device)) + continue; + + _ = TryGetString(item, "dst", out var destination); + _ = TryGetString(item, "gateway", out var gateway); + + result.Add(new RouteEntry( + destination ?? "default", + gateway ?? "", + device, + TryGetInt32(item, "metric") ?? 0)); + } + + return result; + } + catch (JsonException) + { + return Array.Empty(); + } + } + + private static IwLinkInfo? ParseIwLink(string? output) + { + if (string.IsNullOrWhiteSpace(output) || + output.Contains("Not connected", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + string? ssid = null; + string? bssid = null; + string? rateDescription = null; + int? rssi = null; + int? frequencyMhz = null; + double? receiveRate = null; + double? transmitRate = null; + + foreach (var rawLine in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var line = rawLine.Trim(); + + if (line.StartsWith("Connected to ", StringComparison.OrdinalIgnoreCase)) + bssid = line["Connected to ".Length..].Split(' ', 2)[0]; + else if (line.StartsWith("SSID:", StringComparison.OrdinalIgnoreCase)) + ssid = line["SSID:".Length..].Trim(); + else if (line.StartsWith("freq:", StringComparison.OrdinalIgnoreCase)) + frequencyMhz = ParseFirstInt(line["freq:".Length..]); + else if (line.StartsWith("signal:", StringComparison.OrdinalIgnoreCase)) + rssi = ParseFirstInt(line["signal:".Length..]); + else if (line.StartsWith("rx bitrate:", StringComparison.OrdinalIgnoreCase)) + { + rateDescription = line; + receiveRate = ParseFirstDouble(line["rx bitrate:".Length..]); + } + else if (line.StartsWith("tx bitrate:", StringComparison.OrdinalIgnoreCase)) + { + rateDescription ??= line; + transmitRate = ParseFirstDouble(line["tx bitrate:".Length..]); + } + } + + if (string.IsNullOrWhiteSpace(ssid) || string.IsNullOrWhiteSpace(bssid)) + return null; + + return new IwLinkInfo( + ssid, + bssid, + rssi, + frequencyMhz is > 0 ? frequencyMhz.Value / 1000d : null, + FrequencyMhzToChannel(frequencyMhz), + receiveRate, + transmitRate, + rateDescription); + } + + private static IReadOnlyList ParseNmcliWifiRows(string? output) + { + if (string.IsNullOrWhiteSpace(output)) + return Array.Empty(); + + var result = new List(); + + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var fields = SplitEscapedFields(line); + if (fields.Count < 8 || string.IsNullOrWhiteSpace(fields[1])) + continue; + + var frequencyMhz = ParseFirstInt(fields[4]); + result.Add(new NmcliWifiRow( + fields[0] is "*" or "yes", + fields[1], + fields[2], + ParseFirstInt(fields[3]), + frequencyMhz is > 0 ? frequencyMhz.Value / 1000d : null, + ParseFirstInt(fields[5]) ?? FrequencyMhzToChannel(frequencyMhz), + ParseFirstDouble(fields[6]), + string.IsNullOrWhiteSpace(fields[7]) ? null : fields[7])); + } + + return result; + } + + private static List CreateVisibleNetworks( + IReadOnlyList rows) + { + return rows + .GroupBy(row => row.Ssid, StringComparer.Ordinal) + .Select(group => new VisibleWirelessNetworkInfo + { + Ssid = group.Key, + SignalQualityPercent = group.Max(row => row.SignalQualityPercent), + Authentication = group.Select(row => row.Security).FirstOrDefault(value => value is not null), + BssidCount = group.Select(row => row.Bssid).Where(value => value.Length > 0).Distinct().Count(), + IsConnectable = null + }) + .OrderByDescending(network => network.SignalQualityPercent) + .ThenBy(network => network.Ssid, StringComparer.Ordinal) + .ToList(); + } + + private static List SplitEscapedFields(string line) + { + var result = new List(); + var current = new List(); + var escaped = false; + + foreach (var character in line) + { + if (escaped) + { + current.Add(character); + escaped = false; + } + else if (character == '\\') + { + escaped = true; + } + else if (character == ':') + { + result.Add(new string(current.ToArray())); + current.Clear(); + } + else + { + current.Add(character); + } + } + + if (escaped) + current.Add('\\'); + + result.Add(new string(current.ToArray())); + return result; + } + + private static string? InferPhyType(string? rateDescription) + { + if (string.IsNullOrWhiteSpace(rateDescription)) + return null; + if (rateDescription.Contains("EHT", StringComparison.OrdinalIgnoreCase)) + return "802.11be"; + if (rateDescription.Contains("HE-", StringComparison.OrdinalIgnoreCase)) + return "802.11ax"; + if (rateDescription.Contains("VHT-", StringComparison.OrdinalIgnoreCase)) + return "802.11ac"; + if (rateDescription.Contains("MCS", StringComparison.OrdinalIgnoreCase)) + return "802.11n"; + return null; + } + + private static int? FrequencyMhzToChannel(int? frequencyMhz) + { + if (frequencyMhz is null or <= 0) + return null; + if (frequencyMhz == 2484) + return 14; + if (frequencyMhz is >= 2412 and <= 2472) + return (frequencyMhz.Value - 2407) / 5; + if (frequencyMhz is >= 5000 and <= 5900) + return (frequencyMhz.Value - 5000) / 5; + if (frequencyMhz is >= 5955 and <= 7115) + return (frequencyMhz.Value - 5950) / 5; + return null; + } + + private static async Task> ReadOsReleaseAsync( + CancellationToken cancellationToken) + { + var result = new Dictionary(StringComparer.Ordinal); + + try + { + foreach (var line in await File.ReadAllLinesAsync("/etc/os-release", cancellationToken)) + { + var separator = line.IndexOf('='); + if (separator <= 0) + continue; + + result[line[..separator]] = line[(separator + 1)..].Trim().Trim('"'); + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + + return result; + } + + private static async Task ReadTrimmedTextAsync( + string path, + CancellationToken cancellationToken) + { + try + { + return (await File.ReadAllTextAsync(path, cancellationToken)).Trim(); + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + private static string? GetDriverName(string interfaceName) + { + try + { + var driverPath = Path.Combine(SysClassNet, interfaceName, "device", "driver"); + return Directory.ResolveLinkTarget(driverPath, returnFinalTarget: true)?.Name; + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + private static int GetInterfaceIndex(string interfaceName, AddressFamily addressFamily) + { + try + { + var networkInterface = NetworkInterface.GetAllNetworkInterfaces() + .FirstOrDefault(item => string.Equals(item.Name, interfaceName, StringComparison.Ordinal)); + var properties = networkInterface?.GetIPProperties(); + + return addressFamily == AddressFamily.InterNetwork + ? properties?.GetIPv4Properties()?.Index ?? 0 + : properties?.GetIPv6Properties()?.Index ?? 0; + } + catch (NetworkInformationException) + { + return 0; + } + } + + private static async Task RunCommandAsync( + string fileName, + IReadOnlyList arguments, + CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(CommandTimeout); + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.StartInfo.Environment["LC_ALL"] = "C"; + foreach (var argument in arguments) + process.StartInfo.ArgumentList.Add(argument); + + try + { + process.Start(); + var outputTask = process.StandardOutput.ReadToEndAsync(timeout.Token); + var errorTask = process.StandardError.ReadToEndAsync(timeout.Token); + + await process.WaitForExitAsync(timeout.Token); + var standardOutput = await outputTask; + var standardError = await errorTask; + + return process.ExitCode == 0 + ? new CommandResult(standardOutput, standardError) + : null; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + TryKill(process); + return null; + } + catch (OperationCanceledException) + { + TryKill(process); + throw; + } + catch (Exception exception) when (exception is System.ComponentModel.Win32Exception or IOException) + { + return null; + } + } + + private static void TryKill(Process process) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + } + } + + private static bool TryGetString(JsonElement element, string propertyName, out string? value) + { + value = null; + if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) + return false; + + value = property.GetString(); + return value is not null; + } + + private static int? TryGetInt32(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var property) && property.TryGetInt32(out var value) + ? value + : null; + } + + private static string GetNeighborState(JsonElement element) + { + if (!element.TryGetProperty("state", out var state)) + return ""; + if (state.ValueKind == JsonValueKind.String) + return state.GetString() ?? ""; + if (state.ValueKind == JsonValueKind.Array) + return string.Join(",", state.EnumerateArray().Select(value => value.GetString())); + return ""; + } + + private static bool IsDefaultDestination(string destination) + { + return string.IsNullOrWhiteSpace(destination) || + destination is "default" or "0.0.0.0/0" or "::/0"; + } + + private static string NormalizeDestination(string destination, AddressFamily addressFamily) + { + if (IsDefaultDestination(destination)) + return addressFamily == AddressFamily.InterNetwork ? "0.0.0.0/0" : "::/0"; + + if (IPAddress.TryParse(destination, out var address)) + return $"{destination}/{(address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128)}"; + + return destination; + } + + private static bool TryParseHexUInt64(string? value, out ulong result) + { + result = 0; + if (string.IsNullOrWhiteSpace(value)) + return false; + + return ulong.TryParse( + value.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? value[2..] : value, + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out result); + } + + private static int? ParseFirstInt(string value) + { + var token = value.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + return int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) + ? result + : null; + } + + private static double? ParseFirstDouble(string value) + { + var token = value.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + return double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out var result) + ? result + : null; + } + + [DllImport("libc")] + private static extern uint geteuid(); + + private static uint GetEffectiveUserId() + { + try + { + return geteuid(); + } + catch (DllNotFoundException) + { + return uint.MaxValue; + } + } + + private sealed record CommandResult(string StandardOutput, string StandardError); + + private sealed record RouteEntry(string Destination, string NextHop, string Device, int Metric); + + private sealed record IwLinkInfo( + string Ssid, + string Bssid, + int? RssiDbm, + double? FrequencyGhz, + int? Channel, + double? ReceiveRateMbps, + double? TransmitRateMbps, + string? RateDescription); + + private sealed record NmcliWifiRow( + bool IsActive, + string Ssid, + string Bssid, + int? SignalQualityPercent, + double? FrequencyGhz, + int? Channel, + double? RateMbps, + string? Security); +} diff --git a/xidio/xidio.Platform.Linux/xidio.Platform.Linux.csproj b/xidio/xidio.Platform.Linux/xidio.Platform.Linux.csproj new file mode 100644 index 0000000..a5e4516 --- /dev/null +++ b/xidio/xidio.Platform.Linux/xidio.Platform.Linux.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/xidio/xidio.slnx b/xidio/xidio.slnx index 1df6d62..4013310 100644 --- a/xidio/xidio.slnx +++ b/xidio/xidio.slnx @@ -1,6 +1,7 @@ +