Fix router frontdoor readiness and route contracts

This commit is contained in:
master
2026-03-10 10:19:49 +02:00
parent eae2dfc9d4
commit 7acf0ae8f2
37 changed files with 1408 additions and 1914 deletions

View File

@@ -268,4 +268,6 @@ public sealed class GatewayHealthOptions
public string DegradedThreshold { get; set; } = "15s";
public string CheckInterval { get; set; } = "5s";
public List<string> RequiredMicroservices { get; set; } = [];
}

View File

@@ -40,6 +40,11 @@ public static class GatewayOptionsValidator
_ = GatewayValueParser.ParseDuration(options.Health.DegradedThreshold, TimeSpan.FromSeconds(15));
_ = GatewayValueParser.ParseDuration(options.Health.CheckInterval, TimeSpan.FromSeconds(5));
if (options.Health.RequiredMicroservices.Any(service => string.IsNullOrWhiteSpace(service)))
{
throw new InvalidOperationException("Gateway health required microservices must not contain empty values.");
}
ValidateRoutes(options.Routes);
}
@@ -133,6 +138,23 @@ public static class GatewayOptionsValidator
{
_ = GatewayValueParser.ParseDuration(route.DefaultTimeout, TimeSpan.FromSeconds(30));
}
if (route.IsRegex && !string.IsNullOrWhiteSpace(route.TranslatesTo))
{
var regex = new Regex(route.Path);
var groupCount = regex.GetGroupNumbers().Length;
var refs = Regex.Matches(route.TranslatesTo, @"\$(\d+)");
foreach (Match refMatch in refs)
{
var groupNum = int.Parse(refMatch.Groups[1].Value);
if (groupNum >= groupCount)
{
throw new InvalidOperationException(
$"{prefix}: TranslatesTo references ${groupNum} but regex only has {groupCount - 1} capture groups.");
}
}
}
break;
}
}

View File

