Protect monitoring key and add SSH terminal

This commit is contained in:
Ochenstarik 2026-07-16 19:57:31 +07:00
parent 0b642cf9a3
commit 7bd7c950a6
5 changed files with 150 additions and 9 deletions

View file

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

View file

@ -74,6 +74,11 @@
Click="SshKeyButton_Click" Click="SshKeyButton_Click"
Icon="Permissions" Icon="Permissions"
Label="SSH-ключ" /> Label="SSH-ключ" />
<AppBarButton
AutomationProperties.Name="Открыть прямой SSH-терминал"
Click="TerminalButton_Click"
Icon="OpenFile"
Label="Терминал" />
<AppBarButton <AppBarButton
AutomationProperties.Name="Добавить сервер" AutomationProperties.Name="Добавить сервер"
Click="AddServerButton_Click" 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) private async void AddServerButton_Click(object sender, RoutedEventArgs e)
{ {
try try

View file

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

View file

@ -1,5 +1,6 @@
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Security.Cryptography;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Windows.Storage; using Windows.Storage;
@ -26,6 +27,7 @@ public sealed record ServerMetrics(
public sealed partial class SshMonitorService public sealed partial class SshMonitorService
{ {
private const string KeyFileName = "server-monitor-manager-ed25519"; private const string KeyFileName = "server-monitor-manager-ed25519";
private const string ProtectedKeySuffix = ".dpapi";
public async Task<string> EnsureKeyPairAsync(CancellationToken cancellationToken = default) public async Task<string> EnsureKeyPairAsync(CancellationToken cancellationToken = default)
{ {
@ -34,9 +36,11 @@ public sealed partial class SshMonitorService
CreationCollisionOption.OpenIfExists); CreationCollisionOption.OpenIfExists);
var privateKeyPath = Path.Combine(keyFolder.Path, KeyFileName); var privateKeyPath = Path.Combine(keyFolder.Path, KeyFileName);
var publicKeyPath = privateKeyPath + ".pub"; 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[] var arguments = new[]
{ {
"-q", "-t", "ed25519", "-a", "64", "-N", string.Empty, "-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); 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(); return (await File.ReadAllTextAsync(publicKeyPath, cancellationToken)).Trim();
} }
@ -106,7 +119,7 @@ public sealed partial class SshMonitorService
await EnsureKeyPairAsync(cancellationToken); await EnsureKeyPairAsync(cancellationToken);
var localFolder = ApplicationData.Current.LocalFolder.Path; 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 knownHostsPath = Path.Combine(localFolder, "ssh", "known_hosts");
var target = $"{profile.User}@{profile.Host}"; var target = $"{profile.User}@{profile.Host}";
var arguments = new[] var arguments = new[]
@ -121,10 +134,79 @@ public sealed partial class SshMonitorService
target, target,
command command
}; };
return await RunProcessAsync( try
ResolveOpenSshTool("ssh.exe"), {
arguments, return await RunProcessAsync(
cancellationToken); 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) private static void ValidateProfile(ServerProfileData profile)