fix(router): ship audit bundle frontdoor cutover

This commit is contained in:
master
2026-03-08 14:30:12 +02:00
parent 8852928115
commit 30532800ec
9 changed files with 367 additions and 16 deletions

View File

@@ -45,6 +45,8 @@ public static class GatewayOptionsValidator
private static void ValidateRoutes(List<StellaOpsRoute> routes)
{
var exactPathIndices = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < routes.Count; i++)
{
var route = routes[i];
@@ -66,6 +68,17 @@ public static class GatewayOptionsValidator
throw new InvalidOperationException($"{prefix}: Path is not a valid regex pattern: {ex.Message}");
}
}
else
{
var normalizedPath = NormalizePath(route.Path);
if (exactPathIndices.TryGetValue(normalizedPath, out var existingIndex))
{
throw new InvalidOperationException(
$"{prefix}: Duplicate route path '{normalizedPath}' already defined by Route[{existingIndex}].");
}
exactPathIndices[normalizedPath] = i;
}
switch (route.Type)
{
@@ -124,4 +137,16 @@ public static class GatewayOptionsValidator
}
}
}
private static string NormalizePath(string value)
{
var normalized = value.Trim();
if (!normalized.StartsWith('/'))
{
normalized = "/" + normalized;
}
normalized = normalized.TrimEnd('/');
return string.IsNullOrEmpty(normalized) ? "/" : normalized;
}
}

View File

@@ -1,9 +1,9 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS base
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 8443
FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish src/Router/StellaOps.Gateway.WebService/StellaOps.Gateway.WebService.csproj -c Release -o /app/publish

View File