@@ -20,7 +20,11 @@ public sealed class HealthCheckMiddleware
_next = next;
}
public async Task InvokeAsync(HttpContext context, GatewayServiceStatus status, GatewayMetrics metrics)
public async Task InvokeAsync(
HttpContext context,
GatewayServiceStatus status,
GatewayMetrics metrics,
GatewayReadinessEvaluator readinessEvaluator)
{
if (GatewayRoutes.IsMetricsPath(context.Request.Path))
{
@@ -37,28 +41,34 @@ public sealed class HealthCheckMiddleware
var path = context.Request.Path.Value ?? string.Empty;
if (path.Equals("/health/live", StringComparison.OrdinalIgnoreCase))
{
await WriteHealthAsync(context, StatusCodes.Status200OK, "live", status);
await WriteHealthAsync(context, StatusCodes.Status200OK, "live", status, readinessEvaluator.Evaluate(status));
return;
}
if (path.Equals("/health/ready", StringComparison.OrdinalIgnoreCase))
{
var readyStatus = status.IsReady ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
await WriteHealthAsync(context, readyStatus, "ready", status);
var readiness = readinessEvaluator.Evaluate(status);
var readyStatus = readiness.IsReady ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
await WriteHealthAsync(context, readyStatus, "ready", status, readiness);
return;
}
if (path.Equals("/health/startup", StringComparison.OrdinalIgnoreCase))
{
var startupStatus = status.IsStarted ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable;
await WriteHealthAsync(context, startupStatus, "startup", status);
await WriteHealthAsync(context, startupStatus, "startup", status, readinessEvaluator.Evaluate(status));
return;
}
await WriteHealthAsync(context, StatusCodes.Status200OK, "ok", status);
await WriteHealthAsync(context, StatusCodes.Status200OK, "ok", status, readinessEvaluator.Evaluate(status));
}
private static Task WriteHealthAsync(HttpContext context, int statusCode, string status, GatewayServiceStatus serviceStatus)
private static Task WriteHealthAsync(
HttpContext context,
int statusCode,
string status,
GatewayServiceStatus serviceStatus,
GatewayReadinessReport readiness)
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json; charset=utf-8";
@@ -67,7 +77,10 @@ public sealed class HealthCheckMiddleware
{
status,
started = serviceStatus.IsStarted,
ready = serviceStatus.IsReady,
ready = readiness.IsReady,
transportReady = serviceStatus.IsReady,
requiredMicroservices = readiness.RequiredMicroservices,
missingMicroservices = readiness.MissingMicroservices,
traceId = context.TraceIdentifier
};

View File

@@ -1,4 +1,5 @@
using System.Net.WebSockets;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.FileProviders;
using StellaOps.Gateway.WebService.Configuration;
@@ -47,7 +48,7 @@ public sealed class RouteDispatchMiddleware
return;
}
var route = _resolver.Resolve(context.Request.Path);
var (route, regexMatch) = _resolver.Resolve(context.Request.Path);
if (route is null)
{
await _next(context);
@@ -83,13 +84,13 @@ public sealed class RouteDispatchMiddleware
await HandleStaticFile(context, route);
break;
case StellaOpsRouteType.ReverseProxy:
await HandleReverseProxy(context, route);
await HandleReverseProxy(context, route, regexMatch);
break;
case StellaOpsRouteType.WebSocket:
await HandleWebSocket(context, route);
break;
case StellaOpsRouteType.Microservice:
PrepareMicroserviceRoute(context, route);
PrepareMicroserviceRoute(context, route, regexMatch);
await _next(context);
break;
default:
@@ -178,17 +179,24 @@ public sealed class RouteDispatchMiddleware
await stream.CopyToAsync(context.Response.Body, context.RequestAborted);
}
private async Task HandleReverseProxy(HttpContext context, StellaOpsRoute route)
private async Task HandleReverseProxy(HttpContext context, StellaOpsRoute route, Match? regexMatch)
{
var requestPath = context.Request.Path.Value ?? string.Empty;
var resolvedTranslatesTo = ResolveCaptureGroups(route.TranslatesTo, regexMatch);
var captureGroupsResolved = !string.Equals(resolvedTranslatesTo, route.TranslatesTo, StringComparison.Ordinal);
var remainingPath = requestPath;
if (!route.IsRegex && requestPath.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase))
if (captureGroupsResolved)
{
// Capture groups resolved: TranslatesTo already contains the full target path.
remainingPath = string.Empty;
}
else if (!route.IsRegex && requestPath.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase))
{
remainingPath = requestPath[route.Path.Length..];
}
var upstreamBase = route.TranslatesTo!.TrimEnd('/');
var upstreamBase = resolvedTranslatesTo!.TrimEnd('/');
var upstreamUri = new Uri($"{upstreamBase}{remainingPath}{context.Request.QueryString}");
var client = _httpClientFactory.CreateClient("RouteDispatch");
@@ -279,15 +287,34 @@ public sealed class RouteDispatchMiddleware
}
}
private static void PrepareMicroserviceRoute(HttpContext context, StellaOpsRoute route)
private static void PrepareMicroserviceRoute(HttpContext context, StellaOpsRoute route, Match? regexMatch)
{
var translatedPath = ResolveTranslatedMicroservicePath(context.Request.Path.Value, route);
// If regex route with capture groups, resolve $1/$2/etc. in TranslatesTo
var effectiveRoute = route;
if (regexMatch is not null && !string.IsNullOrWhiteSpace(route.TranslatesTo))
{
var resolvedTranslatesTo = ResolveCaptureGroups(route.TranslatesTo, regexMatch);
if (!string.Equals(resolvedTranslatesTo, route.TranslatesTo, StringComparison.Ordinal))
{
effectiveRoute = new StellaOpsRoute
{
Type = route.Type,
Path = route.Path,
IsRegex = route.IsRegex,
TranslatesTo = resolvedTranslatesTo,
DefaultTimeout = route.DefaultTimeout,
PreserveAuthHeaders = route.PreserveAuthHeaders
};
}
}
var translatedPath = ResolveTranslatedMicroservicePath(context.Request.Path.Value, effectiveRoute);
if (!string.Equals(translatedPath, context.Request.Path.Value, StringComparison.Ordinal))
{
context.Items[RouterHttpContextKeys.TranslatedRequestPath] = translatedPath;
}
var targetMicroservice = ResolveRouteTargetMicroservice(route);
var targetMicroservice = ResolveRouteTargetMicroservice(effectiveRoute);
if (!string.IsNullOrWhiteSpace(targetMicroservice))
{
context.Items[RouterHttpContextKeys.RouteTargetMicroservice] = targetMicroservice;
@@ -300,6 +327,22 @@ public sealed class RouteDispatchMiddleware
}
}
private static string? ResolveCaptureGroups(string? translatesTo, Match? regexMatch)
{
if (regexMatch is null || string.IsNullOrWhiteSpace(translatesTo))
{
return translatesTo;
}
var resolved = translatesTo;
for (var i = regexMatch.Groups.Count - 1; i >= 1; i--)
{
resolved = resolved.Replace($"${i}", regexMatch.Groups[i].Value);
}
return resolved;
}
private static string ResolveTranslatedMicroservicePath(string? requestPathValue, StellaOpsRoute route)
{
var requestPath = string.IsNullOrWhiteSpace(requestPathValue) ? "/" : requestPathValue!;
@@ -314,12 +357,18 @@ public sealed class RouteDispatchMiddleware
return requestPath;
}
// For regex routes, the TranslatesTo (after capture group substitution)
// already contains the full target path. Use it directly.
if (route.IsRegex)
{
return NormalizePath(targetPrefix);
}
var normalizedRoutePath = NormalizePath(route.Path);
var normalizedRequestPath = NormalizePath(requestPath);
var remainingPath = normalizedRequestPath;
if (!route.IsRegex &&
normalizedRequestPath.StartsWith(normalizedRoutePath, StringComparison.OrdinalIgnoreCase))
if (normalizedRequestPath.StartsWith(normalizedRoutePath, StringComparison.OrdinalIgnoreCase))
{
remainingPath = normalizedRequestPath[normalizedRoutePath.Length..];
if (!remainingPath.StartsWith('/'))

View File

@@ -74,6 +74,7 @@ builder.Services.Replace(ServiceDescriptor.Singleton<StellaOps.Router.Gateway.Au
builder.Services.AddSingleton<GatewayServiceStatus>();
builder.Services.AddSingleton<GatewayMetrics>();
builder.Services.AddSingleton<GatewayReadinessEvaluator>();
// Load router transport plugins
var transportPluginLoader = new RouterTransportPluginLoader(
@@ -128,7 +129,7 @@ builder.Services.AddSingleton(new IdentityHeaderPolicyOptions
IdentityEnvelopeIssuer = bootstrapOptions.Auth.IdentityEnvelopeIssuer,
IdentityEnvelopeTtl = TimeSpan.FromSeconds(Math.Max(1, bootstrapOptions.Auth.IdentityEnvelopeTtlSeconds)),
JwtPassthroughPrefixes = bootstrapOptions.Routes
.Where(r => r.PreserveAuthHeaders)
.Where(r => r.PreserveAuthHeaders && !r.IsRegex)
.Select(r => r.Path)
.ToList(),
ApprovedAuthPassthroughPrefixes = [.. bootstrapOptions.Auth.ApprovedAuthPassthroughPrefixes],

View File

@@ -27,7 +27,7 @@ public sealed class StellaOpsRouteResolver
}
}
public StellaOpsRoute? Resolve(PathString path)
public (StellaOpsRoute? Route, Match? RegexMatch) Resolve(PathString path)
{
var pathValue = path.Value ?? string.Empty;
@@ -35,9 +35,10 @@ public sealed class StellaOpsRouteResolver
{
if (pattern is not null)
{
if (pattern.IsMatch(pathValue))
var match = pattern.Match(pathValue);
if (match.Success)
{
return route;
return (route, match);
}
}
else
@@ -47,12 +48,12 @@ public sealed class StellaOpsRouteResolver
pathValue.StartsWith(route.Path, StringComparison.OrdinalIgnoreCase) &&
route.Path.EndsWith('/'))
{
return route;
return (route, null);
}
}
}
return null;
return (null, null);
}
/// <summary>

