1251 lines
46 KiB
C#
1251 lines
46 KiB
C#
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using System.Text.Json;
|
|
using System.Threading.RateLimiting;
|
|
using Microsoft.AspNetCore.Authentication.Certificate;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.Options;
|
|
using ServerMonitorManager.Control;
|
|
using ServerMonitorManager.Core;
|
|
|
|
const string AutomationSourceClaim = "smm:source_node_id";
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
builder.Services.AddProblemDetails();
|
|
builder.Services.AddHealthChecks();
|
|
builder.Services.AddRateLimiter(options =>
|
|
{
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
options.AddPolicy("enrollment", context => RateLimitPartition.GetFixedWindowLimiter(
|
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
_ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = 10,
|
|
Window = TimeSpan.FromMinutes(1),
|
|
QueueLimit = 0,
|
|
AutoReplenishment = true
|
|
}));
|
|
});
|
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
|
options.SerializerOptions.TypeInfoResolverChain.Insert(0, SmmJsonContext.Default));
|
|
builder.Services.AddOptions<ControlOptions>()
|
|
.Bind(builder.Configuration.GetSection(ControlOptions.SectionName))
|
|
.Validate(options =>
|
|
!string.IsNullOrWhiteSpace(options.DatabasePath)
|
|
&& !string.IsNullOrWhiteSpace(options.CertificateAuthorityPath)
|
|
&& !string.IsNullOrWhiteSpace(options.HubHelperPath)
|
|
&& !string.IsNullOrWhiteSpace(options.PrivilegeEscalationPath)
|
|
&& !string.IsNullOrWhiteSpace(options.BackupDirectory)
|
|
&& options.HeartbeatSeconds is >= 10 and <= 300
|
|
&& options.MaxBufferedMetricAgeHours is >= 1 and <= 168
|
|
&& options.MetricRetentionHours is >= 24 and <= 8760
|
|
&& options.IdempotencyRetentionHours is >= 1 and <= 720
|
|
&& options.AuditRetentionDays is >= 1 and <= 3650
|
|
&& options.LinkRetentionDays is >= 1 and <= 3650
|
|
&& options.MaintenanceIntervalMinutes is >= 1 and <= 1440
|
|
&& options.LinkExpirationPollSeconds is >= 1 and <= 300
|
|
&& options.LinkReconciliationSeconds is >= 30 and <= 3600
|
|
&& options.BackupIntervalHours is >= 1 and <= 720
|
|
&& options.BackupRetentionCount is >= 1 and <= 100
|
|
&& options.ClientCertificateDays is >= 1 and <= 90,
|
|
"Invalid Control paths, heartbeat, retention, maintenance, expiration, reconciliation, or backup settings.")
|
|
.ValidateOnStart();
|
|
builder.Services.AddSingleton(TimeProvider.System);
|
|
builder.Services.AddSingleton<ControlStore>();
|
|
builder.Services.AddSingleton<CertificateAuthority>();
|
|
builder.Services.AddSingleton<ControlEventBroker>();
|
|
builder.Services.AddSingleton<ILinkPolicyApplier, LinkPolicyApplier>();
|
|
builder.Services.AddSingleton<LinkService>();
|
|
builder.Services.AddSingleton<CertificateLifecycleService>();
|
|
builder.Services.AddSingleton<NodeEnrollmentService>();
|
|
builder.Services.AddSingleton<ControlBackupService>();
|
|
builder.Services.AddHostedService<LinkExpirationBackgroundService>();
|
|
builder.Services.AddHostedService<LinkReconciliationBackgroundService>();
|
|
builder.Services.AddHostedService<ControlMaintenanceBackgroundService>();
|
|
builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
|
|
.AddCertificate(options =>
|
|
{
|
|
options.AllowedCertificateTypes = CertificateTypes.All;
|
|
options.RevocationMode = X509RevocationMode.NoCheck;
|
|
options.ValidateCertificateUse = true;
|
|
options.ValidateValidityPeriod = true;
|
|
options.Events = new CertificateAuthenticationEvents
|
|
{
|
|
OnCertificateValidated = async context =>
|
|
{
|
|
var store = context.HttpContext.RequestServices.GetRequiredService<ControlStore>();
|
|
var identity = await store.ResolveIdentityAsync(context.ClientCertificate.Thumbprint);
|
|
if (identity is null)
|
|
{
|
|
context.Fail("The agent certificate is unknown, expired, or revoked.");
|
|
return;
|
|
}
|
|
|
|
var claims = new List<Claim>
|
|
{
|
|
new(ClaimTypes.NameIdentifier, identity.Id),
|
|
new(ClaimTypes.Role, identity.Role)
|
|
};
|
|
if (identity.SourceNodeId is not null)
|
|
{
|
|
claims.Add(new Claim(AutomationSourceClaim, identity.SourceNodeId));
|
|
}
|
|
context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, context.Scheme.Name));
|
|
context.Success();
|
|
}
|
|
};
|
|
});
|
|
builder.Services.AddOptions<CertificateAuthenticationOptions>(
|
|
CertificateAuthenticationDefaults.AuthenticationScheme)
|
|
.Configure<CertificateAuthority>((options, authority) =>
|
|
{
|
|
options.ChainTrustValidationMode = X509ChainTrustMode.CustomRootTrust;
|
|
options.CustomTrustStore.Add(authority.PublicCertificate);
|
|
});
|
|
builder.Services.AddAuthorization(options =>
|
|
{
|
|
options.AddPolicy("Agent", policy => policy.RequireRole("Agent"));
|
|
options.AddPolicy("Operator", policy => policy.RequireRole("Operator"));
|
|
options.AddPolicy("Automation", policy => policy.RequireRole("Automation"));
|
|
});
|
|
builder.WebHost.ConfigureKestrel(options =>
|
|
{
|
|
options.Limits.MaxRequestBodySize = 64 * 1024;
|
|
options.ConfigureHttpsDefaults(https =>
|
|
https.ClientCertificateMode = ClientCertificateMode.AllowCertificate);
|
|
});
|
|
|
|
var app = builder.Build();
|
|
app.UseExceptionHandler();
|
|
app.UseHsts();
|
|
app.UseRateLimiter();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
var store = app.Services.GetRequiredService<ControlStore>();
|
|
var backupService = app.Services.GetRequiredService<ControlBackupService>();
|
|
if (args is ["backup-restore", var backupPath])
|
|
{
|
|
await backupService.RestoreAsync(backupPath);
|
|
Console.WriteLine("Control backup restored. Start the service and verify /healthz.");
|
|
return 0;
|
|
}
|
|
await store.InitializeAsync();
|
|
|
|
if (args is ["backup-create"])
|
|
{
|
|
var createdBackupPath = await backupService.CreateAsync(DateTimeOffset.UtcNow);
|
|
Console.WriteLine(createdBackupPath);
|
|
return 0;
|
|
}
|
|
|
|
if (args is ["token-create", var nodeId])
|
|
{
|
|
if (!NodeIdValidator.IsValid(nodeId))
|
|
{
|
|
Console.Error.WriteLine("Node id must contain 1-63 lowercase letters, digits, or hyphens.");
|
|
return 2;
|
|
}
|
|
|
|
Console.WriteLine(await store.CreateEnrollmentTokenAsync(nodeId, TimeSpan.FromMinutes(10)));
|
|
return 0;
|
|
}
|
|
|
|
if (args is ["device-token-create", var deviceId])
|
|
{
|
|
if (!NodeIdValidator.IsValid(deviceId))
|
|
{
|
|
Console.Error.WriteLine("Device id must contain 1-63 lowercase letters, digits, or hyphens.");
|
|
return 2;
|
|
}
|
|
|
|
Console.WriteLine(await store.CreateDeviceEnrollmentTokenAsync(deviceId, TimeSpan.FromMinutes(10)));
|
|
return 0;
|
|
}
|
|
|
|
if (args is ["automation-token-create", var automationId, var sourceNodeId])
|
|
{
|
|
if (!NodeIdValidator.IsValid(automationId) || !NodeIdValidator.IsValid(sourceNodeId))
|
|
{
|
|
Console.Error.WriteLine("Automation and source Node ids must contain 1-63 lowercase letters, digits, or hyphens.");
|
|
return 2;
|
|
}
|
|
|
|
var response = await store.CreateAutomationTokenAsync(
|
|
new AutomationTokenCreateRequest(
|
|
automationId, sourceNodeId, Guid.NewGuid().ToString()),
|
|
"hub-cli",
|
|
TimeSpan.FromMinutes(10));
|
|
Console.WriteLine(JsonSerializer.Serialize(response, SmmJsonContext.Default.AutomationTokenResponse));
|
|
return 0;
|
|
}
|
|
|
|
app.MapHealthChecks("/healthz").AllowAnonymous();
|
|
|
|
app.MapPost("/api/v1/enroll", async (
|
|
EnrollmentRequest request,
|
|
ControlStore controlStore,
|
|
CertificateAuthority authority,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!NodeIdValidator.IsValid(request.NodeId)
|
|
|| string.IsNullOrWhiteSpace(request.Token)
|
|
|| string.IsNullOrWhiteSpace(request.CertificateSigningRequestPem)
|
|
|| !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["request"] = ["Invalid enrollment request."]
|
|
});
|
|
}
|
|
|
|
EnrollmentResponse? response;
|
|
try
|
|
{
|
|
response = await controlStore.EnrollAsync(
|
|
request,
|
|
() => authority.IssueClientCertificate(request.NodeId, request.CertificateSigningRequestPem),
|
|
cancellationToken);
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails
|
|
{
|
|
Title = "Idempotency key conflict",
|
|
Status = StatusCodes.Status409Conflict
|
|
});
|
|
}
|
|
catch (Exception exception) when (exception is CryptographicException or InvalidOperationException)
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["certificateSigningRequestPem"] = ["Invalid certificate signing request."]
|
|
});
|
|
}
|
|
|
|
return response is null
|
|
? Results.Unauthorized()
|
|
: Results.Ok(response);
|
|
}).AllowAnonymous().RequireRateLimiting("enrollment");
|
|
|
|
app.MapPost("/api/v1/device-enroll", async (
|
|
DeviceEnrollmentRequest request,
|
|
ControlStore controlStore,
|
|
CertificateAuthority authority,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!NodeIdValidator.IsValid(request.DeviceId)
|
|
|| string.IsNullOrWhiteSpace(request.Token)
|
|
|| string.IsNullOrWhiteSpace(request.CertificateSigningRequestPem)
|
|
|| !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["request"] = ["Invalid device enrollment request."]
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var response = await controlStore.EnrollDeviceAsync(
|
|
request,
|
|
() => authority.IssueClientCertificate(request.DeviceId, request.CertificateSigningRequestPem),
|
|
cancellationToken);
|
|
return response is null ? Results.Unauthorized() : Results.Ok(response);
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails
|
|
{
|
|
Title = "Idempotency key conflict",
|
|
Status = StatusCodes.Status409Conflict
|
|
});
|
|
}
|
|
catch (Exception exception) when (exception is CryptographicException or InvalidOperationException)
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["certificateSigningRequestPem"] = ["Invalid certificate signing request."]
|
|
});
|
|
}
|
|
}).AllowAnonymous().RequireRateLimiting("enrollment");
|
|
|
|
app.MapPost("/api/v1/automation-enroll", async (
|
|
AutomationEnrollmentRequest request,
|
|
ControlStore controlStore,
|
|
CertificateAuthority authority,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!NodeIdValidator.IsValid(request.AutomationId)
|
|
|| string.IsNullOrWhiteSpace(request.Token)
|
|
|| string.IsNullOrWhiteSpace(request.CertificateSigningRequestPem)
|
|
|| !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["request"] = ["Invalid automation enrollment request."]
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var response = await controlStore.EnrollAutomationAsync(
|
|
request,
|
|
() => authority.IssueClientCertificate(
|
|
request.AutomationId, request.CertificateSigningRequestPem),
|
|
cancellationToken);
|
|
return response is null ? Results.Unauthorized() : Results.Ok(response);
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails
|
|
{
|
|
Title = "Idempotency key conflict",
|
|
Status = StatusCodes.Status409Conflict
|
|
});
|
|
}
|
|
catch (Exception exception) when (exception is CryptographicException or InvalidOperationException)
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["certificateSigningRequestPem"] = ["Invalid certificate signing request."]
|
|
});
|
|
}
|
|
}).AllowAnonymous().RequireRateLimiting("enrollment");
|
|
|
|
var agents = app.MapGroup("/api/v1/agents").RequireAuthorization("Agent");
|
|
agents.MapPost("/heartbeat", async (
|
|
AgentHeartbeat heartbeat,
|
|
HttpContext context,
|
|
ControlStore controlStore,
|
|
LinkService linkService,
|
|
IOptions<ControlOptions> options,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!NodeIdValidator.IsValid(heartbeat.NodeId)
|
|
|| !IdempotencyKeyValidator.IsValid(heartbeat.IdempotencyKey)
|
|
|| heartbeat.SentAt < DateTimeOffset.UtcNow.AddHours(-options.Value.MaxBufferedMetricAgeHours)
|
|
|| heartbeat.SentAt > DateTimeOffset.UtcNow.AddMinutes(1))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["heartbeat"] = ["Invalid or stale heartbeat."]
|
|
});
|
|
}
|
|
|
|
var certificate = await context.Connection.GetClientCertificateAsync(cancellationToken);
|
|
if (certificate is null)
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
if (!await controlStore.IsCertificateForNodeAsync(
|
|
certificate.Thumbprint,
|
|
heartbeat.NodeId,
|
|
cancellationToken))
|
|
{
|
|
return Results.Forbid();
|
|
}
|
|
|
|
try
|
|
{
|
|
var mutation = await controlStore.RecordHeartbeatAsync(
|
|
heartbeat,
|
|
options.Value.HeartbeatSeconds,
|
|
cancellationToken);
|
|
if (mutation.RequiresReconciliation)
|
|
{
|
|
var reconciliation = await linkService.ReconcileLinksForNodeAsync(
|
|
heartbeat.NodeId, cancellationToken);
|
|
if (reconciliation.Failed == 0)
|
|
{
|
|
await controlStore.CompleteAgentReconciliationAsync(
|
|
heartbeat.NodeId, cancellationToken);
|
|
}
|
|
}
|
|
var broker = context.RequestServices.GetRequiredService<ControlEventBroker>();
|
|
broker.Publish(
|
|
"agent.heartbeat",
|
|
heartbeat.NodeId,
|
|
JsonSerializer.Serialize(heartbeat, SmmJsonContext.Default.AgentHeartbeat));
|
|
return Results.Ok(mutation.Response);
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails
|
|
{
|
|
Title = "Idempotency key conflict",
|
|
Status = StatusCodes.Status409Conflict
|
|
});
|
|
}
|
|
});
|
|
agents.MapPost("/certificate/renew", async (
|
|
CertificateRenewalRequest request,
|
|
HttpContext context,
|
|
CertificateLifecycleService lifecycle,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var nodeId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
if (string.IsNullOrWhiteSpace(nodeId)
|
|
|| !NodeIdValidator.IsValid(nodeId)
|
|
|| !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["request"] = ["Invalid node id or idempotency key."]
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var issued = await lifecycle.RenewAgentCertificateAsync(
|
|
nodeId, request, nodeId, cancellationToken);
|
|
return Results.Ok(new CertificateRenewalResponse(
|
|
nodeId, issued.CertificatePem, issued.CertificateAuthorityPem, issued.ExpiresAt));
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
|
}
|
|
});
|
|
agents.MapGet("/provisioning/jobs/next", async (
|
|
HttpContext context,
|
|
ControlStore controlStore,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var nodeId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
if (string.IsNullOrWhiteSpace(nodeId) || !NodeIdValidator.IsValid(nodeId))
|
|
{
|
|
return Results.Forbid();
|
|
}
|
|
|
|
var job = await controlStore.ClaimNextProvisioningJobAsync(nodeId, cancellationToken);
|
|
return job is null ? Results.NoContent() : Results.Ok(job);
|
|
});
|
|
agents.MapPost("/provisioning/jobs/{id}/progress", async (
|
|
string id,
|
|
ProvisioningJobProgressRequest request,
|
|
HttpContext context,
|
|
ControlStore controlStore,
|
|
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[]>
|
|
{
|
|
["provisioningProgress"] =
|
|
["Invalid job id, state, progress, step, event code, message, or idempotency key."]
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var job = await controlStore.ReportProvisioningProgressAsync(
|
|
nodeId, id, request, cancellationToken);
|
|
return job is null ? Results.NotFound() : Results.Ok(job);
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
|
}
|
|
catch (ProvisioningTransitionException exception)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
|
}
|
|
});
|
|
agents.MapPost("/provisioning/jobs/{id}/preflight-facts", async (
|
|
string id,
|
|
ProvisioningPreflightReportRequest request,
|
|
HttpContext context,
|
|
ControlStore controlStore,
|
|
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[]>
|
|
{
|
|
["preflightFacts"] = ["Invalid job id, facts, observation time, or idempotency key."]
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var facts = await controlStore.RecordPreflightFactsAsync(
|
|
nodeId, id, request, cancellationToken);
|
|
return facts is null ? Results.NotFound() : Results.Ok(facts);
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
|
}
|
|
catch (ProvisioningTransitionException exception)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
|
}
|
|
});
|
|
agents.MapPost("/provisioning/jobs/{id}/base-install-plan", async (
|
|
string id,
|
|
SystemBaseInstallPlanReportRequest request,
|
|
HttpContext context,
|
|
ControlStore controlStore,
|
|
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[]>
|
|
{
|
|
["baseInstallPlan"] = ["Invalid job id, plan, or idempotency key."]
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var plan = await controlStore.RecordBaseInstallPlanAsync(
|
|
nodeId, id, request, cancellationToken);
|
|
return plan is null ? Results.NotFound() : Results.Ok(plan);
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
|
}
|
|
catch (ProvisioningTransitionException exception)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
|
}
|
|
catch (ProvisioningPlanValidationException exception)
|
|
{
|
|
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.MapPost("/certificates/renew", async (
|
|
CertificateRenewalRequest request,
|
|
HttpContext context,
|
|
CertificateLifecycleService lifecycle,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var deviceId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
if (string.IsNullOrWhiteSpace(deviceId)
|
|
|| !NodeIdValidator.IsValid(deviceId)
|
|
|| !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["request"] = ["Invalid device id or idempotency key."]
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var issued = await lifecycle.RenewDeviceCertificateAsync(
|
|
deviceId, request, deviceId, cancellationToken);
|
|
return Results.Ok(new CertificateRenewalResponse(
|
|
deviceId, issued.CertificatePem, issued.CertificateAuthorityPem, issued.ExpiresAt));
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
|
}
|
|
});
|
|
control.MapPost("/agents/{nodeId}/enrollment-code", async (
|
|
string nodeId,
|
|
HttpContext context,
|
|
NodeEnrollmentService enrollmentService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!NodeIdValidator.IsValid(nodeId))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["nodeId"] = ["Node id must contain 1-63 lowercase letters, digits, or hyphens."]
|
|
});
|
|
}
|
|
|
|
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown";
|
|
try
|
|
{
|
|
var response = await enrollmentService.CreateEnrollmentCodeAsync(
|
|
nodeId, actor, cancellationToken);
|
|
return Results.Ok(response);
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
|
}
|
|
}).RequireRateLimiting("enrollment");
|
|
control.MapPost("/nodes/{nodeId}/enrollment-code", async (
|
|
string nodeId,
|
|
HttpContext context,
|
|
NodeEnrollmentService enrollmentService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!NodeIdValidator.IsValid(nodeId))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["nodeId"] = ["Node id must contain 1-63 lowercase letters, digits, or hyphens."]
|
|
});
|
|
}
|
|
|
|
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown";
|
|
try
|
|
{
|
|
var response = await enrollmentService.CreateEnrollmentCodeAsync(
|
|
nodeId, actor, cancellationToken);
|
|
return Results.Ok(response);
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
|
}
|
|
}).RequireRateLimiting("enrollment");
|
|
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,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!NodeIdValidator.IsValid(nodeId))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["nodeId"] = ["Invalid node id."]
|
|
});
|
|
}
|
|
var facts = await controlStore.GetPreflightFactsAsync(nodeId, cancellationToken);
|
|
return facts is null ? Results.NotFound() : Results.Ok(facts);
|
|
});
|
|
control.MapPut("/agents/{nodeId}/desired/preflight", async (
|
|
string nodeId,
|
|
PreflightDesiredStateUpdateRequest request,
|
|
HttpContext context,
|
|
ControlStore controlStore,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!NodeIdValidator.IsValid(nodeId) || !PreflightDesiredStateValidator.IsValid(request))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["preflightDesiredState"] =
|
|
["Invalid schema, requirements, architectures, audit reason, or idempotency key."]
|
|
});
|
|
}
|
|
try
|
|
{
|
|
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
|
|
var desired = await controlStore.SetPreflightDesiredStateAsync(
|
|
nodeId, request, actor, cancellationToken);
|
|
return Results.Ok(desired);
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
|
}
|
|
catch (ProvisioningNodeNotFoundException)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
});
|
|
control.MapGet("/agents/{nodeId}/drift/preflight", async (
|
|
string nodeId,
|
|
ControlStore controlStore,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!NodeIdValidator.IsValid(nodeId))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["nodeId"] = ["Invalid node id."]
|
|
});
|
|
}
|
|
var assessment = await controlStore.AssessPreflightDriftAsync(nodeId, cancellationToken);
|
|
return assessment is null ? Results.NotFound() : Results.Ok(assessment);
|
|
});
|
|
control.MapPost("/agents/{nodeId}/provisioning/jobs", async (
|
|
string nodeId,
|
|
ProvisioningJobCreateRequest request,
|
|
HttpContext context,
|
|
ControlStore controlStore,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!NodeIdValidator.IsValid(nodeId) || !ProvisioningJobValidator.IsValid(request))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["provisioningJob"] =
|
|
["Invalid node id, action schema, parameters, TTL, audit reason, or idempotency key."]
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
|
|
var job = await controlStore.CreateProvisioningJobAsync(
|
|
nodeId, request, actor, cancellationToken);
|
|
return Results.Created($"/api/v1/control/provisioning/jobs/{job.Id}", job);
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
|
}
|
|
catch (ProvisioningNodeNotFoundException)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
catch (SqliteException exception) when (exception.SqliteErrorCode == 19)
|
|
{
|
|
return Results.Conflict(new ProblemDetails
|
|
{
|
|
Title = "The node already has an incompatible active provisioning job."
|
|
});
|
|
}
|
|
});
|
|
control.MapGet("/provisioning/jobs/{id}", async (
|
|
string id,
|
|
ControlStore controlStore,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!ProvisioningJobValidator.IsValidId(id))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["provisioningJob"] = ["Invalid provisioning job id."]
|
|
});
|
|
}
|
|
var job = await controlStore.GetProvisioningJobAsync(id, cancellationToken);
|
|
return job is null ? Results.NotFound() : Results.Ok(job);
|
|
});
|
|
control.MapGet("/provisioning/jobs/{id}/plan", async (
|
|
string id,
|
|
ControlStore controlStore,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!ProvisioningJobValidator.IsValidId(id))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["baseInstallPlan"] = ["Invalid provisioning job id."]
|
|
});
|
|
}
|
|
var plan = await controlStore.GetBaseInstallPlanAsync(id, cancellationToken);
|
|
return plan is null ? Results.NotFound() : Results.Ok(plan);
|
|
});
|
|
control.MapGet("/provisioning/jobs/{id}/events", async (
|
|
string id,
|
|
int? limit,
|
|
ControlStore controlStore,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!ProvisioningJobValidator.IsValidId(id) || limit is < 1 or > 200)
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["provisioningEvents"] = ["Invalid provisioning job id or limit (1-200)."]
|
|
});
|
|
}
|
|
var events = await controlStore.ListProvisioningEventsAsync(
|
|
id, limit ?? 100, cancellationToken);
|
|
return events is null ? Results.NotFound() : Results.Ok(events.ToArray());
|
|
});
|
|
control.MapPost("/provisioning/jobs/{id}/confirm", async (
|
|
string id,
|
|
ProvisioningJobCommandRequest request,
|
|
HttpContext context,
|
|
ControlStore controlStore,
|
|
CancellationToken cancellationToken) =>
|
|
await ChangeProvisioningJobAsync(
|
|
id, request, context, controlStore, confirm: true, cancellationToken));
|
|
control.MapPost("/provisioning/jobs/{id}/cancel", async (
|
|
string id,
|
|
ProvisioningJobCommandRequest request,
|
|
HttpContext context,
|
|
ControlStore controlStore,
|
|
CancellationToken cancellationToken) =>
|
|
await ChangeProvisioningJobAsync(
|
|
id, request, context, controlStore, confirm: false, cancellationToken));
|
|
control.MapPost("/provisioning/jobs/{id}/retry", async (
|
|
string id,
|
|
ProvisioningJobCommandRequest request,
|
|
HttpContext context,
|
|
ControlStore controlStore,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!ProvisioningJobValidator.IsValidId(id)
|
|
|| !ProvisioningJobValidator.IsValid(request))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["provisioningJob"] = ["Invalid job id, reason, or idempotency key."]
|
|
});
|
|
}
|
|
try
|
|
{
|
|
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
|
|
var job = await controlStore.RetryProvisioningJobAsync(
|
|
id, request, actor, cancellationToken);
|
|
return job is null ? Results.NotFound() : Results.Ok(job);
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
|
}
|
|
catch (ProvisioningTransitionException exception)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
|
}
|
|
});
|
|
control.MapPost("/provisioning/jobs/{id}/rollback", async (
|
|
string id,
|
|
ProvisioningJobCommandRequest request,
|
|
HttpContext context,
|
|
ControlStore controlStore,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!ProvisioningJobValidator.IsValidId(id)
|
|
|| !ProvisioningJobValidator.IsValid(request))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["provisioningJob"] = ["Invalid job id, reason, or idempotency key."]
|
|
});
|
|
}
|
|
try
|
|
{
|
|
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
|
|
var job = await controlStore.StartProvisioningRollbackAsync(
|
|
id, request, actor, cancellationToken);
|
|
return job is null ? Results.NotFound() : Results.Ok(job);
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
|
}
|
|
catch (ProvisioningTransitionException exception)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
|
}
|
|
});
|
|
control.MapPost("/automations/token", async (
|
|
AutomationTokenCreateRequest request,
|
|
HttpContext context,
|
|
ControlStore controlStore,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!NodeIdValidator.IsValid(request.AutomationId)
|
|
|| !NodeIdValidator.IsValid(request.SourceNodeId)
|
|
|| !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["automation"] = ["Invalid automation id, source Node id, or idempotency key."]
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
|
|
return Results.Ok(await controlStore.CreateAutomationTokenAsync(
|
|
request, actor, TimeSpan.FromMinutes(10), cancellationToken));
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails { Title = exception.Message });
|
|
}
|
|
});
|
|
control.MapPost("/agents/{nodeId}/reenroll", async (
|
|
string nodeId,
|
|
CertificateReenrollmentRequest request,
|
|
HttpContext context,
|
|
CertificateLifecycleService lifecycle,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!NodeIdValidator.IsValid(nodeId) || !CertificateReenrollmentValidator.IsValid(request))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["certificate"] = ["Invalid node id, reason, or idempotency key."]
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
|
|
var ticket = await lifecycle.ReenrollAgentAsync(nodeId, request, actor, cancellationToken);
|
|
return ticket is null ? Results.NotFound() : Results.Ok(ticket);
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
|
}
|
|
});
|
|
control.MapPost("/devices/{deviceId}/reenroll", async (
|
|
string deviceId,
|
|
CertificateReenrollmentRequest request,
|
|
HttpContext context,
|
|
CertificateLifecycleService lifecycle,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!NodeIdValidator.IsValid(deviceId) || !CertificateReenrollmentValidator.IsValid(request))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["certificate"] = ["Invalid device id, reason, or idempotency key."]
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
|
|
if (string.Equals(actor, deviceId, StringComparison.Ordinal))
|
|
{
|
|
return Results.BadRequest(new ProblemDetails
|
|
{
|
|
Title = "An Operator cannot revoke its own certificate. Use another Operator or the local Hub CLI."
|
|
});
|
|
}
|
|
var ticket = await lifecycle.ReenrollDeviceAsync(deviceId, request, actor, cancellationToken);
|
|
return ticket is null ? Results.NotFound() : Results.Ok(ticket);
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
|
}
|
|
});
|
|
control.MapGet("/links", async (ControlStore controlStore, CancellationToken cancellationToken) =>
|
|
Results.Ok((await controlStore.ListLinksAsync(cancellationToken)).ToArray()));
|
|
control.MapPost("/links", async (
|
|
LinkPolicyCreateRequest request,
|
|
HttpContext context,
|
|
LinkService linkService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (!LinkPolicyValidator.IsValid(request))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["link"] = ["Invalid source, target, protocol, port, TTL, reason, or idempotency key."]
|
|
});
|
|
}
|
|
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
|
|
try
|
|
{
|
|
var link = await linkService.CreateAsync(request, actor, cancellationToken);
|
|
return Results.Created($"/api/v1/control/links/{link.Id}", link);
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
|
}
|
|
catch (SqliteException exception) when (exception.SqliteErrorCode == 19)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = "An active Link already exists." });
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
return Results.BadRequest(new ProblemDetails { Title = exception.Message });
|
|
}
|
|
});
|
|
control.MapPost("/links/{id}/disable", async (
|
|
string id,
|
|
LinkPolicyDisableRequest request,
|
|
HttpContext context,
|
|
LinkService linkService,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
if (id.Length != 32 || !Guid.TryParseExact(id, "N", out _)
|
|
|| !IdempotencyKeyValidator.IsValid(request.IdempotencyKey))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["link"] = ["Invalid Link id or idempotency key."]
|
|
});
|
|
}
|
|
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
|
|
try
|
|
{
|
|
var link = await linkService.DisableAsync(id, request, actor, cancellationToken);
|
|
return link is null ? Results.NotFound() : Results.Ok(link);
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
|
}
|
|
});
|
|
control.MapGet("/events", async (HttpContext context, ControlEventBroker broker) =>
|
|
{
|
|
context.Response.ContentType = "application/x-ndjson";
|
|
context.Response.Headers.CacheControl = "no-store";
|
|
using var subscription = broker.Subscribe();
|
|
await foreach (var controlEvent in subscription.Reader.ReadAllAsync(context.RequestAborted))
|
|
{
|
|
await JsonSerializer.SerializeAsync(
|
|
context.Response.Body,
|
|
controlEvent,
|
|
SmmJsonContext.Default.ControlEvent,
|
|
context.RequestAborted);
|
|
await context.Response.WriteAsync("\n", context.RequestAborted);
|
|
await context.Response.Body.FlushAsync(context.RequestAborted);
|
|
}
|
|
});
|
|
|
|
var automation = app.MapGroup("/api/v1/automation").RequireAuthorization("Automation");
|
|
automation.MapGet("/links", async (
|
|
HttpContext context,
|
|
ControlStore controlStore,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
var sourceNodeId = context.User.FindFirstValue(AutomationSourceClaim);
|
|
if (string.IsNullOrWhiteSpace(sourceNodeId))
|
|
{
|
|
return Results.Forbid();
|
|
}
|
|
|
|
var grants = (await controlStore.ListEffectiveLinksForNodeAsync(sourceNodeId, cancellationToken))
|
|
.Where(link => string.Equals(link.SourceNodeId, sourceNodeId, StringComparison.Ordinal))
|
|
.Select(link => new AutomationLinkGrant(
|
|
link.TargetNodeId,
|
|
link.Protocol,
|
|
link.Port,
|
|
link.DesiredState,
|
|
link.ActualState,
|
|
link.Version,
|
|
link.ExpiresAt))
|
|
.ToArray();
|
|
return Results.Ok(grants);
|
|
});
|
|
|
|
await app.RunAsync();
|
|
return 0;
|
|
|
|
static async Task<IResult> ChangeProvisioningJobAsync(
|
|
string id,
|
|
ProvisioningJobCommandRequest request,
|
|
HttpContext context,
|
|
ControlStore controlStore,
|
|
bool confirm,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!ProvisioningJobValidator.IsValidId(id)
|
|
|| !ProvisioningJobValidator.IsValid(request))
|
|
{
|
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
|
{
|
|
["provisioningJob"] = ["Invalid job id, reason, or idempotency key."]
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var actor = context.User.FindFirstValue(ClaimTypes.NameIdentifier)!;
|
|
var job = confirm
|
|
? await controlStore.ConfirmProvisioningJobAsync(id, request, actor, cancellationToken)
|
|
: await controlStore.CancelProvisioningJobAsync(id, request, actor, cancellationToken);
|
|
return job is null ? Results.NotFound() : Results.Ok(job);
|
|
}
|
|
catch (IdempotencyConflictException)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = "Idempotency key conflict" });
|
|
}
|
|
catch (ProvisioningTransitionException exception)
|
|
{
|
|
return Results.Conflict(new ProblemDetails { Title = exception.Message });
|
|
}
|
|
}
|
|
|
|
public partial class Program;
|
|
|
|
internal static class NodeIdValidator
|
|
{
|
|
public static bool IsValid(string value)
|
|
=> value.Length is >= 1 and <= 63
|
|
&& value.All(character => character is >= 'a' and <= 'z' or >= '0' and <= '9' or '-');
|
|
}
|
|
|
|
internal static class IdempotencyKeyValidator
|
|
{
|
|
public static bool IsValid(string value)
|
|
=> Guid.TryParse(value, out _);
|
|
}
|
|
|
|
internal static class LinkPolicyValidator
|
|
{
|
|
public static bool IsValid(LinkPolicyCreateRequest request)
|
|
=> NodeIdValidator.IsValid(request.SourceNodeId)
|
|
&& NodeIdValidator.IsValid(request.TargetNodeId)
|
|
&& request.SourceNodeId != request.TargetNodeId
|
|
&& request.Protocol is "tcp" or "udp"
|
|
&& request.Port is >= 1 and <= 65535
|
|
&& request.TtlMinutes is >= 0 and <= 525600
|
|
&& request.Reason.Length <= 256
|
|
&& IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
|
|
}
|
|
|
|
internal static class CertificateReenrollmentValidator
|
|
{
|
|
public static bool IsValid(CertificateReenrollmentRequest request)
|
|
=> request.Reason.Length is >= 1 and <= 200
|
|
&& IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
|
|
}
|
|
|
|
internal static class ProvisioningJobValidator
|
|
{
|
|
private const int MaximumParametersBytes = 16 * 1024;
|
|
|
|
public static bool IsValid(ProvisioningJobCreateRequest request)
|
|
{
|
|
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;
|
|
}
|
|
return SystemBaseInstallSchema.TryParse(request.Parameters, out _);
|
|
}
|
|
|
|
public static bool IsValid(ProvisioningJobCommandRequest request)
|
|
=> request.Reason.Length is >= 1 and <= 256
|
|
&& IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
|
|
|
|
public static bool IsValid(ProvisioningJobProgressRequest request)
|
|
=> request.State is ProvisioningJobStates.Preflight
|
|
or ProvisioningJobStates.Running
|
|
or ProvisioningJobStates.Verifying
|
|
or ProvisioningJobStates.Completed
|
|
or ProvisioningJobStates.Failed
|
|
or ProvisioningJobStates.NeedsReconciliation
|
|
or ProvisioningJobStates.RollingBack
|
|
or ProvisioningJobStates.RolledBack
|
|
or ProvisioningJobStates.RollbackFailed
|
|
&& request.ProgressPercent is >= 0 and <= 100
|
|
&& IsSafeCode(request.Step, 64)
|
|
&& IsSafeCode(request.EventCode, 64)
|
|
&& request.Message.Length <= 512
|
|
&& IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
|
|
|
|
public static bool IsValid(ProvisioningPreflightReportRequest request)
|
|
=> request.ObservedAt >= DateTimeOffset.UtcNow.AddHours(-1)
|
|
&& request.ObservedAt <= DateTimeOffset.UtcNow.AddMinutes(1)
|
|
&& request.Facts is not null
|
|
&& IsSafeFact(request.Facts.OperatingSystem, 32)
|
|
&& IsSafeFact(request.Facts.OperatingSystemVersion, 64)
|
|
&& request.Facts.Architecture is "x64" or "x86" or "arm" or "arm64"
|
|
&& IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
|
|
|
|
public static bool IsValid(SystemBaseInstallPlanReportRequest request)
|
|
=> request.Plan is not null
|
|
&& request.Plan.Packages is { Length: <= 64 }
|
|
&& 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 _);
|
|
|
|
public static bool RequiresConfirmation(string actionType)
|
|
=> actionType == "system.base-install";
|
|
|
|
private static bool IsSafeCode(string value, int maximumLength)
|
|
=> value.Length is >= 1 && value.Length <= maximumLength
|
|
&& value.All(character => character is >= 'a' and <= 'z'
|
|
or >= '0' and <= '9'
|
|
or '.' or '-' or '_');
|
|
|
|
private static bool IsSafeFact(string? value, int maximumLength)
|
|
=> value is not null
|
|
&& value.Length is >= 1 && value.Length <= maximumLength
|
|
&& value.All(character => char.IsAsciiLetterOrDigit(character)
|
|
|| character is '_' or '-' or '.');
|
|
|
|
}
|
|
|
|
internal static class PreflightDesiredStateValidator
|
|
{
|
|
private static readonly HashSet<string> SupportedArchitectures =
|
|
new(["x64", "x86", "arm", "arm64"], StringComparer.Ordinal);
|
|
|
|
public static bool IsValid(PreflightDesiredStateUpdateRequest request)
|
|
=> request.SchemaVersion == 1
|
|
&& request.Desired is not null
|
|
&& request.Desired.AllowedArchitectures is { Length: >= 1 and <= 4 }
|
|
&& request.Desired.AllowedArchitectures.Distinct(StringComparer.Ordinal).Count()
|
|
== request.Desired.AllowedArchitectures.Length
|
|
&& request.Desired.AllowedArchitectures.All(SupportedArchitectures.Contains)
|
|
&& request.AuditReason is { Length: >= 1 and <= 256 }
|
|
&& IdempotencyKeyValidator.IsValid(request.IdempotencyKey);
|
|
}
|