Authorize confirmed provisioning execution
This commit is contained in:
parent
8ddd4e19a9
commit
1eae8e1eea
11 changed files with 393 additions and 1 deletions
|
|
@ -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. It executes read-only Linux `preflight` and builds a deterministic, non-mutating `system.base-install` plan from catalog-backed parameters. Control independently validates and stores that immutable plan before the job enters `AwaitingConfirmation`; confirmed base-install jobs remain safely queued until the mutating executor and factual verification are implemented. 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 builds a deterministic, non-mutating `system.base-install` plan from catalog-backed parameters. Control independently validates and stores that immutable plan before the job enters `AwaitingConfirmation`. After confirmation, Control can issue an idempotent, audited, two-minute ECDSA execution grant bound to the exact job, Node, plan hash, and Control CA; confirmed base-install jobs remain safely queued until the helper consumes this grant and factual verification is implemented. 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
|
||||
|
||||
|
|
|
|||
|
|
@ -289,6 +289,8 @@ GET /api/v1/nodes/{nodeId}/vpn-profiles
|
|||
|
||||
Agent получает только задания своего Node. Automation identity не создаёт provisioning jobs, не читает VPN secrets и не управляет пользователями. Mutation требует Operator certificate, idempotency key и audit reason.
|
||||
|
||||
Подтверждённая опасная операция дополнительно требует короткоживущий execution grant. Control подписывает его ECDSA-ключом Control CA и связывает с `node_id`, `job_id`, action/schema, SHA-256 подтверждённого plan, nonce и сроком действия. Agent получает grant через `POST /api/v1/agents/provisioning/jobs/{jobId}/execution-grant`; root-helper проверяет подпись по закреплённому Control CA до запуска любой мутации.
|
||||
|
||||
Каждый action type имеет отдельную versioned JSON Schema. Неизвестные смысловые поля отклоняются на Control, Agent и helper.
|
||||
|
||||
## 15. Хранение и аудит
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@
|
|||
- [ ] versioned JSON schemas для остальных action type;
|
||||
- [x] restricted root helper через Unix socket (`preflight` и non-mutating plan для `system.base-install`);
|
||||
- [x] двухфазный `system.base-install`: сохранённый проверенный plan до Operator confirmation;
|
||||
- [x] короткоживущий ECDSA execution grant, привязанный к Node, job и SHA-256 подтверждённого plan;
|
||||
- [x] structured redacted events, bounded Operator history и progress;
|
||||
- [x] `NeedsReconciliation` после истечения execution TTL и неопределённого результата;
|
||||
- [ ] desired/factual configuration и drift (`preflight` завершён; остальные action type ещё не подключены);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ServerMonitorManager.Core;
|
||||
|
||||
namespace ServerMonitorManager.Control;
|
||||
|
||||
|
|
@ -65,6 +66,49 @@ public sealed class CertificateAuthority : IDisposable
|
|||
notAfter);
|
||||
}
|
||||
|
||||
public ProvisioningExecutionGrant SignProvisioningExecutionGrant(
|
||||
ProvisioningJob job,
|
||||
SystemBaseInstallPlan plan,
|
||||
DateTimeOffset issuedAt,
|
||||
TimeSpan lifetime)
|
||||
{
|
||||
if (job.ActionType != "system.base-install"
|
||||
|| job.SchemaVersion != 1
|
||||
|| job.State != ProvisioningJobStates.Queued
|
||||
|| !job.ConfirmationRequired
|
||||
|| job.ConfirmedAt is null
|
||||
|| lifetime <= TimeSpan.Zero
|
||||
|| lifetime > ProvisioningExecutionGrantCodec.MaximumLifetime)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Only a confirmed queued base installation job can receive an execution grant.");
|
||||
}
|
||||
|
||||
var grant = new ProvisioningExecutionGrant(
|
||||
ProvisioningExecutionGrantCodec.ProtocolVersion,
|
||||
job.Id,
|
||||
job.NodeId,
|
||||
job.ActionType,
|
||||
job.SchemaVersion,
|
||||
ProvisioningExecutionGrantCodec.ComputePlanSha256(plan),
|
||||
issuedAt.ToUnixTimeSeconds(),
|
||||
issuedAt.Add(lifetime).ToUnixTimeSeconds(),
|
||||
Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16)),
|
||||
ProvisioningExecutionGrantCodec.SignatureAlgorithm,
|
||||
string.Empty);
|
||||
using var key = _issuer.GetECDsaPrivateKey();
|
||||
if (key is not { KeySize: 256 })
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Control CA must use an ECDSA P-256 key to sign provisioning execution grants.");
|
||||
}
|
||||
var signature = key.SignData(
|
||||
ProvisioningExecutionGrantCodec.CreateSigningPayload(grant),
|
||||
HashAlgorithmName.SHA256,
|
||||
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
return grant with { Signature = ProvisioningExecutionGrantCodec.EncodeBase64Url(signature) };
|
||||
}
|
||||
|
||||
public void Dispose() => _issuer.Dispose();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -498,6 +498,46 @@ agents.MapPost("/provisioning/jobs/{id}/base-install-plan", async (
|
|||
return Results.BadRequest(new ProblemDetails { Title = exception.Message });
|
||||
}
|
||||
});
|
||||
agents.MapPost("/provisioning/jobs/{id}/execution-grant", async (
|
||||
string id,
|
||||
ProvisioningExecutionGrantRequest request,
|
||||
HttpContext context,
|
||||
ControlStore controlStore,
|
||||
CertificateAuthority authority,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var nodeId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(nodeId)
|
||||
|| !NodeIdValidator.IsValid(nodeId)
|
||||
|| !ProvisioningJobValidator.IsValidId(id)
|
||||
|| !ProvisioningJobValidator.IsValid(request))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["executionGrant"] = ["Invalid job id or idempotency key."]
|
||||
});
|
||||
}
|
||||
try
|
||||
{
|
||||
var grant = await controlStore.IssueBaseInstallExecutionGrantAsync(
|
||||
nodeId,
|
||||
id,
|
||||
request,
|
||||
(job, plan) => authority.SignProvisioningExecutionGrant(
|
||||
job, plan, timeProvider.GetUtcNow(), TimeSpan.FromMinutes(2)),
|
||||
cancellationToken);
|
||||
return grant is null ? Results.NotFound() : Results.Ok(grant);
|
||||
}
|
||||
catch (IdempotencyConflictException)
|
||||
{
|
||||
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
||||
}
|
||||
catch (ProvisioningTransitionException exception)
|
||||
{
|
||||
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
||||
}
|
||||
});
|
||||
|
||||
var control = app.MapGroup("/api/v1/control").RequireAuthorization("Operator");
|
||||
control.MapGet("/agents", async (ControlStore controlStore, CancellationToken cancellationToken) =>
|
||||
|
|
@ -1056,6 +1096,9 @@ internal static class ProvisioningJobValidator
|
|||
&& request.Plan.Warnings is { Length: <= 2 }
|
||||
&& IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
|
||||
|
||||
public static bool IsValid(ProvisioningExecutionGrantRequest request)
|
||||
=> IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
|
||||
|
||||
public static bool IsValidId(string id)
|
||||
=> id.Length == 32 && Guid.TryParseExact(id, "N", out _);
|
||||
|
||||
|
|
|
|||
|
|
@ -132,6 +132,77 @@ public sealed partial class ControlStore
|
|||
jobId, reader.GetString(3), reader.GetInt32(0), plan,
|
||||
DateTimeOffset.Parse(reader.GetString(2)));
|
||||
}
|
||||
|
||||
public async Task<ProvisioningExecutionGrant?> IssueBaseInstallExecutionGrantAsync(
|
||||
string nodeId,
|
||||
string jobId,
|
||||
ProvisioningExecutionGrantRequest request,
|
||||
Func<ProvisioningJob, SystemBaseInstallPlan, ProvisioningExecutionGrant> issueGrant,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await OpenAsync(cancellationToken);
|
||||
await using var transaction =
|
||||
(SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
|
||||
var operationKey = $"provisioning-execution-grant:{nodeId}:{jobId}:{request.IdempotencyKey}";
|
||||
var requestHash = Fingerprint(
|
||||
request, SmmJsonContext.Default.ProvisioningExecutionGrantRequest);
|
||||
var cached = await ReadIdempotentAsync(
|
||||
connection, transaction, operationKey, requestHash,
|
||||
SmmJsonContext.Default.ProvisioningExecutionGrant, cancellationToken);
|
||||
if (cached is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return cached;
|
||||
}
|
||||
|
||||
var job = await ReadProvisioningJobAsync(connection, transaction, jobId, cancellationToken);
|
||||
if (job is null || !string.Equals(job.NodeId, nodeId, StringComparison.Ordinal))
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return null;
|
||||
}
|
||||
if (job.ActionType != "system.base-install"
|
||||
|| job.SchemaVersion != 1
|
||||
|| job.State != ProvisioningJobStates.Queued
|
||||
|| !job.ConfirmationRequired
|
||||
|| job.ConfirmedAt is null
|
||||
|| job.ExpiresAt <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
throw new ProvisioningTransitionException(job.State, "ExecutionGrant");
|
||||
}
|
||||
|
||||
var planCommand = connection.CreateCommand();
|
||||
planCommand.Transaction = transaction;
|
||||
planCommand.CommandText = """
|
||||
SELECT plan_json FROM provisioning_base_install_plans
|
||||
WHERE job_id = $job AND schema_version = 1;
|
||||
""";
|
||||
planCommand.Parameters.AddWithValue("$job", jobId);
|
||||
var planJson = await planCommand.ExecuteScalarAsync(cancellationToken) as string;
|
||||
if (planJson is null)
|
||||
{
|
||||
throw new ProvisioningTransitionException(job.State, "ExecutionGrant");
|
||||
}
|
||||
var plan = JsonSerializer.Deserialize(
|
||||
planJson, SmmJsonContext.Default.SystemBaseInstallPlan)
|
||||
?? throw new InvalidDataException("Stored base installation plan is invalid.");
|
||||
var grant = issueGrant(job, plan);
|
||||
await WriteIdempotentAsync(
|
||||
connection, transaction, operationKey, requestHash, grant,
|
||||
SmmJsonContext.Default.ProvisioningExecutionGrant, cancellationToken);
|
||||
await WriteAuditAsync(
|
||||
connection, transaction, nodeId, "provisioning.execution-grant.issued", jobId,
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
grant.ActionType,
|
||||
grant.SchemaVersion,
|
||||
grant.PlanSha256,
|
||||
grant.ExpiresAtUnixSeconds
|
||||
}),
|
||||
cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return grant;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ProvisioningPlanValidationException()
|
||||
|
|
|
|||
|
|
@ -284,6 +284,23 @@ public sealed record ProvisioningBaseInstallPlanRecord(
|
|||
SystemBaseInstallPlan Plan,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
|
||||
public sealed record ProvisioningExecutionGrant(
|
||||
string ProtocolVersion,
|
||||
string JobId,
|
||||
string NodeId,
|
||||
string ActionType,
|
||||
int SchemaVersion,
|
||||
string PlanSha256,
|
||||
long IssuedAtUnixSeconds,
|
||||
long ExpiresAtUnixSeconds,
|
||||
string Nonce,
|
||||
string SignatureAlgorithm,
|
||||
string Signature);
|
||||
|
||||
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
|
||||
public sealed record ProvisioningExecutionGrantRequest(string IdempotencyKey);
|
||||
|
||||
public static class PreflightDriftStatuses
|
||||
{
|
||||
public const string NotConfigured = "NotConfigured";
|
||||
|
|
|
|||
134
src/ServerMonitorManager.Core/ProvisioningExecutionGrantCodec.cs
Normal file
134
src/ServerMonitorManager.Core/ProvisioningExecutionGrantCodec.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ServerMonitorManager.Core;
|
||||
|
||||
public static class ProvisioningExecutionGrantCodec
|
||||
{
|
||||
public const string ProtocolVersion = "1";
|
||||
public const string SignatureAlgorithm = "ECDSA-P256-SHA256-P1363";
|
||||
public static readonly TimeSpan MaximumLifetime = TimeSpan.FromMinutes(5);
|
||||
|
||||
public static string ComputePlanSha256(SystemBaseInstallPlan plan)
|
||||
{
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(
|
||||
plan, SmmJsonContext.Default.SystemBaseInstallPlan);
|
||||
return Convert.ToHexStringLower(SHA256.HashData(json));
|
||||
}
|
||||
|
||||
public static byte[] CreateSigningPayload(ProvisioningExecutionGrant grant)
|
||||
=> Encoding.UTF8.GetBytes(string.Join('\n',
|
||||
[
|
||||
"SMM-PROVISIONING-GRANT-V1",
|
||||
grant.ProtocolVersion,
|
||||
grant.JobId,
|
||||
grant.NodeId,
|
||||
grant.ActionType,
|
||||
grant.SchemaVersion.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
grant.PlanSha256,
|
||||
grant.IssuedAtUnixSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
grant.ExpiresAtUnixSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
grant.Nonce,
|
||||
grant.SignatureAlgorithm
|
||||
]));
|
||||
|
||||
public static bool Verify(
|
||||
ProvisioningExecutionGrant grant,
|
||||
X509Certificate2 controlAuthority,
|
||||
string expectedJobId,
|
||||
string expectedNodeId,
|
||||
SystemBaseInstallPlan expectedPlan,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
if (grant.ProtocolVersion != ProtocolVersion
|
||||
|| grant.SignatureAlgorithm != SignatureAlgorithm
|
||||
|| grant.JobId != expectedJobId
|
||||
|| grant.NodeId != expectedNodeId
|
||||
|| grant.ActionType != "system.base-install"
|
||||
|| grant.SchemaVersion != 1
|
||||
|| grant.JobId is not { Length: 32 }
|
||||
|| !grant.JobId.All(Uri.IsHexDigit)
|
||||
|| grant.Nonce is not { Length: 32 }
|
||||
|| !grant.Nonce.All(Uri.IsHexDigit)
|
||||
|| grant.PlanSha256 is not { Length: 64 }
|
||||
|| !grant.PlanSha256.All(Uri.IsHexDigit)
|
||||
|| grant.Signature is not { Length: >= 1 and <= 128 })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset issuedAt;
|
||||
DateTimeOffset expiresAt;
|
||||
try
|
||||
{
|
||||
issuedAt = DateTimeOffset.FromUnixTimeSeconds(grant.IssuedAtUnixSeconds);
|
||||
expiresAt = DateTimeOffset.FromUnixTimeSeconds(grant.ExpiresAtUnixSeconds);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (issuedAt > now.AddSeconds(30)
|
||||
|| expiresAt <= now
|
||||
|| expiresAt <= issuedAt
|
||||
|| expiresAt - issuedAt > MaximumLifetime)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedHash = ComputePlanSha256(expectedPlan);
|
||||
if (!CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.ASCII.GetBytes(expectedHash),
|
||||
Encoding.ASCII.GetBytes(grant.PlanSha256.ToLowerInvariant())))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] signature;
|
||||
try
|
||||
{
|
||||
signature = DecodeBase64Url(grant.Signature);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (signature.Length != 64)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var key = controlAuthority.GetECDsaPublicKey();
|
||||
return key is { KeySize: 256 }
|
||||
&& key.VerifyData(
|
||||
CreateSigningPayload(grant), signature, HashAlgorithmName.SHA256,
|
||||
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
}
|
||||
|
||||
public static string EncodeBase64Url(ReadOnlySpan<byte> value)
|
||||
=> Convert.ToBase64String(value)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
|
||||
private static byte[] DecodeBase64Url(string value)
|
||||
{
|
||||
if (value.Length is < 1 or > 128
|
||||
|| value.Any(character => !char.IsAsciiLetterOrDigit(character)
|
||||
&& character is not '-' and not '_'))
|
||||
{
|
||||
throw new FormatException("Invalid base64url value.");
|
||||
}
|
||||
var padded = value.Replace('-', '+').Replace('_', '/');
|
||||
padded += (padded.Length % 4) switch
|
||||
{
|
||||
2 => "==",
|
||||
3 => "=",
|
||||
0 => string.Empty,
|
||||
_ => throw new FormatException("Invalid base64url value.")
|
||||
};
|
||||
return Convert.FromBase64String(padded);
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,8 @@ namespace ServerMonitorManager.Core;
|
|||
[JsonSerializable(typeof(SystemBaseInstallPlan))]
|
||||
[JsonSerializable(typeof(SystemBaseInstallPlanReportRequest))]
|
||||
[JsonSerializable(typeof(ProvisioningBaseInstallPlanRecord))]
|
||||
[JsonSerializable(typeof(ProvisioningExecutionGrant))]
|
||||
[JsonSerializable(typeof(ProvisioningExecutionGrantRequest))]
|
||||
[JsonSerializable(typeof(ProvisioningJob))]
|
||||
[JsonSerializable(typeof(ProvisioningJob[]))]
|
||||
[JsonSerializable(typeof(ProvisioningEvent))]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System.Security.Cryptography;
|
|||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ServerMonitorManager.Control;
|
||||
using ServerMonitorManager.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace ServerMonitorManager.Control.Tests;
|
||||
|
|
@ -49,6 +50,59 @@ public sealed class CertificateAuthorityTests : IDisposable
|
|||
oid => oid.Value == "1.3.6.1.5.5.7.3.2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProvisioningExecutionGrantIsBoundToConfirmedJobPlanAndExpiry()
|
||||
{
|
||||
Directory.CreateDirectory(_directory);
|
||||
var caPath = Path.Combine(_directory, "grant-control-ca.pfx");
|
||||
using var caKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var caRequest = new CertificateRequest("CN=SMM Grant Test CA", caKey, HashAlgorithmName.SHA256);
|
||||
caRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
caRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
|
||||
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign | X509KeyUsageFlags.DigitalSignature,
|
||||
true));
|
||||
using var ca = caRequest.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddMinutes(-1),
|
||||
DateTimeOffset.UtcNow.AddYears(2));
|
||||
File.WriteAllBytes(caPath, ca.Export(X509ContentType.Pfx));
|
||||
using var authority = new CertificateAuthority(Options.Create(new ControlOptions
|
||||
{
|
||||
DatabasePath = Path.Combine(_directory, "unused-grant.db"),
|
||||
CertificateAuthorityPath = caPath
|
||||
}));
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var parameters = System.Text.Json.JsonSerializer.SerializeToElement(
|
||||
new SystemBaseInstallParameters(
|
||||
"UTC", "en_US.UTF-8", true, false, 1, ["core"],
|
||||
"disabled", null, 60, true, "never"),
|
||||
SmmJsonContext.Default.SystemBaseInstallParameters);
|
||||
var job = new ProvisioningJob(
|
||||
Guid.NewGuid().ToString("N"), "home", "system.base-install", 1, parameters,
|
||||
ProvisioningJobStates.Queued, true, "test", "operator",
|
||||
now.AddMinutes(-1), now, now.AddMinutes(30), now, null, 4, 25,
|
||||
"confirmed-queued", null);
|
||||
var plan = new SystemBaseInstallPlan(
|
||||
"UTC", "en_US.UTF-8", true, false,
|
||||
SystemBaseInstallCatalogDefinition.ExpandGroups(["core"]),
|
||||
"disabled", null, 60, true, "never", []);
|
||||
|
||||
var grant = authority.SignProvisioningExecutionGrant(
|
||||
job, plan, now, TimeSpan.FromMinutes(2));
|
||||
|
||||
Assert.True(ProvisioningExecutionGrantCodec.Verify(
|
||||
grant, authority.PublicCertificate, job.Id, job.NodeId, plan, now));
|
||||
Assert.False(ProvisioningExecutionGrantCodec.Verify(
|
||||
grant, authority.PublicCertificate, job.Id, "other", plan, now));
|
||||
Assert.False(ProvisioningExecutionGrantCodec.Verify(
|
||||
grant, authority.PublicCertificate, job.Id, job.NodeId,
|
||||
plan with { Packages = [.. plan.Packages, "untrusted-package"] }, now));
|
||||
Assert.False(ProvisioningExecutionGrantCodec.Verify(
|
||||
grant, authority.PublicCertificate, job.Id, job.NodeId, plan, now.AddMinutes(3)));
|
||||
Assert.Throws<InvalidOperationException>(() => authority.SignProvisioningExecutionGrant(
|
||||
job with { ConfirmedAt = null }, plan, now, TimeSpan.FromMinutes(2)));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directory))
|
||||
|
|
|
|||
|
|
@ -181,6 +181,30 @@ public sealed class ControlApiTests : IAsyncDisposable
|
|||
ServerMonitorManager.Core.ProvisioningJob>(cancellationToken);
|
||||
Assert.Equal(ServerMonitorManager.Core.ProvisioningJobStates.Queued, confirmed!.State);
|
||||
Assert.Equal("confirmed-queued", confirmed.CurrentStep);
|
||||
var grantRequest = new { idempotencyKey = Guid.NewGuid().ToString() };
|
||||
var grantResponse = await agent.PostAsJsonAsync(
|
||||
$"/api/v1/agents/provisioning/jobs/{job.Id}/execution-grant",
|
||||
grantRequest,
|
||||
cancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, grantResponse.StatusCode);
|
||||
var grant = await grantResponse.Content.ReadFromJsonAsync<
|
||||
ServerMonitorManager.Core.ProvisioningExecutionGrant>(cancellationToken);
|
||||
var grantReplayResponse = await agent.PostAsJsonAsync(
|
||||
$"/api/v1/agents/provisioning/jobs/{job.Id}/execution-grant",
|
||||
grantRequest,
|
||||
cancellationToken);
|
||||
var grantReplay = await grantReplayResponse.Content.ReadFromJsonAsync<
|
||||
ServerMonitorManager.Core.ProvisioningExecutionGrant>(cancellationToken);
|
||||
Assert.Equal(grant!.Signature, grantReplay!.Signature);
|
||||
var authority = _factory.Services.GetRequiredService<
|
||||
ServerMonitorManager.Control.CertificateAuthority>();
|
||||
Assert.True(ServerMonitorManager.Core.ProvisioningExecutionGrantCodec.Verify(
|
||||
grant,
|
||||
authority.PublicCertificate,
|
||||
job.Id,
|
||||
"home",
|
||||
storedPlan.Plan,
|
||||
DateTimeOffset.UtcNow));
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await agent.GetAsync(
|
||||
"/api/v1/agents/provisioning/jobs/next", cancellationToken)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync(
|
||||
|
|
|
|||
Loading…
Reference in a new issue