View File

@@ -0,0 +1,139 @@
using Microsoft.Extensions.Options;
using StellaOps.Gateway.WebService.Configuration;
using StellaOps.Router.Common.Abstractions;
using StellaOps.Router.Common.Enums;
namespace StellaOps.Gateway.WebService.Services;
public sealed record GatewayReadinessReport
{
public required bool IsReady { get; init; }
public required bool Started { get; init; }
public required bool TransportReady { get; init; }
public IReadOnlyList<string> RequiredMicroservices { get; init; } = [];
public IReadOnlyList<string> MissingMicroservices { get; init; } = [];
}
public sealed class GatewayReadinessEvaluator
{
private readonly IGlobalRoutingState _routingState;
private readonly IOptions<GatewayOptions> _options;
public GatewayReadinessEvaluator(IGlobalRoutingState routingState, IOptions<GatewayOptions> options)
{
_routingState = routingState;
_options = options;
}
public GatewayReadinessReport Evaluate(GatewayServiceStatus status)
{
ArgumentNullException.ThrowIfNull(status);
var requiredMicroservices = NormalizeRequiredMicroservices(_options.Value.Health.RequiredMicroservices);
if (!status.IsStarted || !status.IsReady)
{
return new GatewayReadinessReport
{
IsReady = false,
Started = status.IsStarted,
TransportReady = status.IsReady,
RequiredMicroservices = requiredMicroservices,
MissingMicroservices = requiredMicroservices
};
}
if (requiredMicroservices.Length == 0)
{
return new GatewayReadinessReport
{
IsReady = true,
Started = true,
TransportReady = true,
RequiredMicroservices = requiredMicroservices,
MissingMicroservices = []
};
}
var registeredServices = _routingState.GetAllConnections()
.Where(connection => connection.Status is InstanceHealthStatus.Healthy or InstanceHealthStatus.Degraded)
.Select(connection => NormalizeServiceKey(connection.Instance.ServiceName))
.Where(serviceName => !string.IsNullOrWhiteSpace(serviceName))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var missing = requiredMicroservices
.Where(required => !registeredServices.Any(registered => IsServiceMatch(registered, required)))
.ToArray();
return new GatewayReadinessReport
{
IsReady = missing.Length == 0,
Started = true,
TransportReady = true,
RequiredMicroservices = requiredMicroservices,
MissingMicroservices = missing
};
}
private static string[] NormalizeRequiredMicroservices(IEnumerable<string>? services)
{
return (services ?? [])
.Select(NormalizeServiceKey)
.Where(serviceName => !string.IsNullOrWhiteSpace(serviceName))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray()!;
}
private static bool IsServiceMatch(string? registeredServiceName, string requiredServiceName)
{
var normalizedRegistered = NormalizeServiceKey(registeredServiceName);
if (string.IsNullOrWhiteSpace(normalizedRegistered))
{
return false;
}
return string.Equals(normalizedRegistered, requiredServiceName, StringComparison.OrdinalIgnoreCase);
}
private static string? NormalizeServiceKey(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var normalized = value.Trim().ToLowerInvariant();
var portSeparator = normalized.IndexOf(':');
if (portSeparator >= 0)
{
normalized = normalized[..portSeparator];
}
const string localDomain = ".stella-ops.local";
if (normalized.EndsWith(localDomain, StringComparison.Ordinal))
{
normalized = normalized[..^localDomain.Length];
}
normalized = StripSuffix(normalized, "-web");
normalized = StripSuffix(normalized, "-api");
normalized = StripSuffix(normalized, "-service");
normalized = StripSuffix(normalized, "-gateway");
return string.IsNullOrWhiteSpace(normalized)
? null
: normalized;
}
private static string StripSuffix(string value, string suffix)
{
return value.EndsWith(suffix, StringComparison.Ordinal)
? value[..^suffix.Length]
: value;
}
}

