From 16885258f0238d218cb147a497d52db1030116e5 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Sun, 19 Jul 2026 23:49:51 +0700 Subject: [PATCH] Define strict base install schema catalog --- docs/roadmap.md | 2 +- src/ServerMonitorManager.Control/Program.cs | 67 ++++++++++++++++--- src/ServerMonitorManager.Core/Contracts.cs | 39 +++++++++++ .../SmmJsonContext.cs | 5 ++ .../ControlApiTests.cs | 46 ++++++++++++- .../ProvisioningHelperTests.cs | 14 ++++ 6 files changed, 163 insertions(+), 10 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 6fbff72..45b8a71 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -126,7 +126,7 @@ - [ ] preflight ОС, архитектуры, SSH, firewall, APT и capabilities; - [ ] Desktop wizard timezone/locale/packages/swap/unattended upgrades; -- [ ] versioned package allowlist; +- [x] versioned package allowlist (catalog v1 с фиксированными package groups); - [ ] root-only backups и symlink protection; - [ ] user lifecycle без sudo по умолчанию; - [ ] SSH public keys, fingerprints и permissions; diff --git a/src/ServerMonitorManager.Control/Program.cs b/src/ServerMonitorManager.Control/Program.cs index aea5554..e608ea4 100644 --- a/src/ServerMonitorManager.Control/Program.cs +++ b/src/ServerMonitorManager.Control/Program.cs @@ -464,6 +464,8 @@ agents.MapPost("/provisioning/jobs/{id}/preflight-facts", async ( var control = app.MapGroup("/api/v1/control").RequireAuthorization("Operator"); control.MapGet("/agents", async (ControlStore controlStore, CancellationToken cancellationToken) => Results.Ok((await controlStore.ListAgentsAsync(cancellationToken)).ToArray())); +control.MapGet("/provisioning/catalogs/system-base-install/1", () => + Results.Ok(SystemBaseInstallCatalogDefinition.Create())); control.MapGet("/agents/{nodeId}/facts/preflight", async ( string nodeId, ControlStore controlStore, @@ -945,14 +947,35 @@ internal static class ProvisioningJobValidator private const int MaximumParametersBytes = 16 * 1024; public static bool IsValid(ProvisioningJobCreateRequest request) - => request.SchemaVersion == 1 - && request.ActionType is "preflight" or "system.base-install" - && request.Parameters.ValueKind == JsonValueKind.Object - && !request.Parameters.EnumerateObject().Any() - && request.Parameters.GetRawText().Length <= MaximumParametersBytes - && request.TtlMinutes is >= 5 and <= 1440 - && request.AuditReason.Length is >= 1 and <= 256 - && IdempotencyKeyValidator.IsValid(request.IdempotencyKey); + { + if (request.SchemaVersion != 1 + || request.Parameters.ValueKind != JsonValueKind.Object + || request.Parameters.GetRawText().Length > MaximumParametersBytes + || request.TtlMinutes is < 5 or > 1440 + || request.AuditReason is not { Length: >= 1 and <= 256 } + || !IdempotencyKeyValidator.IsValid(request.IdempotencyKey)) + { + return false; + } + if (request.ActionType == "preflight") + { + return !request.Parameters.EnumerateObject().Any(); + } + if (request.ActionType != "system.base-install") + { + return false; + } + try + { + var parameters = JsonSerializer.Deserialize( + request.Parameters, SmmJsonContext.Default.SystemBaseInstallParameters); + return parameters is not null && IsValid(parameters); + } + catch (JsonException) + { + return false; + } + } public static bool IsValid(ProvisioningJobCommandRequest request) => request.Reason.Length is >= 1 and <= 256 @@ -989,6 +1012,22 @@ internal static class ProvisioningJobValidator public static bool RequiresConfirmation(string actionType) => actionType == "system.base-install"; + private static bool IsValid(SystemBaseInstallParameters parameters) + => IsSafeTimezone(parameters.Timezone) + && IsSafeLocale(parameters.Locale) + && (!parameters.AptUpgrade || parameters.AptUpdate) + && parameters.PackageCatalogVersion == SystemBaseInstallCatalogDefinition.Version + && parameters.PackageGroupIds is { Length: <= 4 } + && parameters.PackageGroupIds.Distinct(StringComparer.Ordinal).Count() + == parameters.PackageGroupIds.Length + && parameters.PackageGroupIds.All(SystemBaseInstallCatalogDefinition.ContainsGroup) + && parameters.SwapMode is "disabled" or "automatic" or "explicit" + && (parameters.SwapMode == "explicit" + ? parameters.SwapSizeMiB is >= 128 and <= 1_048_576 + : parameters.SwapSizeMiB is null) + && parameters.VmSwappiness is >= 0 and <= 200 + && parameters.RebootPolicy == "never"; + private static bool IsSafeCode(string value, int maximumLength) => value.Length is >= 1 && value.Length <= maximumLength && value.All(character => character is >= 'a' and <= 'z' @@ -1000,6 +1039,18 @@ internal static class ProvisioningJobValidator && value.Length is >= 1 && value.Length <= maximumLength && value.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-' or '.'); + + private static bool IsSafeTimezone(string? value) + => value is { Length: >= 1 and <= 64 } + && value[0] is not '/' and not '.' + && !value.Contains("..", StringComparison.Ordinal) + && value.All(character => char.IsAsciiLetterOrDigit(character) + || character is '/' or '_' or '-' or '+'); + + private static bool IsSafeLocale(string? value) + => value is { Length: >= 1 and <= 32 } + && value.All(character => char.IsAsciiLetterOrDigit(character) + || character is '_' or '-' or '.' or '@'); } internal static class PreflightDesiredStateValidator diff --git a/src/ServerMonitorManager.Core/Contracts.cs b/src/ServerMonitorManager.Core/Contracts.cs index 032735d..512cab3 100644 --- a/src/ServerMonitorManager.Core/Contracts.cs +++ b/src/ServerMonitorManager.Core/Contracts.cs @@ -236,6 +236,28 @@ public sealed record PreflightDriftAssessment( NodePreflightDesiredState? Desired, NodePreflightFacts? Facts); +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed record SystemBaseInstallParameters( + string Timezone, + string Locale, + bool AptUpdate, + bool AptUpgrade, + int PackageCatalogVersion, + string[] PackageGroupIds, + string SwapMode, + int? SwapSizeMiB, + int VmSwappiness, + bool EnableUnattendedUpgrades, + string RebootPolicy); + +public sealed record SystemPackageGroup( + string Id, + string[] Packages); + +public sealed record SystemBaseInstallCatalog( + int Version, + SystemPackageGroup[] Groups); + public static class PreflightDriftStatuses { public const string NotConfigured = "NotConfigured"; @@ -306,3 +328,20 @@ public static class ProvisioningActionCatalog public const string PreflightModuleHash = "2dc48fb4528a291221954fc2dd3478d431b66fe34228f29684ce1648dbe2f32b"; } + +public static class SystemBaseInstallCatalogDefinition +{ + public const int Version = 1; + + public static SystemBaseInstallCatalog Create() + => new(Version, + [ + new("core", ["ca-certificates", "curl", "jq"]), + new("development", ["build-essential", "git"]), + new("diagnostics", ["htop", "iotop"]), + new("container-host", ["dbus-user-session", "uidmap"]) + ]); + + public static bool ContainsGroup(string id) + => id is "core" or "development" or "diagnostics" or "container-host"; +} diff --git a/src/ServerMonitorManager.Core/SmmJsonContext.cs b/src/ServerMonitorManager.Core/SmmJsonContext.cs index 524d64c..255898e 100644 --- a/src/ServerMonitorManager.Core/SmmJsonContext.cs +++ b/src/ServerMonitorManager.Core/SmmJsonContext.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization; namespace ServerMonitorManager.Core; +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] [JsonSerializable(typeof(EnrollmentRequest))] [JsonSerializable(typeof(EnrollmentResponse))] [JsonSerializable(typeof(AgentHeartbeat))] @@ -37,6 +38,10 @@ namespace ServerMonitorManager.Core; [JsonSerializable(typeof(PreflightDesiredStateUpdateRequest))] [JsonSerializable(typeof(NodePreflightDesiredState))] [JsonSerializable(typeof(PreflightDriftAssessment))] +[JsonSerializable(typeof(SystemBaseInstallParameters))] +[JsonSerializable(typeof(SystemPackageGroup))] +[JsonSerializable(typeof(SystemPackageGroup[]))] +[JsonSerializable(typeof(SystemBaseInstallCatalog))] [JsonSerializable(typeof(ProvisioningJob))] [JsonSerializable(typeof(ProvisioningJob[]))] [JsonSerializable(typeof(ProvisioningEvent))] diff --git a/tests/ServerMonitorManager.Control.Tests/ControlApiTests.cs b/tests/ServerMonitorManager.Control.Tests/ControlApiTests.cs index 1fef077..ca4e601 100644 --- a/tests/ServerMonitorManager.Control.Tests/ControlApiTests.cs +++ b/tests/ServerMonitorManager.Control.Tests/ControlApiTests.cs @@ -96,13 +96,32 @@ public sealed class ControlApiTests : IAsyncDisposable using var client = _factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Test-Identity", "windows-pc"); client.DefaultRequestHeaders.Add("X-Test-Role", "Operator"); + var catalog = await client.GetFromJsonAsync( + "/api/v1/control/provisioning/catalogs/system-base-install/1", + cancellationToken); + Assert.Equal(1, catalog!.Version); + Assert.Contains(catalog.Groups, group => group.Id == "development" + && group.Packages.Contains("git")); using var response = await client.PostAsJsonAsync( "/api/v1/control/agents/home/provisioning/jobs", new { actionType = "system.base-install", schemaVersion = 1, - parameters = new { }, + parameters = new + { + timezone = "UTC", + locale = "en_US.UTF-8", + aptUpdate = true, + aptUpgrade = false, + packageCatalogVersion = 1, + packageGroupIds = new[] { "core", "development" }, + swapMode = "automatic", + swapSizeMiB = (int?)null, + vmSwappiness = 60, + enableUnattendedUpgrades = true, + rebootPolicy = "never" + }, ttlMinutes = 60, auditReason = "API integration test", idempotencyKey = Guid.NewGuid().ToString() @@ -114,6 +133,31 @@ public sealed class ControlApiTests : IAsyncDisposable cancellationToken); Assert.NotNull(job); Assert.Equal(ServerMonitorManager.Core.ProvisioningJobStates.AwaitingConfirmation, job.State); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync( + "/api/v1/control/agents/home/provisioning/jobs", + new + { + actionType = "system.base-install", + schemaVersion = 1, + parameters = new + { + timezone = "UTC", + locale = "en_US.UTF-8", + aptUpdate = true, + aptUpgrade = false, + packageCatalogVersion = 1, + packageGroupIds = new[] { "curl" }, + swapMode = "disabled", + swapSizeMiB = (int?)null, + vmSwappiness = 60, + enableUnattendedUpgrades = false, + rebootPolicy = "never" + }, + ttlMinutes = 60, + auditReason = "Reject arbitrary package input", + idempotencyKey = Guid.NewGuid().ToString() + }, + cancellationToken)).StatusCode); Assert.Equal(HttpStatusCode.OK, (await client.GetAsync( $"/api/v1/control/provisioning/jobs/{job.Id}", cancellationToken)).StatusCode); using var eventsResponse = await client.GetAsync( diff --git a/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs b/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs index f5c8190..ca33b0d 100644 --- a/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs +++ b/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs @@ -53,4 +53,18 @@ public sealed class ProvisioningHelperTests Assert.Throws(() => JsonSerializer.Deserialize(json, SmmJsonContext.Default.ProvisioningHelperRequest)); } + + [Fact] + public void BaseInstallSchemaRejectsCommandText() + { + const string json = """ + {"timezone":"UTC","locale":"en_US.UTF-8","aptUpdate":true,"aptUpgrade":false, + "packageCatalogVersion":1,"packageGroupIds":["core"],"swapMode":"disabled", + "swapSizeMiB":null,"vmSwappiness":60,"enableUnattendedUpgrades":true, + "rebootPolicy":"never","command":"id"} + """; + + Assert.Throws(() => + JsonSerializer.Deserialize(json, SmmJsonContext.Default.SystemBaseInstallParameters)); + } }