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;
|
- 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;
|
- 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;
|
- 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;
|
- 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;
|
- 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;
|
- limits Links by source, destination `/32`, TCP/UDP port, policy version, and optional TTL;
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@
|
||||||
- [x] автоматическое обновление каждые 30 секунд;
|
- [x] автоматическое обновление каждые 30 секунд;
|
||||||
- [x] короткая локальная история до 240 точек на сервер;
|
- [x] короткая локальная история до 240 точек на сервер;
|
||||||
- [x] встроенный график CPU, RAM и диска;
|
- [x] встроенный график CPU, RAM и диска;
|
||||||
- [ ] экспорт диагностики без секретов;
|
- [x] экспорт диагностики без секретов;
|
||||||
- [x] отдельный прямой SSH-терминал;
|
- [x] отдельный прямой SSH-терминал;
|
||||||
- [x] отдельная terminal identity и подтверждение пользователя;
|
- [x] отдельная terminal identity и подтверждение пользователя;
|
||||||
- [ ] отдельная automation identity для AI-агента.
|
- [ ] отдельная 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>
|
/// </summary>
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
private Window? _window;
|
internal static Window? MainWindow { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes the singleton application object. This is the first line of authored code
|
/// 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>
|
/// <param name="args">Details about the launch request and process.</param>
|
||||||
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
|
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
|
||||||
{
|
{
|
||||||
_window = new MainWindow();
|
MainWindow = new MainWindow();
|
||||||
_window.Activate();
|
MainWindow.Activate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,11 @@
|
||||||
Click="DeleteServerButton_Click"
|
Click="DeleteServerButton_Click"
|
||||||
Icon="Delete"
|
Icon="Delete"
|
||||||
Label="Удалить" />
|
Label="Удалить" />
|
||||||
|
<AppBarButton
|
||||||
|
AutomationProperties.Name="Экспортировать безопасную диагностику"
|
||||||
|
Click="ExportDiagnosticsButton_Click"
|
||||||
|
Icon="Save"
|
||||||
|
Label="Диагностика" />
|
||||||
</CommandBar>
|
</CommandBar>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ using Microsoft.UI.Xaml.Media;
|
||||||
using ServerMonitorManager.Core;
|
using ServerMonitorManager.Core;
|
||||||
using Windows.ApplicationModel.DataTransfer;
|
using Windows.ApplicationModel.DataTransfer;
|
||||||
using Windows.Foundation;
|
using Windows.Foundation;
|
||||||
|
using Windows.Storage;
|
||||||
|
using Windows.Storage.Pickers;
|
||||||
|
|
||||||
namespace ServerMonitorManager_Desktop;
|
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)
|
private async void TerminalButton_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (ServerList.SelectedItem is not ServerViewModel selected)
|
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}");
|
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]
|
[Fact]
|
||||||
public async Task EnrollmentTokenIsAtomicAndIdempotent()
|
public async Task EnrollmentTokenIsAtomicAndIdempotent()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue