Protect monitoring key and add SSH terminal
This commit is contained in:
parent
0b642cf9a3
commit
7bd7c950a6
5 changed files with 150 additions and 9 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|
|
||||||
|
|
@ -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,11 +134,80 @@ public sealed partial class SshMonitorService
|
||||||
target,
|
target,
|
||||||
command
|
command
|
||||||
};
|
};
|
||||||
|
try
|
||||||
|
{
|
||||||
return await RunProcessAsync(
|
return await RunProcessAsync(
|
||||||
ResolveOpenSshTool("ssh.exe"),
|
ResolveOpenSshTool("ssh.exe"),
|
||||||
arguments,
|
arguments,
|
||||||
cancellationToken);
|
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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue