Export redacted desktop diagnostics

This commit is contained in:
Ochenstarik 2026-07-17 00:56:29 +07:00
parent 2fe570afd8
commit 160a5c683d
7 changed files with 251 additions and 4 deletions

View file

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

View file

@ -64,7 +64,7 @@
- [x] автоматическое обновление каждые 30 секунд;
- [x] короткая локальная история до 240 точек на сервер;
- [x] встроенный график CPU, RAM и диска;
- [ ] экспорт диагностики без секретов;
- [x] экспорт диагностики без секретов;
- [x] отдельный прямой SSH-терминал;
- [x] отдельная terminal identity и подтверждение пользователя;
- [ ] отдельная automation identity для AI-агента.

View file

@ -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<DiagnosticServerInput> servers,
IEnumerable<DiagnosticNodeInput> nodes,
IEnumerable<DiagnosticLinkInput> links,
IEnumerable<DiagnosticMetricInput> 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<DiagnosticServer> Servers,
IReadOnlyList<DiagnosticNode> Nodes,
IReadOnlyList<DiagnosticLink> Links,
IReadOnlyList<DiagnosticMetric> 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);
}

View file

@ -21,7 +21,7 @@ namespace ServerMonitorManager_Desktop;
/// </summary>
public partial class App : Application
{
private Window? _window;
internal static Window? MainWindow { get; private set; }
/// <summary>
/// Initializes the singleton application object. This is the first line of authored code
@ -38,7 +38,7 @@ public partial class App : Application
/// <param name="args">Details about the launch request and process.</param>
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
{
_window = new MainWindow();
_window.Activate();
MainWindow = new MainWindow();
MainWindow.Activate();
}
}

View file

@ -99,6 +99,11 @@
Click="DeleteServerButton_Click"
Icon="Delete"
Label="Удалить" />
<AppBarButton
AutomationProperties.Name="Экспортировать безопасную диагностику"
Click="ExportDiagnosticsButton_Click"
Icon="Save"
Label="Диагностика" />
</CommandBar>
</Grid>

View file

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

View file

@ -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()
{