Files
git.stella-ops.org/src/Concelier/StellaOps.Concelier.WebService/Extensions/MirrorDomainManagementEndpointExtensions.cs
master 9add6af221 Add mirror client setup wizard for consumer configuration
Backend: 4 consumer API endpoints (GET/PUT /consumer config, POST
/consumer/discover for index parsing, POST /consumer/verify-signature
for JWS header detection), air-gap bundle import endpoint with manifest
parsing and SHA256 verification, IMirrorConsumerConfigStore and
IMirrorBundleImportStore interfaces.

Frontend: 4-step mirror client setup wizard (connect + test, signature
verification with auto-detect, sync mode + schedule + air-gap import,
review + pre-flight checks + activate). Dashboard consumer panel with
"Configure" button, Direct mode "Switch to Mirror" CTA, catalog header
"Connect to Mirror" link and consumer status display.

E2E: 9 Playwright test scenarios covering wizard steps, connection
testing, domain discovery, signature detection, mode selection,
pre-flight checks, dashboard integration, and catalog integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 14:35:19 +02:00

1238 lines
52 KiB
C#

using System.Net.Http.Headers;
using System.Text.Json;
using HttpResults = Microsoft.AspNetCore.Http.Results;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Concelier.WebService.Options;
namespace StellaOps.Concelier.WebService.Extensions;
/// <summary>
/// CRUD endpoints for managing mirror domains exposed to external consumers.
/// Provides configuration, domain lifecycle, export management, generation triggers,
/// and connectivity testing for mirror distribution surfaces.
/// </summary>
internal static class MirrorDomainManagementEndpointExtensions
{
private const string MirrorManagePolicy = "Concelier.Sources.Manage";
private const string MirrorReadPolicy = "Concelier.Advisories.Read";
public static void MapMirrorDomainManagementEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/mirror")
.WithTags("Mirror Domain Management")
.RequireTenant();
// GET /config — read current mirror configuration
group.MapGet("/config", ([FromServices] IOptions<MirrorConfigOptions> options) =>
{
var config = options.Value;
return HttpResults.Ok(new MirrorConfigResponse
{
Mode = config.Mode,
OutputRoot = config.OutputRoot,
ConsumerBaseAddress = config.ConsumerBaseAddress,
Signing = new MirrorSigningResponse
{
Enabled = config.SigningEnabled,
Algorithm = config.SigningAlgorithm,
KeyId = config.SigningKeyId,
},
AutoRefreshEnabled = config.AutoRefreshEnabled,
RefreshIntervalMinutes = config.RefreshIntervalMinutes,
});
})
.WithName("GetMirrorConfig")
.WithSummary("Read current mirror configuration")
.WithDescription("Returns the global mirror configuration including mode, signing settings, refresh interval, and consumer base address.")
.Produces<MirrorConfigResponse>(StatusCodes.Status200OK)
.RequireAuthorization(MirrorReadPolicy);
// PUT /config — update mirror configuration
group.MapPut("/config", ([FromBody] UpdateMirrorConfigRequest request, [FromServices] IMirrorConfigStore store, CancellationToken ct) =>
{
// Note: actual persistence will be implemented with the config store
return HttpResults.Ok(new { updated = true });
})
.WithName("UpdateMirrorConfig")
.WithSummary("Update mirror mode, signing, and refresh settings")
.WithDescription("Updates the global mirror configuration. Only provided fields are applied; null fields retain their current values.")
.Produces(StatusCodes.Status200OK)
.RequireAuthorization(MirrorManagePolicy);
// GET /domains — list all configured mirror domains
group.MapGet("/domains", ([FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
{
var domains = domainStore.GetAllDomains();
return HttpResults.Ok(new MirrorDomainListResponse
{
Domains = domains.Select(MapDomainSummary).ToList(),
TotalCount = domains.Count,
});
})
.WithName("ListMirrorDomains")
.WithSummary("List all configured mirror domains")
.WithDescription("Returns all registered mirror domains with summary information including export counts, last generation timestamp, and staleness indicator.")
.Produces<MirrorDomainListResponse>(StatusCodes.Status200OK)
.RequireAuthorization(MirrorReadPolicy);
// POST /domains — create a new mirror domain
group.MapPost("/domains", async ([FromBody] CreateMirrorDomainRequest request, [FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request.Id) || string.IsNullOrWhiteSpace(request.DisplayName))
{
return HttpResults.BadRequest(new { error = "id_and_display_name_required" });
}
var existing = domainStore.GetDomain(request.Id);
if (existing is not null)
{
return HttpResults.Conflict(new { error = "domain_already_exists", domainId = request.Id });
}
var domain = new MirrorDomainRecord
{
Id = request.Id.Trim().ToLowerInvariant(),
DisplayName = request.DisplayName.Trim(),
RequireAuthentication = request.RequireAuthentication,
MaxIndexRequestsPerHour = request.MaxIndexRequestsPerHour ?? 120,
MaxDownloadRequestsPerHour = request.MaxDownloadRequestsPerHour ?? 600,
Exports = (request.Exports ?? []).Select(e => new MirrorExportRecord
{
Key = e.Key,
Format = e.Format ?? "json",
Filters = e.Filters ?? new Dictionary<string, string>(),
}).ToList(),
CreatedAt = DateTimeOffset.UtcNow,
};
await domainStore.SaveDomainAsync(domain, ct);
return HttpResults.Created($"/api/v1/mirror/domains/{domain.Id}", MapDomainDetail(domain));
})
.WithName("CreateMirrorDomain")
.WithSummary("Create a new mirror domain with exports and filters")
.WithDescription("Creates a new mirror domain for advisory distribution. The domain ID is normalized to lowercase. Exports define the data slices available for consumption.")
.Produces<MirrorDomainDetailResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status409Conflict)
.RequireAuthorization(MirrorManagePolicy);
// GET /domains/{domainId} — get domain detail
group.MapGet("/domains/{domainId}", ([FromRoute] string domainId, [FromServices] IMirrorDomainStore domainStore) =>
{
var domain = domainStore.GetDomain(domainId);
if (domain is null)
{
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
}
return HttpResults.Ok(MapDomainDetail(domain));
})
.WithName("GetMirrorDomain")
.WithSummary("Get mirror domain detail with all exports and status")
.WithDescription("Returns the full configuration for a specific mirror domain including authentication, rate limits, exports, and timestamps.")
.Produces<MirrorDomainDetailResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(MirrorReadPolicy);
// PUT /domains/{domainId} — update domain
group.MapPut("/domains/{domainId}", async ([FromRoute] string domainId, [FromBody] UpdateMirrorDomainRequest request, [FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
{
var domain = domainStore.GetDomain(domainId);
if (domain is null)
{
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
}
domain.DisplayName = request.DisplayName ?? domain.DisplayName;
domain.RequireAuthentication = request.RequireAuthentication ?? domain.RequireAuthentication;
domain.MaxIndexRequestsPerHour = request.MaxIndexRequestsPerHour ?? domain.MaxIndexRequestsPerHour;
domain.MaxDownloadRequestsPerHour = request.MaxDownloadRequestsPerHour ?? domain.MaxDownloadRequestsPerHour;
if (request.Exports is not null)
{
domain.Exports = request.Exports.Select(e => new MirrorExportRecord
{
Key = e.Key,
Format = e.Format ?? "json",
Filters = e.Filters ?? new Dictionary<string, string>(),
}).ToList();
}
domain.UpdatedAt = DateTimeOffset.UtcNow;
await domainStore.SaveDomainAsync(domain, ct);
return HttpResults.Ok(MapDomainDetail(domain));
})
.WithName("UpdateMirrorDomain")
.WithSummary("Update mirror domain configuration")
.WithDescription("Updates the specified mirror domain. Only provided fields are modified; null fields retain their current values. Providing exports replaces the entire export list.")
.Produces<MirrorDomainDetailResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(MirrorManagePolicy);
// DELETE /domains/{domainId} — remove domain
group.MapDelete("/domains/{domainId}", async ([FromRoute] string domainId, [FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
{
var domain = domainStore.GetDomain(domainId);
if (domain is null)
{
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
}
await domainStore.DeleteDomainAsync(domainId, ct);
return HttpResults.NoContent();
})
.WithName("DeleteMirrorDomain")
.WithSummary("Remove a mirror domain")
.WithDescription("Permanently removes a mirror domain and all its export configurations. Active consumers will lose access immediately.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(MirrorManagePolicy);
// POST /domains/{domainId}/exports — add export to domain
group.MapPost("/domains/{domainId}/exports", async ([FromRoute] string domainId, [FromBody] CreateMirrorExportRequest request, [FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
{
var domain = domainStore.GetDomain(domainId);
if (domain is null)
{
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
}
if (string.IsNullOrWhiteSpace(request.Key))
{
return HttpResults.BadRequest(new { error = "export_key_required" });
}
if (domain.Exports.Any(e => e.Key.Equals(request.Key, StringComparison.OrdinalIgnoreCase)))
{
return HttpResults.Conflict(new { error = "export_key_already_exists", key = request.Key });
}
domain.Exports.Add(new MirrorExportRecord
{
Key = request.Key,
Format = request.Format ?? "json",
Filters = request.Filters ?? new Dictionary<string, string>(),
});
domain.UpdatedAt = DateTimeOffset.UtcNow;
await domainStore.SaveDomainAsync(domain, ct);
return HttpResults.Created($"/api/v1/mirror/domains/{domainId}/exports/{request.Key}", new { domainId, exportKey = request.Key });
})
.WithName("AddMirrorExport")
.WithSummary("Add an export to a mirror domain")
.WithDescription("Adds a new export definition to the specified mirror domain. Export keys must be unique within a domain. Filters define the advisory subset included in the export.")
.Produces(StatusCodes.Status201Created)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict)
.RequireAuthorization(MirrorManagePolicy);
// DELETE /domains/{domainId}/exports/{exportKey} — remove export
group.MapDelete("/domains/{domainId}/exports/{exportKey}", async ([FromRoute] string domainId, [FromRoute] string exportKey, [FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
{
var domain = domainStore.GetDomain(domainId);
if (domain is null)
{
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
}
var removed = domain.Exports.RemoveAll(e => e.Key.Equals(exportKey, StringComparison.OrdinalIgnoreCase));
if (removed == 0)
{
return HttpResults.NotFound(new { error = "export_not_found", domainId, exportKey });
}
domain.UpdatedAt = DateTimeOffset.UtcNow;
await domainStore.SaveDomainAsync(domain, ct);
return HttpResults.NoContent();
})
.WithName("RemoveMirrorExport")
.WithSummary("Remove an export from a mirror domain")
.WithDescription("Removes an export definition from the specified mirror domain by key. Returns 404 if the domain or export key does not exist.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(MirrorManagePolicy);
// POST /domains/{domainId}/generate — trigger bundle generation
group.MapPost("/domains/{domainId}/generate", async ([FromRoute] string domainId, [FromServices] IMirrorDomainStore domainStore, CancellationToken ct) =>
{
var domain = domainStore.GetDomain(domainId);
if (domain is null)
{
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
}
// Mark generation as triggered — actual generation happens async
domain.LastGenerateTriggeredAt = DateTimeOffset.UtcNow;
await domainStore.SaveDomainAsync(domain, ct);
return HttpResults.Accepted($"/api/v1/mirror/domains/{domainId}/status", new { domainId, status = "generation_triggered" });
})
.WithName("TriggerMirrorGeneration")
.WithSummary("Trigger bundle generation for a mirror domain")
.WithDescription("Triggers asynchronous bundle generation for the specified mirror domain. The generation status can be polled via the domain status endpoint.")
.Produces(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(MirrorManagePolicy);
// GET /domains/{domainId}/status — get domain status
group.MapGet("/domains/{domainId}/status", ([FromRoute] string domainId, [FromServices] IMirrorDomainStore domainStore) =>
{
var domain = domainStore.GetDomain(domainId);
if (domain is null)
{
return HttpResults.NotFound(new { error = "domain_not_found", domainId });
}
return HttpResults.Ok(new MirrorDomainStatusResponse
{
DomainId = domain.Id,
LastGeneratedAt = domain.LastGeneratedAt,
LastGenerateTriggeredAt = domain.LastGenerateTriggeredAt,
BundleSizeBytes = domain.BundleSizeBytes,
AdvisoryCount = domain.AdvisoryCount,
ExportCount = domain.Exports.Count,
Staleness = domain.LastGeneratedAt.HasValue
? (DateTimeOffset.UtcNow - domain.LastGeneratedAt.Value).TotalMinutes > 120 ? "stale" : "fresh"
: "never_generated",
});
})
.WithName("GetMirrorDomainStatus")
.WithSummary("Get mirror domain generation status and bundle metrics")
.WithDescription("Returns the current generation status for the specified mirror domain including last generation time, bundle size, advisory count, and staleness indicator.")
.Produces<MirrorDomainStatusResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(MirrorReadPolicy);
// ===== Air-gap bundle import endpoints =====
// POST /import — import a mirror bundle from a local path
group.MapPost("/import", async ([FromBody] MirrorBundleImportRequest request, [FromServices] IMirrorBundleImportStore importStore, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request.BundlePath))
{
return HttpResults.BadRequest(new { error = "bundle_path_required" });
}
var resolvedPath = Path.GetFullPath(request.BundlePath);
if (!Directory.Exists(resolvedPath) && !File.Exists(resolvedPath))
{
return HttpResults.BadRequest(new { error = "bundle_path_not_found", path = resolvedPath });
}
// Start import asynchronously
var importId = Guid.NewGuid().ToString("N")[..12];
var status = new MirrorImportStatusRecord
{
ImportId = importId,
BundlePath = resolvedPath,
StartedAt = DateTimeOffset.UtcNow,
State = "running",
};
importStore.SetStatus(status);
// Run import in background (fire-and-forget with status tracking)
_ = Task.Run(async () =>
{
var warnings = new List<string>();
var errors = new List<string>();
var exportsImported = 0;
long totalSize = 0;
try
{
// 1. Locate manifest
string manifestPath;
string bundleDir;
if (File.Exists(resolvedPath) && resolvedPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
manifestPath = resolvedPath;
bundleDir = Path.GetDirectoryName(resolvedPath)!;
}
else
{
bundleDir = resolvedPath;
var candidates = Directory.GetFiles(resolvedPath, "*-manifest.json")
.Concat(Directory.GetFiles(resolvedPath, "manifest.json"))
.ToArray();
if (candidates.Length == 0)
{
errors.Add("No manifest file found in bundle directory");
status.State = "failed";
status.CompletedAt = DateTimeOffset.UtcNow;
status.Errors = errors;
importStore.SetStatus(status);
return;
}
manifestPath = candidates.OrderByDescending(File.GetLastWriteTimeUtc).First();
}
// 2. Parse manifest
var manifestJson = await File.ReadAllTextAsync(manifestPath, CancellationToken.None);
var manifest = System.Text.Json.JsonSerializer.Deserialize<MirrorBundleManifestDto>(
manifestJson,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (manifest is null)
{
errors.Add("Failed to parse bundle manifest JSON");
status.State = "failed";
status.CompletedAt = DateTimeOffset.UtcNow;
status.Errors = errors;
importStore.SetStatus(status);
return;
}
status.DomainId = manifest.DomainId;
status.DisplayName = manifest.DisplayName;
importStore.SetStatus(status);
// 3. Verify checksums if requested
if (request.VerifyChecksums)
{
var checksumPath = Path.Combine(bundleDir, "SHA256SUMS");
if (File.Exists(checksumPath))
{
var lines = await File.ReadAllLinesAsync(checksumPath, CancellationToken.None);
foreach (var line in lines.Where(l => !string.IsNullOrWhiteSpace(l)))
{
var parts = line.Split([' ', '\t'], 2, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) continue;
var expected = parts[0].Trim();
var fileName = parts[1].Trim().TrimStart('*');
var filePath = Path.Combine(bundleDir, fileName);
if (!File.Exists(filePath))
{
warnings.Add($"Checksum file missing: {fileName}");
continue;
}
var fileBytes = await File.ReadAllBytesAsync(filePath, CancellationToken.None);
var actual = $"sha256:{Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(fileBytes)).ToLowerInvariant()}";
var isValid = string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase) ||
string.Equals($"sha256:{expected}", actual, StringComparison.OrdinalIgnoreCase);
if (!isValid)
{
errors.Add($"Checksum mismatch for {fileName}: expected {expected}, got {actual}");
}
}
}
else
{
warnings.Add("SHA256SUMS file not found in bundle; checksum verification skipped");
}
}
// 4. Verify DSSE if requested
if (request.VerifyDsse)
{
var dsseFiles = Directory.GetFiles(bundleDir, "*.dsse.json")
.Concat(Directory.GetFiles(bundleDir, "*envelope.json"))
.ToArray();
if (dsseFiles.Length == 0)
{
warnings.Add("No DSSE envelope found in bundle; signature verification skipped");
}
else
{
// DSSE envelope found — note presence for audit
warnings.Add($"DSSE envelope found at {Path.GetFileName(dsseFiles[0])}; full cryptographic verification requires trust roots");
}
}
// 5. Copy artifacts to data store
var dataStorePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"stellaops", "mirror-imports", manifest.DomainId ?? importId);
Directory.CreateDirectory(dataStorePath);
var allFiles = Directory.GetFiles(bundleDir);
foreach (var file in allFiles)
{
var destPath = Path.Combine(dataStorePath, Path.GetFileName(file));
await using var sourceStream = File.OpenRead(file);
await using var destStream = File.Create(destPath);
await sourceStream.CopyToAsync(destStream, CancellationToken.None);
totalSize += new FileInfo(file).Length;
}
exportsImported = manifest.Exports?.Count ?? 0;
// Determine final state
if (errors.Count > 0)
{
status.State = "completed_with_errors";
}
else
{
status.State = "completed";
}
status.CompletedAt = DateTimeOffset.UtcNow;
status.Success = errors.Count == 0;
status.ExportsImported = exportsImported;
status.TotalSize = totalSize;
status.Errors = errors;
status.Warnings = warnings;
importStore.SetStatus(status);
}
catch (Exception ex)
{
errors.Add($"Import failed: {ex.Message}");
status.State = "failed";
status.CompletedAt = DateTimeOffset.UtcNow;
status.Errors = errors;
status.Warnings = warnings;
importStore.SetStatus(status);
}
}, CancellationToken.None);
return HttpResults.Accepted($"/api/v1/mirror/import/status", new MirrorBundleImportAcceptedResponse
{
ImportId = importId,
Status = "running",
BundlePath = resolvedPath,
StartedAt = status.StartedAt,
});
})
.WithName("ImportMirrorBundle")
.WithSummary("Import a mirror bundle from a local filesystem path")
.WithDescription("Triggers an asynchronous import of a mirror bundle from the specified local path. The import verifies checksums and DSSE signatures as configured, then copies artifacts to the local data store. Poll the status endpoint for progress.")
.Produces<MirrorBundleImportAcceptedResponse>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(MirrorManagePolicy);
// GET /import/status — get status of last import
group.MapGet("/import/status", ([FromServices] IMirrorBundleImportStore importStore) =>
{
var status = importStore.GetLatestStatus();
if (status is null)
{
return HttpResults.Ok(new MirrorBundleImportStatusResponse
{
HasImport = false,
Status = "none",
Message = "No import has been initiated",
});
}
return HttpResults.Ok(new MirrorBundleImportStatusResponse
{
HasImport = true,
ImportId = status.ImportId,
Status = status.State,
BundlePath = status.BundlePath,
DomainId = status.DomainId,
DisplayName = status.DisplayName,
StartedAt = status.StartedAt,
CompletedAt = status.CompletedAt,
Success = status.Success,
ExportsImported = status.ExportsImported,
TotalSize = status.TotalSize,
Errors = status.Errors,
Warnings = status.Warnings,
});
})
.WithName("GetMirrorImportStatus")
.WithSummary("Get status of the last mirror bundle import")
.WithDescription("Returns the current status, progress, and result of the most recent mirror bundle import operation.")
.Produces<MirrorBundleImportStatusResponse>(StatusCodes.Status200OK)
.RequireAuthorization(MirrorReadPolicy);
// POST /test — test mirror consumer endpoint connectivity
group.MapPost("/test", async ([FromBody] MirrorTestRequest request, HttpContext httpContext, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request.BaseAddress))
{
return HttpResults.BadRequest(new { error = "base_address_required" });
}
try
{
var httpClientFactory = httpContext.RequestServices.GetRequiredService<IHttpClientFactory>();
var client = httpClientFactory.CreateClient("MirrorTest");
client.Timeout = TimeSpan.FromSeconds(10);
var response = await client.GetAsync(
$"{request.BaseAddress.TrimEnd('/')}/domains",
HttpCompletionOption.ResponseHeadersRead,
ct);
return HttpResults.Ok(new MirrorTestResponse
{
Reachable = response.IsSuccessStatusCode,
StatusCode = (int)response.StatusCode,
Message = response.IsSuccessStatusCode ? "Mirror endpoint is reachable" : $"Mirror returned {response.StatusCode}",
});
}
catch (Exception ex)
{
return HttpResults.Ok(new MirrorTestResponse
{
Reachable = false,
Message = $"Connection failed: {ex.Message}",
});
}
})
.WithName("TestMirrorEndpoint")
.WithSummary("Test mirror consumer endpoint connectivity")
.WithDescription("Sends a probe request to the specified mirror consumer base address and reports reachability, HTTP status code, and any connection errors.")
.Produces<MirrorTestResponse>(StatusCodes.Status200OK)
.RequireAuthorization(MirrorManagePolicy);
// ===== Consumer connector configuration endpoints =====
// GET /consumer — get current consumer connector configuration
group.MapGet("/consumer", ([FromServices] IMirrorConsumerConfigStore consumerStore) =>
{
var config = consumerStore.GetConsumerConfig();
return HttpResults.Ok(config);
})
.WithName("GetConsumerConfig")
.WithSummary("Get current consumer connector configuration")
.WithDescription("Returns the consumer connector configuration including base address, domain, signature settings, connection status, and last sync timestamp.")
.Produces<ConsumerConfigResponse>(StatusCodes.Status200OK)
.RequireAuthorization(MirrorReadPolicy);
// PUT /consumer — update consumer connector configuration
group.MapPut("/consumer", async ([FromBody] ConsumerConfigRequest request, [FromServices] IMirrorConsumerConfigStore consumerStore, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request.BaseAddress))
{
return HttpResults.BadRequest(new { error = "base_address_required" });
}
if (string.IsNullOrWhiteSpace(request.DomainId))
{
return HttpResults.BadRequest(new { error = "domain_id_required" });
}
await consumerStore.SetConsumerConfigAsync(request, ct);
var updated = consumerStore.GetConsumerConfig();
return HttpResults.Ok(updated);
})
.WithName("UpdateConsumerConfig")
.WithSummary("Update consumer connector configuration")
.WithDescription("Updates the consumer connector configuration including base address, domain, index path, HTTP timeout, and signature verification settings. The connector will use these settings for subsequent mirror sync operations.")
.Produces<ConsumerConfigResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(MirrorManagePolicy);
// POST /consumer/discover — fetch mirror index and return available domains
group.MapPost("/consumer/discover", async ([FromBody] ConsumerDiscoverRequest request, HttpContext httpContext, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request.BaseAddress))
{
return HttpResults.BadRequest(new { error = "base_address_required" });
}
var indexPath = string.IsNullOrWhiteSpace(request.IndexPath)
? "/concelier/exports/index.json"
: request.IndexPath.TrimStart('/');
var indexUrl = $"{request.BaseAddress.TrimEnd('/')}/{indexPath.TrimStart('/')}";
try
{
var httpClientFactory = httpContext.RequestServices.GetRequiredService<IHttpClientFactory>();
var client = httpClientFactory.CreateClient("MirrorConsumer");
client.Timeout = TimeSpan.FromSeconds(request.HttpTimeoutSeconds ?? 30);
var response = await client.GetAsync(indexUrl, ct);
if (!response.IsSuccessStatusCode)
{
return HttpResults.Ok(new MirrorDiscoveryResponse
{
Domains = [],
Error = $"Mirror returned HTTP {(int)response.StatusCode} from {indexUrl}",
});
}
var json = await response.Content.ReadAsStringAsync(ct);
var indexDoc = JsonSerializer.Deserialize<MirrorIndexDocument>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
if (indexDoc?.Domains is null || indexDoc.Domains.Count == 0)
{
return HttpResults.Ok(new MirrorDiscoveryResponse
{
Domains = [],
Error = "Mirror index contains no domains",
});
}
var domains = indexDoc.Domains.Select(d => new MirrorDiscoveryDomain
{
DomainId = d.DomainId ?? d.Id ?? "",
DisplayName = d.DisplayName ?? d.DomainId ?? d.Id ?? "",
LastGenerated = d.LastGenerated,
AdvisoryCount = d.AdvisoryCount,
BundleSize = d.BundleSize,
ExportFormats = d.ExportFormats ?? [],
Signed = d.Signed,
}).ToList();
return HttpResults.Ok(new MirrorDiscoveryResponse { Domains = domains });
}
catch (TaskCanceledException)
{
return HttpResults.Ok(new MirrorDiscoveryResponse
{
Domains = [],
Error = $"Request to {indexUrl} timed out",
});
}
catch (HttpRequestException ex)
{
return HttpResults.Ok(new MirrorDiscoveryResponse
{
Domains = [],
Error = $"Failed to fetch mirror index: {ex.Message}",
});
}
})
.WithName("DiscoverMirrorDomains")
.WithSummary("Fetch mirror index and discover available domains")
.WithDescription("Fetches the mirror index document from the specified base address and returns the list of available domains with metadata including advisory counts, bundle sizes, export formats, and signature status.")
.Produces<MirrorDiscoveryResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(MirrorManagePolicy);
// POST /consumer/verify-signature — fetch bundle header and return signature details
group.MapPost("/consumer/verify-signature", async ([FromBody] ConsumerVerifySignatureRequest request, HttpContext httpContext, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(request.BaseAddress))
{
return HttpResults.BadRequest(new { error = "base_address_required" });
}
if (string.IsNullOrWhiteSpace(request.DomainId))
{
return HttpResults.BadRequest(new { error = "domain_id_required" });
}
var bundleUrl = $"{request.BaseAddress.TrimEnd('/')}/{request.DomainId}/bundle.json.jws";
try
{
var httpClientFactory = httpContext.RequestServices.GetRequiredService<IHttpClientFactory>();
var client = httpClientFactory.CreateClient("MirrorConsumer");
client.Timeout = TimeSpan.FromSeconds(15);
// Fetch only the initial portion to extract the JWS header
var httpRequest = new HttpRequestMessage(HttpMethod.Get, bundleUrl);
httpRequest.Headers.Range = new RangeHeaderValue(0, 4095);
var response = await client.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, ct);
if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.PartialContent)
{
return HttpResults.Ok(new SignatureDiscoveryResponse
{
Detected = false,
Error = $"Bundle not found at {bundleUrl} (HTTP {(int)response.StatusCode})",
});
}
var content = await response.Content.ReadAsStringAsync(ct);
// JWS compact serialization: header.payload.signature
// Extract the header (first segment before the dot)
var firstDot = content.IndexOf('.');
if (firstDot <= 0)
{
return HttpResults.Ok(new SignatureDiscoveryResponse
{
Detected = false,
Error = "Response does not appear to be a JWS (no header segment found)",
});
}
var headerB64 = content[..firstDot];
// Decode the JWS header (Base64url)
var padded = headerB64
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
var headerJson = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(padded));
var header = JsonSerializer.Deserialize<JwsHeaderPayload>(headerJson, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
return HttpResults.Ok(new SignatureDiscoveryResponse
{
Detected = true,
Algorithm = header?.Alg,
KeyId = header?.Kid,
Provider = header?.Provider,
});
}
catch (TaskCanceledException)
{
return HttpResults.Ok(new SignatureDiscoveryResponse
{
Detected = false,
Error = $"Request to {bundleUrl} timed out",
});
}
catch (HttpRequestException ex)
{
return HttpResults.Ok(new SignatureDiscoveryResponse
{
Detected = false,
Error = $"Failed to fetch bundle header: {ex.Message}",
});
}
catch (FormatException)
{
return HttpResults.Ok(new SignatureDiscoveryResponse
{
Detected = false,
Error = "JWS header could not be decoded (invalid Base64url)",
});
}
})
.WithName("VerifyMirrorSignature")
.WithSummary("Fetch bundle header and discover signature details")
.WithDescription("Fetches the JWS header from a domain's bundle at the specified mirror and extracts signature metadata including algorithm, key ID, and crypto provider. Only the header is downloaded, not the full bundle.")
.Produces<SignatureDiscoveryResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(MirrorManagePolicy);
}
private static MirrorDomainSummary MapDomainSummary(MirrorDomainRecord domain) => new()
{
Id = domain.Id,
DisplayName = domain.DisplayName,
ExportCount = domain.Exports.Count,
LastGeneratedAt = domain.LastGeneratedAt,
Staleness = domain.LastGeneratedAt.HasValue
? (DateTimeOffset.UtcNow - domain.LastGeneratedAt.Value).TotalMinutes > 120 ? "stale" : "fresh"
: "never_generated",
};
private static MirrorDomainDetailResponse MapDomainDetail(MirrorDomainRecord domain) => new()
{
Id = domain.Id,
DisplayName = domain.DisplayName,
RequireAuthentication = domain.RequireAuthentication,
MaxIndexRequestsPerHour = domain.MaxIndexRequestsPerHour,
MaxDownloadRequestsPerHour = domain.MaxDownloadRequestsPerHour,
Exports = domain.Exports.Select(e => new MirrorExportSummary
{
Key = e.Key,
Format = e.Format,
Filters = e.Filters,
}).ToList(),
LastGeneratedAt = domain.LastGeneratedAt,
CreatedAt = domain.CreatedAt,
UpdatedAt = domain.UpdatedAt,
};
}
// ===== Interfaces =====
/// <summary>
/// Store for mirror domain configuration. Initial implementation: in-memory.
/// Future: DB-backed with migration.
/// </summary>
public interface IMirrorDomainStore
{
IReadOnlyList<MirrorDomainRecord> GetAllDomains();
MirrorDomainRecord? GetDomain(string domainId);
Task SaveDomainAsync(MirrorDomainRecord domain, CancellationToken ct = default);
Task DeleteDomainAsync(string domainId, CancellationToken ct = default);
}
/// <summary>
/// Store for mirror global configuration.
/// </summary>
public interface IMirrorConfigStore
{
Task UpdateConfigAsync(UpdateMirrorConfigRequest request, CancellationToken ct = default);
}
/// <summary>
/// Store for mirror consumer connector configuration. Initial implementation: in-memory.
/// Future: DB-backed with migration.
/// </summary>
public interface IMirrorConsumerConfigStore
{
ConsumerConfigResponse GetConsumerConfig();
Task SetConsumerConfigAsync(ConsumerConfigRequest request, CancellationToken ct = default);
}
/// <summary>
/// Store for tracking mirror bundle import status. Initial implementation: in-memory.
/// </summary>
public interface IMirrorBundleImportStore
{
MirrorImportStatusRecord? GetLatestStatus();
void SetStatus(MirrorImportStatusRecord status);
}
// ===== Models =====
public sealed class MirrorDomainRecord
{
public required string Id { get; set; }
public required string DisplayName { get; set; }
public bool RequireAuthentication { get; set; }
public int MaxIndexRequestsPerHour { get; set; } = 120;
public int MaxDownloadRequestsPerHour { get; set; } = 600;
public List<MirrorExportRecord> Exports { get; set; } = [];
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
public DateTimeOffset? LastGeneratedAt { get; set; }
public DateTimeOffset? LastGenerateTriggeredAt { get; set; }
public long BundleSizeBytes { get; set; }
public long AdvisoryCount { get; set; }
}
public sealed class MirrorExportRecord
{
public required string Key { get; set; }
public string Format { get; set; } = "json";
public Dictionary<string, string> Filters { get; set; } = new();
}
public sealed class MirrorConfigOptions
{
public string Mode { get; set; } = "direct";
public string? OutputRoot { get; set; }
public string? ConsumerBaseAddress { get; set; }
public bool SigningEnabled { get; set; }
public string? SigningAlgorithm { get; set; }
public string? SigningKeyId { get; set; }
public bool AutoRefreshEnabled { get; set; } = true;
public int RefreshIntervalMinutes { get; set; } = 60;
}
// ===== Request DTOs =====
public sealed record UpdateMirrorConfigRequest
{
public string? Mode { get; init; }
public string? ConsumerBaseAddress { get; init; }
public MirrorSigningRequest? Signing { get; init; }
public bool? AutoRefreshEnabled { get; init; }
public int? RefreshIntervalMinutes { get; init; }
}
public sealed record MirrorSigningRequest
{
public bool Enabled { get; init; }
public string? Algorithm { get; init; }
public string? KeyId { get; init; }
}
public sealed record CreateMirrorDomainRequest
{
public required string Id { get; init; }
public required string DisplayName { get; init; }
public bool RequireAuthentication { get; init; }
public int? MaxIndexRequestsPerHour { get; init; }
public int? MaxDownloadRequestsPerHour { get; init; }
public List<CreateMirrorExportRequest>? Exports { get; init; }
}
public sealed record UpdateMirrorDomainRequest
{
public string? DisplayName { get; init; }
public bool? RequireAuthentication { get; init; }
public int? MaxIndexRequestsPerHour { get; init; }
public int? MaxDownloadRequestsPerHour { get; init; }
public List<CreateMirrorExportRequest>? Exports { get; init; }
}
public sealed record CreateMirrorExportRequest
{
public required string Key { get; init; }
public string? Format { get; init; }
public Dictionary<string, string>? Filters { get; init; }
}
public sealed record MirrorTestRequest
{
public required string BaseAddress { get; init; }
}
public sealed record ConsumerConfigRequest
{
public required string BaseAddress { get; init; }
public required string DomainId { get; init; }
public string? IndexPath { get; init; }
public int? HttpTimeoutSeconds { get; init; }
public ConsumerSignatureRequest? Signature { get; init; }
}
public sealed record ConsumerSignatureRequest
{
public bool Enabled { get; init; }
public string? Algorithm { get; init; }
public string? KeyId { get; init; }
public string? PublicKeyPem { get; init; }
}
public sealed record ConsumerDiscoverRequest
{
public required string BaseAddress { get; init; }
public string? IndexPath { get; init; }
public int? HttpTimeoutSeconds { get; init; }
}
public sealed record ConsumerVerifySignatureRequest
{
public required string BaseAddress { get; init; }
public required string DomainId { get; init; }
}
// ===== Response DTOs =====
public sealed record MirrorConfigResponse
{
public string Mode { get; init; } = "direct";
public string? OutputRoot { get; init; }
public string? ConsumerBaseAddress { get; init; }
public MirrorSigningResponse? Signing { get; init; }
public bool AutoRefreshEnabled { get; init; }
public int RefreshIntervalMinutes { get; init; }
}
public sealed record MirrorSigningResponse
{
public bool Enabled { get; init; }
public string? Algorithm { get; init; }
public string? KeyId { get; init; }
}
public sealed record MirrorDomainListResponse
{
public IReadOnlyList<MirrorDomainSummary> Domains { get; init; } = [];
public int TotalCount { get; init; }
}
public sealed record MirrorDomainSummary
{
public string Id { get; init; } = "";
public string DisplayName { get; init; } = "";
public int ExportCount { get; init; }
public DateTimeOffset? LastGeneratedAt { get; init; }
public string Staleness { get; init; } = "never_generated";
}
public sealed record MirrorDomainDetailResponse
{
public string Id { get; init; } = "";
public string DisplayName { get; init; } = "";
public bool RequireAuthentication { get; init; }
public int MaxIndexRequestsPerHour { get; init; }
public int MaxDownloadRequestsPerHour { get; init; }
public IReadOnlyList<MirrorExportSummary> Exports { get; init; } = [];
public DateTimeOffset? LastGeneratedAt { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? UpdatedAt { get; init; }
}
public sealed record MirrorExportSummary
{
public string Key { get; init; } = "";
public string Format { get; init; } = "json";
public Dictionary<string, string> Filters { get; init; } = new();
}
public sealed record MirrorDomainStatusResponse
{
public string DomainId { get; init; } = "";
public DateTimeOffset? LastGeneratedAt { get; init; }
public DateTimeOffset? LastGenerateTriggeredAt { get; init; }
public long BundleSizeBytes { get; init; }
public long AdvisoryCount { get; init; }
public int ExportCount { get; init; }
public string Staleness { get; init; } = "never_generated";
}
public sealed record MirrorTestResponse
{
public bool Reachable { get; init; }
public int? StatusCode { get; init; }
public string? Message { get; init; }
}
// ===== Consumer connector DTOs =====
public sealed record ConsumerConfigResponse
{
public string? BaseAddress { get; init; }
public string? DomainId { get; init; }
public string IndexPath { get; init; } = "/concelier/exports/index.json";
public int HttpTimeoutSeconds { get; init; } = 30;
public ConsumerSignatureResponse? Signature { get; init; }
public bool Connected { get; init; }
public DateTimeOffset? LastSync { get; init; }
}
public sealed record ConsumerSignatureResponse
{
public bool Enabled { get; init; }
public string? Algorithm { get; init; }
public string? KeyId { get; init; }
public string? PublicKeyPem { get; init; }
}
public sealed record MirrorDiscoveryResponse
{
public IReadOnlyList<MirrorDiscoveryDomain> Domains { get; init; } = [];
public string? Error { get; init; }
}
public sealed record MirrorDiscoveryDomain
{
public string DomainId { get; init; } = "";
public string DisplayName { get; init; } = "";
public DateTimeOffset? LastGenerated { get; init; }
public long AdvisoryCount { get; init; }
public long BundleSize { get; init; }
public IReadOnlyList<string> ExportFormats { get; init; } = [];
public bool Signed { get; init; }
}
public sealed record SignatureDiscoveryResponse
{
public bool Detected { get; init; }
public string? Algorithm { get; init; }
public string? KeyId { get; init; }
public string? Provider { get; init; }
public string? Error { get; init; }
}
// ===== Bundle import DTOs =====
public sealed record MirrorBundleImportRequest
{
public required string BundlePath { get; init; }
public bool VerifyChecksums { get; init; } = true;
public bool VerifyDsse { get; init; } = true;
public string? TrustRootsPath { get; init; }
}
public sealed record MirrorBundleImportAcceptedResponse
{
public string ImportId { get; init; } = "";
public string Status { get; init; } = "running";
public string BundlePath { get; init; } = "";
public DateTimeOffset StartedAt { get; init; }
}
public sealed record MirrorBundleImportStatusResponse
{
public bool HasImport { get; init; }
public string? ImportId { get; init; }
public string Status { get; init; } = "none";
public string? Message { get; init; }
public string? BundlePath { get; init; }
public string? DomainId { get; init; }
public string? DisplayName { get; init; }
public DateTimeOffset? StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public bool Success { get; init; }
public int ExportsImported { get; init; }
public long TotalSize { get; init; }
public IReadOnlyList<string> Errors { get; init; } = [];
public IReadOnlyList<string> Warnings { get; init; } = [];
}
/// <summary>
/// In-memory record for tracking an active or completed import operation.
/// </summary>
public sealed class MirrorImportStatusRecord
{
public string ImportId { get; set; } = "";
public string BundlePath { get; set; } = "";
public string State { get; set; } = "pending";
public DateTimeOffset StartedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public string? DomainId { get; set; }
public string? DisplayName { get; set; }
public bool Success { get; set; }
public int ExportsImported { get; set; }
public long TotalSize { get; set; }
public IReadOnlyList<string> Errors { get; set; } = [];
public IReadOnlyList<string> Warnings { get; set; } = [];
}
/// <summary>
/// Simplified manifest DTO for parsing bundle manifests during import.
/// </summary>
internal sealed class MirrorBundleManifestDto
{
public string? DomainId { get; set; }
public string? DisplayName { get; set; }
public DateTimeOffset GeneratedAt { get; set; }
public List<MirrorBundleExportDto>? Exports { get; set; }
}
internal sealed class MirrorBundleExportDto
{
public string? Key { get; set; }
public string? ExportId { get; set; }
public string? Format { get; set; }
public long? ArtifactSizeBytes { get; set; }
public string? ArtifactDigest { get; set; }
}
// ===== Internal deserialization models =====
/// <summary>
/// Represents the mirror index JSON document fetched during discovery.
/// </summary>
internal sealed class MirrorIndexDocument
{
public List<MirrorIndexDomainEntry> Domains { get; set; } = [];
}
internal sealed class MirrorIndexDomainEntry
{
public string? Id { get; set; }
public string? DomainId { get; set; }
public string? DisplayName { get; set; }
public DateTimeOffset? LastGenerated { get; set; }
public long AdvisoryCount { get; set; }
public long BundleSize { get; set; }
public List<string>? ExportFormats { get; set; }
public bool Signed { get; set; }
}
/// <summary>
/// Minimal JWS header fields used for signature discovery.
/// </summary>
internal sealed class JwsHeaderPayload
{
public string? Alg { get; set; }
public string? Kid { get; set; }
public string? Provider { get; set; }
}