From 160a5c683dc9c786c74c475fce467208a2b13f62 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Fri, 17 Jul 2026 00:56:29 +0700 Subject: [PATCH] Export redacted desktop diagnostics --- README.md | 1 + docs/roadmap.md | 2 +- .../DiagnosticsExportService.cs | 149 ++++++++++++++++++ src/ServerMonitorManager.Desktop/App.xaml.cs | 6 +- .../MainPage.xaml | 5 + .../MainPage.xaml.cs | 64 ++++++++ .../ControlStoreTests.cs | 28 ++++ 7 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 src/ServerMonitorManager.Core/DiagnosticsExportService.cs diff --git a/README.md b/README.md index f84d555..12eb6d5 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The current alpha combines a packaged WinUI 3 desktop client, an ASP.NET Core co - keeps several server profiles, groups, tags, favorites, alerts, and short local metric history; - generates a dedicated Ed25519 SSH key and stores private material only on the Windows device; - opens direct SSH terminals without sending a private terminal key to the Hub; +- exports support diagnostics with hashed endpoint identities and without hosts, users, keys, certificates, or tokens; - joins servers through one Hub with a public IP; secondary servers need outbound access only; - creates directional Links such as `AI agent → Home server:22` and disables each Link independently; - limits Links by source, destination `/32`, TCP/UDP port, policy version, and optional TTL; diff --git a/docs/roadmap.md b/docs/roadmap.md index a524b5a..2283874 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -64,7 +64,7 @@ - [x] автоматическое обновление каждые 30 секунд; - [x] короткая локальная история до 240 точек на сервер; - [x] встроенный график CPU, RAM и диска; -- [ ] экспорт диагностики без секретов; +- [x] экспорт диагностики без секретов; - [x] отдельный прямой SSH-терминал; - [x] отдельная terminal identity и подтверждение пользователя; - [ ] отдельная automation identity для AI-агента. diff --git a/src/ServerMonitorManager.Core/DiagnosticsExportService.cs b/src/ServerMonitorManager.Core/DiagnosticsExportService.cs new file mode 100644 index 0000000..2c83043 --- /dev/null +++ b/src/ServerMonitorManager.Core/DiagnosticsExportService.cs @@ -0,0 +1,149 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace ServerMonitorManager.Core; + +public sealed record DiagnosticServerInput( + string EndpointIdentity, + bool IsHub, + bool IsOnline, + bool HasWarning, + double CpuPercent); + +public sealed record DiagnosticNodeInput( + string NodeIdentity, + string State, + int HandshakeAgeSeconds); + +public sealed record DiagnosticLinkInput( + string SourceIdentity, + string TargetIdentity, + string Protocol, + int Port, + string State, + long Version, + long ExpiresUnix); + +public sealed record DiagnosticMetricInput( + string ServerIdentity, + DateTimeOffset Timestamp, + double CpuPercent, + double MemoryPercent, + double DiskPercent); + +public static class DiagnosticsExportService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + + public static string CreateJson( + IEnumerable servers, + IEnumerable nodes, + IEnumerable links, + IEnumerable history, + bool controlConfigured) + { + var serverSnapshots = servers.Select(server => new DiagnosticServer( + Fingerprint($"endpoint:{server.EndpointIdentity}"), + server.IsHub, + server.IsOnline, + server.HasWarning, + Math.Round(server.CpuPercent, 2))).ToArray(); + var nodeSnapshots = nodes.Select(node => new DiagnosticNode( + Fingerprint($"node:{node.NodeIdentity}"), + NormalizeState(node.State), + node.HandshakeAgeSeconds)).ToArray(); + var linkSnapshots = links.Select(link => new DiagnosticLink( + Fingerprint($"node-name:{link.SourceIdentity}"), + Fingerprint($"node-name:{link.TargetIdentity}"), + link.Protocol, + link.Port, + NormalizeState(link.State), + link.Version, + link.ExpiresUnix)).ToArray(); + var metricSnapshots = history + .OrderBy(sample => sample.Timestamp) + .Select(sample => new DiagnosticMetric( + Fingerprint($"server-id:{sample.ServerIdentity}"), + sample.Timestamp, + Math.Round(sample.CpuPercent, 2), + Math.Round(sample.MemoryPercent, 2), + Math.Round(sample.DiskPercent, 2))) + .ToArray(); + var snapshot = new DiagnosticSnapshot( + 1, + DateTimeOffset.UtcNow, + Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown", + Environment.OSVersion.VersionString, + RuntimeInformation.ProcessArchitecture.ToString(), + controlConfigured, + serverSnapshots, + nodeSnapshots, + linkSnapshots, + metricSnapshots); + return JsonSerializer.Serialize(snapshot, JsonOptions); + } + + private static string Fingerprint(string value) + => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..16].ToLowerInvariant(); + + private static string NormalizeState(string value) + => value.Trim().ToLowerInvariant() switch + { + "online" => "online", + "offline" => "offline", + "connecting" => "connecting", + "active" => "active", + "disconnecting" => "disconnecting", + "partial" => "partial", + "disabled" => "disabled", + "failed" => "failed", + _ => "unknown" + }; + + private sealed record DiagnosticSnapshot( + int FormatVersion, + DateTimeOffset GeneratedAt, + string AppVersion, + string OsVersion, + string Architecture, + bool ControlConfigured, + IReadOnlyList Servers, + IReadOnlyList Nodes, + IReadOnlyList Links, + IReadOnlyList Metrics); + + private sealed record DiagnosticServer( + string EndpointFingerprint, + bool IsHub, + bool IsOnline, + bool HasWarning, + double CpuPercent); + + private sealed record DiagnosticNode( + string NodeFingerprint, + string State, + int HandshakeAgeSeconds); + + private sealed record DiagnosticLink( + string SourceFingerprint, + string TargetFingerprint, + string Protocol, + int Port, + string State, + long Version, + long ExpiresUnix); + + private sealed record DiagnosticMetric( + string ServerFingerprint, + DateTimeOffset Timestamp, + double CpuPercent, + double MemoryPercent, + double DiskPercent); +} diff --git a/src/ServerMonitorManager.Desktop/App.xaml.cs b/src/ServerMonitorManager.Desktop/App.xaml.cs index ad15c41..a8d9e0c 100644 --- a/src/ServerMonitorManager.Desktop/App.xaml.cs +++ b/src/ServerMonitorManager.Desktop/App.xaml.cs @@ -21,7 +21,7 @@ namespace ServerMonitorManager_Desktop; /// public partial class App : Application { - private Window? _window; + internal static Window? MainWindow { get; private set; } /// /// Initializes the singleton application object. This is the first line of authored code @@ -38,7 +38,7 @@ public partial class App : Application /// Details about the launch request and process. protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) { - _window = new MainWindow(); - _window.Activate(); + MainWindow = new MainWindow(); + MainWindow.Activate(); } } diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml b/src/ServerMonitorManager.Desktop/MainPage.xaml index c84382d..32def29 100644 --- a/src/ServerMonitorManager.Desktop/MainPage.xaml +++ b/src/ServerMonitorManager.Desktop/MainPage.xaml @@ -99,6 +99,11 @@ Click="DeleteServerButton_Click" Icon="Delete" Label="Удалить" /> + diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs index 9fe4b65..cc6f579 100644 --- a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs +++ b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs @@ -7,6 +7,8 @@ using Microsoft.UI.Xaml.Media; using ServerMonitorManager.Core; using Windows.ApplicationModel.DataTransfer; using Windows.Foundation; +using Windows.Storage; +using Windows.Storage.Pickers; namespace ServerMonitorManager_Desktop; @@ -114,6 +116,68 @@ public sealed partial class MainPage : Page } } + private async void ExportDiagnosticsButton_Click(object sender, RoutedEventArgs e) + { + try + { + var window = App.MainWindow + ?? throw new InvalidOperationException("Главное окно приложения недоступно."); + var picker = new FileSavePicker + { + SuggestedStartLocation = PickerLocationId.DocumentsLibrary, + SuggestedFileName = $"server-monitor-manager-diagnostics-{DateTimeOffset.Now:yyyyMMdd-HHmmss}" + }; + picker.FileTypeChoices.Add("JSON diagnostics", [".json"]); + WinRT.Interop.InitializeWithWindow.Initialize( + picker, + WinRT.Interop.WindowNative.GetWindowHandle(window)); + var file = await picker.PickSaveFileAsync(); + if (file is null) + { + return; + } + + var json = DiagnosticsExportService.CreateJson( + Servers.Select(server => new DiagnosticServerInput( + $"{server.Profile.Host}:{server.Profile.Port}", + server.IsHub, + server.IsOnline, + server.HasWarning, + server.CpuPercent)), + MeshNodes.Select(node => new DiagnosticNodeInput( + $"{node.Name}:{node.Address}", + node.State, + node.HandshakeAgeSeconds)), + MeshLinks.Select(link => new DiagnosticLinkInput( + link.Source, + link.Target, + link.Protocol, + link.Port, + link.State, + link.Version, + link.ExpiresUnix)), + _history.Select(sample => new DiagnosticMetricInput( + sample.ServerId, + sample.Timestamp, + sample.CpuPercent, + sample.MemoryPercent, + sample.DiskPercent)), + _control.IsConfigured); + await FileIO.WriteTextAsync(file, json); + ShowInfo( + "Диагностика экспортирована", + "Файл не содержит адресов серверов, пользователей, ключей, сертификатов или токенов.", + InfoBarSeverity.Success); + } + catch (Exception exception) + { + ShowInfo( + "Не удалось экспортировать диагностику", + CompactError(exception), + InfoBarSeverity.Error); + } + } + private async void TerminalButton_Click(object sender, RoutedEventArgs e) { if (ServerList.SelectedItem is not ServerViewModel selected) diff --git a/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs b/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs index 5239562..cbb3fee 100644 --- a/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs +++ b/tests/ServerMonitorManager.Control.Tests/ControlStoreTests.cs @@ -10,6 +10,34 @@ public sealed class ControlStoreTests : IAsyncDisposable { private readonly string _directory = Path.Combine(Path.GetTempPath(), $"smm-tests-{Guid.NewGuid():N}"); + [Fact] + public void DiagnosticsExportOmitsRawIdentitiesAndNormalizesStates() + { + const string endpoint = "root@secret.example.test:20202"; + const string node = "private-home-node:10.77.0.23"; + const string source = "confidential-ai-agent"; + const string target = "confidential-home"; + const string serverId = "private-local-server-id"; + + var json = DiagnosticsExportService.CreateJson( + [new DiagnosticServerInput(endpoint, true, true, false, 12.345)], + [new DiagnosticNodeInput(node, "unexpected-private-state", 42)], + [new DiagnosticLinkInput(source, target, "tcp", 22, "Partial", 7, 0)], + [new DiagnosticMetricInput(serverId, DateTimeOffset.UtcNow, 1, 2, 3)], + controlConfigured: true); + + Assert.DoesNotContain(endpoint, json, StringComparison.Ordinal); + Assert.DoesNotContain("secret.example.test", json, StringComparison.Ordinal); + Assert.DoesNotContain(node, json, StringComparison.Ordinal); + Assert.DoesNotContain(source, json, StringComparison.Ordinal); + Assert.DoesNotContain(target, json, StringComparison.Ordinal); + Assert.DoesNotContain(serverId, json, StringComparison.Ordinal); + Assert.Contains("endpoint_fingerprint", json, StringComparison.Ordinal); + Assert.Contains("node_fingerprint", json, StringComparison.Ordinal); + Assert.Contains("\"state\": \"unknown\"", json, StringComparison.Ordinal); + Assert.Contains("\"state\": \"partial\"", json, StringComparison.Ordinal); + } + [Fact] public async Task EnrollmentTokenIsAtomicAndIdempotent() {