@@ -24,7 +24,10 @@ using StellaOps.Router.Gateway.Middleware;
using StellaOps.Router.Gateway.OpenApi;
using StellaOps.Router.Gateway.RateLimit;
using StellaOps.Router.Gateway.Routing;
using System.Net;
using System.Net.Sockets;
using System.Runtime.Loader;
using System.Security.Cryptography.X509Certificates;
var builder = WebApplication.CreateBuilder(args);
@@ -157,7 +160,14 @@ var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
builder.TryAddStellaOpsLocalBinding("router");
if (ShouldApplyStellaOpsLocalBinding())
{
builder.TryAddStellaOpsLocalBinding("router");
}
else
{
ConfigureContainerFrontdoorBindings(builder);
}
var app = builder.Build();
app.LogStellaOpsLocalHostname("router");
@@ -378,6 +388,116 @@ static void ConfigureGatewayOptionsMapping(WebApplicationBuilder builder, Gatewa
}
static bool ShouldApplyStellaOpsLocalBinding()
{
var runningInContainer = string.Equals(
Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"),
"true",
StringComparison.OrdinalIgnoreCase);
if (!runningInContainer)
{
return true;
}
// Compose-published container runs already define the frontdoor port contract.
// Respect explicit container port settings instead of replacing them with 80/443.
var explicitUrls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS");
var explicitHttpPorts = Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS");
var explicitHttpsPorts = Environment.GetEnvironmentVariable("ASPNETCORE_HTTPS_PORTS");
return string.IsNullOrWhiteSpace(explicitUrls)
&& string.IsNullOrWhiteSpace(explicitHttpPorts)
&& string.IsNullOrWhiteSpace(explicitHttpsPorts);
}
static void ConfigureContainerFrontdoorBindings(WebApplicationBuilder builder)
{
var currentUrls = builder.WebHost.GetSetting(WebHostDefaults.ServerUrlsKey) ?? string.Empty;
builder.WebHost.ConfigureKestrel((context, kestrel) =>
{
var defaultCert = LoadDefaultCertificate(context.Configuration);
foreach (var rawUrl in currentUrls.Split(';', StringSplitOptions.RemoveEmptyEntries))
{
if (!Uri.TryCreate(rawUrl.Trim(), UriKind.Absolute, out var uri))
{
continue;
}
var address = ResolveListenAddress(uri.Host);
if (string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
kestrel.Listen(address, uri.Port, listenOptions =>
{
if (defaultCert is not null)
{
listenOptions.UseHttps(defaultCert);
}
else
{
listenOptions.UseHttps();
}
});
continue;
}
kestrel.Listen(address, uri.Port);
}
if (defaultCert is not null && IsPortAvailable(443, IPAddress.Any))
{
kestrel.ListenAnyIP(443, listenOptions => listenOptions.UseHttps(defaultCert));
}
});
}
static X509Certificate2? LoadDefaultCertificate(IConfiguration configuration)
{
var certPath = configuration["Kestrel:Certificates:Default:Path"];
var certPass = configuration["Kestrel:Certificates:Default:Password"];
if (string.IsNullOrWhiteSpace(certPath) || !File.Exists(certPath))
{
return null;
}
return X509CertificateLoader.LoadPkcs12FromFile(certPath, certPass);
}
static IPAddress ResolveListenAddress(string host)
{
if (string.IsNullOrWhiteSpace(host) ||
string.Equals(host, "*", StringComparison.OrdinalIgnoreCase) ||
string.Equals(host, "+", StringComparison.OrdinalIgnoreCase) ||
string.Equals(host, "0.0.0.0", StringComparison.OrdinalIgnoreCase))
{
return IPAddress.Any;
}
if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
{
return IPAddress.Loopback;
}
return IPAddress.Parse(host);
}
static bool IsPortAvailable(int port, IPAddress address)
{
try
{
using var listener = new TcpListener(address, port);
listener.Start();
listener.Stop();
return true;
}
catch
{
return false;
}
}
static string? ResolveAuthorityClaimsUrl(GatewayAuthorityOptions authorityOptions)
{
if (!string.IsNullOrWhiteSpace(authorityOptions.ClaimsOverridesUrl))
@@ -418,5 +538,3 @@ static string? ResolveAuthorityClaimsUrl(GatewayAuthorityOptions authorityOption
return builder.Uri.GetLeftPart(UriPartial.Authority).TrimEnd('/');
}

View File

@@ -72,10 +72,9 @@
{ "Type": "ReverseProxy", "Path": "/api/v1/notifier", "TranslatesTo": "http://notifier.stella-ops.local/api/v1/notifier" },
{ "Type": "ReverseProxy", "Path": "/api/v1/concelier", "TranslatesTo": "http://concelier.stella-ops.local/api/v1/concelier" },
{ "Type": "ReverseProxy", "Path": "/api/cvss", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/cvss", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/v1/evidence-packs", "TranslatesTo": "http://evidencelocker.stella-ops.local/v1/evidence-packs" },
{ "Type": "ReverseProxy", "Path": "/v1/evidence-packs", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs" },
{ "Type": "ReverseProxy", "Path": "/v1/runs", "TranslatesTo": "http://orchestrator.stella-ops.local/v1/runs" },
{ "Type": "ReverseProxy", "Path": "/v1/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai" },
{ "Type": "ReverseProxy", "Path": "/v1/audit-bundles", "TranslatesTo": "http://evidencelocker.stella-ops.local/v1/audit-bundles" },
{ "Type": "ReverseProxy", "Path": "/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/risk", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk", "PreserveAuthHeaders": true },
@@ -88,13 +87,11 @@
{ "Type": "ReverseProxy", "Path": "/api/v1/findings", "TranslatesTo": "http://findings.stella-ops.local/api/v1/findings", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/v1/integrations", "TranslatesTo": "http://integrations.stella-ops.local/api/v1/integrations", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/v1/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy" },
{ "Type": "ReverseProxy", "Path": "/api/policy", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/policy", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/v1/reachability", "TranslatesTo": "http://reachgraph.stella-ops.local/api/v1/reachability" },
{ "Type": "ReverseProxy", "Path": "/api/v1/attestor", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestor" },
{ "Type": "ReverseProxy", "Path": "/api/v1/attestations", "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestations" },
{ "Type": "ReverseProxy", "Path": "/api/v1/sbom", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sbom" },
{ "Type": "ReverseProxy", "Path": "/api/v1/signals", "TranslatesTo": "http://signals.stella-ops.local/api/v1/signals" },
{ "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex" },
{ "Type": "ReverseProxy", "Path": "/api/v1/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/v1/authority/quotas", "TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/v1/authority", "TranslatesTo": "http://authority.stella-ops.local/api/v1/authority", "PreserveAuthHeaders": true },
@@ -105,7 +102,6 @@
{ "Type": "ReverseProxy", "Path": "/api/v1/search", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search" },
{ "Type": "ReverseProxy", "Path": "/api/v1/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai" },
{ "Type": "ReverseProxy", "Path": "/api/v1/advisory", "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory" },
{ "Type": "ReverseProxy", "Path": "/api/v1/concelier", "TranslatesTo": "http://concelier.stella-ops.local/api/v1/concelier" },
{ "Type": "ReverseProxy", "Path": "/api/v1/vulnerabilities", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/vulnerabilities" },
{ "Type": "ReverseProxy", "Path": "/api/v1/watchlist", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/watchlist" },
{ "Type": "ReverseProxy", "Path": "/api/v1/resolve", "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/resolve" },

View File

@@ -313,6 +313,30 @@ public sealed class GatewayOptionsValidatorTests
Assert.Contains("regex", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_DuplicateExactRoutePath_Throws()
{
var options = CreateValidOptions();
options.Routes.Add(new StellaOpsRoute
{
Type = StellaOpsRouteType.ReverseProxy,
Path = "/v1/audit-bundles",
TranslatesTo = "http://exportcenter.stella-ops.local/v1/audit-bundles"
});
options.Routes.Add(new StellaOpsRoute
{
Type = StellaOpsRouteType.ReverseProxy,
Path = "/v1/audit-bundles",
TranslatesTo = "http://evidencelocker.stella-ops.local/v1/audit-bundles"
});
var exception = Assert.Throws<InvalidOperationException>(() =>
GatewayOptionsValidator.Validate(options));
Assert.Contains("Duplicate route path", exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("/v1/audit-bundles", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_ValidRegex_DoesNotThrow()
{

View File

@@ -1,9 +1,11 @@
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Matching;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Primitives;
@@ -198,6 +200,87 @@ public sealed class AspNetRouterRequestDispatcherTests
Assert.Contains("\"timeWindow\":\"24h\"", responseBody, StringComparison.Ordinal);
}
[Fact]
public async Task DispatchAsync_BindsAuditBundleCreateRequest_WithAcceptedUnionResult()
{
var builder = WebApplication.CreateBuilder();
var app = builder.Build();
app.MapPost(
"/v1/audit-bundles",
Results<Accepted<AuditBundleAcceptedResponse>, BadRequest<AuditBundleErrorEnvelope>> (
[FromBody] AuditBundleCreateRequest body) =>
{
if (string.IsNullOrWhiteSpace(body.Subject.Name))
{
return TypedResults.BadRequest(
new AuditBundleErrorEnvelope(
new AuditBundleErrorDetail("INVALID_REQUEST", "Subject name is required")));
}
return TypedResults.Accepted(
$"/v1/audit-bundles/{body.Subject.Name}",
new AuditBundleAcceptedResponse(
$"bndl-{body.Subject.Name}",
"Pending",
$"/v1/audit-bundles/{body.Subject.Name}",
30));
});
var endpointRouteBuilder = (IEndpointRouteBuilder)app;
var endpointDataSource = new StaticEndpointDataSource(
endpointRouteBuilder.DataSources.SelectMany(static dataSource => dataSource.Endpoints).ToArray());
var dispatcher = new AspNetRouterRequestDispatcher(
app.Services,
endpointDataSource,
new StellaRouterBridgeOptions
{
ServiceName = "exportcenter",
Version = "1.0.0-alpha1",
Region = "local",
AuthorizationTrustMode = GatewayAuthorizationTrustMode.ServiceEnforced
},
NullLogger<AspNetRouterRequestDispatcher>.Instance);
var transportFrame = FrameConverter.ToFrame(new RequestFrame
{
RequestId = "req-audit-1",
Method = "POST",
Path = "/v1/audit-bundles",
Headers = new Dictionary<string, string>
{
["content-type"] = "application/json"
},
Payload = Encoding.UTF8.GetBytes("""
{
"subject": {
"type": "IMAGE",
"name": "asset-review-prod",
"digest": {
"sha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111"
}
},
"includeContent": {
"vulnReports": true,
"sbom": true,
"vexDecisions": true,
"policyEvaluations": true,
"attestations": true
}
}
""")
});
var roundTrippedRequest = FrameConverter.ToRequestFrame(transportFrame);
Assert.NotNull(roundTrippedRequest);
var response = await dispatcher.DispatchAsync(roundTrippedRequest!);
var responseBody = Encoding.UTF8.GetString(response.Payload.ToArray());
Assert.Equal(StatusCodes.Status202Accepted, response.StatusCode);
Assert.Contains("\"bundleId\":\"bndl-asset-review-prod\"", responseBody, StringComparison.Ordinal);
Assert.Contains("\"status\":\"Pending\"", responseBody, StringComparison.Ordinal);
}
private static AspNetRouterRequestDispatcher CreateDispatcher(RouteEndpoint endpoint, StellaRouterBridgeOptions options)
{
var services = new ServiceCollection();
@@ -227,4 +310,27 @@ public sealed class AspNetRouterRequestDispatcherTests
}
private sealed record PreferencesBody(string[] Regions, string[] Environments, string TimeWindow);
private sealed record AuditBundleCreateRequest(
AuditBundleSubjectRef Subject,
AuditBundleTimeWindow? TimeWindow,
AuditBundleContentSelection IncludeContent,
string? CallbackUrl = null);
private sealed record AuditBundleSubjectRef(
string Type,
string Name,
IReadOnlyDictionary<string, string> Digest);
private sealed record AuditBundleTimeWindow(DateTimeOffset? From, DateTimeOffset? To);
private sealed record AuditBundleContentSelection(
bool VulnReports = true,
bool Sbom = true,
bool VexDecisions = true,
bool PolicyEvaluations = true,
bool Attestations = true);
private sealed record AuditBundleAcceptedResponse(
string BundleId,
string Status,
string StatusUrl,
int? EstimatedCompletionSeconds);
private sealed record AuditBundleErrorEnvelope(AuditBundleErrorDetail Error);
private sealed record AuditBundleErrorDetail(string Code, string Message);
}