From 7be4f153fb5d66387a0cbd21d178c8ccf5e90c28 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Sun, 19 Jul 2026 23:59:51 +0700 Subject: [PATCH] Add safe base install plan generation --- README.md | 2 +- docs/roadmap.md | 2 +- .../ProvisioningHelperClient.cs | 33 +++++++-- src/ServerMonitorManager.Control/Program.cs | 38 +--------- src/ServerMonitorManager.Core/Contracts.cs | 74 ++++++++++++++++++- .../SmmJsonContext.cs | 1 + .../ProvisioningHelperServer.cs | 57 ++++++++++++-- .../ProvisioningHelperTests.cs | 21 ++++++ 8 files changed, 178 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 9d3174e..8429a96 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ In the application, generate or copy the monitoring SSH key, add the Hub profile The current development branch implements dedicated Windows pages for Servers, Links, Sessions, and Settings; SSH monitoring; directional Links; one-time enrollment; separate mTLS Agent, Operator, and source-scoped Automation identities; certificate revocation/re-enrollment; SQLite control state; audit; authenticated event streaming; Windows Control API integration; and a bounded durable Agent buffer with downsampling. -Reconnect reconciliation is implemented with a durable SQLite marker: after a Node returns, the Hub reapplies the latest effective disabled policies and clears the marker only after the firewall confirms success. Control also expires TTL Links through the firewall helper, prunes bounded operational data, versions its SQLite schema, and creates verified backups of SQLite state and the Control CA. The Provisioning control plane persists versioned jobs, enforces TTL/audit/idempotency and one active job per Node, and supports confirmation, progress, reconciliation, retry, and rollback states. A restricted root helper accepts only versioned, module-hashed allowlisted requests through a local Unix socket; the first executable action is read-only Linux `preflight`. Its typed desired and factual states are persisted, versioned, idempotent, and compared through fixed drift codes exposed to Operators. CI exercises process boundaries, authorization, Agent parsing, Desktop contracts, and concurrent heartbeat/replay. Still required are mutating Provisioning actions with factual-state verification, physical WireGuard/nftables/reboot acceptance, trusted public code signing, Xray, and clients for additional platforms. +Reconnect reconciliation is implemented with a durable SQLite marker: after a Node returns, the Hub reapplies the latest effective disabled policies and clears the marker only after the firewall confirms success. Control also expires TTL Links through the firewall helper, prunes bounded operational data, versions its SQLite schema, and creates verified backups of SQLite state and the Control CA. The Provisioning control plane persists versioned jobs, enforces TTL/audit/idempotency and one active job per Node, and supports confirmation, progress, reconciliation, retry, and rollback states. A restricted root helper accepts only versioned, module-hashed allowlisted requests through a local Unix socket. It executes read-only Linux `preflight` and can build a deterministic, non-mutating `system.base-install` plan from catalog-backed parameters. Typed desired and factual states are persisted, versioned, idempotent, and compared through fixed drift codes exposed to Operators. CI exercises process boundaries, authorization, Agent parsing, Desktop contracts, and concurrent heartbeat/replay. Still required are mutating Provisioning actions with factual-state verification, physical WireGuard/nftables/reboot acceptance, trusted public code signing, Xray, and clients for additional platforms. ## License and project policy diff --git a/docs/roadmap.md b/docs/roadmap.md index 45b8a71..c6fc5ff 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -116,7 +116,7 @@ - [x] атомарный Agent job channel только для собственного `node_id`; - [x] начальные строгие JSON schemas v1 для `preflight` и `system.base-install`; - [ ] versioned JSON schemas для остальных action type; -- [x] restricted root helper через Unix socket (первый allowlisted action `preflight`); +- [x] restricted root helper через Unix socket (`preflight` и non-mutating plan для `system.base-install`); - [x] structured redacted events, bounded Operator history и progress; - [x] `NeedsReconciliation` после истечения execution TTL и неопределённого результата; - [ ] desired/factual configuration и drift (`preflight` завершён; остальные action type ещё не подключены); diff --git a/src/ServerMonitorManager.Agent/ProvisioningHelperClient.cs b/src/ServerMonitorManager.Agent/ProvisioningHelperClient.cs index 48b13a8..d0c2673 100644 --- a/src/ServerMonitorManager.Agent/ProvisioningHelperClient.cs +++ b/src/ServerMonitorManager.Agent/ProvisioningHelperClient.cs @@ -16,6 +16,33 @@ public sealed class ProvisioningHelperClient(string socketPath) var request = new ProvisioningHelperRequest( "1", job.Id, job.ActionType, job.SchemaVersion, ProvisioningActionCatalog.PreflightModuleHash, job.Parameters); + var response = await SendAsync(request, cancellationToken); + if (!response.Success || response.Preflight is null) + { + throw new InvalidOperationException($"Provisioning helper rejected the request: {response.Code}"); + } + return response.Preflight; + } + + public async Task CreateBaseInstallPlanAsync( + ProvisioningJob job, + CancellationToken cancellationToken) + { + var request = new ProvisioningHelperRequest( + "1", job.Id, job.ActionType, job.SchemaVersion, + ProvisioningActionCatalog.SystemBaseInstallModuleHash, job.Parameters); + var response = await SendAsync(request, cancellationToken); + if (!response.Success || response.BaseInstallPlan is null) + { + throw new InvalidOperationException($"Provisioning helper rejected the request: {response.Code}"); + } + return response.BaseInstallPlan; + } + + private async Task SendAsync( + ProvisioningHelperRequest request, + CancellationToken cancellationToken) + { using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), cancellationToken); await using var stream = new NetworkStream(socket, ownsSocket: false); @@ -24,11 +51,7 @@ public sealed class ProvisioningHelperClient(string socketPath) var payload = await ReadResponseAsync(stream, cancellationToken); var response = JsonSerializer.Deserialize(payload, SmmJsonContext.Default.ProvisioningHelperResponse) ?? throw new InvalidDataException("Provisioning helper returned an empty response."); - if (!response.Success || response.Preflight is null) - { - throw new InvalidOperationException($"Provisioning helper rejected the request: {response.Code}"); - } - return response.Preflight; + return response; } private static async Task ReadResponseAsync(Stream stream, CancellationToken cancellationToken) diff --git a/src/ServerMonitorManager.Control/Program.cs b/src/ServerMonitorManager.Control/Program.cs index e608ea4..5988f7f 100644 --- a/src/ServerMonitorManager.Control/Program.cs +++ b/src/ServerMonitorManager.Control/Program.cs @@ -965,16 +965,7 @@ internal static class ProvisioningJobValidator { return false; } - try - { - var parameters = JsonSerializer.Deserialize( - request.Parameters, SmmJsonContext.Default.SystemBaseInstallParameters); - return parameters is not null && IsValid(parameters); - } - catch (JsonException) - { - return false; - } + return SystemBaseInstallSchema.TryParse(request.Parameters, out _); } public static bool IsValid(ProvisioningJobCommandRequest request) @@ -1012,22 +1003,6 @@ 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' @@ -1040,17 +1015,6 @@ internal static class ProvisioningJobValidator && 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 512cab3..a9f5a53 100644 --- a/src/ServerMonitorManager.Core/Contracts.cs +++ b/src/ServerMonitorManager.Core/Contracts.cs @@ -177,7 +177,8 @@ public sealed record ProvisioningHelperResponse( bool Success, string Code, string Message, - ProvisioningPreflightResult? Preflight); + ProvisioningPreflightResult? Preflight, + SystemBaseInstallPlan? BaseInstallPlan); [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] public sealed record ProvisioningPreflightResult( @@ -258,6 +259,19 @@ public sealed record SystemBaseInstallCatalog( int Version, SystemPackageGroup[] Groups); +public sealed record SystemBaseInstallPlan( + string Timezone, + string Locale, + bool AptUpdate, + bool AptUpgrade, + string[] Packages, + string SwapMode, + int? SwapSizeMiB, + int VmSwappiness, + bool EnableUnattendedUpgrades, + string RebootPolicy, + string[] Warnings); + public static class PreflightDriftStatuses { public const string NotConfigured = "NotConfigured"; @@ -327,6 +341,8 @@ public static class ProvisioningActionCatalog { public const string PreflightModuleHash = "2dc48fb4528a291221954fc2dd3478d431b66fe34228f29684ce1648dbe2f32b"; + public const string SystemBaseInstallModuleHash = + "355d55e214b941160a32957ced1a681e3c7324f94ecb340f26042f0c3b59b99e"; } public static class SystemBaseInstallCatalogDefinition @@ -344,4 +360,60 @@ public static class SystemBaseInstallCatalogDefinition public static bool ContainsGroup(string id) => id is "core" or "development" or "diagnostics" or "container-host"; + + public static string[] ExpandGroups(IEnumerable ids) + { + var selected = ids.ToHashSet(StringComparer.Ordinal); + return Create().Groups + .Where(group => selected.Contains(group.Id)) + .SelectMany(group => group.Packages) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } +} + +public static class SystemBaseInstallSchema +{ + public static bool TryParse(JsonElement json, out SystemBaseInstallParameters? parameters) + { + try + { + parameters = JsonSerializer.Deserialize( + json, SmmJsonContext.Default.SystemBaseInstallParameters); + return parameters is not null && IsValid(parameters); + } + catch (JsonException) + { + parameters = null; + return false; + } + } + + public 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 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 '@'); } diff --git a/src/ServerMonitorManager.Core/SmmJsonContext.cs b/src/ServerMonitorManager.Core/SmmJsonContext.cs index 255898e..a77cce2 100644 --- a/src/ServerMonitorManager.Core/SmmJsonContext.cs +++ b/src/ServerMonitorManager.Core/SmmJsonContext.cs @@ -42,6 +42,7 @@ namespace ServerMonitorManager.Core; [JsonSerializable(typeof(SystemPackageGroup))] [JsonSerializable(typeof(SystemPackageGroup[]))] [JsonSerializable(typeof(SystemBaseInstallCatalog))] +[JsonSerializable(typeof(SystemBaseInstallPlan))] [JsonSerializable(typeof(ProvisioningJob))] [JsonSerializable(typeof(ProvisioningJob[]))] [JsonSerializable(typeof(ProvisioningEvent))] diff --git a/src/ServerMonitorManager.Provisioning.Helper/ProvisioningHelperServer.cs b/src/ServerMonitorManager.Provisioning.Helper/ProvisioningHelperServer.cs index 55b89b5..b1c0455 100644 --- a/src/ServerMonitorManager.Provisioning.Helper/ProvisioningHelperServer.cs +++ b/src/ServerMonitorManager.Provisioning.Helper/ProvisioningHelperServer.cs @@ -46,9 +46,22 @@ public sealed class ProvisioningHelperServer(string socketPath) { return Failure("request.invalid-job", "Invalid provisioning job identifier."); } - if (request.ActionType != "preflight" || request.SchemaVersion != 1 - || request.ModuleHash != ProvisioningActionCatalog.PreflightModuleHash - || request.Parameters.ValueKind != JsonValueKind.Object + if (request.SchemaVersion != 1 || request.Parameters.ValueKind != JsonValueKind.Object) + { + return Failure("action.denied", "The requested action is not allowed."); + } + + return request.ActionType switch + { + "preflight" => ExecutePreflight(request), + "system.base-install" => CreateBaseInstallPlan(request), + _ => Failure("action.denied", "The requested action is not allowed.") + }; + } + + private static ProvisioningHelperResponse ExecutePreflight(ProvisioningHelperRequest request) + { + if (request.ModuleHash != ProvisioningActionCatalog.PreflightModuleHash || request.Parameters.EnumerateObject().Any()) { return Failure("action.denied", "The requested action is not allowed."); @@ -64,7 +77,41 @@ public sealed class ProvisioningHelperServer(string socketPath) Exists("/usr/sbin/nft", "/usr/bin/nft", "/sbin/nft"), Exists("/usr/bin/wg", "/usr/sbin/wg", "/bin/wg"), Exists("/usr/bin/apt-get", "/bin/apt-get")); - return new ProvisioningHelperResponse(true, "preflight.completed", "Preflight completed.", result); + return new ProvisioningHelperResponse( + true, "preflight.completed", "Preflight completed.", result, null); + } + + private static ProvisioningHelperResponse CreateBaseInstallPlan(ProvisioningHelperRequest request) + { + if (request.ModuleHash != ProvisioningActionCatalog.SystemBaseInstallModuleHash + || !SystemBaseInstallSchema.TryParse(request.Parameters, out var parameters)) + { + return Failure("action.denied", "The requested action is not allowed."); + } + + var warnings = new List(); + if (!Exists("/usr/bin/apt-get", "/bin/apt-get")) + { + warnings.Add("apt.missing"); + } + if (!File.Exists(Path.Combine("/usr/share/zoneinfo", parameters!.Timezone))) + { + warnings.Add("timezone.missing"); + } + var plan = new SystemBaseInstallPlan( + parameters.Timezone, + parameters.Locale, + parameters.AptUpdate, + parameters.AptUpgrade, + SystemBaseInstallCatalogDefinition.ExpandGroups(parameters.PackageGroupIds), + parameters.SwapMode, + parameters.SwapSizeMiB, + parameters.VmSwappiness, + parameters.EnableUnattendedUpgrades, + parameters.RebootPolicy, + [.. warnings]); + return new ProvisioningHelperResponse( + true, "system.base-install.plan-ready", "Base install plan is ready.", null, plan); } private static async Task HandleAsync(Socket socket, CancellationToken cancellationToken) @@ -135,5 +182,5 @@ public sealed class ProvisioningHelperServer(string socketPath) private static bool Exists(params string[] paths) => paths.Any(File.Exists); private static ProvisioningHelperResponse Failure(string code, string message) - => new(false, code, message, null); + => new(false, code, message, null, null); } diff --git a/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs b/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs index ca33b0d..2ed11c2 100644 --- a/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs +++ b/tests/ServerMonitorManager.Control.Tests/ProvisioningHelperTests.cs @@ -67,4 +67,25 @@ public sealed class ProvisioningHelperTests Assert.Throws(() => JsonSerializer.Deserialize(json, SmmJsonContext.Default.SystemBaseInstallParameters)); } + + [Fact] + public void HelperBuildsDeterministicBaseInstallPlanWithoutCommands() + { + var parameters = new SystemBaseInstallParameters( + "UTC", "en_US.UTF-8", true, false, 1, + ["development", "core"], "disabled", null, 60, true, "never"); + var json = JsonSerializer.SerializeToElement( + parameters, SmmJsonContext.Default.SystemBaseInstallParameters); + var response = ProvisioningHelperServer.Execute(new ProvisioningHelperRequest( + "1", new string('c', 32), "system.base-install", 1, + ProvisioningActionCatalog.SystemBaseInstallModuleHash, json)); + + Assert.True(response.Success); + Assert.Equal("system.base-install.plan-ready", response.Code); + Assert.Null(response.Preflight); + Assert.Equal( + ["ca-certificates", "curl", "jq", "build-essential", "git"], + response.BaseInstallPlan!.Packages); + Assert.Equal("never", response.BaseInstallPlan.RebootPolicy); + } }