Add Windows SSH monitoring MVP #1

Merged
ochenstarik-ui merged 23 commits from agent/windows-ssh-monitoring into main 2026-07-16 15:25:19 +00:00
5 changed files with 150 additions and 9 deletions
Showing only changes of commit 7bd7c950a6 - Show all commits

View file

@ -42,7 +42,7 @@
- [x] атомарное погашение token;
- [ ] отзыв и повторная регистрация Node;
- [ ] подтверждение fingerprint Hub;
- [ ] защита desktop SSH-ключа через DPAPI.
- [x] защита desktop SSH-ключа через DPAPI.
## Этап 4 — управляемые Links
@ -64,8 +64,8 @@
- [x] короткая локальная история до 240 точек на сервер;
- [x] встроенный график CPU, RAM и диска;
- [ ] экспорт диагностики без секретов;
- [ ] отдельный прямой SSH-терминал;
- [ ] отдельная terminal identity и подтверждение пользователя;
- [x] отдельный прямой SSH-терминал;
- [x] отдельная terminal identity и подтверждение пользователя;
- [ ] отдельная automation identity для AI-агента.
## Этап 6 — постоянный control layer

View file

@ -74,6 +74,11 @@
Click="SshKeyButton_Click"
Icon="Permissions"
Label="SSH-ключ" />
<AppBarButton
AutomationProperties.Name="Открыть прямой SSH-терминал"
Click="TerminalButton_Click"
Icon="OpenFile"
Label="Терминал" />
<AppBarButton
AutomationProperties.Name="Добавить сервер"
Click="AddServerButton_Click"

View file

@ -108,6 +108,59 @@ public sealed partial class MainPage : Page
}
}
private async void TerminalButton_Click(object sender, RoutedEventArgs e)
{
if (ServerList.SelectedItem is not ServerViewModel selected)
{
ShowInfo("Сервер не выбран", "Выберите сервер, к которому нужно открыть SSH-терминал.", InfoBarSeverity.Warning);
return;
}
var userBox = new TextBox
{
Header = "Unix-пользователь",
PlaceholderText = "starik",
MinWidth = 360
};
AutomationProperties.SetName(userBox, "Пользователь интерактивного SSH-терминала");
var dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = $"SSH-терминал: {selected.Name}",
Content = new StackPanel
{
Spacing = 12,
Children =
{
new TextBlock
{
Text = "Терминал использует системный OpenSSH и ваши обычные SSH-ключи. Ключ мониторинга с ограниченной командой здесь не применяется.",
TextWrapping = TextWrapping.Wrap
},
userBox
}
},
PrimaryButtonText = "Открыть",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Primary
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary)
{
return;
}
try
{
_ssh.OpenInteractiveTerminal(selected.Profile, userBox.Text.Trim());
ShowInfo("SSH-терминал открыт", $"Подключение к {selected.Profile.Host} запущено от имени {userBox.Text.Trim()}.", InfoBarSeverity.Success);
}
catch (Exception exception)
{
ShowInfo("Не удалось открыть SSH-терминал", exception.Message, InfoBarSeverity.Error);
}
}
private async void AddServerButton_Click(object sender, RoutedEventArgs e)
{
try

View file

@ -56,6 +56,7 @@
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2270" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.4.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
</ItemGroup>
<!--

View file

@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using Windows.Storage;
@ -26,6 +27,7 @@ public sealed record ServerMetrics(
public sealed partial class SshMonitorService
{
private const string KeyFileName = "server-monitor-manager-ed25519";
private const string ProtectedKeySuffix = ".dpapi";
public async Task<string> EnsureKeyPairAsync(CancellationToken cancellationToken = default)
{
@ -34,9 +36,11 @@ public sealed partial class SshMonitorService
CreationCollisionOption.OpenIfExists);
var privateKeyPath = Path.Combine(keyFolder.Path, KeyFileName);
var publicKeyPath = privateKeyPath + ".pub";
var protectedKeyPath = privateKeyPath + ProtectedKeySuffix;
if (!File.Exists(privateKeyPath) || !File.Exists(publicKeyPath))
if (!File.Exists(publicKeyPath) || (!File.Exists(privateKeyPath) && !File.Exists(protectedKeyPath)))
{
File.Delete(protectedKeyPath);
var arguments = new[]
{
"-q", "-t", "ed25519", "-a", "64", "-N", string.Empty,
@ -45,6 +49,15 @@ public sealed partial class SshMonitorService
await RunProcessAsync(ResolveOpenSshTool("ssh-keygen.exe"), arguments, cancellationToken);
}
if (File.Exists(privateKeyPath))
{
var privateKey = await File.ReadAllBytesAsync(privateKeyPath, cancellationToken);
var protectedKey = ProtectedData.Protect(privateKey, null, DataProtectionScope.CurrentUser);
await File.WriteAllBytesAsync(protectedKeyPath, protectedKey, cancellationToken);
CryptographicOperations.ZeroMemory(privateKey);
File.Delete(privateKeyPath);
}
return (await File.ReadAllTextAsync(publicKeyPath, cancellationToken)).Trim();
}
@ -106,7 +119,7 @@ public sealed partial class SshMonitorService
await EnsureKeyPairAsync(cancellationToken);
var localFolder = ApplicationData.Current.LocalFolder.Path;
var privateKeyPath = Path.Combine(localFolder, "ssh", KeyFileName);
var privateKeyPath = await MaterializePrivateKeyAsync(cancellationToken);
var knownHostsPath = Path.Combine(localFolder, "ssh", "known_hosts");
var target = $"{profile.User}@{profile.Host}";
var arguments = new[]
@ -121,10 +134,79 @@ public sealed partial class SshMonitorService
target,
command
};
return await RunProcessAsync(
ResolveOpenSshTool("ssh.exe"),
arguments,
cancellationToken);
try
{
return await RunProcessAsync(
ResolveOpenSshTool("ssh.exe"),
arguments,
cancellationToken);
}
finally
{
File.Delete(privateKeyPath);
}
}
public void OpenInteractiveTerminal(ServerProfileData profile, string terminalUser)
{
ValidateProfile(profile);
if (!SafeUserRegex().IsMatch(terminalUser))
{
throw new InvalidOperationException("Некорректное имя пользователя терминала.");
}
var ssh = ResolveOpenSshTool("ssh.exe");
var sshArguments = new[]
{
"-p", profile.Port.ToString(CultureInfo.InvariantCulture),
$"{terminalUser}@{profile.Host}"
};
var windowsTerminal = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"WindowsApps",
"wt.exe");
var startInfo = new ProcessStartInfo
{
FileName = File.Exists(windowsTerminal) ? windowsTerminal : ssh,
UseShellExecute = true
};
if (File.Exists(windowsTerminal))
{
startInfo.ArgumentList.Add("new-tab");
startInfo.ArgumentList.Add(ssh);
}
foreach (var argument in sshArguments)
{
startInfo.ArgumentList.Add(argument);
}
_ = Process.Start(startInfo)
?? throw new InvalidOperationException("Не удалось открыть SSH-терминал.");
}
private static async Task<string> MaterializePrivateKeyAsync(CancellationToken cancellationToken)
{
var localFolder = ApplicationData.Current.LocalFolder.Path;
var protectedKeyPath = Path.Combine(localFolder, "ssh", KeyFileName + ProtectedKeySuffix);
var protectedKey = await File.ReadAllBytesAsync(protectedKeyPath, cancellationToken);
var privateKey = ProtectedData.Unprotect(protectedKey, null, DataProtectionScope.CurrentUser);
var temporaryFile = await ApplicationData.Current.TemporaryFolder.CreateFileAsync(
$"{KeyFileName}-{Guid.NewGuid():N}",
CreationCollisionOption.FailIfExists);
try
{
await File.WriteAllBytesAsync(temporaryFile.Path, privateKey, cancellationToken);
return temporaryFile.Path;
}
catch
{
File.Delete(temporaryFile.Path);
throw;
}
finally
{
CryptographicOperations.ZeroMemory(privateKey);
}
}
private static void ValidateProfile(ServerProfileData profile)