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; 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 })); options.AddPolicy("password-login", context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true })); }); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Insert(0, SmmJsonContext.Default)); builder.Services.AddOptions() .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.AddOptions() .Bind(builder.Configuration.GetSection(PasswordLoginOptions.SectionName)); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddAuthentication(options => { options.DefaultScheme = "Combined"; options.DefaultAuthenticateScheme = "Combined"; options.DefaultChallengeScheme = CertificateAuthenticationDefaults.AuthenticationScheme; }) .AddPolicyScheme("Combined", "mTLS or Testing Password Session", options => { options.ForwardDefaultSelector = context => { if (context.Request.Headers.ContainsKey("X-Test-Role")) { return "TestCert"; } if (context.Connection.ClientCertificate is not null) { return CertificateAuthenticationDefaults.AuthenticationScheme; } if (context.Request.Headers.ContainsKey("Authorization") || context.Request.Cookies.ContainsKey(PasswordSessionAuthenticationHandler.SessionCookieName)) { return PasswordSessionAuthenticationHandler.SchemeName; } return CertificateAuthenticationDefaults.AuthenticationScheme; }; }) .AddCertificate(options => { options.AllowedCertificateTypes = CertificateTypes.All; options.RevocationMode = X509RevocationMode.NoCheck; options.ValidateCertificateUse = true; options.ValidateValidityPeriod = true; options.Events = new CertificateAuthenticationEvents { OnChallenge = context => { context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.HandleResponse(); return Task.CompletedTask; }, OnCertificateValidated = async context => { var store = context.HttpContext.RequestServices.GetRequiredService(); 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 { 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(); } }; }) .AddScheme( PasswordSessionAuthenticationHandler.SchemeName, _ => { }); builder.Services.AddOptions( CertificateAuthenticationDefaults.AuthenticationScheme) .Configure((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 passwordOptions = builder.Configuration .GetSection(PasswordLoginOptions.SectionName) .Get() ?? new PasswordLoginOptions(); if (passwordOptions.EnabledForTesting) { app.Logger.LogWarning( "SECURITY WARNING: Password login is enabled for testing purposes (Authentication:PasswordLogin:EnabledForTesting=true). Do not enable in production environments!"); } var store = app.Services.GetRequiredService(); var backupService = app.Services.GetRequiredService(); 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 { ["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 { ["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 { ["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 { ["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 { ["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 { ["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 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 { ["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(); 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 { ["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 { ["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 { ["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 { ["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 { ["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 auth = app.MapGroup("/api/v1/auth"); auth.MapGet("/status", (IOptionsMonitor options) => Results.Ok(new PasswordLoginStatusResponse(options.CurrentValue.EnabledForTesting))); auth.MapPost("/login", async ( PasswordLoginRequest request, HttpContext context, PasswordSessionService sessionService, IOptionsMonitor options, CancellationToken cancellationToken) => { if (!options.CurrentValue.EnabledForTesting) { return Results.NotFound(new ProblemDetails { Title = "Password login is disabled for testing.", Status = StatusCodes.Status404NotFound }); } if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) { PasswordHasher.PerformDummyVerification(request.Password); await Task.Delay(500, cancellationToken); return Results.Unauthorized(); } var session = sessionService.AuthenticateAndCreateSession(request.Username, request.Password); if (session is null) { await Task.Delay(500, cancellationToken); return Results.Unauthorized(); } context.Response.Cookies.Append( PasswordSessionAuthenticationHandler.SessionCookieName, session.Token, new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict, Expires = session.ExpiresAt, Path = "/" }); return Results.Ok(session); }).RequireRateLimiting("password-login"); auth.MapPost("/logout", ( HttpContext context, PasswordSessionService sessionService) => { string? token = null; if (context.Request.Headers.TryGetValue("Authorization", out var authHeader) && !string.IsNullOrWhiteSpace(authHeader)) { var headerStr = authHeader.ToString(); if (headerStr.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { token = headerStr["Bearer ".Length..].Trim(); } } if (string.IsNullOrWhiteSpace(token) && context.Request.Cookies.TryGetValue(PasswordSessionAuthenticationHandler.SessionCookieName, out var cookieVal)) { token = cookieVal; } sessionService.RevokeSession(token); context.Response.Cookies.Delete( PasswordSessionAuthenticationHandler.SessionCookieName, new CookieOptions { Path = "/", Secure = true, SameSite = SameSiteMode.Strict }); return Results.NoContent(); }); var console = app.MapGroup("/"); if (!passwordOptions.EnabledForTesting) { console.RequireAuthorization("Operator"); } console.MapGet("/", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "index.html", "text/html; charset=utf-8")); console.MapGet("/index.html", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "index.html", "text/html; charset=utf-8")); console.MapGet("/style.css", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "style.css", "text/css; charset=utf-8")); console.MapGet("/app.js", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "app.js", "application/javascript; charset=utf-8")); console.MapGet("/console", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "index.html", "text/html; charset=utf-8")); 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 { ["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 { ["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 { ["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 { ["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 { ["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 { ["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 { ["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 { ["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 { ["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 { ["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 { ["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 { ["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 { ["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 { ["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 { ["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 { ["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 IResult GetWebConsoleAsset(IWebHostEnvironment env, string fileName, string contentType) { var webRoot = env.WebRootPath; if (string.IsNullOrWhiteSpace(webRoot)) { webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot"); } var filePath = Path.Combine(webRoot, fileName); if (File.Exists(filePath)) { return Results.File(filePath, contentType); } var assembly = typeof(Program).Assembly; var resourceName = assembly.GetManifestResourceNames() .FirstOrDefault(name => name.EndsWith(fileName, StringComparison.OrdinalIgnoreCase)); if (resourceName is not null) { var stream = assembly.GetManifestResourceStream(resourceName); if (stream is not null) { return Results.Stream(stream, contentType); } } return Results.NotFound(); } static async Task 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 { ["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 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); }