Export redacted desktop diagnostics
This commit is contained in:
parent
2fe570afd8
commit
160a5c683d
7 changed files with 251 additions and 4 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@
|
|||
- [x] автоматическое обновление каждые 30 секунд;
|
||||
- [x] короткая локальная история до 240 точек на сервер;
|
||||
- [x] встроенный график CPU, RAM и диска;
|
||||
- [ ] экспорт диагностики без секретов;
|
||||
- [x] экспорт диагностики без секретов;
|
||||
- [x] отдельный прямой SSH-терминал;
|
||||
- [x] отдельная terminal identity и подтверждение пользователя;
|
||||
- [ ] отдельная automation identity для AI-агента.
|
||||
|
|
|
|||
149
src/ServerMonitorManager.Core/DiagnosticsExportService.cs
Normal file
149
src/ServerMonitorManager.Core/DiagnosticsExportService.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,11 @@
|
|||
Click="DeleteServerButton_Click"
|
||||
Icon="Delete"
|
||||
Label="Удалить" />
|
||||
<AppBarButton
|
||||
AutomationProperties.Name="Экспортировать безопасную диагностику"
|
||||
Click="ExportDiagnosticsButton_Click"
|
||||
Icon="Save"
|
||||
Label="Диагностика" />
|
||||
</CommandBar>
|
||||
</Grid>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue