fix(security): harden enrollment and provisioning helper (#7)

Co-authored-by: Ochenstarik <ochenstarik@inbox.ru>
This commit is contained in:
ochenstarik-ui 2026-07-31 02:32:47 +07:00 committed by GitHub
parent 2d28b8d19c
commit 93e0f8ddbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1150 additions and 50 deletions

View file

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

View file

@ -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" <<EOF
SMM_NodeId=$node_id
SMM_AgentUid=$(id -u "$AGENT_USER")
SMM_ControlUrl=${control_url%/}
SMM_StateDirectory=$STATE_DIR/agent
SMM_EnrollmentTokenDirectory=$ENROLLMENT_DIR
SMM_CertificateAuthorityPath=$ETC_DIR/control-ca.crt
EOF
chown root:"$AGENT_USER" "$ETC_DIR/agent.env"
chmod 0640 "$ETC_DIR/agent.env"
read_enrollment_token
runuser -u "$AGENT_USER" -- env \
token_file="$ENROLLMENT_DIR/enroll-token"
token_temp="$(mktemp "$ENROLLMENT_DIR/.enroll-token.XXXXXXXX")"
ENROLLMENT_TOKEN_TEMP="$token_temp"
if ! printf '%s' "$ENROLL_TOKEN" >"$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)}."
}

View file

@ -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<byte[]> 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);

View file

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

View file

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

View file

@ -18,4 +18,7 @@
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ServerMonitorManager.Control.Tests" />
</ItemGroup>
</Project>

View file

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

View file

@ -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<DateTimeOffset> _recentRequests = new();
private readonly Queue<DateTimeOffset> _recentUnauthorizedAttempts = new();
private readonly Dictionary<uint, Queue<DateTimeOffset>> _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<Task>(_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<DateTimeOffset>();
_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<DateTimeOffset> attempts, DateTimeOffset cutoff)
{
while (attempts.TryPeek(out var timestamp) && timestamp <= cutoff)
{
attempts.Dequeue();
}
}
[SupportedOSPlatform("linux")]
private static UnixPeerCredentials GetPeerCredentials(Socket socket)
{
Span<byte> 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<ProvisioningHelperResponse> ExecuteRequestAsync(
ProvisioningHelperRequest request,
CancellationToken cancellationToken)
{
var response = Execute(request, timezoneExecutor: null);
if (response.Code != "execution.unavailable" || _timezoneExecutor is null)
{
return response;
}
var result = await _timezoneExecutor.ExecuteAsync(request, cancellationToken);
return new ProvisioningHelperResponse(
result.Success, result.Code, result.Message, null, null, result);
}
private static ProvisioningHelperResponse ExecutePreflight(ProvisioningHelperRequest request)
{
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<byte[]> ReadRequestAsync(Stream stream, CancellationToken cancellationToken)
{
using var buffer = new MemoryStream();
var singleByte = new byte[1];
while (buffer.Length <= MaximumRequestBytes)
var rented = ArrayPool<byte>.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<byte>.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);
}

View file

@ -17,7 +17,10 @@ public interface IProvisioningFileSystem
public interface IProvisioningProcessRunner
{
ProvisioningProcessResult Run(string fileName, IReadOnlyList<string> arguments);
Task<ProvisioningProcessResult> RunAsync(
string fileName,
IReadOnlyList<string> 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<ProvisioningBaseInstallExecutionResult> 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<ProvisioningBaseInstallExecutionResult> 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<ProvisioningProcessResult> QueryTimezoneAsync(CancellationToken cancellationToken)
=> processRunner.RunAsync(
TimedatectlPath,
["show", "--property=Timezone", "--value"],
cancellationToken);
private Task<ProvisioningProcessResult> 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<string> arguments)
public async Task<ProvisioningProcessResult> RunAsync(
string fileName,
IReadOnlyList<string> 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);
}
}

View file

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

View file

@ -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<string?>? 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<ProvisioningHelperResponse> SendPreflightAsync(
string socketPath,
CancellationToken cancellationToken)
{
var responsePayload = await SendPreflightPayloadAsync(socketPath, cancellationToken);
return JsonSerializer.Deserialize(
responsePayload!,
SmmJsonContext.Default.ProvisioningHelperResponse)!;
}
private static async Task<string?> 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<string?> 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<string?> 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<ProvisioningProcessResult> _results = new(results);
public List<(string FileName, IReadOnlyList<string> Arguments)> Calls { get; } = [];
public ProvisioningProcessResult Run(string fileName, IReadOnlyList<string> arguments)
public Task<ProvisioningProcessResult> RunAsync(
string fileName,
IReadOnlyList<string> 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<ProvisioningProcessResult> RunAsync(
string fileName,
IReadOnlyList<string> 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.")
});
}
}
}

View file

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

View file

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