diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh
index 7b2a603..d6957af 100755
--- a/deploy/ochenstarik-server-monitor-manager.sh
+++ b/deploy/ochenstarik-server-monitor-manager.sh
@@ -689,6 +689,31 @@ install_node_from_code() {
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() {
local role="$1" archive="$2" binary unit user backup_id
require_root
@@ -700,6 +725,9 @@ update_role() {
*) fail "Unknown role: $role" ;;
esac
[[ -x "$TEMP_DIR/$role/$binary" ]] || fail "$role binary is missing."
+ if [[ "$role" == "agent" ]]; then
+ refresh_agent_uid
+ fi
systemctl stop "$unit"
if [[ "$role" == "agent" ]]; then
systemctl stop "$PROVISIONING_HELPER_UNIT" 2>/dev/null || true
diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs
index a05a31f..d64ab39 100644
--- a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs
+++ b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs
@@ -58,6 +58,33 @@ public sealed partial class MainPage : Page
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)
{
ServerList.SelectedItem = server;
@@ -121,6 +148,18 @@ public sealed partial class MainPage : Page
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)
{
await RefreshAllAsync();
@@ -628,7 +667,16 @@ public sealed partial class MainPage : Page
checked((int)portBox.Value),
userBox.Text.Trim(),
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)
{
return;
@@ -698,7 +746,8 @@ public sealed partial class MainPage : Page
WarningValueText.Text = warnings.ToString(CultureInfo.InvariantCulture);
WarningDetailText.Text = warnings == 0 ? "Нет предупреждений" : "Проверьте доступность и ресурсы";
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);
}
@@ -711,6 +760,14 @@ public sealed partial class MainPage : Page
private async Task RefreshServerAsync(ServerViewModel server)
{
+ if (server.HostKeyPendingConfirmation)
+ {
+ server.Status = "Требуется подтверждение host key";
+ server.IsOnline = false;
+ server.HasWarning = true;
+ return;
+ }
+
server.Status = "Подключение…";
try
{
diff --git a/src/ServerMonitorManager.Desktop/Pages/ServersPage.xaml b/src/ServerMonitorManager.Desktop/Pages/ServersPage.xaml
index 55ca948..71c688a 100644
--- a/src/ServerMonitorManager.Desktop/Pages/ServersPage.xaml
+++ b/src/ServerMonitorManager.Desktop/Pages/ServersPage.xaml
@@ -51,6 +51,7 @@
+
@@ -67,6 +68,12 @@
+
diff --git a/src/ServerMonitorManager.Desktop/Pages/ServersPage.xaml.cs b/src/ServerMonitorManager.Desktop/Pages/ServersPage.xaml.cs
index d60587c..bf1e408 100644
--- a/src/ServerMonitorManager.Desktop/Pages/ServersPage.xaml.cs
+++ b/src/ServerMonitorManager.Desktop/Pages/ServersPage.xaml.cs
@@ -26,6 +26,9 @@ public sealed partial class ServersPage : Page
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)
=> await _host.RefreshServersFromPageAsync();
diff --git a/src/ServerMonitorManager.Desktop/ServerViewModel.cs b/src/ServerMonitorManager.Desktop/ServerViewModel.cs
index 5759db2..019c7e5 100644
--- a/src/ServerMonitorManager.Desktop/ServerViewModel.cs
+++ b/src/ServerMonitorManager.Desktop/ServerViewModel.cs
@@ -14,7 +14,7 @@ public sealed record ServerProfileData(
public sealed class ServerViewModel : INotifyPropertyChanged
{
- private string _status = "Ожидает проверки";
+ private string _status;
private string _cpuText = "—";
private double _cpuPercent;
private string _memoryText = "—";
@@ -24,12 +24,22 @@ public sealed class ServerViewModel : INotifyPropertyChanged
private bool _isOnline;
private bool _hasWarning;
- public ServerViewModel(ServerProfileData profile) => Profile = profile;
+ public ServerViewModel(ServerProfileData profile)
+ {
+ Profile = profile;
+ _status = HostKeyPendingConfirmation
+ ? "Требуется подтверждение host key"
+ : "Ожидает проверки";
+ }
public ServerProfileData Profile { get; }
public string Name => Profile.Name;
public string Endpoint => $"{Profile.User}@{Profile.Host}:{Profile.Port}";
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 CpuText { get => _cpuText; set => Set(ref _cpuText, value); }
public double CpuPercent { get => _cpuPercent; set => Set(ref _cpuPercent, value); }
diff --git a/src/ServerMonitorManager.Desktop/SshConnectionArguments.cs b/src/ServerMonitorManager.Desktop/SshConnectionArguments.cs
new file mode 100644
index 0000000..aa8dc11
--- /dev/null
+++ b/src/ServerMonitorManager.Desktop/SshConnectionArguments.cs
@@ -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 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}"
+ ];
+ }
+}
diff --git a/src/ServerMonitorManager.Desktop/SshHostKeyTrust.cs b/src/ServerMonitorManager.Desktop/SshHostKeyTrust.cs
index 4ef318e..d50ac84 100644
--- a/src/ServerMonitorManager.Desktop/SshHostKeyTrust.cs
+++ b/src/ServerMonitorManager.Desktop/SshHostKeyTrust.cs
@@ -130,39 +130,65 @@ internal static class SshHostKeyTrust
return false;
}
- var endpoint = FormatEndpoint(host, port);
+ string? pin = null;
foreach (var line in File.ReadLines(path))
{
- var parts = SplitFields(line);
- if (parts.Length < 3
- || !string.Equals(parts[0], endpoint, StringComparison.Ordinal)
- || !PreferredKeyTypes.Contains(parts[1], StringComparer.Ordinal))
+ var trimmed = line.Trim();
+ if (trimmed.Length == 0 || trimmed.StartsWith('#'))
{
continue;
}
-
- try
- {
- var keyBlob = Convert.FromBase64String(parts[2]);
- try
- {
- var actual = $"SHA256:{Convert.ToBase64String(SHA256.HashData(keyBlob)).TrimEnd('=')}";
- if (string.Equals(actual, expectedFingerprint, StringComparison.Ordinal))
- {
- return true;
- }
- }
- finally
- {
- CryptographicOperations.ZeroMemory(keyBlob);
- }
- }
- catch (FormatException)
+ if (pin is not null)
{
return false;
}
+ pin = trimmed;
}
- return false;
+
+ 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
+ {
+ var keyBlob = Convert.FromBase64String(parts[2]);
+ try
+ {
+ var actual = $"SHA256:{Convert.ToBase64String(SHA256.HashData(keyBlob)).TrimEnd('=')}";
+ return string.Equals(actual, expectedFingerprint, StringComparison.Ordinal);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(keyBlob);
+ }
+ }
+ catch (FormatException)
+ {
+ 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;
+ }
+
+ var path = GetPinPath(directory, current.Host, current.Port);
+ return IsTrusted(
+ path,
+ current.Host,
+ current.Port,
+ current.HostKeyFingerprint);
}
diff --git a/src/ServerMonitorManager.Desktop/SshMonitorService.cs b/src/ServerMonitorManager.Desktop/SshMonitorService.cs
index e63988c..11341ea 100644
--- a/src/ServerMonitorManager.Desktop/SshMonitorService.cs
+++ b/src/ServerMonitorManager.Desktop/SshMonitorService.cs
@@ -122,39 +122,16 @@ public sealed partial class SshMonitorService
Path.Combine(localFolder, "ssh", "known-hosts"),
profile.Host,
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 using var privateKeySession = await MaterializePrivateKeyAsync(cancellationToken);
- var target = $"{profile.User}@{profile.Host}";
- var arguments = new[]
- {
- "-F", "none",
- "-i", privateKeySession.Path,
- "-p", profile.Port.ToString(CultureInfo.InvariantCulture),
- "-o", "BatchMode=yes",
- "-o", "ConnectTimeout=8",
- "-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
- };
+ var arguments = SshConnectionArguments.BuildRestricted(
+ profile.Host,
+ profile.Port,
+ profile.User,
+ knownHostsPath,
+ profile.HostKeyFingerprint,
+ privateKeySession.Path,
+ command);
return await RunProcessAsync(
ResolveOpenSshTool("ssh.exe"),
arguments,
@@ -200,11 +177,19 @@ public sealed partial class SshMonitorService
}
var ssh = ResolveOpenSshTool("ssh.exe");
- var sshArguments = new[]
- {
- "-p", profile.Port.ToString(CultureInfo.InvariantCulture),
- $"{terminalUser}@{profile.Host}"
- };
+ var knownHostsPath = SshHostKeyTrust.GetPinPath(
+ Path.Combine(
+ ApplicationData.Current.LocalFolder.Path,
+ "ssh",
+ "known-hosts"),
+ profile.Host,
+ profile.Port);
+ var sshArguments = SshConnectionArguments.BuildInteractive(
+ profile.Host,
+ profile.Port,
+ terminalUser,
+ knownHostsPath,
+ profile.HostKeyFingerprint);
var windowsTerminal = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
@@ -213,11 +198,12 @@ public sealed partial class SshMonitorService
var startInfo = new ProcessStartInfo
{
FileName = File.Exists(windowsTerminal) ? windowsTerminal : ssh,
- UseShellExecute = true
+ UseShellExecute = false
};
if (File.Exists(windowsTerminal))
{
startInfo.ArgumentList.Add("new-tab");
+ startInfo.ArgumentList.Add("--");
startInfo.ArgumentList.Add(ssh);
}
foreach (var argument in sshArguments)
diff --git a/src/ServerMonitorManager.Provisioning.Helper/Program.cs b/src/ServerMonitorManager.Provisioning.Helper/Program.cs
index bef9e67..7fa928e 100644
--- a/src/ServerMonitorManager.Provisioning.Helper/Program.cs
+++ b/src/ServerMonitorManager.Provisioning.Helper/Program.cs
@@ -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.");
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();
Console.CancelKeyPress += (_, eventArgs) =>
diff --git a/src/ServerMonitorManager.Provisioning.Helper/ProvisioningAgentIdentity.cs b/src/ServerMonitorManager.Provisioning.Helper/ProvisioningAgentIdentity.cs
new file mode 100644
index 0000000..0ab42a1
--- /dev/null
+++ b/src/ServerMonitorManager.Provisioning.Helper/ProvisioningAgentIdentity.cs
@@ -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;
+ }
+}
diff --git a/src/ServerMonitorManager.Provisioning.Helper/ProvisioningHelperServer.cs b/src/ServerMonitorManager.Provisioning.Helper/ProvisioningHelperServer.cs
index 1529024..3c6dc1d 100644
--- a/src/ServerMonitorManager.Provisioning.Helper/ProvisioningHelperServer.cs
+++ b/src/ServerMonitorManager.Provisioning.Helper/ProvisioningHelperServer.cs
@@ -267,11 +267,42 @@ public sealed class ProvisioningHelperServer
}
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 ExecuteRequestAsync(
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")
{
@@ -281,46 +312,9 @@ public sealed class ProvisioningHelperServer
{
return Failure("request.invalid-job", "Invalid provisioning job identifier.");
}
- if (request.SchemaVersion != 1 || request.Parameters.ValueKind != JsonValueKind.Object)
- {
- return 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 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);
+ return request.SchemaVersion == 1 && request.Parameters.ValueKind == JsonValueKind.Object
+ ? null
+ : Failure("action.denied", "The requested action is not allowed.");
}
private static ProvisioningHelperResponse ExecutePreflight(ProvisioningHelperRequest request)
diff --git a/src/ServerMonitorManager.Provisioning.Helper/TimezoneProvisioningExecutor.cs b/src/ServerMonitorManager.Provisioning.Helper/TimezoneProvisioningExecutor.cs
index 709caf8..8323a43 100644
--- a/src/ServerMonitorManager.Provisioning.Helper/TimezoneProvisioningExecutor.cs
+++ b/src/ServerMonitorManager.Provisioning.Helper/TimezoneProvisioningExecutor.cs
@@ -36,9 +36,6 @@ public sealed class TimezoneProvisioningExecutor(
private const string TimedatectlPath = "/usr/bin/timedatectl";
private const string ZoneinfoRoot = "/usr/share/zoneinfo";
- public ProvisioningBaseInstallExecutionResult Execute(ProvisioningHelperRequest request)
- => ExecuteAsync(request, CancellationToken.None).GetAwaiter().GetResult();
-
public async Task ExecuteAsync(
ProvisioningHelperRequest request,
CancellationToken cancellationToken)
diff --git a/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs b/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs
index 9d6e325..e534e48 100644
--- a/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs
+++ b/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs
@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Net.Sockets;
+using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
@@ -13,6 +14,26 @@ namespace ServerMonitorManager.Control.Tests;
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]
public void HelperRejectsEveryActionOutsideFixedAllowlist()
{
@@ -108,7 +129,7 @@ public sealed class ProvisioningHelperTests
}
[Fact]
- public void ConfirmedTimezoneOnlyPlanUsesAllowlistedBinaryCreatesBackupAndVerifies()
+ public async Task ConfirmedTimezoneOnlyPlanUsesAllowlistedBinaryCreatesBackupAndVerifies()
{
using var authority = CreateAuthority(out var signingKey);
using (signingKey)
@@ -125,7 +146,8 @@ public sealed class ProvisioningHelperTests
var executor = new TimezoneProvisioningExecutor(
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.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();
+ 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)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]
- public void ValidNoOpTimezoneCompletesOnlyAfterFactualVerification()
+ public async Task ValidNoOpTimezoneCompletesOnlyAfterFactualVerification()
{
using var authority = CreateAuthority(out var signingKey);
using (signingKey)
@@ -159,7 +222,9 @@ public sealed class ProvisioningHelperTests
var executor = new TimezoneProvisioningExecutor(
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.False(result.Changed);
@@ -170,7 +235,7 @@ public sealed class ProvisioningHelperTests
}
[Fact]
- public void ConsumedGrantCannotBeReplayedEvenAfterVerifiedNoOp()
+ public async Task ConsumedGrantCannotBeReplayedEvenAfterVerifiedNoOp()
{
using var authority = CreateAuthority(out var signingKey);
using (signingKey)
@@ -185,12 +250,14 @@ public sealed class ProvisioningHelperTests
authority, "home", files, firstProcess, TimeProvider.System,
"/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 replayExecutor = new TimezoneProvisioningExecutor(
authority, "home", files, secondProcess, TimeProvider.System,
"/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.False(replay.Success);
@@ -200,7 +267,7 @@ public sealed class ProvisioningHelperTests
}
[Fact]
- public void ForgedMismatchedAndExpiredGrantsCauseZeroMutation()
+ public async Task ForgedMismatchedAndExpiredGrantsCauseZeroMutation()
{
using var authority = CreateAuthority(out var signingKey);
using (signingKey)
@@ -224,7 +291,8 @@ public sealed class ProvisioningHelperTests
var executor = new TimezoneProvisioningExecutor(
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.Equal("execution.authorization-denied", result.Code);
@@ -235,7 +303,7 @@ public sealed class ProvisioningHelperTests
}
[Fact]
- public void ValidGrantForAnotherNodeIsRejectedBeforeMutation()
+ public async Task ValidGrantForAnotherNodeIsRejectedBeforeMutation()
{
using var authority = CreateAuthority(out var signingKey);
using (signingKey)
@@ -266,7 +334,8 @@ public sealed class ProvisioningHelperTests
authority, "home", files, process, TimeProvider.System,
"/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.Equal("execution.authorization-denied", result.Code);
@@ -277,7 +346,7 @@ public sealed class ProvisioningHelperTests
}
[Fact]
- public void UnsupportedBaseInstallFieldsCauseZeroMutation()
+ public async Task UnsupportedBaseInstallFieldsCauseZeroMutation()
{
using var authority = CreateAuthority(out var signingKey);
using (signingKey)
@@ -303,7 +372,9 @@ public sealed class ProvisioningHelperTests
var executor = new TimezoneProvisioningExecutor(
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.Equal("system.base-install.unsupported-fields", result.Code);
@@ -366,7 +437,7 @@ public sealed class ProvisioningHelperTests
}
[Fact]
- public void SymlinkedBackupPathIsRejectedBeforeTimezoneMutation()
+ public async Task SymlinkedBackupPathIsRejectedBeforeTimezoneMutation()
{
using var authority = CreateAuthority(out var signingKey);
using (signingKey)
@@ -380,8 +451,9 @@ public sealed class ProvisioningHelperTests
var executor = new TimezoneProvisioningExecutor(
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.Equal("backup.unsafe-path", result.Code);
@@ -392,7 +464,7 @@ public sealed class ProvisioningHelperTests
}
[Fact]
- public void VerificationFailureAttemptsAndFactuallyVerifiesRollback()
+ public async Task VerificationFailureAttemptsAndFactuallyVerifiesRollback()
{
using var authority = CreateAuthority(out var signingKey);
using (signingKey)
@@ -410,8 +482,9 @@ public sealed class ProvisioningHelperTests
var executor = new TimezoneProvisioningExecutor(
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.Equal("timezone.verification-failed", result.Code);
diff --git a/tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj b/tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj
index b978a7d..076bbfe 100644
--- a/tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj
+++ b/tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj
@@ -8,6 +8,8 @@
+
+
diff --git a/tests/ServerMonitorManager.Desktop.Security.Tests/SshConnectionArgumentsTests.cs b/tests/ServerMonitorManager.Desktop.Security.Tests/SshConnectionArgumentsTests.cs
new file mode 100644
index 0000000..631dcfe
--- /dev/null
+++ b/tests/ServerMonitorManager.Desktop.Security.Tests/SshConnectionArgumentsTests.cs
@@ -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(() =>
+ 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 arguments, string option)
+ => arguments[arguments.ToList().IndexOf(option) + 1];
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_directory))
+ {
+ Directory.Delete(_directory, recursive: true);
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/ServerMonitorManager.Desktop.Security.Tests/SshHostKeyTrustTests.cs b/tests/ServerMonitorManager.Desktop.Security.Tests/SshHostKeyTrustTests.cs
index af492c6..efb1a1d 100644
--- a/tests/ServerMonitorManager.Desktop.Security.Tests/SshHostKeyTrustTests.cs
+++ b/tests/ServerMonitorManager.Desktop.Security.Tests/SshHostKeyTrustTests.cs
@@ -67,6 +67,40 @@ public sealed class SshHostKeyTrustTests : IDisposable
"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]
public void GetPinPathIsEndpointScopedAndDoesNotExposeHostname()
{
diff --git a/tests/bootstrap/test-bootstrap-contract.sh b/tests/bootstrap/test-bootstrap-contract.sh
index 21b24e6..f5aace6 100755
--- a/tests/bootstrap/test-bootstrap-contract.sh
+++ b/tests/bootstrap/test-bootstrap-contract.sh
@@ -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_TEMP"' "$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)"
version_output="$(bash "$bootstrap" --version)"
diff --git a/tests/windows/Test-DesktopContracts.ps1 b/tests/windows/Test-DesktopContracts.ps1
index 80201c4..f7fd4b1 100644
--- a/tests/windows/Test-DesktopContracts.ps1
+++ b/tests/windows/Test-DesktopContracts.ps1
@@ -23,8 +23,12 @@ $appCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
Join-Path $root 'src\ServerMonitorManager.Desktop\App.xaml.cs')
$sshCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
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 (
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 (
Join-Path $root '.github\workflows\windows-build.yml')
@@ -65,9 +69,9 @@ if ($windowsWorkflow.IndexOf(
[StringComparison]::Ordinal) -lt 0) {
throw 'Windows CI must execute the Desktop security tests.'
}
-if ($sshCode.IndexOf('StrictHostKeyChecking=yes', [StringComparison]::Ordinal) -lt 0 -or
- $sshCode.IndexOf('StrictHostKeyChecking=accept-new', [StringComparison]::Ordinal) -ge 0) {
- throw 'Restricted SSH must use only explicitly pinned host keys.'
+if ($sshConnectionCode.IndexOf('StrictHostKeyChecking=yes', [StringComparison]::Ordinal) -lt 0 -or
+ $sshConnectionCode.IndexOf('StrictHostKeyChecking=accept-new', [StringComparison]::Ordinal) -ge 0) {
+ throw 'SSH connections must use only explicitly pinned host keys.'
}
$isolatedSshOptions = @(
'"-F", "none"',
@@ -79,8 +83,8 @@ $isolatedSshOptions = @(
'"CheckHostIP=no"'
)
foreach ($option in $isolatedSshOptions) {
- if ($sshCode.IndexOf($option, [StringComparison]::Ordinal) -lt 0) {
- throw "Restricted SSH is missing trust-isolation option: $option"
+ if ($sshConnectionCode.IndexOf($option, [StringComparison]::Ordinal) -lt 0) {
+ throw "SSH trust policy is missing isolation option: $option"
}
}
$ssh = Join-Path $env:SystemRoot 'System32\OpenSSH\ssh.exe'
@@ -128,5 +132,9 @@ if ($serverViewModelCode.IndexOf(
if ($mainCode.IndexOf('ConfirmHostKeyAsync(', [StringComparison]::Ordinal) -lt 0) {
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.'