fix(desktop): pin terminal host key #10
18 changed files with 563 additions and 135 deletions
|
|
@ -689,6 +689,31 @@ install_node_from_code() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refresh_agent_uid() {
|
||||||
|
local env_file="$ETC_DIR/agent.env" agent_uid temp line found=0
|
||||||
|
[[ -f "$env_file" && ! -L "$env_file" ]] \
|
||||||
|
|| fail "Agent environment is missing or unsafe: $env_file"
|
||||||
|
agent_uid="$(id -u "$AGENT_USER")"
|
||||||
|
temp="$(mktemp "$ETC_DIR/.agent.env.XXXXXXXX")"
|
||||||
|
if ! while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
if [[ "$line" == SMM_AgentUid=* ]]; then
|
||||||
|
printf 'SMM_AgentUid=%s\n' "$agent_uid"
|
||||||
|
found=1
|
||||||
|
else
|
||||||
|
printf '%s\n' "$line"
|
||||||
|
fi
|
||||||
|
done <"$env_file" >"$temp"; then
|
||||||
|
rm -f -- "$temp"
|
||||||
|
fail "Could not refresh SMM_AgentUid in agent.env."
|
||||||
|
fi
|
||||||
|
if [[ "$found" == "0" ]]; then
|
||||||
|
printf 'SMM_AgentUid=%s\n' "$agent_uid" >>"$temp"
|
||||||
|
fi
|
||||||
|
chown root:"$AGENT_USER" "$temp"
|
||||||
|
chmod 0640 "$temp"
|
||||||
|
mv -fT -- "$temp" "$env_file"
|
||||||
|
}
|
||||||
|
|
||||||
update_role() {
|
update_role() {
|
||||||
local role="$1" archive="$2" binary unit user backup_id
|
local role="$1" archive="$2" binary unit user backup_id
|
||||||
require_root
|
require_root
|
||||||
|
|
@ -700,6 +725,9 @@ update_role() {
|
||||||
*) fail "Unknown role: $role" ;;
|
*) fail "Unknown role: $role" ;;
|
||||||
esac
|
esac
|
||||||
[[ -x "$TEMP_DIR/$role/$binary" ]] || fail "$role binary is missing."
|
[[ -x "$TEMP_DIR/$role/$binary" ]] || fail "$role binary is missing."
|
||||||
|
if [[ "$role" == "agent" ]]; then
|
||||||
|
refresh_agent_uid
|
||||||
|
fi
|
||||||
systemctl stop "$unit"
|
systemctl stop "$unit"
|
||||||
if [[ "$role" == "agent" ]]; then
|
if [[ "$role" == "agent" ]]; then
|
||||||
systemctl stop "$PROVISIONING_HELPER_UNIT" 2>/dev/null || true
|
systemctl stop "$PROVISIONING_HELPER_UNIT" 2>/dev/null || true
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,33 @@ public sealed partial class MainPage : Page
|
||||||
DeleteServerButton_Click(this, new RoutedEventArgs());
|
DeleteServerButton_Click(this, new RoutedEventArgs());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal async Task ConfirmHostKeyFromPageAsync(ServerViewModel? server)
|
||||||
|
{
|
||||||
|
if (server is null)
|
||||||
|
{
|
||||||
|
ShowInfo("Сервер не выбран", "Выберите профиль для подтверждения host key.", InfoBarSeverity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fingerprint = await ConfirmHostKeyAsync(server.Profile);
|
||||||
|
if (fingerprint is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var index = Servers.IndexOf(server);
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var confirmed = new ServerViewModel(server.Profile with { HostKeyFingerprint = fingerprint });
|
||||||
|
Servers[index] = confirmed;
|
||||||
|
ServerList.SelectedItem = confirmed;
|
||||||
|
await SaveProfilesAsync();
|
||||||
|
await RefreshServerAsync(confirmed);
|
||||||
|
ShowInfo("SSH host key подтверждён", confirmed.Name, InfoBarSeverity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
internal void OpenTerminalFromPage(ServerViewModel? server)
|
internal void OpenTerminalFromPage(ServerViewModel? server)
|
||||||
{
|
{
|
||||||
ServerList.SelectedItem = server;
|
ServerList.SelectedItem = server;
|
||||||
|
|
@ -121,6 +148,18 @@ public sealed partial class MainPage : Page
|
||||||
RenderHistory();
|
RenderHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var pendingNames = Servers
|
||||||
|
.Where(server => server.HostKeyPendingConfirmation)
|
||||||
|
.Select(server => server.Name)
|
||||||
|
.ToArray();
|
||||||
|
if (pendingNames.Length > 0)
|
||||||
|
{
|
||||||
|
ShowInfo(
|
||||||
|
"Требуется подтверждение SSH host key",
|
||||||
|
$"Нажмите «Подтвердить host key» в карточке: {string.Join(", ", pendingNames)}.",
|
||||||
|
InfoBarSeverity.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
if (Servers.Count > 0)
|
if (Servers.Count > 0)
|
||||||
{
|
{
|
||||||
await RefreshAllAsync();
|
await RefreshAllAsync();
|
||||||
|
|
@ -628,7 +667,16 @@ public sealed partial class MainPage : Page
|
||||||
checked((int)portBox.Value),
|
checked((int)portBox.Value),
|
||||||
userBox.Text.Trim(),
|
userBox.Text.Trim(),
|
||||||
hubBox.IsChecked == true);
|
hubBox.IsChecked == true);
|
||||||
var hostKeyFingerprint = await ConfirmHostKeyAsync(updatedProfile);
|
var knownHostsDirectory = Path.Combine(
|
||||||
|
ApplicationData.Current.LocalFolder.Path,
|
||||||
|
"ssh",
|
||||||
|
"known-hosts");
|
||||||
|
var hostKeyFingerprint = SshHostKeyTrust.CanReusePin(
|
||||||
|
knownHostsDirectory,
|
||||||
|
selected.Profile,
|
||||||
|
updatedProfile)
|
||||||
|
? selected.Profile.HostKeyFingerprint
|
||||||
|
: await ConfirmHostKeyAsync(updatedProfile);
|
||||||
if (hostKeyFingerprint is null)
|
if (hostKeyFingerprint is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|
@ -698,7 +746,8 @@ public sealed partial class MainPage : Page
|
||||||
WarningValueText.Text = warnings.ToString(CultureInfo.InvariantCulture);
|
WarningValueText.Text = warnings.ToString(CultureInfo.InvariantCulture);
|
||||||
WarningDetailText.Text = warnings == 0 ? "Нет предупреждений" : "Проверьте доступность и ресурсы";
|
WarningDetailText.Text = warnings == 0 ? "Нет предупреждений" : "Проверьте доступность и ресурсы";
|
||||||
HeaderStatusText.Text = $"SSH monitoring · {Servers.Count} сервер(а) · обновлено {DateTime.Now:HH:mm:ss}";
|
HeaderStatusText.Text = $"SSH monitoring · {Servers.Count} сервер(а) · обновлено {DateTime.Now:HH:mm:ss}";
|
||||||
if (Servers.Any(server => server.IsHub) || _control.IsConfigured)
|
if (Servers.Any(server => server.IsHub && !server.HostKeyPendingConfirmation)
|
||||||
|
|| _control.IsConfigured)
|
||||||
{
|
{
|
||||||
await RefreshMeshAsync(showSuccess: false);
|
await RefreshMeshAsync(showSuccess: false);
|
||||||
}
|
}
|
||||||
|
|
@ -711,6 +760,14 @@ public sealed partial class MainPage : Page
|
||||||
|
|
||||||
private async Task RefreshServerAsync(ServerViewModel server)
|
private async Task RefreshServerAsync(ServerViewModel server)
|
||||||
{
|
{
|
||||||
|
if (server.HostKeyPendingConfirmation)
|
||||||
|
{
|
||||||
|
server.Status = "Требуется подтверждение host key";
|
||||||
|
server.IsOnline = false;
|
||||||
|
server.HasWarning = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
server.Status = "Подключение…";
|
server.Status = "Подключение…";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<StackPanel VerticalAlignment="Center" Spacing="2">
|
<StackPanel VerticalAlignment="Center" Spacing="2">
|
||||||
<TextBlock FontWeight="SemiBold" Text="{x:Bind Name}" />
|
<TextBlock FontWeight="SemiBold" Text="{x:Bind Name}" />
|
||||||
|
|
@ -67,6 +68,12 @@
|
||||||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind HealthText, Mode=OneWay}" />
|
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind HealthText, Mode=OneWay}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Grid.Column="3" VerticalAlignment="Center" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind LatencyText, Mode=OneWay}" />
|
<TextBlock Grid.Column="3" VerticalAlignment="Center" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind LatencyText, Mode=OneWay}" />
|
||||||
|
<Button
|
||||||
|
Grid.Column="4"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
AutomationProperties.Name="Подтвердить SSH host key"
|
||||||
|
Click="ConfirmHostKeyButton_Click"
|
||||||
|
Content="{x:Bind HostKeyConfirmationAction}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListView.ItemTemplate>
|
</ListView.ItemTemplate>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ public sealed partial class ServersPage : Page
|
||||||
|
|
||||||
private void DeleteButton_Click(object sender, RoutedEventArgs e) => _host.DeleteServerFromPage(SelectedServer);
|
private void DeleteButton_Click(object sender, RoutedEventArgs e) => _host.DeleteServerFromPage(SelectedServer);
|
||||||
|
|
||||||
|
private async void ConfirmHostKeyButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
=> await _host.ConfirmHostKeyFromPageAsync((sender as FrameworkElement)?.DataContext as ServerViewModel);
|
||||||
|
|
||||||
private async void RefreshButton_Click(object sender, RoutedEventArgs e)
|
private async void RefreshButton_Click(object sender, RoutedEventArgs e)
|
||||||
=> await _host.RefreshServersFromPageAsync();
|
=> await _host.RefreshServersFromPageAsync();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ public sealed record ServerProfileData(
|
||||||
|
|
||||||
public sealed class ServerViewModel : INotifyPropertyChanged
|
public sealed class ServerViewModel : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
private string _status = "Ожидает проверки";
|
private string _status;
|
||||||
private string _cpuText = "—";
|
private string _cpuText = "—";
|
||||||
private double _cpuPercent;
|
private double _cpuPercent;
|
||||||
private string _memoryText = "—";
|
private string _memoryText = "—";
|
||||||
|
|
@ -24,12 +24,22 @@ public sealed class ServerViewModel : INotifyPropertyChanged
|
||||||
private bool _isOnline;
|
private bool _isOnline;
|
||||||
private bool _hasWarning;
|
private bool _hasWarning;
|
||||||
|
|
||||||
public ServerViewModel(ServerProfileData profile) => Profile = profile;
|
public ServerViewModel(ServerProfileData profile)
|
||||||
|
{
|
||||||
|
Profile = profile;
|
||||||
|
_status = HostKeyPendingConfirmation
|
||||||
|
? "Требуется подтверждение host key"
|
||||||
|
: "Ожидает проверки";
|
||||||
|
}
|
||||||
|
|
||||||
public ServerProfileData Profile { get; }
|
public ServerProfileData Profile { get; }
|
||||||
public string Name => Profile.Name;
|
public string Name => Profile.Name;
|
||||||
public string Endpoint => $"{Profile.User}@{Profile.Host}:{Profile.Port}";
|
public string Endpoint => $"{Profile.User}@{Profile.Host}:{Profile.Port}";
|
||||||
public bool IsHub => Profile.IsHub;
|
public bool IsHub => Profile.IsHub;
|
||||||
|
public bool HostKeyPendingConfirmation => string.IsNullOrWhiteSpace(Profile.HostKeyFingerprint);
|
||||||
|
public string HostKeyConfirmationAction => HostKeyPendingConfirmation
|
||||||
|
? "Подтвердить host key"
|
||||||
|
: "Подтвердить заново";
|
||||||
public string Status { get => _status; set => Set(ref _status, value); }
|
public string Status { get => _status; set => Set(ref _status, value); }
|
||||||
public string CpuText { get => _cpuText; set => Set(ref _cpuText, value); }
|
public string CpuText { get => _cpuText; set => Set(ref _cpuText, value); }
|
||||||
public double CpuPercent { get => _cpuPercent; set => Set(ref _cpuPercent, value); }
|
public double CpuPercent { get => _cpuPercent; set => Set(ref _cpuPercent, value); }
|
||||||
|
|
|
||||||
69
src/ServerMonitorManager.Desktop/SshConnectionArguments.cs
Normal file
69
src/ServerMonitorManager.Desktop/SshConnectionArguments.cs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace ServerMonitorManager_Desktop;
|
||||||
|
|
||||||
|
internal static class SshConnectionArguments
|
||||||
|
{
|
||||||
|
internal static string[] BuildRestricted(
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
string user,
|
||||||
|
string knownHostsPath,
|
||||||
|
string? expectedFingerprint,
|
||||||
|
string privateKeyPath,
|
||||||
|
string command)
|
||||||
|
{
|
||||||
|
var arguments = BuildTrusted(host, port, user, knownHostsPath, expectedFingerprint);
|
||||||
|
arguments.InsertRange(4,
|
||||||
|
[
|
||||||
|
"-i", privateKeyPath,
|
||||||
|
"-o", "BatchMode=yes",
|
||||||
|
"-o", "ConnectTimeout=8",
|
||||||
|
"-o", "IdentitiesOnly=yes",
|
||||||
|
"-o", "IdentityAgent=none"
|
||||||
|
]);
|
||||||
|
arguments.Add(command);
|
||||||
|
return [.. arguments];
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string[] BuildInteractive(
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
string user,
|
||||||
|
string knownHostsPath,
|
||||||
|
string? expectedFingerprint)
|
||||||
|
=> [.. BuildTrusted(host, port, user, knownHostsPath, expectedFingerprint)];
|
||||||
|
|
||||||
|
private static List<string> BuildTrusted(
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
string user,
|
||||||
|
string knownHostsPath,
|
||||||
|
string? expectedFingerprint)
|
||||||
|
{
|
||||||
|
if (!SshHostKeyTrust.IsTrusted(
|
||||||
|
knownHostsPath,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
expectedFingerprint))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"SSH host key не подтверждён. Нажмите «Подтвердить host key» в карточке сервера.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
"-F", "none",
|
||||||
|
"-p", port.ToString(CultureInfo.InvariantCulture),
|
||||||
|
"-o", "StrictHostKeyChecking=yes",
|
||||||
|
"-o", $"UserKnownHostsFile={knownHostsPath}",
|
||||||
|
"-o", "GlobalKnownHostsFile=none",
|
||||||
|
"-o", "KnownHostsCommand=none",
|
||||||
|
"-o", "UpdateHostKeys=no",
|
||||||
|
"-o", "VerifyHostKeyDNS=no",
|
||||||
|
"-o", "CanonicalizeHostname=no",
|
||||||
|
"-o", "CheckHostIP=no",
|
||||||
|
$"{user}@{host}"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -130,16 +130,28 @@ internal static class SshHostKeyTrust
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var endpoint = FormatEndpoint(host, port);
|
string? pin = null;
|
||||||
foreach (var line in File.ReadLines(path))
|
foreach (var line in File.ReadLines(path))
|
||||||
{
|
{
|
||||||
var parts = SplitFields(line);
|
var trimmed = line.Trim();
|
||||||
if (parts.Length < 3
|
if (trimmed.Length == 0 || trimmed.StartsWith('#'))
|
||||||
|| !string.Equals(parts[0], endpoint, StringComparison.Ordinal)
|
|
||||||
|| !PreferredKeyTypes.Contains(parts[1], StringComparer.Ordinal))
|
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (pin is not null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pin = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = pin is null ? [] : SplitFields(pin);
|
||||||
|
if (parts.Length != 3
|
||||||
|
|| !string.Equals(parts[0], FormatEndpoint(host, port), StringComparison.Ordinal)
|
||||||
|
|| !PreferredKeyTypes.Contains(parts[1], StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -147,10 +159,7 @@ internal static class SshHostKeyTrust
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var actual = $"SHA256:{Convert.ToBase64String(SHA256.HashData(keyBlob)).TrimEnd('=')}";
|
var actual = $"SHA256:{Convert.ToBase64String(SHA256.HashData(keyBlob)).TrimEnd('=')}";
|
||||||
if (string.Equals(actual, expectedFingerprint, StringComparison.Ordinal))
|
return string.Equals(actual, expectedFingerprint, StringComparison.Ordinal);
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -162,9 +171,26 @@ internal static class SshHostKeyTrust
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static bool CanReusePin(
|
||||||
|
string directory,
|
||||||
|
ServerProfileData current,
|
||||||
|
ServerProfileData updated)
|
||||||
|
{
|
||||||
|
if (!string.Equals(current.Host, updated.Host, StringComparison.Ordinal)
|
||||||
|
|| current.Port != updated.Port)
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var path = GetPinPath(directory, current.Host, current.Port);
|
||||||
|
return IsTrusted(
|
||||||
|
path,
|
||||||
|
current.Host,
|
||||||
|
current.Port,
|
||||||
|
current.HostKeyFingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private static string[] SplitFields(string line)
|
private static string[] SplitFields(string line)
|
||||||
=> line.Split(
|
=> line.Split(
|
||||||
|
|
|
||||||
|
|
@ -122,39 +122,16 @@ public sealed partial class SshMonitorService
|
||||||
Path.Combine(localFolder, "ssh", "known-hosts"),
|
Path.Combine(localFolder, "ssh", "known-hosts"),
|
||||||
profile.Host,
|
profile.Host,
|
||||||
profile.Port);
|
profile.Port);
|
||||||
if (!SshHostKeyTrust.IsTrusted(
|
|
||||||
knownHostsPath,
|
|
||||||
profile.Host,
|
|
||||||
profile.Port,
|
|
||||||
profile.HostKeyFingerprint))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"SSH host key is not explicitly confirmed for this server profile.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await EnsureKeyPairAsync(cancellationToken);
|
await EnsureKeyPairAsync(cancellationToken);
|
||||||
await using var privateKeySession = await MaterializePrivateKeyAsync(cancellationToken);
|
await using var privateKeySession = await MaterializePrivateKeyAsync(cancellationToken);
|
||||||
var target = $"{profile.User}@{profile.Host}";
|
var arguments = SshConnectionArguments.BuildRestricted(
|
||||||
var arguments = new[]
|
profile.Host,
|
||||||
{
|
profile.Port,
|
||||||
"-F", "none",
|
profile.User,
|
||||||
"-i", privateKeySession.Path,
|
knownHostsPath,
|
||||||
"-p", profile.Port.ToString(CultureInfo.InvariantCulture),
|
profile.HostKeyFingerprint,
|
||||||
"-o", "BatchMode=yes",
|
privateKeySession.Path,
|
||||||
"-o", "ConnectTimeout=8",
|
command);
|
||||||
"-o", "IdentitiesOnly=yes",
|
|
||||||
"-o", "IdentityAgent=none",
|
|
||||||
"-o", "StrictHostKeyChecking=yes",
|
|
||||||
"-o", $"UserKnownHostsFile={knownHostsPath}",
|
|
||||||
"-o", "GlobalKnownHostsFile=none",
|
|
||||||
"-o", "KnownHostsCommand=none",
|
|
||||||
"-o", "UpdateHostKeys=no",
|
|
||||||
"-o", "VerifyHostKeyDNS=no",
|
|
||||||
"-o", "CanonicalizeHostname=no",
|
|
||||||
"-o", "CheckHostIP=no",
|
|
||||||
target,
|
|
||||||
command
|
|
||||||
};
|
|
||||||
return await RunProcessAsync(
|
return await RunProcessAsync(
|
||||||
ResolveOpenSshTool("ssh.exe"),
|
ResolveOpenSshTool("ssh.exe"),
|
||||||
arguments,
|
arguments,
|
||||||
|
|
@ -200,11 +177,19 @@ public sealed partial class SshMonitorService
|
||||||
}
|
}
|
||||||
|
|
||||||
var ssh = ResolveOpenSshTool("ssh.exe");
|
var ssh = ResolveOpenSshTool("ssh.exe");
|
||||||
var sshArguments = new[]
|
var knownHostsPath = SshHostKeyTrust.GetPinPath(
|
||||||
{
|
Path.Combine(
|
||||||
"-p", profile.Port.ToString(CultureInfo.InvariantCulture),
|
ApplicationData.Current.LocalFolder.Path,
|
||||||
$"{terminalUser}@{profile.Host}"
|
"ssh",
|
||||||
};
|
"known-hosts"),
|
||||||
|
profile.Host,
|
||||||
|
profile.Port);
|
||||||
|
var sshArguments = SshConnectionArguments.BuildInteractive(
|
||||||
|
profile.Host,
|
||||||
|
profile.Port,
|
||||||
|
terminalUser,
|
||||||
|
knownHostsPath,
|
||||||
|
profile.HostKeyFingerprint);
|
||||||
var windowsTerminal = Path.Combine(
|
var windowsTerminal = Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
"Microsoft",
|
"Microsoft",
|
||||||
|
|
@ -213,11 +198,12 @@ public sealed partial class SshMonitorService
|
||||||
var startInfo = new ProcessStartInfo
|
var startInfo = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = File.Exists(windowsTerminal) ? windowsTerminal : ssh,
|
FileName = File.Exists(windowsTerminal) ? windowsTerminal : ssh,
|
||||||
UseShellExecute = true
|
UseShellExecute = false
|
||||||
};
|
};
|
||||||
if (File.Exists(windowsTerminal))
|
if (File.Exists(windowsTerminal))
|
||||||
{
|
{
|
||||||
startInfo.ArgumentList.Add("new-tab");
|
startInfo.ArgumentList.Add("new-tab");
|
||||||
|
startInfo.ArgumentList.Add("--");
|
||||||
startInfo.ArgumentList.Add(ssh);
|
startInfo.ArgumentList.Add(ssh);
|
||||||
}
|
}
|
||||||
foreach (var argument in sshArguments)
|
foreach (var argument in sshArguments)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@ if (!uint.TryParse(Environment.GetEnvironmentVariable("SMM_AgentUid"), out var a
|
||||||
Console.Error.WriteLine("SMM_AgentUid must identify the enrolled Agent user.");
|
Console.Error.WriteLine("SMM_AgentUid must identify the enrolled Agent user.");
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
if (!ProvisioningAgentIdentity.MatchesConfiguredUid("/etc/passwd", agentUserId))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"SMM_AgentUid={agentUserId} does not match the installed ochenstarik-smm-agent user; update agent.env before starting the provisioning helper.");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
using var shutdown = new CancellationTokenSource();
|
using var shutdown = new CancellationTokenSource();
|
||||||
Console.CancelKeyPress += (_, eventArgs) =>
|
Console.CancelKeyPress += (_, eventArgs) =>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
namespace ServerMonitorManager.Provisioning.Helper;
|
||||||
|
|
||||||
|
public static class ProvisioningAgentIdentity
|
||||||
|
{
|
||||||
|
private const string AgentUser = "ochenstarik-smm-agent";
|
||||||
|
|
||||||
|
public static bool MatchesConfiguredUid(string passwdPath, uint configuredUserId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var line in File.ReadLines(passwdPath))
|
||||||
|
{
|
||||||
|
var fields = line.Split(':');
|
||||||
|
if (fields.Length >= 3
|
||||||
|
&& string.Equals(fields[0], AgentUser, StringComparison.Ordinal)
|
||||||
|
&& uint.TryParse(fields[2], out var actualUserId))
|
||||||
|
{
|
||||||
|
return actualUserId == configuredUserId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -267,11 +267,42 @@ public sealed class ProvisioningHelperServer
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ProvisioningHelperResponse Execute(ProvisioningHelperRequest request)
|
public static ProvisioningHelperResponse Execute(ProvisioningHelperRequest request)
|
||||||
=> Execute(request, null);
|
{
|
||||||
|
var validationFailure = ValidateEnvelope(request);
|
||||||
|
return validationFailure ?? ExecuteValidated(request);
|
||||||
|
}
|
||||||
|
|
||||||
public static ProvisioningHelperResponse Execute(
|
private static ProvisioningHelperResponse ExecuteValidated(ProvisioningHelperRequest request)
|
||||||
|
=> request.ActionType switch
|
||||||
|
{
|
||||||
|
"preflight" => ExecutePreflight(request),
|
||||||
|
"system.base-install" => request.Execution is null
|
||||||
|
? CreateBaseInstallPlan(request)
|
||||||
|
: Failure("execution.unavailable", "Provisioning execution is unavailable."),
|
||||||
|
_ => Failure("action.denied", "The requested action is not allowed.")
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task<ProvisioningHelperResponse> ExecuteRequestAsync(
|
||||||
ProvisioningHelperRequest request,
|
ProvisioningHelperRequest request,
|
||||||
TimezoneProvisioningExecutor? timezoneExecutor)
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var validationFailure = ValidateEnvelope(request);
|
||||||
|
if (validationFailure is not null)
|
||||||
|
{
|
||||||
|
return validationFailure;
|
||||||
|
}
|
||||||
|
if (request.Execution is null
|
||||||
|
|| !string.Equals(request.ActionType, "system.base-install", StringComparison.Ordinal)
|
||||||
|
|| _timezoneExecutor is null)
|
||||||
|
{
|
||||||
|
return ExecuteValidated(request);
|
||||||
|
}
|
||||||
|
var result = await _timezoneExecutor.ExecuteAsync(request, cancellationToken);
|
||||||
|
return new ProvisioningHelperResponse(
|
||||||
|
result.Success, result.Code, result.Message, null, null, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProvisioningHelperResponse? ValidateEnvelope(ProvisioningHelperRequest request)
|
||||||
{
|
{
|
||||||
if (request.ProtocolVersion != "1")
|
if (request.ProtocolVersion != "1")
|
||||||
{
|
{
|
||||||
|
|
@ -281,46 +312,9 @@ public sealed class ProvisioningHelperServer
|
||||||
{
|
{
|
||||||
return Failure("request.invalid-job", "Invalid provisioning job identifier.");
|
return Failure("request.invalid-job", "Invalid provisioning job identifier.");
|
||||||
}
|
}
|
||||||
if (request.SchemaVersion != 1 || request.Parameters.ValueKind != JsonValueKind.Object)
|
return request.SchemaVersion == 1 && request.Parameters.ValueKind == JsonValueKind.Object
|
||||||
{
|
? null
|
||||||
return Failure("action.denied", "The requested action is not allowed.");
|
: Failure("action.denied", "The requested action is not allowed.");
|
||||||
}
|
|
||||||
|
|
||||||
return request.ActionType switch
|
|
||||||
{
|
|
||||||
"preflight" => ExecutePreflight(request),
|
|
||||||
"system.base-install" => request.Execution is null
|
|
||||||
? CreateBaseInstallPlan(request)
|
|
||||||
: ExecuteBaseInstall(request, timezoneExecutor),
|
|
||||||
_ => Failure("action.denied", "The requested action is not allowed.")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ProvisioningHelperResponse ExecuteBaseInstall(
|
|
||||||
ProvisioningHelperRequest request,
|
|
||||||
TimezoneProvisioningExecutor? timezoneExecutor)
|
|
||||||
{
|
|
||||||
if (timezoneExecutor is null)
|
|
||||||
{
|
|
||||||
return Failure("execution.unavailable", "Provisioning execution is unavailable.");
|
|
||||||
}
|
|
||||||
var result = timezoneExecutor.Execute(request);
|
|
||||||
return new ProvisioningHelperResponse(
|
|
||||||
result.Success, result.Code, result.Message, null, null, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ProvisioningHelperResponse> ExecuteRequestAsync(
|
|
||||||
ProvisioningHelperRequest request,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var response = Execute(request, timezoneExecutor: null);
|
|
||||||
if (response.Code != "execution.unavailable" || _timezoneExecutor is null)
|
|
||||||
{
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
var result = await _timezoneExecutor.ExecuteAsync(request, cancellationToken);
|
|
||||||
return new ProvisioningHelperResponse(
|
|
||||||
result.Success, result.Code, result.Message, null, null, result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ProvisioningHelperResponse ExecutePreflight(ProvisioningHelperRequest request)
|
private static ProvisioningHelperResponse ExecutePreflight(ProvisioningHelperRequest request)
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,6 @@ public sealed class TimezoneProvisioningExecutor(
|
||||||
private const string TimedatectlPath = "/usr/bin/timedatectl";
|
private const string TimedatectlPath = "/usr/bin/timedatectl";
|
||||||
private const string ZoneinfoRoot = "/usr/share/zoneinfo";
|
private const string ZoneinfoRoot = "/usr/share/zoneinfo";
|
||||||
|
|
||||||
public ProvisioningBaseInstallExecutionResult Execute(ProvisioningHelperRequest request)
|
|
||||||
=> ExecuteAsync(request, CancellationToken.None).GetAwaiter().GetResult();
|
|
||||||
|
|
||||||
public async Task<ProvisioningBaseInstallExecutionResult> ExecuteAsync(
|
public async Task<ProvisioningBaseInstallExecutionResult> ExecuteAsync(
|
||||||
ProvisioningHelperRequest request,
|
ProvisioningHelperRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
|
using System.Reflection;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
@ -13,6 +14,26 @@ namespace ServerMonitorManager.Control.Tests;
|
||||||
|
|
||||||
public sealed class ProvisioningHelperTests
|
public sealed class ProvisioningHelperTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ConfiguredAgentUidMustMatchTheInstalledAgentUser()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"smm-passwd-{Guid.NewGuid():N}");
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
path,
|
||||||
|
"root:x:0:0:root:/root:/bin/bash\n"
|
||||||
|
+ "ochenstarik-smm-agent:x:1234:1234::/var/lib/ochenstarik-server-monitor-manager:/usr/sbin/nologin\n",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(ProvisioningAgentIdentity.MatchesConfiguredUid(path, 1234));
|
||||||
|
Assert.False(ProvisioningAgentIdentity.MatchesConfiguredUid(path, 1235));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HelperRejectsEveryActionOutsideFixedAllowlist()
|
public void HelperRejectsEveryActionOutsideFixedAllowlist()
|
||||||
{
|
{
|
||||||
|
|
@ -108,7 +129,7 @@ public sealed class ProvisioningHelperTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ConfirmedTimezoneOnlyPlanUsesAllowlistedBinaryCreatesBackupAndVerifies()
|
public async Task ConfirmedTimezoneOnlyPlanUsesAllowlistedBinaryCreatesBackupAndVerifies()
|
||||||
{
|
{
|
||||||
using var authority = CreateAuthority(out var signingKey);
|
using var authority = CreateAuthority(out var signingKey);
|
||||||
using (signingKey)
|
using (signingKey)
|
||||||
|
|
@ -125,7 +146,8 @@ public sealed class ProvisioningHelperTests
|
||||||
var executor = new TimezoneProvisioningExecutor(
|
var executor = new TimezoneProvisioningExecutor(
|
||||||
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
||||||
|
|
||||||
var result = executor.Execute(CreateExecutionRequest(plan, grant));
|
var result = await executor.ExecuteAsync(
|
||||||
|
CreateExecutionRequest(plan, grant), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.True(result.Changed);
|
Assert.True(result.Changed);
|
||||||
|
|
@ -145,8 +167,49 @@ public sealed class ProvisioningHelperTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("null")]
|
||||||
|
[InlineData("[]")]
|
||||||
|
public async Task ExecutionDispatchRejectsNonObjectParametersBeforeMutation(string parametersJson)
|
||||||
|
{
|
||||||
|
using var authority = CreateAuthority(out var signingKey);
|
||||||
|
using (signingKey)
|
||||||
|
using (var parameters = JsonDocument.Parse(parametersJson))
|
||||||
|
{
|
||||||
|
var plan = CreateTimezoneOnlyPlan("Europe/Berlin");
|
||||||
|
var events = new List<string>();
|
||||||
|
var files = new FakeFileSystem(events);
|
||||||
|
files.Files.Add("/usr/share/zoneinfo/Europe/Berlin");
|
||||||
|
var process = new FakeProcessRunner(events,
|
||||||
|
new(0, "UTC\n", ""),
|
||||||
|
new(0, "", ""),
|
||||||
|
new(0, "Europe/Berlin\n", ""));
|
||||||
|
var executor = new TimezoneProvisioningExecutor(
|
||||||
|
authority, "home", files, process, TimeProvider.System,
|
||||||
|
"/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
||||||
|
var server = new ProvisioningHelperServer("unused", 0, executor);
|
||||||
|
var request = CreateExecutionRequest(plan, SignGrant(authority, signingKey, plan)) with
|
||||||
|
{
|
||||||
|
Parameters = parameters.RootElement.Clone()
|
||||||
|
};
|
||||||
|
var dispatch = typeof(ProvisioningHelperServer).GetMethod(
|
||||||
|
"ExecuteRequestAsync",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||||
|
|
||||||
|
var task = (Task<ProvisioningHelperResponse>)dispatch.Invoke(
|
||||||
|
server,
|
||||||
|
[request, TestContext.Current.CancellationToken])!;
|
||||||
|
var response = await task;
|
||||||
|
|
||||||
|
Assert.False(response.Success);
|
||||||
|
Assert.Equal("action.denied", response.Code);
|
||||||
|
Assert.Empty(files.Writes);
|
||||||
|
Assert.Empty(process.Calls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ValidNoOpTimezoneCompletesOnlyAfterFactualVerification()
|
public async Task ValidNoOpTimezoneCompletesOnlyAfterFactualVerification()
|
||||||
{
|
{
|
||||||
using var authority = CreateAuthority(out var signingKey);
|
using var authority = CreateAuthority(out var signingKey);
|
||||||
using (signingKey)
|
using (signingKey)
|
||||||
|
|
@ -159,7 +222,9 @@ public sealed class ProvisioningHelperTests
|
||||||
var executor = new TimezoneProvisioningExecutor(
|
var executor = new TimezoneProvisioningExecutor(
|
||||||
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
||||||
|
|
||||||
var result = executor.Execute(CreateExecutionRequest(plan, SignGrant(authority, signingKey, plan)));
|
var result = await executor.ExecuteAsync(
|
||||||
|
CreateExecutionRequest(plan, SignGrant(authority, signingKey, plan)),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.False(result.Changed);
|
Assert.False(result.Changed);
|
||||||
|
|
@ -170,7 +235,7 @@ public sealed class ProvisioningHelperTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ConsumedGrantCannotBeReplayedEvenAfterVerifiedNoOp()
|
public async Task ConsumedGrantCannotBeReplayedEvenAfterVerifiedNoOp()
|
||||||
{
|
{
|
||||||
using var authority = CreateAuthority(out var signingKey);
|
using var authority = CreateAuthority(out var signingKey);
|
||||||
using (signingKey)
|
using (signingKey)
|
||||||
|
|
@ -185,12 +250,14 @@ public sealed class ProvisioningHelperTests
|
||||||
authority, "home", files, firstProcess, TimeProvider.System,
|
authority, "home", files, firstProcess, TimeProvider.System,
|
||||||
"/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
"/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
||||||
|
|
||||||
var first = executor.Execute(CreateExecutionRequest(plan, grant));
|
var first = await executor.ExecuteAsync(
|
||||||
|
CreateExecutionRequest(plan, grant), TestContext.Current.CancellationToken);
|
||||||
var secondProcess = new FakeProcessRunner([]);
|
var secondProcess = new FakeProcessRunner([]);
|
||||||
var replayExecutor = new TimezoneProvisioningExecutor(
|
var replayExecutor = new TimezoneProvisioningExecutor(
|
||||||
authority, "home", files, secondProcess, TimeProvider.System,
|
authority, "home", files, secondProcess, TimeProvider.System,
|
||||||
"/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
"/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
||||||
var replay = replayExecutor.Execute(CreateExecutionRequest(plan, grant));
|
var replay = await replayExecutor.ExecuteAsync(
|
||||||
|
CreateExecutionRequest(plan, grant), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.True(first.Success);
|
Assert.True(first.Success);
|
||||||
Assert.False(replay.Success);
|
Assert.False(replay.Success);
|
||||||
|
|
@ -200,7 +267,7 @@ public sealed class ProvisioningHelperTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ForgedMismatchedAndExpiredGrantsCauseZeroMutation()
|
public async Task ForgedMismatchedAndExpiredGrantsCauseZeroMutation()
|
||||||
{
|
{
|
||||||
using var authority = CreateAuthority(out var signingKey);
|
using var authority = CreateAuthority(out var signingKey);
|
||||||
using (signingKey)
|
using (signingKey)
|
||||||
|
|
@ -224,7 +291,8 @@ public sealed class ProvisioningHelperTests
|
||||||
var executor = new TimezoneProvisioningExecutor(
|
var executor = new TimezoneProvisioningExecutor(
|
||||||
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
||||||
|
|
||||||
var result = executor.Execute(CreateExecutionRequest(plan, grant));
|
var result = await executor.ExecuteAsync(
|
||||||
|
CreateExecutionRequest(plan, grant), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
Assert.Equal("execution.authorization-denied", result.Code);
|
Assert.Equal("execution.authorization-denied", result.Code);
|
||||||
|
|
@ -235,7 +303,7 @@ public sealed class ProvisioningHelperTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ValidGrantForAnotherNodeIsRejectedBeforeMutation()
|
public async Task ValidGrantForAnotherNodeIsRejectedBeforeMutation()
|
||||||
{
|
{
|
||||||
using var authority = CreateAuthority(out var signingKey);
|
using var authority = CreateAuthority(out var signingKey);
|
||||||
using (signingKey)
|
using (signingKey)
|
||||||
|
|
@ -266,7 +334,8 @@ public sealed class ProvisioningHelperTests
|
||||||
authority, "home", files, process, TimeProvider.System,
|
authority, "home", files, process, TimeProvider.System,
|
||||||
"/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
"/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
||||||
|
|
||||||
var result = executor.Execute(request);
|
var result = await executor.ExecuteAsync(
|
||||||
|
request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
Assert.Equal("execution.authorization-denied", result.Code);
|
Assert.Equal("execution.authorization-denied", result.Code);
|
||||||
|
|
@ -277,7 +346,7 @@ public sealed class ProvisioningHelperTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UnsupportedBaseInstallFieldsCauseZeroMutation()
|
public async Task UnsupportedBaseInstallFieldsCauseZeroMutation()
|
||||||
{
|
{
|
||||||
using var authority = CreateAuthority(out var signingKey);
|
using var authority = CreateAuthority(out var signingKey);
|
||||||
using (signingKey)
|
using (signingKey)
|
||||||
|
|
@ -303,7 +372,9 @@ public sealed class ProvisioningHelperTests
|
||||||
var executor = new TimezoneProvisioningExecutor(
|
var executor = new TimezoneProvisioningExecutor(
|
||||||
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
||||||
|
|
||||||
var result = executor.Execute(CreateExecutionRequest(plan, SignGrant(authority, signingKey, plan)));
|
var result = await executor.ExecuteAsync(
|
||||||
|
CreateExecutionRequest(plan, SignGrant(authority, signingKey, plan)),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
Assert.Equal("system.base-install.unsupported-fields", result.Code);
|
Assert.Equal("system.base-install.unsupported-fields", result.Code);
|
||||||
|
|
@ -366,7 +437,7 @@ public sealed class ProvisioningHelperTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SymlinkedBackupPathIsRejectedBeforeTimezoneMutation()
|
public async Task SymlinkedBackupPathIsRejectedBeforeTimezoneMutation()
|
||||||
{
|
{
|
||||||
using var authority = CreateAuthority(out var signingKey);
|
using var authority = CreateAuthority(out var signingKey);
|
||||||
using (signingKey)
|
using (signingKey)
|
||||||
|
|
@ -380,8 +451,9 @@ public sealed class ProvisioningHelperTests
|
||||||
var executor = new TimezoneProvisioningExecutor(
|
var executor = new TimezoneProvisioningExecutor(
|
||||||
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
||||||
|
|
||||||
var result = executor.Execute(CreateExecutionRequest(
|
var result = await executor.ExecuteAsync(
|
||||||
plan, SignGrant(authority, signingKey, plan)));
|
CreateExecutionRequest(plan, SignGrant(authority, signingKey, plan)),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
Assert.Equal("backup.unsafe-path", result.Code);
|
Assert.Equal("backup.unsafe-path", result.Code);
|
||||||
|
|
@ -392,7 +464,7 @@ public sealed class ProvisioningHelperTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void VerificationFailureAttemptsAndFactuallyVerifiesRollback()
|
public async Task VerificationFailureAttemptsAndFactuallyVerifiesRollback()
|
||||||
{
|
{
|
||||||
using var authority = CreateAuthority(out var signingKey);
|
using var authority = CreateAuthority(out var signingKey);
|
||||||
using (signingKey)
|
using (signingKey)
|
||||||
|
|
@ -410,8 +482,9 @@ public sealed class ProvisioningHelperTests
|
||||||
var executor = new TimezoneProvisioningExecutor(
|
var executor = new TimezoneProvisioningExecutor(
|
||||||
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
authority, "home", files, process, TimeProvider.System, "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback");
|
||||||
|
|
||||||
var result = executor.Execute(CreateExecutionRequest(
|
var result = await executor.ExecuteAsync(
|
||||||
plan, SignGrant(authority, signingKey, plan)));
|
CreateExecutionRequest(plan, SignGrant(authority, signingKey, plan)),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.False(result.Success);
|
Assert.False(result.Success);
|
||||||
Assert.Equal("timezone.verification-failed", result.Code);
|
Assert.Equal("timezone.verification-failed", result.Code);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="..\..\src\ServerMonitorManager.Desktop\SshPrivateKeySession.cs" Link="SshPrivateKeySession.cs" />
|
<Compile Include="..\..\src\ServerMonitorManager.Desktop\SshPrivateKeySession.cs" Link="SshPrivateKeySession.cs" />
|
||||||
<Compile Include="..\..\src\ServerMonitorManager.Desktop\SshHostKeyTrust.cs" Link="SshHostKeyTrust.cs" />
|
<Compile Include="..\..\src\ServerMonitorManager.Desktop\SshHostKeyTrust.cs" Link="SshHostKeyTrust.cs" />
|
||||||
|
<Compile Include="..\..\src\ServerMonitorManager.Desktop\SshConnectionArguments.cs" Link="SshConnectionArguments.cs" />
|
||||||
|
<Compile Include="..\..\src\ServerMonitorManager.Desktop\ServerViewModel.cs" Link="ServerViewModel.cs" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||||
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
<PackageReference Include="xunit.v3" Version="3.2.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
using ServerMonitorManager_Desktop;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ServerMonitorManager.Desktop.Security.Tests;
|
||||||
|
|
||||||
|
public sealed class SshConnectionArgumentsTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directory = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"smm-ssh-arguments-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InteractiveTerminalUsesOnlyTheConfirmedEndpointPin()
|
||||||
|
{
|
||||||
|
var candidate = SshHostKeyTrust.ParseCandidate(
|
||||||
|
"server.example",
|
||||||
|
2222,
|
||||||
|
"[server.example]:2222 ssh-ed25519 AQIDBA==\n");
|
||||||
|
var path = SshHostKeyTrust.GetPinPath(_directory, candidate.Host, candidate.Port);
|
||||||
|
await SshHostKeyTrust.WriteAsync(path, candidate, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var arguments = SshConnectionArguments.BuildInteractive(
|
||||||
|
"server.example",
|
||||||
|
2222,
|
||||||
|
"operator",
|
||||||
|
path,
|
||||||
|
candidate.Fingerprint);
|
||||||
|
|
||||||
|
Assert.Equal("none", ValueAfter(arguments, "-F"));
|
||||||
|
Assert.Contains("StrictHostKeyChecking=yes", arguments);
|
||||||
|
Assert.Contains($"UserKnownHostsFile={path}", arguments);
|
||||||
|
Assert.Contains("GlobalKnownHostsFile=none", arguments);
|
||||||
|
Assert.Contains("KnownHostsCommand=none", arguments);
|
||||||
|
Assert.Contains("UpdateHostKeys=no", arguments);
|
||||||
|
Assert.Contains("CheckHostIP=no", arguments);
|
||||||
|
Assert.Equal("operator@server.example", arguments[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InteractiveTerminalRejectsProfileWithoutConfirmedFingerprint()
|
||||||
|
{
|
||||||
|
var path = SshHostKeyTrust.GetPinPath(_directory, "server.example", 22);
|
||||||
|
|
||||||
|
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
SshConnectionArguments.BuildInteractive(
|
||||||
|
"server.example", 22, "operator", path, expectedFingerprint: null));
|
||||||
|
|
||||||
|
Assert.Contains("Подтвердить host key", exception.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UnchangedEndpointCanReuseItsPersistedPin()
|
||||||
|
{
|
||||||
|
var candidate = SshHostKeyTrust.ParseCandidate(
|
||||||
|
"server.example",
|
||||||
|
22,
|
||||||
|
"server.example ssh-ed25519 AQIDBA==\n");
|
||||||
|
var path = SshHostKeyTrust.GetPinPath(_directory, candidate.Host, candidate.Port);
|
||||||
|
await SshHostKeyTrust.WriteAsync(path, candidate, TestContext.Current.CancellationToken);
|
||||||
|
var current = new ServerProfileData(
|
||||||
|
"id", "Old name", candidate.Host, candidate.Port, "monitor", false, candidate.Fingerprint);
|
||||||
|
var renamed = current with { Name = "New name", User = "other" };
|
||||||
|
var moved = renamed with { Host = "other.example" };
|
||||||
|
|
||||||
|
Assert.True(SshHostKeyTrust.CanReusePin(_directory, current, renamed));
|
||||||
|
Assert.False(SshHostKeyTrust.CanReusePin(_directory, current, moved));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LegacyProfileHasDedicatedPendingConfirmationState()
|
||||||
|
{
|
||||||
|
var server = new ServerViewModel(
|
||||||
|
new ServerProfileData("id", "Legacy", "server.example", 22, "monitor"));
|
||||||
|
|
||||||
|
Assert.True(server.HostKeyPendingConfirmation);
|
||||||
|
Assert.Equal("Требуется подтверждение host key", server.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ValueAfter(IReadOnlyList<string> arguments, string option)
|
||||||
|
=> arguments[arguments.ToList().IndexOf(option) + 1];
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_directory))
|
||||||
|
{
|
||||||
|
Directory.Delete(_directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,40 @@ public sealed class SshHostKeyTrustTests : IDisposable
|
||||||
"SHA256:wrong"));
|
"SHA256:wrong"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IsTrustedRejectsAnAdditionalEndpointKey()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_directory);
|
||||||
|
var path = Path.Combine(_directory, "known_hosts");
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
path,
|
||||||
|
"server.example ssh-ed25519 AQIDBA==\nserver.example ssh-rsa BQYHCA==\n",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(SshHostKeyTrust.IsTrusted(
|
||||||
|
path,
|
||||||
|
"server.example",
|
||||||
|
22,
|
||||||
|
"SHA256:n2SnR+G5fxMfq7a0Rylsm28CAeefs8U1bmx36JtqgGo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IsTrustedRejectsAnAdditionalCertificateAuthority()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_directory);
|
||||||
|
var path = Path.Combine(_directory, "known_hosts");
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
path,
|
||||||
|
"server.example ssh-ed25519 AQIDBA==\n@cert-authority server.example ssh-ed25519 BQYHCA==\n",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(SshHostKeyTrust.IsTrusted(
|
||||||
|
path,
|
||||||
|
"server.example",
|
||||||
|
22,
|
||||||
|
"SHA256:n2SnR+G5fxMfq7a0Rylsm28CAeefs8U1bmx36JtqgGo"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetPinPathIsEndpointScopedAndDoesNotExposeHostname()
|
public void GetPinPathIsEndpointScopedAndDoesNotExposeHostname()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,15 @@ grep -Fq 'rm -f -- "$token_file"' "$bootstrap"
|
||||||
grep -Fq 'rm -f -- "$ENROLLMENT_TOKEN_FILE"' "$bootstrap"
|
grep -Fq 'rm -f -- "$ENROLLMENT_TOKEN_FILE"' "$bootstrap"
|
||||||
grep -Fq 'rm -f -- "$ENROLLMENT_TOKEN_TEMP"' "$bootstrap"
|
grep -Fq 'rm -f -- "$ENROLLMENT_TOKEN_TEMP"' "$bootstrap"
|
||||||
grep -Fq 'SMM_AgentUid=$(id -u "$AGENT_USER")' "$bootstrap"
|
grep -Fq 'SMM_AgentUid=$(id -u "$AGENT_USER")' "$bootstrap"
|
||||||
|
grep -Fq 'refresh_agent_uid() {' "$bootstrap"
|
||||||
|
grep -Fq 'agent_uid="$(id -u "$AGENT_USER")"' "$bootstrap"
|
||||||
|
grep -Fq "printf 'SMM_AgentUid=%s\\n' \"\$agent_uid\"" "$bootstrap"
|
||||||
|
grep -Fq ' refresh_agent_uid' "$bootstrap"
|
||||||
|
grep -Fq 'temp="$(mktemp "$ETC_DIR/.agent.env.XXXXXXXX")"' "$bootstrap"
|
||||||
|
grep -Fq 'mv -fT -- "$temp" "$env_file"' "$bootstrap"
|
||||||
|
refresh_line="$(grep -F -m1 -n ' refresh_agent_uid' "$bootstrap" | cut -d: -f1)"
|
||||||
|
stop_line="$(grep -F -m1 -n ' systemctl stop "$unit"' "$bootstrap" | cut -d: -f1)"
|
||||||
|
(( refresh_line < stop_line ))
|
||||||
|
|
||||||
help_output="$(bash "$bootstrap" --help)"
|
help_output="$(bash "$bootstrap" --help)"
|
||||||
version_output="$(bash "$bootstrap" --version)"
|
version_output="$(bash "$bootstrap" --version)"
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,12 @@ $appCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
|
||||||
Join-Path $root 'src\ServerMonitorManager.Desktop\App.xaml.cs')
|
Join-Path $root 'src\ServerMonitorManager.Desktop\App.xaml.cs')
|
||||||
$sshCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
|
$sshCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
|
||||||
Join-Path $root 'src\ServerMonitorManager.Desktop\SshMonitorService.cs')
|
Join-Path $root 'src\ServerMonitorManager.Desktop\SshMonitorService.cs')
|
||||||
|
$sshConnectionCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
|
||||||
|
Join-Path $root 'src\ServerMonitorManager.Desktop\SshConnectionArguments.cs')
|
||||||
$serverViewModelCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
|
$serverViewModelCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
|
||||||
Join-Path $root 'src\ServerMonitorManager.Desktop\ServerViewModel.cs')
|
Join-Path $root 'src\ServerMonitorManager.Desktop\ServerViewModel.cs')
|
||||||
|
$serversXaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (
|
||||||
|
Join-Path $root 'src\ServerMonitorManager.Desktop\Pages\ServersPage.xaml')
|
||||||
$windowsWorkflow = Get-Content -Raw -Encoding UTF8 -LiteralPath (
|
$windowsWorkflow = Get-Content -Raw -Encoding UTF8 -LiteralPath (
|
||||||
Join-Path $root '.github\workflows\windows-build.yml')
|
Join-Path $root '.github\workflows\windows-build.yml')
|
||||||
|
|
||||||
|
|
@ -65,9 +69,9 @@ if ($windowsWorkflow.IndexOf(
|
||||||
[StringComparison]::Ordinal) -lt 0) {
|
[StringComparison]::Ordinal) -lt 0) {
|
||||||
throw 'Windows CI must execute the Desktop security tests.'
|
throw 'Windows CI must execute the Desktop security tests.'
|
||||||
}
|
}
|
||||||
if ($sshCode.IndexOf('StrictHostKeyChecking=yes', [StringComparison]::Ordinal) -lt 0 -or
|
if ($sshConnectionCode.IndexOf('StrictHostKeyChecking=yes', [StringComparison]::Ordinal) -lt 0 -or
|
||||||
$sshCode.IndexOf('StrictHostKeyChecking=accept-new', [StringComparison]::Ordinal) -ge 0) {
|
$sshConnectionCode.IndexOf('StrictHostKeyChecking=accept-new', [StringComparison]::Ordinal) -ge 0) {
|
||||||
throw 'Restricted SSH must use only explicitly pinned host keys.'
|
throw 'SSH connections must use only explicitly pinned host keys.'
|
||||||
}
|
}
|
||||||
$isolatedSshOptions = @(
|
$isolatedSshOptions = @(
|
||||||
'"-F", "none"',
|
'"-F", "none"',
|
||||||
|
|
@ -79,8 +83,8 @@ $isolatedSshOptions = @(
|
||||||
'"CheckHostIP=no"'
|
'"CheckHostIP=no"'
|
||||||
)
|
)
|
||||||
foreach ($option in $isolatedSshOptions) {
|
foreach ($option in $isolatedSshOptions) {
|
||||||
if ($sshCode.IndexOf($option, [StringComparison]::Ordinal) -lt 0) {
|
if ($sshConnectionCode.IndexOf($option, [StringComparison]::Ordinal) -lt 0) {
|
||||||
throw "Restricted SSH is missing trust-isolation option: $option"
|
throw "SSH trust policy is missing isolation option: $option"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$ssh = Join-Path $env:SystemRoot 'System32\OpenSSH\ssh.exe'
|
$ssh = Join-Path $env:SystemRoot 'System32\OpenSSH\ssh.exe'
|
||||||
|
|
@ -128,5 +132,9 @@ if ($serverViewModelCode.IndexOf(
|
||||||
if ($mainCode.IndexOf('ConfirmHostKeyAsync(', [StringComparison]::Ordinal) -lt 0) {
|
if ($mainCode.IndexOf('ConfirmHostKeyAsync(', [StringComparison]::Ordinal) -lt 0) {
|
||||||
throw 'Add/edit flow must require explicit host-key fingerprint confirmation.'
|
throw 'Add/edit flow must require explicit host-key fingerprint confirmation.'
|
||||||
}
|
}
|
||||||
|
if ($serversXaml.IndexOf('Click="ConfirmHostKeyButton_Click"', [StringComparison]::Ordinal) -lt 0 -or
|
||||||
|
$serverViewModelCode.IndexOf('HostKeyPendingConfirmation', [StringComparison]::Ordinal) -lt 0) {
|
||||||
|
throw 'Legacy profiles must expose direct host-key confirmation in the server card.'
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host 'Windows desktop contracts passed.'
|
Write-Host 'Windows desktop contracts passed.'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue