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;
|
||||
- [ ] отзыв и повторная регистрация 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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<!--
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue