Execute confirmed timezone provisioning safely #6
6 changed files with 163 additions and 10 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue