fix(security): close desktop and helper debts (#10)
Co-authored-by: Ochenstarik <ochenstarik@inbox.ru>
This commit is contained in:
parent
c320b7df9c
commit
ba14d29211
18 changed files with 563 additions and 135 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2">
|
||||
<TextBlock FontWeight="SemiBold" Text="{x:Bind Name}" />
|
||||
|
|
@ -67,6 +68,12 @@
|
|||
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{x:Bind HealthText, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
<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>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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
|
||||
{
|
||||
|
|
@ -147,10 +159,7 @@ internal static class SshHostKeyTrust
|
|||
try
|
||||
{
|
||||
var actual = $"SHA256:{Convert.ToBase64String(SHA256.HashData(keyBlob)).TrimEnd('=')}";
|
||||
if (string.Equals(actual, expectedFingerprint, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return string.Equals(actual, expectedFingerprint, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -162,9 +171,26 @@ internal static class SshHostKeyTrust
|
|||
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);
|
||||
}
|
||||
|
||||
|
||||
private static string[] SplitFields(string line)
|
||||
=> line.Split(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
=> 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,
|
||||
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<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);
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<ProvisioningBaseInstallExecutionResult> ExecuteAsync(
|
||||
ProvisioningHelperRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -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<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]
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
<ItemGroup>
|
||||
<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\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="xunit.v3" Version="3.2.2" />
|
||||
<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"));
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
Loading…
Reference in a new issue