From 93e0f8ddbd0bb0a87bce000340a7228f68c80488 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Fri, 31 Jul 2026 02:32:47 +0700 Subject: [PATCH] fix(security): harden enrollment and provisioning helper (#7) Co-authored-by: Ochenstarik --- .github/workflows/linux-control-agent.yml | 3 + deploy/ochenstarik-server-monitor-manager.sh | 43 +- src/ServerMonitorManager.Agent/AgentClient.cs | 102 +++++ .../AgentOptions.cs | 2 + src/ServerMonitorManager.Agent/Program.cs | 10 +- .../ServerMonitorManager.Agent.csproj | 3 + .../Program.cs | 7 +- .../ProvisioningHelperServer.cs | 282 ++++++++++++- .../TimezoneProvisioningExecutor.cs | 126 +++++- .../EnrollmentTokenTests.cs | 137 +++++++ .../ProvisioningHelperTests.cs | 387 +++++++++++++++++- tests/bootstrap/test-bootstrap-contract.sh | 18 + tests/bootstrap/test-enrollment-token-argv.sh | 80 ++++ 13 files changed, 1150 insertions(+), 50 deletions(-) create mode 100644 tests/ServerMonitorManager.Control.Tests/EnrollmentTokenTests.cs create mode 100644 tests/bootstrap/test-enrollment-token-argv.sh diff --git a/.github/workflows/linux-control-agent.yml b/.github/workflows/linux-control-agent.yml index e442118..c157dde 100644 --- a/.github/workflows/linux-control-agent.yml +++ b/.github/workflows/linux-control-agent.yml @@ -44,12 +44,15 @@ jobs: bash -n deploy/ochenstarik-smm-emergency bash -n tests/bootstrap/run-native-systemd-smoke.sh bash -n tests/bootstrap/run-systemd-container-smoke.sh + bash -n tests/bootstrap/test-enrollment-token-argv.sh shellcheck --severity=error deploy/ochenstarik-server-monitor-manager.sh shellcheck --severity=error deploy/ochenstarik-smm-policy-apply shellcheck --severity=error deploy/ochenstarik-smm-emergency shellcheck --severity=error tests/bootstrap/run-native-systemd-smoke.sh shellcheck --severity=error tests/bootstrap/run-systemd-container-smoke.sh + shellcheck --severity=error tests/bootstrap/test-enrollment-token-argv.sh bash tests/bootstrap/test-bootstrap-contract.sh + bash tests/bootstrap/test-enrollment-token-argv.sh - name: Publish agent amd64 run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime linux-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh index a766e64..7b2a603 100755 --- a/deploy/ochenstarik-server-monitor-manager.sh +++ b/deploy/ochenstarik-server-monitor-manager.sh @@ -7,6 +7,7 @@ readonly PROGRAM_VERSION="0.2.0-dev" readonly ETC_DIR="/etc/ochenstarik-server-monitor-manager" readonly LIB_DIR="/usr/local/lib/ochenstarik-server-monitor-manager" readonly STATE_DIR="/var/lib/ochenstarik-server-monitor-manager" +readonly ENROLLMENT_DIR="${STATE_DIR}-enrollment" readonly BACKUP_DIR="${STATE_DIR}/bootstrap-backups" readonly CONTROL_USER="ochenstarik-smm-control" readonly AGENT_USER="ochenstarik-smm-agent" @@ -24,11 +25,19 @@ readonly HUB_MESH_ADDRESS="10.77.0.1/24" TEMP_DIR="" MESH_PEER_CODE="" +ENROLLMENT_TOKEN_FILE="" +ENROLLMENT_TOKEN_TEMP="" log() { printf '%s\n' "[$PROGRAM] $*"; } fail() { printf '%s\n' "[$PROGRAM] ERROR: $*" >&2; exit 1; } cleanup() { + if [[ -n "$ENROLLMENT_TOKEN_FILE" ]]; then + rm -f -- "$ENROLLMENT_TOKEN_FILE" + fi + if [[ -n "$ENROLLMENT_TOKEN_TEMP" ]]; then + rm -f -- "$ENROLLMENT_TOKEN_TEMP" + fi if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then rm -rf -- "$TEMP_DIR" fi @@ -522,7 +531,7 @@ read_enrollment_token() { } install_agent() { - local archive="$1" node_id="$2" control_url="$3" ca_cert="$4" backup_id + local archive="$1" node_id="$2" control_url="$3" ca_cert="$4" backup_id token_file token_temp require_root validate_platform validate_node_id "$node_id" @@ -541,6 +550,7 @@ install_agent() { ensure_system_user "$AGENT_USER" install -d -m 0750 -o root -g "$AGENT_USER" "$ETC_DIR" install -d -m 0700 -o "$AGENT_USER" -g "$AGENT_USER" "$STATE_DIR/agent" + install -d -m 0710 -o root -g "$AGENT_USER" "$ENROLLMENT_DIR" install -d -m 0700 -o root -g root "$STATE_DIR/provisioning/rollback" install_tree_atomic "$TEMP_DIR/agent" "$LIB_DIR/agent" "root:root" install_tree_atomic "$TEMP_DIR/provisioning-helper" "$LIB_DIR/provisioning-helper" "root:root" @@ -554,21 +564,41 @@ install_agent() { fi cat >"$ETC_DIR/agent.env" <"$token_temp"; then + ENROLL_TOKEN="" + fail "Could not write the enrollment token file." + fi + chown "$AGENT_USER:$AGENT_USER" "$token_temp" + chmod 0400 "$token_temp" + ENROLLMENT_TOKEN_FILE="$token_file" + mv -fT -- "$token_temp" "$token_file" + ENROLLMENT_TOKEN_TEMP="" + ENROLL_TOKEN="" + if ! runuser -u "$AGENT_USER" -- env \ "SMM_NodeId=$node_id" \ "SMM_ControlUrl=${control_url%/}" \ "SMM_StateDirectory=$STATE_DIR/agent" \ + "SMM_EnrollmentTokenDirectory=$ENROLLMENT_DIR" \ "SMM_CertificateAuthorityPath=$ETC_DIR/control-ca.crt" \ - "SMM_EnrollToken=$ENROLL_TOKEN" \ - "$LIB_DIR/agent/ochenstarik-smm-agent" - ENROLL_TOKEN="" + "SMM_EnrollTokenFile=$token_file" \ + "$LIB_DIR/agent/ochenstarik-smm-agent"; then + rm -f -- "$token_file" + fail "Agent enrollment failed." + fi + rm -f -- "$token_file" + ENROLLMENT_TOKEN_FILE="" chown root:"$AGENT_USER" "$ETC_DIR/control-ca.crt" chmod 0640 "$ETC_DIR/control-ca.crt" install_unit "$TEMP_DIR/deploy/$AGENT_UNIT" "$AGENT_UNIT" @@ -855,7 +885,8 @@ uninstall_agent() { systemctl disable --now "$PROVISIONING_HELPER_UNIT" 2>/dev/null || true rm -f -- "/etc/systemd/system/$AGENT_UNIT" "/etc/systemd/system/$PROVISIONING_HELPER_UNIT" "$ETC_DIR/agent.env" rm -rf -- "$LIB_DIR/agent" "$LIB_DIR/provisioning-helper" - [[ "$purge" == "--purge" ]] && rm -rf -- "$STATE_DIR/agent" "$ETC_DIR/control-ca.crt" + [[ "$purge" == "--purge" ]] && rm -rf -- \ + "$STATE_DIR/agent" "$ENROLLMENT_DIR" "$ETC_DIR/control-ca.crt" systemctl daemon-reload log "Agent removed${purge:+ ($purge)}." } diff --git a/src/ServerMonitorManager.Agent/AgentClient.cs b/src/ServerMonitorManager.Agent/AgentClient.cs index 497d0df..651871b 100644 --- a/src/ServerMonitorManager.Agent/AgentClient.cs +++ b/src/ServerMonitorManager.Agent/AgentClient.cs @@ -9,8 +9,110 @@ namespace ServerMonitorManager.Agent; internal sealed class AgentClient(AgentOptions options) { + private const int MaximumEnrollmentTokenBytes = 4096; private readonly string _certificatePath = Path.Combine(options.StateDirectory, "agent.pfx"); + public async Task EnrollFromFileAsync(string path, CancellationToken cancellationToken) + { + var expectedPath = Path.GetFullPath(Path.Combine(options.EnrollmentTokenDirectory, "enroll-token")); + var suppliedPath = Path.GetFullPath(path); + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (!string.Equals(suppliedPath, expectedPath, comparison)) + { + throw new InvalidDataException( + "Enrollment token file must be the dedicated file in the enrollment directory."); + } + + var bytes = await ReadAndDeleteEnrollmentTokenAsync(path, cancellationToken); + try + { + await EnrollAsync(Encoding.UTF8.GetString(bytes), cancellationToken); + } + finally + { + CryptographicOperations.ZeroMemory(bytes); + } + } + + internal static async Task ReadAndDeleteEnrollmentTokenAsync( + string path, + CancellationToken cancellationToken) + { + byte[]? bytes = null; + var deleteTokenFile = false; + try + { + if (!Path.IsPathFullyQualified(path)) + { + throw new InvalidDataException("Enrollment token file path must be absolute."); + } + deleteTokenFile = true; + + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + const UnixFileMode forbidden = UnixFileMode.GroupRead | UnixFileMode.GroupWrite + | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherWrite + | UnixFileMode.OtherExecute; + if ((File.GetUnixFileMode(path) & forbidden) != 0) + { + throw new UnauthorizedAccessException( + "Enrollment token file must not be accessible by group or other users."); + } + } + + await using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.None, + bufferSize: 4096, + useAsync: true); + if (stream.Length is <= 0 or > MaximumEnrollmentTokenBytes) + { + throw new InvalidDataException("Enrollment token file has an invalid size."); + } + + bytes = new byte[stream.Length]; + await stream.ReadExactlyAsync(bytes, cancellationToken); + if (bytes.Any(static value => value is not ( + >= (byte)'A' and <= (byte)'Z' + or >= (byte)'a' and <= (byte)'z' + or >= (byte)'0' and <= (byte)'9' + or (byte)'-' + or (byte)'_'))) + { + throw new InvalidDataException("Enrollment token file is not valid base64url data."); + } + + return bytes; + } + catch + { + if (bytes is not null) + { + CryptographicOperations.ZeroMemory(bytes); + } + throw; + } + finally + { + if (deleteTokenFile) + { + try + { + File.Delete(path); + } + catch (UnauthorizedAccessException) + { + // Production handoff lives in a root-owned directory. The root + // bootstrap owns final path cleanup on every exit path. + } + } + } + } + public async Task EnrollAsync(string token, CancellationToken cancellationToken) { Directory.CreateDirectory(options.StateDirectory); diff --git a/src/ServerMonitorManager.Agent/AgentOptions.cs b/src/ServerMonitorManager.Agent/AgentOptions.cs index b5603d5..509e848 100644 --- a/src/ServerMonitorManager.Agent/AgentOptions.cs +++ b/src/ServerMonitorManager.Agent/AgentOptions.cs @@ -7,6 +7,8 @@ public sealed class AgentOptions public string StateDirectory { get; init; } = "/var/lib/ochenstarik-server-monitor-manager/agent"; public string CertificateAuthorityPath { get; init; } = "/etc/ochenstarik-server-monitor-manager/control-ca.crt"; public string ProvisioningSocketPath { get; init; } = "/run/ochenstarik-server-monitor-manager/provisioning.sock"; + public string EnrollmentTokenDirectory { get; init; } = "/var/lib/ochenstarik-server-monitor-manager-enrollment"; + public string? EnrollTokenFile { get; init; } public int HeartbeatSeconds { get; init; } = 30; public int BufferMaxSamples { get; init; } = 720; public int BufferRecentSamples { get; init; } = 120; diff --git a/src/ServerMonitorManager.Agent/Program.cs b/src/ServerMonitorManager.Agent/Program.cs index c9693a7..7bc09c6 100644 --- a/src/ServerMonitorManager.Agent/Program.cs +++ b/src/ServerMonitorManager.Agent/Program.cs @@ -35,10 +35,14 @@ Console.CancelKeyPress += (_, eventArgs) => shutdown.Cancel(); }; var client = new AgentClient(options); -var enrollmentToken = configuration["EnrollToken"]; -if (!string.IsNullOrWhiteSpace(enrollmentToken)) +if (!string.IsNullOrWhiteSpace(configuration["EnrollToken"])) { - await client.EnrollAsync(enrollmentToken, shutdown.Token); + Console.Error.WriteLine("Inline enrollment tokens are not supported; use SMM_EnrollTokenFile."); + return 2; +} +if (!string.IsNullOrWhiteSpace(options.EnrollTokenFile)) +{ + await client.EnrollFromFileAsync(options.EnrollTokenFile, shutdown.Token); Console.WriteLine("Agent enrollment completed."); return 0; } diff --git a/src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj b/src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj index 8f46603..cee78b2 100644 --- a/src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj +++ b/src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj @@ -18,4 +18,7 @@ + + + diff --git a/src/ServerMonitorManager.Provisioning.Helper/Program.cs b/src/ServerMonitorManager.Provisioning.Helper/Program.cs index 9ebef5a..bef9e67 100644 --- a/src/ServerMonitorManager.Provisioning.Helper/Program.cs +++ b/src/ServerMonitorManager.Provisioning.Helper/Program.cs @@ -23,6 +23,11 @@ if (localNodeId is not { Length: >= 1 and <= 63 } Console.Error.WriteLine("SMM_NodeId must identify the local enrolled Node."); return 2; } +if (!uint.TryParse(Environment.GetEnvironmentVariable("SMM_AgentUid"), out var agentUserId)) +{ + Console.Error.WriteLine("SMM_AgentUid must identify the enrolled Agent user."); + return 2; +} using var shutdown = new CancellationTokenSource(); Console.CancelKeyPress += (_, eventArgs) => @@ -39,5 +44,5 @@ var timezoneExecutor = new TimezoneProvisioningExecutor( new ProvisioningProcessRunner(), TimeProvider.System, rollbackDirectory); -await new ProvisioningHelperServer(socketPath, timezoneExecutor).RunAsync(shutdown.Token); +await new ProvisioningHelperServer(socketPath, agentUserId, timezoneExecutor).RunAsync(shutdown.Token); return 0; diff --git a/src/ServerMonitorManager.Provisioning.Helper/ProvisioningHelperServer.cs b/src/ServerMonitorManager.Provisioning.Helper/ProvisioningHelperServer.cs index 210ddc0..1529024 100644 --- a/src/ServerMonitorManager.Provisioning.Helper/ProvisioningHelperServer.cs +++ b/src/ServerMonitorManager.Provisioning.Helper/ProvisioningHelperServer.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Net.Sockets; using System.Runtime.InteropServices; using System.Runtime.Versioning; @@ -10,15 +11,68 @@ namespace ServerMonitorManager.Provisioning.Helper; public sealed class ProvisioningHelperServer { private const int MaximumRequestBytes = 16 * 1024; + private const int SocketOptionLevel = 1; + private const int SocketPeerCredentials = 17; private readonly string _socketPath; + private readonly uint _expectedPeerUserId; private readonly TimezoneProvisioningExecutor? _timezoneExecutor; + private readonly TimeSpan _connectionTimeout; + private readonly int _maximumConcurrentConnections; + private readonly int _requestsPerMinute; + private readonly int _unauthorizedAttemptsPerMinute; + private readonly int _globalUnauthorizedAttemptsPerMinute; + private readonly TimeProvider _timeProvider; + private readonly object _rateLimitLock = new(); + private readonly Queue _recentRequests = new(); + private readonly Queue _recentUnauthorizedAttempts = new(); + private readonly Dictionary> _recentUnauthorizedAttemptsByUser = []; + private DateTimeOffset? _lastRequestRateLimitLog; public ProvisioningHelperServer( string socketPath, - TimezoneProvisioningExecutor? timezoneExecutor = null) + uint expectedPeerUserId, + TimezoneProvisioningExecutor? timezoneExecutor = null, + TimeSpan? connectionTimeout = null, + int maximumConcurrentConnections = 4, + int requestsPerMinute = 120, + int unauthorizedAttemptsPerMinute = 30, + int globalUnauthorizedAttemptsPerMinute = 120, + TimeProvider? timeProvider = null) { + if (maximumConcurrentConnections <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(maximumConcurrentConnections), + "Connection limit must be positive."); + } + if (requestsPerMinute <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(requestsPerMinute), + "Request limit must be positive."); + } + if (connectionTimeout is { } timeout && timeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(connectionTimeout), + "Connection timeout must be positive."); + } + if (unauthorizedAttemptsPerMinute <= 0 || globalUnauthorizedAttemptsPerMinute <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(unauthorizedAttemptsPerMinute), + "Unauthorized connection limits must be positive."); + } + _socketPath = socketPath; + _expectedPeerUserId = expectedPeerUserId; _timezoneExecutor = timezoneExecutor; + _connectionTimeout = connectionTimeout ?? TimeSpan.FromSeconds(30); + _maximumConcurrentConnections = maximumConcurrentConnections; + _requestsPerMinute = requestsPerMinute; + _unauthorizedAttemptsPerMinute = unauthorizedAttemptsPerMinute; + _globalUnauthorizedAttemptsPerMinute = globalUnauthorizedAttemptsPerMinute; + _timeProvider = timeProvider ?? TimeProvider.System; } [SupportedOSPlatform("linux")] @@ -32,32 +86,186 @@ public sealed class ProvisioningHelperServer UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.GroupWrite); listener.Listen(8); + using var connectionSlots = new SemaphoreSlim(_maximumConcurrentConnections); + var handlers = new List(_maximumConcurrentConnections); try { while (!cancellationToken.IsCancellationRequested) { - var connection = await listener.AcceptAsync(cancellationToken); + await connectionSlots.WaitAsync(cancellationToken); + Socket? connection = null; try { - await HandleAsync(connection, cancellationToken); + connection = await listener.AcceptAsync(cancellationToken); + handlers.RemoveAll(static task => task.IsCompleted); + handlers.Add(Task.Run( + () => HandleConnectionAsync( + connection, + connectionSlots, + cancellationToken), + CancellationToken.None)); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch { + connection?.Dispose(); + connectionSlots.Release(); throw; } - catch (Exception) - { - connection.Dispose(); - // A malformed or disconnected local client must not terminate the helper listener. - } } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } finally { + await Task.WhenAll(handlers); File.Delete(_socketPath); } } + [SupportedOSPlatform("linux")] + private async Task HandleConnectionAsync( + Socket connection, + SemaphoreSlim connectionSlots, + CancellationToken serverCancellationToken) + { + try + { + var credentials = GetPeerCredentials(connection); + if (credentials.UserId != _expectedPeerUserId) + { + if (TryConsumeUnauthorizedAttempt(credentials.UserId)) + { + Console.Error.WriteLine( + $"Provisioning helper rejected peer uid {credentials.UserId}."); + } + connection.Dispose(); + return; + } + if (!TryConsumeRequest()) + { + if (ShouldLogRequestRateLimit()) + { + Console.Error.WriteLine( + $"Provisioning helper rate limit exceeded for uid {credentials.UserId}."); + } + connection.Dispose(); + return; + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource( + serverCancellationToken); + timeout.CancelAfter(_connectionTimeout); + try + { + await HandleAsync(connection, timeout.Token); + } + catch (OperationCanceledException) when (!serverCancellationToken.IsCancellationRequested) + { + connection.Dispose(); + Console.Error.WriteLine( + $"Provisioning helper connection timed out for uid {credentials.UserId}."); + } + } + catch (OperationCanceledException) when (serverCancellationToken.IsCancellationRequested) + { + connection.Dispose(); + } + catch (Exception exception) + { + connection.Dispose(); + Console.Error.WriteLine( + $"Provisioning helper rejected a local connection: {exception.GetType().Name}."); + } + finally + { + connectionSlots.Release(); + } + } + + private bool TryConsumeRequest() + { + var now = _timeProvider.GetUtcNow(); + var cutoff = now - TimeSpan.FromMinutes(1); + lock (_rateLimitLock) + { + while (_recentRequests.TryPeek(out var timestamp) && timestamp <= cutoff) + { + _recentRequests.Dequeue(); + } + if (_recentRequests.Count >= _requestsPerMinute) + { + return false; + } + _recentRequests.Enqueue(now); + return true; + } + } + + private bool TryConsumeUnauthorizedAttempt(uint userId) + { + var now = _timeProvider.GetUtcNow(); + var cutoff = now - TimeSpan.FromMinutes(1); + lock (_rateLimitLock) + { + TrimExpired(_recentUnauthorizedAttempts, cutoff); + if (!_recentUnauthorizedAttemptsByUser.TryGetValue(userId, out var userAttempts)) + { + userAttempts = new Queue(); + _recentUnauthorizedAttemptsByUser[userId] = userAttempts; + } + TrimExpired(userAttempts, cutoff); + if (_recentUnauthorizedAttempts.Count >= _globalUnauthorizedAttemptsPerMinute + || userAttempts.Count >= _unauthorizedAttemptsPerMinute) + { + return false; + } + _recentUnauthorizedAttempts.Enqueue(now); + userAttempts.Enqueue(now); + return true; + } + } + + private bool ShouldLogRequestRateLimit() + { + var now = _timeProvider.GetUtcNow(); + lock (_rateLimitLock) + { + if (_lastRequestRateLimitLog is { } lastLog + && lastLog > now - TimeSpan.FromMinutes(1)) + { + return false; + } + _lastRequestRateLimitLog = now; + return true; + } + } + + private static void TrimExpired(Queue attempts, DateTimeOffset cutoff) + { + while (attempts.TryPeek(out var timestamp) && timestamp <= cutoff) + { + attempts.Dequeue(); + } + } + + [SupportedOSPlatform("linux")] + private static UnixPeerCredentials GetPeerCredentials(Socket socket) + { + Span rawCredentials = stackalloc byte[12]; + if (socket.GetRawSocketOption( + SocketOptionLevel, + SocketPeerCredentials, + rawCredentials) != rawCredentials.Length) + { + throw new InvalidDataException("SO_PEERCRED returned an invalid credential length."); + } + return new UnixPeerCredentials( + BitConverter.ToInt32(rawCredentials), + BitConverter.ToUInt32(rawCredentials[4..]), + BitConverter.ToUInt32(rawCredentials[8..])); + } + public static ProvisioningHelperResponse Execute(ProvisioningHelperRequest request) => Execute(request, null); @@ -101,6 +309,20 @@ public sealed class ProvisioningHelperServer 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); + } + private static ProvisioningHelperResponse ExecutePreflight(ProvisioningHelperRequest request) { if (request.ModuleHash != ProvisioningActionCatalog.PreflightModuleHash @@ -167,12 +389,16 @@ public sealed class ProvisioningHelperServer var payload = await ReadRequestAsync(stream, cancellationToken); var request = JsonSerializer.Deserialize(payload, SmmJsonContext.Default.ProvisioningHelperRequest) ?? throw new JsonException("Empty request."); - response = Execute(request, _timezoneExecutor); + response = await ExecuteRequestAsync(request, cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (InvalidRequestSizeException) + { + response = Failure("request.invalid-size", "Invalid helper request size."); + } catch (Exception) { response = Failure("request.invalid", "Invalid helper request."); @@ -185,19 +411,35 @@ public sealed class ProvisioningHelperServer private static async Task ReadRequestAsync(Stream stream, CancellationToken cancellationToken) { using var buffer = new MemoryStream(); - var singleByte = new byte[1]; - while (buffer.Length <= MaximumRequestBytes) + var rented = ArrayPool.Shared.Rent(4096); + try { - var count = await stream.ReadAsync(singleByte, cancellationToken); - if (count == 0 || singleByte[0] == (byte)'\n') + while (buffer.Length <= MaximumRequestBytes) { - break; + var remaining = MaximumRequestBytes + 1 - checked((int)buffer.Length); + var count = await stream.ReadAsync( + rented.AsMemory(0, Math.Min(rented.Length, remaining)), + cancellationToken); + if (count == 0) + { + break; + } + var newline = rented.AsSpan(0, count).IndexOf((byte)'\n'); + var payloadCount = newline >= 0 ? newline : count; + buffer.Write(rented, 0, payloadCount); + if (newline >= 0) + { + break; + } } - buffer.WriteByte(singleByte[0]); + } + finally + { + ArrayPool.Shared.Return(rented, clearArray: true); } if (buffer.Length == 0 || buffer.Length > MaximumRequestBytes) { - throw new InvalidDataException("Request size is invalid."); + throw new InvalidRequestSizeException(); } return buffer.ToArray(); } @@ -229,4 +471,10 @@ public sealed class ProvisioningHelperServer private static ProvisioningHelperResponse Failure(string code, string message) => new(false, code, message, null, null); + + private sealed class InvalidRequestSizeException : Exception + { + } + + private readonly record struct UnixPeerCredentials(int ProcessId, uint UserId, uint GroupId); } diff --git a/src/ServerMonitorManager.Provisioning.Helper/TimezoneProvisioningExecutor.cs b/src/ServerMonitorManager.Provisioning.Helper/TimezoneProvisioningExecutor.cs index b6e1e4e..709caf8 100644 --- a/src/ServerMonitorManager.Provisioning.Helper/TimezoneProvisioningExecutor.cs +++ b/src/ServerMonitorManager.Provisioning.Helper/TimezoneProvisioningExecutor.cs @@ -17,7 +17,10 @@ public interface IProvisioningFileSystem public interface IProvisioningProcessRunner { - ProvisioningProcessResult Run(string fileName, IReadOnlyList arguments); + Task RunAsync( + string fileName, + IReadOnlyList arguments, + CancellationToken cancellationToken); } public sealed record ProvisioningProcessResult(int ExitCode, string StandardOutput, string StandardError); @@ -34,7 +37,13 @@ public sealed class TimezoneProvisioningExecutor( 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) { + cancellationToken.ThrowIfCancellationRequested(); var authorization = request.Execution; if (authorization is null || request.ProtocolVersion != ProvisioningExecutionGrantCodec.ProtocolVersion @@ -104,7 +113,11 @@ public sealed class TimezoneProvisioningExecutor( ProvisioningProcessResult observedBefore; try { - observedBefore = QueryTimezone(); + observedBefore = await QueryTimezoneAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } catch { @@ -141,11 +154,11 @@ public sealed class TimezoneProvisioningExecutor( var failureCode = "timezone.mutation-failed"; try { - var mutation = SetTimezone(plan.Timezone); + var mutation = await SetTimezoneAsync(plan.Timezone, cancellationToken); if (mutation.ExitCode == 0) { failureCode = "timezone.verification-failed"; - var observedAfter = NormalizeTimezone(QueryTimezone()); + var observedAfter = NormalizeTimezone(await QueryTimezoneAsync(cancellationToken)); if (string.Equals(observedAfter, plan.Timezone, StringComparison.Ordinal)) { return new ProvisioningBaseInstallExecutionResult( @@ -154,6 +167,10 @@ public sealed class TimezoneProvisioningExecutor( } } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return await RecoverAfterCancellationAsync(previousTimezone); + } catch { // A failed process or verification can still have partially changed the host. @@ -163,14 +180,18 @@ public sealed class TimezoneProvisioningExecutor( string? observedRollback = null; try { - var rollback = SetTimezone(previousTimezone); + var rollback = await SetTimezoneAsync(previousTimezone, cancellationToken); if (rollback.ExitCode == 0) { - observedRollback = NormalizeTimezone(QueryTimezone()); + observedRollback = NormalizeTimezone(await QueryTimezoneAsync(cancellationToken)); rollbackSucceeded = string.Equals( observedRollback, previousTimezone, StringComparison.Ordinal); } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return await RecoverAfterCancellationAsync(previousTimezone); + } catch { rollbackSucceeded = false; @@ -189,13 +210,65 @@ public sealed class TimezoneProvisioningExecutor( observedRollback); } - private ProvisioningProcessResult QueryTimezone() - => processRunner.Run( - TimedatectlPath, - ["show", "--property=Timezone", "--value"]); + private async Task RecoverAfterCancellationAsync( + string previousTimezone) + { + using var recoveryTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + string? observedTimezone = null; + try + { + observedTimezone = NormalizeTimezone( + await QueryTimezoneAsync(recoveryTimeout.Token)); + if (!string.Equals(observedTimezone, previousTimezone, StringComparison.Ordinal)) + { + var rollback = await SetTimezoneAsync(previousTimezone, recoveryTimeout.Token); + if (rollback.ExitCode == 0) + { + observedTimezone = NormalizeTimezone( + await QueryTimezoneAsync(recoveryTimeout.Token)); + } + } + var rollbackSucceeded = string.Equals( + observedTimezone, previousTimezone, StringComparison.Ordinal); + return new ProvisioningBaseInstallExecutionResult( + false, + "timezone.execution-cancelled", + rollbackSucceeded + ? "Timezone execution was cancelled; prior state was verified." + : "Timezone execution was cancelled; host state requires reconciliation.", + !rollbackSucceeded, + false, + true, + rollbackSucceeded, + observedTimezone); + } + catch + { + return new ProvisioningBaseInstallExecutionResult( + false, + "timezone.execution-cancelled", + "Timezone execution was cancelled; host state requires reconciliation.", + true, + false, + true, + false, + observedTimezone); + } + } - private ProvisioningProcessResult SetTimezone(string timezone) - => processRunner.Run(TimedatectlPath, ["set-timezone", timezone]); + private Task QueryTimezoneAsync(CancellationToken cancellationToken) + => processRunner.RunAsync( + TimedatectlPath, + ["show", "--property=Timezone", "--value"], + cancellationToken); + + private Task SetTimezoneAsync( + string timezone, + CancellationToken cancellationToken) + => processRunner.RunAsync( + TimedatectlPath, + ["set-timezone", timezone], + cancellationToken); private bool HasSymlinkedManagedPath(string path) { @@ -308,7 +381,10 @@ public sealed class ProvisioningFileSystem : IProvisioningFileSystem public sealed class ProvisioningProcessRunner : IProvisioningProcessRunner { - public ProvisioningProcessResult Run(string fileName, IReadOnlyList arguments) + public async Task RunAsync( + string fileName, + IReadOnlyList arguments, + CancellationToken cancellationToken) { if (!string.Equals(fileName, "/usr/bin/timedatectl", StringComparison.Ordinal)) { @@ -330,16 +406,26 @@ public sealed class ProvisioningProcessRunner : IProvisioningProcessRunner ?? throw new InvalidOperationException("Provisioning process could not be started."); var standardOutput = process.StandardOutput.ReadToEndAsync(); var standardError = process.StandardError.ReadToEndAsync(); - if (!process.WaitForExit(30_000)) + using var processTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + processTimeout.CancelAfter(TimeSpan.FromSeconds(30)); + try { - process.Kill(entireProcessTree: true); - process.WaitForExit(); - throw new TimeoutException("Provisioning process timed out."); + await process.WaitForExitAsync(processTimeout.Token); + await Task.WhenAll(standardOutput, standardError); + } + catch (OperationCanceledException) + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + await process.WaitForExitAsync(CancellationToken.None); + await Task.WhenAll(standardOutput, standardError); + throw; } - Task.WaitAll(standardOutput, standardError); return new ProvisioningProcessResult( process.ExitCode, - standardOutput.GetAwaiter().GetResult(), - standardError.GetAwaiter().GetResult()); + await standardOutput, + await standardError); } } diff --git a/tests/ServerMonitorManager.Control.Tests/EnrollmentTokenTests.cs b/tests/ServerMonitorManager.Control.Tests/EnrollmentTokenTests.cs new file mode 100644 index 0000000..c695dcd --- /dev/null +++ b/tests/ServerMonitorManager.Control.Tests/EnrollmentTokenTests.cs @@ -0,0 +1,137 @@ +using System.Security.Cryptography; +using System.Text; +using ServerMonitorManager.Agent; +using Xunit; + +namespace ServerMonitorManager.Control.Tests; + +public sealed class EnrollmentTokenTests : IDisposable +{ + private readonly string _directory = Path.Combine( + Path.GetTempPath(), + $"smm-enrollment-token-{Guid.NewGuid():N}"); + + [Fact] + public async Task TokenFileIsReadOnceAndDeleted() + { + Directory.CreateDirectory(_directory); + var path = Path.Combine(_directory, "enroll-token"); + await File.WriteAllBytesAsync( + path, + Encoding.UTF8.GetBytes("one-time-token"), + TestContext.Current.CancellationToken); + RestrictTokenFile(path); + + var tokenBytes = await AgentClient.ReadAndDeleteEnrollmentTokenAsync( + path, + TestContext.Current.CancellationToken); + try + { + Assert.Equal("one-time-token", Encoding.UTF8.GetString(tokenBytes)); + Assert.False(File.Exists(path)); + } + finally + { + CryptographicOperations.ZeroMemory(tokenBytes); + } + } + + [Fact] + public async Task OversizedTokenFileIsRejectedAndDeleted() + { + Directory.CreateDirectory(_directory); + var path = Path.Combine(_directory, "enroll-token"); + await File.WriteAllBytesAsync(path, new byte[4097], TestContext.Current.CancellationToken); + RestrictTokenFile(path); + + await Assert.ThrowsAsync(() => + AgentClient.ReadAndDeleteEnrollmentTokenAsync( + path, + TestContext.Current.CancellationToken)); + + Assert.False(File.Exists(path)); + } + + [Fact] + public async Task RelativeTokenPathIsRejectedWithoutDeletingTheFile() + { + var fileName = $"smm-relative-token-{Guid.NewGuid():N}"; + await File.WriteAllTextAsync( + fileName, + "must-remain", + TestContext.Current.CancellationToken); + try + { + await Assert.ThrowsAsync(() => + AgentClient.ReadAndDeleteEnrollmentTokenAsync( + fileName, + TestContext.Current.CancellationToken)); + + Assert.True(File.Exists(fileName)); + } + finally + { + File.Delete(fileName); + } + } + + [Fact] + public async Task MultilineTokenIsRejectedAndDeleted() + { + Directory.CreateDirectory(_directory); + var path = Path.Combine(_directory, "enroll-token"); + await File.WriteAllTextAsync( + path, + "first-line\nsecond-line", + TestContext.Current.CancellationToken); + RestrictTokenFile(path); + + await Assert.ThrowsAsync(() => + AgentClient.ReadAndDeleteEnrollmentTokenAsync( + path, + TestContext.Current.CancellationToken)); + + Assert.False(File.Exists(path)); + } + + [Fact] + public async Task EnrollmentDoesNotDeleteAnArbitraryAbsoluteFile() + { + Directory.CreateDirectory(_directory); + var path = Path.Combine(Path.GetTempPath(), $"smm-unrelated-{Guid.NewGuid():N}"); + await File.WriteAllTextAsync(path, "must-remain", TestContext.Current.CancellationToken); + try + { + var client = new AgentClient(new AgentOptions + { + StateDirectory = _directory, + EnrollmentTokenDirectory = _directory + }); + + await Assert.ThrowsAsync(() => + client.EnrollFromFileAsync(path, TestContext.Current.CancellationToken)); + + Assert.True(File.Exists(path)); + } + finally + { + File.Delete(path); + } + } + + private static void RestrictTokenFile(string path) + { + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } +} \ No newline at end of file diff --git a/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs b/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs index a518f9e..9d6e325 100644 --- a/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs +++ b/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs @@ -1,5 +1,8 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text; using System.Text.Json; using ServerMonitorManager.Agent; using ServerMonitorManager.Core; @@ -422,6 +425,344 @@ public sealed class ProvisioningHelperTests } } + [Fact] + public async Task SilentClientDoesNotBlockASecondValidRequest() + { + if (!OperatingSystem.IsLinux()) + { + return; + } + + var socketPath = Path.Combine(Path.GetTempPath(), $"smm-{Guid.NewGuid():N}.sock"); + using var shutdown = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + shutdown.CancelAfter(TimeSpan.FromSeconds(5)); + var server = new ProvisioningHelperServer( + socketPath, + expectedPeerUserId: GetEffectiveUserId(), + connectionTimeout: TimeSpan.FromSeconds(1)); + var serverTask = server.RunAsync(shutdown.Token); + using var silentClient = new Socket( + AddressFamily.Unix, + SocketType.Stream, + ProtocolType.Unspecified); + try + { + await WaitForSocketAsync(socketPath, shutdown.Token); + await silentClient.ConnectAsync( + new UnixDomainSocketEndPoint(socketPath), + shutdown.Token); + + var response = await SendPreflightAsync(socketPath, shutdown.Token); + + Assert.True(response.Success); + Assert.Equal("preflight.completed", response.Code); + } + finally + { + shutdown.Cancel(); + silentClient.Dispose(); + try + { + await serverTask; + } + catch (OperationCanceledException) + { + } + } + Assert.False(File.Exists(socketPath)); + } + + [Fact] + public async Task SynchronousExecutionCannotBlockAcceptLoopAndIsCancelledOnShutdown() + { + if (!OperatingSystem.IsLinux()) + { + return; + } + + using var authority = CreateAuthority(out var signingKey); + using (signingKey) + using (var shutdown = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken)) + { + shutdown.CancelAfter(TimeSpan.FromSeconds(5)); + var plan = CreateTimezoneOnlyPlan("Europe/Berlin"); + var files = new FakeFileSystem([]); + files.Files.Add("/usr/share/zoneinfo/Europe/Berlin"); + var process = new SynchronouslyBlockingProcessRunner(); + var executor = new TimezoneProvisioningExecutor( + authority, + "home", + files, + process, + TimeProvider.System, + "/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback"); + var socketPath = Path.Combine(Path.GetTempPath(), $"smm-{Guid.NewGuid():N}.sock"); + var server = new ProvisioningHelperServer( + socketPath, + GetEffectiveUserId(), + executor, + connectionTimeout: TimeSpan.FromSeconds(5)); + var serverTask = server.RunAsync(shutdown.Token); + Task? executionTask = null; + try + { + await WaitForSocketAsync(socketPath, shutdown.Token); + executionTask = SendRequestPayloadAsync( + socketPath, + CreateExecutionRequest(plan, SignGrant(authority, signingKey, plan)), + shutdown.Token); + Assert.True(process.Started.Wait( + TimeSpan.FromSeconds(2), + TestContext.Current.CancellationToken)); + using var secondRequest = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + secondRequest.CancelAfter(TimeSpan.FromSeconds(1)); + + var response = await SendPreflightAsync(socketPath, secondRequest.Token); + + Assert.True(response.Success); + Assert.Equal("preflight.completed", response.Code); + } + finally + { + shutdown.Cancel(); + await serverTask; + if (executionTask is not null) + { + try + { + await executionTask; + } + catch (OperationCanceledException) when (shutdown.IsCancellationRequested) + { + } + } + } + Assert.True(process.CancellationObserved); + Assert.True(process.RecoveryObserved); + Assert.False(File.Exists(socketPath)); + } + } + + [Fact] + public async Task UnexpectedPeerUserIdIsRejected() + { + if (!OperatingSystem.IsLinux()) + { + return; + } + + var socketPath = Path.Combine(Path.GetTempPath(), $"smm-{Guid.NewGuid():N}.sock"); + using var shutdown = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + shutdown.CancelAfter(TimeSpan.FromSeconds(5)); + var currentUserId = GetEffectiveUserId(); + var expectedUserId = currentUserId == uint.MaxValue + ? uint.MaxValue - 1 + : currentUserId + 1; + var server = new ProvisioningHelperServer(socketPath, expectedUserId); + var serverTask = server.RunAsync(shutdown.Token); + try + { + await WaitForSocketAsync(socketPath, shutdown.Token); + + Assert.Null(await SendPreflightPayloadAsync(socketPath, shutdown.Token)); + } + finally + { + shutdown.Cancel(); + await serverTask; + } + } + + [Fact] + public async Task PartialConnectionIsClosedAfterTimeout() + { + if (!OperatingSystem.IsLinux()) + { + return; + } + + var socketPath = Path.Combine(Path.GetTempPath(), $"smm-{Guid.NewGuid():N}.sock"); + using var shutdown = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + shutdown.CancelAfter(TimeSpan.FromSeconds(5)); + var server = new ProvisioningHelperServer( + socketPath, + GetEffectiveUserId(), + connectionTimeout: TimeSpan.FromMilliseconds(100)); + var serverTask = server.RunAsync(shutdown.Token); + try + { + await WaitForSocketAsync(socketPath, shutdown.Token); + using var client = new Socket( + AddressFamily.Unix, + SocketType.Stream, + ProtocolType.Unspecified); + await client.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), shutdown.Token); + await client.SendAsync(new byte[] { (byte)'{' }, shutdown.Token); + var buffer = new byte[1]; + + var count = await client.ReceiveAsync(buffer, shutdown.Token); + + Assert.Equal(0, count); + } + finally + { + shutdown.Cancel(); + await serverTask; + } + } + + [Fact] + public async Task RequestRateLimitRejectsExcessConnections() + { + if (!OperatingSystem.IsLinux()) + { + return; + } + + var socketPath = Path.Combine(Path.GetTempPath(), $"smm-{Guid.NewGuid():N}.sock"); + using var shutdown = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + shutdown.CancelAfter(TimeSpan.FromSeconds(5)); + var server = new ProvisioningHelperServer( + socketPath, + GetEffectiveUserId(), + requestsPerMinute: 1); + var serverTask = server.RunAsync(shutdown.Token); + try + { + await WaitForSocketAsync(socketPath, shutdown.Token); + + Assert.NotNull(await SendPreflightPayloadAsync(socketPath, shutdown.Token)); + Assert.Null(await SendPreflightPayloadAsync(socketPath, shutdown.Token)); + } + finally + { + shutdown.Cancel(); + await serverTask; + } + } + + [Fact] + public async Task RequestFramingIsBoundedAndListenerSurvivesInvalidInput() + { + if (!OperatingSystem.IsLinux()) + { + return; + } + + var socketPath = Path.Combine(Path.GetTempPath(), $"smm-{Guid.NewGuid():N}.sock"); + using var shutdown = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + shutdown.CancelAfter(TimeSpan.FromSeconds(5)); + var server = new ProvisioningHelperServer(socketPath, GetEffectiveUserId()); + var serverTask = server.RunAsync(shutdown.Token); + try + { + await WaitForSocketAsync(socketPath, shutdown.Token); + + var exactPayload = await SendPayloadAsync( + socketPath, + new string(' ', 16 * 1024) + "\n", + shutdown.Token); + var oversizedPayload = await SendPayloadAsync( + socketPath, + new string('x', 16 * 1024 + 1), + shutdown.Token); + var validResponse = await SendPreflightAsync(socketPath, shutdown.Token); + + var oversizedResponse = JsonSerializer.Deserialize( + oversizedPayload!, + SmmJsonContext.Default.ProvisioningHelperResponse)!; + Assert.DoesNotContain("too large", exactPayload, StringComparison.OrdinalIgnoreCase); + Assert.Equal("request.invalid-size", oversizedResponse.Code); + Assert.Equal("Invalid helper request size.", oversizedResponse.Message); + Assert.True(validResponse.Success); + } + finally + { + shutdown.Cancel(); + await serverTask; + } + } + + private static async Task WaitForSocketAsync(string path, CancellationToken cancellationToken) + { + while (!File.Exists(path)) + { + await Task.Delay(10, cancellationToken); + } + } + + private static async Task SendPreflightAsync( + string socketPath, + CancellationToken cancellationToken) + { + var responsePayload = await SendPreflightPayloadAsync(socketPath, cancellationToken); + return JsonSerializer.Deserialize( + responsePayload!, + SmmJsonContext.Default.ProvisioningHelperResponse)!; + } + + private static async Task SendPreflightPayloadAsync( + string socketPath, + CancellationToken cancellationToken) + { + using var document = JsonDocument.Parse("{}"); + var request = new ProvisioningHelperRequest( + "1", new string('e', 32), "preflight", 1, + ProvisioningActionCatalog.PreflightModuleHash, + document.RootElement.Clone()); + return await SendRequestPayloadAsync(socketPath, request, cancellationToken); + } + + private static Task SendRequestPayloadAsync( + string socketPath, + ProvisioningHelperRequest request, + CancellationToken cancellationToken) + { + var payload = JsonSerializer.Serialize( + request, + SmmJsonContext.Default.ProvisioningHelperRequest) + "\n"; + return SendPayloadAsync(socketPath, payload, cancellationToken); + } + + private static async Task SendPayloadAsync( + string socketPath, + string payload, + CancellationToken cancellationToken) + { + using var socket = new Socket( + AddressFamily.Unix, + SocketType.Stream, + ProtocolType.Unspecified); + await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), cancellationToken); + try + { + await using var stream = new NetworkStream(socket, ownsSocket: false); + await stream.WriteAsync(Encoding.UTF8.GetBytes(payload), cancellationToken); + using var reader = new StreamReader(stream, Encoding.UTF8); + return await reader.ReadLineAsync(cancellationToken); + } + catch (IOException) + { + return null; + } + catch (SocketException) + { + return null; + } + } + + [DllImport("libc")] + private static extern uint geteuid(); + + private static uint GetEffectiveUserId() => geteuid(); + private static ProvisioningHelperRequest CreateExecutionRequest( SystemBaseInstallPlan plan, ProvisioningExecutionGrant grant) @@ -498,13 +839,53 @@ public sealed class ProvisioningHelperTests private readonly Queue _results = new(results); public List<(string FileName, IReadOnlyList Arguments)> Calls { get; } = []; - public ProvisioningProcessResult Run(string fileName, IReadOnlyList arguments) + public Task RunAsync( + string fileName, + IReadOnlyList arguments, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); Calls.Add((fileName, arguments.ToArray())); events.Add($"process:{string.Join(' ', arguments)}"); - return _results.Count == 0 + return Task.FromResult(_results.Count == 0 ? throw new InvalidOperationException("Unexpected process call.") - : _results.Dequeue(); + : _results.Dequeue()); + } + } + + private sealed class SynchronouslyBlockingProcessRunner : IProvisioningProcessRunner + { + private int _calls; + public ManualResetEventSlim Started { get; } = new(false); + public bool CancellationObserved { get; private set; } + public bool RecoveryObserved { get; private set; } + + public Task RunAsync( + string fileName, + IReadOnlyList arguments, + CancellationToken cancellationToken) + { + var call = Interlocked.Increment(ref _calls); + if (call == 1) + { + return Task.FromResult(new ProvisioningProcessResult(0, "UTC\n", "")); + } + if (call == 2) + { + Started.Set(); + cancellationToken.WaitHandle.WaitOne(); + CancellationObserved = cancellationToken.IsCancellationRequested; + cancellationToken.ThrowIfCancellationRequested(); + throw new InvalidOperationException("Cancellation was not observed."); + } + RecoveryObserved = true; + return Task.FromResult(call switch + { + 3 => new ProvisioningProcessResult(0, "Europe/Berlin\n", ""), + 4 => new ProvisioningProcessResult(0, "", ""), + 5 => new ProvisioningProcessResult(0, "UTC\n", ""), + _ => throw new InvalidOperationException("Unexpected recovery process call.") + }); } } } diff --git a/tests/bootstrap/test-bootstrap-contract.sh b/tests/bootstrap/test-bootstrap-contract.sh index 6beedaa..21b24e6 100755 --- a/tests/bootstrap/test-bootstrap-contract.sh +++ b/tests/bootstrap/test-bootstrap-contract.sh @@ -11,6 +11,24 @@ provisioning_helper_unit="$root/deploy/ochenstarik-smm-provisioning-helper.servi grep -Fq 'EnvironmentFile=/etc/ochenstarik-server-monitor-manager/agent.env' "$provisioning_helper_unit" grep -Fq 'ReadWritePaths=/var/lib/ochenstarik-server-monitor-manager/provisioning/rollback' "$provisioning_helper_unit" grep -Fq 'install -d -m 0700 -o root -g root "$STATE_DIR/provisioning/rollback"' "$bootstrap" +if grep -Fq 'SMM_EnrollToken=$ENROLL_TOKEN' "$bootstrap"; then + printf '%s\n' "enrollment token is exposed through process argv" >&2 + exit 1 +fi +grep -Fq 'readonly ENROLLMENT_DIR="${STATE_DIR}-enrollment"' "$bootstrap" +grep -Fq 'install -d -m 0710 -o root -g "$AGENT_USER" "$ENROLLMENT_DIR"' "$bootstrap" +grep -Fq 'token_temp="$(mktemp "$ENROLLMENT_DIR/.enroll-token.XXXXXXXX")"' "$bootstrap" +if grep -Fq '$STATE_DIR/enrollment' "$bootstrap"; then + printf '%s\n' "enrollment directory is beneath Control-writable state" >&2 + exit 1 +fi +grep -Fq 'chmod 0400 "$token_temp"' "$bootstrap" +grep -Fq 'mv -fT -- "$token_temp" "$token_file"' "$bootstrap" +grep -Fq '"SMM_EnrollTokenFile=$token_file"' "$bootstrap" +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" help_output="$(bash "$bootstrap" --help)" version_output="$(bash "$bootstrap" --version)" diff --git a/tests/bootstrap/test-enrollment-token-argv.sh b/tests/bootstrap/test-enrollment-token-argv.sh new file mode 100644 index 0000000..9da26a4 --- /dev/null +++ b/tests/bootstrap/test-enrollment-token-argv.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +IFS=$'\n\t' + +[[ "$(uname -s)" == "Linux" ]] || { + printf '%s\n' "enrollment argv test requires Linux" >&2 + exit 1 +} + +root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +agent="$root/src/ServerMonitorManager.Agent/bin/Release/net10.0/ochenstarik-smm-agent.dll" +[[ -f "$agent" ]] || { + printf '%s\n' "build the Release agent before running this test" >&2 + exit 1 +} + +fixture="$(mktemp -d -t smm-enrollment-argv.XXXXXXXX)" +pid="" +cleanup() { + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fi + rm -rf -- "$fixture" +} +trap cleanup EXIT + +token="smm-secret-$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')" +state_dir="$fixture/state" +enrollment_dir="$fixture/enrollment" +token_file="$enrollment_dir/enroll-token" +ca_file="$fixture/control-ca.crt" +mkdir -p "$state_dir" "$enrollment_dir" +printf '%s' "$token" >"$token_file" +chmod 0600 "$token_file" +openssl req -x509 -newkey rsa:2048 -nodes -subj /CN=smm-test-ca \ + -keyout "$fixture/control-ca.key" -out "$ca_file" -days 1 >/dev/null 2>&1 + +SMM_NodeId=argv-test \ +SMM_ControlUrl=https://192.0.2.1:7443 \ +SMM_StateDirectory="$state_dir" \ +SMM_EnrollmentTokenDirectory="$enrollment_dir" \ +SMM_CertificateAuthorityPath="$ca_file" \ +SMM_EnrollTokenFile="$token_file" \ + dotnet "$agent" >"$fixture/agent.log" 2>&1 & +pid="$!" + +for _ in {1..500}; do + if [[ ! -e "$token_file" ]]; then + break + fi + if ! kill -0 "$pid" 2>/dev/null; then + cat "$fixture/agent.log" >&2 + printf '%s\n' "agent exited before consuming its enrollment token" >&2 + exit 1 + fi + sleep 0.01 +done +[[ ! -e "$token_file" ]] || { + cat "$fixture/agent.log" >&2 + printf '%s\n' "agent did not delete its enrollment token file" >&2 + exit 1 +} + +cmdline="$(tr '\0' ' ' <"/proc/$pid/cmdline")" +[[ "$cmdline" != *"$token"* ]] || { + printf '%s\n' "enrollment token is visible in /proc/$pid/cmdline" >&2 + exit 1 +} + +for process_cmdline in /proc/[0-9]*/cmdline; do + [[ -r "$process_cmdline" ]] || continue + cmdline="$(tr '\0' ' ' <"$process_cmdline" 2>/dev/null || true)" + [[ "$cmdline" != *"$token"* ]] || { + printf '%s\n' "enrollment token is visible in $process_cmdline" >&2 + exit 1 + } +done + +printf '%s\n' "ENROLLMENT_TOKEN_ARGV=PASS"