View File

@@ -71,125 +71,91 @@
"Health": {
"StaleThreshold": "30s",
"DegradedThreshold": "20s",
"CheckInterval": "5s"
"CheckInterval": "5s",
"RequiredMicroservices": []
},
"Routes": [
{ "Type": "ReverseProxy", "Path": "/api/v1/release-orchestrator", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/release-orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/v1/approvals", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/approvals" },
{ "Type": "ReverseProxy", "Path": "/api/v1/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex" },
{ "Type": "ReverseProxy", "Path": "/api/v1/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local/api/v1/vexlens" },
{ "Type": "ReverseProxy", "Path": "/api/v1/notify", "TranslatesTo": "http://notify.stella-ops.local/api/v1/notify" },
{ "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://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": "/policy/simulations", "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/simulations", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/policy/shadow", "TranslatesTo": "http://policy-gateway.stella-ops.local/policy/shadow", "PreserveAuthHeaders": true },
{ "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 },
{ "Type": "ReverseProxy", "Path": "/api/analytics", "TranslatesTo": "http://platform.stella-ops.local/api/analytics" },
{ "Type": "ReverseProxy", "Path": "/api/release-orchestrator", "TranslatesTo": "http://jobengine.stella-ops.local/api/release-orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/releases", "TranslatesTo": "http://jobengine.stella-ops.local/api/releases" },
{ "Type": "ReverseProxy", "Path": "/api/approvals", "TranslatesTo": "http://jobengine.stella-ops.local/api/approvals" },
{ "Type": "ReverseProxy", "Path": "/api/v1/platform", "TranslatesTo": "http://platform.stella-ops.local/api/v1/platform" },
{ "Type": "ReverseProxy", "Path": "/api/v1/scanner", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/scanner" },
{ "Type": "ReverseProxy", "Path": "/api/v1/jobengine", "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/jobengine", "PreserveAuthHeaders": true },
{ "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/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/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 },
{ "Type": "ReverseProxy", "Path": "/api/v1/trust", "TranslatesTo": "http://authority.stella-ops.local/api/v1/trust", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/v1/evidence", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/evidence" },
{ "Type": "ReverseProxy", "Path": "/api/v1/proofs", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/proofs" },
{ "Type": "ReverseProxy", "Path": "/api/v1/timeline", "TranslatesTo": "http://timelineindexer.stella-ops.local/api/v1/timeline" },
{ "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/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" },
{ "Type": "ReverseProxy", "Path": "/api/v1/ops/binaryindex", "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/ops/binaryindex" },
{ "Type": "ReverseProxy", "Path": "/api/v1/verdicts", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/verdicts" },
{ "Type": "ReverseProxy", "Path": "/api/v1/lineage", "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/lineage" },
{ "Type": "ReverseProxy", "Path": "/api/v1/export", "TranslatesTo": "http://exportcenter.stella-ops.local/api/v1/export" },
{ "Type": "ReverseProxy", "Path": "/v1/audit-bundles", "TranslatesTo": "http://exportcenter.stella-ops.local/v1/audit-bundles" },
{ "Type": "ReverseProxy", "Path": "/api/v1/triage", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/triage" },
{ "Type": "ReverseProxy", "Path": "/api/v1/governance", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance" },
{ "Type": "ReverseProxy", "Path": "/api/v1/determinization", "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization" },
{ "Type": "ReverseProxy", "Path": "/api/v1/opsmemory", "TranslatesTo": "http://opsmemory.stella-ops.local/api/v1/opsmemory" },
{ "Type": "ReverseProxy", "Path": "/api/v1/secrets", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/secrets" },
{ "Type": "ReverseProxy", "Path": "/api/v1/sources", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/sources" },
{ "Type": "ReverseProxy", "Path": "/api/v1/workflows", "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/workflows" },
{ "Type": "ReverseProxy", "Path": "/api/v1/witnesses", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/witnesses" },
{ "Type": "ReverseProxy", "Path": "/api/gate", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/gate", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/risk-budget", "TranslatesTo": "http://policy-engine.stella-ops.local/api/risk-budget" },
{ "Type": "ReverseProxy", "Path": "/api/fix-verification", "TranslatesTo": "http://scanner.stella-ops.local/api/fix-verification" },
{ "Type": "ReverseProxy", "Path": "/api/compare", "TranslatesTo": "http://sbomservice.stella-ops.local/api/compare" },
{ "Type": "ReverseProxy", "Path": "/api/change-traces", "TranslatesTo": "http://sbomservice.stella-ops.local/api/change-traces" },
{ "Type": "ReverseProxy", "Path": "/api/exceptions", "TranslatesTo": "http://policy-gateway.stella-ops.local/api/exceptions", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/verdicts", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/verdicts" },
{ "Type": "ReverseProxy", "Path": "/api/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local/api/orchestrator" },
{ "Type": "ReverseProxy", "Path": "/api/v1/gateway/rate-limits", "TranslatesTo": "http://platform.stella-ops.local/api/v1/gateway/rate-limits", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/sbomservice", "TranslatesTo": "http://sbomservice.stella-ops.local/api/sbomservice" },
{ "Type": "ReverseProxy", "Path": "/api/vuln-explorer", "TranslatesTo": "http://vulnexplorer.stella-ops.local/api/vuln-explorer" },
{ "Type": "ReverseProxy", "Path": "/api/vex", "TranslatesTo": "http://vexhub.stella-ops.local/api/vex" },
{ "Type": "ReverseProxy", "Path": "/api/admin/plans", "TranslatesTo": "http://registry-token.stella-ops.local/api/admin/plans", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/api/admin", "TranslatesTo": "http://platform.stella-ops.local/api/admin" },
{ "Type": "ReverseProxy", "Path": "/api", "TranslatesTo": "http://platform.stella-ops.local/api" },
{ "Type": "Microservice", "Path": "^/api/v1/vulnerabilities(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/vulnerabilities$1" },
{ "Type": "Microservice", "Path": "^/api/v1/watchlist(.*)", "IsRegex": true, "TranslatesTo": "http://attestor.stella-ops.local/api/v1/watchlist$1" },
{ "Type": "Microservice", "Path": "^/api/v1/triage(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/triage$1" },
{ "Type": "Microservice", "Path": "^/api/v1/secrets(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/secrets$1" },
{ "Type": "Microservice", "Path": "^/api/v1/sources(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/sources$1" },
{ "Type": "Microservice", "Path": "^/api/v1/witnesses(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/witnesses$1" },
{ "Type": "Microservice", "Path": "^/api/v1/trust(.*)", "IsRegex": true, "TranslatesTo": "http://authority.stella-ops.local/api/v1/trust$1" },
{ "Type": "Microservice", "Path": "^/api/v1/evidence(.*)", "IsRegex": true, "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/evidence$1" },
{ "Type": "Microservice", "Path": "^/api/v1/proofs(.*)", "IsRegex": true, "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/proofs$1" },
{ "Type": "Microservice", "Path": "^/api/v1/verdicts(.*)", "IsRegex": true, "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/verdicts$1" },
{ "Type": "Microservice", "Path": "^/api/v1/release-orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/release-orchestrator$1" },
{ "Type": "Microservice", "Path": "^/api/v1/approvals(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/approvals$1" },
{ "Type": "Microservice", "Path": "^/api/v1/attestations(.*)", "IsRegex": true, "TranslatesTo": "http://attestor.stella-ops.local/api/v1/attestations$1" },
{ "Type": "Microservice", "Path": "^/api/v1/sbom(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/sbom$1" },
{ "Type": "Microservice", "Path": "^/api/v1/lineage(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/v1/lineage$1" },
{ "Type": "Microservice", "Path": "^/api/v1/resolve(.*)", "IsRegex": true, "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/resolve$1" },
{ "Type": "Microservice", "Path": "^/api/v1/ops/binaryindex(.*)", "IsRegex": true, "TranslatesTo": "http://binaryindex.stella-ops.local/api/v1/ops/binaryindex$1" },
{ "Type": "Microservice", "Path": "^/api/v1/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/policy$1" },
{ "Type": "Microservice", "Path": "^/api/v1/governance(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/v1/governance$1" },
{ "Type": "Microservice", "Path": "^/api/v1/determinization(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/v1/determinization$1" },
{ "Type": "Microservice", "Path": "^/api/v1/workflows(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/workflows$1" },
{ "Type": "Microservice", "Path": "^/api/v1/authority/quotas(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/authority/quotas$1" },
{ "Type": "Microservice", "Path": "^/api/v1/release-control(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/release-control$1" },
{ "Type": "Microservice", "Path": "^/api/v1/gateway/rate-limits(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/gateway/rate-limits$1" },
{ "Type": "Microservice", "Path": "^/api/v1/reachability(.*)", "IsRegex": true, "TranslatesTo": "http://reachgraph.stella-ops.local/api/v1/reachability$1" },
{ "Type": "Microservice", "Path": "^/api/v1/timeline(.*)", "IsRegex": true, "TranslatesTo": "http://timelineindexer.stella-ops.local/api/v1/timeline$1" },
{ "Type": "Microservice", "Path": "^/api/v1/audit(.*)", "IsRegex": true, "TranslatesTo": "http://timeline.stella-ops.local/api/v1/audit$1" },
{ "Type": "Microservice", "Path": "^/api/v1/export(.*)", "IsRegex": true, "TranslatesTo": "http://exportcenter.stella-ops.local/api/v1/export$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory-sources(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/advisory-sources$1" },
{ "Type": "Microservice", "Path": "^/api/v1/notifier/delivery(.*)", "IsRegex": true, "TranslatesTo": "http://notifier.stella-ops.local/api/v2/notify/deliveries$1" },
{ "Type": "Microservice", "Path": "^/api/v1/search(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" },
{ "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" },
{ "Type": "Microservice", "Path": "^/api/v1/vex(.*)", "IsRegex": true, "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex$1" },
{ "Type": "Microservice", "Path": "^/api/v1/doctor/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://doctor-scheduler.stella-ops.local/api/v1/doctor/scheduler$1" },
{ "Type": "Microservice", "Path": "^/api/v2/context(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/context$1" },
{ "Type": "Microservice", "Path": "^/api/v2/releases(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/releases$1" },
{ "Type": "Microservice", "Path": "^/api/v2/security(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/security$1" },
{ "Type": "Microservice", "Path": "^/api/v2/topology(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/topology$1" },
{ "Type": "Microservice", "Path": "^/api/v2/integrations(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/integrations$1" },
{ "Type": "Microservice", "Path": "^/api/v1/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v1/$1$2" },
{ "Type": "Microservice", "Path": "^/api/v2/([^/]+)(.*)", "IsRegex": true, "TranslatesTo": "http://$1.stella-ops.local/api/v2/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(cvss|gate|exceptions|policy)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(risk|risk-budget)(.*)", "IsRegex": true, "TranslatesTo": "http://policy-engine.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(release-orchestrator|releases|approvals)(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/(compare|change-traces|sbomservice)(.*)", "IsRegex": true, "TranslatesTo": "http://sbomservice.stella-ops.local/api/$1$2" },
{ "Type": "Microservice", "Path": "^/api/fix-verification(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/fix-verification$1" },
{ "Type": "Microservice", "Path": "^/api/verdicts(.*)", "IsRegex": true, "TranslatesTo": "http://evidencelocker.stella-ops.local/api/verdicts$1" },
{ "Type": "Microservice", "Path": "^/api/vuln-explorer(.*)", "IsRegex": true, "TranslatesTo": "http://vulnexplorer.stella-ops.local/api/vuln-explorer$1" },
{ "Type": "Microservice", "Path": "^/api/vex(.*)", "IsRegex": true, "TranslatesTo": "http://vexhub.stella-ops.local/api/vex$1" },
{ "Type": "Microservice", "Path": "^/api/admin/plans(.*)", "IsRegex": true, "TranslatesTo": "http://registry-token.stella-ops.local/api/admin/plans$1" },
{ "Type": "Microservice", "Path": "^/api/admin(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/admin$1" },
{ "Type": "Microservice", "Path": "^/api/analytics(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/analytics$1" },
{ "Type": "Microservice", "Path": "^/api/orchestrator(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/orchestrator$1" },
{ "Type": "Microservice", "Path": "^/api/jobengine(.*)", "IsRegex": true, "TranslatesTo": "http://orchestrator.stella-ops.local/api/jobengine$1" },
{ "Type": "Microservice", "Path": "^/api/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://scheduler.stella-ops.local/api/scheduler$1" },
{ "Type": "Microservice", "Path": "^/api/doctor(.*)", "IsRegex": true, "TranslatesTo": "http://doctor.stella-ops.local/api/doctor$1" },
{ "Type": "Microservice", "Path": "^/policy(.*)", "IsRegex": true, "TranslatesTo": "http://policy-gateway.stella-ops.local/policy$1" },
{ "Type": "Microservice", "Path": "^/v1/evidence-packs(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs$1" },
{ "Type": "Microservice", "Path": "^/v1/runs(.*)", "IsRegex": true, "TranslatesTo": "http://jobengine.stella-ops.local/v1/runs$1" },
{ "Type": "Microservice", "Path": "^/v1/advisory-ai(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai$1" },
{ "Type": "Microservice", "Path": "^/v1/audit-bundles(.*)", "IsRegex": true, "TranslatesTo": "http://exportcenter.stella-ops.local/v1/audit-bundles$1" },
{ "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "http://authority.stella-ops.local/connect" },
{ "Type": "ReverseProxy", "Path": "/.well-known", "TranslatesTo": "http://authority.stella-ops.local/.well-known" },
{ "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "http://authority.stella-ops.local/jwks" },
{ "Type": "ReverseProxy", "Path": "/authority/console", "TranslatesTo": "http://authority.stella-ops.local/console" },
{ "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "http://authority.stella-ops.local/authority" },
{ "Type": "ReverseProxy", "Path": "/console", "TranslatesTo": "http://authority.stella-ops.local/console" },
{ "Type": "ReverseProxy", "Path": "/rekor", "TranslatesTo": "http://rekor.stella-ops.local:3322" },
{ "Type": "ReverseProxy", "Path": "/platform/envsettings.json", "TranslatesTo": "http://platform.stella-ops.local/platform/envsettings.json" },
{ "Type": "ReverseProxy", "Path": "/envsettings.json", "TranslatesTo": "http://platform.stella-ops.local/platform/envsettings.json" },
{ "Type": "ReverseProxy", "Path": "/api/v1/setup", "TranslatesTo": "http://platform.stella-ops.local/api/v1/setup" },
{ "Type": "ReverseProxy", "Path": "/platform", "TranslatesTo": "http://platform.stella-ops.local/platform" },
{ "Type": "ReverseProxy", "Path": "/connect", "TranslatesTo": "http://authority.stella-ops.local", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/.well-known", "TranslatesTo": "http://authority.stella-ops.local/.well-known", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "http://authority.stella-ops.local/jwks", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "http://authority.stella-ops.local/authority", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/console", "TranslatesTo": "http://authority.stella-ops.local/console", "PreserveAuthHeaders": true },
{ "Type": "ReverseProxy", "Path": "/scanner", "TranslatesTo": "http://scanner.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/policyGateway", "TranslatesTo": "http://policy-gateway.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/policyEngine", "TranslatesTo": "http://policy-engine.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/concelier", "TranslatesTo": "http://concelier.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/attestor", "TranslatesTo": "http://attestor.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/notify", "TranslatesTo": "http://notify.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/notifier", "TranslatesTo": "http://notifier.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/scheduler", "TranslatesTo": "http://scheduler.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/signals", "TranslatesTo": "http://signals.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/excititor", "TranslatesTo": "http://excititor.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/findingsLedger", "TranslatesTo": "http://findings.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/vexhub", "TranslatesTo": "http://vexhub.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/vexlens", "TranslatesTo": "http://vexlens.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/orchestrator", "TranslatesTo": "http://orchestrator.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/taskrunner", "TranslatesTo": "http://taskrunner.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/cartographer", "TranslatesTo": "http://cartographer.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/reachgraph", "TranslatesTo": "http://reachgraph.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/doctor", "TranslatesTo": "http://doctor.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/integrations", "TranslatesTo": "http://integrations.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/replay", "TranslatesTo": "http://replay.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/exportcenter", "TranslatesTo": "http://exportcenter.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/evidencelocker", "TranslatesTo": "http://evidencelocker.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/signer", "TranslatesTo": "http://signer.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/binaryindex", "TranslatesTo": "http://binaryindex.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/riskengine", "TranslatesTo": "http://riskengine.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/vulnexplorer", "TranslatesTo": "http://vulnexplorer.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/sbomservice", "TranslatesTo": "http://sbomservice.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/advisoryai", "TranslatesTo": "http://advisoryai.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/unknowns", "TranslatesTo": "http://unknowns.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/timelineindexer", "TranslatesTo": "http://timelineindexer.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/opsmemory", "TranslatesTo": "http://opsmemory.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/issuerdirectory", "TranslatesTo": "http://issuerdirectory.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/symbols", "TranslatesTo": "http://symbols.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/packsregistry", "TranslatesTo": "http://packsregistry.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/registryTokenservice", "TranslatesTo": "http://registry-token.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/airgapController", "TranslatesTo": "http://airgap-controller.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/airgapTime", "TranslatesTo": "http://airgap-time.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/smremote", "TranslatesTo": "http://smremote.stella-ops.local" },
{ "Type": "ReverseProxy", "Path": "/api", "TranslatesTo": "http://platform.stella-ops.local/api" },
{ "Type": "StaticFiles", "Path": "/", "TranslatesTo": "/app/wwwroot", "Headers": { "x-spa-fallback": "true" } },
{ "Type": "NotFoundPage", "Path": "/_error/404", "TranslatesTo": "/app/wwwroot/index.html" },
{ "Type": "ServerErrorPage", "Path": "/_error/500", "TranslatesTo": "/app/wwwroot/index.html" }