fix(security): close desktop and helper debts

This commit is contained in:
Ochenstarik 2026-07-31 13:17:56 +07:00
parent c320b7df9c
commit d373217ea4
18 changed files with 563 additions and 135 deletions

View file

@ -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

View file

@ -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
{ {

View file

@ -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>

View file

@ -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();

View file

@ -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); }

View 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}"
];
}
}

View file

@ -130,39 +130,65 @@ 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)
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)
{ {
return false; 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);
} }

View file

@ -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)

View file

@ -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) =>

View file

@ -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;
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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);

View file

@ -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" />

View file

@ -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);
}
}
}

View file

@ -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()
{ {

View file

@ -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)"

View file

@ -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.'