diff --git a/CHANGELOG.md b/CHANGELOG.md index e7616ad..68fce62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ 本文档格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。 +## [Unreleased] + +### Added + +- 增加 Linux 平台 Provider,支持系统与权限、物理网卡与驱动、Wi-Fi、DHCP、路由、metric、默认路由和邻居表信息收集。 + ## [0.1.0] - 2026-05-22 ### Added @@ -16,3 +22,4 @@ - 增加 CI、标签发布工作流与 Nerdbank.GitVersioning 版本管理。 [0.1.0]: https://github.com/LyCecilion/xidio/releases/tag/v0.1.0 +[Unreleased]: https://github.com/LyCecilion/xidio/compare/v0.1.0...HEAD diff --git a/README.md b/README.md index af48ab2..e97542c 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ xidio 的灵感来自 [NanCunChild](https://github.com/NanCunChild) 的 [Marduk] - **交互式场景问询**:通过 CLI 收集位置、连接方式、问题现象与影响范围,为后续判断补充用户上下文。 - **分层网络信息收集**:收集操作系统、时间、网络接口、IP、DHCP、DNS、默认路由、邻居表和主动探测结果。 - **Windows 深度适配**:利用 Windows 系统能力获取网卡、Wi-Fi、路由、代理与 PPPoE 等诊断信息。 -- **跨平台架构**:通用诊断逻辑位于 `xidio.Core`,平台差异由独立 Provider 隔离;当前已有 Windows 和 macOS Provider,Linux Provider 正在路线图中。 +- **跨平台架构**:通用诊断逻辑位于 `xidio.Core`,平台差异由独立 Provider 隔离;当前已有 Windows、Linux 和 macOS Provider。 - **结构化与可取消**:核心诊断输出结构化数据,长时间任务支持进度报告和 `CancellationToken`。 ## 🚀 Quick Start @@ -53,6 +53,7 @@ xidio/ ├── xidio/ │ ├── xidio.CLI/ # 命令行入口与交互式报告 │ ├── xidio.Core/ # 诊断模型、收集、分析与抽象 +│ ├── xidio.Platform.Linux/ # Linux 平台 Provider │ ├── xidio.Platform.Windows/ # Windows 平台 Provider │ ├── xidio.Platform.macOS/ # macOS 平台 Provider │ └── xidio.slnx # .NET 解决方案 @@ -84,6 +85,8 @@ dotnet run --project xidio/xidio.CLI/xidio.CLI.csproj -f net10.0-windows 可以从发行版安装 .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 @@ -106,7 +109,7 @@ 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 与 Intel Mac 也可使用已启用 `aarch64-darwin` 和 `x86_64-darwin` 的 `nix develop`。 +Apple Silicon Mac 也可使用已启用 `aarch64-darwin` 的 `nix develop`。当前 nixpkgs 已不再支持 `x86_64-darwin`,Intel Mac 请使用原生 .NET 10 SDK。 ## 🗺️ Roadmap diff --git a/ROADMAP.md b/ROADMAP.md index ab65422..24ae0b5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,7 +23,7 @@ xidio 需要在 Windows、Linux 和 macOS 上完成“信息收集 → 分层分 ### 0.2 跨平台收集补全 -- [ ] 建立并接入 `xidio.Platform.Linux`。 +- [x] 建立并接入 `xidio.Platform.Linux`,收集系统、物理网卡、Wi-Fi、DHCP、路由、metric 与邻居表信息。 - [ ] 对齐 Windows、Linux 和 macOS 的基础系统、网卡、路由、DNS、邻居表、代理与 Wi-Fi 收集能力。 - [ ] 补全 PPPoE 链路、会话、错误码与路由检查。 - [ ] 为外部命令解析与平台特性增加测试。 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.nix b/flake.nix index 3557606..de823ad 100644 --- a/flake.nix +++ b/flake.nix @@ -11,7 +11,6 @@ supportedSystems = [ "x86_64-linux" "aarch64-linux" - "x86_64-darwin" "aarch64-darwin" ]; @@ -32,7 +31,10 @@ ] ++ lib.optionals stdenv.isLinux [ clang + iproute2 + iw lld + networkmanager ]; buildInputs = with pkgs; [ 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.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 @@ +