Execute confirmed timezone provisioning safely #6

Merged
ochenstarik-ui merged 34 commits from hermes/system-base-install-execution into main 2026-07-30 14:40:30 +00:00
6 changed files with 163 additions and 10 deletions
Showing only changes of commit 16885258f0 - Show all commits

View file

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

View file

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

View file

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

View file

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

View file

@ -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<ServerMonitorManager.Core.SystemBaseInstallCatalog>(
"/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(

View file

@ -53,4 +53,18 @@ public sealed class ProvisioningHelperTests
Assert.Throws<JsonException>(() =>
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<JsonException>(() =>
JsonSerializer.Deserialize(json, SmmJsonContext.Default.SystemBaseInstallParameters));
}
}